diff --git a/README.md b/README.md index a5bf19b..a30ea55 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,12 @@ API мода `render.set-lights` (до 512, плавно интерполиру У каждого объекта свой звук и своя дальность слышимости (`attenuation_distance` в `sounds.json`): - **двигатели:** двухтактный мопед шахеда (220 м), турбина Герани-3, электромотор Ланцета, тяжёлый рёв крылатых ракет (320 м), ракетный рёв Х-22, Циркона и перехватчиков, нарастающий визг падающей баллистики и её ударная волна (700–900 м); + Двигатели дронов и ракет — **постоянные источники звука** (API 20, `render.sound-sources`, #95): один источник на + объект идёт за его настоящей позицией, зажигается с нарастанием, при сбитии «захлёбывается» (тон падает, громкость + гаснет за секунду) и замолкает вместе с объектом; в повторе и экспорте источники берутся из записанных траекторий и + времени повтора (перемотка и смена плана пересоздают их). Одновременно звучат 24 ближайших и громких, остальные + «виртуальные». Взрывы, пуски и выстрелы остались разовыми, с задержкой по скорости звука. Баллистика (визг при + падении) тоже разовая. Зацикливается устойчивая часть файла (начало и затухание в конце обрезаны), стык — 50 мс. - **пуски:** хлопок и рвущийся рёв Patriot, свистящий старт Панциря, глухой газовый выброс и затем огромный рёв С-400, у С-500 ещё ниже и дольше (до 1 км); - **пушки:** тяжёлые 35-мм очереди Гепарда и частые 30-мм Панциря; diff --git a/airdef-mod/src/engines.rs b/airdef-mod/src/engines.rs new file mode 100644 index 0000000..31bdc6d --- /dev/null +++ b/airdef-mod/src/engines.rs @@ -0,0 +1,330 @@ +//! Engines as persistent sound sources (#95): a drone's or a missile's engine is one source that +//! follows the entity, instead of a one-shot repeated every second or two. +//! +//! The simulation (live) and the replay (from a recording's tracks and the replay's time) each +//! *declare* the engines sounding this tick; [`Engines::step`] turns that into the changes of +//! `render.sound-sources`: a start with an attack when an engine appears (ignition), an update +//! every tick, a stop with a release when it is gone (burnout, the entity removed), and a fall +//! of pitch and gain over a second when it is damaged (a drone shot down). A declaration missing +//! for a tick or two (a step the simulation skipped) does not stop the sound; a *cut* (a replay +//! seek, a new shot) stops every engine and starts them again under new ids, so nothing glides +//! across the jump. +use glam::DVec3; +use std::collections::{BTreeMap, BTreeSet}; + +/// Ticks an engine may go undeclared before it is stopped. +const GRACE: u32 = 3; +/// Ticks a damaged engine takes to die: its pitch falls and it fades. +const DYING: u32 = 20; +/// The most engines that sound at once (the nearest ones; the game's budget picks the audible). +const MAX: usize = 128; +/// Doppler applied to engines. The kinds already differ in pitch, and the fight's speed must not +/// change what is heard, so it stays off; the game bounds it to 12% anyway. +pub const DOPPLER: f32 = 0.0; + +/// An engine that sounds this tick. +#[derive(Clone, Debug, PartialEq)] +pub struct Decl { + /// The entity: a drone's id, a missile's with a high bit, a replay track's with another. + pub key: u32, + pub event: &'static str, + pub at: DVec3, + /// Blocks a second. + pub vel: DVec3, + pub gain: f32, + pub pitch: f32, + /// Shot down: the engine dies away. + pub damaged: bool, +} + +/// What a source is asked to be. +#[derive(Clone, Debug, PartialEq)] +pub struct Voice { + pub event: &'static str, + pub at: DVec3, + pub vel: DVec3, + pub gain: f32, + pub pitch: f32, + pub attack: f32, + pub release: f32, + pub loop_from: f32, + pub loop_to: f32, +} + +/// One change of `render.sound-sources`. +#[derive(Clone, Debug, PartialEq)] +pub enum Op { + Start(u32, Voice), + Update(u32, Voice), + Stop(u32), +} + +/// The steady part of each engine sound, seconds into the file: the sounds fade in at their start +/// and die away at their end (synthesis, reverb tail, fade: tools/gen_sounds.py), so looping a +/// whole file leaves a gap; these were found as `audio::tests::a_loop_of_the_steady_part_of_the_ +/// engine_sounds_has_no_hole` finds them (after the fade-in, before the sound begins to die). +pub fn steady_part(event: &str) -> (f32, f32) { + match event { + "airdef:drone.shahed" => (0.2, 2.2), + "airdef:drone.geran3" => (0.2, 2.0), + "airdef:drone.lancet" => (0.2, 1.4), + "airdef:drone.cruise" => (0.2, 2.6), + "airdef:drone.rocket" => (0.2, 2.6), + "airdef:missile.flight" => (0.2, 2.2), + _ => (0.0, 0.0), + } +} + +struct Live { + id: u32, + event: &'static str, + missed: u32, + dying: u32, +} + +#[derive(Default)] +pub struct Engines { + live: BTreeMap, + /// Engines that died and are still declared (a wreck falling): not started again. + dead: BTreeSet, + next_id: u32, +} + +impl Engines { + pub fn is_empty(&self) -> bool { + self.live.is_empty() + } + + /// The game released every source: forget them (nothing to stop). + pub fn reset(&mut self) { + self.live.clear(); + self.dead.clear(); + } + + fn voice(d: &Decl, dying: u32) -> Voice { + let u = (dying as f32 / DYING as f32).min(1.0); + let (loop_from, loop_to) = steady_part(d.event); + Voice { + event: d.event, + at: d.at, + vel: d.vel, + // A shot-down engine coughs: the pitch falls by almost half, the sound fades. + gain: d.gain * (1.0 - u), + pitch: d.pitch * (1.0 - 0.45 * u), + attack: 0.3, + release: 0.25, + loop_from, + loop_to, + } + } + + /// One tick: what is declared now, and whether the picture cut (a seek or a new shot). + pub fn step(&mut self, mut decls: Vec, cut: bool, listener: DVec3) -> Vec { + let mut ops = Vec::new(); + if cut { + for (_, l) in std::mem::take(&mut self.live) { + ops.push(Op::Stop(l.id)); + } + self.dead.clear(); + } + if decls.len() > MAX { + decls.sort_by(|a, b| { + a.at.distance_squared(listener) + .total_cmp(&b.at.distance_squared(listener)) + .then(a.key.cmp(&b.key)) + }); + decls.truncate(MAX); + } + decls.sort_by_key(|d| d.key); + for l in self.live.values_mut() { + l.missed += 1; + } + let declared: BTreeSet = decls.iter().map(|d| d.key).collect(); + self.dead.retain(|k| declared.contains(k)); + for d in &decls { + if self.dead.contains(&d.key) { + continue; + } + match self.live.get_mut(&d.key) { + Some(l) if l.event == d.event => { + l.missed = 0; + if d.damaged { + l.dying += 1; + } + if l.dying >= DYING { + ops.push(Op::Stop(l.id)); + self.live.remove(&d.key); + self.dead.insert(d.key); + } else { + ops.push(Op::Update(l.id, Self::voice(d, l.dying))); + } + } + other => { + // A new engine, or the same entity with another sound: the old one releases. + if let Some(l) = other { + ops.push(Op::Stop(l.id)); + } + self.next_id = self.next_id.wrapping_add(1).max(1); + let dying = u32::from(d.damaged); + ops.push(Op::Start(self.next_id, Self::voice(d, dying))); + self.live.insert( + d.key, + Live { + id: self.next_id, + event: d.event, + missed: 0, + dying, + }, + ); + } + } + } + // Gone for good: the entity was removed (burnout, a hit, a crash). + let gone: Vec = self + .live + .iter() + .filter(|(_, l)| l.missed > GRACE) + .map(|(k, _)| *k) + .collect(); + for k in gone { + if let Some(l) = self.live.remove(&k) { + ops.push(Op::Stop(l.id)); + } + } + ops + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn d(key: u32, x: f64) -> Decl { + Decl { + key, + event: "airdef:drone.shahed", + at: DVec3::new(x, 50.0, 0.0), + vel: DVec3::new(30.0, 0.0, 0.0), + gain: 0.8, + pitch: 1.0, + damaged: false, + } + } + fn starts(ops: &[Op]) -> usize { + ops.iter().filter(|o| matches!(o, Op::Start(..))).count() + } + fn stops(ops: &[Op]) -> usize { + ops.iter().filter(|o| matches!(o, Op::Stop(_))).count() + } + + #[test] + fn an_engine_ignites_follows_and_stops_when_its_entity_goes() { + let mut e = Engines::default(); + let ops = e.step(vec![d(1, 0.0)], false, DVec3::ZERO); + assert_eq!(starts(&ops), 1); + let Op::Start(id, v) = &ops[0] else { panic!() }; + assert_eq!((v.attack, v.loop_from, v.loop_to), (0.3, 0.2, 2.2)); + for i in 1..10 { + let ops = e.step(vec![d(1, f64::from(i))], false, DVec3::ZERO); + assert!(matches!(&ops[..], [Op::Update(u, v)] if u == id && v.at.x == f64::from(i))); + } + // Undeclared for a moment: still sounding; then gone. + for _ in 0..GRACE { + assert!(e.step(vec![], false, DVec3::ZERO).is_empty()); + } + let ops = e.step(vec![], false, DVec3::ZERO); + assert_eq!(ops, vec![Op::Stop(*id)]); + assert!(e.is_empty()); + } + + #[test] + fn a_shot_down_engine_coughs_and_dies_and_stays_dead_while_it_falls() { + let mut e = Engines::default(); + e.step(vec![d(1, 0.0)], false, DVec3::ZERO); + let mut last = 1.0; + let mut stopped_at = None; + for t in 1..60 { + let mut f = d(1, 0.0); + f.damaged = true; + let ops = e.step(vec![f], false, DVec3::ZERO); + match ops.as_slice() { + [Op::Update(_, v)] => { + assert!(v.pitch < last, "the pitch falls"); + last = v.pitch; + } + [Op::Stop(_)] => stopped_at = stopped_at.or(Some(t)), + [] => {} + o => panic!("{o:?}"), + } + } + assert_eq!(stopped_at, Some(DYING)); + assert!(last < 0.6); + // The wreck is gone, a new drone with the same key sounds again. + e.step(vec![], false, DVec3::ZERO); + assert_eq!(starts(&e.step(vec![d(1, 0.0)], false, DVec3::ZERO)), 1); + } + + #[test] + fn a_cut_stops_every_engine_and_starts_them_under_new_ids() { + let mut e = Engines::default(); + let a = e.step(vec![d(1, 0.0), d(2, 5.0)], false, DVec3::ZERO); + let ops = e.step(vec![d(1, 100.0), d(2, 105.0)], true, DVec3::ZERO); + assert_eq!((stops(&ops), starts(&ops)), (2, 2)); + let old: Vec = a.iter().filter_map(|o| if let Op::Start(i, _) = o { Some(*i) } else { None }).collect(); + for o in &ops { + if let Op::Start(i, _) = o { + assert!(!old.contains(i), "a new id, so nothing glides across the jump"); + } + } + } + + #[test] + fn another_sound_for_the_same_entity_restarts_it() { + let mut e = Engines::default(); + e.step(vec![d(1, 0.0)], false, DVec3::ZERO); + let mut other = d(1, 0.0); + other.event = "airdef:drone.rocket"; + let ops = e.step(vec![other], false, DVec3::ZERO); + assert_eq!((stops(&ops), starts(&ops)), (1, 1)); + } + + #[test] + fn only_the_nearest_are_kept_and_the_same_input_gives_the_same_ops() { + let run = || { + let mut e = Engines::default(); + let all: Vec = (0..300).map(|i| d(i, f64::from(i) * 3.0)).collect(); + e.step(all, false, DVec3::ZERO) + }; + let ops = run(); + assert_eq!(starts(&ops), MAX); + assert_eq!(ops, run()); + let far = ops.iter().filter_map(|o| if let Op::Start(_, v) = o { Some(v.at.x) } else { None }).fold(0.0, f64::max); + assert!(far <= (MAX as f64) * 3.0); + } + + #[test] + fn a_hundred_seek_cycles_leave_no_engine_behind() { + let mut e = Engines::default(); + let mut open = std::collections::BTreeSet::new(); + for round in 0..100 { + let ops = e.step(vec![d(1, round as f64), d(2, 0.0)], round % 3 == 0, DVec3::ZERO); + for o in ops { + match o { + Op::Start(i, _) => { + open.insert(i); + } + Op::Stop(i) => { + assert!(open.remove(&i), "a stop for a source that was never started"); + } + Op::Update(i, _) => assert!(open.contains(&i)), + } + } + } + for o in e.step(vec![], true, DVec3::ZERO) { + if let Op::Stop(i) = o { + open.remove(&i); + } + } + assert!(open.is_empty(), "every start was stopped"); + } +} diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 62e73fa..e6ab42c 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -13,6 +13,7 @@ use horizon_mod_sdk::{ }; mod cinema; +mod engines; mod commands; mod layout; mod film; @@ -107,6 +108,11 @@ struct AirDef { punch: f32, /// Sounds on their way to the player at the speed of sound. pending: Vec, + /// The engines as persistent sound sources (#95), and the source generation they were started under. + engines: engines::Engines, + engine_gen: u32, + /// Whether the engines are a replay's (a change of mode cuts them). + engines_replay: bool, /// The handles `render.model-id` gave the model names (valid under `model_generation`); none: /// a name the game doesn't know. model_ids: std::collections::HashMap<&'static str, Option>, @@ -149,6 +155,30 @@ fn switch_allowed(exporting: bool) -> Result<(), String> { } } +/// An engine voice as a `render.sound-sources` entry. +fn source_op(kind: render::SourceOpKind, id: u32, v: &engines::Voice) -> render::SourceOp { + render::SourceOp { + kind, + id, + event: v.event.to_string(), + x: v.at.x, + y: v.at.y, + z: v.at.z, + vx: v.vel.x as f32, + vy: v.vel.y as f32, + vz: v.vel.z as f32, + gain: v.gain, + pitch: v.pitch, + looping: true, + priority: 1.0, + attack: v.attack, + release: v.release, + doppler: engines::DOPPLER, + loop_from: v.loop_from, + loop_to: v.loop_to, + } +} + /// A sound heard later: when, what, where, and how loud and high it was. struct PendingSound { due: u64, @@ -615,7 +645,59 @@ impl AirDef { } } - fn emit(&mut self, out: Out) { + /// The engines the simulation or the replay declared become persistent sound sources (#95): + /// started, followed, stopped. The one-shots below stay as they were. + fn step_engines(&mut self, out: &mut Out) { + let replay = self.replay.as_ref().is_some_and(|r| r.filming()); + let cut = std::mem::take(&mut out.engine_cut) || replay != self.engines_replay; + self.engines_replay = replay; + let generation = render::sound_source_generation(); + if generation != self.engine_gen { + // The game released every source (a world closed, a film began or ended). + self.engines.reset(); + self.engine_gen = generation; + } + if self.engines.is_empty() && out.engines.is_empty() && !cut { + return; + } + let ops: Vec = self + .engines + .step(std::mem::take(&mut out.engines), cut, self.listener) + .into_iter() + .map(|op| match op { + engines::Op::Start(id, v) => source_op(render::SourceOpKind::Start, id, &v), + engines::Op::Update(id, v) => source_op(render::SourceOpKind::Update, id, &v), + engines::Op::Stop(id) => render::SourceOp { + kind: render::SourceOpKind::Stop, + id, + event: String::new(), + x: 0.0, + y: 0.0, + z: 0.0, + vx: 0.0, + vy: 0.0, + vz: 0.0, + gain: 0.0, + pitch: 1.0, + looping: false, + priority: 1.0, + attack: 0.0, + release: 0.0, + doppler: 0.0, + loop_from: 0.0, + loop_to: 0.0, + }, + }) + .collect(); + if !ops.is_empty() && !render::sound_sources(generation, &ops) { + // Released between the two calls: start them again under the new generation. + self.engines.reset(); + self.engine_gen = u32::MAX; + } + } + + fn emit(&mut self, mut out: Out) { + self.step_engines(&mut out); if self.sim.shader { render::set_params(&self.shader_params(&out)); } @@ -1404,6 +1486,9 @@ impl Mod for AirDef { listener: DVec3::ZERO, punch: 0.0, pending: Vec::new(), + engines: engines::Engines::default(), + engine_gen: u32::MAX, + engines_replay: false, model_ids: std::collections::HashMap::new(), model_generation: u32::MAX, by_id: Vec::new(), @@ -1547,6 +1632,7 @@ impl Mod for AirDef { out.models.clear(); out.particles.clear(); out.sounds.clear(); + out.engines.clear(); out.shake = None; } r.tick(&self.sim, &mut self.ground, self.listener, &mut out); diff --git a/airdef-mod/src/replay.rs b/airdef-mod/src/replay.rs index 9832484..2155b93 100644 --- a/airdef-mod/src/replay.rs +++ b/airdef-mod/src/replay.rs @@ -68,6 +68,8 @@ pub struct Player { fov: f32, /// The last tick whose events made their sounds. heard: f64, + /// The replay time the engines were last declared at (a jump back or far ahead cuts them). + engine_t: f64, /// Time stands still (Space, or the end reached). paused: bool, /// The film's own clock in seconds (the replay's moment follows from the plan). @@ -265,6 +267,7 @@ impl Player { look: DVec3::NEG_Z, fov: 50.0, heard: start, + engine_t: -1.0, paused: false, tau: 0.0, wait: false, @@ -443,6 +446,48 @@ impl Player { sim.draw_ghosts(rec, self.t, GHOSTS, out); self.direct(rec, ground); self.sounds(rec, listener, out); + self.engines(rec, listener, false, out); + } + + /// The engines as the recording had them at the replay's time, for persistent sound sources + /// (#95): every flying drone and lit interceptor, where its track puts it now, moving as the + /// track moves, placed round the listener like `sounds`. Taken from the tracks and the replay's + /// time, never from the live entities. Silent while time stands still, runs too fast to + /// follow, or a title card shows; a seek, a clip change or a new shot cuts them (they start + /// afresh), so no sound glides across a jump. + fn engines(&mut self, rec: &Recording, listener: DVec3, shot_changed: bool, out: &mut Out) { + let moved = self.t - self.engine_t; + if self.engine_t < 0.0 || shot_changed || moved < -1e-9 || moved > self.rate * 1.5 + 2.0 { + out.engine_cut = true; + } + self.engine_t = self.t; + if self.rate <= 0.0 || self.rate > 4.0 || self.card > 0 { + return; + } + let step = STEP as f64; + for (k, tr) in rec.tracks.iter().enumerate() { + let Some(p) = tr.at(rec.origin, self.t) else { continue }; + let ahead = tr.at(rec.origin, self.t + step).unwrap_or(p); + let behind = tr.at(rec.origin, self.t - step).unwrap_or(p); + // Blocks per tick of the replay: the track's, times how fast the replay runs. + let vel = (ahead - behind) / (2.0 * step) * self.rate; + let key = 0x8000_0000 | k as u32; + let at = listener + (p - self.eye); + match tr.what { + What::Drone(kind) => { + if let Some((event, pitch)) = kind.engine_voice() { + let falling = tr.fell.is_some_and(|f| self.t >= f as f64); + out.engine(key, event, at, vel, 0.8, pitch, falling); + } + } + What::Missile(kind, _) => { + if self.t - tr.first as f64 > kind.lit_after() as f64 { + let (event, gain, pitch) = kind.engine_voice(); + out.engine(key, event, at, vel, gain, pitch, false); + } + } + } + } } /// Sounds as the camera hears them: moved so the player stands where the camera is. @@ -512,6 +557,7 @@ impl Player { sim.draw_ghosts(rec, self.t, base, out); if !self.wait { self.sounds(rec, listener, out); + self.engines(rec, listener, changed, out); } else { self.heard = self.t; } @@ -1004,3 +1050,137 @@ mod surface_tests { assert_eq!(above_surface(eye, Some(None)), eye); } } + +#[cfg(test)] +mod engine_tests { + use super::*; + use crate::engines::{Engines, Op}; + use crate::sim::{MissileKind, record::Recording}; + + fn recording() -> Recording { + let mut rec = Recording::blank(2000); + // A Shahed flying 1000 blocks east over 1000 ticks, shot down at 600; an interceptor from 400. + rec.straight(1, What::Drone(DroneKind::Shahed), 0, 1000, DVec3::new(0.0, 80.0, 0.0), DVec3::new(1000.0, 80.0, 0.0)); + rec.tracks[0].fell = Some(600); + rec.straight(2, What::Missile(MissileKind::Pac3, 0), 400, 700, DVec3::new(0.0, 0.0, 0.0), DVec3::new(600.0, 80.0, 0.0)); + rec + } + fn player(rec: &Recording) -> Player { + let mut p = Player::new(1, Mode::Full, &Sim::new(1)); + p.eye = DVec3::new(500.0, 80.0, 30.0); + let _ = rec; + p + } + /// A host that remembers which sources run. + #[derive(Default)] + struct Host(std::collections::BTreeMap); + impl Host { + fn apply(&mut self, ops: Vec) { + for o in ops { + match o { + Op::Start(id, v) => assert!(self.0.insert(id, [v.at.x, v.at.y, v.at.z]).is_none()), + Op::Update(id, v) => assert!(self.0.insert(id, [v.at.x, v.at.y, v.at.z]).is_some()), + Op::Stop(id) => assert!(self.0.remove(&id).is_some()), + } + } + } + } + + #[test] + fn engines_follow_the_recorded_tracks_at_the_replays_time_around_the_listener() { + let rec = recording(); + let mut p = player(&rec); + let listener = DVec3::new(-5.0, 70.0, 12.0); + p.t = 450.0; + p.rate = 1.0; + let mut out = Out::default(); + p.engines(&rec, listener, false, &mut out); + // The first declaration of a replay starts the engines afresh. + assert!(out.engine_cut); + assert_eq!(out.engines.len(), 2); + let drone = out.engines.iter().find(|d| d.key == 0x8000_0000).unwrap(); + // The drone at tick 450 is at x = 450; the eye stands at x = 500; the player at -5. + assert!((drone.at.x - (listener.x + (450.0 - 500.0))).abs() < 1e-6, "{:?}", drone.at); + assert!((drone.at.z - (listener.z + (0.0 - 30.0))).abs() < 1e-6); + assert!(!drone.damaged && drone.vel.x > 0.0); + // The interceptor is lit after its cold launch only. + p.t = 401.0; + let mut out = Out::default(); + p.engines(&rec, listener, false, &mut out); + assert_eq!(out.engines.iter().filter(|d| d.key == 0x8000_0001).count(), usize::from(401.0 - 400.0 > f64::from(MissileKind::Pac3.lit_after()))); + } + + #[test] + fn pause_seek_and_cut_release_and_restart_engines_without_leaving_any() { + let rec = recording(); + let mut p = player(&rec); + let mut engines = Engines::default(); + let mut host = Host::default(); + let listener = DVec3::ZERO; + let mut tick = |p: &mut Player, engines: &mut Engines, host: &mut Host, shot: bool| { + let mut out = Out::default(); + p.engines(&rec, listener, shot, &mut out); + host.apply(engines.step(std::mem::take(&mut out.engines), out.engine_cut, listener)); + }; + p.t = 100.0; + p.rate = 1.0; + for _ in 0..30 { + p.t += 1.0; + tick(&mut p, &mut engines, &mut host, false); + } + assert_eq!(host.0.len(), 1, "the drone alone"); + // Paused: the sources go after their grace. + p.rate = 0.0; + for _ in 0..6 { + tick(&mut p, &mut engines, &mut host, false); + } + assert!(host.0.is_empty()); + // Played on again: they start again without a cut. + p.rate = 1.0; + tick(&mut p, &mut engines, &mut host, false); + assert_eq!(host.0.len(), 1); + let before: Vec = host.0.keys().copied().collect(); + // A seek back and forward, and a hundred shot changes: always one drone, a new source each time. + p.t = 50.0; + tick(&mut p, &mut engines, &mut host, false); + assert_eq!(host.0.len(), 1); + assert!(!host.0.contains_key(&before[0]), "the seek restarted the source"); + p.t = 380.0; + tick(&mut p, &mut engines, &mut host, false); + for i in 0..100 { + p.t = 380.0 + f64::from(i % 7); + tick(&mut p, &mut engines, &mut host, i % 3 == 0); + assert!(host.0.len() <= 2); + } + // The drone is shot down at 600: its engine dies away and stays dead while the wreck falls + // (the interceptor, flying till 700, is the one source left). + p.t = 599.0; + tick(&mut p, &mut engines, &mut host, false); + let mut silent_after = None; + for i in 0..40 { + p.t = 600.0 + f64::from(i); + tick(&mut p, &mut engines, &mut host, false); + if host.0.len() == 1 && silent_after.is_none() { + silent_after = Some(i); + } + } + assert!(silent_after.is_some_and(|i| i <= 25), "{silent_after:?}"); + // The film ends: nothing is left. + p.rate = 0.0; + for _ in 0..6 { + tick(&mut p, &mut engines, &mut host, false); + } + assert!(host.0.is_empty()); + } + + #[test] + fn a_fast_replay_is_silent() { + let rec = recording(); + let mut p = player(&rec); + p.t = 100.0; + p.rate = 16.0; + let mut out = Out::default(); + p.engines(&rec, DVec3::ZERO, false, &mut out); + assert!(out.engines.is_empty()); + } +} diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index b3f6c28..88651ea 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -20,6 +20,7 @@ mod ew; pub mod lights; mod logistics; mod orders; +mod voices; pub mod outcome; mod units; mod why; @@ -81,6 +82,10 @@ pub struct Out { pub models: Vec, pub particles: Vec, pub sounds: Vec<(&'static str, DVec3, f32, f32)>, + /// The engines sounding this tick (persistent sources, `crate::engines`), and whether the + /// picture cut (a replay seek or a new shot): every engine then starts afresh. + pub engines: Vec, + pub engine_cut: bool, pub commands: Vec, /// Camera jolt (strength, seconds), the strongest this tick. pub shake: Option<(f32, f32)>, @@ -109,6 +114,10 @@ impl Out { pub fn sound(&mut self, event: &'static str, at: DVec3, volume: f32, pitch: f32) { self.sounds.push((event, at, volume, pitch)); } + /// An engine that sounds: `vel` is the simulation's, blocks a tick. + pub fn engine(&mut self, key: u32, event: &'static str, at: DVec3, vel: DVec3, gain: f32, pitch: f32, damaged: bool) { + self.engines.push(crate::engines::Decl { key, event, at, vel: vel * 20.0, gain, pitch, damaged }); + } fn jolt(&mut self, strength: f32, seconds: f32) { if self.shake.is_none_or(|(s, _)| s < strength) { self.shake = Some((strength.min(1.0), seconds)); @@ -1684,6 +1693,9 @@ impl Sim { out.particles("minecraft:large_smoke", d.pos, 0.2, DVec3::new(0.0, 0.05, 0.0), 0.02, 1); } out.particles("minecraft:flame", d.pos, 0.25, DVec3::ZERO, 0.02, 2); + if let Some((event, pitch)) = d.kind.engine_voice() { + out.engine(d.id, event, d.pos, d.vel, 0.8, pitch, true); + } if sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.3, &sweep, now) || d.age > 600 { // The wreck's warhead may still go off where it lands. let warhead = if spec.decoy || !self.rng.chance(0.35) { 0 } else { spec.warhead.min(2) }; @@ -1927,9 +1939,9 @@ impl Sim { out.particles("minecraft:cloud", back - d.vel.normalize_or_zero() * 1.5, 0.15, DVec3::ZERO, 0.005, 1); } } - let (event, pitch, every) = spec.sound; - if d.age % every == 0 { - out.sound(event, d.pos, 1.0, pitch); + // The engine is one source that follows the drone (#95). + if let Some((event, pitch)) = d.kind.engine_voice() { + out.engine(d.id, event, d.pos, d.vel, 0.8, pitch, false); } } for (drone, pos) in splits { @@ -2692,24 +2704,12 @@ impl Sim { done.push((m.id, None, false, m.sam)); continue; } - if lit && spec.flame == 0.0 { - if m.age % 20 == 1 { - out.sound("airdef:drone.lancet", m.pos, 0.8, 1.6); - } - } else if lit { - if (m.age - spec.cold) % 46 == 1 { - let pitch = match m.kind { - MissileKind::Pac3 => 1.0, - MissileKind::M57e6 => 1.12, - MissileKind::M48n6 => 0.88, - MissileKind::M77n6 => 0.8, - MissileKind::IrisT | MissileKind::Amraam => 1.05, - MissileKind::M9m317 => 0.9, - MissileKind::M9m338 => 1.15, - MissileKind::P1sun => 1.7, - }; - out.sound("airdef:missile.flight", m.pos, 1.0, pitch); - } + if lit { + // The motor is one source that follows the missile from ignition on (#95). + let (event, gain, pitch) = m.kind.engine_voice(); + out.engine(0x4000_0000 | m.id, event, m.pos, m.vel, gain, pitch, false); + } + if lit && spec.flame > 0.0 { let back = m.pos - new * 1.4 * spec.scale; out.particles("minecraft:flame", back, 0.05, -new * 0.1, 0.01, 2); if m.age % 2 == 0 { diff --git a/airdef-mod/src/sim/voices.rs b/airdef-mod/src/sim/voices.rs new file mode 100644 index 0000000..cd10dba --- /dev/null +++ b/airdef-mod/src/sim/voices.rs @@ -0,0 +1,40 @@ +//! What the simulation tells the engine sources (`crate::engines`): which sound an entity's +//! engine makes, from the specs the rest of the simulation owns. +use super::{DroneKind, MissileKind}; + +impl DroneKind { + /// The sound of its engine and its pitch; none for the ballistic ones, which fall with a + /// scream played once (and the Oreshnik's blocks and submunitions, which have no engine). + pub fn engine_voice(self) -> Option<(&'static str, f32)> { + let spec = self.spec(); + if spec.ballistic.is_some() || matches!(self, DroneKind::OreshnikBlock | DroneKind::OreshnikSub) { + return None; + } + Some((spec.sound.0, spec.sound.1)) + } +} + +impl MissileKind { + /// The sound of its motor, its gain and its pitch: a rocket's roar, or the electric whine of + /// a motor without a flame. + pub fn engine_voice(self) -> (&'static str, f32, f32) { + if self.spec().flame == 0.0 { + return ("airdef:drone.lancet", 0.8, 1.6); + } + let pitch = match self { + MissileKind::Pac3 => 1.0, + MissileKind::M57e6 => 1.12, + MissileKind::M48n6 => 0.88, + MissileKind::M77n6 => 0.8, + MissileKind::IrisT | MissileKind::Amraam => 1.05, + MissileKind::M9m317 => 0.9, + MissileKind::M9m338 => 1.15, + MissileKind::P1sun => 1.7, + }; + ("airdef:missile.flight", 1.0, pitch) + } + /// Ticks from the launch until the motor lights (a cold launch throws it out of the tube first). + pub fn lit_after(self) -> u32 { + self.spec().cold + } +} diff --git a/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch b/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch index 7102404..433ebf2 100644 --- a/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch +++ b/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch @@ -1,7 +1,7 @@ From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:37:45 +0300 -Subject: [PATCH 01/36] Mod API (AirDef fork): world models, client particles, +Subject: [PATCH 01/37] Mod API (AirDef fork): world models, client particles, sounds, ground heights render.set-models draws block models with an interpolated pose per id; diff --git a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch b/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch index c464889..d890cd5 100644 --- a/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch +++ b/horizon-patches/0002-cargo-horizon-mod-report-particles-and-sounds.patch @@ -1,7 +1,7 @@ From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:39:45 +0300 -Subject: [PATCH 02/36] cargo-horizon-mod: report particles and sounds +Subject: [PATCH 02/37] cargo-horizon-mod: report particles and sounds Co-Authored-By: Claude Opus 5.5 --- diff --git a/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch b/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch index 3f6f6dc..aae930c 100644 --- a/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch +++ b/horizon-patches/0003-Mod-API-AirDef-fork-full-screen-post-pass-for-mods.patch @@ -1,7 +1,7 @@ From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 13:33:10 +0300 -Subject: [PATCH 03/36] Mod API (AirDef fork): full-screen post pass for mods +Subject: [PATCH 03/37] Mod API (AirDef fork): full-screen post pass for mods render.set-post compiles a mod's WGSL after a prelude (camera matrices, time, 256 vec4 of params, the world's colour and depth) and runs it after diff --git a/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch b/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch index d64e24d..40e687b 100644 --- a/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch +++ b/horizon-patches/0004-Mod-models-draw-far-models-as-scaled-impostors-past-.patch @@ -1,7 +1,7 @@ From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 22:09:30 +0300 -Subject: [PATCH 04/36] Mod models: draw far models as scaled impostors past +Subject: [PATCH 04/37] Mod models: draw far models as scaled impostors past the far plane, cull at 4096 --- diff --git a/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch b/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch index f300824..485019d 100644 --- a/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch +++ b/horizon-patches/0005-Mod-cameras-render.set-views-draws-the-world-from-ot.patch @@ -1,7 +1,7 @@ From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 22:31:00 +0300 -Subject: [PATCH 05/36] Mod cameras: render.set-views draws the world from +Subject: [PATCH 05/37] Mod cameras: render.set-views draws the world from other points into pictures that HUD view shapes show (API level 9) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch b/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch index 4f6a7ba..7eb1750 100644 --- a/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch +++ b/horizon-patches/0006-Mods-more-fuel-per-tick-and-per-event-AirDef-plans-r.patch @@ -1,7 +1,7 @@ From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 23:08:21 +0300 -Subject: [PATCH 06/36] Mods: more fuel per tick and per event (AirDef plans +Subject: [PATCH 06/37] Mods: more fuel per tick and per event (AirDef plans raids) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch b/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch index 696a5d7..dbdcafd 100644 --- a/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch +++ b/horizon-patches/0007-Mod-cameras-up-to-1920x1080-full-screen-replays.patch @@ -1,7 +1,7 @@ From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 23:25:29 +0300 -Subject: [PATCH 07/36] Mod cameras up to 1920x1080 (full-screen replays) +Subject: [PATCH 07/37] Mod cameras up to 1920x1080 (full-screen replays) Co-Authored-By: Claude Opus 5.5 --- diff --git a/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch b/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch index aff487a..81ba163 100644 --- a/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch +++ b/horizon-patches/0008-Mod-cameras-distant-terrain-tiles-fog-measured-from-.patch @@ -1,7 +1,7 @@ From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 11:15:11 +0300 -Subject: [PATCH 08/36] Mod cameras: distant terrain tiles, fog measured from +Subject: [PATCH 08/37] Mod cameras: distant terrain tiles, fog measured from the camera, no impostors while one is shown Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch b/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch index c7c3721..ba2938a 100644 --- a/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch +++ b/horizon-patches/0009-Mods-game.world-id-so-mods-keep-state-per-world-API-.patch @@ -1,7 +1,7 @@ From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 11:25:45 +0300 -Subject: [PATCH 09/36] Mods: game.world-id, so mods keep state per world (API +Subject: [PATCH 09/37] Mods: game.world-id, so mods keep state per world (API level 9) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch b/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch index e833ca5..2c23558 100644 --- a/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch +++ b/horizon-patches/0010-cargo-horizon-mod-trial-runs-behind-feature-full-so-.patch @@ -1,7 +1,7 @@ From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 12:01:08 +0300 -Subject: [PATCH 10/36] cargo-horizon-mod: trial runs behind feature full, so a +Subject: [PATCH 10/37] cargo-horizon-mod: trial runs behind feature full, so a packing-only tool skips wasmtime Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch b/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch index 0d4a2d2..852c672 100644 --- a/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch +++ b/horizon-patches/0011-HD-lighting-G-buffer-pass-per-pixel-mod-point-lights.patch @@ -1,7 +1,7 @@ From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 13:18:43 +0300 -Subject: [PATCH 11/36] HD lighting: G-buffer pass, per-pixel mod point lights +Subject: [PATCH 11/37] HD lighting: G-buffer pass, per-pixel mod point lights in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API level 10) diff --git a/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch b/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch index 6413832..4d55a8f 100644 --- a/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch +++ b/horizon-patches/0012-HD-lighting-stage-2-sun-and-moon-shadow-cascades-the.patch @@ -1,7 +1,7 @@ From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 13:59:43 +0300 -Subject: [PATCH 12/36] HD lighting stage 2: sun and moon shadow cascades, the +Subject: [PATCH 12/37] HD lighting stage 2: sun and moon shadow cascades, the world relit (sun by face angle and shadow, sky and block light through the lightmap), light shafts and halos, fog over it again, a light colour grade diff --git a/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch b/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch index c72845e..2581d3f 100644 --- a/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch +++ b/horizon-patches/0013-HD-bind-groups-cached-until-a-resize-or-a-new-source.patch @@ -1,7 +1,7 @@ From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:19:53 +0300 -Subject: [PATCH 13/36] HD: bind groups cached until a resize or a new source, +Subject: [PATCH 13/37] HD: bind groups cached until a resize or a new source, shadow passes draw only the entities that cast shadows Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch b/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch index a39e82a..8a59bff 100644 --- a/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch +++ b/horizon-patches/0014-HD-lights-culled-per-16x16-tile-surface-and-halo-lis.patch @@ -1,7 +1,7 @@ From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:25:44 +0300 -Subject: [PATCH 14/36] HD: lights culled per 16x16 tile (surface and halo +Subject: [PATCH 14/37] HD: lights culled per 16x16 tile (surface and halo lists built on the CPU), a naga test for the lighting shaders Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch b/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch index dff1c54..e0c76ba 100644 --- a/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch +++ b/horizon-patches/0015-HD-GPU-time-per-pass-in-the-F3-timing-list-half-reso.patch @@ -1,7 +1,7 @@ From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:43:36 +0300 -Subject: [PATCH 15/36] HD: GPU time per pass in the F3 timing list, +Subject: [PATCH 15/37] HD: GPU time per pass in the F3 timing list, half-resolution GTAO with a depth-aware blur Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch b/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch index 0df9d22..df1e48c 100644 --- a/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch +++ b/horizon-patches/0016-HD-shadows-draw-only-sun-facing-quads-and-skip-burie.patch @@ -1,7 +1,7 @@ From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:02:02 +0300 -Subject: [PATCH 16/36] HD: shadows draw only sun-facing quads and skip buried +Subject: [PATCH 16/37] HD: shadows draw only sun-facing quads and skip buried sections, the far cascade every second frame; TAA (Halton jitter, reprojection by depth, YCoCg variance clipping) before bloom diff --git a/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch b/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch index e6a95ae..ced81bf 100644 --- a/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch +++ b/horizon-patches/0017-Mods-model-handles-render.model-id-model-generation-.patch @@ -1,7 +1,7 @@ From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:02:02 +0300 -Subject: [PATCH 17/36] Mods: model handles (render.model-id, model-generation, +Subject: [PATCH 17/37] Mods: model handles (render.model-id, model-generation, set-models-by-id; API level 11) so mods send no model names each tick; clear-post and set-lighting documented diff --git a/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch b/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch index 1a21730..a101c4d 100644 --- a/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch +++ b/horizon-patches/0018-HD-temporal-upscaling-HORIZON_HD_SCALE-world-G-buffe.patch @@ -1,7 +1,7 @@ From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:19:57 +0300 -Subject: [PATCH 18/36] HD: temporal upscaling (HORIZON_HD_SCALE: world, +Subject: [PATCH 18/37] HD: temporal upscaling (HORIZON_HD_SCALE: world, G-buffer and lighting at a lower size, rebuilt to the window in the TAA pass); fix flickering shadows near the player (the near cascade lost its sections on frames the far one was held, the held far matrix ignored the diff --git a/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch b/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch index e4dd294..17eb5a9 100644 --- a/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch +++ b/horizon-patches/0019-HD-water-its-own-pass-colour-normal-depth-the-floor-.patch @@ -1,7 +1,7 @@ From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:47:52 +0300 -Subject: [PATCH 19/36] HD water: its own pass (colour, normal, depth), the +Subject: [PATCH 19/37] HD water: its own pass (colour, normal, depth), the floor under it lit, a composite with world-space waves, refraction with depth absorption in the biome colour, screen-space reflections, Fresnel and a shadowed sun glint (HORIZON_HD_WATER=0 to switch off) diff --git a/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch b/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch index a81d06c..29f84c4 100644 --- a/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch +++ b/horizon-patches/0020-HD-water-no-vanilla-texture-on-the-surface-the-biome.patch @@ -1,7 +1,7 @@ From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 17:56:04 +0300 -Subject: [PATCH 20/36] HD water: no vanilla texture on the surface (the biome +Subject: [PATCH 20/37] HD water: no vanilla texture on the surface (the biome colour only), a darker, deeper colour Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch b/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch index c4e5725..602202d 100644 --- a/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch +++ b/horizon-patches/0021-HD-sky-and-light-a-physical-atmosphere-Rayleigh-Mie-.patch @@ -1,7 +1,7 @@ From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 18:38:19 +0300 -Subject: [PATCH 21/36] HD sky and light: a physical atmosphere (Rayleigh, Mie, +Subject: [PATCH 21/37] HD sky and light: a physical atmosphere (Rayleigh, Mie, ozone; constants and transmittance after Photon, MIT) for the sky, haze, fog and water reflections; warmer sunlight from the atmosphere's transmittance, a gentle sky-coloured ambient, soft PCSS-lite shadows near the player, a haze diff --git a/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch b/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch index 3757ce6..fabd5a0 100644 --- a/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch +++ b/horizon-patches/0022-HD-water-a-darker-blue-a-little-clearer-ripples-a-li.patch @@ -1,7 +1,7 @@ From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 19:02:16 +0300 -Subject: [PATCH 22/36] HD water: a darker blue, a little clearer, ripples a +Subject: [PATCH 22/37] HD water: a darker blue, a little clearer, ripples a little higher Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch b/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch index 6b765ca..977b73d 100644 --- a/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch +++ b/horizon-patches/0023-HD-Photon-style-sunsets-Rec.-2020-atmosphere-sun-tin.patch @@ -1,7 +1,7 @@ From 982265d21bb9d74ec0040db03feb5a55762286ad Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 21:41:48 +0300 -Subject: [PATCH 23/36] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun +Subject: [PATCH 23/37] HD: Photon-style sunsets (Rec. 2020 atmosphere, sun tint, blue hour; MIT), volumetric clouds with ground shadows instead of vanilla's in HD, volumetric fog with sun shafts and morning mist (HORIZON_HD_CLOUDS=0, HORIZON_HD_VOLFOG=0) diff --git a/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch b/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch index 8af4dc3..cf245a8 100644 --- a/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch +++ b/horizon-patches/0024-Mods-world-queries-for-simulations-render.query-rays.patch @@ -1,7 +1,7 @@ From 24971b3c67d8c87673eb2e0d204cedf8a2f46d1c Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 21:43:22 +0300 -Subject: [PATCH 24/36] Mods: world queries for simulations (render.query-rays, +Subject: [PATCH 24/37] Mods: world queries for simulations (render.query-rays, query-sections, world-changes; API level 12): block-exact rays and section occupancy by collision, sight or sound, answered next tick within budgets, and a log of changed sections diff --git a/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch b/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch index 8a3eafe..671c276 100644 --- a/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch +++ b/horizon-patches/0025-HD-fixes-rain-level-from-the-world-not-from-rain-geo.patch @@ -1,7 +1,7 @@ From fdec4960983bdff82f3793460f9a5234f0cd3f49 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 22:56:15 +0300 -Subject: [PATCH 25/36] HD fixes: rain level from the world not from rain +Subject: [PATCH 25/37] HD fixes: rain level from the world not from rain geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint without a depth heuristic (#35), air fog once over water (#37), translucent things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick diff --git a/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch b/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch index fb93d05..83acdb2 100644 --- a/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch +++ b/horizon-patches/0026-HD-split-into-modules-gfx-hd-one-Rust-file-and-one-W.patch @@ -1,7 +1,7 @@ From 21391afb26f49505d6cdaf454a6355cd4e747503 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 23:19:53 +0300 -Subject: [PATCH 26/36] HD split into modules (gfx/hd/: one Rust file and one +Subject: [PATCH 26/37] HD split into modules (gfx/hd/: one Rust file and one WGSL file per pass, a shared prelude), contracts for colour, depth, alpha, time and coordinates written down, tests that the frame layout and bindings match between Rust and WGSL (#45) diff --git a/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch b/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch index 41aa70c..b4d0a9c 100644 --- a/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch +++ b/horizon-patches/0027-HD-clouds-noise-scales-that-divide-the-camera-s-6553.patch @@ -1,7 +1,7 @@ From 8d4cadbf8534309ac847d34e229f6d764770475d Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 23:22:19 +0300 -Subject: [PATCH 27/36] HD clouds: noise scales that divide the camera's +Subject: [PATCH 27/37] HD clouds: noise scales that divide the camera's 65536-block wrap, so the clouds and their shadows no longer jump at x or z = 0 diff --git a/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch b/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch index d025681..0e5c915 100644 --- a/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch +++ b/horizon-patches/0028-HD-volumetric-smoke-and-missile-trails-with-clear-tu.patch @@ -1,7 +1,7 @@ From 0fb59c782abf269fe555faa3241b31acf41e26d7 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:17:11 +0300 -Subject: [PATCH 28/36] HD: volumetric smoke and missile trails with clear +Subject: [PATCH 28/37] HD: volumetric smoke and missile trails with clear tunnels through fog and cloud (render.set-smoke, API level 14; #54); sun speculars by material, wet surfaces and puddles in rain, wind in foliage (HORIZON_HD_SPECULAR=0, HORIZON_HD_WIND=0, HORIZON_HD_SMOKE=0); HD shaders diff --git a/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch b/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch index a7b3820..7c7f9ff 100644 --- a/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch +++ b/horizon-patches/0029-HD-clouds-composited-by-depth-so-terrain-and-vehicle.patch @@ -1,7 +1,7 @@ From e4bc29da45d0d38244c6eb2ba3ffb9ab853540c0 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:26:09 +0300 -Subject: [PATCH 29/36] HD clouds composited by depth so terrain and vehicles +Subject: [PATCH 29/37] HD clouds composited by depth so terrain and vehicles inside or behind the layer are hidden and those in front stay in front (#67); world-anchored noise everywhere (cloud detail by height above the layer, fog, smoke and puddle scales dividing the wrap) (#68); the replay's time is visual diff --git a/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch b/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch index 73de521..9c36a76 100644 --- a/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch +++ b/horizon-patches/0030-HD-smoke-young-trails-drawn-at-least-as-wide-as-a-gr.patch @@ -1,7 +1,7 @@ From 20f699796afb12b600236a4e8859cd97430f86f0 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:44:45 +0300 -Subject: [PATCH 30/36] HD smoke: young trails drawn at least as wide as a grid +Subject: [PATCH 30/37] HD smoke: young trails drawn at least as wide as a grid cell (density scaled to keep the mass), gentler bulges, so a trail is a smooth ribbon and not a chain of puffs diff --git a/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch b/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch index 6b060fb..a4177e4 100644 --- a/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch +++ b/horizon-patches/0031-Mods-render.set-main-view-API-level-15-renders-the-s.patch @@ -1,7 +1,7 @@ From 35ffbaa26dd91855c8b93446646ba8c58448d3d0 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 03:59:41 +0300 -Subject: [PATCH 31/36] Mods: render.set-main-view (API level 15) renders the +Subject: [PATCH 31/37] Mods: render.set-main-view (API level 15) renders the screen's world from a mod camera with the full pipeline, so replays get HD and the post effects; small mod views get the post pass with their own camera diff --git a/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch b/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch index 15b60e3..325ca2e 100644 --- a/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch +++ b/horizon-patches/0032-Spot-lights-for-mods-render.set-spot-lights-API-leve.patch @@ -1,7 +1,7 @@ From 27e24527550942695164dd3a769a42a659e797ca Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 09:28:05 +0300 -Subject: [PATCH 32/36] Spot lights for mods (render.set-spot-lights, API level +Subject: [PATCH 32/37] Spot lights for mods (render.set-spot-lights, API level 16): surfaces lit in the cone, a volumetric beam stopped by depth (#60); film capture (render.capture-*, set-film-quality, API level 17): fixed-step clock, frames piped to ffmpeg or PNGs, offline audio, no chat in the frames (#58); diff --git a/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch b/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch index 078d380..80d35b7 100644 --- a/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch +++ b/horizon-patches/0033-HD-clouds-and-fog-polish-cloud-panorama-cirrus-terra.patch @@ -1,7 +1,7 @@ From 615f5c3fa7c19a9895f8972a88333c6f250ad78a Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 14:30:39 +0300 -Subject: [PATCH 33/36] HD clouds and fog polish: cloud panorama, cirrus, +Subject: [PATCH 33/37] HD clouds and fog polish: cloud panorama, cirrus, terrain-aware fog, far smoke cascade; replay world log and film clock; material overlays by sprite name diff --git a/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch b/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch index 037e3e2..0264f77 100644 --- a/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch +++ b/horizon-patches/0034-HD-coloured-sun-through-glass-and-leaves-rain-ripple.patch @@ -1,7 +1,7 @@ From 0254984fdcce53a4a0f2bff15f9be3b78c93842e Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 15:01:09 +0300 -Subject: [PATCH 34/36] HD: coloured sun through glass and leaves, rain ripples +Subject: [PATCH 34/37] HD: coloured sun through glass and leaves, rain ripples on water, film motion blur and depth of field Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0035-Film-export-every-frame-in-flight-reaches-the-writer.patch b/horizon-patches/0035-Film-export-every-frame-in-flight-reaches-the-writer.patch index 27e1f11..15451fd 100644 --- a/horizon-patches/0035-Film-export-every-frame-in-flight-reaches-the-writer.patch +++ b/horizon-patches/0035-Film-export-every-frame-in-flight-reaches-the-writer.patch @@ -1,7 +1,7 @@ From 9645d3f1f47d8bb358d5630288bdd801bd874911 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 15:19:32 +0300 -Subject: [PATCH 35/36] Film export: every frame in flight reaches the writer, +Subject: [PATCH 35/37] Film export: every frame in flight reaches the writer, listener follows the main view, cloud wind on film frames, set-smoke reports real activity diff --git a/horizon-patches/0036-Mod-command-trees-command.set-tree-and-set-suggestio.patch b/horizon-patches/0036-Mod-command-trees-command.set-tree-and-set-suggestio.patch index 697e257..ff0c0d6 100644 --- a/horizon-patches/0036-Mod-command-trees-command.set-tree-and-set-suggestio.patch +++ b/horizon-patches/0036-Mod-command-trees-command.set-tree-and-set-suggestio.patch @@ -1,7 +1,7 @@ From 22d6835507d5627b0fe4a749b082b025b35e0262 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 15:43:11 +0300 -Subject: [PATCH 36/36] Mod command trees: command.set-tree and +Subject: [PATCH 36/37] Mod command trees: command.set-tree and set-suggestions, typed parse, colouring and completion from one tree (API 19) Co-Authored-By: Claude Opus 5.5 diff --git a/horizon-patches/0037-Persistent-mod-sound-sources-start-update-stop-with-.patch b/horizon-patches/0037-Persistent-mod-sound-sources-start-update-stop-with-.patch new file mode 100644 index 0000000..e572a06 --- /dev/null +++ b/horizon-patches/0037-Persistent-mod-sound-sources-start-update-stop-with-.patch @@ -0,0 +1,2000 @@ +From 9be21a54fff055e78db245488c28a971a823fdba Mon Sep 17 00:00:00 2001 +From: chexit +Date: Fri, 30 Oct 2026 16:10:23 +0300 +Subject: [PATCH 37/37] Persistent mod sound sources: start, update, stop with + generations, click-free voices, loop sections, voice budget and virtual loops + (API 20) + +Co-Authored-By: Claude Opus 5.5 +--- + crates/horizon-client/src/audio.rs | 368 ++++++++++- + crates/horizon-client/src/game/mods.rs | 44 ++ + crates/horizon-client/src/lib.rs | 1 + + crates/horizon-client/src/mod_sources.rs | 616 ++++++++++++++++++ + crates/horizon-client/src/sound_engine.rs | 10 + + crates/horizon-mod-sdk/wit/horizon.wit | 76 +++ + crates/horizon-mods/src/api_level.rs | 7 +- + crates/horizon-mods/src/lib.rs | 2 + + crates/horizon-mods/src/runtime.rs | 93 ++- + crates/horizon-mods/src/sources.rs | 315 +++++++++ + crates/horizon-mods/tests/sandbox.rs | 36 + + crates/horizon-mods/tests/ui_guest/src/lib.rs | 33 + + docs/api/mods-reference.md | 37 ++ + docs/api/mods.md | 8 + + 14 files changed, 1636 insertions(+), 10 deletions(-) + create mode 100644 crates/horizon-client/src/mod_sources.rs + create mode 100644 crates/horizon-mods/src/sources.rs + +diff --git a/crates/horizon-client/src/audio.rs b/crates/horizon-client/src/audio.rs +index b09178c1d..61df7bf7d 100644 +--- a/crates/horizon-client/src/audio.rs ++++ b/crates/horizon-client/src/audio.rs +@@ -39,6 +39,60 @@ struct Channel { + looping: bool, + at: [f32; 3], + relative: bool, ++ /// A mod source (AirDef fork): smoothed gain, pan and pitch, a click-free envelope. ++ voice: Option, ++} ++/// What makes a channel a mod source's voice. ++#[derive(Clone, Debug)] ++struct Voice { ++ /// The envelope now and where it goes, and how fast (per second). ++ env: f32, ++ env_target: f32, ++ env_rate: f32, ++ /// Fading out: silent means stopped. ++ releasing: bool, ++ velocity: [f32; 3], ++ /// Seconds since the pose was set (the position is carried along the velocity). ++ age: f32, ++ doppler: f32, ++ /// Left, right and plain gain of the last buffer, to ramp from. ++ last: Option<[f32; 3]>, ++ pitch_now: Option, ++ /// The part of the sound a loop repeats, in seconds (0, 0: all of it). ++ loop_from: f32, ++ loop_to: f32, ++} ++/// The most a source is carried along its velocity between two poses, in seconds. ++const CARRY: f32 = 0.15; ++/// The speed of sound in blocks a second, for Doppler. ++const SOUND_SPEED: f32 = 343.0; ++/// The Doppler pitch factor for a source receding at `away` blocks a second, `strength` 0..1, ++/// bounded to a change of 12%. ++pub fn doppler_factor(away: f32, strength: f32) -> f32 { ++ let f = (SOUND_SPEED / (SOUND_SPEED + away)).clamp(0.88, 1.12); ++ 1.0 + (f - 1.0) * strength.clamp(0.0, 1.0) ++} ++/// The loop of a voice in source frames: (from, to, crossfade length). ++fn voice_loop(samples: usize, rate: u32, from: f32, to: f32) -> (f64, f64, f64) { ++ let span = (samples.max(2) - 1) as f64; ++ let b = if to > from { (f64::from(to) * f64::from(rate)).min(span) } else { span }; ++ let a = (f64::from(from) * f64::from(rate)).min(b * 0.5); ++ let x = (0.05 * f64::from(rate)).min((b - a) / 4.0); ++ (a, b, x) ++} ++/// A sample at a fractional position; in the last `x` frames before a loop's end (`b`) the end ++/// fades out as the loop's start (`a`) fades in, with equal power, so the join is seamless. ++fn voice_sample(samples: &[f32], p: f64, looping: bool, (a, b, x): (f64, f64, f64)) -> f32 { ++ let at = |p: f64| { ++ let i = p as usize; ++ let t = (p - i as f64) as f32; ++ samples[i] * (1.0 - t) + samples[(i + 1).min(samples.len() - 1)] * t ++ }; ++ if looping && x > 0.0 && p >= b - x && p < b { ++ let u = ((p - (b - x)) / x) as f32 * std::f32::consts::FRAC_PI_2; ++ return at(p) * u.cos() + at(a + (p - (b - x))) * u.sin(); ++ } ++ at(p) + } + #[derive(Default)] + struct Mixer { +@@ -172,6 +226,8 @@ pub struct Audio { + pub stopped: std::collections::BTreeMap, + /// What film capture set aside: (live, loaded) as they were. + film_saved: Option<(bool, bool)>, ++ /// The mods' persistent sound sources (mod API level 20). ++ pub mod_sources: crate::mod_sources::ModSources, + } + fn decode(path: &Path) -> Option<(Vec, u32)> { + use symphonia::core::{ +@@ -240,6 +296,7 @@ impl Backend for MixerBackend { + looping: false, + at: [0.0; 3], + relative: false, ++ voice: None, + }, + ); + Some(id) +@@ -337,6 +394,43 @@ impl Backend for MixerBackend { + fn stop(&mut self, channel: ChannelId) { + self.with(channel, |c| c.state = State::Stopped); + } ++ fn voice_begin(&mut self, channel: ChannelId, attack: f32, loop_from: f32, loop_to: f32) { ++ self.with(channel, |c| { ++ c.voice = Some(Voice { ++ env: 0.0, ++ env_target: 1.0, ++ env_rate: 1.0 / attack.max(0.005), ++ releasing: false, ++ velocity: [0.0; 3], ++ age: 0.0, ++ doppler: 0.0, ++ last: None, ++ pitch_now: None, ++ loop_from, ++ loop_to, ++ }); ++ }); ++ } ++ fn voice_pose(&mut self, channel: ChannelId, velocity: [f32; 3], doppler: f32) { ++ self.with(channel, |c| { ++ if let Some(v) = c.voice.as_mut() { ++ v.velocity = velocity; ++ v.doppler = doppler; ++ v.age = 0.0; ++ } ++ }); ++ } ++ fn voice_release(&mut self, channel: ChannelId, release: f32) { ++ self.with(channel, |c| match c.voice.as_mut() { ++ // A voice not sounding yet has nothing to fade. ++ Some(v) if c.state == State::Playing => { ++ v.releasing = true; ++ v.env_target = 0.0; ++ v.env_rate = 1.0 / release.max(0.005); ++ } ++ _ => c.state = State::Stopped, ++ }); ++ } + fn pause(&mut self, channel: ChannelId) { + self.with(channel, |c| { + if c.state == State::Playing { +@@ -433,13 +527,26 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) { + if c.state != State::Playing { + continue; + } ++ // A mod source is carried along its velocity since its pose was set. ++ let carry = match &c.voice { ++ Some(v) if !c.relative => v.age.min(CARRY), ++ _ => 0.0, ++ }; ++ let at = match &c.voice { ++ Some(v) => [ ++ c.at[0] + v.velocity[0] * carry, ++ c.at[1] + v.velocity[1] * carry, ++ c.at[2] + v.velocity[2] * carry, ++ ], ++ None => c.at, ++ }; + let d = if c.relative { +- [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])] ++ [f64::from(at[0]), f64::from(at[1]), f64::from(at[2])] + } else { + [ +- f64::from(c.at[0]) - listener[0], +- f64::from(c.at[1]) - listener[1], +- f64::from(c.at[2]) - listener[2], ++ f64::from(at[0]) - listener[0], ++ f64::from(at[1]) - listener[1], ++ f64::from(at[2]) - listener[2], + ] + }; + let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32; +@@ -451,16 +558,76 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) { + stereo_gains(dot(d, right), dot(d, up), dot(d, forward)) + } + .map(|pan| gain * pan); +- let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0)); ++ let dop = match &c.voice { ++ Some(v) if v.doppler > 0.0 && !c.relative && distance > 1e-3 => { ++ let away = (f64::from(v.velocity[0]) * d[0] ++ + f64::from(v.velocity[1]) * d[1] ++ + f64::from(v.velocity[2]) * d[2]) as f32 ++ / distance; ++ doppler_factor(away, v.doppler) ++ } ++ _ => 1.0, ++ }; ++ let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0) * dop); + voices.push((*id, [left, right, gain], step)); + } +- for (id, [left, right, gain], step) in voices { ++ let buffer_seconds = (data.len() / channels.max(1)) as f32 / rate as f32; ++ for (id, [left, right, gain], mut step) in voices { + let Some(c) = mixer.channels.get_mut(&id) else { + continue; + }; +- let Some((samples, _)) = c.samples.clone() else { ++ let Some((samples, source_rate)) = c.samples.clone() else { + continue; + }; ++ if let Some(v) = c.voice.as_mut() { ++ // Pitch (Doppler too) moves towards its target over about 30 ms, so a pose ++ // every tick is no staircase; the phase never jumps. ++ let target = step; ++ let now = v.pitch_now.unwrap_or(target as f32); ++ let alpha = 1.0 - (-buffer_seconds / 0.03).exp(); ++ let now = now + (target as f32 - now) * alpha; ++ v.pitch_now = Some(now); ++ step = f64::from(now); ++ let from = v.last.unwrap_or([left, right, gain]); ++ let looping = c.looping && samples.len() > 1; ++ let region = voice_loop(samples.len(), source_rate, v.loop_from, v.loop_to); ++ let span = (samples.len().max(2) - 1) as f64; ++ let end = if looping { region.1 } else { span }; ++ let frames = (data.len() / channels.max(1)).max(1); ++ let env_step = v.env_rate / rate as f32; ++ for (k, frame) in data.chunks_mut(channels).enumerate() { ++ if c.position >= end { ++ if looping { ++ c.position = c.position - region.1 + region.0 + region.2; ++ } else { ++ c.state = State::Stopped; ++ break; ++ } ++ } ++ if v.env < v.env_target { ++ v.env = (v.env + env_step).min(v.env_target); ++ } else if v.env > v.env_target { ++ v.env = (v.env - env_step).max(v.env_target); ++ } ++ if v.releasing && v.env <= 0.0 { ++ c.state = State::Stopped; ++ break; ++ } ++ let s = voice_sample(&samples, c.position, looping, region) * v.env; ++ let t = k as f32 / frames as f32; ++ let lerp = |a: f32, b: f32| a + (b - a) * t; ++ if channels >= 2 { ++ frame[0] += s * lerp(from[0], left); ++ frame[1] += s * lerp(from[1], right); ++ } else { ++ frame[0] += s * lerp(from[2], gain); ++ } ++ c.position += step; ++ } ++ v.last = Some([left, right, gain]); ++ v.age += buffer_seconds; ++ continue; ++ } + for frame in data.chunks_mut(channels) { + let mut i = c.position as usize; + if i + 1 >= samples.len() { +@@ -630,6 +797,7 @@ impl Audio { + heard: Default::default(), + stopped: Default::default(), + film_saved: None, ++ mod_sources: Default::default(), + } + } + /// Film capture starts: every sound stops, and from now on only the sounds played between +@@ -755,9 +923,43 @@ impl Audio { + pub fn tick(&mut self, paused: bool, ctx: &TickContext) { + self.sync_volumes(); + self.engine.tick(paused, ctx, &mut self.backend); ++ self.tick_mod_sources(); ++ } ++ /// Runs `f` on the mod sources with the film (if one runs) hearing them. ++ fn with_mod_sources( ++ &mut self, ++ f: impl FnOnce(&mut crate::mod_sources::ModSources, &SoundEngine, &mut MixerBackend) -> R, ++ ) -> R { ++ let allowed = self.backend.allow; ++ self.backend.allow = true; ++ let r = f(&mut self.mod_sources, &self.engine, &mut self.backend); ++ self.backend.allow = allowed; ++ r ++ } ++ /// Once per client tick: finished and faded sources give their channels back, volumes ++ /// follow the sliders, the voice budget is settled for where the listener is. ++ pub fn tick_mod_sources(&mut self) { ++ self.sync_volumes(); ++ let listener = self.listener; ++ self.with_mod_sources(|m, e, b| m.tick(listener, e, b)); ++ } ++ /// The mods' source changes, then the release of the sources of runs that are gone. ++ pub fn apply_mod_sources( ++ &mut self, ++ changes: Vec, ++ runs: &[u64], ++ ) { ++ self.sync_volumes(); ++ let listener = self.listener; ++ self.with_mod_sources(|m, e, b| { ++ m.retain_runs(runs, b); ++ m.apply(changes, listener, e, b); ++ m.tick(listener, e, b); ++ }); + } + /// `SoundManager.pauseAllExcept(MUSIC, UI)`. + pub fn pause(&mut self) { ++ self.mod_sources.pause(&mut self.backend); + self.engine.pause_all_except( + &[crate::sound_engine::MUSIC, crate::sound_engine::UI], + &mut self.backend, +@@ -765,10 +967,12 @@ impl Audio { + } + /// `SoundManager.resume`. + pub fn resume(&mut self) { ++ self.mod_sources.resume(&mut self.backend); + self.engine.resume(&mut self.backend); + } + /// `SoundManager.stop()`: every sound. + pub fn stop_all(&mut self) { ++ self.mod_sources.release_all(&mut self.backend); + self.engine.stop_all(&mut self.backend); + } + /// `SoundManager.reload` (resource packs changed): stop every sound, then +@@ -885,6 +1089,11 @@ impl Audio { + .lock() + .map_or(0, |m| m.channels.len()) + .into(); ++ let [sources, voiced, fading] = self.mod_sources.counts(); ++ census["mod_sources"] = serde_json::json!({ ++ "sources": sources, "voices": voiced, "fading": fading, ++ "epoch": self.mod_sources.epoch, "dropped": self.mod_sources.dropped, ++ }); + if let Ok(cache) = self.backend.cache.lock() { + census["decoded"] = cache.entries.len().into(); + census["decoded_mib"] = (cache.bytes >> 20).into(); +@@ -993,6 +1202,7 @@ mod tests { + looping, + at, + relative: false, ++ voice: None, + } + } + +@@ -1159,4 +1369,148 @@ mod tests { + mix(&mut m, &mut data, 1, 48_000); + assert_eq!(data, [0.5; 2]); + } ++ ++ fn voice_channel(samples: Vec, at: [f32; 3], looping: bool, attack: f32, loop_from: f32, loop_to: f32) -> Channel { ++ let mut c = channel(samples, Some(100.0), at, looping); ++ c.voice = Some(Voice { ++ env: 0.0, ++ env_target: 1.0, ++ env_rate: 1.0 / attack.max(0.005), ++ releasing: false, ++ velocity: [0.0; 3], ++ age: 0.0, ++ doppler: 0.0, ++ last: None, ++ pitch_now: None, ++ loop_from: loop_from, ++ loop_to, ++ }); ++ c ++ } ++ fn render(m: &mut Mixer, buffers: usize, frames: usize) -> Vec { ++ let mut out = Vec::new(); ++ for _ in 0..buffers { ++ let mut b = vec![0.0f32; frames * 2]; ++ mix(m, &mut b, 2, 48_000); ++ out.extend(b); ++ } ++ out ++ } ++ fn max_step(x: &[f32]) -> f32 { ++ x.chunks(2).zip(x.chunks(2).skip(1)).map(|(a, b)| (b[0] - a[0]).abs().max((b[1] - a[1]).abs())).fold(0.0, f32::max) ++ } ++ ++ #[test] ++ fn a_voice_attacks_and_releases_without_a_click() { ++ // A constant tone: any jump is the envelope's. ++ let tone: Vec = (0..48_000).map(|i| (i as f32 * 0.05).sin() * 0.5).collect(); ++ let mut m = Mixer::default(); ++ m.channels.insert(1, voice_channel(tone.clone(), [0.0, 0.0, 5.0], true, 0.05, 0.0, 0.0)); ++ let a = render(&mut m, 20, 480); ++ // The tone alone steps at most about 0.05 * 0.5 * pan; the attack must not exceed that by much. ++ let tone_step = 0.5 * 0.05 * 0.6; ++ assert!(max_step(&a) < tone_step * 1.5, "attack step {}", max_step(&a)); ++ assert!(a[a.len() - 2].abs() > 0.0 || a[a.len() - 4].abs() > 0.0); ++ // Release: faded over 50 ms, then stopped. ++ let mut v = m.channels.get_mut(&1).unwrap(); ++ let voice = v.voice.as_mut().unwrap(); ++ voice.releasing = true; ++ voice.env_target = 0.0; ++ voice.env_rate = 1.0 / 0.05; ++ let r = render(&mut m, 20, 480); ++ assert!(max_step(&r) < tone_step * 1.5, "release step {}", max_step(&r)); ++ v = m.channels.get_mut(&1).unwrap(); ++ assert_eq!(v.state, State::Stopped); ++ assert!(r[r.len() - 200..].iter().all(|s| s.abs() < 1e-6)); ++ } ++ ++ #[test] ++ fn a_moving_source_pans_without_steps_between_buffers() { ++ let tone: Vec = (0..48_000).map(|i| (i as f32 * std::f32::consts::TAU * 100.0 / 48_000.0).sin() * 0.5).collect(); ++ let mut c = voice_channel(tone, [-20.0, 0.0, 10.0], true, 0.005, 0.0, 0.0); ++ c.voice.as_mut().unwrap().env = 1.0; ++ let mut m = Mixer::default(); ++ m.channels.insert(1, c); ++ let mut out = Vec::new(); ++ for tick in 0..40 { ++ // The mod's pose every 50 ms: 40 blocks a second to the right. ++ let c = m.channels.get_mut(&1).unwrap(); ++ c.at[0] = -20.0 + tick as f32 * 2.0; ++ let v = c.voice.as_mut().unwrap(); ++ v.velocity = [40.0, 0.0, 0.0]; ++ v.age = 0.0; ++ out.extend(render(&mut m, 5, 480)); ++ } ++ assert!(max_step(&out) < 0.5 * 0.0131 * 1.2 + 0.005, "step {}", max_step(&out)); ++ // It crossed from the right side to the left. ++ let (first, last) = (&out[..2000], &out[out.len() - 2000..]); ++ let energy = |x: &[f32], side: usize| x.chunks(2).map(|f| f[side] * f[side]).sum::(); ++ // Facing +z, -x is on the right. ++ assert!(energy(first, 1) > energy(first, 0) && energy(last, 0) > energy(last, 1)); ++ } ++ ++ #[test] ++ fn rendering_is_deterministic() { ++ let tone: Vec = (0..4_800).map(|i| ((i * 7919 % 1000) as f32 - 500.0) / 500.0).collect(); ++ let run = || { ++ let mut m = Mixer::default(); ++ m.channels.insert(1, voice_channel(tone.clone(), [3.0, 1.0, 9.0], true, 0.02, 0.0, 0.1)); ++ m.channels.get_mut(&1).unwrap().voice.as_mut().unwrap().doppler = 1.0; ++ m.channels.get_mut(&1).unwrap().voice.as_mut().unwrap().velocity = [0.0, 0.0, -80.0]; ++ render(&mut m, 30, 333) ++ }; ++ assert_eq!(run(), run()); ++ } ++ ++ #[test] ++ fn doppler_is_bounded_and_off_at_zero() { ++ assert_eq!(doppler_factor(-300.0, 0.0), 1.0); ++ assert!(doppler_factor(-300.0, 1.0) <= 1.12 + 1e-6 && doppler_factor(-300.0, 1.0) > 1.0); ++ assert!(doppler_factor(1e6, 1.0) >= 0.88 - 1e-6); ++ assert!(doppler_factor(-30.0, 1.0) > doppler_factor(-30.0, 0.5)); ++ assert!(doppler_factor(30.0, 1.0) < 1.0); ++ } ++ ++ #[test] ++ fn a_loop_of_the_steady_part_of_the_engine_sounds_has_no_hole() { ++ // The engine sounds fade in over 0.08 s and die away at their ends (the synthesis, then its ++ // reverb, then a 0.35 s fade: tools/gen_sounds.py), so looping the whole file leaves a gap. ++ // The mod loops the steady part; the values here are found the way its table was. ++ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) ++ .join("../../../airdef-mod/assets/airdef/sounds"); ++ let mut rows = Vec::new(); ++ for name in ["drone_shahed", "drone_geran3", "drone_cruise", "drone_lancet", "drone_rocket", "missile_flight"] { ++ let Some((samples, rate)) = decode(&root.join(format!("{name}.ogg"))) else { ++ return; // the mod's assets are not beside the engine checkout ++ }; ++ let rms = |x: &[f32]| (x.iter().map(|s| s * s).sum::() / x.len() as f32).sqrt(); ++ let body = rms(&samples[samples.len() / 4..samples.len() / 2]); ++ let win = (rate / 20) as usize; ++ let steady = samples.chunks(win).map(rms).enumerate().filter(|(_, r)| *r > 0.8 * body).map(|(i, _)| i).max().unwrap_or(0); ++ let (from, to) = (0.2f32, steady as f32 * 0.05); ++ println!("{name}: loop {from:.2} to {to:.2} of {:.2}", samples.len() as f32 / rate as f32); ++ for (label, a, b) in [("whole file", 0.0f32, 0.0f32), ("steady part", from, to)] { ++ let mut c = voice_channel(samples.clone(), [0.0, 0.0, 1.0], true, 0.005, a, b); ++ c.samples = Some((Arc::new(samples.clone()), rate)); ++ c.linear = None; ++ c.voice.as_mut().unwrap().env = 1.0; ++ let mut m = Mixer::default(); ++ m.channels.insert(1, c); ++ let len = if b > a { b - a } else { samples.len() as f32 / rate as f32 }; ++ // Four loops, in windows of 50 ms, after the first second. ++ let out = render(&mut m, (4.0 * len * 20.0) as usize + 20, win); ++ let windows: Vec = out.chunks(2 * win).skip(20).map(|w| rms(w) / body.max(1e-6)).collect(); ++ let low = windows.iter().copied().fold(f32::MAX, f32::min); ++ let high = windows.iter().copied().fold(0.0, f32::max); ++ println!(" {label:12} loudness over the loop relative to the body: {low:.2}..{high:.2}"); ++ rows.push((name, label, low, high, max_step(&out))); ++ } ++ } ++ for (name, label, low, high, _) in &rows { ++ if *label == "steady part" { ++ // 0.6 is the pan of a source straight ahead: the loop stays within -6 dB of the body (the whole file drops to nothing). ++ assert!(*low > 0.3, "{name}: {low}..{high} over the loop"); ++ } ++ } ++ } + } +diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs +index b524ff83f..59851d91f 100644 +--- a/crates/horizon-client/src/game/mods.rs ++++ b/crates/horizon-client/src/game/mods.rs +@@ -331,6 +331,7 @@ impl Game<'_> { + pub(super) fn merge_remote_tree(&mut self) {} + pub(super) fn sync_mods(&mut self) {} + pub(super) fn tick_mods(&mut self) {} ++ pub(super) fn sync_mod_sources(&mut self) {} + pub(super) fn mods_frame(&mut self, _elapsed: Duration) {} + pub(super) fn sync_world_log(&mut self) {} + pub(super) fn mods_film_requests( +@@ -682,6 +683,7 @@ impl Game<'_> { + self.run_quiet_batch(&mod_id, &lines); + } + if !self.mods.mods.is_running() { ++ self.sync_mod_sources(); + return; + } + self.tick_mod_use(); +@@ -705,10 +707,50 @@ impl Game<'_> { + .map(|s| s.0) + }) + }); ++ self.mods.mods.set_source_generation(self.audio.mod_sources.epoch); + self.mods.mods.tick(self.ticks); + self.refresh_mod_trees(); + self.update_mod_models(); + self.show_mod_output(); ++ self.sync_mod_sources(); ++ } ++ /// The mods' persistent sound sources (API level 20): their changes go to the mixer, and the ++ /// sources of a mod that no longer runs are released. The generation the mods read is the ++ /// audio's: whenever it released every source (world closed, film begun), they start again. ++ pub(super) fn sync_mod_sources(&mut self) { ++ use horizon_client::mod_sources::{Change, Params}; ++ use horizon_mods::sources::SourceChange; ++ let params = |p: horizon_mods::sources::SourceParams| Params { ++ event: p.event, ++ at: p.at, ++ velocity: p.velocity, ++ gain: p.gain, ++ pitch: p.pitch, ++ looping: p.looping, ++ priority: p.priority, ++ attack: p.attack, ++ release: p.release, ++ doppler: p.doppler, ++ loop_from: p.loop_from, ++ loop_to: p.loop_to, ++ }; ++ self.mods.mods.set_source_generation(self.audio.mod_sources.epoch); ++ let changes: Vec = self ++ .mods ++ .mods ++ .take_source_changes() ++ .into_iter() ++ .map(|(run, c)| match c { ++ SourceChange::Start(id, p) => Change::Start((run, id), params(p)), ++ SourceChange::Update(id, p) => Change::Update((run, id), params(p)), ++ SourceChange::Stop(id) => Change::Stop((run, id)), ++ }) ++ .collect(); ++ let runs = self.mods.mods.source_runs(); ++ let idle = changes.is_empty() && self.audio.mod_sources.counts()[0] == 0; ++ if !idle || !runs.is_empty() { ++ self.audio.apply_mod_sources(changes, &runs); ++ } + } + /// Restarts the dev mods rebuilt since the last check, once a second. + pub(super) fn reload_dev_mods(&mut self) { +@@ -790,8 +832,10 @@ impl Game<'_> { + let snapshot = self.mod_snapshot(); + self.mods.mods.set_snapshot(snapshot); + } ++ self.mods.mods.set_source_generation(self.audio.mod_sources.epoch); + self.mods.mods.frame(elapsed.as_secs_f32()); + self.show_mod_output(); ++ self.sync_mod_sources(); + } + /// AirDef fork: the block-change log a mod asked for (`render.world-log-start`), what it + /// covers, and the mod's view of the world at an earlier tick (`set-world-time-view`), +diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs +index 58afe409e..ba7c8a7a7 100644 +--- a/crates/horizon-client/src/lib.rs ++++ b/crates/horizon-client/src/lib.rs +@@ -153,6 +153,7 @@ pub mod shader_pack_list; + pub mod shadows; + pub mod signs; + pub mod skins; ++pub mod mod_sources; + pub mod sound_engine; + pub mod sound_events; + pub mod spans; +diff --git a/crates/horizon-client/src/mod_sources.rs b/crates/horizon-client/src/mod_sources.rs +new file mode 100644 +index 000000000..42312d47e +--- /dev/null ++++ b/crates/horizon-client/src/mod_sources.rs +@@ -0,0 +1,616 @@ ++//! Persistent sound sources for mods (AirDef fork, mod API level 20): sounds that go on, follow a ++//! pose and have a lifetime, over the same mixer channels the rest of the game plays on. ++//! ++//! A source is a mixer channel with a smoothed voice (`Backend::voice_*`): gain, pan and pitch ++//! ramp across each buffer, it fades in over its attack and out over its release, a loop can lay ++//! its end over its start, and the position is carried along the velocity between two poses. ++//! This module only decides *which* sources hold a channel: a budget of [`VOICES`], the loudest ++//! near ones first (priority times what the listener would hear); the others stay virtual, alive ++//! and silent, and get a voice when they rank again. A stop always works: it frees a channel or a ++//! virtual entry at once, whatever the budget did. ++use crate::sound_engine::{Backend, ChannelId, SoundEngine}; ++use crate::sound_events::{Sentinel, Sound}; ++use horizon_core::random::LegacyRandom; ++use std::collections::BTreeMap; ++ ++/// Channels at most this many sources sound on at once. ++pub const VOICES: usize = 24; ++/// Faded-out channels kept until silent; past this a release is cut short (a stop is still done). ++pub const RELEASING: usize = 24; ++/// Below this loudness a source gets no voice. ++const AUDIBLE: f32 = 0.002; ++/// A source that holds a voice is kept against one that ranks this much higher. ++const STICKY: f32 = 1.25; ++ ++/// A source's owner (the mod's run) and its id. ++pub type Key = (u64, u32); ++ ++/// What a mod asked of a source. ++#[derive(Clone, Debug, PartialEq)] ++pub struct Params { ++ pub event: String, ++ pub at: [f64; 3], ++ pub velocity: [f32; 3], ++ pub gain: f32, ++ pub pitch: f32, ++ pub looping: bool, ++ pub priority: f32, ++ pub attack: f32, ++ pub release: f32, ++ pub doppler: f32, ++ pub loop_from: f32, ++ pub loop_to: f32, ++} ++ ++/// One change, as the mod runtime's books give them. ++#[derive(Clone, Debug, PartialEq)] ++pub enum Change { ++ Start(Key, Params), ++ Update(Key, Params), ++ Stop(Key), ++} ++ ++struct Source { ++ p: Params, ++ sound: Sound, ++ /// The sound's own volume and pitch factors, drawn once. ++ sound_volume: f32, ++ sound_pitch: f32, ++ channel: Option, ++} ++ ++/// The mod sources of a game. ++#[derive(Default)] ++pub struct ModSources { ++ sources: BTreeMap, ++ releasing: Vec, ++ /// Moves on whenever every source was released (the mods read it as the generation). ++ pub epoch: u32, ++ paused: bool, ++ /// Sources dropped for good: one-shots that found no voice (a count for `status`). ++ pub dropped: u32, ++} ++ ++fn seed(key: Key, event: &str) -> i64 { ++ let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ key.0 ^ (u64::from(key.1) << 32); ++ for b in event.bytes() { ++ h = (h ^ u64::from(b)).wrapping_mul(0x100_0000_01b3); ++ } ++ h as i64 ++} ++ ++impl ModSources { ++ /// How many sources live, how many sound, how many fade out. ++ pub fn counts(&self) -> [usize; 3] { ++ let voiced = self.sources.values().filter(|s| s.channel.is_some()).count(); ++ [self.sources.len(), voiced, self.releasing.len()] ++ } ++ /// The channels in use (voices and fading ones). ++ pub fn channels(&self) -> usize { ++ self.counts()[1] + self.releasing.len() ++ } ++ ++ pub fn apply( ++ &mut self, ++ changes: Vec, ++ listener: [f64; 3], ++ engine: &SoundEngine, ++ backend: &mut impl Backend, ++ ) { ++ // Stops first: nothing a budget does later can leave one undone. ++ let (stops, rest): (Vec<_>, Vec<_>) = changes ++ .into_iter() ++ .partition(|c| matches!(c, Change::Stop(_))); ++ for c in stops { ++ if let Change::Stop(key) = c { ++ self.stop(key, backend); ++ } ++ } ++ for c in rest { ++ match c { ++ Change::Start(key, p) => self.start(key, p, engine, backend), ++ Change::Update(key, p) => self.update(key, p, engine, backend), ++ Change::Stop(_) => {} ++ } ++ } ++ self.rebalance(listener, engine, backend); ++ } ++ ++ fn start(&mut self, key: Key, p: Params, engine: &SoundEngine, backend: &mut impl Backend) { ++ // A restart: the old one releases first. ++ self.stop(key, backend); ++ if !engine.loaded { ++ return; ++ } ++ let event = crate::sound_events::normalize(&p.event); ++ let mut random = LegacyRandom::new(seed(key, &event)); ++ let Some(sound) = engine.registry.sound(&event, &mut random) else { ++ return; ++ }; ++ if sound.sentinel != Sentinel::None { ++ return; ++ } ++ let sound_volume = sound.volume.sample(&mut random); ++ let sound_pitch = sound.pitch.sample(&mut random); ++ self.sources.insert( ++ key, ++ Source { ++ p, ++ sound, ++ sound_volume, ++ sound_pitch, ++ channel: None, ++ }, ++ ); ++ } ++ ++ fn update(&mut self, key: Key, mut p: Params, engine: &SoundEngine, backend: &mut impl Backend) { ++ let Some(s) = self.sources.get_mut(&key) else { ++ return; ++ }; ++ p.event = std::mem::take(&mut s.p.event); ++ s.p = p; ++ if let Some(c) = s.channel { ++ Self::push(s, c, engine, backend); ++ } ++ } ++ ++ /// Releases one source: a voice fades out over its release, a virtual one just goes. ++ pub fn stop(&mut self, key: Key, backend: &mut impl Backend) { ++ if let Some(s) = self.sources.remove(&key) ++ && let Some(c) = s.channel ++ { ++ self.release(c, s.p.release, backend); ++ } ++ } ++ ++ fn release(&mut self, channel: ChannelId, seconds: f32, backend: &mut impl Backend) { ++ if self.releasing.len() >= RELEASING { ++ // Cut short: the oldest fading channel goes at once, the stop still happens. ++ let old = self.releasing.remove(0); ++ backend.destroy(old); ++ } ++ backend.voice_release(channel, seconds); ++ self.releasing.push(channel); ++ } ++ ++ /// Releases the sources of any run not in `runs` (a mod stopped or was reloaded). ++ pub fn retain_runs(&mut self, runs: &[u64], backend: &mut impl Backend) { ++ let gone: Vec = self ++ .sources ++ .keys() ++ .filter(|k| !runs.contains(&k.0)) ++ .copied() ++ .collect(); ++ for k in gone { ++ self.stop(k, backend); ++ } ++ } ++ ++ /// Everything stops now (the world closed, a film began or ended, the sounds reloaded): ++ /// no fade, the channels are destroyed, and the generation moves on. ++ pub fn release_all(&mut self, backend: &mut impl Backend) { ++ for (_, s) in std::mem::take(&mut self.sources) { ++ if let Some(c) = s.channel { ++ backend.destroy(c); ++ } ++ } ++ for c in self.releasing.drain(..) { ++ backend.destroy(c); ++ } ++ self.epoch = self.epoch.wrapping_add(1); ++ } ++ ++ pub fn pause(&mut self, backend: &mut impl Backend) { ++ self.paused = true; ++ for c in self.sources.values().filter_map(|s| s.channel) { ++ backend.pause(c); ++ } ++ for &c in &self.releasing { ++ backend.pause(c); ++ } ++ } ++ ++ pub fn resume(&mut self, backend: &mut impl Backend) { ++ self.paused = false; ++ for c in self.sources.values().filter_map(|s| s.channel) { ++ backend.unpause(c); ++ } ++ for &c in &self.releasing { ++ backend.unpause(c); ++ } ++ } ++ ++ fn volume(s: &Source, engine: &SoundEngine) -> f32 { ++ let category = crate::settings::sound_source(&s.p.event); ++ engine.calculate_volume(s.p.gain * s.sound_volume, category) ++ } ++ fn range(s: &Source) -> f32 { ++ s.sound.attenuation_distance(s.p.gain * s.sound_volume) ++ } ++ ++ /// Sets everything a pose changes on the source's channel. ++ fn push(s: &Source, channel: ChannelId, engine: &SoundEngine, backend: &mut impl Backend) { ++ backend.set_pitch( ++ channel, ++ SoundEngine::calculate_pitch(s.p.pitch * s.sound_pitch), ++ ); ++ backend.set_volume(channel, Self::volume(s, engine)); ++ backend.set_attenuation(channel, Some(Self::range(s))); ++ backend.set_looping(channel, s.p.looping); ++ backend.set_position(channel, s.p.at); ++ backend.voice_pose(channel, s.p.velocity, s.p.doppler); ++ } ++ ++ /// What the listener would hear of the source now, 0 to 1. ++ fn loudness(s: &Source, listener: [f64; 3], engine: &SoundEngine) -> f32 { ++ let d = [ ++ s.p.at[0] - listener[0], ++ s.p.at[1] - listener[1], ++ s.p.at[2] - listener[2], ++ ]; ++ let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32; ++ let range = Self::range(s); ++ if range <= 0.0 { ++ return 0.0; ++ } ++ Self::volume(s, engine) * (1.0 - distance.min(range) / range) ++ } ++ ++ /// Once per tick: channels of finished or faded sources are given back, volumes follow the ++ /// sliders, and the budget is settled. ++ pub fn tick(&mut self, listener: [f64; 3], engine: &SoundEngine, backend: &mut impl Backend) { ++ let mut i = 0; ++ while i < self.releasing.len() { ++ if backend.stopped(self.releasing[i]) { ++ backend.destroy(self.releasing.remove(i)); ++ } else { ++ i += 1; ++ } ++ } ++ // A source that is not looping ends by itself. ++ let ended: Vec = self ++ .sources ++ .iter() ++ .filter(|(_, s)| !s.p.looping && s.channel.is_some_and(|c| backend.stopped(c))) ++ .map(|(k, _)| *k) ++ .collect(); ++ for k in ended { ++ if let Some(s) = self.sources.remove(&k) ++ && let Some(c) = s.channel ++ { ++ backend.destroy(c); ++ } ++ } ++ for s in self.sources.values() { ++ if let Some(c) = s.channel { ++ backend.set_volume(c, Self::volume(s, engine)); ++ } ++ } ++ self.rebalance(listener, engine, backend); ++ } ++ ++ /// Gives the loudest sources the voices and takes them from the rest. ++ fn rebalance(&mut self, listener: [f64; 3], engine: &SoundEngine, backend: &mut impl Backend) { ++ let mut ranked: Vec<(f32, Key)> = self ++ .sources ++ .iter() ++ .filter_map(|(k, s)| { ++ let loud = Self::loudness(s, listener, engine); ++ (loud > AUDIBLE).then(|| { ++ let sticky = if s.channel.is_some() { STICKY } else { 1.0 }; ++ (loud * s.p.priority * sticky, *k) ++ }) ++ }) ++ .collect(); ++ // Loudest first; the key breaks ties, so the same input ranks the same way. ++ ranked.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1))); ++ let winners: Vec = ranked.iter().take(VOICES).map(|(_, k)| *k).collect(); ++ // Take voices from those that lost them: a virtual loop keeps living, a one-shot is gone. ++ let losers: Vec = self ++ .sources ++ .iter() ++ .filter(|(k, s)| s.channel.is_some() && !winners.contains(k)) ++ .map(|(k, _)| *k) ++ .collect(); ++ for k in losers { ++ let Some(s) = self.sources.get_mut(&k) else { ++ continue; ++ }; ++ let (channel, release, looping) = (s.channel.take(), s.p.release.min(0.1), s.p.looping); ++ if let Some(c) = channel { ++ self.release(c, release, backend); ++ } ++ if !looping { ++ self.sources.remove(&k); ++ self.dropped += 1; ++ } ++ } ++ // And voices to the winners. ++ for k in winners { ++ let Some(s) = self.sources.get_mut(&k) else { ++ continue; ++ }; ++ if s.channel.is_some() { ++ continue; ++ } ++ // A channel in the engine's pool is not needed; one more is made. ++ if self.releasing.len() + self.sources.values().filter(|s| s.channel.is_some()).count() ++ >= VOICES + RELEASING ++ { ++ break; ++ } ++ let Some(s) = self.sources.get_mut(&k) else { ++ continue; ++ }; ++ let Some(channel) = backend.create(s.sound.stream) else { ++ continue; ++ }; ++ Self::push(s, channel, engine, backend); ++ backend.set_relative(channel, false); ++ backend.voice_begin(channel, s.p.attack, s.p.loop_from, s.p.loop_to); ++ backend.voice_pose(channel, s.p.velocity, s.p.doppler); ++ backend.start(channel, &s.sound, s.p.looping); ++ if self.paused { ++ backend.pause(channel); ++ } ++ s.channel = Some(channel); ++ } ++ // One-shots that found no voice are never heard: forget them. ++ let mute: Vec = self ++ .sources ++ .iter() ++ .filter(|(_, s)| s.channel.is_none() && !s.p.looping) ++ .map(|(k, _)| *k) ++ .collect(); ++ for k in mute { ++ self.sources.remove(&k); ++ self.dropped += 1; ++ } ++ } ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ use crate::sound_events::SoundRegistry; ++ use std::collections::HashMap; ++ ++ /// A backend that records what the sources do to their channels. ++ #[derive(Default)] ++ struct Rec { ++ next: ChannelId, ++ live: HashMap, ++ destroyed: u32, ++ } ++ #[derive(Default, Clone)] ++ struct Chan { ++ at: [f64; 3], ++ volume: f32, ++ looping: bool, ++ started: bool, ++ stopped: bool, ++ releasing: Option, ++ attack: f32, ++ } ++ impl Backend for Rec { ++ fn create(&mut self, _: bool) -> Option { ++ self.next += 1; ++ self.live.insert(self.next, Chan::default()); ++ Some(self.next) ++ } ++ fn destroy(&mut self, c: ChannelId) { ++ self.live.remove(&c); ++ self.destroyed += 1; ++ } ++ fn set_pitch(&mut self, _: ChannelId, _: f32) {} ++ fn set_volume(&mut self, c: ChannelId, v: f32) { ++ if let Some(c) = self.live.get_mut(&c) { ++ c.volume = v; ++ } ++ } ++ fn set_attenuation(&mut self, _: ChannelId, _: Option) {} ++ fn set_looping(&mut self, c: ChannelId, l: bool) { ++ if let Some(c) = self.live.get_mut(&c) { ++ c.looping = l; ++ } ++ } ++ fn set_position(&mut self, c: ChannelId, p: [f64; 3]) { ++ if let Some(c) = self.live.get_mut(&c) { ++ c.at = p; ++ } ++ } ++ fn set_relative(&mut self, _: ChannelId, _: bool) {} ++ fn start(&mut self, c: ChannelId, _: &Sound, _: bool) { ++ if let Some(c) = self.live.get_mut(&c) { ++ c.started = true; ++ } ++ } ++ fn stop(&mut self, c: ChannelId) { ++ if let Some(c) = self.live.get_mut(&c) { ++ c.stopped = true; ++ } ++ } ++ fn pause(&mut self, _: ChannelId) {} ++ fn unpause(&mut self, _: ChannelId) {} ++ fn stopped(&mut self, c: ChannelId) -> bool { ++ self.live.get(&c).is_none_or(|c| c.stopped) ++ } ++ fn voice_begin(&mut self, c: ChannelId, attack: f32, _: f32, _: f32) { ++ if let Some(c) = self.live.get_mut(&c) { ++ c.attack = attack; ++ } ++ } ++ fn voice_release(&mut self, c: ChannelId, release: f32) { ++ if let Some(c) = self.live.get_mut(&c) { ++ c.releasing = Some(release); ++ } ++ } ++ } ++ ++ fn engine() -> SoundEngine { ++ let mut registry = SoundRegistry::default(); ++ let json = br#"{"hum":{"sounds":[{"name":"t:hum","attenuation_distance":100}]}, ++ "bang":{"sounds":[{"name":"t:bang","attenuation_distance":100}]}}"#; ++ registry.add_file("t", json, &|_, _| true); ++ SoundEngine::new(registry) ++ } ++ fn p(event: &str, x: f64, looping: bool) -> Params { ++ Params { ++ event: event.into(), ++ at: [x, 0.0, 0.0], ++ velocity: [0.0; 3], ++ gain: 1.0, ++ pitch: 1.0, ++ looping, ++ priority: 1.0, ++ attack: 0.1, ++ release: 0.2, ++ doppler: 0.0, ++ loop_from: 0.0, ++ loop_to: 0.0, ++ } ++ } ++ const ZERO: [f64; 3] = [0.0; 3]; ++ ++ #[test] ++ fn start_update_stop_follow_the_lifecycle() { ++ let (e, mut b, mut m) = (engine(), Rec::default(), ModSources::default()); ++ m.apply(vec![Change::Start((1, 5), p("t:hum", 10.0, true))], ZERO, &e, &mut b); ++ assert_eq!(m.counts(), [1, 1, 0]); ++ let c = *b.live.keys().next().unwrap(); ++ assert!(b.live[&c].started && b.live[&c].looping); ++ assert_eq!(b.live[&c].attack, 0.1); ++ m.apply(vec![Change::Update((1, 5), p("", 20.0, true))], ZERO, &e, &mut b); ++ assert_eq!(b.live[&c].at[0], 20.0); ++ // An update for an id that does not run (or another run's) changes nothing. ++ m.apply(vec![Change::Update((2, 5), p("", 99.0, true))], ZERO, &e, &mut b); ++ assert_eq!(b.live[&c].at[0], 20.0); ++ m.apply(vec![Change::Stop((1, 5))], ZERO, &e, &mut b); ++ assert_eq!(m.counts(), [0, 0, 1]); ++ assert_eq!(b.live[&c].releasing, Some(0.2)); ++ // The faded channel is given back once silent. ++ b.stop(c); ++ m.tick(ZERO, &e, &mut b); ++ assert_eq!(m.counts(), [0, 0, 0]); ++ assert!(b.live.is_empty()); ++ } ++ ++ #[test] ++ fn a_stop_of_another_run_or_generation_touches_nothing() { ++ let (e, mut b, mut m) = (engine(), Rec::default(), ModSources::default()); ++ m.apply(vec![Change::Start((1, 5), p("t:hum", 10.0, true))], ZERO, &e, &mut b); ++ m.apply(vec![Change::Stop((2, 5)), Change::Stop((1, 6))], ZERO, &e, &mut b); ++ assert_eq!(m.counts(), [1, 1, 0]); ++ } ++ ++ #[test] ++ fn the_budget_keeps_the_loud_near_ones_and_virtualises_the_rest_without_losing_a_stop() { ++ let (e, mut b, mut m) = (engine(), Rec::default(), ModSources::default()); ++ // 100 loops from 1 to 100 blocks: the budget gives voices to the nearest. ++ let starts = (0..100u32) ++ .map(|i| Change::Start((1, i), p("t:hum", f64::from(i) + 1.0, true))) ++ .collect(); ++ m.apply(starts, ZERO, &e, &mut b); ++ let [all, voiced, _] = m.counts(); ++ assert_eq!((all, voiced), (100, VOICES)); ++ assert!(b.live.values().all(|c| c.at[0] <= VOICES as f64 + 0.5)); ++ assert!(b.live.len() <= VOICES + RELEASING); ++ // The listener walks: the far ones win the voices, the near ones go virtual and live on. ++ m.tick([90.0, 0.0, 0.0], &e, &mut b); ++ assert_eq!(m.counts()[0], 100); ++ assert!(m.counts()[1] <= VOICES); ++ assert!(m.sources.iter().filter(|(_, s)| s.channel.is_some()).all(|(k, _)| k.1 > 60)); ++ // Stopping all of them leaves nothing, voiced or virtual. ++ let stops = (0..100u32).map(|i| Change::Stop((1, i))).collect(); ++ m.apply(stops, [90.0, 0.0, 0.0], &e, &mut b); ++ assert_eq!(m.counts()[0], 0); ++ for c in b.live.keys().copied().collect::>() { ++ b.stop(c); ++ } ++ m.tick(ZERO, &e, &mut b); ++ assert!(b.live.is_empty(), "no voice outlives its source"); ++ } ++ ++ #[test] ++ fn a_one_shot_without_a_voice_is_forgotten_and_a_finished_one_gives_its_channel_back() { ++ let (e, mut b, mut m) = (engine(), Rec::default(), ModSources::default()); ++ // Far beyond its range: never heard, so not kept. ++ m.apply(vec![Change::Start((1, 1), p("t:bang", 500.0, false))], ZERO, &e, &mut b); ++ assert_eq!(m.counts(), [0, 0, 0]); ++ m.apply(vec![Change::Start((1, 2), p("t:bang", 5.0, false))], ZERO, &e, &mut b); ++ assert_eq!(m.counts()[1], 1); ++ let c = *b.live.keys().next().unwrap(); ++ b.stop(c); ++ m.tick(ZERO, &e, &mut b); ++ assert_eq!(m.counts(), [0, 0, 0]); ++ assert!(b.live.is_empty()); ++ } ++ ++ #[test] ++ fn release_all_frees_every_channel_and_moves_the_generation() { ++ let (e, mut b, mut m) = (engine(), Rec::default(), ModSources::default()); ++ let starts = (0..30u32) ++ .map(|i| Change::Start((1, i), p("t:hum", 5.0, true))) ++ .collect(); ++ m.apply(starts, ZERO, &e, &mut b); ++ m.apply(vec![Change::Stop((1, 0))], ZERO, &e, &mut b); ++ let epoch = m.epoch; ++ m.release_all(&mut b); ++ assert!(b.live.is_empty()); ++ assert_eq!(m.counts(), [0, 0, 0]); ++ assert_eq!(m.epoch, epoch + 1); ++ } ++ ++ #[test] ++ fn a_run_that_is_gone_loses_its_sources() { ++ let (e, mut b, mut m) = (engine(), Rec::default(), ModSources::default()); ++ m.apply( ++ vec![ ++ Change::Start((1, 1), p("t:hum", 5.0, true)), ++ Change::Start((2, 1), p("t:hum", 6.0, true)), ++ ], ++ ZERO, ++ &e, ++ &mut b, ++ ); ++ m.retain_runs(&[2], &mut b); ++ assert_eq!(m.counts()[0], 1); ++ assert!(m.sources.contains_key(&(2, 1))); ++ } ++ ++ #[test] ++ fn a_hundred_start_seek_stop_cycles_accumulate_no_voices() { ++ let (e, mut b, mut m) = (engine(), Rec::default(), ModSources::default()); ++ for round in 0..100u32 { ++ let starts = (0..8u32) ++ .map(|i| Change::Start((1, round * 8 + i), p("t:hum", f64::from(i) + 2.0, true))) ++ .collect(); ++ m.apply(starts, ZERO, &e, &mut b); ++ // A seek: everything restarts under new ids, the old ones stop. ++ if round % 2 == 0 { ++ let stops = (0..8u32).map(|i| Change::Stop((1, round * 8 + i))).collect(); ++ m.apply(stops, ZERO, &e, &mut b); ++ } else { ++ m.release_all(&mut b); ++ } ++ for c in b.live.keys().copied().collect::>() { ++ if b.live[&c].releasing.is_some() { ++ b.stop(c); ++ } ++ } ++ m.tick(ZERO, &e, &mut b); ++ assert!(b.live.len() <= VOICES + RELEASING); ++ } ++ assert_eq!(m.counts(), [0, 0, 0]); ++ assert!(b.live.is_empty()); ++ } ++ ++ #[test] ++ fn a_restart_releases_the_old_voice_and_attacks_a_new_one() { ++ let (e, mut b, mut m) = (engine(), Rec::default(), ModSources::default()); ++ m.apply(vec![Change::Start((1, 1), p("t:hum", 5.0, true))], ZERO, &e, &mut b); ++ m.apply(vec![Change::Start((1, 1), p("t:hum", 6.0, true))], ZERO, &e, &mut b); ++ assert_eq!(m.counts(), [1, 1, 1]); ++ } ++} +diff --git a/crates/horizon-client/src/sound_engine.rs b/crates/horizon-client/src/sound_engine.rs +index f3e2dd75d..57a70f065 100644 +--- a/crates/horizon-client/src/sound_engine.rs ++++ b/crates/horizon-client/src/sound_engine.rs +@@ -573,6 +573,16 @@ pub trait Backend { + fn unpause(&mut self, channel: ChannelId); + /// `Channel.stopped`. + fn stopped(&mut self, channel: ChannelId) -> bool; ++ /// Mod sources (AirDef fork): the channel becomes a smoothed voice that fades in over ++ /// `attack` seconds and loops the part of the sound from `loop_from` to `loop_to` seconds ++ /// (0, 0: all of it), crossfading the join. ++ fn voice_begin(&mut self, _channel: ChannelId, _attack: f32, _loop_from: f32, _loop_to: f32) {} ++ /// A voice's velocity (blocks a second) and how much Doppler it takes (0 to 1). ++ fn voice_pose(&mut self, _channel: ChannelId, _velocity: [f32; 3], _doppler: f32) {} ++ /// A voice fades out over `release` seconds and stops by itself. ++ fn voice_release(&mut self, channel: ChannelId, _release: f32) { ++ self.stop(channel); ++ } + } + /// A `SoundEventListener.onPlaySound` call. + #[derive(Clone, Debug, PartialEq)] +diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit +index 2798c2bcd..b7d17d075 100644 +--- a/crates/horizon-mod-sdk/wit/horizon.wit ++++ b/crates/horizon-mod-sdk/wit/horizon.wit +@@ -594,6 +594,82 @@ interface render { + /// Plays a sound event (`minecraft:entity.generic.explode`, + /// `:`) at a point on this client. + play-sound: func(event: string, x: f64, y: f64, z: f64, volume: f32, pitch: f32); ++ /// What a `sound-sources` entry does. ++ enum source-op-kind { ++ /// Starts source `id` (one that is already running restarts: the old ++ /// one releases, the new one attacks). ++ start, ++ /// Sets everything of a running source: pose, velocity, gain, pitch, ++ /// looping and the rest. An id that is not running is ignored. ++ update, ++ /// Releases source `id` (fading over its `release`). Only `id` ++ /// is read. ++ stop, ++ } ++ /// One change of a persistent sound source (AirDef fork, API level 20): a ++ /// sound that goes on, follows a pose and has a lifetime, over the game's ++ /// own mixer. Unlike `play-sound` events they are not repeated; a source ++ /// is started, updated as often as the mod likes (a tick) and stopped. ++ /// The ids are the mod's own numbers (per mod, up to 256 running). ++ record source-op { ++ kind: source-op-kind, ++ id: u32, ++ /// The sound event (`:`), read by `start`: the same ++ /// variant is chosen for the whole life of the source. ++ event: string, ++ /// Where it is, in the world as the listener hears it (the listener ++ /// stays at the player; a mod that shows another place moves the ++ /// source by the offset itself). ++ x: f64, ++ y: f64, ++ z: f64, ++ /// Blocks a second. The mixer carries the source on along it between ++ /// calls (at most 0.15 s), so a source updated once a tick moves ++ /// without steps. Also what Doppler reads. ++ vx: f32, ++ vy: f32, ++ vz: f32, ++ /// 0 to 16, times the event's volume and the category sliders; above 1 it only widens ++ /// the distance it is heard from, as in vanilla. ++ gain: f32, ++ /// 0.5 to 2, the sound's own pitch (Doppler is added by `doppler`). ++ pitch: f32, ++ /// A looping source goes on until stopped; a source that is not ++ /// looping plays once and is gone. ++ looping: bool, ++ /// More important sources keep their voice when there are not enough ++ /// (0.1 to 10, 1 usually). Sources are ranked by priority times what ++ /// the listener would hear, 24 get a voice; the others stay virtual (silent but ++ /// alive, a loop only) and are heard when they rank again. ++ priority: f32, ++ /// Seconds to fade in (0 to 5; 0 is 5 ms, so there is no click). ++ attack: f32, ++ /// Seconds to fade out after `stop` (0 to 5; 0 is 5 ms). ++ release: f32, ++ /// How much of the Doppler shift of the velocity is applied, 0 (none, ++ /// the usual choice) to 1, bounded to a change of pitch of 12%. The ++ /// velocity is taken against a listener at rest. Leave 0 when the ++ /// pitch already follows the speed. ++ doppler: f32, ++ /// A looping source repeats the part of its sound from `loop-from` to ++ /// `loop-to` seconds into it (both 0: all of it), the join crossfaded over ++ /// 50 ms: for sounds that fade in and die away at their ends. ++ loop-from: f32, ++ loop-to: f32, ++ } ++ /// Moves on whenever the game released every source of every mod (the ++ /// world closed, the session ended, a film began or ended, the sounds ++ /// reloaded). A mod reads it before `sound-sources` and starts its ++ /// sources again when it changed. ++ sound-source-generation: func() -> u32; ++ /// Starts, updates and stops persistent sound sources. `generation` is ++ /// the `sound-source-generation` the mod's sources belong to; if it is ++ /// not the current one the call is ignored and returns false (read the ++ /// generation, forget the sources, start them again). Stops are never ++ /// dropped by a limit: only new sources beyond 256 running are, and at ++ /// most 512 entries a call count. Every source of a mod that stops or is ++ /// reloaded is released by the game. ++ sound-sources: func(generation: u32, ops: list) -> bool; + /// Asks for the ground height of these columns (x, z): at most 512 a + /// tick, answered by `ground` from the next tick on. + query-ground: func(columns: list>); +diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs +index 2300f0fcc..8abd768eb 100644 +--- a/crates/horizon-mods/src/api_level.rs ++++ b/crates/horizon-mods/src/api_level.rs +@@ -7,13 +7,13 @@ + //! version's level so the launcher can tell before it installs. + + /// The newest level this Horizon serves. +-pub const LEVEL: u32 = 19; ++pub const LEVEL: u32 = 20; + + /// Every function the game offers mods, as `interface.function`, with its + /// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new + /// function takes the next level and raises [`LEVEL`] (the `api_reference` + /// test fails until it is listed here). +-pub const FUNCTIONS: [(&str, u32); 80] = [ ++pub const FUNCTIONS: [(&str, u32); 82] = [ + ("log.write", 0), + ("game.get-player", 0), + ("game.get-clock", 0), +@@ -103,6 +103,9 @@ pub const FUNCTIONS: [(&str, u32); 80] = [ + // AirDef fork: a command's tree of subcommands and typed arguments. + ("command.set-tree", 19), + ("command.set-suggestions", 19), ++ // AirDef fork: persistent sound sources over the mixer. ++ ("render.sound-source-generation", 20), ++ ("render.sound-sources", 20), + ]; + + /// What the game's program carries so the launcher can read its level +diff --git a/crates/horizon-mods/src/lib.rs b/crates/horizon-mods/src/lib.rs +index d79eaf5d8..6627c7d04 100644 +--- a/crates/horizon-mods/src/lib.rs ++++ b/crates/horizon-mods/src/lib.rs +@@ -16,6 +16,8 @@ pub mod modpack; + #[cfg(feature = "runtime")] + mod runtime; + #[cfg(feature = "runtime")] ++pub mod sources; ++#[cfg(feature = "runtime")] + pub mod testing; + pub mod world_query; + +diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs +index 4762af39c..f974495d6 100644 +--- a/crates/horizon-mods/src/runtime.rs ++++ b/crates/horizon-mods/src/runtime.rs +@@ -87,7 +87,7 @@ pub use render_bindings::horizon::mods::render::{ + Lighting as ModLighting, Minimap as ModMinimap, ModelInstance, ModelInstanceId, ParticleSpawn, + Point as RenderPoint, PointLight, Policy as WorldPolicy, QuadShape, Ray as WorldRay, + RayAnswer as WorldRayAnswer, Rect as RenderRect, Section as WorldSection, SectionFill, Shape, +- SmokeSegment, SpotLight, SpriteShape, TextShape, Verdict as WorldVerdict, ViewShape, ++ SmokeSegment, SourceOp, SourceOpKind, SpotLight, SpriteShape, TextShape, Verdict as WorldVerdict, ViewShape, + Zoom as ModZoom, + }; + use render_bindings::{RenderHost, horizon::mods::render}; +@@ -326,6 +326,8 @@ pub struct ModelTable { + world_log_state: Option<(u32, u64, u64)>, + /// `render.set-world-time-view`: (log id, tick). + time_view: Option<(u32, u64)>, ++ /// `render.sound-source-generation`: moves on when the game released every source. ++ source_generation: u32, + } + + /// The ids of block-change logs: never reused in a run of the game. +@@ -535,6 +537,9 @@ struct State { + lights: Vec, + /// `render.set-spot-lights` (AirDef fork). + spots: Vec, ++ /// `render.sound-sources` (AirDef fork): the changes the game has not taken, and this run's number. ++ sources: crate::sources::SourceBook, ++ run_serial: u64, + lighting: Option, + /// `render.set-time-override` (AirDef fork). + time_override: Option, +@@ -790,6 +795,11 @@ impl Mods { + main_view: None, + lights: Vec::new(), + spots: Vec::new(), ++ sources: Default::default(), ++ run_serial: { ++ static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); ++ SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed) ++ }, + lighting: None, + time_override: None, + trees: Vec::new(), +@@ -1376,6 +1386,43 @@ impl Mods { + .flat_map(move |r| r.store.data().lights.iter().map(move |l| (i, l))) + }) + } ++ /// Moves the generation `render.sound-source-generation` reports (the game released ++ /// every source): the mods' pending source changes go with the old one. ++ pub fn set_source_generation(&mut self, generation: u32) { ++ let Ok(mut t) = self.model_table.lock() else { ++ return; ++ }; ++ if t.source_generation == generation { ++ return; ++ } ++ t.source_generation = generation; ++ drop(t); ++ for e in &mut self.entries { ++ if let Some(r) = e.run.as_mut() { ++ r.store.data_mut().sources.clear(); ++ } ++ } ++ } ++ /// The source changes every running mod made since the last call, tagged with the ++ /// number of the mod's run ([`Mods::source_runs`]). ++ pub fn take_source_changes(&mut self) -> Vec<(u64, crate::sources::SourceChange)> { ++ let mut out = Vec::new(); ++ for e in &mut self.entries { ++ if let Some(r) = e.run.as_mut() { ++ let d = r.store.data_mut(); ++ let serial = d.run_serial; ++ out.extend(d.sources.take().into_iter().map(|c| (serial, c))); ++ } ++ } ++ out ++ } ++ /// The runs that are alive: the game releases the sources of any other. ++ pub fn source_runs(&self) -> Vec { ++ self.entries ++ .iter() ++ .filter_map(|e| e.run.as_ref().map(|r| r.store.data().run_serial)) ++ .collect() ++ } + /// Every running mod's spot lights (AirDef fork), with the mod's index. + pub fn spot_lights(&self) -> impl Iterator { + self.entries.iter().enumerate().flat_map(|(i, e)| { +@@ -2643,6 +2690,50 @@ impl render::Host for State { + sections: Vec::new(), + }) + } ++ fn sound_source_generation(&mut self) -> u32 { ++ self.model_table.lock().map_or(0, |t| t.source_generation) ++ } ++ fn sound_sources(&mut self, generation: u32, ops: Vec) -> bool { ++ if self ++ .model_table ++ .lock() ++ .map_or(true, |t| t.source_generation != generation) ++ { ++ return false; ++ } ++ use crate::sources::{OPS_PER_CALL, SourceKind, SourceParams}; ++ for (i, o) in ops.into_iter().enumerate() { ++ let kind = match o.kind { ++ SourceOpKind::Start => SourceKind::Start, ++ SourceOpKind::Update => SourceKind::Update, ++ SourceOpKind::Stop => SourceKind::Stop, ++ }; ++ // Past the call's count only stops are read: a limit never loses one. ++ if i >= OPS_PER_CALL && kind != SourceKind::Stop { ++ continue; ++ } ++ let params = (kind != SourceKind::Stop) ++ .then(|| { ++ SourceParams::new( ++ &o.event, ++ [o.x, o.y, o.z], ++ [o.vx, o.vy, o.vz], ++ o.gain, ++ o.pitch, ++ o.looping, ++ o.priority, ++ o.attack, ++ o.release, ++ o.doppler, ++ o.loop_from, ++ o.loop_to, ++ ) ++ }) ++ .flatten(); ++ self.sources.apply(kind, o.id, params); ++ } ++ true ++ } + fn play_sound(&mut self, event: String, x: f64, y: f64, z: f64, volume: f32, pitch: f32) { + if !(x.is_finite() && y.is_finite() && z.is_finite()) { + return; +diff --git a/crates/horizon-mods/src/sources.rs b/crates/horizon-mods/src/sources.rs +new file mode 100644 +index 000000000..0bdbf0644 +--- /dev/null ++++ b/crates/horizon-mods/src/sources.rs +@@ -0,0 +1,315 @@ ++//! Persistent sound sources (`render.sound-sources`, AirDef fork, API level 20): the ++//! book a mod's start/update/stop calls are written in until the game takes them. ++//! ++//! The book keeps one entry per id with the *net* change the game has not seen yet, so ++//! what a mod does between two takes cannot grow it, and a limit can never swallow a stop: ++//! only the starts of new sources beyond [`MAX_SOURCES`] running are refused, and a stop of ++//! a source the game was told of always reaches it. ++use std::collections::{BTreeMap, HashSet}; ++ ++/// Sources a mod may have running (started and not stopped). ++pub const MAX_SOURCES: usize = 256; ++/// Entries one call reads before only stops count. ++pub const OPS_PER_CALL: usize = 512; ++ ++/// What one entry does. ++#[derive(Clone, Copy, Debug, PartialEq, Eq)] ++pub enum SourceKind { ++ Start, ++ Update, ++ Stop, ++} ++ ++/// A source as the game plays it, every number already in its range. ++#[derive(Clone, Debug, PartialEq)] ++pub struct SourceParams { ++ /// The sound event; empty in an update (the source keeps the one it started with). ++ pub event: String, ++ pub at: [f64; 3], ++ /// Blocks a second. ++ pub velocity: [f32; 3], ++ pub gain: f32, ++ pub pitch: f32, ++ pub looping: bool, ++ pub priority: f32, ++ /// Seconds. ++ pub attack: f32, ++ pub release: f32, ++ pub doppler: f32, ++ /// The part of the sound a loop repeats, seconds into it (0, 0: all of it). ++ pub loop_from: f32, ++ pub loop_to: f32, ++} ++ ++impl SourceParams { ++ /// Clamps what a mod gave; None for a position that is not a place. ++ #[allow(clippy::too_many_arguments)] ++ pub fn new( ++ event: &str, ++ at: [f64; 3], ++ velocity: [f32; 3], ++ gain: f32, ++ pitch: f32, ++ looping: bool, ++ priority: f32, ++ attack: f32, ++ release: f32, ++ doppler: f32, ++ loop_from: f32, ++ loop_to: f32, ++ ) -> Option { ++ if !at.iter().all(|v| v.is_finite() && v.abs() < 30_000_000.0) { ++ return None; ++ } ++ let num = |v: f32, lo: f32, hi: f32, default: f32| { ++ if v.is_finite() { ++ v.clamp(lo, hi) ++ } else { ++ default ++ } ++ }; ++ Some(Self { ++ event: event.chars().take(128).collect(), ++ at, ++ velocity: velocity.map(|v| num(v, -2000.0, 2000.0, 0.0)), ++ gain: num(gain, 0.0, 16.0, 1.0), ++ pitch: num(pitch, 0.5, 2.0, 1.0), ++ looping, ++ priority: num(priority, 0.1, 10.0, 1.0), ++ attack: num(attack, 0.0, 5.0, 0.0), ++ release: num(release, 0.0, 5.0, 0.0), ++ doppler: num(doppler, 0.0, 1.0, 0.0), ++ loop_from: num(loop_from, 0.0, 600.0, 0.0), ++ loop_to: num(loop_to, 0.0, 600.0, 0.0), ++ }) ++ } ++} ++ ++/// A change the game has to make. ++#[derive(Clone, Debug, PartialEq)] ++pub enum SourceChange { ++ Start(u32, SourceParams), ++ Update(u32, SourceParams), ++ Stop(u32), ++} ++ ++#[derive(Clone, Debug)] ++enum Next { ++ Start(SourceParams), ++ Update(SourceParams), ++} ++ ++#[derive(Clone, Debug, Default)] ++struct Pending { ++ /// The game has a source of this id to stop first (a stop, or a restart). ++ stop_first: bool, ++ next: Option, ++} ++ ++/// One mod's sources. ++#[derive(Default)] ++pub struct SourceBook { ++ live: HashSet, ++ pending: BTreeMap, ++} ++ ++impl SourceBook { ++ /// How many sources the mod has running. ++ pub fn running(&self) -> usize { ++ self.live.len() ++ } ++ ++ /// Writes one entry. False when a start was refused (too many running, or no place). ++ pub fn apply(&mut self, kind: SourceKind, id: u32, params: Option) -> bool { ++ match kind { ++ SourceKind::Start => { ++ let Some(p) = params else { ++ return false; ++ }; ++ if !self.live.contains(&id) && self.live.len() >= MAX_SOURCES { ++ return false; ++ } ++ let known_to_game = self.live.contains(&id) ++ && !matches!( ++ self.pending.get(&id), ++ Some(Pending { ++ stop_first: false, ++ next: Some(Next::Start(_)) ++ }) ++ ); ++ self.live.insert(id); ++ let e = self.pending.entry(id).or_default(); ++ if known_to_game { ++ e.stop_first = true; ++ } ++ e.next = Some(Next::Start(p)); ++ true ++ } ++ SourceKind::Update => { ++ let Some(mut p) = params else { ++ return false; ++ }; ++ if !self.live.contains(&id) { ++ return false; ++ } ++ let e = self.pending.entry(id).or_default(); ++ match &e.next { ++ Some(Next::Start(old)) => { ++ p.event = old.event.clone(); ++ e.next = Some(Next::Start(p)); ++ } ++ _ => { ++ p.event.clear(); ++ e.next = Some(Next::Update(p)); ++ } ++ } ++ true ++ } ++ SourceKind::Stop => { ++ if !self.live.remove(&id) { ++ return false; ++ } ++ match self.pending.get_mut(&id) { ++ Some(Pending { ++ stop_first: false, ++ next: Some(Next::Start(_)), ++ }) => { ++ // The game never heard of it. ++ self.pending.remove(&id); ++ } ++ Some(e) => { ++ e.stop_first = true; ++ e.next = None; ++ } ++ None => { ++ self.pending.insert( ++ id, ++ Pending { ++ stop_first: true, ++ next: None, ++ }, ++ ); ++ } ++ } ++ true ++ } ++ } ++ } ++ ++ /// What the game has to do now, by id (a stop before the start of the same id), and ++ /// nothing pending after. ++ pub fn take(&mut self) -> Vec { ++ let mut out = Vec::new(); ++ for (id, e) in std::mem::take(&mut self.pending) { ++ if e.stop_first { ++ out.push(SourceChange::Stop(id)); ++ } ++ match e.next { ++ Some(Next::Start(p)) => out.push(SourceChange::Start(id, p)), ++ Some(Next::Update(p)) => out.push(SourceChange::Update(id, p)), ++ None => {} ++ } ++ } ++ out ++ } ++ ++ /// Forgets everything: the game released every source (a new generation). ++ pub fn clear(&mut self) { ++ self.live.clear(); ++ self.pending.clear(); ++ } ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ fn p(event: &str, x: f64) -> SourceParams { ++ SourceParams::new(event, [x, 0.0, 0.0], [0.0; 3], 1.0, 1.0, true, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0) ++ .unwrap() ++ } ++ ++ #[test] ++ fn start_update_stop_reach_the_game_in_order() { ++ let mut b = SourceBook::default(); ++ assert!(b.apply(SourceKind::Start, 1, Some(p("a:b", 1.0)))); ++ assert!(b.apply(SourceKind::Update, 1, Some(p("", 2.0)))); ++ // Merged: one start with the newest pose, the event kept. ++ let c = b.take(); ++ assert_eq!(c, vec![SourceChange::Start(1, p("a:b", 2.0))]); ++ assert!(b.apply(SourceKind::Update, 1, Some(p("ignored", 3.0)))); ++ assert_eq!(b.take(), vec![SourceChange::Update(1, p("", 3.0))]); ++ assert!(b.apply(SourceKind::Stop, 1, None)); ++ assert_eq!(b.take(), vec![SourceChange::Stop(1)]); ++ assert_eq!(b.running(), 0); ++ // Not running any more: ignored. ++ assert!(!b.apply(SourceKind::Update, 1, Some(p("", 4.0)))); ++ assert!(!b.apply(SourceKind::Stop, 1, None)); ++ assert!(b.take().is_empty()); ++ } ++ ++ #[test] ++ fn a_source_the_game_never_heard_of_leaves_no_trace() { ++ let mut b = SourceBook::default(); ++ for _ in 0..1000 { ++ b.apply(SourceKind::Start, 7, Some(p("a:b", 0.0))); ++ b.apply(SourceKind::Stop, 7, None); ++ } ++ assert!(b.take().is_empty()); ++ } ++ ++ #[test] ++ fn a_stop_after_the_game_knows_the_source_is_never_lost() { ++ let mut b = SourceBook::default(); ++ b.apply(SourceKind::Start, 1, Some(p("a:b", 0.0))); ++ b.take(); ++ // Churn between two takes stays bounded and ends in the stop. ++ for i in 0..1000 { ++ b.apply(SourceKind::Start, 1, Some(p("a:b", f64::from(i)))); ++ b.apply(SourceKind::Stop, 1, None); ++ } ++ assert_eq!(b.take(), vec![SourceChange::Stop(1)]); ++ // A restart is a stop, then a start. ++ b.apply(SourceKind::Start, 2, Some(p("a:b", 0.0))); ++ b.take(); ++ b.apply(SourceKind::Start, 2, Some(p("a:c", 5.0))); ++ assert_eq!( ++ b.take(), ++ vec![SourceChange::Stop(2), SourceChange::Start(2, p("a:c", 5.0))] ++ ); ++ } ++ ++ #[test] ++ fn the_limit_refuses_new_starts_but_never_stops() { ++ let mut b = SourceBook::default(); ++ for id in 0..MAX_SOURCES as u32 { ++ assert!(b.apply(SourceKind::Start, id, Some(p("a:b", 0.0)))); ++ } ++ assert!(!b.apply(SourceKind::Start, 9999, Some(p("a:b", 0.0)))); ++ // An id already running restarts even at the limit. ++ assert!(b.apply(SourceKind::Start, 5, Some(p("a:b", 0.0)))); ++ b.take(); ++ for id in 0..MAX_SOURCES as u32 { ++ assert!(b.apply(SourceKind::Stop, id, None)); ++ } ++ let stops = b.take(); ++ assert_eq!(stops.len(), MAX_SOURCES); ++ assert!(stops.iter().all(|c| matches!(c, SourceChange::Stop(_)))); ++ assert!(b.apply(SourceKind::Start, 9999, Some(p("a:b", 0.0)))); ++ } ++ ++ #[test] ++ fn numbers_are_clamped_and_a_place_must_be_one() { ++ let q = SourceParams::new( ++ "a:b", [0.0; 3], [f32::NAN, 1e9, 0.0], 99.0, 9.0, false, 0.0, -1.0, 99.0, 5.0, 0.5, 9.0, ++ ) ++ .unwrap(); ++ assert_eq!(q.velocity, [0.0, 2000.0, 0.0]); ++ assert_eq!((q.gain, q.pitch, q.priority), (16.0, 2.0, 0.1)); ++ assert_eq!((q.attack, q.release, q.doppler, q.loop_to), (0.0, 5.0, 1.0, 9.0)); ++ assert!( ++ SourceParams::new("a:b", [f64::NAN, 0.0, 0.0], [0.0; 3], 1.0, 1.0, true, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0) ++ .is_none() ++ ); ++ } ++} +diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs +index b0aa8d322..2730984a6 100644 +--- a/crates/horizon-mods/tests/sandbox.rs ++++ b/crates/horizon-mods/tests/sandbox.rs +@@ -838,6 +838,42 @@ fn quiet_commands_come_out_as_one_batch_capped() { + assert_eq!(batches[0].1[255], "setblock 255 300 0 stone"); + } + ++#[test] ++fn sound_sources_follow_the_generation_and_the_run() { ++ use horizon_mods::sources::SourceChange; ++ let mut mods = load("horizon_mods_test_ui_guest.hmod"); ++ let feedback = |mods: &mut Mods| -> Vec { ++ mods.drain() ++ .into_iter() ++ .filter_map(|o| match o { ++ Output::Feedback { text, .. } => Some(text), ++ _ => None, ++ }) ++ .collect() ++ }; ++ // The guest starts a source and stops it in one call: the game never hears of it. ++ assert!(mods.command("source")); ++ assert_eq!(feedback(&mut mods), ["true"]); ++ assert!(mods.take_source_changes().is_empty()); ++ // A new generation (the game released every source): the guest reads it and goes on. ++ mods.set_source_generation(7); ++ assert!(mods.command("source")); ++ assert_eq!(feedback(&mut mods), ["true"]); ++ let runs = mods.source_runs(); ++ assert_eq!(runs.len(), 1); ++ // A source that stays is handed to the game once, tagged with the run. ++ assert!(mods.command("source_keep")); ++ let _ = feedback(&mut mods); ++ let changes = mods.take_source_changes(); ++ assert!(matches!(&changes[..], [(run, SourceChange::Start(1, _))] if *run == runs[0])); ++ assert!(mods.take_source_changes().is_empty()); ++ // The game released everything: the generation moves, pending changes go with it. ++ assert!(mods.command("source_keep")); ++ let _ = feedback(&mut mods); ++ mods.set_source_generation(8); ++ assert!(mods.take_source_changes().is_empty()); ++} ++ + #[test] + fn corner_panels_text_width_and_readings() { + let mut mods = load("horizon_mods_test_ui_guest.hmod"); +diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs +index 2696da95b..5219e1ffe 100644 +--- a/crates/horizon-mods/tests/ui_guest/src/lib.rs ++++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs +@@ -116,6 +116,8 @@ impl Mod for Guest { + "raycast", + "quiet", + "quiet_many", ++ "source", ++ "source_keep", + "recipes", + "browser", + "browser_poll", +@@ -263,6 +265,37 @@ impl Mod for Guest { + }); + render::set_far_terrain(far); + } ++ // API level 20: the generation, and a source started and stopped at once. ++ "source" | "source_keep" => { ++ let g = render::sound_source_generation(); ++ let op = |kind| render::SourceOp { ++ kind, ++ id: 1, ++ event: "a:b".into(), ++ x: 0.0, ++ y: 0.0, ++ z: 0.0, ++ vx: 0.0, ++ vy: 0.0, ++ vz: 0.0, ++ gain: 1.0, ++ pitch: 1.0, ++ looping: true, ++ priority: 1.0, ++ attack: 0.0, ++ release: 0.0, ++ doppler: 0.0, ++ loop_from: 0.0, ++ loop_to: 0.0, ++ }; ++ let ops = if name == "source_keep" { ++ vec![op(render::SourceOpKind::Start)] ++ } else { ++ vec![op(render::SourceOpKind::Start), op(render::SourceOpKind::Stop)] ++ }; ++ let ok = render::sound_sources(g, &ops); ++ return Ok(Some(format!("{ok}"))); ++ } + "saves" => command::set_suggestions("saves", &args.split(',').map(str::to_owned).collect::>()), + "quiet" => command::run_quiet(&args.split(';').map(str::to_owned).collect::>()), + "quiet_many" => { +diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md +index 22ad00589..c33e79ec4 100644 +--- a/docs/api/mods-reference.md ++++ b/docs/api/mods-reference.md +@@ -553,6 +553,41 @@ Particles to spawn on this client (AirDef fork). + | `color` | `u32` | 0xRRGGBB for dust and coloured types (0: white). | + | `scale` | `f32` | Dust size (0.01 to 4). | + ++### enum `source-op-kind` ++ ++What a `sound-sources` entry does. ++ ++| Case | | ++| --- | --- | ++| `start` | Starts source `id` (one that is already running restarts: the old one releases, the new one attacks). | ++| `update` | Sets everything of a running source: pose, velocity, gain, pitch, looping and the rest. An id that is not running is ignored. | ++| `stop` | Releases source `id` (fading over its `release`). Only `id` is read. | ++ ++### record `source-op` ++ ++One change of a persistent sound source (AirDef fork, API level 20): a sound that goes on, follows a pose and has a lifetime, over the game's own mixer. Unlike `play-sound` events they are not repeated; a source is started, updated as often as the mod likes (a tick) and stopped. The ids are the mod's own numbers (per mod, up to 256 running). ++ ++| Field | Type | | ++| --- | --- | --- | ++| `kind` | `source-op-kind` | | ++| `id` | `u32` | | ++| `event` | `string` | The sound event (`:`), read by `start`: the same variant is chosen for the whole life of the source. | ++| `x` | `f64` | Where it is, in the world as the listener hears it (the listener stays at the player; a mod that shows another place moves the source by the offset itself). | ++| `y` | `f64` | | ++| `z` | `f64` | | ++| `vx` | `f32` | Blocks a second. The mixer carries the source on along it between calls (at most 0.15 s), so a source updated once a tick moves without steps. Also what Doppler reads. | ++| `vy` | `f32` | | ++| `vz` | `f32` | | ++| `gain` | `f32` | 0 to 16, times the event's volume and the category sliders; above 1 it only widens the distance it is heard from, as in vanilla. | ++| `pitch` | `f32` | 0.5 to 2, the sound's own pitch (Doppler is added by `doppler`). | ++| `looping` | `bool` | A looping source goes on until stopped; a source that is not looping plays once and is gone. | ++| `priority` | `f32` | More important sources keep their voice when there are not enough (0.1 to 10, 1 usually). Sources are ranked by priority times what the listener would hear, 24 get a voice; the others stay virtual (silent but alive, a loop only) and are heard when they rank again. | ++| `attack` | `f32` | Seconds to fade in (0 to 5; 0 is 5 ms, so there is no click). | ++| `release` | `f32` | Seconds to fade out after `stop` (0 to 5; 0 is 5 ms). | ++| `doppler` | `f32` | How much of the Doppler shift of the velocity is applied, 0 (none, the usual choice) to 1, bounded to a change of pitch of 12%. The velocity is taken against a listener at rest. Leave 0 when the pitch already follows the speed. | ++| `loop-from` | `f32` | A looping source repeats the part of its sound from `loop-from` to `loop-to` seconds into it (both 0: all of it), the join crossfaded over 50 ms: for sounds that fade in and die away at their ends. | ++| `loop-to` | `f32` | | ++ + ### enum `policy` + + What a block is to a query (AirDef fork): the three policies differ on purpose, so a sensor and a missile may disagree about a pane of glass. +@@ -767,6 +802,8 @@ Where a capture is. + | `set-models-by-id` | `func(generation: u32, models: list) -> bool` | `set-models` with handles instead of names: the same models, limits and interpolation, fewer bytes across the boundary. `generation` is the `model-generation` the handles were asked under; if it is not the current one the call is ignored and returns false (re-ask the handles and call again). Handles the game doesn't know draw nothing. | + | `spawn-particles` | `func(particles: list)` | Spawns particles on this client only: no command, no server. At most 512 spawns a call. | + | `play-sound` | `func(event: string, x: f64, y: f64, z: f64, volume: f32, pitch: f32)` | Plays a sound event (`minecraft:entity.generic.explode`, `:`) at a point on this client. | ++| `sound-source-generation` | `func() -> u32` | Moves on whenever the game released every source of every mod (the world closed, the session ended, a film began or ended, the sounds reloaded). A mod reads it before `sound-sources` and starts its sources again when it changed. | ++| `sound-sources` | `func(generation: u32, ops: list) -> bool` | Starts, updates and stops persistent sound sources. `generation` is the `sound-source-generation` the mod's sources belong to; if it is not the current one the call is ignored and returns false (read the generation, forget the sources, start them again). Stops are never dropped by a limit: only new sources beyond 256 running are, and at most 512 entries a call count. Every source of a mod that stops or is reloaded is released by the game. | + | `query-ground` | `func(columns: list>)` | Asks for the ground height of these columns (x, z): at most 512 a tick, answered by `ground` from the next tick on. | + | `ground` | `func() -> list>` | The answers to the last tick's `query-ground`: (x, z, y), y one above the highest block that blocks motion or holds a fluid, as the motion blocking heightmap; -64 where the chunk isn't loaded. | + | `query-rays` | `func(rays: list)` | Traces segments through the loaded blocks (AirDef fork): each is walked block by block on the host, against the real collision shapes (`policy`), and answered by `rays` from the next tick on. At most 256 segments a tick, each at most 512 blocks long (longer ones are answered `unknown` past that); over the budget they are answered `unknown` too, never `clear`. A game with no chunk at a place answers `unknown` for it. | +diff --git a/docs/api/mods.md b/docs/api/mods.md +index c255056e7..7f0db0417 100644 +--- a/docs/api/mods.md ++++ b/docs/api/mods.md +@@ -106,6 +106,14 @@ What real mods need next, in the order modpacks and servers need it: + + ## Changelog + ++- API level 20 (AirDef fork, capability `render`): `sound-sources` starts, updates and stops persistent sound ++ sources (pose, velocity, gain, pitch, looping, priority, attack, release, optional bounded Doppler, loop overlap) ++ over the game's own mixer, and `sound-source-generation` tells the mod when the game released them all (world ++ closed, film begun or ended, sounds reloaded) so it starts them again. A stale generation is ignored. Gain, pan and ++ pitch are smoothed across each audio buffer and the position is carried along the velocity between calls, so a ++ source updated once a tick has no steps; 24 sources hold a voice (loudest and nearest times priority), the rest of ++ the loops stay virtual; a stop is never lost to a limit; the sources of a mod that stops or is reloaded are ++ released. A film mixes them on its own clock like `play-sound`. + - API level 19 (AirDef fork, capability `command`): `set-tree` gives a client mod's command a tree of subcommands + (literals), typed words (`word`, `choice`, `integer` and `number` with bounds, `names` from a list, `block-pos`), + optional branches (`runs`), the rest of the line (`text`) and `key=value` options; `set-suggestions` sets the list a +-- +2.54.0 (Apple Git-157) +