Resolve sound events exactly as vanilla SoundManager does

Add SoundRegistry (sounds.json with replace, subtitles, file validation and
event references weighing as much as their event) and a SoundEngine model,
with a differential harness resolving every sounds.json event against the
real SoundManager.prepare and SimpleSoundInstance.getOrResolve.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-10 21:16:28 +02:00
1 parent 1e03d531eb
commit ad4d836f6f
7 files changed
+1651

No files matched your search

@@ -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<String> = 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<String> = 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);
}
}
+2
View File
@@ -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;
+936
View File
@@ -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<Sound>,
/// `soundEvent != null` after `getOrResolve`, and its subtitle.
pub event: Option<Option<String>>,
}
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<ChannelId>;
/// `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<f32>);
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<String>,
pub position: [f64; 3],
pub relative: bool,
pub looping: bool,
pub range: f32,
}
/// `ChannelAccess.ChannelHandle`.
#[derive(Clone, Copy, Debug)]
struct Handle {
channel: Option<ChannelId>,
streaming: bool,
stopped: bool,
}
pub struct SoundEngine {
pub registry: SoundRegistry,
/// `loaded`: the library opened.
pub loaded: bool,
tick_count: i32,
instances: HashMap<InstanceId, SoundInstance>,
/// Instances their owner still refers to.
retained: HashMap<InstanceId, ()>,
next_instance: InstanceId,
/// `instanceToChannel` (insertion order).
instance_to_channel: Vec<(InstanceId, u64)>,
handles: HashMap<u64, Handle>,
next_handle: u64,
/// `instanceBySource`.
instance_by_source: Vec<(usize, InstanceId)>,
gain_by_source: [f32; 11],
ticking: Vec<InstanceId>,
/// `queuedSounds` (insertion order).
queued: Vec<(InstanceId, i32)>,
delete_time: HashMap<InstanceId, i32>,
queued_tickable: Vec<InstanceId>,
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<Notification>,
}
fn handle_channel(h: &Handle) -> Option<ChannelId> {
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<u64> {
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<InstanceId> = self.instances.keys().copied().collect();
self.instance_to_channel.clear();
let handles: Vec<u64> = 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<usize>,
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<InstanceId> = 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<InstanceId> = 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<String>, 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<u64> = 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<ChannelId> = 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<ChannelId> {
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<InstanceId> {
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
}
+385
View File
@@ -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<Sampled>),
}
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/<path>.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<Sound>,
/// The subtitle translation key.
pub subtitle: Option<String>,
}
/// The sound event registry (`SoundManager.registry`).
#[derive(Clone, Debug, Default)]
pub struct SoundRegistry {
events: HashMap<String, WeighedSoundEvents>,
}
/// `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<Sound> {
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/<namespace>/sounds.json` under a resource root, files
/// validated against `assets/<namespace>/sounds/`.
pub fn load(root: &Path) -> Self {
let assets = root.join("assets");
let mut namespaces: Vec<String> = 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::<Value>(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<Item = &String> {
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<Sound> {
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());
}
}
@@ -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<String>();
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);
}
}
+88
View File
@@ -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()
+51
View File
@@ -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()