Use screaming goat voice events from the saved flag

Select the ambient, hurt and death event names through the same goat state used by vanilla. Add a dedicated-server packet regression with adult, baby and silent controls.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
This commit is contained in:
TheMrZZandCodex gpt-6.1-sol committed 2026-11-02 20:36:38 +01:00
1 parent 7cc36d34c3
commit 6d8fae1d71
5 files changed
+299 -4

No files matched your search

+7
View File
@@ -1920,8 +1920,15 @@ pub fn speed_bonus(m: &Mob) -> f64 {
}
/// The ambient, hurt and death sounds of species that pick them by state
/// (`getHurtSound` / `getDeathSound` overrides); the type's otherwise.
/// `Goat.getAmbientSound`, `Goat.getHurtSound`, and `Goat.getDeathSound`
/// select the screaming voices from the goat's saved flag.
pub fn voice_sounds(m: &Mob) -> [&'static str; 3] {
match m.species() {
Species::Goat if m.extra.screaming => [
"entity.goat.screaming.ambient",
"entity.goat.screaming.hurt",
"entity.goat.screaming.death",
],
Species::Nautilus | Species::ZombieNautilus => super::nautilus::sounds(m),
Species::SulfurCube => super::sulfur_cube::sounds(m),
Species::Armadillo => super::armadillo::sounds(m),
@@ -0,0 +1,71 @@
package dev.horizonlab.parity;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientboundAddEntityPacket;
import net.minecraft.network.protocol.game.ClientboundDisguisedChatPacket;
import net.minecraft.network.protocol.game.ClientboundSoundPacket;
import net.minecraft.network.protocol.game.ClientboundSoundEntityPacket;
import net.minecraft.network.protocol.game.ClientboundSystemChatPacket;
import net.minecraft.world.phys.Vec3;
/** The sound packets of the frozen goat scene, with server-chat phase barriers. */
final class GoatVoiceTranscript {
private final Map<Integer, Vec3> goats = new HashMap<>();
private final List<String> lines = new ArrayList<>();
private String phase = "setup";
private String pendingAck;
private boolean tracked;
private boolean done;
void accept(Packet<?> packet) {
if (packet instanceof ClientboundAddEntityPacket p
&& BuiltInRegistries.ENTITY_TYPE.getKey(p.getType()).toString().equals("minecraft:goat")) {
// Keep spawn positions through removal: a death sound can name the removed entity.
goats.put(p.getId(), new Vec3(p.getX(), p.getY(), p.getZ()));
}
String text = null;
if (packet instanceof ClientboundSystemChatPacket p) text = p.content().getString();
else if (packet instanceof ClientboundDisguisedChatPacket p) text = p.message().getString();
if (text != null) {
for (String name : List.of("ambient", "hurt", "death")) {
if (text.endsWith("goat_voice_" + name)) {
phase = name;
pendingAck = "goat voice phase " + name;
}
}
if (text.endsWith("goat_voice_done")) done = true;
}
if (packet instanceof ClientboundSoundPacket p) {
sound(p.getSound().value().location().toString(), p.getSource().ordinal(),
p.getVolume(), p.getPitch(), new Vec3(p.getX(), p.getY(), p.getZ()));
} else if (packet instanceof ClientboundSoundEntityPacket p) {
sound(p.getSound().value().location().toString(), p.getSource().ordinal(),
p.getVolume(), p.getPitch(), goats.get(p.getId()));
}
}
private void sound(String name, int category, float volume, float pitch, Vec3 at) {
if (at != null && !phase.equals("setup") && name.startsWith("minecraft:entity.goat.")) {
lines.add(phase + " " + name + " " + category + " " + volume + " " + pitch
+ " " + at.x + " " + at.z);
}
}
String acknowledgement() {
if (!tracked && goats.size() >= 18) {
tracked = true;
return "goat voices tracked";
}
String ack = pendingAck;
pendingAck = null;
return ack;
}
boolean done() { return done; }
List<String> lines() { return lines; }
}
@@ -116,6 +116,7 @@ public final class VanillaProtocolClient {
private String state;
/** With a fifth argument {@code entities}: the entity transcript mode (EntityTranscript). */
private EntityTranscript entities;
private GoatVoiceTranscript goatVoices;
public static void main(String[] args) throws Exception {
ParityData.init();
@@ -125,6 +126,10 @@ public final class VanillaProtocolClient {
Path output = Path.of(args[3]);
VanillaProtocolClient client = new VanillaProtocolClient();
if (args.length > 4 && args[4].equals("entities")) client.entities = new EntityTranscript();
if (args.length > 4 && args[4].equals("goat-voices")) {
client.entities = new EntityTranscript();
client.goatVoices = new GoatVoiceTranscript();
}
try {
client.status(host, port);
client.join(host, port, name);
@@ -452,7 +457,7 @@ public final class VanillaProtocolClient {
boolean loaded = false;
PositionMoveRotation position = new PositionMoveRotation(Vec3.ZERO, Vec3.ZERO, 0, 0);
long lastChunk = 0;
long deadline = System.currentTimeMillis() + 150_000;
long deadline = System.currentTimeMillis() + (goatVoices == null ? 150_000 : 600_000);
int step = 0;
long stepAt = 0;
// The death the console deals and the respawn after it (PlayerList.respawn): from the
@@ -510,6 +515,7 @@ public final class VanillaProtocolClient {
Packet<?> packet = r.packet;
counts.merge(r.type, 1, Integer::sum);
if (entities != null && !entities.acceptSelf(packet) && step >= ENTITIES) entities.accept(packet);
if (goatVoices != null) goatVoices.accept(packet);
if (r.type.equals("minecraft:chunk_batch_start")) ordered = false;
if (respawning && !respawned) {
String line = respawnLine(r, playerId);
@@ -671,14 +677,20 @@ public final class VanillaProtocolClient {
// Entity mode: the harness's console summons the scene once it reads this
// line; everything tracked from then on is recorded until the client leaves.
record("play-chunks", String.valueOf(chunks.size()));
send(new ServerboundChatPacket("summon the entities", Instant.now(), 0L, Optional.empty(),
send(new ServerboundChatPacket(goatVoices == null ? "summon the entities" : "summon the goats", Instant.now(), 0L, Optional.empty(),
new LastSeenMessages.Update(0, new BitSet(), (byte) 0)));
step = ENTITIES;
stepAt = now;
} else if (step == ENTITIES && now - stepAt > ENTITY_WAIT) {
for (String line : entities.lines()) record("play-entities", line);
} else if (step == ENTITIES && (goatVoices == null ? now - stepAt > ENTITY_WAIT : goatVoices.done())) {
if (goatVoices == null) for (String line : entities.lines()) record("play-entities", line);
else for (String line : goatVoices.lines()) record("play-goat-voice", line);
return;
}
if (goatVoices != null) {
String ack = goatVoices.acknowledgement();
if (ack != null) send(new ServerboundChatPacket(ack, Instant.now(), 0L, Optional.empty(),
new LastSeenMessages.Update(0, new BitSet(), (byte) 0)));
}
// Once the chunks stop coming: chat, then /list, then leave.
if (entities == null && loaded && lastChunk > 0 && now - lastChunk > 3000 && step == 0) {
record("play-chunks", chunks.size() + " " + chunks);
+3
View File
@@ -75,6 +75,9 @@ Goat.getBreedOffspring port crates/horizon-client/src/game/creatures.rs::breed t
Goat.customServerAiStep inline crates/horizon-client/src/mobs/ai.rs::generic goals stand in for the brain: Float, Panic 2.0, Breed, Tempt, FollowParent, stroll and look (ai.rs generic) and the ram goal (goat.rs); the ram cooldown counts down in goat.rs::tick
Goat.setYHeadRot port crates/horizon-client/src/mobs/control.rs::look_control_tick the head is clamped to `getMaxHeadYRot` (15 degrees) from the body after every look update, walking or standing
Goat.playEatingSound port crates/horizon-client/src/mobs/ai.rs::eating_sound screaming or normal eat sound at 0.8 to 1.2 pitch
Goat.getAmbientSound port crates/horizon-client/src/mobs/ai.rs::voice_sounds screaming ambient event selected by IsScreamingGoat
Goat.getHurtSound port crates/horizon-client/src/mobs/ai.rs::voice_sounds screaming hurt event selected by IsScreamingGoat
Goat.getDeathSound port crates/horizon-client/src/mobs/ai.rs::voice_sounds screaming death event selected by IsScreamingGoat
Goat.finalizeSpawn port crates/horizon-client/src/mobs/finalize.rs::Ctx::finalize gap: player: live spawns use spawning.rs::finalize, which has no goat arm: no screaming goats (2 percent), no one-horned goats (10 percent), and no starting ram cooldown, so a fresh goat can ram at once; Ctx::finalize only runs for structure mobs and the probe
Goat.getDefaultDimensions port crates/horizon-client/src/mobs/living.rs::Mob::dims baby box (0.45 x 0.65, eye 0.59375) from the species table; no long jump pose scale since there is no long jump
Goat.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_to_nbt IsScreamingGoat, HasLeftHorn, HasRightHorn
1 Fox.defineSynchedData port crates/horizon-client/src/game/lan.rs::mob_meta gap: minor: the type and the flags byte are synced; the two trusted-entity slots are not
75 Goat.customServerAiStep inline crates/horizon-client/src/mobs/ai.rs::generic goals stand in for the brain: Float, Panic 2.0, Breed, Tempt, FollowParent, stroll and look (ai.rs generic) and the ram goal (goat.rs); the ram cooldown counts down in goat.rs::tick
76 Goat.setYHeadRot port crates/horizon-client/src/mobs/control.rs::look_control_tick the head is clamped to `getMaxHeadYRot` (15 degrees) from the body after every look update, walking or standing
77 Goat.playEatingSound port crates/horizon-client/src/mobs/ai.rs::eating_sound screaming or normal eat sound at 0.8 to 1.2 pitch
78 Goat.getAmbientSound port crates/horizon-client/src/mobs/ai.rs::voice_sounds screaming ambient event selected by IsScreamingGoat
79 Goat.getHurtSound port crates/horizon-client/src/mobs/ai.rs::voice_sounds screaming hurt event selected by IsScreamingGoat
80 Goat.getDeathSound port crates/horizon-client/src/mobs/ai.rs::voice_sounds screaming death event selected by IsScreamingGoat
81 Goat.finalizeSpawn port crates/horizon-client/src/mobs/finalize.rs::Ctx::finalize gap: player: live spawns use spawning.rs::finalize, which has no goat arm: no screaming goats (2 percent), no one-horned goats (10 percent), and no starting ram cooldown, so a fresh goat can ram at once; Ctx::finalize only runs for structure mobs and the probe
82 Goat.getDefaultDimensions port crates/horizon-client/src/mobs/living.rs::Mob::dims baby box (0.45 x 0.65, eye 0.59375) from the species table; no long jump pose scale since there is no long jump
83 Goat.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_to_nbt IsScreamingGoat, HasLeftHorn, HasRightHorn
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""Goat voice event names and metadata on real Java/Horizon dedicated servers.
Silent ambient suppression is a separate gap: record those two controls, but
gate only their hurt/death phases. Death follows 21 actual cooldown ticks.
"""
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import time
try:
from . import lab, test_rust_parity as base
from .test_dedicated_server_parity import Server, free_port, game_time
from .test_server_world_gen_parity import escape
except ImportError:
import lab
import test_rust_parity as base
from test_dedicated_server_parity import Server, free_port, game_time
from test_server_world_gen_parity import escape
PLAYER = 'Tester'
CASES = [('adult', False, False, False), ('screaming_adult', True, False, False),
('baby', False, True, False), ('screaming_baby', True, True, False),
('silent', False, False, True), ('screaming_silent', True, False, True)]
MESSAGE = re.compile(r'\[Server thread/INFO\]: (?:System chat: )?(.*)$')
def run_side(label, command, client_command, out):
directory = out / label
if directory.exists():
shutil.rmtree(directory)
directory.mkdir(parents=True)
port = free_port()
props = {'server-ip': '127.0.0.1', 'server-port': str(port), 'online-mode': 'false', 'white-list': 'false',
'level-type': 'minecraft:flat', 'generate-structures': 'false', 'level-seed': '42',
'gamemode': 'creative', 'force-gamemode': 'true', 'allow-flight': 'true',
'view-distance': '2', 'simulation-distance': '2', 'spawn-protection': '0',
'pause-when-empty-seconds': '-1', 'max-tick-time': '-1'}
(directory / 'server.properties').write_text(
''.join(f'{k}={escape(v)}\n' for k, v in props.items()), encoding='iso-8859-1')
(directory / 'eula.txt').write_text('eula=true\n')
transcript = out / f'{label}-packets.txt'
server = Server(command, directory, label)
client = None
serial = 0
def reply(text):
nonlocal serial
serial += 1
marker = f'goat_voice_barrier_{serial}'
start = len(server.lines)
server.console(text)
server.console(f'say {marker}')
server.wait_for(re.escape(f'[Server] {marker}') + '$', timeout=180)
return [m.group(1) for line in server.lines[start:] if (m := MESSAGE.search(line))
and not m.group(1).endswith(f'[Server] {marker}')]
def advance(ticks):
start = game_time(server)
reply(f'tick step {ticks}')
deadline = time.monotonic() + 180
while game_time(server) < start + ticks:
assert time.monotonic() < deadline, f'{label}: tick step stalled'
assert game_time(server) == start + ticks
def phase(name):
server.console(f'say goat_voice_{name}')
server.wait_for(rf'<{PLAYER}> goat voice phase {name}', timeout=120)
flags = []
try:
server.wait_for(r'Done \(')
with (out / f'{label}-client.log').open('w') as log:
client = subprocess.Popen([*map(str, client_command), '127.0.0.1', str(port), PLAYER,
str(transcript), 'goat-voices'], cwd=lab.ROOT,
stdout=log, stderr=subprocess.STDOUT)
server.wait_for(rf'<{PLAYER}> summon the goats', timeout=180)
reply('tick freeze')
reply('gamerule minecraft:spawn_mobs false')
reply('kill @e[type=!minecraft:player]')
reply('forceload add 0 0 15 15')
deadline = time.monotonic() + 180
while not any('Test passed' in line for line in reply('execute if loaded 0 220 0')):
assert time.monotonic() < deadline, f'{label}: scene chunk did not load'
reply('fill 0 219 0 15 219 15 minecraft:stone')
reply(f'tp {PLAYER} 8.5 220 11.5')
for i, (_, screaming, baby, silent) in enumerate(CASES):
for j in range(3):
tag = f'goat_voice_{i}_{j}'
nbt = ('{NoAI:1b,NoGravity:1b,PersistenceRequired:1b,'
f'Age:{-24000 if baby else 0},Silent:{int(silent)}b,'
f'IsScreamingGoat:{int(screaming)}b,Tags:["goat_voice_{i}","{tag}"]}}')
reply(f'summon minecraft:goat {2.5 + i * 2} 220 {2.5 + j * 2} {nbt}')
observed = reply('execute if entity @e[tag=' + tag + ',nbt={'
f'IsScreamingGoat:{int(screaming)}b' + '}]')
assert any('Test passed' in line for line in observed), (tag, observed)
flags.append(tag)
advance(1)
server.wait_for(rf'<{PLAYER}> goat voices tracked', timeout=120)
reply('tick rate 200')
phase('ambient')
advance(2400)
phase('hurt')
for i in range(len(CASES)):
reply(f'damage @e[tag=goat_voice_{i},limit=1] 1 minecraft:generic')
# A one-tick interval leaves hurt immunity active and invalidates death controls.
advance(21)
phase('death')
for i in range(len(CASES)):
reply(f'damage @e[tag=goat_voice_{i},limit=1] 100 minecraft:generic')
advance(1)
server.console('say goat_voice_done')
client.wait(timeout=180)
finally:
if client is not None and client.poll() is None:
client.kill()
client.wait()
server.stop()
lines = transcript.read_text().splitlines()
assert not any(line.startswith('error ') for line in lines), lines[-10:]
sounds = []
for line in lines:
if not line.startswith('play-goat-voice '):
continue
phase_name, name, category, volume, pitch, x, z = line.split()[1:]
index = round((float(x) - 2.5) / 2)
assert 0 <= index < len(CASES) and abs(float(x) - (2.5 + 2 * index)) < 0.2, line
sounds.append({'case': CASES[index][0], 'phase': phase_name, 'name': name,
'category': int(category), 'volume': float(volume), 'pitch': float(pitch)})
return {'flags': flags, 'sounds': sounds}
def checks(result):
failures, excluded = [], {}
for name, screaming, baby, silent in CASES:
for phase in ('ambient', 'hurt', 'death'):
sounds = [s for s in result['sounds'] if s['case'] == name and s['phase'] == phase
and s['name'].endswith(('.ambient',) if phase == 'ambient' else ('.hurt', '.death'))]
if silent and phase == 'ambient':
excluded[name] = sounds
continue
if silent:
valid = not sounds
else:
expected = 'minecraft:entity.goat.' + ('screaming.' if screaming else '') + phase
lo, hi = (1.3, 1.7) if baby else (0.8, 1.2)
valid = bool(sounds) and all(s['name'] == expected and s['category'] == 6
and s['volume'] == 1 and lo - 1e-6 <= s['pitch'] <= hi + 1e-6
for s in sounds)
if not valid:
failures.append({'case': name, 'phase': phase, 'sounds': sounds})
return {'checks': len(result['flags']) + 16, 'failures': failures, 'excluded_silent_ambient': excluded}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--binary', type=Path, help='prebuilt Horizon game (otherwise build fast)')
parser.add_argument('--out', type=Path, default=lab.LOCAL / 'goat-voices-parity')
args = parser.parse_args()
lock, jars, libraries = base.verify_artifacts()
if args.binary:
binary = args.binary.resolve(strict=True)
else:
subprocess.run(['cargo', 'build', '--profile', 'fast', '-p', 'horizon-client',
'--bin', 'horizon-client'], cwd=lab.ROOT, check=True)
metadata = json.loads(subprocess.check_output(
['cargo', 'metadata', '--no-deps', '--format-version', '1'], cwd=lab.ROOT, text=True))
binary = Path(metadata['target_directory']) / 'fast' / (
'horizon-client.exe' if os.name == 'nt' else 'horizon-client')
out = args.out.resolve()
out.mkdir(parents=True, exist_ok=True)
classes = out / 'classes'
classes.mkdir(exist_ok=True)
source = lab.ROOT / 'mods/horizon/src/test/java'
cp = os.pathsep.join(map(str, [jars['server'], *libraries]))
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none',
'-sourcepath', source, '-cp', cp, '-d', classes,
source / 'dev/horizonlab/parity/VanillaProtocolClient.java'], check=True)
client = ['java', '-Xmx1g', '-cp', str(classes) + os.pathsep + cp,
'dev.horizonlab.parity.VanillaProtocolClient']
commands = {'vanilla': ['java', '-Xmx2g', '-cp', cp, 'net.minecraft.server.Main', 'nogui'],
'horizon': [binary, '--resources', lab.RESOURCES.resolve(),
'--dedicated', '--nogui']}
results = {label: run_side(label, command, client, out) for label, command in commands.items()}
report = {label: checks(result) for label, result in results.items()}
lab.write_json(out / 'records.json', results)
lab.write_json(out / 'report.json', {'schema': 1, 'minecraft': lock['minecraft']['version'], **report})
assert not report['vanilla']['failures'], report['vanilla']
for label, case in report.items():
print(f"{label}: {case['checks'] - len(case['failures'])}/{case['checks']} scoped checks pass", flush=True)
for failure in case['failures']:
print(json.dumps(failure), flush=True)
assert not report['horizon']['failures'], f'scoped goat voice failures; see {out}'
if __name__ == '__main__':
main()