diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index aa0490655..a5d67f61f 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1790,6 +1790,9 @@ impl Game<'_> { self.audio.play(&name, Some(at), p.volume, p.pitch); } } + C::StopSound(p) => self + .audio + .stop_sounds(p.sound.as_deref(), p.source.map(|s| s as usize)), C::AddEntity(p) => { let kind = self .remote diff --git a/crates/horizon-protocol/examples/stop_sound_probe.rs b/crates/horizon-protocol/examples/stop_sound_probe.rs new file mode 100644 index 000000000..d09478bd5 --- /dev/null +++ b/crates/horizon-protocol/examples/stop_sound_probe.rs @@ -0,0 +1,31 @@ +use horizon_protocol::{ + buf::{Codec, Reader, Writer}, + packets::play::StopSound, +}; +use std::{error::Error, io::Write}; +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().collect(); + let mut out = std::fs::File::create(&args[2])?; + for line in std::fs::read_to_string(&args[1])?.lines() { + let input: serde_json::Value = serde_json::from_str(line)?; + let hex = input["wire"].as_str().unwrap(); + let bytes: Vec = (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16)) + .collect::>()?; + let mut reader = Reader::new(&bytes); + let packet = StopSound::read(&mut reader)?; + assert_eq!(reader.remaining(), 0); + let mut writer = Writer::new(); + StopSound::write(&packet, &mut writer); + assert_eq!(writer.data, bytes); + writeln!( + out, + "{}", + serde_json::json!({"source":packet.source,"sound":packet.sound}) + )?; + } + assert!(StopSound::read(&mut Reader::new(&[1, 11])).is_err()); + assert!(StopSound::read(&mut Reader::new(&[3])).is_err()); + Ok(()) +} diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index 42e3ba686..519568282 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -1118,6 +1118,42 @@ packet!(SoundEntity { pitch: f32 => Float, seed: i64 => Long, }); + +/// ClientboundStopSoundPacket: bit 0 selects a source, bit 1 an event id. +#[derive(Clone, Debug, PartialEq)] +pub struct StopSound { + pub source: Option, + pub sound: Option, +} +impl Codec for StopSound { + fn read(r: &mut Reader) -> crate::Result { + let flags = r.u8()?; + let source = if flags & 1 != 0 { + let value = r.varint()?; + if !(0..11).contains(&value) { + return Err(crate::Error::new("invalid sound source")); + } + Some(value) + } else { + None + }; + let sound = if flags & 2 != 0 { + Some(r.string(32767)?) + } else { + None + }; + Ok(Self { source, sound }) + } + fn write(p: &Self, w: &mut Writer) { + w.u8(u8::from(p.source.is_some()) | (u8::from(p.sound.is_some()) << 1)); + if let Some(source) = p.source { + w.varint(source); + } + if let Some(sound) = &p.sound { + w.string(sound); + } + } +} /// One entry of ClientboundCommandsPacket: a literal or an argument node. #[derive(Clone, Debug, PartialEq)] pub struct CommandNode { @@ -1678,6 +1714,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound { SetTitlesAnimation(TitlesAnimation) = SET_TITLES_ANIMATION, Sound(Sound) = SOUND, SoundEntity(SoundEntity) = SOUND_ENTITY, + StopSound(StopSound) = STOP_SOUND, StartConfiguration(Empty) = START_CONFIGURATION, SwingAnimation(SwingAnimation) = SWING_ANIMATION, SystemChat(SystemChat) = SYSTEM_CHAT, diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaStopSoundWireOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaStopSoundWireOracle.java new file mode 100644 index 000000000..aebeba486 --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaStopSoundWireOracle.java @@ -0,0 +1,30 @@ +package dev.horizonlab.parity; + +import com.google.gson.*; +import io.netty.buffer.Unpooled; +import java.nio.file.*; +import java.util.*; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.game.ClientboundStopSoundPacket; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundSource; + +/** Every source/event selection combination through the native stream codec. */ +public final class VanillaStopSoundWireOracle { + public static void main(String[] args) throws Exception { + var out = new ArrayList(); + for (int source = -1; source < SoundSource.values().length; source++) { + for (String sound : new String[]{null, "minecraft:entity.pig.ambient", "example:custom"}) { + var packet = new ClientboundStopSoundPacket(sound == null ? null : Identifier.parse(sound), source < 0 ? null : SoundSource.values()[source]); + var buf = new FriendlyByteBuf(Unpooled.buffer()); + ClientboundStopSoundPacket.STREAM_CODEC.encode(buf, packet); + byte[] bytes = new byte[buf.readableBytes()]; buf.readBytes(bytes); buf.release(); + var result = new JsonObject(); result.addProperty("wire", HexFormat.of().formatHex(bytes)); + result.add("source", source < 0 ? JsonNull.INSTANCE : new JsonPrimitive(source)); + result.add("sound", sound == null ? JsonNull.INSTANCE : new JsonPrimitive(sound)); + out.add(new Gson().toJson(result)); + } + } + Files.write(Path.of(args[0]), out); + } +} diff --git a/tools/test_stop_sound_wire_parity.py b/tools/test_stop_sound_wire_parity.py new file mode 100644 index 000000000..480e760a5 --- /dev/null +++ b/tools/test_stop_sound_wire_parity.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Stop-sound packet bytes and selections against the native Java codec.""" +import json +import os +try: + from . import lab, test_rust_parity as base, oracle_harness as harness +except ImportError: + import lab + import test_rust_parity as base + import oracle_harness as harness + + +def main(): + build=lab.LOCAL/'stop-sound-wire-parity'; build.mkdir(exist_ok=True) + (build/'report.json').unlink(missing_ok=True) + lock,jars,libraries=base.verify_artifacts() + cp=os.pathsep.join(map(str,[jars['server'],*libraries])); classes=build/'classes';classes.mkdir(exist_ok=True) + source=lab.ROOT/'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaStopSoundWireOracle.java' + sources=[source,lab.ROOT/'crates/horizon-protocol/src/packets/play.rs',lab.ROOT/'crates/horizon-client/src/game/remote.rs'] + tested=base.source_hashes(sources) + run=lambda cmd,name:harness.run_logged(cmd,build,name) + run(['javac','--release',lock['minecraft']['java_major'],'-proc:none','-cp',cp,'-d',classes,source],'compile-java') + run(['java','-cp',str(classes)+os.pathsep+cp,'dev.horizonlab.parity.VanillaStopSoundWireOracle',build/'java.jsonl'],'java') + run(['cargo','build','-p','horizon-protocol','--example','stop_sound_probe'],'compile-rust') + run([lab.cargo_target()/'debug/examples/stop_sound_probe',build/'java.jsonl',build/'rust.jsonl'],'rust') + java=[json.loads(x) for x in (build/'java.jsonl').read_text().splitlines()] + rust=[json.loads(x) for x in (build/'rust.jsonl').read_text().splitlines()] + assert len(java)==len(rust)==36 + assert all({'source':a.get('source'),'sound':a.get('sound')}==b for a,b in zip(java,rust)) + base.ensure_sources_unchanged(sources,tested) + lab.write_json(build/'report.json',{'pass':True,'cases':len(java),'sources_sha256':tested}) + print(f'Stop sound: {len(java)} native wire selections match and re-encode byte for byte') + + +if __name__=='__main__':main()