diff --git a/crates/horizon-client/examples/sound_event_fixture.rs b/crates/horizon-client/examples/sound_event_fixture.rs new file mode 100644 index 000000000..57c6dd7be --- /dev/null +++ b/crates/horizon-client/examples/sound_event_fixture.rs @@ -0,0 +1,78 @@ +//! Resolves sound instances through Horizon's sound event registry for +//! `tools/test_sound_event_parity.py`: `sound_event_fixture ROOT INPUT OUTPUT [NAMES]`. +use horizon_client::sound_engine::{Attenuation, MASTER, SoundEngine, SoundInstance}; +use horizon_client::sound_events::{Sentinel, SoundRegistry}; +use horizon_core::random::{LegacyRandom, RandomSource}; +use std::io::Write; + +fn bits(v: f32) -> String { + format!("{:08x}", v.to_bits()) +} + +fn main() { + let args: Vec = std::env::args().collect(); + let registry = SoundRegistry::load(std::path::Path::new(&args[1])); + let mut engine = SoundEngine::new(registry); + let input = std::fs::read_to_string(&args[2]).expect("input"); + let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output")); + let mut names: Vec = engine.registry.names().cloned().collect(); + names.sort(); + for name in names { + writeln!(out, "registered {name}").expect("write"); + } + for line in input.lines() { + let f: Vec<&str> = line.split(' ').collect(); + let seed: i64 = f[1].parse().expect("seed"); + let volume = f32::from_bits(u32::from_str_radix(f[2], 16).expect("volume")); + let pitch = f32::from_bits(u32::from_str_radix(f[3], 16).expect("pitch")); + let event = horizon_client::sound_events::normalize(f[0]); + let instance = SoundInstance::new( + &event, + MASTER, + volume, + pitch, + LegacyRandom::new(seed), + false, + 0, + Attenuation::Linear, + [0.0; 3], + false, + ); + let id = engine.add(instance, true); + engine.resolve(id); + let weight = engine.registry.weight(&event); + let i = engine.instance_mut(id).expect("instance"); + let mut s = line.to_owned(); + match &i.event { + None => s.push_str(" event=none"), + Some(subtitle) => s.push_str(&format!( + " event weight={weight} subtitle={}", + subtitle.as_deref().unwrap_or("-") + )), + } + let v = i.get_volume(); + let p = i.get_pitch(); + let sound = i.sound.clone().expect("resolved"); + let (namespace, path) = sound.path(); + let sentinel = match sound.sentinel { + Sentinel::None => "-", + Sentinel::Empty => "empty", + Sentinel::IntentionallyEmpty => "intentionally_empty", + }; + s.push_str(&format!( + " sound={} path={namespace}:{path} sentinel={sentinel} weight={} type={} stream={} preload={} attenuation={} volume={} pitch={} range={} next={}", + sound.location, + sound.weight, + if sound.event { "SOUND_EVENT" } else { "FILE" }, + sound.stream, + sound.preload, + sound.attenuation_distance, + bits(v), + bits(p), + bits(sound.attenuation_distance(v)), + i.random.next_long() + )); + writeln!(out, "{s}").expect("write"); + engine.release(id); + } +} diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index e5080ea4a..01c135262 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -83,6 +83,8 @@ pub mod settings; pub mod shadows; pub mod signs; pub mod snbt; +pub mod sound_engine; +pub mod sound_events; pub mod sprint; pub mod sprites; pub mod stations; diff --git a/crates/horizon-client/src/sound_engine.rs b/crates/horizon-client/src/sound_engine.rs new file mode 100644 index 000000000..f51545595 --- /dev/null +++ b/crates/horizon-client/src/sound_engine.rs @@ -0,0 +1,936 @@ +//! Vanilla's `SoundEngine` bookkeeping, separated from the mixer: sound +//! instances resolved from the registry with their own random, the channel +//! pools (`Library.CountingChannelPool`), `calculateVolume`/`calculatePitch`, +//! the channel parameters a play sets (gain, pitch, linear attenuation, looping, +//! position, relative), the subtitle notifications, tickable instances, +//! delayed and manually looped sounds, the 20-tick minimum channel lifetime, +//! `isActive`, category gains and stopping by sound or source. +//! +//! The executor is modelled as running every channel task immediately (the +//! sound thread keeps up between ticks); a channel's own end is reported by the +//! backend through `stopped`. +use crate::sound_events::{INTENTIONALLY_EMPTY, Sentinel, Sound, SoundRegistry}; +use horizon_core::random::LegacyRandom; +use std::collections::HashMap; + +/// `SoundSource` indices (vanilla order). +pub const MASTER: usize = 0; +pub const MUSIC: usize = 1; +pub const RECORDS: usize = 2; +pub const AMBIENT: usize = 8; +pub const UI: usize = 10; +/// The pool sizes `Library.init` derives from OpenAL Soft's default 255 mono +/// sources: `clamp((int) sqrt(255), 2, 8)` streaming, the rest static. +pub const STATIC_CHANNELS: usize = 247; +pub const STREAMING_CHANNELS: usize = 8; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PlayResult { + Started, + StartedSilently, + NotStarted, +} +/// `SoundInstance.Attenuation`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Attenuation { + None, + Linear, +} +/// The tickable instance kinds Horizon plays (`TickableSoundInstance`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Ticking { + /// Not tickable. + No, + /// `BiomeAmbientSoundsHandler.LoopSoundInstance`. + BiomeLoop { fade: i32, direction: i32 }, + /// `UnderLiquidAmbientSoundInstance`. + UnderLiquidLoop { fade: i32 }, + /// `UnderLiquidSubSound`. + UnderLiquidSub, +} +/// What tickable instances read about the player. +#[derive(Clone, Copy, Debug, Default)] +pub struct TickContext { + pub player_underwater: bool, + pub player_removed: bool, +} +/// `AbstractSoundInstance` and its subclasses' fields. +#[derive(Clone, Debug)] +pub struct SoundInstance { + pub identifier: String, + pub source: usize, + pub volume: f32, + pub pitch: f32, + pub position: [f64; 3], + pub looping: bool, + pub delay: i32, + pub attenuation: Attenuation, + pub relative: bool, + pub random: LegacyRandom, + pub can_start_silent: bool, + pub ticking: Ticking, + /// `AbstractTickableSoundInstance.stopped`. + pub stopped: bool, + /// `sound` after `getOrResolve`. + pub sound: Option, + /// `soundEvent != null` after `getOrResolve`, and its subtitle. + pub event: Option>, +} +impl SoundInstance { + #[allow(clippy::too_many_arguments)] + pub fn new( + identifier: &str, + source: usize, + volume: f32, + pitch: f32, + random: LegacyRandom, + looping: bool, + delay: i32, + attenuation: Attenuation, + position: [f64; 3], + relative: bool, + ) -> Self { + Self { + identifier: identifier.to_owned(), + source, + volume, + pitch, + position, + looping, + delay, + attenuation, + relative, + random, + can_start_silent: false, + ticking: Ticking::No, + stopped: false, + sound: None, + event: None, + } + } + /// `new SimpleSoundInstance(sound, source, volume, pitch, random, x, y, z)`. + pub fn at( + id: &str, + source: usize, + volume: f32, + pitch: f32, + random: LegacyRandom, + at: [f64; 3], + ) -> Self { + Self::new( + id, + source, + volume, + pitch, + random, + false, + 0, + Attenuation::Linear, + at, + false, + ) + } + /// `SimpleSoundInstance.forUI(sound, pitch, volume)`. + pub fn for_ui(id: &str, pitch: f32, volume: f32, random: LegacyRandom) -> Self { + Self::new( + id, + UI, + volume, + pitch, + random, + false, + 0, + Attenuation::None, + [0.0; 3], + true, + ) + } + /// `SimpleSoundInstance.forMusic`. + pub fn for_music(id: &str, random: LegacyRandom) -> Self { + Self::new( + id, + MUSIC, + 1.0, + 1.0, + random, + false, + 0, + Attenuation::None, + [0.0; 3], + true, + ) + } + /// `SimpleSoundInstance.forJukeboxSong`. + pub fn for_jukebox_song(id: &str, at: [f64; 3], random: LegacyRandom) -> Self { + Self::new( + id, + RECORDS, + 4.0, + 1.0, + random, + false, + 0, + Attenuation::Linear, + at, + false, + ) + } + /// `SimpleSoundInstance.forLocalAmbience`. + pub fn for_local_ambience(id: &str, pitch: f32, volume: f32, random: LegacyRandom) -> Self { + Self::new( + id, + AMBIENT, + volume, + pitch, + random, + false, + 0, + Attenuation::None, + [0.0; 3], + true, + ) + } + /// `SimpleSoundInstance.forAmbientAddition`. + pub fn for_ambient_addition(id: &str, random: LegacyRandom) -> Self { + Self::for_local_ambience(id, 1.0, 1.0, random) + } + /// `SimpleSoundInstance.forAmbientMood`. + pub fn for_ambient_mood(id: &str, random: LegacyRandom, at: [f64; 3]) -> Self { + Self::new( + id, + AMBIENT, + 1.0, + 1.0, + random, + false, + 0, + Attenuation::Linear, + at, + false, + ) + } + /// A relative looping ambient instance (the loop sound constructors). + fn ambient_loop(id: &str, random: LegacyRandom, looping: bool, ticking: Ticking) -> Self { + let mut s = Self::new( + id, + AMBIENT, + 1.0, + 1.0, + random, + looping, + 0, + Attenuation::Linear, + [0.0; 3], + true, + ); + s.ticking = ticking; + s + } + /// `new BiomeAmbientSoundsHandler.LoopSoundInstance(event)`. + pub fn biome_loop(id: &str, random: LegacyRandom) -> Self { + Self::ambient_loop( + id, + random, + true, + Ticking::BiomeLoop { + fade: 0, + direction: 0, + }, + ) + } + /// `UnderLiquidAmbientSoundInstance.underwater(player)`. + pub fn under_liquid_loop(id: &str, random: LegacyRandom) -> Self { + Self::ambient_loop(id, random, true, Ticking::UnderLiquidLoop { fade: 0 }) + } + /// `new UnderLiquidSubSound(player, event)`. + pub fn under_liquid_sub(id: &str, random: LegacyRandom) -> Self { + Self::ambient_loop(id, random, false, Ticking::UnderLiquidSub) + } + pub fn tickable(&self) -> bool { + self.ticking != Ticking::No + } + /// `AbstractSoundInstance.getVolume` (after `getOrResolve`). + pub fn get_volume(&mut self) -> f32 { + let sample = match &self.sound { + Some(s) => s.volume.sample(&mut self.random), + None => 1.0, + }; + self.volume * sample + } + /// `AbstractSoundInstance.getPitch`. + pub fn get_pitch(&mut self) -> f32 { + let sample = match &self.sound { + Some(s) => s.pitch.sample(&mut self.random), + None => 1.0, + }; + self.pitch * sample + } + /// `AbstractTickableSoundInstance.stop`. + fn stop(&mut self) { + self.stopped = true; + self.looping = false; + } + /// `TickableSoundInstance.tick`. + pub fn tick(&mut self, ctx: &TickContext) { + match &mut self.ticking { + Ticking::No => {} + Ticking::BiomeLoop { fade, direction } => { + let stop = *fade < 0; + *fade += *direction; + self.volume = (*fade as f32 / 40.0).clamp(0.0, 1.0); + if stop { + self.stop(); + } + } + Ticking::UnderLiquidLoop { fade } => { + if !ctx.player_removed && *fade >= 0 { + if ctx.player_underwater { + *fade += 1; + } else { + *fade -= 2; + } + *fade = (*fade).min(40); + self.volume = 0.0f32.max((*fade as f32 / 40.0).min(1.0)); + } else { + self.stop(); + } + } + Ticking::UnderLiquidSub => { + if ctx.player_removed || !ctx.player_underwater { + self.stop(); + } + } + } + } + /// `LoopSoundInstance.fadeOut`. + pub fn fade_out(&mut self) { + if let Ticking::BiomeLoop { fade, direction } = &mut self.ticking { + *fade = (*fade).min(40); + *direction = -1; + } + } + /// `LoopSoundInstance.fadeIn`. + pub fn fade_in(&mut self) { + if let Ticking::BiomeLoop { fade, direction } = &mut self.ticking { + *fade = 0.max(*fade); + *direction = 1; + } + } +} +/// A sound instance handle. +pub type InstanceId = u64; +/// A backend channel (an OpenAL source). +pub type ChannelId = u64; +/// The OpenAL side: `Channel` operations, all applied immediately. +pub trait Backend { + /// `Channel.create`. + fn create(&mut self, streaming: bool) -> Option; + /// `Channel.destroy`. + fn destroy(&mut self, channel: ChannelId); + fn set_pitch(&mut self, channel: ChannelId, pitch: f32); + fn set_volume(&mut self, channel: ChannelId, volume: f32); + /// `Some(max distance)` for `linearAttenuation`, None for `disableAttenuation`. + fn set_attenuation(&mut self, channel: ChannelId, linear: Option); + fn set_looping(&mut self, channel: ChannelId, looping: bool); + fn set_position(&mut self, channel: ChannelId, position: [f64; 3]); + fn set_relative(&mut self, channel: ChannelId, relative: bool); + /// Attach the sound's buffer or stream (when loaded) and play it. + fn start(&mut self, channel: ChannelId, sound: &Sound, looping: bool); + fn stop(&mut self, channel: ChannelId); + fn pause(&mut self, channel: ChannelId); + fn unpause(&mut self, channel: ChannelId); + /// `Channel.stopped`. + fn stopped(&mut self, channel: ChannelId) -> bool; +} +/// A `SoundEventListener.onPlaySound` call. +#[derive(Clone, Debug, PartialEq)] +pub struct Notification { + pub identifier: String, + pub subtitle: Option, + pub position: [f64; 3], + pub relative: bool, + pub looping: bool, + pub range: f32, +} +/// `ChannelAccess.ChannelHandle`. +#[derive(Clone, Copy, Debug)] +struct Handle { + channel: Option, + streaming: bool, + stopped: bool, +} +pub struct SoundEngine { + pub registry: SoundRegistry, + /// `loaded`: the library opened. + pub loaded: bool, + tick_count: i32, + instances: HashMap, + /// Instances their owner still refers to. + retained: HashMap, + next_instance: InstanceId, + /// `instanceToChannel` (insertion order). + instance_to_channel: Vec<(InstanceId, u64)>, + handles: HashMap, + next_handle: u64, + /// `instanceBySource`. + instance_by_source: Vec<(usize, InstanceId)>, + gain_by_source: [f32; 11], + ticking: Vec, + /// `queuedSounds` (insertion order). + queued: Vec<(InstanceId, i32)>, + delete_time: HashMap, + queued_tickable: Vec, + used: [usize; 2], + pub limits: [usize; 2], + /// `Options.getSoundSourceVolume` per source (index 0 is master). + pub source_volumes: [f32; 11], + /// A `SoundEventListener` is registered. + pub listening: bool, + pub notifications: Vec, +} +fn handle_channel(h: &Handle) -> Option { + h.channel +} +impl SoundEngine { + pub fn new(registry: SoundRegistry) -> Self { + Self { + registry, + loaded: true, + tick_count: 0, + instances: HashMap::new(), + retained: HashMap::new(), + next_instance: 1, + instance_to_channel: Vec::new(), + handles: HashMap::new(), + next_handle: 1, + instance_by_source: Vec::new(), + gain_by_source: [1.0; 11], + ticking: Vec::new(), + queued: Vec::new(), + delete_time: HashMap::new(), + queued_tickable: Vec::new(), + used: [0, 0], + limits: [STATIC_CHANNELS, STREAMING_CHANNELS], + source_volumes: [1.0; 11], + listening: false, + notifications: Vec::new(), + } + } + /// Hand an instance to the engine. A retained instance stays until + /// `release`; others go once the engine no longer refers to them. + pub fn add(&mut self, instance: SoundInstance, retain: bool) -> InstanceId { + let id = self.next_instance; + self.next_instance += 1; + self.instances.insert(id, instance); + if retain { + self.retained.insert(id, ()); + } + id + } + /// The owner drops its reference. + pub fn release(&mut self, id: InstanceId) { + self.retained.remove(&id); + self.collect(id); + } + fn referenced(&self, id: InstanceId) -> bool { + self.retained.contains_key(&id) + || self.instance_to_channel.iter().any(|(i, _)| *i == id) + || self.queued.iter().any(|(i, _)| *i == id) + || self.queued_tickable.contains(&id) + || self.ticking.contains(&id) + } + fn collect(&mut self, id: InstanceId) { + if !self.referenced(id) { + self.instances.remove(&id); + } + } + pub fn instance(&self, id: InstanceId) -> Option<&SoundInstance> { + self.instances.get(&id) + } + pub fn instance_mut(&mut self, id: InstanceId) -> Option<&mut SoundInstance> { + self.instances.get_mut(&id) + } + pub fn tick_count(&self) -> i32 { + self.tick_count + } + /// `Options.getFinalSoundSourceVolume`. + pub fn final_source_volume(&self, source: usize) -> f32 { + if source == MASTER { + self.source_volumes[MASTER] + } else { + self.source_volumes[source] * self.source_volumes[MASTER] + } + } + /// `calculateVolume(volume, source)`. + pub fn calculate_volume(&self, volume: f32, source: usize) -> f32 { + volume.clamp(0.0, 1.0) + * self.final_source_volume(source).clamp(0.0, 1.0) + * self.gain_by_source[source] + } + /// `calculatePitch`. + pub fn calculate_pitch(pitch: f32) -> f32 { + pitch.clamp(0.5, 2.0) + } + fn channel_of(&self, id: InstanceId) -> Option { + self.instance_to_channel + .iter() + .find(|(i, _)| *i == id) + .map(|(_, h)| *h) + } + /// `refreshCategoryVolume`. + pub fn refresh_category_volume(&mut self, source: usize, backend: &mut impl Backend) { + if !self.loaded { + return; + } + for (id, handle) in self.instance_to_channel.clone() { + let Some(instance) = self.instances.get_mut(&id) else { + continue; + }; + if source == instance.source || source == MASTER { + let (v, s) = (instance.get_volume(), instance.source); + let volume = self.calculate_volume(v, s); + if let Some(c) = self.handles.get(&handle).and_then(handle_channel) { + backend.set_volume(c, volume); + } + } + } + } + /// `updateCategoryVolume`. + pub fn update_category_volume(&mut self, source: usize, gain: f32, backend: &mut impl Backend) { + self.gain_by_source[source] = gain.clamp(0.0, 1.0); + self.refresh_category_volume(source, backend); + } + pub fn category_gain(&self, source: usize) -> f32 { + self.gain_by_source[source] + } + /// `stop(SoundInstance)`. + pub fn stop(&mut self, id: InstanceId, backend: &mut impl Backend) { + if !self.loaded { + return; + } + if let Some(handle) = self.channel_of(id) + && let Some(c) = self.handles.get(&handle).and_then(handle_channel) + { + backend.stop(c); + } + } + /// `ChannelHandle.release`. + fn release_handle(&mut self, handle: u64, backend: &mut impl Backend) { + if let Some(h) = self.handles.get_mut(&handle) { + h.stopped = true; + if let Some(c) = h.channel.take() { + self.used[usize::from(h.streaming)] -= 1; + backend.destroy(c); + } + } + } + /// `stopAll`. + pub fn stop_all(&mut self, backend: &mut impl Backend) { + if !self.loaded { + return; + } + let ids: Vec = self.instances.keys().copied().collect(); + self.instance_to_channel.clear(); + let handles: Vec = self.handles.keys().copied().collect(); + for h in handles { + self.release_handle(h, backend); + } + self.handles.clear(); + self.queued.clear(); + self.ticking.clear(); + self.instance_by_source.clear(); + self.delete_time.clear(); + self.queued_tickable.clear(); + self.gain_by_source = [1.0; 11]; + for id in ids { + self.collect(id); + } + } + /// `stop(Identifier sound, SoundSource source)`. + pub fn stop_matching( + &mut self, + sound: Option<&str>, + source: Option, + backend: &mut impl Backend, + ) { + let matches = |engine: &Self, id: InstanceId| { + sound.is_none_or(|s| engine.instances.get(&id).is_some_and(|i| i.identifier == s)) + }; + if let Some(source) = source { + let ids: Vec = self + .instance_by_source + .iter() + .filter(|(s, _)| *s == source) + .map(|(_, i)| *i) + .collect(); + for id in ids { + if matches(self, id) { + self.stop(id, backend); + } + } + } else if sound.is_none() { + self.stop_all(backend); + } else { + let ids: Vec = self.instance_to_channel.iter().map(|(i, _)| *i).collect(); + for id in ids { + if matches(self, id) { + self.stop(id, backend); + } + } + } + } + /// `getRange`. + fn range(relative: bool, attenuation: Attenuation, distance: f32) -> f32 { + if !relative && attenuation != Attenuation::None { + distance + } else { + f32::INFINITY + } + } + fn notify(&mut self, id: InstanceId, subtitle: Option, distance: f32) { + let Some(i) = self.instances.get(&id) else { + return; + }; + self.notifications.push(Notification { + identifier: i.identifier.clone(), + subtitle, + position: i.position, + relative: i.relative, + looping: i.looping, + range: Self::range(i.relative, i.attenuation, distance), + }); + } + /// `getOrResolve`. + pub fn resolve(&mut self, id: InstanceId) { + let Some(instance) = self.instances.get_mut(&id) else { + return; + }; + if instance.identifier == INTENTIONALLY_EMPTY { + instance.sound = Some(Sound::intentionally_empty()); + instance.event = Some(None); + return; + } + match self.registry.get(&instance.identifier) { + None => { + instance.event = None; + instance.sound = Some(Sound::empty()); + } + Some(event) => { + instance.event = Some(event.subtitle.clone()); + instance.sound = self + .registry + .sound(&instance.identifier, &mut instance.random); + } + } + } + /// `play(SoundInstance)`. + pub fn play(&mut self, id: InstanceId, backend: &mut impl Backend) -> PlayResult { + let result = self.play_inner(id, backend); + self.collect(id); + result + } + /// Add and play an instance. + pub fn play_new( + &mut self, + instance: SoundInstance, + retain: bool, + backend: &mut impl Backend, + ) -> (InstanceId, PlayResult) { + let id = self.add(instance, retain); + (id, self.play(id, backend)) + } + fn play_inner(&mut self, id: InstanceId, backend: &mut impl Backend) -> PlayResult { + if !self.loaded || !self.instances.contains_key(&id) { + return PlayResult::NotStarted; + } + self.resolve(id); + let instance = &self.instances[&id]; + let Some(subtitle) = instance.event.clone() else { + return PlayResult::NotStarted; + }; + let sound = instance.sound.clone().unwrap_or_else(Sound::empty); + if sound.sentinel != Sentinel::None { + return PlayResult::NotStarted; + } + let instance = self.instances.get_mut(&id).expect("present"); + let instance_volume = instance.get_volume(); + let attenuation_distance = sound.attenuation_distance(instance_volume); + if self.listening { + self.notify(id, subtitle, attenuation_distance); + } + let instance = &self.instances[&id]; + let source = instance.source; + let volume = self.calculate_volume(instance_volume, source); + let mut silently = false; + if volume == 0.0 { + if !instance.can_start_silent && source != MUSIC { + return PlayResult::NotStarted; + } + silently = true; + } + let position = instance.position; + let looping = instance.looping && instance.delay <= 0; + let streaming = sound.stream; + let pool = usize::from(streaming); + let channel = if self.used[pool] >= self.limits[pool] { + None + } else { + backend.create(streaming) + }; + let Some(channel) = channel else { + return PlayResult::NotStarted; + }; + self.used[pool] += 1; + let handle = self.next_handle; + self.next_handle += 1; + self.handles.insert( + handle, + Handle { + channel: Some(channel), + streaming, + stopped: false, + }, + ); + self.delete_time.insert(id, self.tick_count + 20); + if let Some(entry) = self.instance_to_channel.iter_mut().find(|(i, _)| *i == id) { + entry.1 = handle; + } else { + self.instance_to_channel.push((id, handle)); + } + if !self.instance_by_source.contains(&(source, id)) { + self.instance_by_source.push((source, id)); + } + let instance = self.instances.get_mut(&id).expect("present"); + let pitch = Self::calculate_pitch(instance.get_pitch()); + let (attenuation, relative, tickable) = + (instance.attenuation, instance.relative, instance.tickable()); + backend.set_pitch(channel, pitch); + backend.set_volume(channel, volume); + backend.set_attenuation( + channel, + (attenuation == Attenuation::Linear).then_some(attenuation_distance), + ); + backend.set_looping(channel, looping && !streaming); + backend.set_position(channel, position); + backend.set_relative(channel, relative); + backend.start(channel, &sound, looping); + if tickable { + self.ticking.push(id); + } + if silently { + PlayResult::StartedSilently + } else { + PlayResult::Started + } + } + /// `queueTickingSound`. + pub fn queue_ticking(&mut self, id: InstanceId) { + self.queued_tickable.push(id); + } + /// `playDelayed`. + pub fn play_delayed(&mut self, id: InstanceId, delay: i32) { + let at = self.tick_count + delay; + if let Some(entry) = self.queued.iter_mut().find(|(i, _)| *i == id) { + entry.1 = at; + } else { + self.queued.push((id, at)); + } + } + /// `isActive`. + pub fn is_active(&self, id: InstanceId) -> bool { + self.loaded && self.channel_of(id).is_some() + } + /// `tick(paused)`, then `ChannelAccess.scheduleTick`. + pub fn tick(&mut self, paused: bool, ctx: &TickContext, backend: &mut impl Backend) { + if !paused { + self.tick_in_game(ctx, backend); + } else { + self.tick_music_when_paused(); + } + self.schedule_tick(backend); + } + fn schedule_tick(&mut self, backend: &mut impl Backend) { + let live: Vec<(u64, ChannelId)> = self + .handles + .iter() + .filter_map(|(h, handle)| handle.channel.map(|c| (*h, c))) + .collect(); + for (h, c) in live { + if backend.stopped(c) { + self.release_handle(h, backend); + } + } + // Released handles nobody maps any more. + let mapped: Vec = self.instance_to_channel.iter().map(|(_, h)| *h).collect(); + self.handles + .retain(|h, handle| handle.channel.is_some() || mapped.contains(h)); + } + fn handle_stopped(&self, handle: u64) -> bool { + self.handles.get(&handle).is_none_or(|h| h.stopped) + } + fn tick_in_game(&mut self, ctx: &TickContext, backend: &mut impl Backend) { + self.tick_count += 1; + for id in std::mem::take(&mut self.queued_tickable) { + self.play(id, backend); + } + for id in self.ticking.clone() { + let Some(instance) = self.instances.get_mut(&id) else { + continue; + }; + instance.tick(ctx); + if instance.stopped { + self.stop(id, backend); + continue; + } + let (v, source) = (instance.get_volume(), instance.source); + let pitch = Self::calculate_pitch(instance.get_pitch()); + let position = instance.position; + let volume = self.calculate_volume(v, source); + let Some(handle) = self.channel_of(id) else { + continue; + }; + if let Some(c) = self.handles.get(&handle).and_then(handle_channel) { + backend.set_volume(c, volume); + backend.set_pitch(c, pitch); + backend.set_position(c, position); + } + let instance = self.instances.get_mut(&id).expect("present"); + if instance.looping + && self.tick_count % 10 == 0 + && volume > 0.0 + && self.listening + && let (Some(sound), Some(subtitle)) = + (instance.sound.clone(), instance.event.clone()) + { + let distance = sound.attenuation_distance(instance.get_volume()); + self.notify(id, subtitle, distance); + } + } + let mut removed = Vec::new(); + let mut index = 0; + while index < self.instance_to_channel.len() { + let (id, handle) = self.instance_to_channel[index]; + if self.handle_stopped(handle) + && self.delete_time.get(&id).copied().unwrap_or(0) <= self.tick_count + { + if let Some(i) = self.instances.get(&id) + && i.looping + && i.delay > 0 + { + let at = self.tick_count + i.delay; + if let Some(entry) = self.queued.iter_mut().find(|(q, _)| *q == id) { + entry.1 = at; + } else { + self.queued.push((id, at)); + } + } + self.instance_to_channel.remove(index); + self.handles.remove(&handle); + self.delete_time.remove(&id); + let source = self.instances.get(&id).map(|i| i.source); + self.instance_by_source + .retain(|e| Some(e.0) != source || e.1 != id); + if let Some(p) = self.ticking.iter().position(|t| *t == id) { + self.ticking.remove(p); + } + removed.push(id); + } else { + index += 1; + } + } + let mut index = 0; + while index < self.queued.len() { + let (id, at) = self.queued[index]; + if self.tick_count >= at { + self.queued.remove(index); + if let Some(i) = self.instances.get_mut(&id) + && i.tickable() + { + i.tick(ctx); + } + self.play(id, backend); + } else { + index += 1; + } + } + for id in removed { + self.collect(id); + } + } + fn tick_music_when_paused(&mut self) { + let mut index = 0; + while index < self.instance_to_channel.len() { + let (id, handle) = self.instance_to_channel[index]; + let music = self.instances.get(&id).is_some_and(|i| i.source == MUSIC); + if music && self.handle_stopped(handle) { + self.instance_to_channel.remove(index); + self.handles.remove(&handle); + self.delete_time.remove(&id); + self.instance_by_source + .retain(|e| e.0 != MUSIC || e.1 != id); + self.collect(id); + } else { + index += 1; + } + } + } + /// `pauseAllExcept`. + pub fn pause_all_except(&mut self, sources: &[usize], backend: &mut impl Backend) { + if !self.loaded { + return; + } + for (id, handle) in self.instance_to_channel.clone() { + let source = self.instances.get(&id).map_or(MASTER, |i| i.source); + if !sources.contains(&source) + && let Some(c) = self.handles.get(&handle).and_then(handle_channel) + { + backend.pause(c); + } + } + } + /// `resume`. + pub fn resume(&mut self, backend: &mut impl Backend) { + if !self.loaded { + return; + } + let channels: Vec = self.handles.values().filter_map(handle_channel).collect(); + for c in channels { + backend.unpause(c); + } + } + /// Channels in use (static, streaming). + pub fn used_channels(&self) -> [usize; 2] { + self.used + } + /// The live channel of an instance. + pub fn channel(&self, id: InstanceId) -> Option { + self.channel_of(id) + .and_then(|h| self.handles.get(&h)) + .and_then(handle_channel) + } + /// Instances with a channel, in insertion order. + pub fn active_instances(&self) -> Vec { + self.instance_to_channel.iter().map(|(i, _)| *i).collect() + } + /// A fresh unseeded random (`RandomSource.create()`). + pub fn unseeded() -> LegacyRandom { + LegacyRandom::new(unique_seed()) + } +} +/// `RandomSupport.generateUniqueSeed`. +pub fn unique_seed() -> i64 { + use std::sync::atomic::{AtomicI64, Ordering}; + static UNIQUIFIER: AtomicI64 = AtomicI64::new(8_682_522_807_148_012); + let mut current = UNIQUIFIER.load(Ordering::Relaxed); + let next = loop { + let next = current.wrapping_mul(1_181_783_497_276_652_981); + match UNIQUIFIER.compare_exchange(current, next, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => break next, + Err(v) => current = v, + } + }; + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos() as i64); + next ^ nanos +} diff --git a/crates/horizon-client/src/sound_events.rs b/crates/horizon-client/src/sound_events.rs new file mode 100644 index 000000000..6a5e179c2 --- /dev/null +++ b/crates/horizon-client/src/sound_events.rs @@ -0,0 +1,385 @@ +//! `sounds.json` sound events as vanilla's `SoundManager.Preparations` builds +//! them: `WeighedSoundEvents` holding files and references to other events +//! (whose weight is the referenced event's total weight, resolved lazily), the +//! `replace` and `subtitle` fields, files that do not exist being dropped, and +//! `WeighedSoundEvents.getSound` drawing `nextInt(totalWeight)` from the sound +//! instance's random. +use horizon_core::random::RandomSource; +use serde_json::Value; +use std::{collections::HashMap, fs, path::Path}; + +/// `SoundManager.EMPTY_SOUND_LOCATION`. +pub const EMPTY: &str = "minecraft:empty"; +/// `SoundManager.INTENTIONALLY_EMPTY_SOUND_LOCATION`. +pub const INTENTIONALLY_EMPTY: &str = "minecraft:intentionally_empty"; + +/// `SampledFloat` as sounds use it: constants and `MultipliedFloats`. +#[derive(Clone, Debug, PartialEq)] +pub enum Sampled { + Constant(f32), + Product(Vec), +} +impl Sampled { + /// `SampledFloat.sample`: constants draw nothing. + pub fn sample(&self, random: &mut impl RandomSource) -> f32 { + match self { + Self::Constant(v) => *v, + Self::Product(values) => { + let mut result = 1.0f32; + for v in values { + result *= v.sample(random); + } + result + } + } + } +} +/// `Sound`. +#[derive(Clone, Debug, PartialEq)] +pub struct Sound { + /// The file identifier (`namespace:path`, under `sounds/` with `.ogg`). + pub location: String, + pub volume: Sampled, + pub pitch: Sampled, + pub weight: i32, + /// `Sound.Type.SOUND_EVENT` (a reference to another event). + pub event: bool, + pub stream: bool, + pub preload: bool, + pub attenuation_distance: i32, + /// The `EMPTY_SOUND` / `INTENTIONALLY_EMPTY_SOUND` singletons (vanilla + /// compares them by identity). + pub sentinel: Sentinel, +} +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Sentinel { + #[default] + None, + Empty, + IntentionallyEmpty, +} +impl Sound { + fn file(location: &str) -> Self { + Self { + location: location.to_owned(), + volume: Sampled::Constant(1.0), + pitch: Sampled::Constant(1.0), + weight: 1, + event: false, + stream: false, + preload: false, + attenuation_distance: 16, + sentinel: Sentinel::None, + } + } + /// `SoundManager.EMPTY_SOUND`. + pub fn empty() -> Self { + Self { + sentinel: Sentinel::Empty, + ..Self::file(EMPTY) + } + } + /// `SoundManager.INTENTIONALLY_EMPTY_SOUND`. + pub fn intentionally_empty() -> Self { + Self { + sentinel: Sentinel::IntentionallyEmpty, + ..Self::file(INTENTIONALLY_EMPTY) + } + } + /// `Sound.getAttenuationDistance(volume)`. + pub fn attenuation_distance(&self, volume: f32) -> f32 { + volume.max(1.0) * self.attenuation_distance as f32 + } + /// `Sound.getPath`: the resource path (`namespace`, `sounds/.ogg`). + pub fn path(&self) -> (&str, String) { + let (namespace, path) = split(&self.location); + (namespace, format!("sounds/{path}.ogg")) + } +} +/// `WeighedSoundEvents`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct WeighedSoundEvents { + /// Files, or references to events (`Sound.event`). + pub sounds: Vec, + /// The subtitle translation key. + pub subtitle: Option, +} +/// The sound event registry (`SoundManager.registry`). +#[derive(Clone, Debug, Default)] +pub struct SoundRegistry { + events: HashMap, +} +/// `Identifier.parse` with the default namespace. +fn split(id: &str) -> (&str, &str) { + match id.split_once(':') { + Some((namespace, path)) => ( + if namespace.is_empty() { + "minecraft" + } else { + namespace + }, + path, + ), + None => ("minecraft", id), + } +} +pub fn normalize(id: &str) -> String { + let (namespace, path) = split(id); + format!("{namespace}:{path}") +} +/// `GsonHelper.getAsFloat`: Gson parses the number's text as a float. +fn json_float(v: &Value, fallback: f32) -> f32 { + match v { + Value::Number(n) => n.to_string().parse().unwrap_or(fallback), + Value::String(s) => s.parse().unwrap_or(fallback), + _ => fallback, + } +} +fn json_int(v: &Value, fallback: i32) -> i32 { + match v { + Value::Number(n) => n + .as_i64() + .map(|i| i as i32) + .or_else(|| n.as_f64().map(|f| f as i32)) + .unwrap_or(fallback), + Value::String(s) => s.parse().unwrap_or(fallback), + _ => fallback, + } +} +fn json_bool(v: &Value, fallback: bool) -> bool { + match v { + Value::Bool(b) => *b, + Value::String(s) => s == "true", + _ => fallback, + } +} +/// `SoundEventRegistrationSerializer.getSound`; None for an invalid entry +/// (vanilla rejects the whole file). +fn parse_sound(v: &Value) -> Option { + if let Value::String(name) = v { + return Some(Sound::file(&normalize(name))); + } + let name = v.get("name")?.as_str()?; + let event = match v.get("type") { + None => false, + Some(t) => match t.as_str()? { + "file" => false, + "event" => true, + _ => return None, + }, + }; + let volume = json_float(v.get("volume").unwrap_or(&Value::Null), 1.0); + let pitch = json_float(v.get("pitch").unwrap_or(&Value::Null), 1.0); + let weight = json_int(v.get("weight").unwrap_or(&Value::Null), 1); + if !(volume > 0.0 && pitch > 0.0 && weight > 0) { + return None; + } + Some(Sound { + location: normalize(name), + volume: Sampled::Constant(volume), + pitch: Sampled::Constant(pitch), + weight, + event, + stream: json_bool(v.get("stream").unwrap_or(&Value::Null), false), + preload: json_bool(v.get("preload").unwrap_or(&Value::Null), false), + attenuation_distance: json_int(v.get("attenuation_distance").unwrap_or(&Value::Null), 16), + sentinel: Sentinel::None, + }) +} +impl SoundRegistry { + /// Every `assets//sounds.json` under a resource root, files + /// validated against `assets//sounds/`. + pub fn load(root: &Path) -> Self { + let assets = root.join("assets"); + let mut namespaces: Vec = fs::read_dir(&assets) + .into_iter() + .flatten() + .flatten() + .filter_map(|e| e.file_name().to_str().map(str::to_owned)) + .collect(); + namespaces.sort(); + let mut registry = Self::default(); + for namespace in namespaces { + let Ok(text) = fs::read(assets.join(&namespace).join("sounds.json")) else { + continue; + }; + let exists = |ns: &str, path: &str| assets.join(ns).join(path).is_file(); + registry.add_file(&namespace, &text, &exists); + } + registry + } + /// One `sounds.json` (`Preparations.handleRegistration` for each entry). + /// `exists(namespace, path)` says whether a sound file is present. + pub fn add_file(&mut self, namespace: &str, text: &[u8], exists: &dyn Fn(&str, &str) -> bool) { + let Ok(Value::Object(map)) = serde_json::from_slice::(text) else { + return; + }; + // A malformed entry makes Gson reject the whole file. + let mut parsed = Vec::new(); + for (name, def) in &map { + let Value::Object(object) = def else { return }; + let replace = json_bool(object.get("replace").unwrap_or(&Value::Null), false); + let subtitle = match object.get("subtitle") { + None | Some(Value::Null) => None, + Some(Value::String(s)) => Some(s.clone()), + Some(_) => return, + }; + let mut sounds = Vec::new(); + if let Some(list) = object.get("sounds") { + let Value::Array(list) = list else { return }; + for s in list { + let Some(sound) = parse_sound(s) else { return }; + sounds.push(sound); + } + } + parsed.push((format!("{namespace}:{name}"), replace, subtitle, sounds)); + } + for (location, replace, subtitle, sounds) in parsed { + let entry = match self.events.get_mut(&location) { + Some(entry) if !replace => entry, + _ => { + self.events.insert( + location.clone(), + WeighedSoundEvents { + sounds: Vec::new(), + subtitle, + }, + ); + self.events.get_mut(&location).expect("inserted") + } + }; + for sound in sounds { + if !sound.event { + let (ns, path) = sound.path(); + if !exists(ns, &path) { + continue; + } + } + entry.sounds.push(sound); + } + } + } + pub fn get(&self, event: &str) -> Option<&WeighedSoundEvents> { + self.events.get(event) + } + pub fn contains(&self, event: &str) -> bool { + self.events.contains_key(event) + } + /// Every registered event id. + pub fn names(&self) -> impl Iterator { + self.events.keys() + } + /// `Weighted.getWeight` of one entry. + fn entry_weight(&self, sound: &Sound, depth: u32) -> i32 { + if !sound.event { + return sound.weight; + } + if depth > 64 { + return 0; + } + self.events + .get(&sound.location) + .map_or(0, |e| self.total(e, depth + 1)) + } + fn total(&self, event: &WeighedSoundEvents, depth: u32) -> i32 { + event + .sounds + .iter() + .fold(0i32, |sum, s| sum.wrapping_add(self.entry_weight(s, depth))) + } + /// `WeighedSoundEvents.getWeight`. + pub fn weight(&self, event: &str) -> i32 { + self.events.get(event).map_or(0, |e| self.total(e, 0)) + } + /// `WeighedSoundEvents.getSound`: None when the event is not registered. + pub fn sound(&self, event: &str, random: &mut impl RandomSource) -> Option { + let e = self.events.get(event)?; + Some(self.pick(e, random, 0)) + } + fn pick( + &self, + event: &WeighedSoundEvents, + random: &mut impl RandomSource, + depth: u32, + ) -> Sound { + let weight = self.total(event, depth); + if event.sounds.is_empty() || weight == 0 { + return Sound::empty(); + } + let Ok(mut index) = random.next_int_bound(weight) else { + return Sound::empty(); + }; + for sound in &event.sounds { + index -= self.entry_weight(sound, depth); + if index < 0 { + if !sound.event { + return sound.clone(); + } + // The anonymous reference Weighted: the referenced event's + // sound with this entry's volume, pitch and stream folded in. + let Some(inner) = self.events.get(&sound.location) else { + return Sound::empty(); + }; + if depth > 64 { + return Sound::empty(); + } + let wrapped = self.pick(inner, random, depth + 1); + return Sound { + location: wrapped.location, + volume: Sampled::Product(vec![wrapped.volume, sound.volume.clone()]), + pitch: Sampled::Product(vec![wrapped.pitch, sound.pitch.clone()]), + weight: sound.weight, + event: false, + stream: wrapped.stream || sound.stream, + preload: wrapped.preload, + attenuation_distance: wrapped.attenuation_distance, + sentinel: Sentinel::None, + }; + } + } + Sound::empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use horizon_core::random::LegacyRandom; + + #[test] + fn references_weigh_as_much_as_their_event() { + let mut r = SoundRegistry::default(); + let json = br#"{ + "a": {"sounds": ["x", "y", "z"]}, + "b": {"sounds": [{"name": "a", "type": "event", "weight": 9, "volume": 0.5}, "w"], "subtitle": "s.b"} + }"#; + r.add_file("minecraft", json, &|_, _| true); + assert_eq!(r.weight("minecraft:b"), 4); + let mut random = LegacyRandom::new(1); + let mut seen = std::collections::HashSet::new(); + for _ in 0..64 { + let s = r.sound("minecraft:b", &mut random).unwrap(); + if s.location != "minecraft:w" { + assert_eq!(s.volume.sample(&mut random), 0.5); + } + seen.insert(s.location); + } + assert_eq!(seen.len(), 4); + assert_eq!( + r.get("minecraft:b").unwrap().subtitle.as_deref(), + Some("s.b") + ); + } + + #[test] + fn missing_files_are_dropped_and_empty_events_give_the_empty_sound() { + let mut r = SoundRegistry::default(); + r.add_file("minecraft", br#"{"a": {"sounds": ["gone"]}}"#, &|_, _| { + false + }); + assert_eq!(r.weight("minecraft:a"), 0); + let mut random = LegacyRandom::new(1); + assert_eq!(r.sound("minecraft:a", &mut random).unwrap(), Sound::empty()); + assert!(r.sound("minecraft:b", &mut random).is_none()); + } +} diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaSoundEventOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaSoundEventOracle.java new file mode 100644 index 000000000..b02ef2783 --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaSoundEventOracle.java @@ -0,0 +1,111 @@ +package dev.horizonlab.parity; + +import com.google.gson.*; +import java.io.*; +import java.lang.reflect.*; +import java.nio.file.*; +import java.util.*; +import net.minecraft.client.Options; +import net.minecraft.client.resources.sounds.*; +import net.minecraft.client.sounds.*; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.contents.TranslatableContents; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.*; +import net.minecraft.server.packs.repository.PackSource; +import net.minecraft.server.packs.resources.MultiPackResourceManager; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; +import net.minecraft.util.profiling.InactiveProfiler; + +/** + * Builds vanilla's sound event registry with the real SoundManager.prepare + * (sounds.json read through a resource manager over the client asset root, + * files validated against the listed .ogg resources) and Preparations.apply, + * then resolves SimpleSoundInstances: getOrResolve with a seeded random, + * getVolume/getPitch, and the instance random's next draw (to fix the number + * of draws). Lines: "event seed volume pitch". + */ +public final class VanillaSoundEventOracle { + static Field field(Class type, String name) throws Exception { + var f = type.getDeclaredField(name); + f.setAccessible(true); + return f; + } + + static String bits(float v) { + return String.format("%08x", Float.floatToRawIntBits(v)); + } + + static String sentinel(Sound sound) { + if (sound == SoundManager.EMPTY_SOUND) return "empty"; + if (sound == SoundManager.INTENTIONALLY_EMPTY_SOUND) return "intentionally_empty"; + return "-"; + } + + /** The real registry, prepared from an asset root. */ + @SuppressWarnings("unchecked") + static SoundManager load(Path root) throws Exception { + var info = new PackLocationInfo("horizon", Component.literal("horizon"), PackSource.BUILT_IN, Optional.empty()); + ResourceManager resources = new MultiPackResourceManager(PackType.CLIENT_RESOURCES, List.of(new PathPackResources(info, root))); + var u = Class.forName("sun.misc.Unsafe"); + Object unsafe = field(u, "theUnsafe").get(null); + Options options = (Options) u.getMethod("allocateInstance", Class.class).invoke(unsafe, Options.class); + var manager = new SoundManager(options); + Method prepare = SoundManager.class.getDeclaredMethod("prepare", ResourceManager.class, net.minecraft.util.profiling.ProfilerFiller.class); + prepare.setAccessible(true); + Object preparations = prepare.invoke(manager, resources, InactiveProfiler.INSTANCE); + Method apply = preparations.getClass().getDeclaredMethod("apply", Map.class, Map.class, SoundEngine.class); + apply.setAccessible(true); + apply.invoke(preparations, field(SoundManager.class, "registry").get(manager), field(SoundManager.class, "soundCache").get(manager), + field(SoundManager.class, "soundEngine").get(manager)); + return manager; + } + + public static void main(String[] args) throws Exception { + net.minecraft.SharedConstants.tryDetectVersion(); + net.minecraft.server.Bootstrap.bootStrap(); + var manager = load(Path.of(args[0])); + try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[2])))) { + var names = new ArrayList(); + for (var id : manager.getAvailableSounds()) names.add(id.toString()); + Collections.sort(names); + for (String name : names) out.println("registered " + name); + for (String line : Files.readAllLines(Path.of(args[1]))) { + String[] f = line.split(" "); + long seed = Long.parseLong(f[1]); + float volume = Float.intBitsToFloat(Integer.parseUnsignedInt(f[2], 16)); + float pitch = Float.intBitsToFloat(Integer.parseUnsignedInt(f[3], 16)); + RandomSource random = RandomSource.create(seed); + var instance = new SimpleSoundInstance(Identifier.parse(f[0]), SoundSource.MASTER, volume, pitch, random, false, 0, + SoundInstance.Attenuation.LINEAR, 0.0, 0.0, 0.0, false); + WeighedSoundEvents event = instance.getOrResolve(manager); + Sound sound = instance.getSound(); + StringBuilder sb = new StringBuilder(line); + if (event == null) { + sb.append(" event=none"); + } else { + String subtitle = "-"; + if (event.getSubtitle() != null && event.getSubtitle().getContents() instanceof TranslatableContents t) subtitle = t.getKey(); + sb.append(" event weight=").append(event.getWeight()).append(" subtitle=").append(subtitle); + } + float v = instance.getVolume(); + float p = instance.getPitch(); + sb.append(" sound=").append(sound.getLocation()).append(" path=").append(sound.getPath()) + .append(" sentinel=").append(sentinel(sound)) + .append(" weight=").append(sound.getWeight()) + .append(" type=").append(sound.getType().name()) + .append(" stream=").append(sound.shouldStream()) + .append(" preload=").append(sound.shouldPreload()) + .append(" attenuation=").append(sound.getAttenuationDistance()) + .append(" volume=").append(bits(v)) + .append(" pitch=").append(bits(p)) + .append(" range=").append(bits(sound.getAttenuationDistance(v))) + .append(" next=").append(random.nextLong()); + out.println(sb); + } + } + System.exit(0); + } +} diff --git a/tools/audio_harness.py b/tools/audio_harness.py new file mode 100644 index 000000000..bfd18e05e --- /dev/null +++ b/tools/audio_harness.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Shared runner for the audio differential harnesses. + +Each harness writes an input file, runs a Java oracle against the pinned +client (the real SoundManager / SoundEngine / MusicManager / ambient handlers; +OpenAL runs on OpenAL Soft's "No Output" device so channel parameters can be +read back from real AL sources) and a Rust example replaying the same inputs +through Horizon's audio code, then requires byte-identical output. +""" +import json +import os +from pathlib import Path +import platform +import subprocess + +try: + from . import lab, test_rust_parity as base, test_model_baking_parity as baking +except ImportError: + import lab, test_rust_parity as base, test_model_baking_parity as baking + +RESOURCES = lab.LOCAL / 'horizon-client/26.3' +COMMON_SOURCES = [ + lab.ROOT / 'tools/audio_harness.py', + lab.ROOT / 'tools/test_rust_parity.py', + lab.ROOT / 'tools/lab.py', + lab.ROOT / 'Cargo.toml', + lab.ROOT / 'Cargo.lock', + lab.ROOT / 'crates/horizon-client/Cargo.toml', + lab.ROOT / 'crates/horizon-client/src/sound_events.rs', + lab.ROOT / 'crates/horizon-client/src/sound_engine.rs', +] + + +def require_sounds(): + if not (RESOURCES / 'assets/minecraft/sounds.json').is_file(): + raise SystemExit('Sound assets missing: run tools/fetch_sounds.py first') + + +def run_harness(name, oracle, example, inputs_text, extra_sources=(), java_args=(), rust_args=(), scope='', timeout=1800): + """Run oracle and example on inputs; returns the number of matched lines.""" + require_sounds() + lock, jars, _ = base.verify_artifacts() + libraries = baking.client_libraries() + build = lab.LOCAL / f'rust-{name}' + build.mkdir(parents=True, exist_ok=True) + report = build / 'report.json' + report.unlink(missing_ok=True) + oracle_path = lab.ROOT / f'mods/horizon/src/test/java/dev/horizonlab/parity/{oracle}.java' + example_path = lab.ROOT / f'crates/horizon-client/examples/{example}.rs' + sources = [*COMMON_SOURCES, oracle_path, example_path, *[Path(s) for s in extra_sources]] + tested = base.source_hashes(sources) + inputs = build / 'inputs.txt' + inputs.write_text(inputs_text) + input_hash = lab.digest(inputs, 'sha256') + + def run(command, log, env=None): + with (build / (log + '.log')).open('w') as out: + subprocess.run(list(map(str, command)), cwd=lab.ROOT, check=True, timeout=timeout, stdout=out, stderr=subprocess.STDOUT, env=env) + + classes = build / 'classes' + classes.mkdir(exist_ok=True) + dependencies = os.pathsep.join(map(str, [jars['client'], *libraries])) + run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-sourcepath', lab.ROOT / 'mods/horizon/src/test/java', + '-cp', dependencies, '-d', classes, oracle_path], 'compile-java') + run(['cargo', 'build', '--locked', '-p', 'horizon-client', '--example', example], 'compile-rust') + metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--locked', '--no-deps', '--format-version', '1'], cwd=lab.ROOT)) + binary = Path(metadata['target_directory']) / 'debug/examples' / (example + ('.exe' if os.name == 'nt' else '')) + run([binary, *rust_args, inputs, build / 'rust.txt'], 'rust') + env = dict(os.environ, ALSOFT_DRIVERS='null') + run(['java', '-Xmx1G', '-cp', str(classes) + os.pathsep + dependencies, f'dev.horizonlab.parity.{oracle}', *java_args, inputs, build / 'client.txt'], + 'client', env=env) + matched = base.compare(build / 'client.txt', build / 'rust.txt') + base.ensure_sources_unchanged(sources, tested) + if input_hash != lab.digest(inputs, 'sha256'): + raise AssertionError('Input fixtures changed') + lab.write_json(report, dict( + schema=1, minecraft=lock['minecraft']['version'], manifest_sha1=lock['minecraft']['manifest_sha1'], platform=platform.platform(), + rustc=subprocess.check_output(['rustc', '--version'], text=True).strip(), + java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0], + sources_sha256=tested, input_sha256=input_hash, client_jar_sha256=lab.digest(jars['client'], 'sha256'), + sounds_json_sha256=lab.digest(RESOURCES / 'assets/minecraft/sounds.json', 'sha256'), + matched_output_lines=matched, output_sha256=lab.digest(build / 'rust.txt', 'sha256'), scope=scope)) + return matched, report + + +def bits(value): + import struct + return struct.pack('>f', value).hex() diff --git a/tools/test_sound_event_parity.py b/tools/test_sound_event_parity.py new file mode 100644 index 000000000..49dc98a96 --- /dev/null +++ b/tools/test_sound_event_parity.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Compare sound event resolution with the vanilla 26.3 client. + +The oracle builds the real `SoundManager` registry from the client asset root +(`SoundManager.prepare` + `Preparations.apply`: sounds.json, `replace`, +subtitles, file validation against the listed .ogg resources, event references +weighing as much as their event) and resolves seeded `SimpleSoundInstance`s: +`getOrResolve`, `getVolume`, `getPitch`, `Sound.getAttenuationDistance`, the +stream/preload/type fields and the instance random's next draw. Horizon's +`SoundRegistry` + `SoundEngine::resolve` replay the same inputs. +""" +import json +import random + +try: + from . import audio_harness as h +except ImportError: + import audio_harness as h + + +def fixtures(): + sounds = json.loads((h.RESOURCES / 'assets/minecraft/sounds.json').read_text()) + rng = random.Random(0x534f554e44) + volumes = [1.0, 0.5, 0.25, 2.0, 4.0, 0.0, 1e-3, 16.0] + pitches = [1.0, 0.5, 2.0, 0.8, 1.2, 3.0, 0.1] + lines = [] + for name in sorted(sounds): + event = 'minecraft:' + name + count = len(sounds[name].get('sounds', [])) + seeds = [0, 1, -1, 42] + [rng.getrandbits(64) - (1 << 63) for _ in range(max(4, 2 * count))] + for seed in seeds: + lines.append(f'{event} {seed} {h.bits(rng.choice(volumes))} {h.bits(rng.choice(pitches))}') + for event in ['minecraft:intentionally_empty', 'minecraft:empty', 'minecraft:not.a.sound', 'horizon:missing', 'block.stone.break', 'music.game']: + for seed in [0, 7, -99]: + lines.append(f'{event} {seed} {h.bits(1.0)} {h.bits(1.0)}') + return lines + + +def main(): + lines = fixtures() + matched, report = h.run_harness( + 'sound-events', 'VanillaSoundEventOracle', 'sound_event_fixture', ''.join(l + '\n' for l in lines), + java_args=[h.RESOURCES], rust_args=[h.RESOURCES], + scope='Real SoundManager.prepare/Preparations.apply over the client asset root and SimpleSoundInstance.getOrResolve/getVolume/getPitch ' + 'for every sounds.json event with seeded randoms (weighted choice, nested event references, MultipliedFloats volume/pitch, ' + 'stream/preload/attenuation fields, EMPTY and INTENTIONALLY_EMPTY sentinels, draw counts) and the full registered event list.') + print(f'{matched:,} sound event lines match the pinned client ({len(lines):,} resolutions): {report}', flush=True) + + +if __name__ == '__main__': + main()