From 6303017b0327584ae9fddfcc929685eef2936fac Mon Sep 17 00:00:00 2001 From: Florian ERNST <30944236+TheMrZZ@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:38:19 +0100 Subject: [PATCH] Classify the verified hostile sound families as hostile Co-Authored-By: Codex --- crates/horizon-client-render/src/settings.rs | 63 ++++- docs/parity/hostile-sound-categories.md | 125 +++++++++ .../horizonlab/parity/LocatorBarClient.java | 19 ++ tools/test_hostile_sound_categories_parity.py | 265 ++++++++++++++++++ 4 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 docs/parity/hostile-sound-categories.md create mode 100644 tools/test_hostile_sound_categories_parity.py diff --git a/crates/horizon-client-render/src/settings.rs b/crates/horizon-client-render/src/settings.rs index 5fdb01d323..8e9fed9dbd 100644 --- a/crates/horizon-client-render/src/settings.rs +++ b/crates/horizon-client-render/src/settings.rs @@ -18,7 +18,7 @@ pub fn set_source_volumes(volumes: [f32; 11]) { pub fn sound_source(event: &str) -> usize { let e = event.trim_start_matches("minecraft:"); let index = |name: &str| SOUND_SOURCES.iter().position(|n| *n == name).unwrap_or(0); - const HOSTILE: [&str; 25] = [ + const HOSTILE: [&str; 34] = [ "zombie", "skeleton", "creeper", @@ -44,6 +44,16 @@ pub fn sound_source(event: &str) -> usize { "silverfish", "zombified_piglin", "wither_skeleton", + "bogged", + "breeze", + "endermite", + "parched", + "warden", + "zombie_villager", + "vex", + "wither", + // Monster's common hurt/death (e.g. Giant) and other shared sounds. + "hostile", ]; if e == "block.potent_sulfur.noxious_gas" { // PotentSulfurBlock.animateTick, its only caller, plays it as AMBIENT. @@ -856,6 +866,57 @@ mod tests { horizon_narrator::Status::Off ); } + /// `Monster.getSoundSource`: the verified families and inherited common sounds. + #[test] + fn hostile_families_keep_their_sound_category() { + for (event, source) in [ + ("entity.zombie.hurt", 5), + ("entity.cow.hurt", 6), + ("block.stone.break", 4), + ("entity.player.hurt", 7), + ("entity.arrow.hit", 7), + ("music_disc.13", 2), + ("item.goat_horn.sound.0", 2), + ("weather.rain", 3), + ("ambient.cave", 8), + ] { + assert_eq!(sound_source(event), source, "{event}"); + } + // Explicit sources still take precedence, including H331's arrow route. + let mut audio = crate::audio::Audio::silent(Path::new("/nonexistent/h068-audio")); + audio.capture = Some(Vec::new()); + audio.play_on(6, "minecraft:entity.arrow.hit", Some([1.0; 3]), 1.0, 1.0); + audio.play_on(4, "minecraft:entity.warden.hurt", Some([1.0; 3]), 1.0, 1.0); + assert_eq!( + audio + .capture + .as_ref() + .unwrap() + .iter() + .map(|s| s.source) + .collect::>(), + [6, 4] + ); + for family in [ + "bogged", + "breeze", + "endermite", + "parched", + "warden", + "zombie_villager", + "vex", + "wither", + "hostile", + ] { + for suffix in ["hurt", "death"] { + for prefix in ["", "minecraft:"] { + let event = format!("{prefix}entity.{family}.{suffix}"); + assert_eq!(sound_source(&event), 5, "{event}"); + } + } + } + } + #[test] fn three_d_skin_layers_move_from_options_txt_to_the_extras_file() { // #8091 wrote the switch into options.txt; a player who had it on keeps diff --git a/docs/parity/hostile-sound-categories.md b/docs/parity/hostile-sound-categories.md new file mode 100644 index 0000000000..31b9d82fae --- /dev/null +++ b/docs/parity/hostile-sound-categories.md @@ -0,0 +1,125 @@ +# Verified hostile sound families + +H068 adds only bogged, breeze, endermite, parched, warden, zombie_villager, vex, +wither and the shared `entity.hostile` family to inferred HOSTILE5. Pinned26.3 +`Monster.getSoundSource` returns HOSTILE; the eight verified types inherit that +source. `Monster.getHurtSound/getDeathSound` supply the common pair used by Giant +(merged#7834). Existing25 names, other branches, normalization, explicit sources, +volumes, names, timing and RNG calls are unchanged. No all-hostile census claim. + +## Frozen live category contract + +Base `69fab597a6f0e8a6ce524d38d1b73e599e343034`. Fresh Java26.3 generated the small +loaded platform/roof world; both actual dedicated Game runs serve independent +copies. One real connected Alice has actual UUID/type/Health20/creative/Pos guards. +Twelve distinct fixed-UUID actors have typed species/Health/NoAI/NoGravity and +loaded floor/air guards. Eleven fresh hurt cases set Health6, require accepted +result/success1 with fresh sentinels, and independently check live Health5. One +fresh Giant takes a lethal hit; raw snapshots independently show Health0. + +Eight assigned hostile types, Giant, zombie and cow supply the hurt events; +Giant supplies common death. Each hit is followed by exactly one real world step +to drain native events, for12 total. No hit is retried and no extra world step is +used for sound readiness. Receipt barriers wait only for the actual packet. This +does not claim identical Java/native emission timing. Body/position snapshots +remain raw; post-hit trajectories are not compared. + +Predeclared16-row contract: stage, event name, source ordinal and sound position. +Actor/control admissions remain required on both sides. Zombie5 and cow6 are +live controls; explicit block4/player7/record2 and Warden-neutral6 override are +controls. Control volume/pitch1 bits are exact. Random hurt pitch/volume must be +finite/positive; no exact random pitch, seed or full packet-family comparison. +No Breeze ambient output is required (H069 remains independent). + +The existing typed SoundPacket switch is reused unchanged from own#10266 in +`LocatorBarClient`: vanilla's protocol decoder reads both servers and retains +all positional sound payload bytes, names, source names, volume/pitch bits, +seeds and arrival order. Nothing is reconstructed from a desired category. All +recorded sound rows stay raw, including any extra ambient sound; only the +predeclared event contract is canonical. No new observer/protocol implementation. +H128 production and H331 arrow production/decoder are not imported or changed. +Keep the identical test-only receipt switch once when combining#10266, while +preserving both drivers. + +Program JSON SHA-256 (declared before fresh Java/baseline): +`fc1d6ae79de25bf520ed8e511b9e20a43f1bb4012b6fbcfee7cb0b8ba526c39f`. + +```sh +python3 tools/test_hostile_sound_categories_parity.py --binary /path/to/old-game --reference-only --work-dir /tmp/h068-java +python3 tools/test_hostile_sound_categories_parity.py --binary /path/to/old-game --reference-dir /tmp/h068-java --work-dir /tmp/h068-old +python3 tools/test_hostile_sound_categories_parity.py --binary /path/to/fixed-game --reference-dir /tmp/h068-java --work-dir /tmp/h068-fixed +``` + +Each output directory is fresh; references are immutable. The owned untouched +69fab executable was reused from the completed H041 baseline after source/hash +verification, not from a historical capture. SHA-256: +`440bc67f89ce05a1691798cde40a5f9c05bfb61699cb124a132803672433b3bd`. +The H068 captures themselves are fresh. Fixed Game is independently built from +the same frozen base plus this classifier change. + +## Unit and evidence limits + +The resource-free classifier regression first verifies existing zombie/cow, +block/player/record/horn/weather/ambient categories and actual `Audio::play_on` +overrides (arrow6 and Warden4). It then checks hurt/death for all nine additions, +with and without the namespace. Old fails at bogged6 versus5; fixed passes. It +preserves the existing arrow inference branch and checks override precedence; +no live arrow-route parity or H331 fix is claimed. + +All program/reference hashes, raw typed sound packets/payloads/seeds/pitch/order, +chat, complete actor bodies, command sent/receipt times, actual clocks, logs, +source/header review and immutable executable evidence remain under `.local/h068/`. +Command/stage timestamps are real UTC observations. Packet receipt and sound +emission timestamps are unavailable and explicitly null; original reused build +start is null. Available build/check elapsed times are retained, not inferred. +Verified inactive owned executable archives retain original paths, modes and +hashes; no shared dependency tree or other owner's artifact was removed. + +No Breeze ambient routing, mob damage policy, AI/physics, exact random stream, +full sound-event/packet family, client rendering, mixer or reopen closure claim. + +## Results + +Fresh Java16/16; untouched current Game **6/16 → fixed16/16**. All actor/action, +position, override and twelve-tick admissions pass on both Game binaries. + +| Stage / event | Java | Old Game | Fixed Game | +| --- | --- | --- | --- | +| `control_block` / `minecraft:block.note_block.harp` | 4 | 4 | 4 | +| `control_player` / `minecraft:entity.player.hurt` | 7 | 7 | 7 | +| `control_record` / `minecraft:music_disc.13` | 2 | 2 | 2 | +| `control_override` / `minecraft:entity.warden.hurt` | 6 | 6 | 6 | +| `bogged` / `minecraft:entity.bogged.hurt` | 5 | 6 | 5 | +| `breeze` / `minecraft:entity.breeze.hurt` | 5 | 6 | 5 | +| `endermite` / `minecraft:entity.endermite.hurt` | 5 | 6 | 5 | +| `parched` / `minecraft:entity.parched.hurt` | 5 | 6 | 5 | +| `warden` / `minecraft:entity.warden.hurt` | 5 | 6 | 5 | +| `zombie_villager` / `minecraft:entity.zombie_villager.hurt` | 5 | 6 | 5 | +| `vex` / `minecraft:entity.vex.hurt` | 5 | 6 | 5 | +| `wither` / `minecraft:entity.wither.hurt` | 5 | 6 | 5 | +| `giant` / `minecraft:entity.hostile.hurt` | 5 | 6 | 5 | +| `zombie` / `minecraft:entity.zombie.hurt` | 5 | 5 | 5 | +| `cow` / `minecraft:entity.cow.hurt` | 6 | 6 | 6 | +| `giant_death` / `minecraft:entity.hostile.death` | 5 | 6 | 5 | + +Only the ten affected category ordinals change in the canonical contract; no +category mask or error normalization. Raw seeds/pitch/packet encodings/order +are preserved separately and are not claimed byte-equal across independent runs. + +Immutable fresh Java reference SHA-256: +`f043ae5906dd9e9fabaa9ddd23255ceb0299e0bbe5b86e14fd80bf85b02a98c9`. Program, observer and reference hashes are verified +unchanged across old/fixed captures. Giant death raw snapshots show Health0 on +both sides. No source modification was needed outside the family table. + +## Local checks and source checkpoint + +Final production/harness:1517 renderer tests, formatting, workspace/server +Clippy, doc tests and public-tree checks pass. Python696 has four existing +macOS tooling failures (ARM installer-fixture assets and three Bash3.2 optional +archive cases), one skip. Narrow installer diagnostic retained; no packaging fix. + +Later main1532c9c15 adds tick tracking/post-AI gates, LAN caching and player +metadata. Classifier/audio dispatch are unchanged; nearby NoAI hurt cases have +no pickup/search inputs affected by those post-passes. This is source review, +not a latest-base capture. Proof remains frozen69fab. Subsequent edits only add +evidence notes; final public-tree/whitespace checks pass. diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java b/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java index 9d6486cba1..fd5523a458 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java @@ -103,6 +103,7 @@ import net.minecraft.world.phys.Vec3; * player's lines to OUT. {@code boats on|off} records boat paddle metadata; * {@code hooks on|off} records the entity a fishing bobber hooks, by type; * {@code paddle NAME LEFT RIGHT} sends the two boolean paddle states; + * {@code sounds on|off} records typed sound packets and their complete payloads; * {@code events on|off} records each level event: its id, block and data; * {@code spawns on|off} records each entity other than a player added to the * level, by type and position. @@ -115,6 +116,7 @@ public final class LocatorBarClient { private static volatile boolean boats; private static volatile boolean hooks; private static volatile boolean state; + private static volatile boolean sounds; private static volatile boolean events; private static volatile boolean spawns; private static volatile boolean menus; @@ -202,6 +204,7 @@ public final class LocatorBarClient { Boolean.parseBoolean(sides[0]), Boolean.parseBoolean(sides[1]))); } case "state" -> state = w[1].equals("on"); + case "sounds" -> sounds = w[1].equals("on"); case "events" -> events = w[1].equals("on"); case "spawns" -> spawns = w[1].equals("on"); case "end" -> { @@ -548,6 +551,22 @@ public final class LocatorBarClient { if (tickSteps && p instanceof net.minecraft.network.protocol.game.ClientboundBlockEventPacket e && e.getPos().equals(new net.minecraft.core.BlockPos(0, 80, 0))) record("piston-event " + e.getB0() + " " + e.getB1()); + if (sounds && p instanceof net.minecraft.network.protocol.game.ClientboundSoundPacket sound) { + com.google.gson.JsonObject j = new com.google.gson.JsonObject(); + j.addProperty("event", sound.getSound().value().location().toString()); + j.addProperty("source", sound.getSource().ordinal()); + j.addProperty("source_name", sound.getSource().name()); + com.google.gson.JsonArray at = new com.google.gson.JsonArray(); + at.add(sound.getX()); at.add(sound.getY()); at.add(sound.getZ()); + j.add("position", at); + j.addProperty("volume", sound.getVolume()); + j.addProperty("pitch", sound.getPitch()); + j.addProperty("volume_bits", Float.floatToRawIntBits(sound.getVolume())); + j.addProperty("pitch_bits", Float.floatToRawIntBits(sound.getPitch())); + j.addProperty("seed", sound.getSeed()); + j.addProperty("payload_hex", java.util.HexFormat.of().formatHex(r.payload)); + record("sound-wire " + j); + } if (state) recordState(p); if (menus) recordMenu(p, r.payload); if (hooks && p instanceof net.minecraft.network.protocol.game.ClientboundAddEntityPacket a) { diff --git a/tools/test_hostile_sound_categories_parity.py b/tools/test_hostile_sound_categories_parity.py new file mode 100644 index 0000000000..36f9a8c402 --- /dev/null +++ b/tools/test_hostile_sound_categories_parity.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +"""Live hurt/death sound categories, through the existing typed network capture. + +Seeds, pitch, payloads, packet order, chat, bodies and real clocks stay raw. +Only the predeclared event/category/position contract is compared across sides. +""" +import argparse +import datetime +import hashlib +import json +import math +from pathlib import Path +import re +import time + +import test_locator_bar_parity as net +from test_dedicated_server_parity import Server, game_time + +PLAYER = 'Alice' +FAMILIES = ['bogged', 'breeze', 'endermite', 'parched', 'warden', 'zombie_villager', 'vex', 'wither', 'giant', 'zombie', 'cow'] +CONTROLS = [ + ('block', 'minecraft:block.note_block.harp', 'block', 4), + ('player', 'minecraft:entity.player.hurt', 'player', 7), + ('record', 'minecraft:music_disc.13', 'record', 2), + ('override', 'minecraft:entity.warden.hurt', 'neutral', 6), +] + + +def now(): + return datetime.datetime.now(datetime.timezone.utc).isoformat() + + +def uuid_words(name): + raw = bytearray(hashlib.md5(('OfflinePlayer:' + name).encode()).digest()) + raw[6] = (raw[6] & 15) | 48 + raw[8] = (raw[8] & 63) | 128 + return ','.join(str(int.from_bytes(raw[i:i+4], 'big', signed=True)) for i in range(0, 16, 4)) + + +def actor(index): + return f'00000044-0000-0000-0000-{index:012x}' + + +def program(): + expected = [dict(stage='control_' + label, event=event, source=source, position=[4.5, 100.0, 4.5]) + for label, event, _, source in CONTROLS] + for kind in FAMILIES: + family = 'hostile' if kind == 'giant' else kind + expected.append(dict(stage=kind, event=f'minecraft:entity.{family}.hurt', source=6 if kind == 'cow' else 5, + position=[8.5, 100.0, 8.5])) + expected.append(dict(stage='giant_death', event='minecraft:entity.hostile.death', source=5, position=[8.5, 100.0, 8.5])) + return dict(player=PLAYER, families=FAMILIES, controls=CONTROLS, expected=expected, + contract=['stage', 'event', 'source', 'position'], world_ticks=12, + damage='1 minecraft:out_of_world (fresh Health6→5); fresh giant death uses100', + diagnostic_fields=['source_name', 'volume', 'pitch', 'volume_bits', 'pitch_bits', 'seed', 'payload_hex', 'receipt_order'], + sound_emission_time=None, packet_receipt_time=None, + scope='Positional sound category and admitted live actor/action; no exact random pitch/seed/trajectory or packet-family claim') + + +def dump(client, label): + client.tell('dump ' + label) + while (line := client.process.stdout.readline()) != 'dumped ' + label + '\n': + if not line: + raise AssertionError('client exited before dump') + client.log.write(line) + + +def raw_sounds(path): + out = [] + if path.exists(): + for order, line in enumerate(path.read_text().splitlines()): + who, rest = line.split(' step=', 1) + stage, kind, data = rest.split(' ', 2) + assert who == PLAYER + if kind == 'sound-wire': + out.append(dict(stage=stage, receipt_order=order, **json.loads(data))) + return out + + +class Commands: + def __init__(self, server, path): + self.server, self.path, self.serial, self.rows = server, path, 0, [] + + def reply(self, command): + self.serial += 1 + start = len(self.server.lines) + row = dict(command=command, sent_at=now(), receipt_at=None, lines=[]) + self.rows.append(row) + self.server.console(command) + self.server.console(f'data modify storage h068:barrier value set value {self.serial}') + self.server.console('data get storage h068:barrier value') + try: + self.server.wait_for(rf'h068:barrier.*contents: {self.serial}$', timeout=90) + row['receipt_at'] = now() + return '\n'.join(self.server.lines[start:]) + finally: + row['lines'] = self.server.lines[start:] + self.path.write_text(json.dumps(self.rows, indent=2) + '\n') + + def passed(self, command): + text = self.reply(command) + assert 'Test passed' in text, (command, text) + + +def generate(java, build): + directory = build / 'generate' + net.server_dir(directory) + server = Server(java, directory, 'generate') + commands = Commands(server, build / 'generate-commands.json') + try: + server.wait_for(r'Done \(') + for rule in ['spawn_mobs', 'advance_time', 'mob_griefing']: + commands.reply(f'gamerule {rule} false') + commands.reply('gamerule respawn_radius 0') + commands.reply('forceload add 0 0 15 15') + deadline = time.monotonic() + 90 + while 'Test passed' not in commands.reply('execute if loaded 8 100 8'): + assert time.monotonic() < deadline, 'generation readiness' + time.sleep(.05) + assert '256' in commands.reply('fill 0 99 0 15 99 15 stone') + commands.reply('fill 0 100 0 15 115 15 air') + assert '256' in commands.reply('fill 0 116 0 15 116 15 stone') + commands.reply('setworldspawn 2 100 2') + commands.reply('save-all flush') + finally: + server.stop() + return directory / 'world' + + +def run(label, command, client_command, build, world, spec): + directory = build / label + port = net.server_dir(directory, world) + server = Server(command, directory, label) + commands = Commands(server, build / (label + '-commands.json')) + path = build / (label + '-wire.txt') + client_log = (build / (label + '-client.log')).open('w') + client, timeline, checks = None, [], [] + def mark(stage, **data): + timeline.append(dict(stage=stage, recorded_at=now(), monotonic_ns=time.monotonic_ns(), sound_emission_time=None, **data)) + try: + server.wait_for(r'Done \(') + commands.reply('tick freeze') + commands.reply('scoreboard objectives add h068 dummy') + client = net.Client(client_command, port, path, client_log) + for line in ['sounds on', 'chat on', 'spawns on', 'join ' + PLAYER, 'fly Alice on']: + client.tell(line) + server.wait_for('Alice joined the game', timeout=120) + client.sync([PLAYER]) + commands.reply('tp Alice 2.5 100 2.5 0 0') + commands.passed('execute if entity @a[name=Alice,nbt={UUID:[I;' + uuid_words(PLAYER) + '],Health:20f,playerGameType:1,Pos:[2.5d,100d,2.5d]}]') + commands.reply('data get entity Alice') + for y, block in [(99, 'stone'), (100, 'air'), (112, 'air'), (115, 'air'), (116, 'stone')]: + commands.passed(f'execute if loaded 8 {y} 8') + commands.passed(f'execute if block 8 {y} 8 {block}') + start = game_time(server) + elapsed = 0 + for case in spec['expected']: + stage, event = case['stage'], case['event'] + client.tell('step ' + stage); client.sync([PLAYER]) + mark(stage, game_time=game_time(server), relative_clock=elapsed) + if stage.startswith('control_'): + _, _, category, _ = next(c for c in CONTROLS if 'control_' + c[0] == stage) + commands.reply(f'playsound {event} {category} Alice 4.5 100 4.5 1 1') + else: + kind = 'giant' if stage == 'giant_death' else stage + index = len(FAMILIES) + 1 if stage == 'giant_death' else FAMILIES.index(kind) + 1 + uid = actor(index) + tag = 'h068_' + stage + commands.reply(f'summon minecraft:{kind} 8.5 100 8.5 {{UUID:[I;68,0,0,{index}],Tags:["{tag}"],Health:6f,NoAI:1b,NoGravity:1b,PersistenceRequired:1b,Invul:0}}') + selector = f'@e[type=minecraft:{kind},tag={tag},nbt={{UUID:[I;68,0,0,{index}],Health:6f,NoAI:1b,NoGravity:1b}},distance=..0]' + commands.passed('execute positioned 8.5 100 8.5 if entity ' + selector) + commands.reply(f'data get entity {uid}') + for key in ['hit_result', 'hit_success']: + commands.reply(f'scoreboard players set {key} h068 -99') + amount = 100 if stage == 'giant_death' else 1 + commands.reply(f'execute store result score hit_result h068 store success score hit_success h068 run damage {uid} {amount} minecraft:out_of_world') + for key in ['hit_result', 'hit_success']: + text = commands.reply(f'scoreboard players get {key} h068') + assert f'{key} has 1 [h068]' in text, (key, text) + if stage != 'giant_death': + commands.passed(f'execute if entity @e[type=minecraft:{kind},tag={tag},nbt={{UUID:[I;68,0,0,{index}],Health:5f}}]') + commands.reply(f'data get entity {uid}') + commands.reply('tick step 1') + elapsed += 1 + deadline = time.monotonic() + 60 + while game_time(server) < start + elapsed: + assert time.monotonic() < deadline, 'world step stalled' + time.sleep(.01) + assert game_time(server) == start + elapsed + if stage != 'giant_death': + commands.passed(f'execute if entity @e[type=minecraft:{kind},tag={tag},nbt={{UUID:[I;68,0,0,{index}],Health:5f,NoAI:1b,NoGravity:1b}}]') + commands.reply(f'data get entity {uid}') + # Wait for receipt only: never retry damage or add a world tick. + deadline = time.monotonic() + 30 + while True: + client.sync([PLAYER]); dump(client, stage) + matches = [r for r in raw_sounds(path) if r['stage'] == stage and r['event'] == event] + if matches: + break + assert time.monotonic() < deadline, (stage, 'no expected hurt/control sound') + time.sleep(.02) + assert len(matches) == 1, (stage, matches) + sound = matches[0] + assert sound['position'] == case['position'], (stage, sound) + assert math.isfinite(sound['volume']) and sound['volume'] > 0 + assert math.isfinite(sound['pitch']) and sound['pitch'] > 0 + if stage.startswith('control_'): + assert (sound['volume_bits'], sound['pitch_bits']) == (1065353216, 1065353216) + checks.append(stage) + mark(stage + '-received', game_time=game_time(server), relative_clock=elapsed) + assert game_time(server) - start == spec['world_ticks'] + finally: + (build / (label + '-stages.json')).write_text(json.dumps(timeline, indent=2) + '\n') + try: + if client: + try: + dump(client, 'failure-or-final'); client.end() + finally: + client.kill() + finally: + server.stop(); client_log.close() + (build / (label + '-sounds.json')).write_text(json.dumps(raw_sounds(path), indent=2) + '\n') + records = raw_sounds(path) + names = {c['event'] for c in spec['expected']} + contract = [{k: r[k] for k in spec['contract']} for r in records if r['event'] in names] + assert len(contract) == len(spec['expected']), (label, contract) + return dict(contract=contract, admission=checks, total_world_ticks=elapsed, + recorded_at=now(), sound_emission_time=None, packet_receipt_time=None) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--binary', type=Path) + parser.add_argument('--work-dir', type=Path) + parser.add_argument('--reference-only', action='store_true') + parser.add_argument('--reference-dir', type=Path) + args = parser.parse_args() + build = (args.work_dir or net.lab.LOCAL / 'hostile-sound-categories' / f'run-{time.time_ns()}').resolve() + build.mkdir(parents=True, exist_ok=False) + spec = program(); (build / 'program.json').write_text(json.dumps(spec, indent=2) + '\n') + name = str(build.relative_to(net.lab.LOCAL)) + _, _, java, native, client = net.sides(name, args.binary.resolve() if args.binary else None) + reference = args.reference_dir.resolve() if args.reference_dir else build + if not args.reference_dir: + world = generate(java, build) + data = run('java', java, client, build, world, spec) + assert data['contract'] == spec['expected'], data + (build / 'reference.json').write_text(json.dumps(data, indent=2) + '\n') + if args.reference_only: + return + expected_bytes = (reference / 'reference.json').read_bytes() + expected = json.loads(expected_bytes) + assert expected['contract'] == spec['expected'] + actual = run('native', native, client, build, reference / 'generate/world', spec) + assert expected_bytes == (reference / 'reference.json').read_bytes() + report = dict(expected=expected, actual=actual, pass_=expected['contract'] == actual['contract'], + reference_sha256=hashlib.sha256(expected_bytes).hexdigest()) + (build / 'report.json').write_text(json.dumps(report, indent=2) + '\n') + print('Sound category contract:', sum(a == b for a, b in zip(expected['contract'], actual['contract'])), '/', len(spec['expected'])) + if not report['pass_']: + raise SystemExit(1) + + +if __name__ == '__main__': + main()