From a3d9d8f2c3ce8d89ae748d6062a5233ad4cfe6c6 Mon Sep 17 00:00:00 2001 From: TheMrZZ <30944236+TheMrZZ@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:08:41 +0100 Subject: [PATCH] Keep client shrieker playback outside shared capture Use the existing unshared audio path for level event 3007 and cover actual event capture, local playback, and random continuation. Co-Authored-By: Codex gpt-6.1-sol --- crates/horizon-client/src/game/sculk.rs | 149 +++++++++++- tools/test_shrieker_local_capture_parity.py | 248 ++++++++++++++++++++ 2 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 tools/test_shrieker_local_capture_parity.py diff --git a/crates/horizon-client/src/game/sculk.rs b/crates/horizon-client/src/game/sculk.rs index 923e5c1ba6..1bb075ac8e 100644 --- a/crates/horizon-client/src/game/sculk.rs +++ b/crates/horizon-client/src/game/sculk.rs @@ -1621,7 +1621,7 @@ impl Game<'_> { self.particles .add(&ParticleOptions::sculk_charge(roll), at, speed); } - /// `LevelEvent` 3007: ten shrieks rising from the shrieker, and the shriek. + /// `LevelEventHandler.levelEvent` 3007: ten shrieks and a local sound. pub(super) fn shriek_event(&mut self, pos: BlockPos) { let top = f64::from(pos.y) + 0.5; for i in 0..10 { @@ -1634,7 +1634,7 @@ impl Game<'_> { let state = self.world.block(pos.x, pos.y, pos.z); if self.state_property(state, "waterlogged") != Some("true") { let pitch = 0.6 + self.particles.random().next_float() * 0.4; - self.audio.play( + self.audio.play_unshared( "minecraft:block.sculk_shrieker.shriek", Some([f64::from(pos.x) + 0.5, top, f64::from(pos.z) + 0.5]), 2.0, @@ -2403,3 +2403,148 @@ mod tests { assert_eq!(dist_sqr([0, 0, 0], (3, 4, 0)), 25); } } + +#[cfg(test)] +mod shriek_capture_tests { + use super::*; + use crate::game::{Audio, Options, Progress, Settings, load_resources}; + use horizon_client::chunk::Chunk; + use horizon_core::{position::ChunkPos, random::LegacyRandom}; + use horizon_world::level::LevelEvent; + use std::sync::Arc; + + #[test] + #[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"] + fn level_event_shriek_keeps_local_play_and_random_but_not_shared_capture() { + let root = std::path::PathBuf::from( + std::env::var_os("HORIZON_TEST_RESOURCES").expect("prepared resources required"), + ); + let settings = Settings::default(); + let loaded = load_resources( + root.clone(), + None, + &root, + &settings, + std::time::Instant::now(), + false, + true, + &Progress::new(), + &mut |_, _| unreachable!(), + ) + .unwrap(); + let mut options = Options::parse(std::iter::empty()).unwrap().unwrap(); + options.resources = root.clone(); + options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer; + let mut game = Game::new( + &loaded.resources, + loaded.pack, + loaded.gui.unwrap(), + options, + settings, + Audio::silent(&root), + None, + ) + .unwrap(); + game.build_registries(); + game.set_allow_commands(true); + game.world + .chunks + .insert((0, 0), Arc::new(Chunk::empty(0, 0))); + game.block_ticks + .add_empty_chunk(ChunkPos::new(0, 0)) + .unwrap(); + assert!(game.run_command("forceload add 0 0")); + assert!(game.run_command("setblock 4 200 4 sculk_shrieker[waterlogged=false] strict")); + assert!(game.run_command("setblock 7 200 4 sculk_shrieker[waterlogged=true] strict")); + assert_eq!( + game.state_property(game.world.block(4, 200, 4), "waterlogged"), + Some("false") + ); + assert_eq!( + game.state_property(game.world.block(7, 200, 4), "waterlogged"), + Some("true") + ); + game.particles.requested = Some(Vec::new()); + game.particles.seed_randoms(6500, 6501, 6502); + let mut random = LegacyRandom::new(6500); + let clock = game.world.game_time; + let mut world_random = game.world.random.clone(); + let effect = "minecraft:block.sculk_shrieker.shriek"; + let before = "minecraft:block.note_block.pling"; + let after = "minecraft:block.amethyst_block.chime"; + game.audio.capture = Some(Vec::new()); + game.audio + .play_on(4, before, Some([1.5, 200.0, 4.5]), 1.0, 1.0); + game.level_events.push(LevelEvent::LevelEvent { + id: 3007, + pos: BlockPos::new(4, 200, 4), + data: 0, + }); + game.process_level_events().unwrap(); + assert_eq!( + game.audio.heard.get(effect), + Some(&1), + "dry effect must still play locally" + ); + random.next_float(); // Same single pitch draw after the particle requests. + assert_eq!( + game.particles.random().next_long(), + random.next_long(), + "dry random continuation" + ); + game.level_events.push(LevelEvent::LevelEvent { + id: 3007, + pos: BlockPos::new(7, 200, 4), + data: 0, + }); + game.process_level_events().unwrap(); + assert_eq!( + game.audio.heard.get(effect), + Some(&1), + "waterlogged effect stays silent" + ); + assert_eq!( + game.particles.random().next_long(), + random.next_long(), + "wet branch consumes no pitch draw" + ); + let requests = game.particles.requested.as_ref().unwrap(); + assert_eq!(requests.len(), 20); + for (i, (options, position, velocity)) in requests.iter().enumerate() { + assert_eq!(options.ty, ParticleType::Shriek); + assert_eq!(options.scale, ((i % 10) * 5) as f32); + assert_eq!(*position, [if i < 10 { 4.5 } else { 7.5 }, 200.5, 4.5]); + assert_eq!(*velocity, [0.0; 3]); + } + game.audio + .play_on(4, after, Some([2.5, 200.0, 4.5]), 0.5, 1.25); + assert_eq!(game.audio.heard.get(before), Some(&1)); + assert_eq!(game.audio.heard.get(after), Some(&1)); + assert_eq!(game.world.game_time, clock); + assert_eq!( + game.world.random.next_long(), + world_random.next_long(), + "level random continuation" + ); + let shared = game + .audio + .capture + .as_ref() + .expect("shared capture restored"); + assert_eq!( + shared.iter().map(|s| s.event.as_str()).collect::>(), + vec![before, after], + "client-level shriek entered shared capture" + ); + for (sound, (position, volume, pitch)) in shared.iter().zip([ + ([1.5, 200.0, 4.5], 1.0, 1.0), + ([2.5, 200.0, 4.5], 0.5, 1.25), + ]) { + assert_eq!( + (sound.position, sound.volume, sound.pitch, sound.source), + (position, volume, pitch, 4) + ); + assert_eq!((sound.entity, sound.except_entity), (None, None)); + } + } +} diff --git a/tools/test_shrieker_local_capture_parity.py b/tools/test_shrieker_local_capture_parity.py new file mode 100644 index 0000000000..3d062f6e02 --- /dev/null +++ b/tools/test_shrieker_local_capture_parity.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""A real client jump lands on a shrieker; retain full raw wire packets. + +Only the shriek's shared-packet absence, admitted level event and explicit shared +controls form the contract. Seeds, random pitch/order and unrelated counts stay raw. +""" +import argparse +import hashlib +import json +import os +import math +import uuid +import anvil_dump as nbt +from pathlib import Path +import struct +import subprocess +import time + +import net_capture +import test_hostile_sound_categories_parity as sound +import test_locator_bar_parity as net +from test_dedicated_server_parity import Server, game_time + +PLAYER = 'Alice' +ACTOR = 'Bob' +SHRIEK = 'minecraft:block.sculk_shrieker.shriek' +CONTROL = 'minecraft:block.note_block.pling' +FIELDS = ['event', 'source', 'position', 'volume_bits', 'pitch_bits'] +CASES = [('dry', False), ('waterlogged', True)] +HELPERS = ['tools/anvil_dump.py','tools/net_capture.py','tools/test_hostile_sound_categories_parity.py', + 'tools/test_locator_bar_parity.py','tools/test_dedicated_server_parity.py', + 'mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java', + 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaPacketCodecOracle.java'] + +def helper_hashes(): + return {n: hashlib.sha256((net.lab.ROOT/n).read_bytes()).hexdigest() for n in HELPERS} + + +def program(): + return dict(cases=CASES, source=[8, 100, 5], + observer=dict(name=PLAYER,position=[2.5,100.0,2.5]), + actor=dict(name=ACTOR,start=[8.5,100.5,1.9],end=[8.5,100.5,5.5]), + initialization='join both clients, then freeze and positively create objective after geometry setup', + observation_ticks=12, action='existing client arc Bob -1, one world tick per received real movement packet', + contract='actual stepOn 3007 at source, shared controls before/after; no separate shriek Sound packet', + raw='all packets/counts/order/seeds/pitch/commands/bodies/absolute clocks', + sound_emission_time=None) + + +def run(label, wet, command, client_command, build, world): + directory = build / label + port = net.server_dir(directory, world) + server = Server(command, directory, label) + commands = sound.Commands(server, build / (label+'-commands.json')) + listen = net.lab.free_port() + capture = build / (label+'-packets.jsonl') + client_log = (build / (label+'-client.log')).open('w') + client = actor = stop = None + actor_log=(build/(label+'-actor-client.log')).open('w') + clocks = [] + def clock(stage, elapsed): + value = game_time(server) + clocks.append(dict(stage=stage, absolute=value, relative=value-start, expected=elapsed, + recorded_at=sound.now(), sound_emission_time=None)) + assert value-start == elapsed, clocks[-1] + try: + server.wait_for(r'Done \(') + stop = net_capture.start_background(listen, f'127.0.0.1:{port}', capture, full=True) + client = net.Client(client_command, listen, build/(label+'-wire.txt'), client_log) + for line in ['sounds on', 'events on', 'chat on', 'join Alice', '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,type=player,nbt={UUID:[I;'+sound.uuid_words(PLAYER)+'],Health:20f,Pos:[2.5d,100d,2.5d],playerGameType:1}]') + commands.reply('data get entity Alice') + actor=net.Client(client_command,listen,build/(label+'-actor-wire.txt'),actor_log) + for line in ['chat on','join Bob','fly Bob on']:actor.tell(line) + server.wait_for('Bob joined the game',timeout=120);actor.sync([ACTOR]) + commands.reply('tick freeze') + commands.reply('tp Bob 8.5 100.5 1.9 0 0') + commands.passed('execute if entity @a[name=Bob,type=player,nbt={UUID:[I;'+sound.uuid_words(ACTOR)+'],Health:20f,Pos:[8.5d,100.5d,1.9d],playerGameType:1}]') + commands.reply('data get entity Bob') + client.sync([PLAYER]);actor.sync([ACTOR]) + for z in range(0,6): + commands.passed(f'execute if loaded 8 100 {z}') + commands.passed(f'execute if block 8 99 {z} stone') + commands.passed(f'execute if block 8 101 {z} air') + for z in range(0,5): + commands.reply(f'setblock 8 100 {z} stone_slab[type=bottom,waterlogged=false] strict') + commands.passed(f'execute if block 8 100 {z} stone_slab[type=bottom,waterlogged=false]') + water = str(wet).lower() + commands.reply(f'setblock 8 100 5 sculk_shrieker[shrieking=false,can_summon=false,waterlogged={water}] strict') + commands.passed(f'execute if block 8 100 5 sculk_shrieker[shrieking=false,can_summon=false,waterlogged={water}]') + created=commands.reply('scoreboard objectives add h065 dummy') + assert 'Created new objective' in created and 'h065' in created,created + start = game_time(server) + controls = [] + for phase in ['before','after']: + client.tell('step '+phase);client.sync([PLAYER]) + if phase == 'after': + client.tell('step actual-jump');client.sync([PLAYER]) + def movement_count(): + try: + rows=net_capture.load(capture) + except json.JSONDecodeError: + return 0 # Proxy may still be appending the final complete raw line. + return sum(r.get('dir')=='c2s' and r.get('state')=='play' and r.get('name')=='move_player_pos_rot' for r in rows) + initial_moves=movement_count() + actor.tell('step actual-jump');actor.sync([ACTOR]) + actor.tell('arc Bob -1') + for tick in range(1,13): + deadline=time.monotonic()+45 + while movement_count()i',3007)] + assert len(level)==1,level + payload=bytes.fromhex(level[0]['data']) + assert len(payload)==17 and payload[-1]==0,level + # Typed official decoding above admits id/position/data; retain the entire + # packet and compare its bytes, including global=false, across the pair. + return dict(controls=controls,level_event=payload.hex(),shriek_packets=sum(r['event']==SHRIEK for r in sounds), + clocks='relative 0 before action / 12 after action',source_save='UUID/Health20/mode1/typedPos bounded1e-5/OnGround1 admitted',sound_emission_time=None) + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--binary',type=Path,required=True) + parser.add_argument('--work-dir',type=Path,required=True) + parser.add_argument('--reference-only',action='store_true') + parser.add_argument('--reference-dir',type=Path) + args=parser.parse_args();build=args.work_dir.resolve();build.mkdir(parents=True,exist_ok=False) + spec=json.dumps(program(),indent=2)+'\n';(build/'program.json').write_text(spec) + source=Path(__file__).read_bytes();sha=hashlib.sha256(source).hexdigest() + (build/'manifest.json').write_text(json.dumps(dict(script_sha256=sha,helpers=helper_hashes(),declared_at=sound.now()),indent=2)) + lock,_,java,native,client=net.sides(str(build),args.binary.resolve()) + _,jars,libs=net.base.verify_artifacts();cp=os.pathsep.join(map(str,[jars['server'],*libs])) + oracle=net.lab.ROOT/'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaPacketCodecOracle.java' + subprocess.run(['javac','--release',str(lock['minecraft']['java_major']),'-proc:none','-cp',str(build/'classes')+os.pathsep+cp,'-d',build/'classes',oracle],check=True,capture_output=True) + codec=['java','-cp',str(build/'classes')+os.pathsep+cp,'dev.horizonlab.parity.VanillaPacketCodecOracle'] + def side(prefix,command,world): + out=[] + for name,wet in CASES: + label=prefix+'-'+name;out.append(run(label,wet,command,client,build,world)) + subprocess.run([*codec,build/(label+'-codec-input.jsonl'),build/(label+'-codec.txt'),'--verbose'],check=True) + return out + reference=args.reference_dir.resolve() if args.reference_dir else build + if not args.reference_dir: + world=sound.generate(java,build) + expected=side('java',java,world) + assert all(r['shriek_packets']==0 for r in expected),expected + (build/'reference.json').write_text(json.dumps(expected,indent=2)+'\n') + else: + assert (reference/'program.json').read_text()==spec + assert json.loads((reference/'manifest.json').read_text())['script_sha256']==sha + assert json.loads((reference/'manifest.json').read_text())['helpers']==helper_hashes() + if args.reference_only:return + ref=(reference/'reference.json').read_bytes();expected=json.loads(ref) + assert all(r['shriek_packets']==0 for r in expected) + actual=side('native',native,reference/'generate/world') + assert (reference/'reference.json').read_bytes()==ref + report=dict(expected=expected,actual=actual,pass_contract=expected==actual, + scope=program()['contract'],script_sha256=sha,reference_sha256=hashlib.sha256(ref).hexdigest()) + (build/'report.json').write_text(json.dumps(report,indent=2)+'\n') + print(json.dumps(report)) + assert report['pass_contract'],'admitted shriek shared-capture mismatch' + + +if __name__=='__main__':main()