mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user