From a4ec831a02497cc93abd87bdc938f8d33accd31a Mon Sep 17 00:00:00 2001 From: Hails <26784904+Hailey-Ross@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:21:12 -0600 Subject: [PATCH] Audio: host clock on physics steps, review fixes, world / NPC audio hook-in Review fixes: - The audio host takes each physics step exactly once (one-step pulses latched, nothing while paused or silenced, at most 4 steps per frame), so pushes and other one-frame events are never dropped or doubled. e2e at 60 fps and above is byte-identical; 30 / 45 fps change only by the latch and the step pair. - Removed the emitter PROFILES fallback, the stray trace dump and the audition script; data-gated tests are #[ignore = "needs the private install data"] and fail loudly when run without it; per-post log lines behind SKATE_AUDIO_TRACE; checked bank parsing, finite output, camera-cut Doppler reset, fewer render allocations. - The 15 verdict-settled A/B switches are removed; their "on" behaviour is the only one (byte-identical). World / NPC audio, hookable by engine systems and mods: - Map-change fix: world and NPC hosts reset when the map's banks are unloaded, so surviving objects sound again. - Engine surface (skate-game::world_audio): TrafficAudio / PedAudio / NpcSkaterAudio components, horn / alarm / speech messages, read-back of who is audible; retail limits (4 cars within 40 m, 15 peds within 50 m with footsteps for the nearest 3, one NPC skater within 30 m), measured in the recompilation. - Mod surface (Lua API 2, `sdk.world_audio.*`); test mod `mods/world-audio-test/` (cars, peds, a ghost skater). - Per-skater audio state builder (local player output identical); remote players take the NPC skater slot; an opt-in non-retail "more audible" setting. - Docs: doc 15 "Hooking up world audio", doc 11 sections, PR plan. --- .gitignore | 13 +- crates/skate-audio-fma/src/lib.rs | 4 + crates/skate-audio/src/bus/flange.rs | 3 +- crates/skate-audio/src/dsp/pan.rs | 5 +- crates/skate-audio/src/eval/mod.rs | 46 +- crates/skate-audio/src/eval/synthetic.rs | 219 ++++++++ crates/skate-audio/src/eval/tests.rs | 216 +------- crates/skate-audio/src/formats/abk.rs | 3 +- crates/skate-audio/src/mixmap/tables.rs | 4 +- crates/skate-audio/src/mixmap/tests.rs | 8 +- crates/skate-audio/src/player/collision.rs | 8 +- crates/skate-audio/src/player/components.rs | 3 +- crates/skate-audio/src/player/contacts.rs | 12 +- crates/skate-audio/src/player/objpos.rs | 3 +- crates/skate-audio/src/player/seams.rs | 9 +- crates/skate-audio/src/player/state.rs | 77 +++ crates/skate-audio/src/runtime.rs | 8 + crates/skate-audio/src/splice/tests.rs | 6 +- crates/skate-audio/src/world/skaters.rs | 16 +- crates/skate-audio/tests/disc_banks.rs | 12 +- crates/skate-audio/tests/disc_grains.rs | 7 +- crates/skate-audio/tests/dsp_oracle.rs | 12 +- crates/skate-audio/tests/offboard_levels.rs | 7 +- crates/skate-audio/tests/player_tricks.rs | 34 +- crates/skate-audio/tests/render_alloc.rs | 62 ++- crates/skate-audio/tests/rolling_banks.rs | 33 +- crates/skate-audio/tests/world_sources.rs | 12 +- crates/skate-audio/tests/world_speech.rs | 6 +- crates/skate-game/src/game_audio/e2e.rs | 344 ++---------- crates/skate-game/src/game_audio/emitters.rs | 270 ++------- crates/skate-game/src/game_audio/grain_bed.rs | 43 +- crates/skate-game/src/game_audio/library.rs | 12 +- crates/skate-game/src/game_audio/mod.rs | 21 +- crates/skate-game/src/game_audio/native.rs | 514 +++++++++++++----- .../src/game_audio/native/prefetch.rs | 10 +- .../skate-game/src/game_audio/npc_skaters.rs | 91 +++- .../src/game_audio/npc_skaters/tests.rs | 72 ++- .../skate-game/src/game_audio/player_audio.rs | 158 +++--- .../skate-game/src/game_audio/skate_events.rs | 313 ++++++----- .../skate-game/src/game_audio/state_replay.rs | 352 ++++++++++++ .../skate-game/src/game_audio/world_bridge.rs | 425 +++++++++++++++ .../src/game_audio/world_sources.rs | 232 +++++++- crates/skate-game/src/main.rs | 1 + crates/skate-game/src/modding/mod.rs | 12 + crates/skate-game/src/modding/world_audio.rs | 313 +++++++++++ crates/skate-game/src/physics.rs | 4 + crates/skate-game/src/presentation.rs | 4 + .../src/tests/audio_state_capture.rs | 92 ++++ crates/skate-game/src/world_audio.rs | 300 ++++++++++ crates/skate-mods/src/api.lua | 27 + crates/skate-mods/src/lib.rs | 1 + crates/skate-mods/src/vm.rs | 83 +++ crates/skate-mods/src/world_audio.rs | 224 ++++++++ docs/hails-additions/11-audio.md | 208 ++++++- docs/hails-additions/15-world-audio.md | 271 +++++++++ docs/hails-additions/PULL-REQUESTS.md | 4 +- docs/hails-additions/README.md | 1 + mods/world-audio-test/main.lua | 263 +++++++++ mods/world-audio-test/mod.json | 59 ++ sdk/skate.lua | 53 ++ tools/audio_audition.py | 96 ---- trace-downtown-lag.json | 5 - 62 files changed, 4359 insertions(+), 1367 deletions(-) create mode 100644 crates/skate-audio/src/eval/synthetic.rs create mode 100644 crates/skate-game/src/game_audio/state_replay.rs create mode 100644 crates/skate-game/src/game_audio/world_bridge.rs create mode 100644 crates/skate-game/src/modding/world_audio.rs create mode 100644 crates/skate-game/src/tests/audio_state_capture.rs create mode 100644 crates/skate-game/src/world_audio.rs create mode 100644 crates/skate-mods/src/world_audio.rs create mode 100644 docs/hails-additions/15-world-audio.md create mode 100644 mods/world-audio-test/main.lua create mode 100644 mods/world-audio-test/mod.json delete mode 100644 tools/audio_audition.py delete mode 100644 trace-downtown-lag.json diff --git a/.gitignore b/.gitignore index a2aa28a7..6045be58 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ /logs/ /.local/ /settings/ +# TRACE_PLAY.bat's CPU trace output. +/trace-*.json /data/ __pycache__/ *.pyc @@ -48,8 +50,12 @@ __pycache__/ *.mus *.mpf *.xma -*.wav -!/mods/Skyline_Drive_Mod/audio/*.wav +# Decoded retail WAVs: setup and the research tools write them only into the private install +# folders (also ignored whole above); mods and the SDK may ship their own WAVs anywhere else. +/assets/private/**/*.wav +/data/**/*.wav +/maps/private/**/*.wav +/.local/**/*.wav .env .env.* *.pem @@ -100,3 +106,6 @@ __pycache__/ # Claude Code notes and skills (local only). /.claude/ + +# The world audio test mod's ghost input: the user's own recorded audio state logs (never committed). +mods/world-audio-test/logs/ diff --git a/crates/skate-audio-fma/src/lib.rs b/crates/skate-audio-fma/src/lib.rs index fd17e77c..a730982a 100644 --- a/crates/skate-audio-fma/src/lib.rs +++ b/crates/skate-audio-fma/src/lib.rs @@ -38,6 +38,10 @@ pub const TWO_PI: f32 = std::f32::consts::TAU; pub const INV_TWO_PI: f32 = f32::from_bits(0x3E22_F983); /// The resampler's interpolation weight constant (≈ 1/65536, about one part in 2^21 off). pub const WEIGHT: f32 = f32::from_bits(0x377F_FC9C); +// Credit: the sin / cos polynomials are XNA Math's `XMVectorSin` / `XMVectorCos` (the Xbox 360 +// math library the title links; its successor is DirectXMath, Microsoft, MIT License, +// https://github.com/microsoft/DirectXMath). The coefficients are the Taylor terms ±1/n! as f32, +// read from the shipped image at the addresses below; the evaluation order follows the image. /// `XMVectorSin` coefficients for V³ … V²³ (`0x822F97C4` … `0x822F97EC`). pub const SIN: [u32; 11] = [ 0xBE2A_AAAB, 0x3C08_8889, 0xB950_0D01, 0x3638_EF1D, 0xB2D7_322B, 0x2F30_9231, 0xAB57_3F9F, 0x274A_963C, 0xA317_A4DA, 0x1EB8_DC78, diff --git a/crates/skate-audio/src/bus/flange.rs b/crates/skate-audio/src/bus/flange.rs index 20c877bd..22f3599f 100644 --- a/crates/skate-audio/src/bus/flange.rs +++ b/crates/skate-audio/src/bus/flange.rs @@ -177,10 +177,11 @@ mod tests { } #[test] + #[ignore = "needs the private install data"] fn the_reverb_owner_gives_the_retail_free_skate_levels() { use crate::mixmap::{MixMap, keys}; let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/audio/aems/MixMapSK8.mxb")); - let Ok(bytes) = bytes else { return eprintln!("skipped: no MixMap") }; + let Ok(bytes) = bytes else { panic!("missing private data: no MixMap") }; let levels = |in5: i32| { let mut m = MixMap::from_bytes(&bytes).unwrap(); for _ in 0..30 { diff --git a/crates/skate-audio/src/dsp/pan.rs b/crates/skate-audio/src/dsp/pan.rs index 78e7e18b..e94e3411 100644 --- a/crates/skate-audio/src/dsp/pan.rs +++ b/crates/skate-audio/src/dsp/pan.rs @@ -223,15 +223,16 @@ mod tests { /// Spec worked gains (mono, focus = level = 1): A (deg), D, focus → L, C, R, Ls, Rs. #[test] fn worked_gains_from_the_spec() { + use std::f32::consts::FRAC_1_SQRT_2; let cases: &[(f32, f32, f32, [f32; 5])] = &[ (0.0, 1.0, 1.0, [0.0, 1.0, 0.0, 0.0, 0.0]), (-30.0, 1.0, 1.0, [1.0, 0.0, 0.0, 0.0, 0.0]), (30.0, 1.0, 1.0, [0.0, 0.0, 1.0, 0.0, 0.0]), - (-15.0, 1.0, 1.0, [0.7071, 0.7071, 0.0, 0.0, 0.0]), + (-15.0, 1.0, 1.0, [FRAC_1_SQRT_2, FRAC_1_SQRT_2, 0.0, 0.0, 0.0]), (-15.0, 1.0, 0.0, [0.9391, 0.0, 0.3437, 0.0, 0.0]), (-90.0, 1.0, 1.0, [0.3673, 0.0, 0.0, 0.9301, 0.0]), (90.0, 1.0, 1.0, [0.0, 0.0, 0.3673, 0.0, 0.9301]), - (180.0, 1.0, 1.0, [0.0, 0.0, 0.0, 0.7071, 0.7071]), + (180.0, 1.0, 1.0, [0.0, 0.0, 0.0, FRAC_1_SQRT_2, FRAC_1_SQRT_2]), (-150.0, 1.0, 1.0, [0.0, 0.0, 0.0, 0.8374, 0.5466]), (0.0, 0.5, 1.0, [0.4196, 0.6791, 0.4196, 0.3055, 0.3055]), (-90.0, 0.5, 1.0, [0.4927, 0.3226, 0.2477, 0.7437, 0.1970]), diff --git a/crates/skate-audio/src/eval/mod.rs b/crates/skate-audio/src/eval/mod.rs index fc4a96d7..4e8858c0 100644 --- a/crates/skate-audio/src/eval/mod.rs +++ b/crates/skate-audio/src/eval/mod.rs @@ -15,6 +15,8 @@ pub mod fmath; pub mod ops; pub mod rng; pub mod symbols; +#[doc(hidden)] +pub mod synthetic; use std::collections::{HashMap, VecDeque}; use std::sync::Arc; @@ -145,6 +147,8 @@ pub struct Evaluator { pub walks: u64, /// When Some, every executed op is appended (tests and golden runs). pub trace: Option>, + /// The walk's snapshot of `order` (kept between walks: no allocation per walk). + walk_order: Vec, } impl Default for Evaluator { @@ -173,6 +177,7 @@ impl Evaluator { countdown: BLOCKS_PER_WALK, walks: 0, trace: None, + walk_order: Vec::new(), } } @@ -188,7 +193,10 @@ impl Evaluator { /// must be installed first. Returns the bank id. pub fn load_bank(&mut self, bank: Bank) -> usize { let bank = Arc::new(bank); - let id = self.banks.len(); + // The first slot an unloaded bank left (map changes would otherwise grow the list for + // ever). Safe to reuse: unloading destroyed the bank's instances and dropped its + // constructors; ids are only keys (posts reach banks in constructor order, not id order). + let id = self.banks.iter().position(Option::is_none).unwrap_or(self.banks.len()); let mut modules: Vec = bank .modules .iter() @@ -211,7 +219,11 @@ impl Evaluator { self.registry.classes[c].constructors.push((id, m)); } } - self.banks.push(Some(LoadedBank { bank, modules })); + let loaded = Some(LoadedBank { bank, modules }); + match self.banks.get_mut(id) { + Some(slot) => *slot = loaded, + None => self.banks.push(loaded), + } id } @@ -298,8 +310,8 @@ impl Evaluator { /// Rewrite the payload of a held post (SetMemberData): only its ClassData clients rerun. pub fn redeliver(&mut self, node: NodeId, payload: &[i32]) { let Some(n) = self.nodes.get(&node.0) else { return }; - for (inst, off) in n.class_data.clone() { - if let Some(i) = self.instance_mut(inst) { + for &(inst, off) in &n.class_data { + if let Some(Some(i)) = self.instances.get_mut(inst as usize) { let count = u8_at(&i.mem, off as usize + 16) as usize; for k in 0..count { put_i32(&mut i.mem, off as usize + 20 + 4 * k, payload.get(k).copied().unwrap_or(0)); @@ -310,10 +322,9 @@ impl Evaluator { /// Release a post: its instances see the ClassDestructor pulse; the poster's reference drops. pub fn release(&mut self, node: NodeId) { - let Some(n) = self.nodes.get_mut(&node.0) else { return }; - let destructors = n.destructors.clone(); - for (inst, off) in destructors { - if let Some(i) = self.instance_mut(inst) { + let Some(n) = self.nodes.get(&node.0) else { return }; + for &(inst, off) in &n.destructors { + if let Some(Some(i)) = self.instances.get_mut(inst as usize) { put_i32(&mut i.mem, off as usize + 16, 1); } } @@ -404,16 +415,17 @@ impl Evaluator { } let Some(Some(lb)) = self.banks.get_mut(inst.bank) else { return }; lb.modules[inst.module].live -= 1; - let module = &lb.bank.modules[inst.module]; - let players: Vec = module.players().to_vec(); - let controllers: Vec = module.controllers().to_vec(); - for p in players { + // The bank's handle (a reference count, no copy of the lists): `self.release` below needs + // `self` while the module's object lists are read. + let bank = lb.bank.clone(); + let module = &bank.modules[inst.module]; + for &p in module.players() { let voice = u32_at(&inst.mem, p as usize + 8); if voice != 0 { host.release(voice); } } - for c in controllers { + for &c in module.controllers() { let child = u32_at(&inst.mem, c as usize + 8); if child != 0 { self.release(NodeId(child)); @@ -475,12 +487,16 @@ impl Evaluator { /// Run every instance's program once, newest first. pub fn walk(&mut self, host: &mut dyn VoiceHost) { - let order: Vec = self.order.iter().copied().collect(); - for id in order { + // The order as the walk starts (instances created or destroyed meanwhile don't change it). + let mut order = std::mem::take(&mut self.walk_order); + order.clear(); + order.extend(self.order.iter().copied()); + for &id in &order { if self.instance(id).is_some() { self.run(id, host); } } + self.walk_order = order; self.walks += 1; } diff --git a/crates/skate-audio/src/eval/synthetic.rs b/crates/skate-audio/src/eval/synthetic.rs new file mode 100644 index 00000000..698e815e --- /dev/null +++ b/crates/skate-audio/src/eval/synthetic.rs @@ -0,0 +1,219 @@ +//! Hand-built ABKC banks for tests (the evaluator's unit tests and the integration tests, e.g. +//! `tests/render_alloc.rs`): not retail data, built from the format spec. Hidden from the docs. +use crate::be::*; +use crate::formats::Bank; +use crate::formats::Project; +use crate::formats::csi::Symbol; + +/// One module of a synthetic bank. +pub struct Spec { + pub max: i16, + pub globals: u16, + pub functions: u16, + pub destructor: bool, + pub class_data: bool, + pub players: Vec, + pub template: Vec, + /// (opcode, pairs, block offset); advances are derived from the next op's block. + pub program: Vec<(u8, Vec<(i32, i32)>, u32)>, + /// Template offsets that must hold the bank offset of the shared sample group. + pub group_ptrs: Vec, +} + +/// An export: kind (0 global, 1 class, 2 function), name id, name, and where the handle goes: +/// None = the module's class handle, Some(off) = template offset. +pub struct Ex { + pub module: usize, + pub kind: u32, + pub name_id: u16, + pub name: &'static str, + pub at: Option, +} + +pub const PROJECT: u16 = 0x1234; + +pub fn project() -> Project { + let s = |name: &str, id: u16, default: i32| Symbol { name: name.into(), name_id: id, default }; + Project { + name: "test.csi".into(), + id: PROJECT, + tables: [ + vec![s("f_msg", 1, 0)], + vec![s("c_test", 1, 0), s("c_util", 2, 0), s("c_req", 3, 0)], + vec![s("g_snd", 1, 77)], + ], + } +} + +pub fn be(v: &mut Vec, w: u32) { + v.extend_from_slice(&w.to_be_bytes()); +} + +/// Build an ABKC bank: header | module records | templates | programs | sample group | S10A | +/// interface list + id records | empty rebase list. Samples: (frames, looping) at 48 kHz mono. +pub fn bank(modules: &[Spec], exports: &[Ex], samples: &[(u32, bool)]) -> Bank { + let records: usize = modules.iter().map(|m| 60 + 4 * m.players.len()).sum(); + let mut at = 0x5C + records; + let mut template_at = Vec::new(); + for m in modules { + template_at.push(at); + at += m.template.len(); + } + let mut program_at = Vec::new(); + let mut programs = Vec::new(); + for m in modules { + program_at.push(at); + let mut p = Vec::new(); + for (i, (op, pairs, block)) in m.program.iter().enumerate() { + let next = m.program.get(i + 1).map_or(m.template.len() as u32, |o| o.2); + p.push(*op); + p.push(pairs.len() as u8); + p.extend_from_slice(&[0, 0]); + for &(s, d) in pairs { + be(&mut p, s as u32); + be(&mut p, d as u32); + } + be(&mut p, next - block); + } + p.push(255); + p.extend_from_slice(&[0, 0, 0]); + at += p.len(); + programs.push(p); + } + let group_at = at; + let mut group = Vec::new(); + be(&mut group, samples.len() as u32); + for i in 0..samples.len() { + group.extend_from_slice(&(i as u16).to_be_bytes()); + group.push(100); + // Bytes 3..7 azimuths; byte 8 is the first byte of the stream offset word. + group.extend_from_slice(&[0, 0, 0, 0, (i + 1) as u8]); + be(&mut group, u32::MAX); + } + at += group.len(); + let s10a_at = at; + let mut s10a = b"S10A".to_vec(); + be(&mut s10a, 0); + be(&mut s10a, samples.len() as u32); + let mut bodies = Vec::new(); + let table = 12 + 4 * samples.len(); + for &(frames, looping) in samples { + be(&mut s10a, (table + bodies.len()) as u32); + be(&mut bodies, (3 << 24) | 48000); + be(&mut bodies, (u32::from(looping) << 29) | frames); + be(&mut bodies, 0); + } + s10a.extend_from_slice(&bodies); + at += s10a.len(); + let interface_at = at; + let mut iface = Vec::new(); + be(&mut iface, exports.len() as u32); + let ids_at = interface_at + 4 + 12 * exports.len(); + let mut ids = Vec::new(); + let mut module_at = Vec::new(); + let mut r = 0x5C; + for m in modules { + module_at.push(r); + r += 60 + 4 * m.players.len(); + } + for e in exports { + let handle = match e.at { + None => module_at[e.module] + 4, + Some(off) => template_at[e.module] + off as usize, + }; + be(&mut iface, handle as u32); + be(&mut iface, (ids_at + ids.len()) as u32); + be(&mut iface, e.kind << 24); + ids.extend_from_slice(&PROJECT.to_be_bytes()); + ids.extend_from_slice(&e.name_id.to_be_bytes()); + ids.extend_from_slice(e.name.as_bytes()); + ids.push(0); + while ids.len() % 4 != 0 { + ids.push(0); + } + } + at = ids_at + ids.len(); + let rebase_at = at; + + let mut d = vec![0u8; 0x5C]; + d[..4].copy_from_slice(b"ABKC"); + d[0x0A..0x0C].copy_from_slice(&(modules.len() as u16).to_be_bytes()); + d[0x18..0x1C].copy_from_slice(&(s10a_at as u32).to_be_bytes()); + d[0x1C..0x20].copy_from_slice(&0x5Cu32.to_be_bytes()); + d[0x20..0x24].copy_from_slice(&(s10a_at as u32).to_be_bytes()); + d[0x34..0x38].copy_from_slice(&(rebase_at as u32).to_be_bytes()); + d[0x38..0x3C].copy_from_slice(&(interface_at as u32).to_be_bytes()); + for (i, m) in modules.iter().enumerate() { + let mut rec = vec![0u8; 60]; + rec[0x1E..0x20].copy_from_slice(&m.max.to_be_bytes()); + rec[0x20..0x22].copy_from_slice(&m.globals.to_be_bytes()); + rec[0x22..0x24].copy_from_slice(&m.functions.to_be_bytes()); + rec[0x24] = m.players.len() as u8; + rec[0x25] = u8::from(m.destructor); + rec[0x26] = u8::from(m.class_data); + rec[0x28..0x2C].copy_from_slice(&(program_at[i] as u32).to_be_bytes()); + rec[0x2C..0x30].copy_from_slice(&(template_at[i] as u32).to_be_bytes()); + rec[0x30..0x34].copy_from_slice(&(m.template.len() as u32).to_be_bytes()); + rec[0x34..0x38].copy_from_slice(&(m.template.len() as u32 - 16).to_be_bytes()); + for p in &m.players { + be(&mut rec, *p); + } + d.extend_from_slice(&rec); + } + for m in modules { + let mut t = m.template.clone(); + for &g in &m.group_ptrs { + put_u32(&mut t, g as usize, group_at as u32); + } + d.extend_from_slice(&t); + } + for p in &programs { + d.extend_from_slice(p); + } + d.extend_from_slice(&group); + d.extend_from_slice(&s10a); + d.extend_from_slice(&iface); + d.extend_from_slice(&ids); + be(&mut d, 0); // empty rebase list + Bank::parse("test.abk", d).expect("synthetic bank") +} + +/// Template words helper. +pub fn words(n: usize) -> Vec { + vec![0u8; 4 * n] +} + +/// A c_test module: destructor @24, ClassData (2 values) @44, Create @72, Player @76 (one input: +/// pitch), Destroy @124. Payload w0 → playcontrol, w1 → pitch, w2 → sample select. +pub fn player_module(max: i16) -> Spec { + let mut t = words(35); // 140 bytes + put_u8(&mut t, 44 + 16, 3); // ClassData: 3 values ((3 + 5)·4 = 32 B → @44..76) + // Create @76 + put_i32(&mut t, 76, 1); + // Player @80: n = 1 input, update outputs + let p = 80usize; + put_u8(&mut t, p + 14, 1); + put_u8(&mut t, p + 15, 1); + put_u8(&mut t, p + 28, 0); // input id 0 (pitch) + put_i32(&mut t, p + 32, -1); // applied + // Destroy @124 (datasize 140 − 16) + Spec { + max, + globals: 0, + functions: 0, + destructor: true, + class_data: true, + players: vec![80], + template: t, + program: vec![ + (0, vec![(-1, 124 + 12 - 24)], 24), + // ClassData @44: values at +20,+24,+28 → playcontrol (+24 of player), pitch value + // (+36), sample select (+20). + (1, vec![(20, 80 + 24 - 44), (24, 80 + 36 - 44), (28, 80 + 20 - 44)], 44), + (3, vec![], 76), + (27, vec![], 80), + (4, vec![], 124), + ], + group_ptrs: vec![80 + 4], + } +} diff --git a/crates/skate-audio/src/eval/tests.rs b/crates/skate-audio/src/eval/tests.rs index 9ca90faa..7121e943 100644 --- a/crates/skate-audio/src/eval/tests.rs +++ b/crates/skate-audio/src/eval/tests.rs @@ -2,221 +2,7 @@ //! query / restart edge, release → destroy, capacity, and the function → global round trip with //! its walk-order rule (spec §2, §5). use super::*; -use crate::formats::Project; -use crate::formats::csi::Symbol; - -/// One module of a synthetic bank. -struct Spec { - max: i16, - globals: u16, - functions: u16, - destructor: bool, - class_data: bool, - players: Vec, - template: Vec, - /// (opcode, pairs, block offset); advances are derived from the next op's block. - program: Vec<(u8, Vec<(i32, i32)>, u32)>, - /// Template offsets that must hold the bank offset of the shared sample group. - group_ptrs: Vec, -} - -/// An export: kind (0 global, 1 class, 2 function), name id, name, and where the handle goes: -/// None = the module's class handle, Some(off) = template offset. -struct Ex { - module: usize, - kind: u32, - name_id: u16, - name: &'static str, - at: Option, -} - -const PROJECT: u16 = 0x1234; - -fn project() -> Project { - let s = |name: &str, id: u16, default: i32| Symbol { name: name.into(), name_id: id, default }; - Project { - name: "test.csi".into(), - id: PROJECT, - tables: [ - vec![s("f_msg", 1, 0)], - vec![s("c_test", 1, 0), s("c_util", 2, 0), s("c_req", 3, 0)], - vec![s("g_snd", 1, 77)], - ], - } -} - -fn be(v: &mut Vec, w: u32) { - v.extend_from_slice(&w.to_be_bytes()); -} - -/// Build an ABKC bank: header | module records | templates | programs | sample group | S10A | -/// interface list + id records | empty rebase list. Samples: (frames, looping) at 48 kHz mono. -fn bank(modules: &[Spec], exports: &[Ex], samples: &[(u32, bool)]) -> Bank { - let records: usize = modules.iter().map(|m| 60 + 4 * m.players.len()).sum(); - let mut at = 0x5C + records; - let mut template_at = Vec::new(); - for m in modules { - template_at.push(at); - at += m.template.len(); - } - let mut program_at = Vec::new(); - let mut programs = Vec::new(); - for m in modules { - program_at.push(at); - let mut p = Vec::new(); - for (i, (op, pairs, block)) in m.program.iter().enumerate() { - let next = m.program.get(i + 1).map_or(m.template.len() as u32, |o| o.2); - p.push(*op); - p.push(pairs.len() as u8); - p.extend_from_slice(&[0, 0]); - for &(s, d) in pairs { - be(&mut p, s as u32); - be(&mut p, d as u32); - } - be(&mut p, next - block); - } - p.push(255); - p.extend_from_slice(&[0, 0, 0]); - at += p.len(); - programs.push(p); - } - let group_at = at; - let mut group = Vec::new(); - be(&mut group, samples.len() as u32); - for i in 0..samples.len() { - group.extend_from_slice(&(i as u16).to_be_bytes()); - group.push(100); - // Bytes 3..7 azimuths; byte 8 is the first byte of the stream offset word. - group.extend_from_slice(&[0, 0, 0, 0, (i + 1) as u8]); - be(&mut group, u32::MAX); - } - at += group.len(); - let s10a_at = at; - let mut s10a = b"S10A".to_vec(); - be(&mut s10a, 0); - be(&mut s10a, samples.len() as u32); - let mut bodies = Vec::new(); - let table = 12 + 4 * samples.len(); - for &(frames, looping) in samples { - be(&mut s10a, (table + bodies.len()) as u32); - be(&mut bodies, (3 << 24) | 48000); - be(&mut bodies, (u32::from(looping) << 29) | frames); - be(&mut bodies, 0); - } - s10a.extend_from_slice(&bodies); - at += s10a.len(); - let interface_at = at; - let mut iface = Vec::new(); - be(&mut iface, exports.len() as u32); - let ids_at = interface_at + 4 + 12 * exports.len(); - let mut ids = Vec::new(); - let mut module_at = Vec::new(); - let mut r = 0x5C; - for m in modules { - module_at.push(r); - r += 60 + 4 * m.players.len(); - } - for e in exports { - let handle = match e.at { - None => module_at[e.module] + 4, - Some(off) => template_at[e.module] + off as usize, - }; - be(&mut iface, handle as u32); - be(&mut iface, (ids_at + ids.len()) as u32); - be(&mut iface, e.kind << 24); - ids.extend_from_slice(&PROJECT.to_be_bytes()); - ids.extend_from_slice(&e.name_id.to_be_bytes()); - ids.extend_from_slice(e.name.as_bytes()); - ids.push(0); - while ids.len() % 4 != 0 { - ids.push(0); - } - } - at = ids_at + ids.len(); - let rebase_at = at; - - let mut d = vec![0u8; 0x5C]; - d[..4].copy_from_slice(b"ABKC"); - d[0x0A..0x0C].copy_from_slice(&(modules.len() as u16).to_be_bytes()); - d[0x18..0x1C].copy_from_slice(&(s10a_at as u32).to_be_bytes()); - d[0x1C..0x20].copy_from_slice(&0x5Cu32.to_be_bytes()); - d[0x20..0x24].copy_from_slice(&(s10a_at as u32).to_be_bytes()); - d[0x34..0x38].copy_from_slice(&(rebase_at as u32).to_be_bytes()); - d[0x38..0x3C].copy_from_slice(&(interface_at as u32).to_be_bytes()); - for (i, m) in modules.iter().enumerate() { - let mut rec = vec![0u8; 60]; - rec[0x1E..0x20].copy_from_slice(&m.max.to_be_bytes()); - rec[0x20..0x22].copy_from_slice(&m.globals.to_be_bytes()); - rec[0x22..0x24].copy_from_slice(&m.functions.to_be_bytes()); - rec[0x24] = m.players.len() as u8; - rec[0x25] = u8::from(m.destructor); - rec[0x26] = u8::from(m.class_data); - rec[0x28..0x2C].copy_from_slice(&(program_at[i] as u32).to_be_bytes()); - rec[0x2C..0x30].copy_from_slice(&(template_at[i] as u32).to_be_bytes()); - rec[0x30..0x34].copy_from_slice(&(m.template.len() as u32).to_be_bytes()); - rec[0x34..0x38].copy_from_slice(&(m.template.len() as u32 - 16).to_be_bytes()); - for p in &m.players { - be(&mut rec, *p); - } - d.extend_from_slice(&rec); - } - for m in modules { - let mut t = m.template.clone(); - for &g in &m.group_ptrs { - put_u32(&mut t, g as usize, group_at as u32); - } - d.extend_from_slice(&t); - } - for p in &programs { - d.extend_from_slice(p); - } - d.extend_from_slice(&group); - d.extend_from_slice(&s10a); - d.extend_from_slice(&iface); - d.extend_from_slice(&ids); - be(&mut d, 0); // empty rebase list - Bank::parse("test.abk", d).expect("synthetic bank") -} - -/// Template words helper. -fn words(n: usize) -> Vec { - vec![0u8; 4 * n] -} - -/// A c_test module: destructor @24, ClassData (2 values) @44, Create @72, Player @76 (one input: -/// pitch), Destroy @124. Payload w0 → playcontrol, w1 → pitch, w2 → sample select. -fn player_module(max: i16) -> Spec { - let mut t = words(35); // 140 bytes - put_u8(&mut t, 44 + 16, 3); // ClassData: 3 values ((3 + 5)·4 = 32 B → @44..76) - // Create @76 - put_i32(&mut t, 76, 1); - // Player @80: n = 1 input, update outputs - let p = 80usize; - put_u8(&mut t, p + 14, 1); - put_u8(&mut t, p + 15, 1); - put_u8(&mut t, p + 28, 0); // input id 0 (pitch) - put_i32(&mut t, p + 32, -1); // applied - // Destroy @124 (datasize 140 − 16) - Spec { - max, - globals: 0, - functions: 0, - destructor: true, - class_data: true, - players: vec![80], - template: t, - program: vec![ - (0, vec![(-1, 124 + 12 - 24)], 24), - // ClassData @44: values at +20,+24,+28 → playcontrol (+24 of player), pitch value - // (+36), sample select (+20). - (1, vec![(20, 80 + 24 - 44), (24, 80 + 36 - 44), (28, 80 + 20 - 44)], 44), - (3, vec![], 76), - (27, vec![], 80), - (4, vec![], 124), - ], - group_ptrs: vec![80 + 4], - } -} +use super::synthetic::*; #[derive(Default)] struct Log { diff --git a/crates/skate-audio/src/formats/abk.rs b/crates/skate-audio/src/formats/abk.rs index 42f724b0..68b66f77 100644 --- a/crates/skate-audio/src/formats/abk.rs +++ b/crates/skate-audio/src/formats/abk.rs @@ -172,7 +172,8 @@ impl Bank { class_data_state: None, function_states: Vec::new(), }; - if (module.template_offset + module.data_size) as usize > d.len() || module.data_size < 24 { + // Checked: two file words can overflow u32 (a malformed bank must fail, not wrap). + if module.template_offset.checked_add(module.data_size).is_none_or(|end| end as usize > d.len()) || module.data_size < 24 { return err(format!("{name}: module {m} template runs past the file")); } module.program = decode_program(name, d, module.program_offset as usize, module.data_size)?; diff --git a/crates/skate-audio/src/mixmap/tables.rs b/crates/skate-audio/src/mixmap/tables.rs index 56fb95bc..dc37740b 100644 --- a/crates/skate-audio/src/mixmap/tables.rs +++ b/crates/skate-audio/src/mixmap/tables.rs @@ -225,11 +225,11 @@ mod tests { /// The generated tables against the retail image's, when the private TU3 image is present. #[test] + #[ignore = "needs the private install data"] fn tables_match_the_image_when_present() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../.local/tu3-image/default_82000000_011B0000.bin"); let Ok(img) = std::fs::read(path) else { - eprintln!("skipped: no TU3 image at {path}"); - return; + panic!("missing private data: no TU3 image at {path}"); }; let word = |a: u32| u32::from_be_bytes(img[(a - 0x8200_0000) as usize..][..4].try_into().unwrap()); let t = Tables::generate(); diff --git a/crates/skate-audio/src/mixmap/tests.rs b/crates/skate-audio/src/mixmap/tests.rs index cc1d5ff8..fc2a65cf 100644 --- a/crates/skate-audio/src/mixmap/tests.rs +++ b/crates/skate-audio/src/mixmap/tests.rs @@ -91,10 +91,10 @@ fn disc_mxb() -> Option> { /// The retail file instantiates exactly the spec's census (§2.4). #[test] +#[ignore = "needs the private install data"] fn disc_mixmap_census() { let Some(bytes) = disc_mxb() else { - eprintln!("skipped: no MixMapSK8.mxb in the dev install or .local/skate3-disc"); - return; + panic!("missing private data: no MixMapSK8.mxb in the dev install or .local/skate3-disc"); }; let file = MixMapFile::parse(&bytes).unwrap(); assert_eq!(file.slots.len(), 14); @@ -116,10 +116,10 @@ fn disc_mixmap_census() { /// Free-skate steady state (Master.in1–4 = 32767, the rest 0): the spec's emitter and ambience /// outputs (§6.2, §6.3). #[test] +#[ignore = "needs the private install data"] fn disc_mixmap_free_skate_outputs() { let Some(bytes) = disc_mxb() else { - eprintln!("skipped: no MixMapSK8.mxb"); - return; + panic!("missing private data: no MixMapSK8.mxb"); }; let mut m = MixMap::from_bytes(&bytes).unwrap(); for id in 1..=4 { diff --git a/crates/skate-audio/src/player/collision.rs b/crates/skate-audio/src/player/collision.rs index 8416b98c..cee42c58 100644 --- a/crates/skate-audio/src/player/collision.rs +++ b/crates/skate-audio/src/player/collision.rs @@ -328,7 +328,7 @@ pub struct CollisionManager { pub posts: u64, pub starts: u64, /// Play the voices through their Collision SubMix (`sub_824D25E0`: mono, env send, eEQChain - /// bus); off = straight into SFX Master as before. + /// bus); the game's host always turns it on (off = straight into SFX Master, the tests). pub submix: bool, } @@ -587,10 +587,11 @@ mod tests { } #[test] + #[ignore = "needs the private install data"] fn a_message_starts_two_voices_then_follows_the_outputs() { let t = tuning(); let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/audio/aems/MixMapSK8.mxb")); - let Ok(bytes) = bytes else { return eprintln!("skipped: no MixMap") }; + let Ok(bytes) = bytes else { panic!("missing private data: no MixMap") }; let mut m = MixMap::from_bytes(&bytes).unwrap(); let mut c = CollisionManager::default(); let mut h = Log::default(); @@ -641,10 +642,11 @@ mod tests { } #[test] + #[ignore = "needs the private install data"] fn with_the_submix_each_voice_starts_on_its_mono_bus_at_the_category_send() { let t = tuning(); let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/audio/aems/MixMapSK8.mxb")); - let Ok(bytes) = bytes else { return eprintln!("skipped: no MixMap") }; + let Ok(bytes) = bytes else { panic!("missing private data: no MixMap") }; let l = Listener { camera: [-3.0, 1.5, 0.0], view: [1.0, 0.0, 0.0], ..Listener::default() }; let msg = Message { material: [95, 9], tier: [1, 1], position: [0.0; 3], level: [20000, 30000], local: true }; let run = |submix: bool| { diff --git a/crates/skate-audio/src/player/components.rs b/crates/skate-audio/src/player/components.rs index 04dc9804..52922054 100644 --- a/crates/skate-audio/src/player/components.rs +++ b/crates/skate-audio/src/player/components.rs @@ -96,7 +96,8 @@ pub struct Grind { pending: Vec, /// Rail level(5) as of the last update: the sounds' env send (`sub_82498140`). env_level: i32, - /// `sub_824C3FC8` / `sub_824C4138` (`SKATE_AEMS_GRIND_ONOFF=0` off). The host turns it on. + /// `sub_824C3FC8` / `sub_824C4138`. The game's host always turns it on; off only isolates the + /// rest in tests. pub onoff: bool, /// On / off sounds started (diagnostics). pub hit_starts: u64, diff --git a/crates/skate-audio/src/player/contacts.rs b/crates/skate-audio/src/player/contacts.rs index f3cae930..138160c9 100644 --- a/crates/skate-audio/src/player/contacts.rs +++ b/crates/skate-audio/src/player/contacts.rs @@ -337,21 +337,21 @@ pub struct Contacts { /// host's `mixmap::cadence`; 0 on the 60 Hz steps between them), so the poster runs n times /// here, once per console frame as retail's per-rendered-frame `sub_824BC188` does on the 30 fps /// console (15-frame cooldown = 0.5 s at any real frame rate). `None`: once per process, the - /// cadence before 2026-10-03 (the tests, `SKATE_AEMS_BODY_CONSOLE=0`). + /// the tests (the game's host always passes `Some`). pub body_calls: Option, /// The deck-impact poster (`sub_824BD000`) the same way: `Some(n)` runs it n times in this /// process (its 6-frame cooldown in console frames = 0.2 s), `None` once per process (before - /// 2026-10-03; `SKATE_AEMS_DECK_CONSOLE=0`). Its messages so far and their digest (diagnostics). + /// 2026-10-03; the tests). Its messages so far and their digest (diagnostics). pub deck_calls: Option, pub deck_posts: u64, pub deck_digest: u64, - /// The push foot's plant / lift (`sub_824BBB28`; `SKATE_AEMS_PLANT_LIFT=0` off) and the body - /// poster (`sub_824BC188`; `SKATE_AEMS_BODY_IMPACTS=0` off). The host turns them on. + /// The push foot's plant / lift (`sub_824BBB28`) and the body poster (`sub_824BC188`). The game's + /// host always turns them on; off only isolates the rest in tests. pub plant_lift_on: bool, pub body_on: bool, /// The bridge's speed graph on the region impacts ([`ContactsTuning::body_speed_curve`] at - /// [`AudioState::com_speed_216`]; `SKATE_AEMS_BODY_CURVE=0` / `E2E_BODY_CURVE=0` off: the - /// impacts as the conditioner wrote them, before 2026-10-03). The host turns it on. + /// [`AudioState::com_speed_216`]; off: the impacts as the conditioner wrote them, the tests). + /// The game's host always turns it on. pub body_speed_on: bool, /// Diagnostics (the e2e harness): when `Some`, every body-poster message with its region and /// the impact the poster read. diff --git a/crates/skate-audio/src/player/objpos.rs b/crates/skate-audio/src/player/objpos.rs index 93d40a86..587bb7dc 100644 --- a/crates/skate-audio/src/player/objpos.rs +++ b/crates/skate-audio/src/player/objpos.rs @@ -133,10 +133,11 @@ mod tests { } #[test] + #[ignore = "needs the private install data"] fn rates_are_signed_by_closing_and_flag_their_sign_flips() { let bytes = match std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/audio/aems/MixMapSK8.mxb")) { Ok(b) => b, - Err(_) => return eprintln!("skipped: no MixMap"), + Err(_) => panic!("missing private data: no MixMap"), }; let mut m = MixMap::from_bytes(&bytes).unwrap(); let key = crate::mixmap::keys::obj_pos2(0); diff --git a/crates/skate-audio/src/player/seams.rs b/crates/skate-audio/src/player/seams.rs index d9c50d3d..ca059d1c 100644 --- a/crates/skate-audio/src/player/seams.rs +++ b/crates/skate-audio/src/player/seams.rs @@ -535,8 +535,9 @@ mod tests { } #[test] + #[ignore = "needs the private install data"] fn grid_hits_once_per_axle_per_line_and_toggle_the_trigger_word() { - let Some(mut m) = mixmap() else { return eprintln!("skipped: no MixMap in the install") }; + let Some(mut m) = mixmap() else { panic!("missing private data: no MixMap in the install") }; let t = tuning(); let mut k = Seams::default(); let first = k.process(&rolling(0.1, 1), &t, &mut m); @@ -574,8 +575,9 @@ mod tests { } #[test] + #[ignore = "needs the private install data"] fn a_material_change_fires_with_the_transition_surface_and_skips_the_grid() { - let Some(mut m) = mixmap() else { return eprintln!("skipped: no MixMap in the install") }; + let Some(mut m) = mixmap() else { panic!("missing private data: no MixMap in the install") }; let t = tuning(); let mut k = Seams::default(); k.process(&rolling(0.1, 1), &t, &mut m); @@ -592,8 +594,9 @@ mod tests { } #[test] + #[ignore = "needs the private install data"] fn distance_mode_fires_front_then_rear() { - let Some(mut m) = mixmap() else { return eprintln!("skipped: no MixMap in the install") }; + let Some(mut m) = mixmap() else { panic!("missing private data: no MixMap in the install") }; let t = tuning(); let mut k = Seams::default(); k.process(&rolling(0.0, 10), &t, &mut m); diff --git a/crates/skate-audio/src/player/state.rs b/crates/skate-audio/src/player/state.rs index 3f439046..0b26d397 100644 --- a/crates/skate-audio/src/player/state.rs +++ b/crates/skate-audio/src/player/state.rs @@ -263,7 +263,84 @@ impl Default for AudioState { } } +/// What a skater that is NOT simulated with the player's physics (a route follower, a remote +/// player, a mod's script) can tell the audio: enough for [`AudioState::rolling`]. Not retail data: +/// retail always fills the whole record from the skater's physics. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LiteSkater { + /// The board's position and velocity (m, m/s, world). + pub position: [f32; 3], + pub velocity: [f32; 3], + /// The board's heading (rad about +Y; 0 = +Z, the game's convention). + pub heading: f32, + /// Wheels down (front-left, front-right, rear-left, rear-right). + pub wheels: [bool; 4], + /// The material under the board (the audio surface material, `material_of_tag(tag)`; + /// `NO_MATERIAL` = none). + pub material: u32, + /// On a rail / ledge and its material (`+692`, `NO_MATERIAL` = none). + pub grinding: bool, + pub grind_material: u32, + /// In the air and for how long (s). + pub airborne: bool, + pub air_time: f32, + /// The step (s). + pub dt: f32, +} + +impl Default for LiteSkater { + fn default() -> Self { + Self { + position: [0.0; 3], + velocity: [0.0; 3], + heading: 0.0, + wheels: [true; 4], + material: NO_MATERIAL, + grinding: false, + grind_material: NO_MATERIAL, + airborne: false, + air_time: 0.0, + dt: 1.0 / 60.0, + } + } +} + impl AudioState { + /// A documented minimal fill for a skater not simulated with the player's physics (spec + /// `world-audio-hookin` §3.5): speed, wheels and their material, board / COM positions and + /// velocities, grind and air flags. Rolling, the Class_rolling surfaces, seams, grinds and + /// landings (the wheels coming down) sound; tricks, foot / body foley and the deck / region + /// impacts stay silent (their inputs stay at the defaults). `local` is false. The wheel + /// positions follow the board's heading (track 0.2 m, wheelbase 0.6 m). + pub fn rolling(l: &LiteSkater) -> Self { + let [vx, _, vz] = l.velocity; + let speed = (vx * vx + vz * vz).sqrt(); + let contact = if l.airborne { [false; 4] } else { l.wheels }; + let wheels = contact.iter().filter(|c| **c).count() as u32; + let (s, c) = l.heading.sin_cos(); + let [x, y, z] = l.position; + let offs = [(0.1, 0.3), (-0.1, 0.3), (0.1, -0.3), (-0.1, -0.3)]; + Self { + dt: l.dt, + ground_speed: speed, + com_velocity: l.velocity, + com_position: [x, y + 1.0, z], + board_position: l.position, + board_velocity: l.velocity, + wheel_count: wheels, + wheel_contact: contact, + wheel_material: [if l.airborne { NO_MATERIAL } else { l.material }; 4], + wheel_position: offs.map(|(r, a)| [x + c * r + s * a, y, z - s * r + c * a]), + airborne: l.airborne, + air_time: if l.airborne { l.air_time } else { 0.0 }, + grinding: l.grinding, + grind_family: if l.grinding { 0 } else { -1 }, + grind_material: if l.grinding { l.grind_material } else { NO_MATERIAL }, + local: false, + ..Self::default() + } + } + /// `+212`: |COM velocity|. pub fn com_speed(&self) -> f32 { let [x, y, z] = self.com_velocity; diff --git a/crates/skate-audio/src/runtime.rs b/crates/skate-audio/src/runtime.rs index 8babbe8c..9824b762 100644 --- a/crates/skate-audio/src/runtime.rs +++ b/crates/skate-audio/src/runtime.rs @@ -201,6 +201,14 @@ impl Runtime { if self.read >= self.stereo.len() { self.render_block(); routes::output_stereo(&self.bus, &mut self.stereo); + // Host-side safety for the device stream, not a parity change: a non-finite sample + // (a NaN / inf from any bug upstream) would poison the output device's mix, so it + // leaves as silence. Finite samples pass untouched (the e2e renders read the bus). + for x in &mut self.stereo { + if !x.is_finite() { + *x = 0.0; + } + } self.read = 0; } let n = (self.stereo.len() - self.read).min(out.len() - at); diff --git a/crates/skate-audio/src/splice/tests.rs b/crates/skate-audio/src/splice/tests.rs index cf11b3ba..42c8b6f9 100644 --- a/crates/skate-audio/src/splice/tests.rs +++ b/crates/skate-audio/src/splice/tests.rs @@ -61,8 +61,9 @@ fn disc_bank(name: &str) -> Option> { } #[test] +#[ignore = "needs the private install data"] fn disc_banks_parse_and_the_contact_ids_resolve() { - let Some(d) = disc_bank("Skate_Collisions") else { return eprintln!("skipped: no extracted SPLC banks") }; + let Some(d) = disc_bank("Skate_Collisions") else { panic!("missing private data: no extracted SPLC banks") }; let bank = SpliceBank::parse(&d).unwrap(); assert!(bank.records.len() > 800 && !bank.containers.is_empty()); let records = bank.records.len(); @@ -91,8 +92,9 @@ fn tone(seconds: f32) -> Arc { } #[test] +#[ignore = "needs the private install data"] fn a_record_plays_its_layers_and_ends_after_its_length() { - let Some(d) = disc_bank("Skate_Collisions") else { return eprintln!("skipped: no extracted SPLC banks") }; + let Some(d) = disc_bank("Skate_Collisions") else { panic!("missing private data: no extracted SPLC banks") }; let bank = SpliceBank::parse(&d).unwrap(); let pcm = (0..bank.samples).map(|_| Some(tone(0.5))).collect(); let mut mixer = Mixer::new(); diff --git a/crates/skate-audio/src/world/skaters.rs b/crates/skate-audio/src/world/skaters.rs index ab6cbe87..6edd5ce4 100644 --- a/crates/skate-audio/src/world/skaters.rs +++ b/crates/skate-audio/src/world/skaters.rs @@ -100,6 +100,17 @@ impl Default for Slots { } impl Slots { + /// `records` NPC records (instances 1..=records): retail has 1 (`PLAYER_INSTANCES` − 1); the + /// game's opt-in non-retail "more audible" layout builds the MixMap with more. + pub fn with_records(records: usize) -> Self { + Self { holders: vec![None; records] } + } + + /// How many NPC records there are. + pub fn records(&self) -> usize { + self.holders.len() + } + /// The instance an NPC skater holds. pub fn instance(&self, id: u64) -> Option { self.holders.iter().position(|h| *h == Some(id)).map(|i| i as u32 + 1) @@ -188,7 +199,7 @@ pub struct NpcSkater { impl NpcSkater { /// `grind_onoff` / `plant_lift` / `body`: the session-review ports, as the local player's - /// host sets them (`SKATE_AEMS_GRIND_ONOFF` / `_PLANT_LIFT` / `_BODY_IMPACTS`). + /// host sets them (always on in the game; off only in tests). pub fn new(instance: u32, parts: Parts, grind_onoff: bool, plant_lift: bool, body: bool) -> Self { let mut grind = Grind::default(); grind.onoff = grind_onoff; @@ -377,10 +388,11 @@ mod tests { } #[test] + #[ignore = "needs the private install data"] fn npc_physics_in13_slews_toward_the_relative_speed() { let bytes = match std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/audio/aems/MixMapSK8.mxb")) { Ok(b) => b, - Err(_) => return eprintln!("skipped: no MixMap"), + Err(_) => panic!("missing private data: no MixMap"), }; let mut m = MixMap::from_bytes(&bytes).unwrap(); let mut npc = NpcSkater::new(1, Parts::default(), false, false, false); diff --git a/crates/skate-audio/tests/disc_banks.rs b/crates/skate-audio/tests/disc_banks.rs index 95d7a813..c9c02f74 100644 --- a/crates/skate-audio/tests/disc_banks.rs +++ b/crates/skate-audio/tests/disc_banks.rs @@ -30,10 +30,10 @@ fn load(dir: &PathBuf) -> (Vec, Vec) { } #[test] +#[ignore = "needs the private install data"] fn every_disc_bank_parses_and_matches_the_spec_census() { let Some(dir) = banks_dir() else { - eprintln!("skipped: no extracted banks"); - return; + panic!("missing private data: no extracted banks"); }; let (projects, banks) = load(&dir); assert_eq!(projects.len(), 9); @@ -104,10 +104,10 @@ fn every_disc_bank_parses_and_matches_the_spec_census() { } #[test] +#[ignore = "needs the private install data"] fn exports_resolve_like_retail() { let Some(dir) = banks_dir() else { - eprintln!("skipped: no extracted banks"); - return; + panic!("missing private data: no extracted banks"); }; let (projects, banks) = load(&dir); let mut eval = Evaluator::new(); @@ -173,10 +173,10 @@ impl VoiceHost for Timed { /// child ended itself; releasing the parent releases the child, and every instance and node goes /// away. #[test] +#[ignore = "needs the private install data"] fn tazer_control_class_owns_and_releases_its_child() { let Some(dir) = banks_dir() else { - eprintln!("skipped: no extracted banks"); - return; + panic!("missing private data: no extracted banks"); }; let mut eval = Evaluator::new(); for name in std::fs::read_to_string(dir.join("csi_order.txt")).unwrap().lines() { diff --git a/crates/skate-audio/tests/disc_grains.rs b/crates/skate-audio/tests/disc_grains.rs index 182d361a..ffef3582 100644 --- a/crates/skate-audio/tests/disc_grains.rs +++ b/crates/skate-audio/tests/disc_grains.rs @@ -4,11 +4,11 @@ use skate_audio::grain::GrainFile; #[test] +#[ignore = "needs the private install data"] fn every_retail_grain_parses_with_a_single_entry_seek_table() { let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/audio/grains"); let Ok(entries) = std::fs::read_dir(dir) else { - eprintln!("skipped: no {dir}"); - return; + panic!("missing private data: no {dir}"); }; let mut seen = Vec::new(); for entry in entries.flatten() { @@ -27,8 +27,7 @@ fn every_retail_grain_parses_with_a_single_entry_seek_table() { seen.push((name, g.stream.samples, g.duration)); } if seen.is_empty() { - eprintln!("skipped: no .grain files in {dir} (run stage_grain_mixmap.py or setup)"); - return; + panic!("missing private data: no .grain files in {dir} (run stage_grain_mixmap.py or setup)"); } seen.sort_by(|a, b| a.0.cmp(&b.0)); assert_eq!(seen.len(), 14); diff --git a/crates/skate-audio/tests/dsp_oracle.rs b/crates/skate-audio/tests/dsp_oracle.rs index 17c85a89..6a95a147 100644 --- a/crates/skate-audio/tests/dsp_oracle.rs +++ b/crates/skate-audio/tests/dsp_oracle.rs @@ -31,10 +31,10 @@ fn floats(words: &[&str]) -> Vec { } #[test] +#[ignore = "needs the private install data"] fn resample_is_bit_exact_with_the_retail_kernel() { let Some(text) = vectors() else { - eprintln!("skipped: no oracle vectors"); - return; + panic!("missing private data: no oracle vectors"); }; let src = signal(16384, 7); let (mut cases, mut samples, mut exact) = (0, 0, 0); @@ -61,10 +61,10 @@ fn resample_is_bit_exact_with_the_retail_kernel() { } #[test] +#[ignore = "needs the private install data"] fn lowpass_coefficients_and_kernel_agree() { let Some(text) = vectors() else { - eprintln!("skipped: no oracle vectors"); - return; + panic!("missing private data: no oracle vectors"); }; let mut poc_coeffs = std::collections::HashMap::new(); let (mut words, mut exact_words, mut max_ulps) = (0, 0, 0u32); @@ -185,10 +185,10 @@ fn fit_biquad_association() { } #[test] +#[ignore = "needs the private install data"] fn gain_ramp_is_bit_exact_with_the_retail_kernel() { let Some(text) = vectors() else { - eprintln!("skipped: no oracle vectors"); - return; + panic!("missing private data: no oracle vectors"); }; let input = signal(256, 23); let (mut samples, mut exact, mut max_ulps) = (0, 0, 0u64); diff --git a/crates/skate-audio/tests/offboard_levels.rs b/crates/skate-audio/tests/offboard_levels.rs index 40076030..f27dc1eb 100644 --- a/crates/skate-audio/tests/offboard_levels.rs +++ b/crates/skate-audio/tests/offboard_levels.rs @@ -248,8 +248,9 @@ fn bank_gains<'a>(r: &'a Run, bank: &str) -> Vec { } #[test] +#[ignore = "needs the private install data"] fn offboard_clothing_and_hands_play_their_retail_banks() { - let Some(_) = harness() else { return eprintln!("skipped: no install MixMap / extracted banks / WAVs") }; + let Some(_) = harness() else { panic!("missing private data: no install MixMap / extracted banks / WAVs") }; // Walking 5.4 km/h on concrete, board not in hand: step layer sk8_foley 82 (samples 69..75, // retail medians 0.21..0.26), walking foley 62 (samples 94..97, retail 0.387), the packets. let r = run(300, |f| walking(f, 5.4, 20, 3)); @@ -327,7 +328,7 @@ fn offboard_clothing_and_hands_play_their_retail_banks() { #[test] #[ignore] fn footstep_patch_sample_groups() { - let Some(_) = harness() else { return eprintln!("skipped") }; + let Some(_) = harness() else { panic!("missing private data") }; let base: Vec = vec![32767, 0, 4096, 0, 25000, 25000, 2456, 9200, 0, 2, 513, 0, 1, 2, 300, 1, 2, 1, 32767, 10000, 15000, 25000, 32767, 28000, 12]; let variants: Vec<(&str, usize, Vec)> = vec![ ("surface w16", 16, (1..=7).collect()), @@ -373,7 +374,7 @@ fn footstep_patch_sample_groups() { #[test] #[ignore] fn owner_outputs_on_foot_and_on_board() { - let Some(h) = harness() else { return eprintln!("skipped") }; + let Some(h) = harness() else { panic!("missing private data") }; for (name, on_foot, kmh) in [("on foot 5.4 km/h", true, 5.4f32), ("on foot 14.4", true, 14.4), ("on board 14.4", false, 14.4), ("on board 0", false, 0.0)] { let mut m = MixMap::from_bytes(&h.mxb).unwrap(); let mut physics = inputs::Physics::default(); diff --git a/crates/skate-audio/tests/player_tricks.rs b/crates/skate-audio/tests/player_tricks.rs index 94c79ef2..45645fdf 100644 --- a/crates/skate-audio/tests/player_tricks.rs +++ b/crates/skate-audio/tests/player_tricks.rs @@ -244,6 +244,7 @@ fn peak(g: &[f32]) -> f32 { /// 0.21–0.31). Asserts: Class_Flips posts once on the first air frame and opens a voice; the /// voices stay within unity and the flip whooshes land within 3 dB of retail's 0.35. #[test] +#[ignore = "needs the private install data"] fn tricks_play_their_retail_banks() { // Flips spin about the deck's At row, shove-its about Up, the ollie pitches about Ri. let cases = [ @@ -257,7 +258,7 @@ fn tricks_play_their_retail_banks() { let mut whooshes = Vec::new(); for (name, id, id2, spin) in cases { let Some(r) = run_tricks(150, |f| trick_state(f, id, id2, 13, 36, spin)) else { - return eprintln!("skipped: no install with the trick banks and MixMap"); + panic!("missing private data: no install with the trick banks and MixMap"); }; let posts: Vec = r.posts.iter().map(|(f, c, w)| format!("{f}:{c}:{}", if *c == "Class_Flips" { w[11] } else { w[9] })).collect(); let list = |bank: &str| -> String { @@ -288,9 +289,10 @@ fn tricks_play_their_retail_banks() { /// First-trigger rule: the same kickflip twice; every voice of the first trick (the bank's first /// use) peaks at the gain the same sample reaches in the second. #[test] +#[ignore = "needs the private install data"] fn the_first_flip_sounds_like_the_second() { let Some(r) = run_tricks(300, |f| trick_state(f % 150, 0, 28, 13, 36, [0.0, 0.0, 25.0])) else { - return eprintln!("skipped: no install with the trick banks and MixMap"); + panic!("missing private data: no install with the trick banks and MixMap"); }; for bank in ["Sk8_Air_Flip_Tricks", "Foley_Cloth"] { let v = &r.voices[bank]; @@ -342,9 +344,10 @@ fn run_treatment_with(b: TreatmentGlobals, state: impl Fn(usize) -> AudioState) /// with the `B+164` flag of PR #4's capture set, slots 0–12 (retail never plays them: the reset /// values stay). #[test] +#[ignore = "needs the private install data"] fn treatment_plays_before_the_landing_at_the_retail_level() { let Some((starts, posts)) = run_treatment(TreatmentGlobals::default()) else { - return eprintln!("skipped: no Treatments bank (decode_bank.py Treatments) or no install"); + panic!("missing private data: no Treatments bank (decode_bank.py Treatments) or no install"); }; let landings = [30 + 13 + 36, 230 + 13 + 36]; println!("Treatments voices (start frame, slot, peak gain), landings at frames {landings:?}:\n {starts:?}"); @@ -393,7 +396,7 @@ fn treatment_slots_16_17_by_word() { ]; for (name, p) in profiles { let Some((starts, _)) = run_treatment_with(TreatmentGlobals::default(), p) else { - return eprintln!("skipped: no Treatments bank or install"); + panic!("missing private data: no Treatments bank or install"); }; let slots: Vec<_> = starts.iter().filter(|v| v.1 >= 15).collect(); println!("{name:32} slots ≥ 15: {slots:?}"); @@ -408,7 +411,7 @@ fn treatment_slots_16_17_by_w8_step() { for v in [0.05f32, 0.1, 0.2, 0.3, 0.37, 0.4, 0.5, 0.7, 1.0, 2.0, 5.0, 10.0] { let p = move |f: usize| AudioState { air_until_landing: if (40..160).contains(&f) { v } else { 0.0 }, ..AudioState::default() }; let Some((starts, _)) = run_treatment_with(TreatmentGlobals::default(), p) else { - return eprintln!("skipped: no Treatments bank or install"); + panic!("missing private data: no Treatments bank or install"); }; println!("w8 {v:5.2} s: {:?}", starts); } @@ -429,7 +432,7 @@ fn treatment_slots_16_17_by_forced_word() { } } for (word, value) in cases { - let Some(mut rig) = Rig::new(&["Treatments"]) else { return eprintln!("skipped: no Treatments bank or install") }; + let Some(mut rig) = Rig::new(&["Treatments"]) else { panic!("missing private data: no Treatments bank or install") }; let (t, g) = (TreatmentTuning::default(), Globals::default()); let mut k = Treatment::default(); let mut starts: Vec<(usize, u16)> = Vec::new(); @@ -593,8 +596,8 @@ fn replay_treat(rows: &[TreatRow], shape: impl Fn(&AudioState, f64) -> AudioStat #[test] #[ignore = "diagnostic"] fn treatment_replays_the_recomp_capture() { - let Some(rows) = treat_rows() else { return eprintln!("skipped: no TREAT session") }; - let Some(voices) = replay_treat(&rows, |s, _| s.clone()) else { return eprintln!("skipped: no Treatments bank") }; + let Some(rows) = treat_rows() else { panic!("missing private data: no TREAT session") }; + let Some(voices) = replay_treat(&rows, |s, _| s.clone()) else { panic!("missing private data: no Treatments bank") }; let airs = treat_airs(&rows); for &(a, b) in &airs { let inside: Vec<_> = voices @@ -699,7 +702,7 @@ fn replay_treat_rows(rows: &[TreatRow], from: f64, to: f64, phase_ms: f64, stall #[test] #[ignore = "diagnostic"] fn treatment_airs_by_walk_phase() { - let Some(rows) = treat_rows() else { return eprintln!("skipped: no TREAT session") }; + let Some(rows) = treat_rows() else { panic!("missing private data: no TREAT session") }; let windows: Vec<(f64, f64, Option<(f64, f64)>)> = std::env::var("TREAT_AIRS").ok().map_or( vec![ (101_400.0, 104_500.0, None), @@ -717,7 +720,7 @@ fn treatment_airs_by_walk_phase() { let first_air = rows.iter().find(|r| r.ms >= from && r.air).map_or(from, |r| r.ms); println!("window {from}..{to} (first air row {first_air}), audio stall {stall:?}:"); for p in (0..32).step_by(2) { - let Some(v) = replay_treat_rows(&rows, from, to, p as f64, stall) else { return eprintln!("skipped: no Treatments bank") }; + let Some(v) = replay_treat_rows(&rows, from, to, p as f64, stall) else { panic!("missing private data: no Treatments bank") }; let list: Vec<_> = v.iter().map(|v| (v.slot, (v.start - first_air).round() as i64, (v.peak * 1e4).round() / 1e4)).collect(); println!(" phase {p:2} ms: {list:?}"); } @@ -738,9 +741,10 @@ const RECOMP_LONG_AIR_GAIN_MEDIAN: f32 = 0.0149; /// voices: 13 / 14 / 15 exactly, 16 / 17 in every 0.6–1.0 s air at the recomp's level. Data-gated /// (the session and the Treatments bank). #[test] +#[ignore = "needs the private install data"] fn treatment_replay_of_the_recomp_capture() { - let Some(rows) = treat_rows() else { return eprintln!("skipped: no TREAT session") }; - let Some(voices) = replay_treat(&rows, |s, _| s.clone()) else { return eprintln!("skipped: no Treatments bank") }; + let Some(rows) = treat_rows() else { panic!("missing private data: no TREAT session") }; + let Some(voices) = replay_treat(&rows, |s, _| s.clone()) else { panic!("missing private data: no Treatments bank") }; let count = |slot: u16| voices.iter().filter(|v| v.slot == slot).count(); for (slot, recomp) in RECOMP_TREATMENT_VOICES { println!("slot {slot}: {} voices (recomp {recomp})", count(slot)); @@ -781,7 +785,7 @@ fn sense_of_speed_replay_of_the_recomp_speeds() { use skate_audio::player::components::SenseOfSpeed; let session = std::env::var("GREC_SESSION").map(PathBuf::from).unwrap_or_else(|_| root().join(".local/recomp/sessions/all_20261002_223613")); let object = std::env::var("GREC_OBJECT").unwrap_or_else(|_| "40C33020".into()); - let Ok(text) = std::fs::read_to_string(session.join("trace.tsv")) else { return eprintln!("skipped: no GREC session") }; + let Ok(text) = std::fs::read_to_string(session.join("trace.tsv")) else { panic!("missing private data: no GREC session") }; // (ms, ground speed m/s, air) let rows: Vec<(f64, f32, bool)> = text .lines() @@ -794,9 +798,9 @@ fn sense_of_speed_replay_of_the_recomp_speeds() { }) .collect(); if rows.is_empty() { - return eprintln!("skipped: no GREC rows for {object}"); + panic!("missing private data: no GREC rows for {object}"); } - let Some(mut rig) = Rig::new(&["sense_of_speed"]) else { return eprintln!("skipped: no sense_of_speed bank") }; + let Some(mut rig) = Rig::new(&["sense_of_speed"]) else { panic!("missing private data: no sense_of_speed bank") }; let mut k = SenseOfSpeed::default(); let mut voices: HashMap = HashMap::new(); let (t0, t1) = (rows[0].0, rows[rows.len() - 1].0); diff --git a/crates/skate-audio/tests/render_alloc.rs b/crates/skate-audio/tests/render_alloc.rs index 6f8008e0..b6d6e5e5 100644 --- a/crates/skate-audio/tests/render_alloc.rs +++ b/crates/skate-audio/tests/render_alloc.rs @@ -7,7 +7,6 @@ use std::alloc::{GlobalAlloc, Layout, System}; use std::cell::Cell; use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; use skate_audio::bus::env::{DEFAULT_PRESET, Preset}; use skate_audio::bus::flange::FlangePreset; @@ -19,15 +18,24 @@ use skate_audio::runtime::Runtime; struct Counting; -static ALLOCATIONS: AtomicU64 = AtomicU64::new(0); thread_local! { static COUNTING: Cell = const { Cell::new(false) }; + /// This thread's counted allocations (each test reads its own). + static ALLOCATIONS: Cell = const { Cell::new(0) }; +} + +fn count_one() { + let _ = ALLOCATIONS.try_with(|n| n.set(n.get() + 1)); +} + +fn allocations() -> u64 { + ALLOCATIONS.with(Cell::get) } unsafe impl GlobalAlloc for Counting { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { if COUNTING.try_with(Cell::get).unwrap_or(false) { - ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + count_one(); } unsafe { System.alloc(layout) } } @@ -36,7 +44,7 @@ unsafe impl GlobalAlloc for Counting { } unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { if COUNTING.try_with(Cell::get).unwrap_or(false) { - ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + count_one(); } unsafe { System.realloc(ptr, layout, new_size) } } @@ -84,6 +92,7 @@ fn scene() -> (Runtime, Vec) { #[test] fn steady_state_render_does_not_allocate() { let (mut rt, ids) = scene(); + let before = allocations(); let mut measured = 0; for block in 0..400usize { if block % 6 == 0 { @@ -103,7 +112,50 @@ fn steady_state_render_does_not_allocate() { COUNTING.with(|c| c.set(false)); measured += usize::from(count); } - let n = ALLOCATIONS.load(Ordering::Relaxed); + let n = allocations() - before; eprintln!("{n} allocations in {measured} steady-state blocks ({} voices)", rt.mixer.voice_count()); assert_eq!(n, 0, "render_block allocated {n} times in {measured} blocks"); } + +/// The game thread's per-frame calls under the runtime lock must not allocate either (PR #32 +/// review, 2026-10-03): with an installed (hand-built) AEMS bank and a posted program playing, +/// `redeliver` every console frame, `release` and the evaluator walks inside `render_block` run +/// without an allocation once warm (the walk reuses its order snapshot; `redeliver` / `release` +/// read their client lists in place; a destroy reads the module's object lists in place). +#[test] +fn installed_bank_redeliver_release_and_walks_do_not_allocate() { + use skate_audio::eval::synthetic::{Ex, bank, player_module, project}; + let (mut rt, _) = scene(); + rt.eval.install_project(&project()); + let pcm = |frames: usize| Some(Arc::new(Pcm { rate: 48_000, channels: vec![(0..frames).map(|i| (i as f32 * 0.01).sin() * 0.2).collect()] })); + let id = rt.load_bank(bank(&[player_module(4)], &[Ex { module: 0, kind: 1, name_id: 1, name: "c_test", at: None }], &[(48_000, true), (24_000, true)]), vec![pcm(48_000), pcm(24_000)]); + let class = rt.eval.class_id("c_test").expect("the bank answers c_test"); + let node = rt.post(class, &[1, 4096, 1]); + let doomed = rt.post(class, &[1, 4096, 0]); + // A release during the warm-up too: the first destroy sizes the evaluator's free list. + let warm = rt.post(class, &[1, 4096, 0]); + let before = allocations(); + let mut measured = 0; + for block in 0..600usize { + // Warm-up: the first walks create the instances and open their voices (allocating). + let count = block >= 120; + COUNTING.with(|c| c.set(count)); + if block % 6 == 0 { + rt.redeliver(node, &[1, 4096 + (block % 600) as i32, 1]); + } + if block == 30 { + rt.release(warm); + } + if block == 300 { + rt.release(doomed); + } + let _ = rt.render_block(); + COUNTING.with(|c| c.set(false)); + measured += usize::from(count); + } + assert!(rt.eval.bank(id).is_some(), "the bank stayed installed"); + let n = allocations() - before; + eprintln!("{n} allocations in {measured} blocks with an installed bank ({} voices)", rt.mixer.voice_count()); + assert!(rt.mixer.voice_count() > 0, "the program plays"); + assert_eq!(n, 0, "redeliver / release / walks allocated {n} times in {measured} blocks"); +} diff --git a/crates/skate-audio/tests/rolling_banks.rs b/crates/skate-audio/tests/rolling_banks.rs index 1ef37761..f68b8cf0 100644 --- a/crates/skate-audio/tests/rolling_banks.rs +++ b/crates/skate-audio/tests/rolling_banks.rs @@ -76,8 +76,9 @@ impl VoiceHost for Opens { /// per (selector, surface) at speed word 5000 with open levels, run 8 s of evaluator blocks and /// list the bank slots opened. Diagnostic print (`--nocapture`). #[test] +#[ignore = "needs the private install data"] fn class_rolling_selector_census() { - let Some(dir) = banks_dir() else { return eprintln!("skipped: no extracted banks") }; + let Some(dir) = banks_dir() else { panic!("missing private data: no extracted banks") }; println!("selector surface speed: bank slots…"); for selector in 0..16 { for surface in [3, 7, 8, 10, 11, 12, 13] { @@ -341,11 +342,12 @@ fn quantiles(v: &[f32]) -> (f32, f32, f32) { /// Rolling_Rattles 0.007 / 0.301 / 0.366; 164620: Rolling_Surfaces 0.010 / 0.121 / 0.388). /// Asserts that something plays and that no voice exceeds unity. #[test] +#[ignore = "needs the private install data"] fn rolling_layers_play_their_retail_banks() { - let (Some(dir), Some(mxb)) = (banks_dir(), mixmap()) else { return eprintln!("skipped: no extracted banks or MixMap") }; + let (Some(dir), Some(mxb)) = (banks_dir(), mixmap()) else { panic!("missing private data: no extracted banks or MixMap") }; let mut tuning = PlayerTuning { surface_table: surface_table(), ..Default::default() }; if tuning.surface_table.len() < 95 { - return eprintln!("skipped: no surface map in the install manifest"); + panic!("missing private data: no surface map in the install manifest"); } tuning.surface_table.truncate(95); // Materials (tag − 1) per rolling surface: 3 asphalt_smooth (tag 1), 7 (tag 10), 8 (tag 8), @@ -363,7 +365,7 @@ fn rolling_layers_play_their_retail_banks() { } (s, pushes && f % 90 == 30) }) else { - return eprintln!("skipped: bank samples not decoded (decode_bank_samples.py)"); + panic!("missing private data: bank samples not decoded (decode_bank_samples.py)"); }; for (bank, g) in &r.gains { let audible: Vec = g.iter().copied().filter(|&x| x > 0.0).collect(); @@ -394,9 +396,10 @@ fn rolling_layers_play_their_retail_banks() { /// c_board_slide: the loose board (state `+780`) through `board_scrapes`. #[test] +#[ignore = "needs the private install data"] fn board_slide_plays_board_scrapes() { - let (Some(dir), Some(mxb)) = (banks_dir(), mixmap()) else { return eprintln!("skipped: no extracted banks or MixMap") }; - let Some(mut rig) = Rig::new(&dir, &["board_scrapes"]) else { return eprintln!("skipped: board_scrapes not decoded") }; + let (Some(dir), Some(mxb)) = (banks_dir(), mixmap()) else { panic!("missing private data: no extracted banks or MixMap") }; + let Some(mut rig) = Rig::new(&dir, &["board_scrapes"]) else { panic!("missing private data: board_scrapes not decoded") }; let mut m = MixMap::from_bytes(&mxb).unwrap(); let mut physics = skate_audio::player::inputs::Physics::default(); let mut slide = BoardSlide::default(); @@ -432,13 +435,14 @@ fn board_slide_plays_board_scrapes() { /// next to retail's per-sample levels (164620: slot 8 median 0.322, 7 0.107, 0 0.050, 2 0.037, /// 11 0.035, 1 0.026, 9 0.027, 6 0.022, 10 0.018). #[test] +#[ignore = "needs the private install data"] fn rattle_samples_by_surface_and_speed() { - let (Some(dir), Some(mxb)) = (banks_dir(), mixmap()) else { return eprintln!("skipped: no extracted banks or MixMap") }; + let (Some(dir), Some(mxb)) = (banks_dir(), mixmap()) else { panic!("missing private data: no extracted banks or MixMap") }; println!("surface speed: slot peak …"); for surface in 0..6 { let mut line = String::new(); for speed in [0, 1500, 4000, 7000, 10000] { - let Some(mut rig) = Rig::new(&dir, &["Rolling_Rattles"]) else { return eprintln!("skipped: not decoded") }; + let Some(mut rig) = Rig::new(&dir, &["Rolling_Rattles"]) else { panic!("missing private data: not decoded") }; let mut m = MixMap::from_bytes(&mxb).unwrap(); let mut physics = skate_audio::player::inputs::Physics::default(); let mut posts = 0; @@ -480,8 +484,9 @@ fn rattle_samples_by_surface_and_speed() { /// The SkateBoard outputs these layers read, by speed (straight rolling, 4 wheels; and in the air). #[test] +#[ignore = "needs the private install data"] fn skateboard_outputs_by_speed() { - let Some(mxb) = mixmap() else { return eprintln!("skipped: no MixMap") }; + let Some(mxb) = mixmap() else { panic!("missing private data: no MixMap") }; println!("km/h air l1 l6 l7 l9 l10 l13 l16 l19 p3 p8 f11 f12 f14 f15 f17 f18 l24 l27 f25 f26 p23"); for air in [false, true] { for kmh in [0.0f32, 3.0, 8.0, 15.0, 25.0, 40.0] { @@ -515,8 +520,9 @@ fn skateboard_outputs_by_speed() { /// Diagnostic: SenseOfSpeed level(1) (rattle gain) and level(4) (wind) on the ground and in the /// air at the same speed (listening report: rolling continues in jumps). #[test] +#[ignore = "needs the private install data"] fn sense_of_speed_levels_ground_vs_air() { - let Some(mxb) = mixmap() else { return eprintln!("skipped: no MixMap") }; + let Some(mxb) = mixmap() else { panic!("missing private data: no MixMap") }; for air in [false, true] { for kmh in [25.0f32, 31.0, 40.0] { let mut m = MixMap::from_bytes(&mxb).unwrap(); @@ -544,18 +550,19 @@ fn sense_of_speed_levels_ground_vs_air() { /// native layers (rattle, held layers, patch) must go quiet in the air through the MixMap words /// (level(6)/(7)/(9)/(1) are 0 without wheels). Prints the per-bank gain before / after takeoff. #[test] +#[ignore = "needs the private install data"] fn layers_go_quiet_after_takeoff() { - let (Some(dir), Some(mxb)) = (banks_dir(), mixmap()) else { return eprintln!("skipped: no extracted banks or MixMap") }; + let (Some(dir), Some(mxb)) = (banks_dir(), mixmap()) else { panic!("missing private data: no extracted banks or MixMap") }; let mut tuning = PlayerTuning { surface_table: surface_table(), ..Default::default() }; if tuning.surface_table.len() < 95 { - return eprintln!("skipped: no surface map"); + panic!("missing private data: no surface map"); } tuning.surface_table.truncate(95); for material in [0u32, 7] { let mut ground = BTreeMap::<&str, f32>::new(); let mut air = BTreeMap::<&str, f32>::new(); let stems: Vec<&'static str> = ROLLING_BANKS.iter().copied().chain(["Rolling_Rattles"]).collect(); - let Some(mut rig) = Rig::new(&dir, &stems) else { return eprintln!("skipped: not decoded") }; + let Some(mut rig) = Rig::new(&dir, &stems) else { panic!("missing private data: not decoded") }; let mut m = MixMap::from_bytes(&mxb).unwrap(); let mut physics = skate_audio::player::inputs::Physics::default(); let mut pos = [ObjPos::default(); 2]; diff --git a/crates/skate-audio/tests/world_sources.rs b/crates/skate-audio/tests/world_sources.rs index 1bcc9181..0984b2e8 100644 --- a/crates/skate-audio/tests/world_sources.rs +++ b/crates/skate-audio/tests/world_sources.rs @@ -229,11 +229,12 @@ fn print_drive(name: &str, d: &Drive) { } #[test] +#[ignore = "needs the private install data"] fn a_car_drives_past_through_its_own_engine_bank() { // The `c01_family01` patch with an idle / max of the default record's order; the named // records' values come from the install's world tuning in game. let record = EngineRecord { idle_rpm: 1400.0, max_rpm: 4000.0, patch: 1, ..Default::default() }; - let Some(d) = drive(record, 12.0, 8.0, 15.0) else { return eprintln!("skipped: no world bank data") }; + let Some(d) = drive(record, 12.0, 8.0, 15.0) else { panic!("missing private data: no world bank data") }; print_drive("C01 at 12 m/s, lane 8 m", &d); // Bank selection: every engine voice that sounds is C01's (the other banks' programs destroy // their instances on the patch word). @@ -266,13 +267,14 @@ fn a_car_drives_past_through_its_own_engine_bank() { } #[test] +#[ignore = "needs the private install data"] fn engine_banks_answer_only_their_patch() { // Each patch through all eight loaded banks: which bank keeps voices (retail: the eight // banks are loaded together and every post sounds in exactly one). let mut table = Vec::new(); for patch in 0..=9 { let record = EngineRecord { patch, ..Default::default() }; - let Some(d) = drive(record, 8.0, 4.0, 1.0 + 90.0 / 8.0) else { return eprintln!("skipped: no world bank data") }; + let Some(d) = drive(record, 8.0, 4.0, 1.0 + 90.0 / 8.0) else { panic!("missing private data: no world bank data") }; let banks: std::collections::BTreeSet = d.rows.iter().flat_map(|r| r.7.keys().filter(|b| b.starts_with("C0")).cloned()).collect(); table.push((patch, banks)); } @@ -288,8 +290,9 @@ fn engine_banks_answer_only_their_patch() { } #[test] +#[ignore = "needs the private install data"] fn a_ped_walks_past_and_steps_on_each_plant() { - let Some(mut h) = harness(&["fstep_livingworld"], &["sk8_foley"]) else { return eprintln!("skipped: no ped bank data") }; + let Some(mut h) = harness(&["fstep_livingworld"], &["sk8_foley"]) else { panic!("missing private data: no ped bank data") }; let l = listener(); let tuning = PedFootstepTuning::default(); let mut pt = PlayerTuning::default(); @@ -354,9 +357,10 @@ fn speech_from_install() -> Option<(skate_audio::world::speech::SpeechIndex, Vec } #[test] +#[ignore = "needs the private install data"] fn a_bumped_ped_warns_with_a_line_of_its_voice() { use skate_audio::world::speech::{SPEECH_BANK, Want, choose, reaction_cues}; - let Some((index, files)) = speech_from_install() else { return eprintln!("skipped: no decoded speech (stage_world_audio.py --decode 501,104,205,101)") }; + let Some((index, files)) = speech_from_install() else { panic!("missing private data: no decoded speech (stage_world_audio.py --decode 501,104,205,101)") }; // Which voices can say each measured reaction (the voice id comes from the ped's model). for want in [Want::Warn, Want::SlamReaction, Want::NearbyCollisionReaction, Want::NearbySkaterTrick] { for cue in reaction_cues(want) { diff --git a/crates/skate-audio/tests/world_speech.rs b/crates/skate-audio/tests/world_speech.rs index e29138c1..4c27feea 100644 --- a/crates/skate-audio/tests/world_speech.rs +++ b/crates/skate-audio/tests/world_speech.rs @@ -240,7 +240,7 @@ macro_rules! data { () => { match load() { Some(d) => d, - None => return eprintln!("skipped: no speech rules export (stage_world_audio.py)"), + None => panic!("missing private data: no speech rules export (stage_world_audio.py)"), } }; } @@ -250,6 +250,7 @@ fn clip_name(d: &Data, id: u16) -> &str { } #[test] +#[ignore = "needs the private install data"] fn flag_one_names_the_far_lines_and_two_the_near_ones() { let d = data!(); let (mut agree, mut against) = (0, 0); @@ -274,6 +275,7 @@ fn flag_one_names_the_far_lines_and_two_the_near_ones() { } #[test] +#[ignore = "needs the private install data"] fn voices_map_to_their_type_and_variant_bits() { let d = data!(); for (voice, bits) in [(59, (kind::BUSINESS_MAN, 1)), (41, (kind::ADULT_MALE, 1)), (49, (kind::ADULT_FEMALE, 8)), (53, (kind::TOURIST_FEMALE, 2)), (87, (kind::BUM, 1)), (90, (kind::SKATER_MALE, 0x10)), (76, (kind::SECURITY_GUARD, 2))] { @@ -290,6 +292,7 @@ fn voices_map_to_their_type_and_variant_bits() { } #[test] +#[ignore = "needs the private install data"] fn a_bumped_business_man_warns_far_or_near_and_cycles_his_takes() { let d = data!(); let mut lib = Library::new(d.headers.clone()); @@ -324,6 +327,7 @@ fn a_bumped_business_man_warns_far_or_near_and_cycles_his_takes() { } #[test] +#[ignore = "needs the private install data"] fn every_free_roam_record_resolves_or_fails_closed() { let d = data!(); let mut missing = 0; diff --git a/crates/skate-game/src/game_audio/e2e.rs b/crates/skate-game/src/game_audio/e2e.rs index 51aee6c5..faa9477e 100644 --- a/crates/skate-game/src/game_audio/e2e.rs +++ b/crates/skate-game/src/game_audio/e2e.rs @@ -15,255 +15,11 @@ use std::sync::{Arc, Mutex}; use skate_audio::formats::{Bank, Project}; use skate_audio::mixmap::{MixMap, keys}; -use skate_audio::player::AudioState; -use skate_audio::player::state::material_of_tag; use skate_audio::runtime::Runtime; use super::player_audio::{BANKS, PlayerAudio}; -use super::skate_events::Riding; -struct Row(HashMap); -impl Row { - fn f(&self, k: &str) -> f32 { - self.0.get(k).copied().unwrap_or(0.0) as f32 - } - fn i(&self, k: &str) -> i64 { - self.0.get(k).copied().unwrap_or(0.0) as i64 - } -} - -fn read(path: &std::path::Path) -> Vec { - let text = std::fs::read_to_string(path).unwrap(); - let mut lines = text.lines(); - let cols: Vec = lines.next().unwrap().split('\t').map(str::to_owned).collect(); - lines.map(|l| Row(cols.iter().cloned().zip(l.split('\t').map(|v| v.parse::().unwrap())).collect())).collect() -} - -/// What `skate_events::observe` / `audio_state` would publish for this row. -struct Script { - x: f32, - air_time: f32, - pushes: u32, - family: Option, - material: Option, - jump_velocity: f32, - /// Last row's push plant (the edge `+335`). - planted: bool, - /// The bridge's `+212` (|COM v|) of the last row and the last row's COM position (the - /// fallback below), for this row's `+216`. - com_212: f32, - com_at: Option<[f32; 3]>, -} - -impl Script { - /// The audio state's `+216` for this row (last row's `+212`), then this row's `+212`: the - /// logged |COM v| (`com_speed`, logs since 2026-10-03 afternoon); for older logs |Δ COM - /// position| × 60 from the logged COM positions (`com_x/y/z`, the reckoning's followed point at - /// 4 decimals, so ±0.006 m/s; the graph saturates from 0.95 m/s); without either 0 (graph 1.0: - /// the impacts as logged). - fn com_216(&mut self, r: &Row) -> f32 { - let previous = self.com_212; - self.com_212 = if r.0.contains_key("com_speed") { - r.f("com_speed") - } else if r.0.contains_key("com_x") { - let at = [r.f("com_x"), r.f("com_y"), r.f("com_z")]; - let v = self.com_at.map_or(0.0, |p| { - let d = [at[0] - p[0], at[1] - p[1], at[2] - p[2]]; - (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() * 60.0 - }); - self.com_at = Some(at); - v - } else { - 0.0 - }; - previous - } - - fn riding(&mut self, r: &Row) -> Riding { - let speed = r.f("speed"); - let com_speed_216 = self.com_216(r); - self.x += speed / 60.0; - let state = r.i("state") as u32; - // As `skate_events::observe`: an unridden board reports no contacts (logs recorded before - // that gate still carry them). - let unridden = super::skate_events::board_unridden(state); - let mask = if unridden { 0 } else { r.i("wheels") as u32 }; - let contact: [bool; 4] = std::array::from_fn(|i| mask & (1 << i) != 0); - let wheels = contact.iter().filter(|c| **c).count() as u32; - // E2E_CONTACT_MATERIALS=1: the materials gated by contact, as the game did before 20:15. - let lines = if std::env::var("E2E_CONTACT_MATERIALS").is_ok_and(|v| v == "1") { - mask - } else if unridden { - 0 - } else if r.0.contains_key("lines") { - r.i("lines") as u32 - } else if mask != 0 { - 15 - } else { - 0 - }; - let tag = if unridden { 0 } else { r.i("tag") as u32 }; - let airborne = (200..300).contains(&state) && wheels == 0; - // `+236` as `skate_events::audio_state`; E2E_AIR_TIME_STATE=0: the old count (air flag only). - let follow = !std::env::var("E2E_AIR_TIME_STATE").is_ok_and(|v| v == "0"); - let air_words = !std::env::var("E2E_AIR_WORDS").is_ok_and(|v| v == "0"); - self.air_time = super::skate_events::air_time_236(self.air_time, state, airborne, 1.0 / 60.0, follow); - let grinding = r.i("grinding") != 0; - if grinding { - self.family = Some(r.i("family") as i32); - // The log's grind tag → `+692` (tag − 1, 0 → 143), as `skate_events::audio_state`. - self.material = Some(material_of_tag(r.i("grind_tag") as u32)); - } - let push = r.i("push") != 0; - // The push plant (`skate_events::push_plant`, session review #1): logs since 2026-10-03 carry - // State55 (`plant`), and the plant and its rise drive `+333 || +334` / `+335` and the bed's - // push count. E2E_PUSH_PLANT=0: the animation's push edge (`push`) as before. Older logs keep - // that unless E2E_PLANT_FROM_FEET=1, which takes the plant from their foot-down bits on the - // ground states without the brake (State55 && (State57 || State56), the bridge's - // `+333 || +334`; a proof aid, not the game's input). - let plant_on = !std::env::var("E2E_PUSH_PLANT").is_ok_and(|v| v == "0"); - let new_log = r.0.contains_key("plant"); - let planted = if plant_on && new_log { - Some(r.i("plant") != 0) - } else if plant_on && std::env::var("E2E_PLANT_FROM_FEET").is_ok_and(|v| v == "1") && r.0.contains_key("foot_down") { - Some(r.i("foot_down") & 3 != 0 && r.i("brake") == 0 && (100..200).contains(&state)) - } else { - None - }; - let (push_planted, push_trigger) = match planted { - Some(now) => (now, now && !self.planted), - None => (push, push), - }; - self.planted = planted.unwrap_or(false); - self.pushes += u32::from(push_trigger); - let scorable = r.i("scorable"); - let jv = r.f("jv"); - if jv != 0.0 { - self.jump_velocity = skate_audio::player::state::jump_velocity(jv); - } - let feet = r.i("feet") as u32; - let h = if new_log { Self::harness(r) } else { AudioState::default() }; - let audio = AudioState { - dt: 1.0 / 60.0, - ground_speed: speed, - com_velocity: [speed, 0.0, 0.0], - com_position: [self.x, 1.0, 0.0], - board_position: [self.x, 0.1, 0.0], - board_velocity: [speed, 0.0, 0.0], - wheel_count: wheels, - wheel_contact: contact, - // The materials come from the wheel lines (`skate_events::audio_state`): logs since 20:15 - // carry their hit mask (`lines`); older logs and the scripts take every line as hitting - // while any wheel is down (retail keeps wheel 0's material on 100 % of 3-wheel frames). - wheel_material: std::array::from_fn(|i| if lines & (1 << i) != 0 { material_of_tag(tag) } else { 143 }), - // Wheel 0's pattern for the front pair, wheel 3's for the rear (logs since the seams - // port); scripted scenarios have none. - seam_pattern: if unridden { [0; 4] } else { - let (f, b) = (r.i("seam0") as u32, r.i("seam3") as u32); - std::array::from_fn(|i| if lines & (1 << i) == 0 { 0 } else if i < 2 { f } else { b }) - }, - // The wheels around wheel 0's logged position along the deck heading (track 0.2 m, - // wheelbase 0.6 m — the engine's board; only the grid crossings read them). - wheel_position: { - let (x0, z0, h) = (r.f("wheel_x"), r.f("wheel_z"), r.f("heading")); - let (ax, az, rx, rz) = (h.sin(), h.cos(), h.cos(), -h.sin()); - let offs = [(0.0, 0.0), (-0.2, 0.0), (0.0, -0.6), (-0.2, -0.6)]; - std::array::from_fn(|i| [x0 + rx * offs[i].0 + ax * offs[i].1, 0.0, z0 + rz * offs[i].0 + az * offs[i].1]) - }, - turn: r.f("turn"), - slope: r.f("slope"), - airborne, - air_time: self.air_time, - brake: r.i("brake") != 0, - manual_brake: r.i("manual") != 0, - balance: r.i("balance") != 0, - grinding, - trick_active: scorable != -1, - hippy_jump: airborne && scorable == 234, - bail: h.bail, - bail_end: h.bail_end, - // The bail, on-foot / off-board flags, the deck spin and the body regions come from the - // logs since 2026-10-03 (session review #10); older logs: off, as before. - on_foot: new_log && state == 500, - soft_wheels: h.soft_wheels, - push_planted, - push_trigger, - feet_in_deck_box: [feet & 1 != 0, feet & 2 != 0], - grind_family: self.family.unwrap_or(-1), - // `+692` = Grinds+216 − 1 (the packer `sub_827A1B78`), as the game publishes it. - grind_material: self.material.unwrap_or(143), - local: true, - jump_velocity: self.jump_velocity, - audio_trick: -1, - scorable: scorable as i32, - // The scenario's slip is the sine of the deck's turn off the roll (lateral / speed). - slip: if wheels > 0 { skate_audio::player::state::slip(r.f("slip") * speed) } else { 0.0 }, - revert: if new_log { r.i("revert") != 0 } else { state == 102 }, - offboard_308: h.offboard_308, - deck_tilt: r.f("tilt"), - deck_spin: h.deck_spin, - // `+240` / `+260` (Class_Treatment w8 / w9) from the rows' to_land / jump_height (both - // scenario kinds carry them); E2E_AIR_WORDS=0: 0 as before 2026-10-02 (no Treatments). - air_until_landing: if air_words { r.f("to_land") } else { 0.0 }, - jump_height: if air_words { r.f("jump_height") } else { 0.0 }, - // Real-play logs carry these (state_log.rs, appended columns); scripted scenarios - // leave them 0. - grind_impact: r.f("grind_impact"), - deck_impact: if unridden { 0.0 } else { r.f("deck_impact") }, - deck_material: if unridden { 143 } else { material_of_tag(r.i("deck_tag") as u32) }, - foot_speed_y: [r.f("foot_y0"), r.f("foot_y1")], - foot_speed_xz: [r.f("foot_xz0"), r.f("foot_xz1")], - // Off-board inputs (logs since 21:00; older logs: none). - foot_down: [r.i("foot_down") & 1 != 0, r.i("foot_down") & 2 != 0], - foot_material: if r.0.contains_key("foot_tag_a") { [r.i("foot_tag_a") as u32, r.i("foot_tag_b") as u32] } else { [143; 2] }, - hands_on_deck: [r.i("hands") & 1 != 0, r.i("hands") & 2 != 0], - footstep_strength: r.f("strength"), - foot_vertical_speed: [r.f("foot_vy_a"), r.f("foot_vy_b")], - // The skeleton inputs (logs since the skeleton-inputs stage; older logs: the defaults). - step_code: if r.0.contains_key("step") { r.i("step") as i32 } else { 1 }, - body_speed: r.f("body"), - limb_speed: r.f("limb"), - com_speed_216, - ..h - }; - Riding { - board: bevy::math::Vec3::new(self.x, 0.1, 0.0), - speed, - surface: tag, - grinding, - braking: r.i("brake") != 0 && speed > 0.5, - wheels, - pushes: self.pushes, - audio, - // The loose-board inputs (logs since the skeleton-inputs stage; older: upright, no contact). - deck_up: if r.0.contains_key("deck_up") { r.f("deck_up") } else { 1.0 }, - deck_contact: r.i("deck_contact") != 0, - deck_material: material_of_tag(r.i("deck_tag") as u32), - } - } -} - -impl Script { - /// The state-log columns appended 2026-10-03 (session review #10). - fn harness(r: &Row) -> AudioState { - AudioState { - push_stroke: r.i("stroke") != 0, - deck_spin: r.f("deck_spin"), - deck_spin_xy: [r.f("spin_x"), r.f("spin_y")], - bail: r.i("bail") != 0, - bail_end: r.i("bail_end") != 0, - offboard_308: r.i("held") != 0, - offboard_air: r.i("offboard_air") != 0, - footplant: r.i("footplant") != 0, - soft_wheels: r.i("soft") != 0, - body_slide_flag: r.i("face") != 0, - body_impact: std::array::from_fn(|i| r.f(&format!("rimp{i}"))), - body_slide: std::array::from_fn(|i| r.f(&format!("rslide{i}"))), - body_tag: std::array::from_fn(|i| r.i(&format!("rtag{i}")) as u32), - ..AudioState::default() - } - } -} +use super::state_replay::{Script, read}; fn globals(m: &mut MixMap) { for id in 1..=4 { @@ -366,24 +122,14 @@ fn e2e_render() { rt.mixer.buses.flange.set_presets(a, b); } } - // The FootStep SubMix graphs (E2E_FOOTSTEP_SUBMIX=0: the foot voices straight into SFX Master). - rt.mixer.buses.submix.enabled = !off("E2E_FOOTSTEP_SUBMIX"); - // Reverb.in0..6 from the preset and the env scale (E2E_REVERB_INPUTS=0: in5 = 32767, no scale). - let reverb_inputs = !off("E2E_REVERB_INPUTS"); + // The FootStep SubMix graphs. + rt.mixer.buses.submix.enabled = true; let shared = Arc::new(Mutex::new(rt)); let mut m = MixMap::from_bytes(&mxb).unwrap(); let mut p = PlayerAudio::new(library.player_tuning(), true); p.contacts_on = contacts && std::env::var("E2E_CONTACTS").map_or(true, |v| v != "0"); p.contact_tuning = library.contacts_tuning(); p.footsteps_on = p.contacts_on && !off("E2E_FOOTSTEPS"); - // The session-review ports (E2E_PLANT_LIFT / E2E_BODY_IMPACTS / E2E_GRIND_ONOFF=0: off). - p.set_review_ports(!off("E2E_PLANT_LIFT"), !off("E2E_BODY_IMPACTS"), !off("E2E_GRIND_ONOFF")); - // The body poster on the console cadence (with the MixMap's; E2E_BODY_CONSOLE=0: per call). - p.body_console = !off("E2E_BODY_CONSOLE"); - // The deck poster too (E2E_DECK_CONSOLE=0: per call). - p.deck_console = !off("E2E_DECK_CONSOLE"); - // The bridge's speed graph on the body impacts (E2E_BODY_CURVE=0: the logged impacts). - p.set_body_curve(!off("E2E_BODY_CURVE")); // E2E_BODY_LOG=1: every body-poster message (`.ours.bodymsg.tsv`). let body_log = std::env::var("E2E_BODY_LOG").is_ok_and(|v| v == "1"); p.set_body_log(body_log); @@ -391,9 +137,6 @@ fn e2e_render() { p.wheels_on = wheels && std::env::var("E2E_WHEELS").map_or(true, |v| v != "0"); (p.rolling_on, p.rattle_on, p.slide_on, p.tricks_on, p.treatment_on) = (optional[0], optional[1], optional[2], optional[3], optional[4]); let mut bed = super::grain_bed::Bed::new(&library).expect("grain bed data"); - // The bed's turn / brake slews once per console evaluation (with the MixMap's cadence; - // E2E_SLEW_CONSOLE=0: per call, dt-scaled, as before). - bed.slew_console = !off("E2E_SLEW_CONSOLE"); // E2E_SLEW_LOG=1: the bed's turn input, |COM v|, special, I and brake per call // (`.ours.slew.tsv`). let mut slew_log = std::env::var("E2E_SLEW_LOG").is_ok_and(|v| v == "1").then(|| { @@ -402,7 +145,7 @@ fn e2e_render() { w }); let seams = skate_audio::player::tuning::PlayerTuning { seam_wobbles: p.tuning.seam_wobbles.clone(), ..Default::default() }; - let mut script = Script { x: 0.0, air_time: 0.0, pushes: 0, family: None, material: None, jump_velocity: 0.0, planted: false, com_212: 0.0, com_at: None }; + let mut script = Script::default(); let mut out = std::io::BufWriter::new(std::fs::File::create(dir.join(format!("{name}.ours.f32"))).unwrap()); let mut voices = std::io::BufWriter::new(std::fs::File::create(dir.join(format!("{name}.ours.voices.tsv"))).unwrap()); writeln!(voices, "frame\tbank\tslot\tgain\tpitch").unwrap(); @@ -433,8 +176,7 @@ fn e2e_render() { // E2E_CALLS=N: the game's host at N rendered frames per 60 Hz tick (353 fps ≈ 6): the full // tick on the first call; Class_Seams on its console cadence (`PlayerAudio::seam_frame`) on // every call, at the wheel positions interpolated from the previous row like the rendered - // board; the blocks spread evenly. 1 (default) = one call per row, the seams per tick at the - // physics positions, as before. + // board; the blocks spread evenly. 1 (default) = one call per row (the game's host at 60 fps). let calls: usize = std::env::var("E2E_CALLS").ok().and_then(|v| v.parse().ok()).unwrap_or(1).max(1); let per_row = substeps * calls; // E2E_FPS=f (overrides E2E_CALLS): the game's host at a rendered frame rate f, as @@ -442,24 +184,20 @@ fn e2e_render() { // row ticks; every call runs `seam_frame`); below 60 fps a call ticks the MixMap once per // elapsed 60 Hz step and rows without a call only render. let fps: Option = std::env::var("E2E_FPS").ok().and_then(|v| v.parse().ok()); - let console = fps.is_some() || calls > 1; // The MixMap's console cadence (`native::mixmap_frame`, `skate_audio::mixmap::cadence`): one // evaluation per two 60 Hz rows with dt 1/30, the Jitter stepped and the eEQChain cleared - // there, the flag inputs held in between. On with E2E_FPS (the game's host) unless - // E2E_MIX_CONSOLE=0; E2E_MIX_CONSOLE=1 turns it on for the per-row renders too. - let mix_console = match std::env::var("E2E_MIX_CONSOLE").ok().as_deref() { - Some("0") => false, - Some("1") => true, - _ => fps.is_some(), - }; - let mut cadence = skate_audio::mixmap::cadence::Cadence::default(); - if mix_console { - super::native::hold_flag_inputs(&mut m); - } - let (mut fps_acc, mut rows_since_call) = (0.0f64, 0usize); + // there, the flag inputs held in between (2026-10-03: the only host; the per-row renders + // before it ran the old 60 Hz evaluation, see doc 11 "A/B switches removed"). + // The game's host clock (`native::HostClock`): each row is a physics step published as + // `skate_events::observe` publishes it (one-step pulses latched until a pass takes them); + // a pass takes the steps since the last one (capped), as `native::mixmap_frame`. + let mut clock = super::native::HostClock::default(); + let mut cues = super::skate_events::Cues::default(); + super::native::hold_flag_inputs(&mut m); + let mut fps_acc = 0.0f64; for (frame, &i) in order.iter().enumerate() { let row_riding = script.riding(&rows[i]); - rows_since_call += 1; + cues.publish(row_riding); let (n_row, calls) = match fps { Some(f) => { fps_acc += f / 60.0; @@ -484,13 +222,12 @@ fn e2e_render() { } } for call in 0..n_row { - // The console seams cadence (Listening test 9) whenever the host runs other than one - // call per row: every call is a rendered frame of 1/f s. - if console { - p.seam_console = true; + // The console seams cadence (Listening test 9): every call is a rendered frame of 1/f s. + { p.seam_alpha = Some((call % calls) as f32 / calls as f32); let dt = fps.map_or(1.0 / (60.0 * calls as f64), |f| 1.0 / f) as f32; - p.seam_frame(&m, &row_riding.audio, dt, &mut shared.lock().unwrap()); + p.step_wheels(cues.riding.wheels_before, cues.riding.audio.wheel_position); + p.seam_frame(&m, &cues.riding.audio, dt, &mut shared.lock().unwrap()); } if call % calls != 0 { owed += blocks_per_frame / per_row as f64; @@ -507,29 +244,32 @@ fn e2e_render() { continue; } let t0 = std::time::Instant::now(); - let riding = row_riding; + // E2E_SUBSTEPS: every ticking call of a row is a pass on the row's sample. + if call > 0 { + cues.publish(row_riding); + } + let pass = clock.pass(&mut cues, false).expect("a ticking call follows a published row"); + let riding = cues.riding; let mut s = riding.audio; if substeps > 1 { s.dt = sub_dt; } globals(&mut m); - if reverb_inputs { + { let v = shared.lock().unwrap().mixer.buses.env.reverb_inputs(); for (id, x) in v.into_iter().enumerate() { m.set_input(keys::REVERB, id, x); } } - // Below 60 fps one call ticks once per elapsed 60 Hz step (`mixmap_frame`'s ticks loop), - // and the listener's velocity and the bed's frame span those steps (the game passes the - // real frame time). Before 2026-10-03 both took one row's dt there: E2E_FPS < 60 renders - // ran the camera velocity (Doppler) and the bed's clocks off by the rows per call. - let ticks = if fps.is_some_and(|f| f < 60.0) { rows_since_call } else { 1 }; + // Below 60 fps one call ticks once per physics step since the last call (the game's + // `HostClock`), and the listener's velocity and the bed's frame span those steps. + let ticks = pass.ticks; let call_dt = sub_dt * ticks as f32; let l = p.listener([script.x - 3.0, 2.5, 0.0], [1.0, 0.0, 0.0], call_dt, &s); - let mix_calls = if mix_console { cadence.advance(ticks) } else { 0 }; - p.jitter_steps = mix_console.then_some(mix_calls); + let mix_calls = pass.calls; + p.jitter_steps = Some(mix_calls); // As `mixmap_frame`: sub_82491180 (half 1) before the inputs, with the last walk's values. - if mix_console && mix_calls > 0 { + if mix_calls > 0 { shared.lock().unwrap().mixer.buses.eq.clear(p.eq_jitter()); } p.write_inputs(&mut m, &s, Some(&l)); @@ -552,28 +292,16 @@ fn e2e_render() { deck_seen = p.deck_trace(); writeln!(deck, "{frame}\t{}\t{:016x}", deck_seen.0, deck_seen.1).unwrap(); } - if mix_console { - for _ in 0..mix_calls { - m.tick(skate_audio::mixmap::cadence::CONSOLE_DT); - } - } else { - for _ in 0..ticks { - m.tick(sub_dt); - } - } - rows_since_call = 0; - if !mix_console && frame % 2 == 1 { - shared.lock().unwrap().mixer.buses.eq.clear(p.eq_jitter()); + for _ in 0..mix_calls { + m.tick(skate_audio::mixmap::cadence::CONSOLE_DT); } let t3 = std::time::Instant::now(); p.update(&m, &s, &mut shared.lock().unwrap(), speed_scale, loose); shared.lock().unwrap().mixer.buses.flange.frame(std::array::from_fn(|i| m.level(keys::REVERB, i))); - if reverb_inputs { - shared.lock().unwrap().mixer.buses.env.scale_frame(m.level(keys::REVERB, 4)); - } + shared.lock().unwrap().mixer.buses.env.scale_frame(m.level(keys::REVERB, 4)); let t4 = std::time::Instant::now(); let routed = p.rolling_on.then(|| (std::mem::take(&mut p.routed.grains), p.routed.primary)); - bed.slew_calls = mix_console.then_some(mix_calls); + bed.slew_calls = Some(mix_calls); super::grain_bed::step(&mut bed, &library, &m, &shared, &riding, call_dt, &seams, routed); if let Some(w) = slew_log.as_mut() { let (turn, brake) = bed.slews(); diff --git a/crates/skate-game/src/game_audio/emitters.rs b/crates/skate-game/src/game_audio/emitters.rs index b6394fcc..3e277342 100644 --- a/crates/skate-game/src/game_audio/emitters.rs +++ b/crates/skate-game/src/game_audio/emitters.rs @@ -20,55 +20,16 @@ //! emitter_payload`: w1 dry = out4 × level, w2 send = out8 × level, w3 pan = out0, w4 pitch = //! out5, w5 low-pass = out6, w8 = the attribute patch = selector); the state's 3-D input gets the //! listener's distance and azimuth each frame. Redelivered every frame, released (state freed) -//! when the listener leaves; -//! - otherwise (the runtime could not start) `PROFILES` holds those programs' measured behaviour (PoC evaluator runs, notes -//! ems-emitters-re.md) and banks without a profile are not played. -use super::{Category, Library, Play, Voices, library::Clip, native::Native, voices::VoiceId}; +//! when the listener leaves. +//! +//! Without the native runtime (an install without the AEMS data: `native.rs` logs the error) the +//! emitters are silent; there is no measured fallback (2026-10-03: the `PROFILES` table is gone). +use super::{Library, native::Native}; use bevy::prelude::*; use skate_audio::eval::NodeId; /// CSTATEMGR_Emitter's pool size. const MAX_ACTIVE: usize = 5; -/// Voices are placed at most this far from the listener towards the emitter, -/// so Bevy's distance attenuation stays 1 and the retail level is the only one. -const PAN_DISTANCE: f32 = 8.0; -/// Stop fade (s). Retail releases at once; this only avoids a click. -const STOP_FADE: f32 = 0.05; - -/// How a bank's program plays. -#[derive(Clone, Copy, Debug, PartialEq)] -enum Pattern { - /// Two voices take turns, each drawing from its own shuffle bag of the - /// bank's samples; the next starts `overlap` seconds before the current ends. - Relay { overlap: f32 }, - /// One voice loops the bank's first sample. - Loop, -} - -/// Measured behaviour of a bank's program: pattern, level and pitch ranges and -/// their modulation periods (seconds). -#[derive(Clone, Copy, Debug)] -struct Profile { - bank: &'static str, - pattern: Pattern, - level: (f32, f32, f32), - pitch: (f32, f32, f32), -} - -const PROFILES: &[Profile] = &[ - Profile { bank: "water_fountain", pattern: Pattern::Relay { overlap: 0.17 }, level: (0.645, 0.942, 3.46), pitch: (0.818, 0.940, 4.0) }, - Profile { bank: "water_lapping", pattern: Pattern::Relay { overlap: 0.17 }, level: (0.466, 1.0, 2.75), pitch: (0.940, 1.062, 4.0) }, - Profile { bank: "water_lapping_pond", pattern: Pattern::Relay { overlap: 0.17 }, level: (0.466, 1.0, 3.52), pitch: (0.940, 1.062, 4.0) }, - Profile { bank: "ocean_wave_small", pattern: Pattern::Relay { overlap: 0.0 }, level: (0.479, 0.824, 4.0), pitch: (0.855, 1.001, 9.98) }, - Profile { bank: "water_dam_close", pattern: Pattern::Loop, level: (0.462, 0.759, 3.5), pitch: (0.952, 1.031, 4.0) }, - Profile { bank: "water_dam_far", pattern: Pattern::Loop, level: (0.797, 1.0, 3.52), pitch: (0.915, 1.062, 4.0) }, - Profile { bank: "fountains_waterlaps_left", pattern: Pattern::Loop, level: (1.0, 1.0, 1.0), pitch: (1.0, 1.0, 1.0) }, - Profile { bank: "fountains_waterlaps_right", pattern: Pattern::Loop, level: (1.0, 1.0, 1.0), pitch: (1.0, 1.0, 1.0) }, -]; - -fn profile(bank: &str) -> Option<&'static Profile> { - PROFILES.iter().find(|p| p.bank == bank) -} /// The `.ems` file of a map (by its `.skate` file stem). fn ems_file(map_stem: &str) -> Option<&'static str> { @@ -263,23 +224,14 @@ struct Emitter { shape: Shape, volume: f32, falloff: i32, - /// The measured table's profile (None when the native runtime plays the bank). - profile: Option<&'static Profile>, bank: String, patch: i32, } -/// A started emitter: its voices and program state. +/// A reached emitter: its native post and emitter state once started. struct Node { record: usize, started: bool, - /// (voice, clip, end time) of the voice currently leading the relay or looping. - voices: Vec<(VoiceId, Clip, f64)>, - /// Which relay voice plays next, and each voice's shuffle bag. - turn: usize, - bags: [Vec; 2], - phase: (f32, f32), - /// The native runtime's post and emitter state (native mode only). post: Option, state: Option, } @@ -290,9 +242,8 @@ pub(super) struct State { emitters: Vec, /// Reached records in discovery order (retail's node list). nodes: Vec, - rng: u32, /// The map's native emitter banks (prefetch, `native::prefetch`), each emitter's index into - /// them (`usize::MAX`: a measured-table emitter) and each bank's prefetch status. + /// them and each bank's prefetch status. banks: Vec, emitter_bank: Vec, bank_status: Vec, @@ -346,66 +297,33 @@ fn prefetch_near(state: &mut State, native: &mut Native, library: &Library, ear: } } -fn random(rng: &mut u32) -> f32 { - if *rng == 0 { - *rng = 0x9e37_79b9; - } - *rng ^= *rng << 13; - *rng ^= *rng >> 17; - *rng ^= *rng << 5; - (*rng >> 8) as f32 / (1u32 << 24) as f32 -} - -/// Next sample from a shuffle bag over `count` samples (no repeat until empty). -fn draw(bag: &mut Vec, count: usize, rng: &mut u32) -> usize { - if bag.is_empty() { - *bag = (0..count).collect(); - } - let at = (random(rng) * bag.len() as f32) as usize % bag.len(); - bag.swap_remove(at) -} - -fn modulate((low, high, period): (f32, f32, f32), phase: f32, now: f64) -> f32 { - let wave = (std::f64::consts::TAU * now / f64::from(period.max(0.1)) + f64::from(phase)).sin() as f32; - low + (high - low) * 0.5 * (1.0 + wave) -} - #[allow(clippy::too_many_arguments)] pub(super) fn update( mut state: Local, - mut commands: Commands, map: Res, - library: Option>, - mut voices: ResMut, - mut assets: ResMut>, + library: Option>, listener: Query<&GlobalTransform, With>, - time: Res>, menu: Option>, replay: Res, - mut native: Option>, + native: Option>, cues: Res, ) { let _timing = super::timing::scope(&super::timing::EMITTERS); - let Some(mut library) = library else { return }; + // No native runtime: the emitters are silent (its start logged why). + let (Some(library), Some(mut native)) = (library, native) else { return }; + let native = &mut *native; let state = &mut *state; let identity = (map.name.clone(), map.generation); if state.map.as_ref() != Some(&identity) { for node in state.nodes.drain(..) { - for (id, ..) in node.voices { - voices.stop(id, STOP_FADE); + if let Some(post) = node.post { + native.release(post); } - if let Some(native) = native.as_deref_mut() { - if let Some(post) = node.post { - native.release(post); - } - if let Some(g) = node.state { - native.release_emitter_state(g); - } + if let Some(g) = node.state { + native.release_emitter_state(g); } } - if let Some(native) = native.as_deref_mut() { - native.unload_map_banks(); - } + native.unload_map_banks(); let stem = map.path.as_deref().and_then(|p| p.file_stem()).and_then(|s| s.to_str()).unwrap_or(""); // Retail's emitter system loads every file of the map's database entry and dispatches by // the attribute's eVolumeType: 1 = looping emitter (here), 5 = reverb zone @@ -413,27 +331,19 @@ pub(super) fn update( // music system, not ported). 6 / 7 (speakers / crowds) are not dispatched by the // emitter system at all. On the disc only the `sfx_` / `skateschool` files hold type 1. let records: Vec<&super::library::EmitterRecord> = ems_files(stem).iter().flat_map(|file| library.emitters(file)).collect(); - let native_banks = native.as_deref(); state.emitters = records.iter().filter(|r| r.kind == 1 && r.flags == 0).filter_map(|r| { - let bank = r.bank.clone()?; - let profile = match native_banks { - Some(n) if n.has_bank(&library, &bank) => None, - Some(_) => return None, - None => Some(profile(&bank)?), - }; + let bank = r.bank.clone().filter(|b| native.has_bank(&library, b))?; let s = r.scalars; Some(Emitter { shape: Shape { position: Vec3::from(r.position), extent: Vec3::from(r.extent), forward: Vec3::new(s[1], s[2], s[3]), core: s[0] }, - volume: r.volume, falloff: r.falloff, profile, bank, patch: r.patch, + volume: r.volume, falloff: r.falloff, bank, patch: r.patch, }) }).collect(); info!("World emitters: {} of {} records on {stem} have a played sound", state.emitters.len(), records.len()); state.banks.clear(); state.emitter_bank.clear(); for e in &state.emitters { - let b = if e.profile.is_some() { - usize::MAX - } else if let Some(b) = state.banks.iter().position(|s| *s == e.bank) { + let b = if let Some(b) = state.banks.iter().position(|s| *s == e.bank) { b } else { state.banks.push(e.bank.clone()); @@ -447,12 +357,8 @@ pub(super) fn update( let Ok(listener) = listener.single() else { return }; let ear = listener.translation(); let skater = cues.riding.board; - if let Some(native) = native.as_deref_mut() { - prefetch_near(state, native, &library, ear); - } + prefetch_near(state, native, &library, ear); let silent = super::silenced(menu.as_deref(), &replay); - let now = time.elapsed_secs_f64(); - let mut rng = state.rng; // Release nodes the listener left (or everything while silenced). let reached: Vec> = state.emitters.iter().map(|e| if silent { None } else { reach(&e.shape, ear) }).collect(); @@ -462,16 +368,11 @@ pub(super) fn update( if node.started { info!("AUDIO_EMITTER stop {} #{}", state.emitters[node.record].bank, node.record); } - for (id, ..) in &node.voices { - voices.stop(*id, STOP_FADE); + if let Some(post) = node.post { + native.release(post); } - if let Some(native) = native.as_deref_mut() { - if let Some(post) = node.post { - native.release(post); - } - if let Some(g) = node.state { - native.release_emitter_state(g); - } + if let Some(g) = node.state { + native.release_emitter_state(g); } } keep @@ -479,12 +380,7 @@ pub(super) fn update( // New hits join the node list in discovery order. for (index, hit) in reached.iter().enumerate() { if hit.is_some() && !state.nodes.iter().any(|n| n.record == index) { - state.nodes.push(Node { - record: index, started: false, voices: Vec::new(), turn: 0, bags: [Vec::new(), Vec::new()], - phase: (random(&mut rng) * std::f32::consts::TAU, random(&mut rng) * std::f32::consts::TAU), - post: None, - state: None, - }); + state.nodes.push(Node { record: index, started: false, post: None, state: None }); } } // Waiting nodes take free states in list order. @@ -496,84 +392,35 @@ pub(super) fn update( node.started = true; active += 1; let e = &state.emitters[node.record]; - info!("AUDIO_EMITTER start {} #{} at {:.1?} volume {:.2}{}", e.bank, node.record, e.shape.position.to_array(), e.volume, - if e.profile.is_none() { " (native)" } else { "" }); - if e.profile.is_none() { - if let Some(native) = native.as_deref_mut() { - match native.ensure_bank(&library, &e.bank) { - Ok(_) => { - if let Some(status) = state.emitter_bank.get(node.record).and_then(|&b| state.bank_status.get_mut(b)) { - *status = BankStatus::Loaded; - } - let level = e.volume * falloff(e.falloff, reached[node.record].unwrap_or(1.0)); - node.state = native.claim_emitter_state(); - if let Some(g) = node.state { - native.set_emitter_position(g, listener, skater, e.shape.position); - } - let payload = native.emitter_payload(node.state, level, super::native::azimuth(listener, e.shape.position), e.patch); - node.post = native.post_emitter(&payload); - } - Err(error) => warn!("AUDIO_EMITTER {}: {error}", e.bank), + info!("AUDIO_EMITTER start {} #{} at {:.1?} volume {:.2} (native)", e.bank, node.record, e.shape.position.to_array(), e.volume); + match native.ensure_bank(&library, &e.bank) { + Ok(_) => { + if let Some(status) = state.emitter_bank.get(node.record).and_then(|&b| state.bank_status.get_mut(b)) { + *status = BankStatus::Loaded; } + let level = e.volume * falloff(e.falloff, reached[node.record].unwrap_or(1.0)); + node.state = native.claim_emitter_state(); + if let Some(g) = node.state { + native.set_emitter_position(g, listener, skater, e.shape.position); + } + let payload = native.emitter_payload(node.state, level, super::native::azimuth(listener, e.shape.position), e.patch); + node.post = native.post_emitter(&payload); } + Err(error) => warn!("AUDIO_EMITTER {}: {error}", e.bank), } } + // The bank's program does the rest; only the game-side words change. for node in state.nodes.iter_mut().filter(|n| n.started) { let emitter = &state.emitters[node.record]; - let Some(d) = reached[node.record] else { continue }; - let Some(profile) = emitter.profile else { - // Native: the bank's program does the rest; only the game-side words change. - if let (Some(post), Some(native)) = (node.post, native.as_deref_mut()) { - let level = emitter.volume * falloff(emitter.falloff, d); - if let Some(g) = node.state { - native.set_emitter_position(g, listener, skater, emitter.shape.position); - } - let payload = native.emitter_payload(node.state, level, super::native::azimuth(listener, emitter.shape.position), emitter.patch); - native.redeliver(post, &payload); - } - continue; - }; - let level = emitter.volume * falloff(emitter.falloff, d) * modulate(profile.level, node.phase.0, now); - let pitch = modulate(profile.pitch, node.phase.1, now); - let towards = emitter.shape.position - ear; - let at = ear + towards.normalize_or_zero() * towards.length().min(PAN_DISTANCE); - node.voices.retain(|(id, ..)| voices.playing(*id)); - for (id, ..) in &node.voices { - voices.set(*id, level, pitch, Some(at)); - } - let samples = library.bank_len(profile.bank); - if samples == 0 { - continue; - } - let play = |looping| Play { category: Category::Ambience, volume: level, pitch, position: Some(at), looping, fade_in: 0.0, envelope: None }; - match profile.pattern { - Pattern::Loop => { - if node.voices.is_empty() { - if let Some(clip) = library.sample(&mut assets, profile.bank, 0) { - if let Some(id) = voices.play(&mut commands, &clip, play(true), now) { - node.voices.push((id, clip, f64::INFINITY)); - } - } - } - } - Pattern::Relay { overlap } => { - let due = node.voices.iter().map(|v| v.2).fold(f64::NEG_INFINITY, f64::max) - f64::from(overlap); - if node.voices.is_empty() || now >= due { - let turn = node.turn; - let index = draw(&mut node.bags[turn], samples, &mut rng); - if let Some(clip) = library.sample(&mut assets, profile.bank, index) { - let seconds = library.sample_seconds(profile.bank, index); - if let Some(id) = voices.play(&mut commands, &clip, play(false), now) { - node.voices.push((id, clip, now + f64::from(seconds / pitch.max(0.25)))); - node.turn = 1 - turn; - } - } - } - } + let (Some(d), Some(post)) = (reached[node.record], node.post) else { continue }; + let level = emitter.volume * falloff(emitter.falloff, d); + if let Some(g) = node.state { + native.set_emitter_position(g, listener, skater, emitter.shape.position); } + let payload = native.emitter_payload(node.state, level, super::native::azimuth(listener, emitter.shape.position), emitter.patch); + native.redeliver(post, &payload); } - state.rng = rng; } #[cfg(test)] @@ -585,18 +432,19 @@ mod tests { /// banks, evaluator random state and blocks stay untouched; requested = the banks within /// `AHEAD` m that are not loaded; far away everything is dropped. Prints its cost per call. #[test] + #[ignore = "needs the private install data"] fn the_prefetch_follows_the_listener_and_touches_no_runtime_state() { use super::super::native::prefetch::{AHEAD, EVICT}; let root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets")); - let Ok(library) = Library::load(root) else { return eprintln!("skipped: no audio install") }; - let Ok(mut native) = Native::start(&library) else { return eprintln!("skipped: no AEMS install") }; + let Ok(library) = Library::load(root) else { panic!("missing private data: no audio install") }; + let Ok(mut native) = Native::start(&library) else { panic!("missing private data: no AEMS install") }; let mut state = State::default(); for r in ems_files("DownTown").iter().flat_map(|f| library.emitters(f)) { let Some(bank) = r.bank.clone().filter(|b| r.kind == 1 && r.flags == 0 && native.has_bank(&library, b)) else { continue }; let s = r.scalars; state.emitters.push(Emitter { shape: Shape { position: Vec3::from(r.position), extent: Vec3::from(r.extent), forward: Vec3::new(s[1], s[2], s[3]), core: s[0] }, - volume: r.volume, falloff: r.falloff, profile: None, bank: bank.clone(), patch: r.patch, + volume: r.volume, falloff: r.falloff, bank: bank.clone(), patch: r.patch, }); let b = state.banks.iter().position(|x| *x == bank).unwrap_or_else(|| { state.banks.push(bank); @@ -605,7 +453,7 @@ mod tests { state.emitter_bank.push(b); } if state.emitters.is_empty() { - return eprintln!("skipped: no DownTown emitters"); + panic!("missing private data: no DownTown emitters"); } state.bank_status = vec![BankStatus::Idle; state.banks.len()]; let snapshot = |n: &Native| { @@ -701,24 +549,17 @@ mod tests { assert_eq!(falloff(7, 0.5), 1.0); } - #[test] - fn shuffle_bag_plays_every_sample_before_repeating() { - let (mut bag, mut rng) = (Vec::new(), 1); - let mut seen: Vec = (0..10).map(|_| draw(&mut bag, 10, &mut rng)).collect(); - seen.sort_unstable(); - assert_eq!(seen, (0..10).collect::>()); - } - /// A DownTown reverb zone (attribute `1F94F2F815C00368` → reverb11) through the retail selector: /// standing in its core the zone's preset fades in and commits, Reverb.in5 rises, and the /// MixMap's Reverb out4 (the global env scale) drops by F213's −400 mB. #[test] + #[ignore = "needs the private install data"] fn a_downtown_reverb_zone_selects_its_preset_and_raises_reverb_in5() { let root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets")); - let Ok(library) = Library::load(root) else { return eprintln!("skipped: no audio install") }; + let Ok(library) = Library::load(root) else { panic!("missing private data: no audio install") }; let records = zone_records(&library, "DownTown"); if records.is_empty() { - return eprintln!("skipped: the install has no reverb-zone presets (stage_reverb_zones.py)"); + panic!("missing private data: the install has no reverb-zone presets (stage_reverb_zones.py)"); } let (presets, _) = library.bus_tuning(); let Some(zone) = records.iter().find(|z| z.attribute == 0x1F94_F2F8_15C0_0368) else { panic!("no 1F94F2F815C00368 zone") }; @@ -742,7 +583,7 @@ mod tests { } assert_eq!(env.target_key(), Some(0xBEEF_C8E3_DE04_FBAE)); assert_eq!(env.reverb_inputs(), [0, 0, 0, 0, 0, 32767, 0], "reverb11 → Reverb.in5"); - let Some(mxb) = library.aems().mixmap.clone() else { return eprintln!("skipped: no MixMap") }; + let Some(mxb) = library.aems().mixmap.clone() else { panic!("missing private data: no MixMap") }; let mut m = skate_audio::mixmap::MixMap::from_bytes(&library.read(&mxb).unwrap()).unwrap(); let out4 = |m: &mut skate_audio::mixmap::MixMap, inputs: [i32; 7]| { for id in 1..=4 { @@ -768,7 +609,6 @@ mod tests { for map in ["University", "DownTown", "Industrial", "SkateSchool", "MegaPark"] { assert!(ems_file(map).is_some(), "{map}"); } - assert!(profile("water_fountain").is_some() && profile("trees_rustle").is_none()); } } diff --git a/crates/skate-game/src/game_audio/grain_bed.rs b/crates/skate-game/src/game_audio/grain_bed.rs index 50372fc2..14e4804a 100644 --- a/crates/skate-game/src/game_audio/grain_bed.rs +++ b/crates/skate-game/src/game_audio/grain_bed.rs @@ -126,14 +126,11 @@ pub(crate) struct Bed { /// The turn intensity (`sub_824C8588`) and brake slews: retail steps each once per call of the /// SkateBoard process, no dt. slews: OwnerSlews, - /// Those slews on the console cadence (2026-10-03, user decision "lets go for like retail"): - /// one retail step per console frame, [`Self::slew_calls`] times per host pass, so a release - /// takes the console's ~333 ms at any real frame rate. `SKATE_AEMS_SLEW_CONSOLE=0` / - /// `E2E_SLEW_CONSOLE=0`: the old host (the step × dt·60, i.e. 0.06 per 1/60 s). - pub(crate) slew_console: bool, /// The console evaluations ending in this pass (`mixmap::cadence`), set by the host before each - /// [`step`]; taken by it. `None` (the MixMap's console cadence off, the per-row e2e renders, - /// tests): the old per-frame slews. + /// [`step`]; taken by it. The slews run on the console cadence (2026-10-03, user decision "lets + /// go for like retail"): one retail step per console frame, so a release takes the console's + /// ~333 ms at any real frame rate. `None` (unit tests only): the per-frame slews (the step × + /// dt·60). pub(crate) slew_calls: Option, /// Send, level ramp, wobbles and the gains each chain holds. chain: ChainState, @@ -182,7 +179,6 @@ impl Bed { push_scale: PushEnvelope::default(), shift: PushShift::default(), slews: OwnerSlews::default(), - slew_console: !std::env::var("SKATE_AEMS_SLEW_CONSOLE").is_ok_and(|v| v == "0"), slew_calls: None, chain: ChainState::default(), latches: Latches::default(), @@ -220,7 +216,6 @@ impl Bed { push_scale: PushEnvelope::default(), shift: PushShift::default(), slews: OwnerSlews::default(), - slew_console: self.slew_console, slew_calls: None, chain: ChainState::default(), latches: Latches::default(), @@ -316,24 +311,29 @@ impl Bed { } } +/// One host pass of the bed, on the frames the MixMap host ran ([`super::native::Native::frame_ticks`]: +/// the physics steps it took), with dt = those steps; nothing on a frame without a step or while +/// silenced (2026-10-03: before, it ran every rendered frame in real time, the menu pause included, +/// on a sample that only changes per physics step; the e2e harness always stepped it per pass). pub(super) fn update( native: Option>, library: Option>, cues: Res, - time: Res>, ) { let _timing = super::timing::scope(&super::timing::GRAIN_BED); let (Some(mut native), Some(library)) = (native, library) else { return }; let native = &mut *native; - // The seam wobbles live with the player tuning. - let seams = skate_audio::player::tuning::PlayerTuning { - seam_wobbles: native.player.as_ref().map(|p| p.tuning.seam_wobbles.clone()).unwrap_or_default(), - ..Default::default() - }; - // The owner's routing (native rolling layers): its binds / stops since the last frame. + let ticks = native.frame_ticks; + if ticks == 0 { + return; + } + // The owner's routing (native rolling layers): its binds / stops since the last pass. let routed = native.player.as_mut().filter(|p| p.components && p.rolling_on).map(|p| (std::mem::take(&mut p.routed.grains), p.routed.primary)); + // The seam wobbles live with the player tuning. + static NO_TUNING: std::sync::OnceLock = std::sync::OnceLock::new(); + let seams = native.player.as_ref().map_or_else(|| NO_TUNING.get_or_init(Default::default), |p| &p.tuning); let (Some(bed), Some(m)) = (&mut native.bed, &native.mixmap) else { return }; - step(bed, &library, m, &native.shared, &cues.riding, time.delta_secs(), &seams, routed); + step(bed, &library, m, &native.shared, &cues.riding, ticks as f32 * super::native::MIX_STEP, seams, routed); } /// One game frame of the bed (after the MixMap tick): [`update`]'s body, callable headless. @@ -376,7 +376,7 @@ pub(super) fn step_with( let r = *r; let s = r.audio; let frames = (dt * 60.0).clamp(0.0, 6.0); - let slew_calls = bed.slew_calls.take().filter(|_| bed.slew_console); + let slew_calls = bed.slew_calls.take(); let speed = r.speed.abs(); // Surface routing: the wanted binding per truck (None = stopped). @@ -562,16 +562,15 @@ mod tests { /// material (tag − 1; 0 = none → 143 → surface 3) → rolling surface → grain member, for every /// surface that plays a grain (1–6, 9). And every member has its tuning and recording. #[test] + #[ignore = "needs the private install data"] fn grain_for_matches_the_vault_surface_map_and_the_bed_loads() { let root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets")); let Ok(library) = Library::load(root) else { - eprintln!("skipped: no audio install under {}", root.display()); - return; + panic!("missing private data: no audio install under {}", root.display()); }; let map = library.surface_map(); if map.is_empty() { - eprintln!("skipped: the install has no grain tuning (stage_grain_mixmap.py)"); - return; + panic!("missing private data: the install has no grain tuning (stage_grain_mixmap.py)"); } assert_eq!(map.len(), 95); for tag in 0..128u32 { diff --git a/crates/skate-game/src/game_audio/library.rs b/crates/skate-game/src/game_audio/library.rs index 698c8180..25691817 100644 --- a/crates/skate-game/src/game_audio/library.rs +++ b/crates/skate-game/src/game_audio/library.rs @@ -17,8 +17,8 @@ const MANIFEST_VERSIONS: std::ops::RangeInclusive = 3..=5; #[derive(Debug, Deserialize)] pub(crate) struct Entry { pub file: String, - #[serde(default)] - pub seconds: f32, + // Manifests also carry `seconds` (the sample length): read only by the removed measured + // emitter relay, so it is ignored. } /// One record of a map's `.ems` emitter file with its sound's attributes @@ -898,11 +898,6 @@ impl Library { self.manifest.banks.get(bank).map_or(0, Vec::len) } - /// Length of a bank sample in seconds at normal speed (0 if unknown). - pub(crate) fn sample_seconds(&self, bank: &str, index: usize) -> f32 { - self.manifest.banks.get(bank).and_then(|b| b.get(index)).map_or(0.0, |e| e.seconds) - } - /// The records of an `.ems` emitter file (empty when absent). pub(crate) fn emitters(&self, file: &str) -> &[EmitterRecord] { self.manifest.emitters.get(file).map_or(&[], Vec::as_slice) @@ -1056,6 +1051,7 @@ mod tests { /// of the user's own disc, class `6EBA5BCD3E38A98A` `default` field `8B164823E008749C`, a /// `Sk8::PointNegGraphData8`: 16-byte header, x at +16, y at +48). #[test] + #[ignore = "needs the private install data"] fn the_body_speed_graph_is_the_stock_vault_record() { use skate_audio::player::contacts::SpeedGraph8; let json: CollisionJson = serde_json::from_str(r#"{"posters": {"body_speed_x": [0, 1, 2, 3, 4, 5, 6, 7], "body_speed_y": [1, 1, 1, 1, 2, 2, 2, 2]}}"#).unwrap(); @@ -1064,7 +1060,7 @@ mod tests { assert_eq!(CollisionJson::default().contacts().body_speed_curve, SpeedGraph8::BODY_SPEED, "else the vault's words"); let path = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/stock/skater-collections.json")); let Ok(text) = std::fs::read_to_string(path) else { - return eprintln!("skipped: no converted skater collections"); + panic!("missing private data: no converted skater collections"); }; let all: serde_json::Value = serde_json::from_str(&text).unwrap(); let rec = all["collections"].as_array().unwrap().iter() diff --git a/crates/skate-game/src/game_audio/mod.rs b/crates/skate-game/src/game_audio/mod.rs index 6ee74f01..5d21bcde 100644 --- a/crates/skate-game/src/game_audio/mod.rs +++ b/crates/skate-game/src/game_audio/mod.rs @@ -24,10 +24,12 @@ mod npc_skaters; mod player_audio; mod random_programs; mod random_sets; -mod skate_events; +pub(crate) mod skate_events; mod state_log; +mod state_replay; mod timing; mod voices; +pub(crate) mod world_bridge; mod world_sources; use bevy::{audio::Volume, prelude::*}; @@ -46,12 +48,16 @@ struct SavedSettings { master: u32, ambience: u32, effects: u32, + /// Opt-in, NOT retail (user decision 2026-10-03): more audible world objects at once (8 cars, + /// 24 peds, 3 NPC / remote skaters instead of retail's 4 / 15 / 1; `native::WorldInstances`). + /// Read at start. `SKATE_AUDIO_MORE_AUDIBLE=1` turns it on for one run. + more_audible_world: bool, // Files saved before 2026-10-03 may hold `"interim"` (the opt-out to the removed interim cue // tables) or the older `"native"`; unknown keys are ignored, so they still load. } impl Default for SavedSettings { fn default() -> Self { - Self { master: 75, ambience: 100, effects: 100 } + Self { master: 75, ambience: 100, effects: 100, more_audible_world: false } } } impl SavedSettings { @@ -87,6 +93,10 @@ impl AudioSettings { pub(crate) fn master(&self) -> f32 { if self.muted { 0.0 } else { self.saved.master as f32 / 100.0 } } + /// The non-retail "more audible" world layout (see `SavedSettings::more_audible_world`). + pub(crate) fn more_audible_world(&self) -> bool { + self.saved.more_audible_world || std::env::var("SKATE_AUDIO_MORE_AUDIBLE").is_ok_and(|v| v == "1") + } pub(crate) fn category(&self, category: Category) -> f32 { let percent = match category { Category::Ambience => self.saved.ambience, @@ -160,6 +170,7 @@ impl Plugin for GameAudioPlugin { .add_plugins(native::register) .add_plugins(world_sources::register) .add_plugins(npc_skaters::register) + .add_plugins(world_bridge::register) .add_systems( PostUpdate, (apply_global_volume, follow_camera).before(bevy::transform::TransformSystems::Propagate), @@ -215,7 +226,7 @@ mod tests { #[test] fn old_settings_files_with_interim_or_native_keys_still_load() { let old: SavedSettings = serde_json::from_str(r#"{"master":60,"ambience":75,"effects":75,"native":false}"#).unwrap(); - assert_eq!(old, SavedSettings { master: 60, ambience: 75, effects: 75 }); + assert_eq!(old, SavedSettings { master: 60, ambience: 75, effects: 75, more_audible_world: false }); let opted_out: SavedSettings = serde_json::from_str(r#"{"master":50,"interim":true}"#).unwrap(); assert_eq!(opted_out, SavedSettings { master: 50, ..SavedSettings::default() }); } @@ -224,7 +235,9 @@ mod tests { fn defaults_are_quiet_and_saved_values_are_bounded() { assert_eq!(SavedSettings::default().master, 75); let loaded: SavedSettings = serde_json::from_str(r#"{"master":400,"ambience":33,"effects":7}"#).unwrap(); - assert_eq!(loaded.validated(), SavedSettings { master: 100, ambience: 30, effects: 5 }); + assert_eq!(loaded.validated(), SavedSettings { master: 100, ambience: 30, effects: 5, more_audible_world: false }); + let more: SavedSettings = serde_json::from_str(r#"{"more_audible_world":true}"#).unwrap(); + assert!(more.more_audible_world && more.master == 75); } #[test] diff --git a/crates/skate-game/src/game_audio/native.rs b/crates/skate-game/src/game_audio/native.rs index f34fe67f..e109eb70 100644 --- a/crates/skate-game/src/game_audio/native.rs +++ b/crates/skate-game/src/game_audio/native.rs @@ -12,11 +12,12 @@ //! posts, redelivers and releases between blocks under the runtime's lock. //! - The stream follows the master volume (the AEMS voices × ambience, the rolling bed × effects) //! and pauses while the menu or a replay runs. -//! - The MixMap mixer (`MixMapSK8.mxb`, `skate_audio::mixmap`) runs on the game thread once per -//! 60 Hz frame (fixed steps): [`mixmap_frame`] writes the inputs we can supply (category gains, -//! pause, the local player's physics and 3-D position, the emitter states' positions), ticks, -//! and the systems read its outputs (the `c_emitter` words; the rolling bed's levels, pitch, -//! filters and pan). +//! - The MixMap mixer (`MixMapSK8.mxb`, `skate_audio::mixmap`) runs on the game thread, clocked by +//! the physics steps ([`HostClock`]: one host tick per step since the last frame, none while +//! nothing steps or the game is silenced): [`mixmap_frame`] writes the inputs we can supply +//! (category gains, the local player's physics and 3-D position, the emitter states' positions), +//! ticks, and the systems read its outputs (the `c_emitter` words; the rolling bed's levels, +//! pitch, filters and pan). //! //! Which systems use it: the skater's sounds (`player_audio.rs`), the `.ems` world emitters //! (`emitters.rs`) and the granular rolling bed (`grain_bed.rs`). Location sets, zone beds and @@ -36,10 +37,59 @@ use super::Library; pub(crate) mod prefetch; -/// The host's step: inputs, the components' process and update run per 60 Hz physics step; the -/// MixMap evaluates on every second one (the console's 30 Hz, `skate_audio::mixmap::cadence`), or -/// on each with `SKATE_AEMS_MIX_CONSOLE=0`. -const MIX_STEP: f32 = 1.0 / 60.0; +/// The host's step: one 60 Hz physics step. The host ticks once per physics step ([`HostClock`]); +/// the MixMap evaluates on every second one (the console's 30 Hz, `skate_audio::mixmap::cadence`). +pub(crate) const MIX_STEP: f32 = 1.0 / 60.0; +/// The most host ticks one frame takes (the steps beyond are dropped; their pulses stay latched). +/// Retail's audio manager runs once per rendered frame and never catches up: a long frame is one +/// call with its long dt (both halves once). Our host counts physics steps to stay on the console +/// grid at any frame rate, so it bounds the catch-up instead: 4 steps = 2 console evaluations, +/// enough for frame-rate independence down to 15 fps (the old Real-time accumulator's cap, 4 × +/// [`MIX_STEP`]), while a hitch (Bevy runs up to 15 physics steps after a 250 ms frame) never +/// releases a burst of evaluations, Jitter steps or poster calls. +pub(crate) const MAX_STEPS_PER_FRAME: u32 = 4; + +/// What a frame's host pass runs ([`HostClock::pass`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Pass { + /// Host ticks: the physics steps taken (capped at [`MAX_STEPS_PER_FRAME`]). + pub ticks: usize, + /// Console evaluations they complete (the console cadence). + pub calls: usize, + /// `sub_82491180` (the eEQChain clear) runs in this pass. + pub clear_eq: bool, +} + +/// The audio host's clock: the physics steps `skate_events::observe` published since the last +/// frame (2026-10-03, PR #32 review: before, a `Time` accumulator of its own drifted from +/// the physics grid after hitches, re-processed a stale sample on frames without a step, dropped +/// the first of two steps' pulses and kept ticking while the menu paused the game). +#[derive(Default)] +pub(crate) struct HostClock { + /// The console's 30 Hz evaluation grid over the steps. + cadence: skate_audio::mixmap::cadence::Cadence, +} + +impl HostClock { + /// Take the frame's steps. `None` (no pass: no inputs, process, tick or update) when nothing + /// stepped, or while the game is silenced (menu, replay): those steps and their pulses are + /// dropped, as the stream is paused. + pub(crate) fn pass(&mut self, cues: &mut super::skate_events::Cues, silenced: bool) -> Option { + let steps = cues.take_steps(); + if steps == 0 || silenced { + return None; + } + let ticks = steps.min(MAX_STEPS_PER_FRAME) as usize; + // The console cadence (`skate_audio::mixmap::cadence`): retail's audio manager evaluates the + // MixMap, steps the Jitter and clears the eEQChain buses once per 1/30 s console frame with + // dt 1/30; here every second step, the flag inputs held in between. + let calls = self.cadence.advance(ticks); + // sub_82491180 in half 1 of the audio manager: every console frame (both halves run when the + // frame is longer than 20 ms). + let clear_eq = calls > 0; + Some(Pass { ticks, calls, clear_eq }) + } +} /// CSTATEMGR_Emitter's pool = the MixMap's Emitter instances. pub(crate) const EMITTER_STATES: usize = 5; @@ -114,12 +164,18 @@ pub(crate) struct Native { emitter_class: Option, /// The MixMap, when the install has `MixMapSK8.mxb`. pub(crate) mixmap: Option, - mix_clock: f32, - /// MixMap frames run (the eEQChain clear runs on every second one; old 60 Hz cadence only). - mix_frames: u64, - /// The console's 30 Hz evaluation grid over the 60 Hz steps ([`mix_console_requested`]). - cadence: skate_audio::mixmap::cadence::Cadence, - /// The flag inputs are held between evaluations (set once under the console cadence). + /// The host's clock (the physics steps). + clock: HostClock, + /// This frame's host ticks (0: no pass), for the systems after [`mixmap_frame`] (the bed). + pub(crate) frame_ticks: usize, + /// Camera cuts seen (teleport, map change; [`mixmap_frame`]): the world hosts reset their own + /// camera velocity when it changes. + pub(crate) cuts: u64, + /// The cut signal last seen (`Presentation::cuts`, the map generation) and the wheel sample + /// of the step at the cut (no seam interpolation across it). + cut_seen: Option<(u64, u64)>, + cut_wheels: Option<[[f32; 3]; 4]>, + /// The flag inputs are held between evaluations (set once, at the first pass). holds: bool, /// The local player's MixMap inputs and components (None without a MixMap). pub(crate) player: Option, @@ -129,10 +185,51 @@ pub(crate) struct Native { pub(crate) bed: Option, /// World emitter banks read and decoded ahead of need on a worker thread ([`prefetch`]). pub(crate) prefetch: prefetch::Prefetch, + /// Bumped by every [`Native::unload_map_banks`] (map change): the world / NPC hosts release + /// their nodes and reset their objects when it changes (an owner that survives the change would + /// otherwise keep redelivering to a node whose instances the unload destroyed). + pub(crate) map_epoch: u64, + /// The world / NPC owners' instance counts the MixMap was built with. + pub(crate) world: WorldInstances, +} + +/// How many MixMap instances the world / NPC owners get: retail's free-skate layout +/// (`mixmap::RETAIL_INSTANCES`: 4 traffic, 15 pedestrian, 2 Player = the local player + 1 NPC +/// skater) or the opt-in non-retail "more audible" layout (settings/audio.json +/// `"more_audible_world": true`, user decision 2026-10-03; read at start): 8 traffic, 24 +/// pedestrian, 4 Player (3 NPC / remote skaters). The MixMap is built with these counts, so the +/// extra objects get the same B lookups, distance curves and posts as retail's instances; only +/// their number is not retail. Instance 0 of every slot is unchanged. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct WorldInstances { + pub traffic: usize, + pub peds: usize, + /// Player-slot instances for NPC / remote skaters (instances 1..=npc). + pub npc: usize, +} + +impl WorldInstances { + pub(crate) const RETAIL: Self = Self { traffic: 4, peds: 15, npc: 1 }; + pub(crate) const MORE_AUDIBLE: Self = Self { traffic: 8, peds: 24, npc: 3 }; + + /// The MixMap's instances per slot. + pub(crate) fn mixmap_instances(self) -> [usize; 14] { + let mut n = skate_audio::mixmap::RETAIL_INSTANCES; + n[skate_audio::world::keys::TRAFFIC as usize] = self.traffic; + n[skate_audio::world::keys::PEDESTRIAN as usize] = self.peds; + n[1] = 1 + self.npc; + n + } } impl Native { + /// Start with retail's instance layout (the data-gated tests). + #[cfg_attr(not(test), allow(dead_code))] pub(super) fn start(library: &Library) -> Result { + Self::start_with(library, WorldInstances::RETAIL) + } + + pub(super) fn start_with(library: &Library, world: WorldInstances) -> Result { let files = library.aems(); if files.projects.is_empty() { return Err("this install has no AEMS banks (run setup to refresh the audio)".into()); @@ -146,7 +243,11 @@ impl Native { let mixmap = match &files.mixmap { Some(file) => { let bytes = library.read(file).map_err(|e| format!("{file}: {e}"))?; - let m = MixMap::from_bytes(&bytes).map_err(|e| e.to_string())?; + let file = skate_audio::mixmap::MixMapFile::parse(&bytes).map_err(|e| e.to_string())?; + let m = MixMap::new(&file, &world.mixmap_instances()); + if world != WorldInstances::RETAIL { + info!("Game audio: more audible world (not retail): {} traffic, {} pedestrian, {} NPC skater instances", world.traffic, world.peds, world.npc); + } info!("Game audio: MixMap {} controllers ({} output blocks)", m.controller_count(), m.output_blocks()); Some(m) } @@ -167,14 +268,18 @@ impl Native { banks: HashMap::new(), emitter_class: None, mixmap, - mix_clock: 0.0, - mix_frames: 0, - cadence: Default::default(), + clock: HostClock::default(), + frame_ticks: 0, + cuts: 0, + cut_seen: None, + cut_wheels: None, holds: false, player, emitter_states: [false; EMITTER_STATES], bed, prefetch: Default::default(), + map_epoch: 0, + world, }; // The environment (reverb) network and the eEQChain buses (optional install data). let (presets, eq) = library.bus_tuning(); @@ -191,9 +296,9 @@ impl Native { runtime.mixer.buses.flange.set_presets(a, b); } // The FootStep SubMix graphs (`sub_82494188`: each foot sound through its EQ record, env - // send and panner); `SKATE_AEMS_FOOTSTEP_SUBMIX=0` = the voices straight into SFX Master. + // send and panner). if let Ok(mut runtime) = native.shared.lock() { - runtime.mixer.buses.submix.enabled = !std::env::var("SKATE_AEMS_FOOTSTEP_SUBMIX").is_ok_and(|v| v == "0"); + runtime.mixer.buses.submix.enabled = true; } native.ensure_bank(library, "emitter_utility")?; native.load_player_banks(library); @@ -335,6 +440,12 @@ impl Native { } } + /// The runtime's id of a loaded bank. + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn bank_id(&self, stem: &str) -> Option { + self.banks.get(stem).copied() + } + /// Whether the runtime holds the bank. pub(crate) fn bank_loaded(&self, stem: &str) -> bool { self.banks.contains_key(stem) @@ -342,6 +453,7 @@ impl Native { /// Unload every bank but the utility and the player's (map change); forget the prefetched ones. pub(crate) fn unload_map_banks(&mut self) { + self.map_epoch += 1; self.prefetch.clear(); let Ok(mut runtime) = self.shared.lock() else { return }; self.banks.retain(|stem, id| { @@ -437,31 +549,6 @@ fn write_position(m: &mut MixMap, key: u32, listener: &GlobalTransform, skater: m.set_input(key, keys::pos::FLAGS, 1); } -/// The inputs we can supply for this frame, then the fixed-step MixMap ticks (spec §1: inputs → -/// components' process → tick → components' update). Written here: Master.in1–4, Music.in1/2/5, -/// Reverb.in5 (free-skate values, §7.4) and Pause.in0 while the menu is open; through -/// `player_audio`: PlayerPhysics 0–14, the two 3DObjPos blocks (skater COM and board, with -/// relative speeds and the sign-flip bits), Jitter, Contacts 1/2/6, Rail 0/1, OffBoard 0; the -/// board owner inputs (`grain_bed.rs`). Left at their free-skate 0: VU (no output meter; only the -/// ambience reads it, not native yet), Menu / NIS / HOM / Challenge / Speech flags (no such modes -/// in free skate), HandGrabs, Music.in3/6 (combo emphasis, not wired). -/// `SKATE_AEMS_REVERB_ZONES=0`: the reverb preset from the region layer only. -fn reverb_zones_requested() -> bool { - !std::env::var("SKATE_AEMS_REVERB_ZONES").is_ok_and(|v| v == "0") -} - -/// `SKATE_AEMS_REVERB_INPUTS=0` keeps the fixed Reverb.in5 = 32767 (no env scale). -fn reverb_inputs_requested() -> bool { - !std::env::var("SKATE_AEMS_REVERB_INPUTS").is_ok_and(|v| v == "0") -} - -/// `SKATE_AEMS_MIX_CONSOLE=0` turns off the MixMap's console cadence: the old evaluation per 60 Hz -/// step with dt 1/60, the Jitter stepped and the eEQChain cleared per step / every second step. -pub(crate) fn mix_console_requested() -> bool { - static ON: std::sync::OnceLock = std::sync::OnceLock::new(); - *ON.get_or_init(|| !std::env::var("SKATE_AEMS_MIX_CONSOLE").is_ok_and(|v| v == "0")) -} - /// The one-frame flag inputs the console's writers set for a whole evaluation, held between our 60 /// Hz writes and the 30 Hz evaluations ([`MixMap::hold_input`]): Contacts 1 / 6 (the landing /// swell and the landing-material flag), Rail 1 (the grind ended), SkateBoard 0 / 4 (the surface @@ -473,25 +560,75 @@ pub(crate) fn hold_flag_inputs(m: &mut MixMap) { } } -/// `SKATE_AEMS_SEAM_PULSE=0` turns off Class_Seams' console cadence (Listening test 9). -fn seam_pulse_requested() -> bool { - static ON: std::sync::OnceLock = std::sync::OnceLock::new(); - *ON.get_or_init(|| !std::env::var("SKATE_AEMS_SEAM_PULSE").is_ok_and(|v| v == "0")) -} - +/// The host pass of a frame (spec §1: inputs → components' process → tick → components' update), +/// on frames that took physics steps ([`HostClock::pass`]: as many host ticks as steps, at most +/// [`MAX_STEPS_PER_FRAME`]; none on a frame without a step, none while silenced). Written here: +/// Master.in1–4, Music.in1/2/5, Reverb.in0..6 (free-skate values, §7.4); through `player_audio`: +/// PlayerPhysics 0–14, the two 3DObjPos blocks (skater COM and board, with relative speeds and the +/// sign-flip bits), Jitter, Contacts 1/2/6, Rail 0/1, OffBoard 0; the board owner inputs +/// (`grain_bed.rs`). Left at their free-skate 0: VU (no output meter; only the ambience reads it, +/// not native yet), Menu / NIS / HOM / Challenge / Speech flags (no such modes in free skate), +/// HandGrabs, Music.in3/6 (combo emphasis, not wired), and Pause.in0: nothing ticks while the menu +/// or a replay silences the game (the stream itself is paused), so no evaluation would read it. +/// Class_Seams' console cadence runs on every rendered frame that is not silenced +/// ([`PlayerAudio::seam_frame`](super::player_audio::PlayerAudio::seam_frame), game time). +#[allow(clippy::too_many_arguments)] pub(super) fn mixmap_frame( native: Option>, - cues: Res, + mut cues: ResMut, listener: Query<&GlobalTransform, With>, - time: Res>, + time: Res>, fixed: Res>, menu: Option>, replay: Res, + cuts: (Option>, Option>), ) { let _timing = super::timing::scope(&super::timing::MIXMAP_FRAME); - let Some(mut native) = native else { return }; + let silenced = super::silenced(menu.as_deref(), &replay); + let Some(mut native) = native else { + cues.take_steps(); + return; + }; let native = &mut *native; + // For the systems after this one (the bed): no pass until the clock says so. + native.frame_ticks = 0; + if let Some(bed) = &mut native.bed { + bed.slew_calls = Some(0); + } + if native.mixmap.is_none() { + cues.take_steps(); + return; + } + // A teleport, camera cut or map change: no camera velocity across it (Doppler), and no seam + // interpolation from the old place (the step at the cut is both ends of the pair). + let signal = (cuts.0.as_deref().map_or(0, |p| p.cuts), cuts.1.as_deref().map_or(0, |m| m.generation)); + let now = cues.riding.audio.wheel_position; + if native.cut_seen.is_some_and(|seen| seen != signal) { + native.cuts += 1; + native.cut_wheels = Some(now); + if let Some(player) = &mut native.player { + player.reset_listener(); + } + } + native.cut_seen = Some(signal); + if native.cut_wheels.is_some_and(|w| w != now) { + native.cut_wheels = None; + } + let before = if native.cut_wheels.is_some() { now } else { cues.riding.wheels_before }; + // Class_Seams on the console's 30 fps process cadence at the rendered board's wheels, on every + // rendered frame (Listening test 9), in game time (`Time`: still while paused). let Some(m) = &mut native.mixmap else { return }; + if let Some(player) = &mut native.player { + player.seam_alpha = Some(fixed.overstep_fraction()); + player.step_wheels(before, now); + if !silenced { + if let Ok(mut runtime) = super::timing::lock(&native.shared, &super::timing::GAME_LOCK) { + player.seam_frame(m, &cues.riding.audio, time.delta_secs(), &mut runtime); + } + } + } + let Some(pass) = native.clock.pass(&mut cues, silenced) else { return }; + native.frame_ticks = pass.ticks; for id in 1..=4 { m.set_input(keys::MASTER, id, 32767); } @@ -500,8 +637,8 @@ pub(super) fn mixmap_frame( } // SFXObj_Reverb's first step (`sub_824DF468`): Reverb.in0..6 from the number of the preset // being faded to (in5 = reverb11/12/15/16/22, which ducks the global env scale out4 by 4 dB). - // `SKATE_AEMS_REVERB_INPUTS=0`: the old fixed in5 = 32767 and no scale. - let reverb = reverb_inputs_requested().then(|| super::timing::lock(&native.shared, &super::timing::GAME_LOCK).ok().map(|r| r.mixer.buses.env.reverb_inputs())).flatten(); + // (Without the runtime lock: in5 = 32767 and no scale this pass.) + let reverb = super::timing::lock(&native.shared, &super::timing::GAME_LOCK).ok().map(|r| r.mixer.buses.env.reverb_inputs()); match reverb { Some(v) => { for (id, x) in v.into_iter().enumerate() { @@ -510,52 +647,19 @@ pub(super) fn mixmap_frame( } None => m.set_input(keys::REVERB, 5, 32767), } - let silenced = super::silenced(menu.as_deref(), &replay); - m.set_input(keys::PAUSE, 0, if silenced { 32767 } else { 0 }); - - // Inputs, components and the tick run only on frames that tick (retail: once per 60 Hz frame), - // so one-frame pulses (landing, surface change, push) always reach an evaluation. - native.mix_clock = (native.mix_clock + time.delta_secs()).min(4.0 * MIX_STEP); - let ticks = (native.mix_clock / MIX_STEP) as usize; - // The bed's turn / brake slews step once per console evaluation (`grain_bed::Bed::slew_calls`): - // none on a frame without one; the count is set below when the frame ticks. - let console = mix_console_requested(); + m.set_input(keys::PAUSE, 0, 0); + let (ticks, calls) = (pass.ticks, pass.calls); + // The bed's turn / brake slews step once per console evaluation (`grain_bed::Bed::slew_calls`). if let Some(bed) = &mut native.bed { - bed.slew_calls = console.then_some(0); + bed.slew_calls = Some(calls); } - // Class_Seams on the console's 30 fps process cadence at the rendered board's wheels, on every - // rendered frame (Listening test 9). `SKATE_AEMS_SEAM_PULSE=0`: its whole process per 60 Hz tick - // at the physics positions, as before. - if let Some(player) = &mut native.player { - let console = seam_pulse_requested(); - player.seam_console = console; - player.seam_alpha = console.then(|| fixed.overstep_fraction()); - if console { - if let Ok(mut runtime) = super::timing::lock(&native.shared, &super::timing::GAME_LOCK) { - player.seam_frame(m, &cues.riding.audio, time.delta_secs(), &mut runtime); - } - } - } - if ticks == 0 { - return; - } - native.mix_clock -= ticks as f32 * MIX_STEP; - // The console cadence (`skate_audio::mixmap::cadence`): retail's audio manager evaluates the - // MixMap, steps the Jitter and clears the eEQChain buses once per 1/30 s console frame with dt - // 1/30; here every second 60 Hz step, the flag inputs held in between. Off: per 60 Hz step. - let calls = if console { native.cadence.advance(ticks) } else { 0 }; - if let Some(bed) = &mut native.bed { - bed.slew_calls = console.then_some(calls); - } - if console && !native.holds { + if !native.holds { hold_flag_inputs(m); native.holds = true; } - // sub_82491180 in half 1 of the audio manager: every console frame (both halves run when the - // frame is longer than 20 ms); the old cadence: every second 60 Hz frame. The jittered eEQChain - // buses take the walk's values and every bus may re-roll again. - native.mix_frames += 1; - if if console { calls > 0 } else { native.mix_frames % 2 == 0 || ticks > 1 } { + // sub_82491180 (half 1): the jittered eEQChain buses take the walk's values and every bus may + // re-roll again. + if pass.clear_eq { let jitter = native.player.as_ref().and_then(|p| p.eq_jitter()); if let Ok(mut runtime) = native.shared.lock() { runtime.mixer.buses.eq.clear(jitter); @@ -566,7 +670,7 @@ pub(super) fn mixmap_frame( let dt = ticks as f32 * MIX_STEP; let l = listener.single().ok().map(|t| player.listener(t.translation().to_array(), t.forward().as_vec3().to_array(), dt, &s)); // SFXObj_Jitter's walk steps once per console evaluation (half 1's process). - player.jitter_steps = console.then_some(calls); + player.jitter_steps = Some(calls); player.write_inputs(m, &s, l.as_ref()); } // With the native rolling layers the owner's surface routing (player::rolling) writes @@ -580,14 +684,8 @@ pub(super) fn mixmap_frame( if let (Some(player), Ok(mut runtime)) = (&mut native.player, super::timing::lock(&native.shared, &super::timing::GAME_LOCK)) { player.process(m, &s, &mut runtime, speed_scale, loose); } - if console { - for _ in 0..calls { - m.tick(skate_audio::mixmap::cadence::CONSOLE_DT); - } - } else { - for _ in 0..ticks { - m.tick(MIX_STEP); - } + for _ in 0..calls { + m.tick(skate_audio::mixmap::cadence::CONSOLE_DT); } if let (Some(player), Ok(mut runtime)) = (&mut native.player, super::timing::lock(&native.shared, &super::timing::GAME_LOCK)) { player.update(m, &s, &mut runtime, speed_scale, loose); @@ -608,11 +706,13 @@ fn start( library: Option>, mut streams: ResMut>, ) { - let (Some(_), Some(library)) = (settings, library) else { return }; + let (Some(settings), Some(library)) = (settings, library) else { return }; + let settings = Some(settings); // Which compiled copy of the DSP loops runs (hardware FMA or plain; same output bits): chosen // once, here, before the first render (doc 11 "Hardware FMA dispatch"). info!("AUDIO_DSP {}", skate_audio::dsp::init_fma()); - match Native::start(&library) { + let world = if settings.as_deref().is_some_and(|s| s.more_audible_world()) { WorldInstances::MORE_AUDIBLE } else { WorldInstances::RETAIL }; + match Native::start_with(&library, world) { Ok(native) => { info!("Game audio: native AEMS runtime on ({} projects)", library.aems().projects.len()); let handle = streams.add(NativeStream { shared: native.shared.clone() }); @@ -642,11 +742,11 @@ fn follow_volume( let Some(settings) = settings else { return }; // rodio 0.20's spatial source gives the ear FARTHER from the source the larger factor // (((d_left - d_right) / gap + 1) / 4 + 0.5 on the left channel), so with the ears where Bevy - // puts them every Bevy positional sound is mirrored. With the native host on, swap the - // ears so Bevy sounds come from the side native voices (Pan2D1) put them on. + // puts them every Bevy positional sound is mirrored: the ears are swapped so Bevy sounds come + // from the side native voices (Pan2D1) put them on. for mut listener in &mut listeners { let gap = listener.left_ear_offset.distance(listener.right_ear_offset); - let want = if native.is_some() { Vec3::X * gap / 2.0 } else { Vec3::X * gap / -2.0 }; + let want = Vec3::X * gap / 2.0; if listener.left_ear_offset != want { listener.left_ear_offset = want; listener.right_ear_offset = -want; @@ -654,19 +754,18 @@ fn follow_volume( } let silenced = super::silenced(menu.as_deref(), &replay); let volume = settings.master().clamp(0.0, 1.0); - // The measured world layers (zone beds, location sets, crossfades) play at measured retail level - // × RETAIL_SCALE; with the native player sounds (retail level) they drop back to the measured - // level. - let native_player = native.as_deref().is_some_and(|n| n.player.as_ref().is_some_and(|p| p.components)); + // The measured world layers (zone beds, location sets, crossfades) are tuned at measured retail + // level × RETAIL_SCALE; against the native voices (retail level) they play at the measured + // level. Bevy and native voices share the ears: the Bevy ones fold like native voices. (Before + // 2026-10-03 an install without the native runtime kept ×RETAIL_SCALE and Bevy's own panning, + // a leftover of the removed interim tables; the native runtime is required now.) if let Some(mut voices) = voices { - let scale = if native_player { 1.0 / super::voices::RETAIL_SCALE } else { 1.0 }; + let scale = 1.0 / super::voices::RETAIL_SCALE; if voices.scale != scale { voices.scale = scale; } - // Bevy and native voices share the ears: fold the Bevy ones like native voices. - let fold = native.is_some(); - if voices.native_fold != fold { - voices.native_fold = fold; + if !voices.native_fold { + voices.native_fold = true; } } if let Some(native) = native { @@ -709,8 +808,8 @@ pub(super) fn reverb_frame( let env = &mut runtime.mixer.buses.env; if env.enabled() { // SFXObj_Reverb's update (`sub_824DE548`) in retail's order, with the reverb-zone - // emitters the listener is in (`emitters::reverb_zones`; `SKATE_AEMS_REVERB_ZONES=0`: none). - let zones = if reverb_zones_requested() { &zones.zones[..] } else { &[] }; + // emitters the listener is in (`emitters::reverb_zones`). + let zones = &zones.zones[..]; env.update(time.delta_secs().clamp(0.0, 0.25), key, zones, camera.as_ref()); } } @@ -747,17 +846,167 @@ mod tests { banks: HashMap::new(), emitter_class: None, mixmap, - mix_clock: 0.0, - mix_frames: 0, - cadence: Default::default(), + clock: HostClock::default(), + frame_ticks: 0, + cuts: 0, + cut_seen: None, + cut_wheels: None, holds: false, player: None, emitter_states: [false; EMITTER_STATES], bed: None, prefetch: Default::default(), + map_epoch: 0, + world: WorldInstances::RETAIL, } } + /// The host clock under jittered frame times, in a Bevy app with the game's clocks (the 60 Hz + /// physics period of `physics/clock.rs`, `Time`'s 250 ms frame cap): a FixedUpdate + /// system publishes one sample per physics step as `skate_events::observe` does (the step's + /// number in `pushes`, a push-plant pulse on some steps), an Update system takes them as + /// [`mixmap_frame`] does. Frames: alternating 1 and 2 steps, frames without a step, a 300 ms + /// hitch, a paused stretch (the menu: `Time` paused) and a silenced one that still + /// steps (a replay / the multiplayer menu). Every step is taken by exactly one frame; each + /// pulse reaches exactly one pass and no pass sees a pulse its steps did not have; a pass + /// ticks once per step (4 at most); nothing passes while paused or silenced or on a frame + /// without a step; the console evaluations are every second tick. + #[test] + fn the_host_clock_takes_every_physics_step_once() { + use super::super::skate_events::{Cues, Riding}; + use bevy::time::{TimePlugin, TimeUpdateStrategy}; + use std::time::Duration; + + #[derive(Resource, Default)] + struct Log { + steps: u32, + /// The steps with a pulse. + pulses: Vec, + /// Per frame: (first step, last step) taken, the pass (ticks, calls, pulse seen) if any, + /// and whether the frame was paused / silenced. + frames: Vec<(u32, u32, Option<(usize, usize, bool)>, bool, bool)>, + taken: u32, + } + #[derive(Resource, Default)] + struct Host { + clock: HostClock, + paused: bool, + silenced: bool, + } + let publish = |mut cues: ResMut, mut log: ResMut| { + log.steps += 1; + let step = log.steps; + // A pulse on every 5th step (the hitch frame takes three). + let pulse = step % 5 == 0; + if pulse { + log.pulses.push(step); + } + let mut r = Riding { pushes: step, ..Default::default() }; + r.audio.push_trigger = pulse; + cues.publish(r); + }; + let take = |mut cues: ResMut, mut log: ResMut, mut host: ResMut| { + let host = &mut *host; + let (from, to) = (log.taken + 1, cues.riding.pushes); + let had = cues.steps; + let pass = host.clock.pass(&mut cues, host.silenced).map(|p| (p.ticks, p.calls, cues.riding.audio.push_trigger)); + assert_eq!(cues.steps, 0, "the frame takes its steps"); + if had > 0 { + assert_eq!(to - from + 1, had, "steps counted = steps published"); + log.taken = to; + } + log.frames.push((from, if had > 0 { to } else { from - 1 }, pass, host.paused, host.silenced)); + }; + let period = Duration::from_nanos(166_666 * 100); + let mut app = App::new(); + app.add_plugins(TimePlugin) + .insert_resource(Time::::from_duration(period)) + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(FixedUpdate, publish) + .add_systems(Update, take); + let ms = |x: f64| Duration::from_secs_f64(x / 1000.0); + let frame = |app: &mut App, dt: Duration, paused: bool, silenced: bool| { + app.insert_resource(TimeUpdateStrategy::ManualDuration(dt)); + { + let mut v = app.world_mut().resource_mut::>(); + if paused { v.pause() } else { v.unpause() } + } + let mut h = app.world_mut().resource_mut::(); + (h.paused, h.silenced) = (paused, silenced); + app.update(); + }; + let step = period.as_secs_f64() * 1000.0; + for _ in 0..2 { + frame(&mut app, ms(step), false, false); + } + // Alternating 1 and 2 steps (~40 fps), then 0-step frames (a fast renderer), then 30 fps. + for i in 0..40 { + frame(&mut app, ms(if i % 2 == 0 { step } else { 2.0 * step }), false, false); + } + for _ in 0..30 { + frame(&mut app, ms(step / 3.0), false, false); + } + for _ in 0..10 { + frame(&mut app, ms(2.0 * step), false, false); + } + // A 300 ms hitch (Time caps it at 250 ms: 15 steps). + frame(&mut app, ms(300.0), false, false); + frame(&mut app, ms(step), false, false); + // The menu: the game paused (no steps) and silenced, for a second. + for _ in 0..60 { + frame(&mut app, ms(step), true, true); + } + // A replay / the multiplayer menu: silenced while the clock still steps. + for _ in 0..20 { + frame(&mut app, ms(step), false, true); + } + for i in 0..40 { + frame(&mut app, ms(if i % 3 == 0 { 0.0 } else { 1.5 * step }), false, false); + } + let log = app.world().resource::(); + assert!(log.steps > 150, "{} steps", log.steps); + // Every step is taken by exactly one frame, in order. + let mut next = 1; + for &(from, to, ..) in &log.frames { + assert_eq!(from, next); + next = to + 1; + } + assert_eq!(next, log.steps + 1, "every step taken"); + let mut seen_pulses = 0; + let (mut ticks, mut calls) = (0usize, 0usize); + let (mut hitch, mut zero_frames, mut double, mut paused_frames, mut merged) = (false, 0, 0, 0, 0); + for &(from, to, pass, paused, silenced) in &log.frames { + let n = (to + 1 - from) as usize; + let pulses = log.pulses.iter().filter(|&&p| (from..=to).contains(&p)).count(); + match pass { + Some((t, c, pulse)) => { + assert!(!paused && !silenced, "a pass while paused / silenced"); + assert_eq!(t, n.min(MAX_STEPS_PER_FRAME as usize), "a tick per step, capped"); + assert_eq!(pulse, pulses > 0, "steps {from}..={to}: pulse seen = pulse published"); + seen_pulses += pulses; + ticks += t; + calls += c; + hitch |= n >= 15; + merged += usize::from(pulses > 1); + double += usize::from(n == 2); + } + None => { + assert!(n == 0 || silenced, "steps {from}..={to} not processed"); + zero_frames += usize::from(n == 0 && !paused); + paused_frames += usize::from(paused); + assert!(!paused || n == 0, "a step while paused"); + } + } + } + let dropped = log.frames.iter().filter(|f| f.4 && f.2.is_none()).map(|f| log.pulses.iter().filter(|&&p| (f.0..=f.1).contains(&p)).count()).sum::(); + assert_eq!(seen_pulses + dropped, log.pulses.len(), "each pulse in exactly one frame"); + assert!(dropped > 0 && seen_pulses > 20); + assert!(hitch && merged > 0 && zero_frames > 20 && double > 20 && paused_frames == 60); + assert_eq!(calls, ticks / 2, "a console evaluation every second tick"); + } + #[test] fn emitter_words_without_a_mixmap_keep_the_old_defaults() { let n = native(None); @@ -780,11 +1029,11 @@ mod tests { /// out4 (−600 mB + the Global ducks' rest) × level, the pan is the B0 azimuth we wrote, the /// send rolls off with camera distance (4 → 70 m) and the filter stays open. #[test] + #[ignore = "needs the private install data"] fn emitter_words_from_the_retail_mixmap() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/audio/aems/MixMapSK8.mxb"); let Ok(bytes) = std::fs::read(path) else { - eprintln!("skipped: no {path}"); - return; + panic!("missing private data: no {path}"); }; let mut n = native(Some(MixMap::from_bytes(&bytes).unwrap())); let g = n.claim_emitter_state().unwrap(); @@ -840,12 +1089,13 @@ mod tests { /// dev install's manifest with parts removed (the data folders are linked, not copied). /// Data-gated: skipped without the install. #[test] + #[ignore = "needs the private install data"] fn missing_install_parts_fall_back_without_breaking() { let real = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/audio")); - let Ok(text) = std::fs::read_to_string(real.join("audio_manifest.json")) else { return eprintln!("skipped: no audio install") }; + let Ok(text) = std::fs::read_to_string(real.join("audio_manifest.json")) else { panic!("missing private data: no audio install") }; let full: serde_json::Value = serde_json::from_str(&text).unwrap(); if full["aems"]["projects"].as_array().is_none_or(|p| p.is_empty()) { - return eprintln!("skipped: the install has no AEMS banks"); + panic!("missing private data: the install has no AEMS banks"); } let dir = std::env::temp_dir().join(format!("skate-audio-fallback-{}", std::process::id())); let audio = dir.join("private/audio"); @@ -862,7 +1112,7 @@ mod tests { if !out.as_ref().is_ok_and(|o| o.status.success()) { let _ = std::fs::remove_dir_all(&dir); let why = out.map_or_else(|e| e.to_string(), |o| String::from_utf8_lossy(&o.stdout).into_owned() + &String::from_utf8_lossy(&o.stderr)); - return eprintln!("skipped: could not link {sub} ({})", why.trim()); + panic!("missing private data: could not link {sub} ({})", why.trim()); } links.push(link); } @@ -874,7 +1124,7 @@ mod tests { let r = Native::start(&library); println!("{name}: {}", match &r { Ok(n) => format!("native on, player {:?}", n.player.as_ref().map(|p| (p.components, p.tricks_on, p.treatment_on, p.contacts_on, p.footsteps_on))), - Err(e) => format!("fallback to the tables: {e}"), + Err(e) => format!("no native runtime (silent): {e}"), }); r }; diff --git a/crates/skate-game/src/game_audio/native/prefetch.rs b/crates/skate-game/src/game_audio/native/prefetch.rs index 2b93b770..bcb80261 100644 --- a/crates/skate-game/src/game_audio/native/prefetch.rs +++ b/crates/skate-game/src/game_audio/native/prefetch.rs @@ -242,11 +242,12 @@ mod tests { /// and takes far less time; a map change (`unload_map_banks`) forgets the prefetch and the next /// start loads on the game thread as before. #[test] + #[ignore = "needs the private install data"] fn prefetched_banks_are_identical_and_not_decoded_at_the_start() { let root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets")); - let Ok(library) = Library::load(root) else { return eprintln!("skipped: no audio install") }; + let Ok(library) = Library::load(root) else { panic!("missing private data: no audio install") }; if library.aems().projects.is_empty() { - return eprintln!("skipped: no AEMS banks"); + panic!("missing private data: no AEMS banks"); } let mut stems: Vec = Vec::new(); for file in ["sfx_downtown", "sfx_university"] { @@ -259,7 +260,7 @@ mod tests { } } if stems.is_empty() { - return eprintln!("skipped: no emitter records"); + panic!("missing private data: no emitter records"); } // Identity: worker result vs the game thread's load. let mut p = Prefetch::default(); @@ -314,7 +315,8 @@ mod tests { v.sort_by(f64::total_cmp); format!("median {:.3} ms, max {:.3} ms", v[v.len() / 2], v[v.len() - 1]) }; + // Timings are printed, not asserted (wall-clock order is the machine's, not the code's; the + // decode counts above are the proof). println!("ensure_bank over {} emitter banks: game-thread load {}; prefetched {}", stems.len(), stat(&mut sync), stat(&mut pre)); - assert!(pre.last() < sync.last()); } } diff --git a/crates/skate-game/src/game_audio/npc_skaters.rs b/crates/skate-game/src/game_audio/npc_skaters.rs index 3ddc24eb..2f8a3e55 100644 --- a/crates/skate-game/src/game_audio/npc_skaters.rs +++ b/crates/skate-game/src/game_audio/npc_skaters.rs @@ -28,7 +28,6 @@ use std::collections::HashMap; use bevy::prelude::*; use skate_audio::eval::NodeId; -use skate_audio::mixmap::cadence::CONSOLE_DT; use skate_audio::player::components::{Command, Slot}; use skate_audio::player::objpos::Listener; use skate_audio::world::skaters::{self, NpcSkater, NpcSkaterAudioState, Parts, Slots, Tuning}; @@ -48,10 +47,6 @@ fn requested() -> bool { *ON.get_or_init(|| !std::env::var("SKATE_AEMS_NPC_SKATERS").is_ok_and(|v| v == "0")) } -fn on(var: &str) -> bool { - !std::env::var(var).is_ok_and(|v| v == "0") -} - #[derive(Resource, Default)] pub(crate) struct NpcHost { slots: Slots, @@ -59,11 +54,15 @@ pub(crate) struct NpcHost { nodes: HashMap<(u64, Slot), NodeId>, classes: HashMap<&'static str, usize>, last_tick: u64, - last_camera: Option<[f32; 3]>, + /// The camera at the last evaluation and the host's cut count then (`Native::cuts`: no + /// velocity across a teleport / map change). + last_camera: Option<([f32; 3], u64)>, announced: bool, /// The held skater's grain bed (instance 1) and its push-plant count (`+335` rises; the bed's /// push envelopes and SkateBoard input 4 follow it), with the native rolling layers on. beds: HashMap, + /// `Native::map_epoch` this host last ran in (None: never ran; [`NpcHost::reset`]). + epoch: Option, } pub(crate) fn register(app: &mut App) { @@ -99,6 +98,37 @@ impl NpcHost { } } + /// A map change (`Native::map_epoch`) or the first run: release every held packet, forget the + /// holders (their instances' 3DObjPos blocks go inactive), stop the NPC bed and drop the + /// per-skater objects, so a skater id that survives the change (a reused id, a persistent + /// publisher) is claimed afresh. The records take the MixMap's count (`Native::world.npc`). + fn reset(&mut self, native: &mut Native) { + self.epoch = Some(native.map_epoch); + let Native { mixmap, shared, world, .. } = native; + if let Some(m) = mixmap.as_mut() { + let l = Listener::default(); + for (_, mut npc) in self.objects.drain() { + npc.deactivate(m, &l); + } + self.last_tick = self.last_tick.min(m.ticks); + } + self.objects.clear(); + if !self.nodes.is_empty() || !self.beds.is_empty() { + if let Ok(mut runtime) = super::timing::lock(shared, &super::timing::GAME_LOCK) { + for (_, node) in self.nodes.drain() { + runtime.release(node); + } + if !self.beds.is_empty() { + super::grain_bed::stop_npc(&mut runtime); + } + } + } + self.nodes.clear(); + self.beds.clear(); + self.slots = Slots::with_records(world.npc); + self.last_camera = None; + } + /// Release every packet a skater holds (it lost its instance). fn release_all(&mut self, rt: &mut skate_audio::runtime::Runtime, owner: u64) { let slots: Vec<(u64, Slot)> = self.nodes.keys().filter(|k| k.0 == owner).copied().collect(); @@ -115,11 +145,12 @@ fn distance(a: [f32; 3], b: [f32; 3]) -> f32 { } #[allow(clippy::too_many_arguments)] -fn frame( +pub(super) fn frame( library: Option>, native: Option>, published: Res, mut host: ResMut, + mut held: ResMut, cues: Res, listener: Query<&GlobalTransform, With>, ) { @@ -132,8 +163,20 @@ fn frame( } let Some(mut native) = native else { return }; let Ok(camera) = listener.single() else { return }; - let host = &mut *host; - let Native { mixmap, player, shared, bed: local_bed, .. } = &mut *native; + run(&mut host, &published, &mut native, library.as_deref(), (camera.translation().to_array(), camera.forward().as_vec3().to_array()), &cues.riding.audio); + let skaters: Vec<(u64, u32)> = host.slots.holders().map(|(g, id)| (id, g)).collect(); + if held.skaters != skaters { + held.skaters = skaters; + } +} + +/// One frame of the host after the inert checks (see the module docs). `camera` = the listener +/// (position, forward), `local` = the local player's audio state. +pub(crate) fn run(host: &mut NpcHost, published: &NpcSkaters, native: &mut Native, library: Option<&super::Library>, camera: ([f32; 3], [f32; 3]), local: &skate_audio::player::AudioState) { + if host.epoch != Some(native.map_epoch) { + host.reset(native); + } + let Native { mixmap, player, shared, bed: local_bed, cuts, .. } = native; let (Some(m), Some(player)) = (mixmap.as_mut(), player.as_mut()) else { return }; if !player.components { return; @@ -141,20 +184,20 @@ fn frame( if m.ticks == host.last_tick { return; } - let evaluations = m.ticks - host.last_tick; + let evaluations = m.ticks.saturating_sub(host.last_tick).max(1); host.last_tick = m.ticks; - let dt = CONSOLE_DT * evaluations.min(4) as f32; + let dt = super::world_sources::evaluation_dt() * evaluations.min(4) as f32; if !host.announced { info!("AUDIO_NPC on: {} NPC skaters published", published.skaters.len()); host.announced = true; } - let cam = camera.translation().to_array(); - let cam_velocity = host.last_camera.map_or([0.0; 3], |last| std::array::from_fn(|i| (cam[i] - last[i]) / dt)); - host.last_camera = Some(cam); - let local = cues.riding.audio; + let (cam, view) = camera; + let cam_velocity = host.last_camera.filter(|l| l.1 == *cuts).map_or([0.0; 3], |(last, _)| std::array::from_fn(|i| (cam[i] - last[i]) / dt)); + host.last_camera = Some((cam, *cuts)); + let local = *local; let l = Listener { camera: cam, - view: camera.forward().as_vec3().to_array(), + view, camera_velocity: cam_velocity, followed: local.com_position, facing: local.com_velocity, @@ -177,9 +220,11 @@ fn frame( } let parts = Parts { rolling: player.rolling_on, rattle: player.rattle_on, contacts: player.contacts_on }; for (id, g) in assignment.claimed { - let npc = NpcSkater::new(g as u32, parts, on("SKATE_AEMS_GRIND_ONOFF"), on("SKATE_AEMS_PLANT_LIFT"), on("SKATE_AEMS_BODY_IMPACTS")); + let npc = NpcSkater::new(g as u32, parts, true, true, true); host.objects.insert(id, npc); - if let (true, Some(bed)) = (parts.rolling, local_bed.as_ref()) { + // The runtime has one NPC bed (`Runtime::npc_grains`): instance 1's. With the non-retail + // "more audible" layout the further instances play without a bed. + if let (true, Some(bed), 1) = (parts.rolling, local_bed.as_ref(), g) { host.beds.insert(id, (bed.for_instance(g as u32), 0)); } info!("AUDIO_NPC claim skater {id} (instance {g})"); @@ -198,7 +243,7 @@ fn frame( // The instance's grain bed after this evaluation (SkateBoard update `sub_824C6BD8`: records // from its outputs), with the binds / stops of the routing's last process; then its owner // inputs 2 / 3 / 4 for the next evaluation. - if let (Some((bed, pushes)), Some(library)) = (host.beds.get_mut(&id), library.as_deref()) { + if let (Some((bed, pushes)), Some(library)) = (host.beds.get_mut(&id), library) { *pushes = pushes.wrapping_add(u32::from(s.push_trigger)); let r = super::skate_events::Riding { board: Vec3::from_array(s.board_position), @@ -212,14 +257,14 @@ fn frame( }; let routed = Some((std::mem::take(&mut npc.routed.grains), npc.routed.primary)); // Its turn / brake slews once per console evaluation, as the local bed's. - bed.slew_calls = player.jitter_steps.is_some().then_some(evaluations as usize); + bed.slew_calls = Some(evaluations as usize); super::grain_bed::step_with(bed, library, m, &r, dt, tuning.player, routed, |apply| apply(&mut *rt)); bed.write_inputs(m, &s, false); } npc.write_inputs(m, &s, &l, local.com_velocity, tuning.player); - // The body poster once per console evaluation, as the local player's (`body_console`). - npc.set_body_calls((player.body_console && player.jitter_steps.is_some()).then_some(evaluations as usize)); - npc.set_deck_calls((player.deck_console && player.jitter_steps.is_some()).then_some(evaluations as usize)); + // The body / deck posters once per console evaluation, as the local player's. + npc.set_body_calls(Some(evaluations as usize)); + npc.set_deck_calls(Some(evaluations as usize)); let cmds = npc.process(m, &s, tuning, &mut rt.splice_host()); host.apply(rt, id, cmds); // The routing's binds wait for the bed's next step (dropped without a bed). diff --git a/crates/skate-game/src/game_audio/npc_skaters/tests.rs b/crates/skate-game/src/game_audio/npc_skaters/tests.rs index 8c96b897..c834a485 100644 --- a/crates/skate-game/src/game_audio/npc_skaters/tests.rs +++ b/crates/skate-game/src/game_audio/npc_skaters/tests.rs @@ -260,10 +260,11 @@ fn pass(library: &Library, mxb: &[u8], local_soft: bool) -> Pass { /// play Skate_Collisions, Seams_Bank, Skate_Metal, GRINDS and WHEEL_SKID_BANK above their session /// rates (`.local/research/npc-skater-audio/instance1_voices.py`). #[test] +#[ignore = "needs the private install data"] fn npc_skater_rolls_and_grinds_past_the_listener() { - let Some((library, mxb)) = install() else { return eprintln!("skipped: no install with a MixMap") }; + let Some((library, mxb)) = install() else { panic!("missing private data: no install with a MixMap") }; if !BANKS.iter().all(|b| library.aems().banks.contains_key(*b)) || library.player_tuning().seam_patterns.len() < 16 { - return eprintln!("skipped: the install lacks the player banks or the seam patterns"); + panic!("missing private data: the install lacks the player banks or the seam patterns"); } let p = pass(&library, &mxb, false); println!(" t dist held RMS dBFS banks (voices opened, max gain)"); @@ -309,12 +310,13 @@ fn npc_skater_rolls_and_grinds_past_the_listener() { /// graph 3 and never touches the local bed's players; release stops it. `--nocapture` prints a /// per-0.5 s table (distance, held, member, voices, record A gain, bed RMS). #[test] +#[ignore = "needs the private install data"] fn npc_skater_rolls_on_its_own_grain_bed() { - let Some((library, mxb)) = install() else { return eprintln!("skipped: no install with a MixMap") }; - let Some(local_bed) = super::super::grain_bed::Bed::new(&library) else { return eprintln!("skipped: no grain recordings / tuning") }; + let Some((library, mxb)) = install() else { panic!("missing private data: no install with a MixMap") }; + let Some(local_bed) = super::super::grain_bed::Bed::new(&library) else { panic!("missing private data: no grain recordings / tuning") }; let (mut rt, _, parts) = runtime(&library); if !parts.rolling { - return eprintln!("skipped: no PatchBank_Rolling_Surfaces (the routing drives the bed)"); + panic!("missing private data: no PatchBank_Rolling_Surfaces (the routing drives the bed)"); } assert!(rt.npc_grains.is_none(), "inert until an NPC bed binds"); let tuning = library.player_tuning(); @@ -407,3 +409,63 @@ fn npc_skater_rolls_on_its_own_grain_bed() { assert!(near > far && far >= 0.0, "louder near the camera ({near} vs {far})"); assert_eq!(free_voices, 0, "stopped a second after the release"); } + +/// The ghost NPC skater through the real host (data-gated: the install and one of the user's +/// state logs, `SKATE_AUDIO_STATE_LOGS` or `.local/audio-state-logs`): a window of the log replayed +/// 5 m from the camera claims Player instance 1 and sounds in the player banks; walked out to +/// 40 m it is released (no NPC voices left); a map change (`unload_map_banks`) resets the host, +/// and the same id claims and sounds again. +#[test] +#[ignore = "needs the private install data"] +fn a_ghost_claims_instance_1_releases_and_survives_a_map_change() { + let Some((library, _)) = install() else { panic!("missing private data: no audio install") }; + let Ok(mut native) = super::super::native::Native::start(&library) else { panic!("missing private data: no AEMS install") }; + let dir = std::env::var_os("SKATE_AUDIO_STATE_LOGS").map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../.local/audio-state-logs")).to_path_buf()); + let Some(log) = std::fs::read_dir(&dir).ok().and_then(|d| d.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x == "tsv")).max()) + else { panic!("missing private data: no state log in {}", dir.display()) }; + let text = std::fs::read_to_string(&log).unwrap(); + let ghost = super::super::state_replay::ghost_states(&text, 30.0, 20.0, [5.0, 0.0, 0.0]).unwrap(); + let mut host = NpcHost::default(); + let mut published = super::NpcSkaters::default(); + let local = AudioState::default(); + let camera = ([0.0, 1.5, 0.0], [1.0, 0.0, 0.0]); + let id = 77u64; + let run = |native: &mut super::super::native::Native, host: &mut NpcHost, published: &mut super::NpcSkaters, frames: std::ops::Range, shift: f32| { + let (mut voices, mut held) = (0, 0); + for f in frames { + let mut s = ghost[f % ghost.len()]; + for p in [&mut s.board_position, &mut s.com_position] { + p[0] += shift; + } + for w in &mut s.wheel_position { + w[0] += shift; + } + published.skaters = vec![skate_audio::world::skaters::NpcSkaterAudioState { id, state: s }]; + let m = native.mixmap.as_mut().unwrap(); + globals(m); + if f % 2 == 0 { + m.tick(skate_audio::mixmap::cadence::CONSOLE_DT); + } + super::run(host, published, native, Some(&library), camera, &local); + let mut rt = native.shared.lock().unwrap(); + for _ in 0..3 { + rt.render_block(); + } + voices += rt.mixer.snapshot().len(); + held += usize::from(host.slots.instance(id) == Some(1)); + } + (voices, held) + }; + let (sounded, held) = run(&mut native, &mut host, &mut published, 0..600, 0.0); + eprintln!("ghost {}: held instance 1 on {held} of 600 frames, {sounded} voice-frames", log.display()); + assert!(held > 0, "the ghost holds instance 1 while within 30 m"); + assert!(sounded > 0, "the ghost's board sounds"); + run(&mut native, &mut host, &mut published, 600..660, 400.0); + assert_eq!(host.slots.instance(id), None, "released at 30 m or more"); + native.unload_map_banks(); + let (again, held) = run(&mut native, &mut host, &mut published, 0..600, 0.0); + assert_eq!(host.epoch, Some(native.map_epoch)); + assert!(held > 0, "claimed again after the map change"); + assert!(again > 0, "and sounds again"); +} diff --git a/crates/skate-game/src/game_audio/player_audio.rs b/crates/skate-game/src/game_audio/player_audio.rs index 384db2eb..0b98ae58 100644 --- a/crates/skate-game/src/game_audio/player_audio.rs +++ b/crates/skate-game/src/game_audio/player_audio.rs @@ -1,12 +1,12 @@ -//! The local player's sounds on the native runtime (`skate_audio::player`), hosted per 60 Hz -//! frame by `native::mixmap_frame`: +//! The local player's sounds on the native runtime (`skate_audio::player`), hosted by +//! `native::mixmap_frame` once per frame that took physics steps (`native::HostClock`): //! 1. inputs: PlayerPhysics, the two 3DObjPos blocks, Jitter, Contacts, Rail, OffBoard; //! 2. components' process (posts / releases) — before the MixMap tick; //! 3. components' update (packets rewritten from the MixMap outputs, redelivered) — after it. //! //! The player components run whenever their banks are in the install (2026-10-03: the interim -//! cue tables and the `SKATE_AEMS_PLAYER=0` / `SKATE_AEMS_FOOTSTEPS=0` opt-outs are gone; without -//! the banks the skater's sounds are silent and the host logs an error). +//! cue tables and their opt-outs are gone; without the banks the skater's sounds are silent and the +//! host logs an error). use std::collections::HashMap; use skate_audio::eval::NodeId; @@ -37,15 +37,6 @@ pub(crate) struct PlayerAudio { /// console cadence (n console evaluations in this pass, `native::mixmap_frame`), `None`: one /// step per call (the old 60 Hz host and the tests). pub(crate) jitter_steps: Option, - /// The body poster (`player::contacts`, `sub_824BC188`) on the console cadence: it runs once per - /// console evaluation of this pass ([`Self::jitter_steps`]), so its 15-frame cooldown is 0.5 s at - /// any real frame rate (2026-10-03). `SKATE_AEMS_BODY_CONSOLE=0` / `E2E_BODY_CONSOLE=0`: once per - /// process, as before. Follows the MixMap cadence: without it (`jitter_steps` None) the old one. - pub(crate) body_console: bool, - /// The deck-impact poster (`sub_824BD000`) on the console cadence too (its 6-frame cooldown = - /// 0.2 s at any frame rate; 2026-10-03). `SKATE_AEMS_DECK_CONSOLE=0` / `E2E_DECK_CONSOLE=0`: once - /// per process, as before. - pub(crate) deck_console: bool, positions: [ObjPos; 2], was_grinding: bool, last_camera: Option<[f32; 3]>, @@ -64,9 +55,6 @@ pub(crate) struct PlayerAudio { pub(crate) seam_alpha: Option, /// The last two physics samples of the wheel positions (previous, current). seam_wheels: Option<([[f32; 3]; 4], [[f32; 3]; 4])>, - /// Class_Seams on the console's 30 fps process cadence ([`Self::seam_frame`]); off: its whole - /// process once per 60 Hz tick (before Listening test 9). - pub(crate) seam_console: bool, /// `SFXObj_Contacts`' Splice one-shots (pops, landings, touchdowns); on when the install has /// the `Skate_Collisions` patch tree ([`PlayerAudio::contacts_on`]). board: board_contacts::Contacts, @@ -123,29 +111,23 @@ impl PlayerAudio { pub(crate) fn new(tuning: PlayerTuning, components: bool) -> Self { let jitter = Jitter::new(&tuning.jitter, skate_audio::grain::GrainBed::SEED); // The collision voices play through their Collision SubMix (`sub_824D25E0`: mono, env send - // at the category's Collision output, eEQChain bus); `SKATE_AEMS_SUBMIX=0` = straight into - // SFX Master as before. + // at the category's Collision output, eEQChain bus). let mut collision = CollisionManager::default(); - collision.submix = !std::env::var("SKATE_AEMS_SUBMIX").is_ok_and(|v| v == "0"); - let on = |var: &str| !std::env::var(var).is_ok_and(|v| v == "0"); + collision.submix = true; let mut board = board_contacts::Contacts::default(); // Session review 2026-10-03: the push foot's plant / lift (#2), the body poster (#4) and the - // grind on / off sounds (#5); `SKATE_AEMS_{PLANT_LIFT,BODY_IMPACTS,GRIND_ONOFF}=0` off. - board.plant_lift_on = on("SKATE_AEMS_PLANT_LIFT"); - board.body_on = on("SKATE_AEMS_BODY_IMPACTS"); - // The bridge's speed graph on the body impacts (`sub_824B0DA8`, 2026-10-03); - // `SKATE_AEMS_BODY_CURVE=0`: the conditioner's impacts as before. - board.body_speed_on = on("SKATE_AEMS_BODY_CURVE"); + // grind on / off sounds (#5); the bridge's speed graph on the body impacts (`sub_824B0DA8`). + board.plant_lift_on = true; + board.body_on = true; + board.body_speed_on = true; let mut grind = Grind::default(); - grind.onoff = on("SKATE_AEMS_GRIND_ONOFF"); + grind.onoff = true; Self { tuning, physics: Physics::default(), contacts: Contacts::default(), jitter, jitter_steps: None, - body_console: on("SKATE_AEMS_BODY_CONSOLE"), - deck_console: on("SKATE_AEMS_DECK_CONSOLE"), positions: [ObjPos::default(); 2], was_grinding: false, last_camera: None, @@ -158,7 +140,6 @@ impl PlayerAudio { seams: Default::default(), seam_alpha: None, seam_wheels: None, - seam_console: false, board, contact_tuning: ContactsTuning::default(), contacts_on: false, @@ -204,21 +185,6 @@ impl PlayerAudio { (self.board.deck_posts, self.board.deck_digest) } - /// The session-review ports (the e2e harness's `E2E_*` switches): the plant / lift, the body - /// poster, the grind on / off sounds. - #[cfg(test)] - pub(crate) fn set_review_ports(&mut self, plant_lift: bool, body: bool, grind_onoff: bool) { - self.board.plant_lift_on = plant_lift; - self.board.body_on = body; - self.grind.onoff = grind_onoff; - } - - /// The bridge's speed graph on the body impacts (the e2e harness's `E2E_BODY_CURVE`). - #[cfg(test)] - pub(crate) fn set_body_curve(&mut self, on: bool) { - self.board.body_speed_on = on; - } - /// Diagnostics (the e2e harness's `E2E_BODY_LOG`): keep every body-poster message. #[cfg(test)] pub(crate) fn set_body_log(&mut self, on: bool) { @@ -305,7 +271,9 @@ impl PlayerAudio { } self.nodes.insert(slot, rt.post(id, &words)); self.posts += 1; - bevy::log::info!("AUDIO_NATIVE post {class} {slot:?} words={words:?}"); + if trace() { + bevy::log::info!("AUDIO_NATIVE post {class} {slot:?} words={words:?}"); + } } Command::Redeliver { slot, words } => { if let Some(&node) = self.nodes.get(&slot) { @@ -315,7 +283,9 @@ impl PlayerAudio { Command::Release { slot } => { if let Some(node) = self.nodes.remove(&slot) { rt.release(node); - bevy::log::info!("AUDIO_NATIVE release {slot:?}"); + if trace() { + bevy::log::info!("AUDIO_NATIVE release {slot:?}"); + } } } } @@ -402,11 +372,9 @@ impl PlayerAudio { rolling_cmds.extend(self.slide.process(loose, &self.tuning.rolling)); } let seam_state = self.seam_state(s); - let seams = if self.seam_console { - Self::seam_commands(self.seams.process_tick(&seam_state, &self.tuning, m_mut)) - } else { - Self::seam_commands(self.seams.process(&seam_state, &self.tuning, m_mut)) - }; + // Class_Seams' process runs on the console cadence in `seam_frame`; the tick only creates + // the packets and writes Cracks.in0. + let seams = Self::seam_commands(self.seams.process_tick(&seam_state, &self.tuning, m_mut)); let m: &MixMap = m_mut; let rail = Owner { mixmap: m, key: keys::rail(0) }; let mut cmds = self.grind.process(s, &self.tuning, &rail); @@ -428,8 +396,10 @@ impl PlayerAudio { // The grind on / off contact sounds of this frame's Rail posts (Splice). self.grind.sounds(s, &self.tuning, &mut rt.splice_host()); let before = self.board.starts; - self.board.body_calls = if self.body_console { self.jitter_steps } else { None }; - self.board.deck_calls = if self.deck_console { self.jitter_steps } else { None }; + // The body / deck posters run once per console evaluation of this pass (their 15 / 6-frame + // cooldowns = 0.5 / 0.2 s at any frame rate; `jitter_steps` None = per call, the tests). + self.board.body_calls = self.jitter_steps; + self.board.deck_calls = self.jitter_steps; self.board.process(s, self.contacts.buckets(), &self.tuning, &self.contact_tuning, &mut rt.splice_host()); self.posts += self.board.starts - before; // The collision manager runs after the player's components (its own state manager). @@ -451,14 +421,30 @@ impl PlayerAudio { } } + /// The wheel positions of the last two physics steps (`Riding::wheels_before`, this sample's), + /// which the rendered board interpolates between: the pair [`Self::seam_state`] reads. Hosts + /// set it before every call (2026-10-03: the pair follows the physics steps, so a frame that + /// takes two steps interpolates between the last two, and a board at rest has no spread); + /// `before = now` after a camera cut / teleport. Without it the pair follows the samples' changes. + pub(crate) fn step_wheels(&mut self, before: [[f32; 3]; 4], now: [[f32; 3]; 4]) { + self.seam_wheels = Some((before, now)); + } + + /// Forget the camera's last position (teleport, camera cut, map change): the next listener + /// has no velocity instead of the jump's (Doppler). + pub(crate) fn reset_listener(&mut self) { + self.last_camera = None; + } + /// The state Class_Seams reads: with [`Self::seam_alpha`], the wheel positions of the rendered /// board (the last two physics samples interpolated), else the physics sample. fn seam_state(&mut self, s: &AudioState) -> AudioState { let Some(alpha) = self.seam_alpha else { return *s }; let now = s.wheel_position; let (prev, cur) = match self.seam_wheels { - Some((_, cur)) if cur != now => (cur, now), - Some(track) => track, + // The host's pair for this sample ([`Self::step_wheels`]). + Some(pair) if pair.1 == now => pair, + Some((_, cur)) => (cur, now), None => (now, now), }; self.seam_wheels = Some((prev, cur)); @@ -471,10 +457,9 @@ impl PlayerAudio { /// Class_Seams on the console cadence (Listening test 9): called on every rendered frame of /// `dt` seconds, before [`Self::process`] on frames that tick. Runs the seams' process on a 30 Hz /// virtual grid at the rendered wheel positions (`seams::Seams::frame`) and their update once - /// per console frame; the tick only creates the packets and writes Cracks.in0. Needs - /// [`Self::seam_console`]. + /// per console frame; the tick only creates the packets and writes Cracks.in0. pub(crate) fn seam_frame(&mut self, m: &MixMap, s: &AudioState, dt: f32, rt: &mut Runtime) { - if !self.components || !self.seam_console { + if !self.components { return; } let seam_state = self.seam_state(s); @@ -520,11 +505,7 @@ impl PlayerAudio { let board = Owner { mixmap: m, key: keys::skateboard(0) }; cmds.extend(self.skid.update(s, &self.tuning, &board)); cmds.extend(self.squeaks.update(s, &board)); - let cracks = Owner { mixmap: m, key: keys::cracks(0) }; - // Under the console cadence the seams' update runs in `seam_frame`. - if !self.seam_console { - cmds.extend(Self::seam_commands(self.seams.update(s, &cracks))); - } + // The seams' update runs in `seam_frame` (the console cadence). if self.rolling_on { cmds.extend(self.rolling.update(s, &self.tuning, &RollingInputs { speed_scale }, &board)); } @@ -567,6 +548,13 @@ impl PlayerAudio { } } +/// `SKATE_AUDIO_TRACE=1`: the `AUDIO_NATIVE post` / `release` lines (one per component post or +/// release, written under the runtime lock; off by default since 2026-10-03). +fn trace() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("SKATE_AUDIO_TRACE").is_some_and(|v| v != "0")) +} + /// The banks the components post into. pub(crate) const BANKS: &[&str] = components::BANKS; /// Optional component banks: a component runs when its first bank loads (the rest add layers). @@ -634,11 +622,12 @@ mod tests { /// 10 km/h; level(1) p90 0.24). Prints the table (`--nocapture`); asserts only the shape: Jitter /// lowers level(1) a little at most and never raises it. #[test] + #[ignore = "needs the private install data"] fn bed_gain_a_with_the_full_inputs() { - let Some((library, base)) = install() else { return eprintln!("skipped: no install with a MixMap") }; + let Some((library, base)) = install() else { panic!("missing private data: no install with a MixMap") }; let tuning = library.player_tuning(); if tuning.jitter.is_empty() { - return eprintln!("skipped: no player_tuning (stage_player_tuning.py)"); + panic!("missing private data: no player_tuning (stage_player_tuning.py)"); } let surface = library.grain_tuning("asphalt_smooth_hard").expect("grain tuning"); println!("km/h level(1) no-jitter level(1) jitter p10/p50/p90 gainA(turn 0) gainA / gainB (turn 0.5) level(2)"); @@ -717,7 +706,7 @@ mod tests { fn set_azimuth(&mut self, v: u32, value: i32) { self.inner.set_azimuth(v, value) } fn query(&mut self, v: u32) -> skate_audio::eval::VoiceStatus { self.inner.query(v) } } - let Some((library, base)) = install() else { return eprintln!("skipped") }; + let Some((library, base)) = install() else { panic!("missing private data") }; let (mut rt, names) = runtime(&library); let mut m = MixMap::from_bytes(&base).unwrap(); let mut p = PlayerAudio::new(library.player_tuning(), true); @@ -732,6 +721,8 @@ mod tests { globals(&mut m); let l = p.listener([s.com_position[0] - 3.5, 2.4, s.com_position[2]], [1.0, -0.3, 0.0], 1.0 / 60.0, &s); p.write_inputs(&mut m, &s, Some(&l)); + // Class_Seams on its console cadence, every frame before the pass (as `mixmap_frame`). + p.seam_frame(&m, &s, 1.0 / 60.0, &mut rt); p.process(&mut m, &s, &mut rt, None, 0); m.tick(1.0 / 60.0); p.update(&m, &s, &mut rt, None, 0); @@ -796,6 +787,8 @@ mod tests { globals(&mut m); let l = p.listener([s.com_position[0] - 3.5, 2.4, s.com_position[2]], [1.0, -0.3, 0.0], 1.0 / 60.0, &s); p.write_inputs(&mut m, &s, Some(&l)); + // Class_Seams on its console cadence, every frame before the pass (as `mixmap_frame`). + p.seam_frame(&m, &s, 1.0 / 60.0, &mut rt); p.process(&mut m, &s, &mut rt, None, 0); m.tick(1.0 / 60.0); p.update(&m, &s, &mut rt, None, 0); @@ -825,11 +818,12 @@ mod tests { /// past 0.001 s), then the board in water (1198). Both sound (their levels come from OffBoard /// outputs 13 / 16, printed with 14 / 15). #[test] + #[ignore = "needs the private install data"] fn the_water_splash_plays_through_the_real_mixmap() { - let Some((library, mxb)) = install() else { return eprintln!("skipped: no install with a MixMap") }; + let Some((library, mxb)) = install() else { panic!("missing private data: no install with a MixMap") }; let (mut rt, _) = runtime(&library); for stem in SPLICE_BANKS { - let Some((bank, pcm)) = library.splice_bank(stem) else { return eprintln!("skipped: no {stem} patch tree") }; + let Some((bank, pcm)) = library.splice_bank(stem) else { panic!("missing private data: no {stem} patch tree") }; let r = &mut rt; r.splice.load_bank(stem, bank, pcm, &mut r.mixer); } @@ -846,6 +840,8 @@ mod tests { globals(&mut m); let l = p.listener([s.com_position[0] - 3.5, 2.4, s.com_position[2]], [1.0, -0.3, 0.0], 1.0 / 60.0, &s); p.write_inputs(&mut m, &s, Some(&l)); + // Class_Seams on its console cadence, every frame before the pass (as `mixmap_frame`). + p.seam_frame(&m, &s, 1.0 / 60.0, &mut rt); p.process(&mut m, &s, &mut rt, None, 0); m.tick(1.0 / 60.0); p.update(&m, &s, &mut rt, None, 0); @@ -869,10 +865,11 @@ mod tests { /// Class_grind GRINDS ≤ 0.28 (medians 0.004–0.11), SenseOfSpeed_wind ≤ 0.54, rattle ≤ 0.28, /// Class_foot_drag FOOT_DRAG ≤ 0.28 (medians 0.002–0.07)). Prints the table (`--nocapture`). #[test] + #[ignore = "needs the private install data"] fn components_play_their_retail_banks() { - let Some((library, mxb)) = install() else { return eprintln!("skipped: no install with a MixMap") }; + let Some((library, mxb)) = install() else { panic!("missing private data: no install with a MixMap") }; if !BANKS.iter().all(|b| library.aems().banks.contains_key(*b)) { - return eprintln!("skipped: the install lacks the player banks"); + panic!("missing private data: the install lacks the player banks"); } let metal = skate_audio::player::state::material_of_tag(9); let concrete = skate_audio::player::state::material_of_tag(3); @@ -902,11 +899,12 @@ mod tests { /// stay at or below unity and that the first hit's voice gain equals the later hits' (first-trigger /// rule: the bank is decoded at start, the same path plays every hit). #[test] + #[ignore = "needs the private install data"] fn seams_play_their_retail_bank() { - let Some((library, mxb)) = install() else { return eprintln!("skipped: no install with a MixMap") }; + let Some((library, mxb)) = install() else { panic!("missing private data: no install with a MixMap") }; let tuning = library.player_tuning(); if tuning.seam_patterns.len() < 16 || !library.aems().banks.contains_key("Seams_Bank") { - return eprintln!("skipped: no seam patterns (stage_bus_tuning.py) or Seams_Bank"); + panic!("missing private data: no seam patterns (stage_bus_tuning.py) or Seams_Bank"); } println!("pattern km/h hits voices gain first / median / p90 / max"); for (pattern, kmh) in [(11u32, 10.0f32), (11, 20.0), (11, 30.0), (4, 20.0)] { @@ -934,12 +932,13 @@ mod tests { /// hits spread over both eight-sample blocks (32–39 and 80–87, retail 180430), not one sample /// per block (Listening test 8). #[test] + #[ignore = "needs the private install data"] fn seam_hits_shuffle_their_samples() { - let Some((library, mxb)) = install() else { return eprintln!("skipped: no install with a MixMap") }; + let Some((library, mxb)) = install() else { panic!("missing private data: no install with a MixMap") }; let tuning = library.player_tuning(); let banks = &library.aems().banks; if tuning.seam_patterns.len() < 16 || !banks.contains_key("Seams_Bank") || !banks.contains_key(skate_audio::player::seams::UTILITY_BANK) { - return eprintln!("skipped: no seam patterns, Seams_Bank or Common.abk (stage_extra_banks.py Common)"); + panic!("missing private data: no seam patterns, Seams_Bank or Common.abk (stage_extra_banks.py Common)"); } let v = 30.0f32 / 3.6; let (mut rt, names) = runtime(&library); @@ -957,6 +956,8 @@ mod tests { globals(&mut m); let l = p.listener([s.com_position[0] - 3.5, 2.4, s.com_position[2]], [1.0, -0.3, 0.0], 1.0 / 60.0, &s); p.write_inputs(&mut m, &s, Some(&l)); + // Class_Seams on its console cadence, every frame before the pass (as `mixmap_frame`). + p.seam_frame(&m, &s, 1.0 / 60.0, &mut rt); p.process(&mut m, &s, &mut rt, None, 0); m.tick(1.0 / 60.0); p.update(&m, &s, &mut rt, None, 0); @@ -981,7 +982,7 @@ mod tests { #[test] #[ignore = "diagnostic print"] fn seam_samples_by_pattern() { - let Some((library, mxb)) = install() else { return eprintln!("skipped: no install with a MixMap") }; + let Some((library, mxb)) = install() else { panic!("missing private data: no install with a MixMap") }; let material: u32 = std::env::var("SEAM_MATERIAL").ok().and_then(|v| v.parse().ok()).unwrap_or(65); let kmh = 30.0f32; let v = kmh / 3.6; @@ -1025,7 +1026,7 @@ mod tests { #[test] #[ignore = "diagnostic print"] fn seam_samples_by_words() { - let Some((library, _)) = install() else { return eprintln!("skipped: no install with a MixMap") }; + let Some((library, _)) = install() else { panic!("missing private data: no install with a MixMap") }; // SEAM_SWEEP=word:value,… overrides words of the base packet (w10 1, w13 12) instead. let sweep: Vec<(usize, i32)> = std::env::var("SEAM_SWEEP") .map(|v| v.split(',').filter_map(|p| p.split_once(':')).filter_map(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?))).collect()) @@ -1071,8 +1072,9 @@ mod tests { /// retail's capture medians (upstream PR #4's driver notes §11, ±0.5 km/h bins: 25 km/h 343, /// 30 km/h 730, 35 km/h 1194, 40 km/h 2017): within 1 dB. #[test] + #[ignore = "needs the private install data"] fn wind_level_matches_the_retail_medians() { - let Some((library, mxb)) = install() else { return eprintln!("skipped: no install with a MixMap") }; + let Some((library, mxb)) = install() else { panic!("missing private data: no install with a MixMap") }; for (kmh, retail) in [(25.0f32, 343.0f32), (30.0, 730.0), (35.0, 1194.0), (40.0, 2017.0)] { let mut m = MixMap::from_bytes(&mxb).unwrap(); let mut p = PlayerAudio::new(library.player_tuning(), false); diff --git a/crates/skate-game/src/game_audio/skate_events.rs b/crates/skate-game/src/game_audio/skate_events.rs index 6f6a75f4..8fdec03f 100644 --- a/crates/skate-game/src/game_audio/skate_events.rs +++ b/crates/skate-game/src/game_audio/skate_events.rs @@ -19,7 +19,7 @@ const BRAKE_TIMEOUT: f32 = 0.1; /// Continuous state sampled at the last physics tick. #[derive(Clone, Copy, Debug, Default)] -pub(super) struct Riding { +pub(crate) struct Riding { pub board: Vec3, pub speed: f32, /// Majority audio surface tag under the wheels in contact (0 with none). @@ -39,25 +39,80 @@ pub(super) struct Riding { pub deck_up: f32, pub deck_contact: bool, pub deck_material: u32, + /// The wheel positions of the physics step before this one (this step's on the first): with + /// `audio.wheel_position` the pair the rendered board interpolates between, which Class_Seams + /// reads (`PlayerAudio::step_wheels`). + pub wheels_before: [[f32; 3]; 4], } +/// What `observe` publishes for the audio host (`native::mixmap_frame`). +/// +/// The host's clock is the physics steps (2026-10-03, PR #32 review): `observe` runs once per +/// physics step and counts the steps since the host last took them ([`Cues::take_steps`]); the +/// host runs inputs / process / tick / update only on frames that took steps, as many ticks as +/// steps (capped), and none while nothing steps (menu pause). The one-step pulses of the steps +/// not yet taken are OR-latched into the published sample ([`latch_pulses`]), so a frame that takes +/// two steps still sees the first one's pulse and a frame without a step re-processes nothing. #[derive(Resource, Default)] -pub(super) struct Cues { +pub(crate) struct Cues { pub riding: Riding, + /// Physics steps published since the host last took them. + pub steps: u32, } +impl Cues { + /// One physics step's sample: latched onto the pending one while the host has not taken it. + pub(super) fn publish(&mut self, mut next: Riding) { + if self.steps > 0 { + latch_pulses(&self.riding, &mut next); + } + self.riding = next; + self.steps = self.steps.saturating_add(1); + } + + /// The steps since the last call (the host's ticks for this frame); the latched pulses are + /// consumed with them (the next [`Cues::publish`] starts fresh). + pub(super) fn take_steps(&mut self) -> u32 { + std::mem::take(&mut self.steps) + } +} + +/// The audio state's one-step pulses, OR-ed over the steps a host pass takes at once: `+335` (the +/// push plant's rise, `push_trigger`). Every other field is a level (the latest step's value is +/// the one retail's per-frame audio manager reads too), a latch (`+228`, `+468`, `+192` / `+692`), +/// a 4-frame max (`+668`, `+496..`) or a counter (`Riding::pushes`), and the components detect +/// their own edges (landing, bail, grind) against the last sample they processed. +pub(super) fn latch_pulses(pending: &Riding, next: &mut Riding) { + next.audio.push_trigger |= pending.audio.push_trigger; +} + +/// `observe`'s own memory: the local player's [`SkaterAudioMemory`] plus what only the local +/// player's logs and the bed's brake input need. #[derive(Default)] -pub(super) struct Seen { - started: bool, - push: bool, - /// Last tick's audio-state push plant (`+333 || +334`, see [`push_plant`]). - planted: bool, +pub(crate) struct Seen { + /// The audio-state builder's per-skater memory. + skater: SkaterAudioMemory, /// Foot braking, time since the last foot-down event, and the last event (+1/-1/0). braking: bool, brake_time: f32, brake_event: i32, on_rail: bool, trace: Option, + /// Rows written to the audio state log. + log_frame: u64, +} + +/// The per-skater memory the audio-state builder ([`skater_audio_state`]) keeps between physics +/// steps: the latches, rings and edges retail's bridge (`sub_824B0DA8`) and conditioner keep per +/// skater. One per simulated skater (the local player's lives in `observe`; an AI-skater system +/// keeps one per skater it fills an `AudioState` for). +#[derive(Clone, Debug, Default)] +pub(crate) struct SkaterAudioMemory { + started: bool, + /// Last tick's animation push contact. + push: bool, + /// Last tick's audio-state push plant (`+333 || +334`, see [`push_plant`]). + planted: bool, /// Seconds in the current air phase (audio state `+236`). air_time: f32, /// Latched grind family / material (`+192` / `+692`). @@ -78,8 +133,6 @@ pub(super) struct Seen { com_speed_212: f32, /// The conditioner's step code (`+740`, `sub_827729B8`). step_code: skate_audio::player::footsteps::StepCode, - /// Rows written to the audio state log. - log_frame: u64, } fn vec(v: skate_core::math::Vector3) -> Vec3 { @@ -110,7 +163,94 @@ fn wheel_surface(physics: &GamePhysics) -> (u32, u32) { (touching.len() as u32, surface.unwrap_or(0)) } -pub(super) fn observe( +/// What one builder step gives besides the record (for `observe`'s publish and logs). +struct Built { + state: skate_audio::player::AudioState, + /// The physical state, wheels down, majority surface tag, on a rail. + state_id: u32, + wheels: u32, + surface: u32, + on_rail: bool, + /// The plant's rise (`+335`) and the animation's push contact edge (the state log's `push`). + plant_edge: bool, + push_edge: bool, + /// State55 (the foot plant flag, the state log's `plant`). + state55: bool, + board: Vec3, + unridden: bool, +} + +/// The retail audio-state record of one skater after a physics step: the only correct way to fill +/// an [`skate_audio::player::AudioState`] from a `GamePhysics` / `SkaterRuntime` pair (air time, +/// grind family / material latch, the deck and region rings, the step code, the push plant, `+216`). +/// The local player's `observe` publishes exactly this; an AI-skater system that simulates its +/// skaters with the player's physics calls it once per physics step per skater (only for skaters +/// near the camera, see `crate::world_audio`), each with its own `memory`. `dt` = the step's +/// seconds. Reads the animation's latched push contact without taking it. +#[cfg_attr(not(test), allow(dead_code))] // the hook for a future AI-skater system (doc 11) +pub(crate) fn skater_audio_state(physics: &GamePhysics, skater: &SkaterRuntime, memory: &mut SkaterAudioMemory, dt: f32) -> skate_audio::player::AudioState { + build(physics, skater, memory, dt, skater.animation_input.audio.push).state +} + +fn build(physics: &GamePhysics, skater: &SkaterRuntime, memory: &mut SkaterAudioMemory, dt: f32, push: bool) -> Built { + let state = skater.player_state.current() as u32; + let deck = physics.board.bodies()[BodyId::Deck.index()].rates; + let board = vec(deck.position); + let grinds = &skater.player_input.physical.grinds; + let on_rail = (grinds.grinding_316 != 0 || grinding(state)) && grinds.leaving_317 == 0; + let unridden = board_unridden(state); + let (wheels, surface) = if unridden { (0, 0) } else { wheel_surface(physics) }; + // The animation's push contact edge (the state log's `push` column). + let push_edge = memory.started && push && !memory.push; + let state55 = skater.player_state.state_flags.get(55 - 52).copied().unwrap_or(false); + let (planted, plant_edge) = push_plant(state55, memory.planted); + let plant_edge = memory.started && plant_edge; + let m = &mut *memory; + let mut audio_state = audio_state(physics, skater, AudioFrame { + dt, + state, + unridden, + wheels, + board: (board, vec(deck.linear_velocity)), + on_rail, + push: planted, + push_trigger: plant_edge, + air_time: &mut m.air_time, + grind_family: &mut m.grind_family, + grind_material: &mut m.grind_material, + jump_velocity: &mut m.jump_velocity, + grind_impact: &mut m.grind_impact, + deck_ring: &mut m.deck_ring, + deck_at: &mut m.deck_at, + region_ring: &mut m.region_ring, + region_at: &mut m.region_at, + step_code: &mut m.step_code, + slip: if wheels > 0 { + let ri = physics.board.part_transforms()[BodyId::Deck.index()].basis.columns[0]; + skate_audio::player::state::slip(vec(deck.linear_velocity).dot(Vec3::from_array(ri))) + } else { + 0.0 + }, + deck_spin: { + let at = physics.board.part_transforms()[BodyId::Deck.index()].basis.columns[2]; + vec(deck.angular_velocity).dot(Vec3::from_array(at)) + }, + deck_spin_xy: { + let basis = physics.board.part_transforms()[BodyId::Deck.index()].basis.columns; + let w = vec(deck.angular_velocity); + [w.dot(Vec3::from_array(basis[0])), w.dot(Vec3::from_array(basis[1]))] + }, + }); + // `+216` = last tick's `+212`; the body poster applies the graph at it (`player::contacts`). + audio_state.com_speed_216 = memory.com_speed_212; + memory.com_speed_212 = audio_state.com_speed(); + memory.started = true; + memory.push = push; + memory.planted = planted; + Built { state: audio_state, state_id: state, wheels, surface, on_rail, plant_edge, push_edge, state55, board, unridden } +} + +pub(crate) fn observe( physics: Res, mut skater: ResMut, mut cues: ResMut, @@ -139,7 +279,7 @@ pub(super) fn observe( if brake_event != 0 && brake_event != seen.brake_event { info!("AUDIO_EVENT brake {} speed={speed:.2} state={state}", if brake_event > 0 { "down" } else { "up" }); } - if audio.push && !seen.push { + if audio.push && !seen.skater.push { info!("AUDIO_EVENT push speed={speed:.2} state={state}"); } let mut brake_time = if audio.brake_down { 0.0 } else { seen.brake_time + time.delta_secs() }; @@ -156,52 +296,11 @@ pub(super) fn observe( info!("AUDIO_EVENT grind {} surface={} ledge={} flag={} leaving={} state={state}", if on_rail { "start" } else { "stop" }, grinds.audio_surface_216, grinds.is_ledge_320, grinds.grinding_316, grinds.leaving_317); } - let unridden = board_unridden(state); - let (wheels, surface) = if unridden { (0, 0) } else { wheel_surface(&physics) }; - // The animation's push contact edge (the state log's `push` column). - let push_edge = seen.started && audio.push && !seen.push; - let state55 = skater.player_state.state_flags.get(55 - 52).copied().unwrap_or(false); - let (planted, plant_edge) = push_plant(push_plant_on(), state55, audio.push, seen.planted, seen.push); - let plant_edge = seen.started && plant_edge; - let memory: &mut Seen = &mut seen; - let mut audio_state = audio_state(&physics, &skater, AudioFrame { - dt: time.delta_secs(), - state, - unridden, - wheels, - board: (board, vec(deck.linear_velocity)), - on_rail, - push: planted, - push_trigger: plant_edge, - air_time: &mut memory.air_time, - grind_family: &mut memory.grind_family, - grind_material: &mut memory.grind_material, - jump_velocity: &mut memory.jump_velocity, - grind_impact: &mut memory.grind_impact, - deck_ring: &mut memory.deck_ring, - deck_at: &mut memory.deck_at, - region_ring: &mut memory.region_ring, - region_at: &mut memory.region_at, - step_code: &mut memory.step_code, - slip: if wheels > 0 { - let ri = physics.board.part_transforms()[BodyId::Deck.index()].basis.columns[0]; - skate_audio::player::state::slip(vec(deck.linear_velocity).dot(Vec3::from_array(ri))) - } else { - 0.0 - }, - deck_spin: { - let at = physics.board.part_transforms()[BodyId::Deck.index()].basis.columns[2]; - vec(deck.angular_velocity).dot(Vec3::from_array(at)) - }, - deck_spin_xy: { - let basis = physics.board.part_transforms()[BodyId::Deck.index()].basis.columns; - let w = vec(deck.angular_velocity); - [w.dot(Vec3::from_array(basis[0])), w.dot(Vec3::from_array(basis[1]))] - }, - }); - // `+216` = last tick's `+212`; the body poster applies the graph at it (`player::contacts`). - audio_state.com_speed_216 = seen.com_speed_212; - let com_speed_212 = audio_state.com_speed(); + let started = seen.skater.started; + let built = build(&physics, &skater, &mut seen.skater, time.delta_secs(), audio.push); + debug_assert_eq!((built.state_id, built.on_rail, built.board), (state, on_rail, board)); + let Built { state: audio_state, wheels, surface, plant_edge, push_edge, state55, unridden, .. } = built; + let com_speed_212 = seen.skater.com_speed_212; if super::state_log::enabled() { let p = &skater.player_input.physical; let s = &audio_state; @@ -281,7 +380,8 @@ pub(super) fn observe( } // The bed's push envelopes (`grain_bed.rs` `pushes_seen`) count the same edge as `+335`. let pushes = cues.riding.pushes.wrapping_add(u32::from(plant_edge)); - cues.riding = Riding { + let wheels_before = if started { cues.riding.audio.wheel_position } else { audio_state.wheel_position }; + cues.publish(Riding { board, speed: physics.riding.motion.ground_speed, surface, @@ -293,41 +393,22 @@ pub(super) fn observe( deck_up: deck_up(&physics, &skater), deck_contact: skater.player_input.physical.collision.flag_3475 != 0, deck_material: skate_audio::player::state::material_of_tag(physics.riding.ground.part_audio_surfaces[2]), - }; - *seen = Seen { - started: true, - push: audio.push, planted, braking, brake_time, brake_event, on_rail, trace: Some(trace), - air_time: seen.air_time, - grind_family: seen.grind_family, - grind_material: seen.grind_material, - jump_velocity: seen.jump_velocity, - grind_impact: seen.grind_impact, - deck_ring: seen.deck_ring, - step_code: seen.step_code, - deck_at: seen.deck_at, - region_ring: seen.region_ring, - region_at: seen.region_at, - com_speed_212, - log_frame: seen.log_frame + u64::from(super::state_log::enabled()), - }; + wheels_before, + }); + seen.braking = braking; + seen.brake_time = brake_time; + seen.brake_event = brake_event; + seen.on_rail = on_rail; + seen.log_frame += u64::from(super::state_log::enabled()); } /// The audio state's `+236` (Air+176, the time in the air *state*) after this tick. It counts /// while the air state (200..300) holds, so it keeps counting on the landing tick (wheels down, /// `+332` already clear, the state still air) and drops to 0 with the state — the recomp's TREAT /// capture (session all_20261002_223306: last air tick 0.750 s, landing tick 0.767 s, then 0; the -/// same one-tick hold as `+240`). `follow_state` off: the count before 2026-10-02 (air flag only, -/// 0 on the landing tick). -pub(super) fn air_time_236(previous: f32, state: u32, airborne: bool, dt: f32, follow_state: bool) -> f32 { - let counting = if follow_state { (200..300).contains(&state) } else { airborne }; - if counting { previous + dt } else { 0.0 } -} - -/// `SKATE_AEMS_AIR_TIME_STATE=0` restores the audio state's `+236` count from before 2026-10-02 -/// (reset on the landing tick instead of with the air state). -fn air_time_follows_state() -> bool { - static ON: std::sync::OnceLock = std::sync::OnceLock::new(); - *ON.get_or_init(|| !std::env::var("SKATE_AEMS_AIR_TIME_STATE").is_ok_and(|v| v == "0")) +/// same one-tick hold as `+240`). +pub(super) fn air_time_236(previous: f32, state: u32, dt: f32) -> f32 { + if (200..300).contains(&state) { previous + dt } else { 0.0 } } /// The audio state's push plant `+333 || +334` and its edge `+335` (the bridge `sub_824B0DA8`): @@ -336,17 +417,10 @@ fn air_time_follows_state() -> bool { /// (record +160 bit 30) is not mapped yet, so the plant is State55 itself (session review /// 2026-10-03 #1). Every push sound but the stroke foley reads these: the rattle and the bed's /// push envelopes (`sub_824C6198`), the clothing plant, the Contacts' plant / lift. Retail plants -/// land +548 ms after the stroke (`+337`); the animation's push contact (`audio.push`) fires near -/// the stroke. `on` false (`SKATE_AEMS_PUSH_PLANT=0`): the animation's push contact, as before. -/// Returns (planted, edge) from this tick's State55 / push contact and last tick's values. -pub(super) fn push_plant(on: bool, state55: bool, push: bool, was_planted: bool, was_push: bool) -> (bool, bool) { - if on { (state55, state55 && !was_planted) } else { (push, push && !was_push) } -} - -/// `SKATE_AEMS_PUSH_PLANT=0` restores the push plant from the animation's push contact. -fn push_plant_on() -> bool { - static ON: std::sync::OnceLock = std::sync::OnceLock::new(); - *ON.get_or_init(|| !std::env::var("SKATE_AEMS_PUSH_PLANT").is_ok_and(|v| v == "0")) +/// land +548 ms after the stroke (`+337`); the animation's push contact fires near the stroke. +/// Returns (planted, edge) from this tick's State55 and last tick's plant. +pub(super) fn push_plant(state55: bool, was_planted: bool) -> (bool, bool) { + (state55, state55 && !was_planted) } /// What [`audio_state`] needs beyond the physics resources. @@ -442,7 +516,7 @@ fn audio_state(physics: &GamePhysics, skater: &SkaterRuntime, f: AudioFrame) -> let lines = &physics.riding.wheel_lines; let wheel_contact: [bool; 4] = std::array::from_fn(|i| !f.unridden && ground.parts[i].in_contact); let airborne = (200..300).contains(&f.state) && f.wheels == 0; - *f.air_time = air_time_236(*f.air_time, f.state, airborne, f.dt, air_time_follows_state()); + *f.air_time = air_time_236(*f.air_time, f.state, f.dt); let grinding = f.on_rail; if p.grinds.grinding_316 != 0 || grinding { *f.grind_family = Some(p.grinds.words_136_140[0] as i32); @@ -649,39 +723,33 @@ mod tests { /// `+236` across an ollie as the recomp's TREAT capture has it (session all_20261002_223306, /// 60 Hz ticks): 0 on the ground, 1/60 on the first air tick, still counting on the landing - /// tick (state 201, wheels down), 0 once the state leaves the air. Off: 0 on the landing tick. + /// tick (state 201, wheels down), 0 once the state leaves the air. #[test] fn air_time_holds_through_the_landing_tick() { let dt = 1.0 / 60.0; // (state, wheels down) per tick: rolling, takeoff, 3 air ticks, landing tick, rolling. let ticks = [(103, true), (201, false), (201, false), (201, false), (201, false), (201, true), (100, true)]; - let run = |follow: bool| { - let mut t = 0.0f32; - ticks.iter().map(|&(state, down)| { - t = air_time_236(t, state, (200..300).contains(&state) && !down, dt, follow); - (t * 60.0).round() as i32 - }).collect::>() - }; - assert_eq!(run(true), [0, 1, 2, 3, 4, 5, 0]); - assert_eq!(run(false), [0, 1, 2, 3, 4, 0, 0]); + let mut t = 0.0f32; + let run: Vec = ticks.iter().map(|&(state, _down)| { + t = air_time_236(t, state, dt); + (t * 60.0).round() as i32 + }).collect(); + assert_eq!(run, [0, 1, 2, 3, 4, 5, 0]); } - /// The push plant follows State55 and fires its edge once per plant; off: the animation's push - /// contact as before. + /// The push plant follows State55 (not the animation's push contact) and fires its edge once + /// per plant. #[test] fn the_push_plant_is_state55_and_its_rise() { // (State55, push contact) per tick: the stroke's contact first, the plant ~0.5 s later. let ticks = [(false, true), (false, true), (false, false), (true, false), (true, false), (false, false)]; - let run = |on: bool| { - let (mut planted, mut push) = (false, false); - ticks.iter().map(|&(s55, p)| { - let r = push_plant(on, s55, p, planted, push); - (planted, push) = (r.0, p); - r - }).collect::>() - }; - assert_eq!(run(true), [(false, false), (false, false), (false, false), (true, true), (true, false), (false, false)]); - assert_eq!(run(false), [(true, true), (true, false), (false, false), (false, false), (false, false), (false, false)]); + let mut planted = false; + let run: Vec<(bool, bool)> = ticks.iter().map(|&(s55, _push)| { + let r = push_plant(s55, planted); + planted = r.0; + r + }).collect(); + assert_eq!(run, [(false, false), (false, false), (false, false), (true, true), (true, false), (false, false)]); } #[test] @@ -714,13 +782,14 @@ mod tests { /// game wrote from Δv·n = v_new·n − v_old·n along its normal and its mass, within the lines' 4-decimal /// rounding. The signed form misses the lines with Δv·n < 0. #[test] + #[ignore = "needs the private install data"] fn region_impacts_match_the_recomps_bail_lines() { use skate_core::physics::skeleton_body::ContactRegion; let root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../.local/recomp/sessions")); let mut lines = Vec::new(); for run in ["bailrun_t2_20261003_100040", "bailrun_ok_20261003_100226", "bailrun_ok2_20261003_100514", "bailrun_ok3_20261003_101354"] { let Ok(text) = std::fs::read_to_string(root.join(run).join("trace.tsv")) else { - return eprintln!("skipped: no recomp bail trace {run}"); + panic!("missing private data: no recomp bail trace {run}"); }; for l in text.lines().filter(|l| l.starts_with("BAILREG\t")) { let f: Vec<&str> = l.split('\t').collect(); diff --git a/crates/skate-game/src/game_audio/state_replay.rs b/crates/skate-game/src/game_audio/state_replay.rs new file mode 100644 index 00000000..8b51a82e --- /dev/null +++ b/crates/skate-game/src/game_audio/state_replay.rs @@ -0,0 +1,352 @@ +//! Replaying a recorded audio state log (`state_log.rs`, `SKATE_AUDIO_STATE_LOG`) or an e2e +//! scenario (`tools/audio-e2e/scenarios.py`) as the per-step audio state the game publishes: the +//! headless e2e render (`e2e.rs`) and the ghost NPC skater (`ghost.rs`) both use it. Moved +//! unchanged out of `e2e.rs` (2026-10-03, world audio hook-in; the e2e renders are byte-identical). +use std::collections::HashMap; + +use skate_audio::player::AudioState; +use skate_audio::player::state::material_of_tag; + +use super::skate_events::Riding; + +pub(crate) struct Row(pub(crate) HashMap); +impl Row { + pub(crate) fn f(&self, k: &str) -> f32 { + self.0.get(k).copied().unwrap_or(0.0) as f32 + } + pub(crate) fn i(&self, k: &str) -> i64 { + self.0.get(k).copied().unwrap_or(0.0) as i64 + } +} + +/// Read a log without panicking (the ghost skater's input): `Err` names the first bad line. The +/// rows must all parse (memory: data with malformed lines is unusable, never parsed around). +pub(crate) fn try_read(text: &str) -> Result, String> { + let mut lines = text.lines(); + let cols: Vec = lines.next().ok_or("empty log")?.split('\t').map(str::to_owned).collect(); + if !cols.iter().any(|c| c == "speed") || !cols.iter().any(|c| c == "board_x") { + return Err("not an audio state log (no speed / board_x columns)".into()); + } + let mut rows = Vec::new(); + for (n, l) in lines.enumerate() { + let values: Result, _> = l.split('\t').map(str::parse::).collect(); + match values { + Ok(v) if v.len() == cols.len() => rows.push(Row(cols.iter().cloned().zip(v).collect())), + _ => return Err(format!("malformed line {}", n + 2)), + } + } + Ok(rows) +} + +#[cfg(test)] +pub(crate) fn read(path: &std::path::Path) -> Vec { + let text = std::fs::read_to_string(path).unwrap(); + let mut lines = text.lines(); + let cols: Vec = lines.next().unwrap().split('\t').map(str::to_owned).collect(); + lines.map(|l| Row(cols.iter().cloned().zip(l.split('\t').map(|v| v.parse::().unwrap())).collect())).collect() +} + +/// What `skate_events::observe` / `audio_state` would publish for this row. +#[derive(Default)] +pub(crate) struct Script { + pub(crate) x: f32, + air_time: f32, + pushes: u32, + family: Option, + material: Option, + jump_velocity: f32, + /// Last row's push plant (the edge `+335`). + planted: bool, + /// The bridge's `+212` (|COM v|) of the last row and the last row's COM position (the + /// fallback below), for this row's `+216`. + com_212: f32, + com_at: Option<[f32; 3]>, + /// The last row's wheel positions (`Riding::wheels_before` of this row). + wheels: Option<[[f32; 3]; 4]>, +} + +impl Script { + /// The audio state's `+216` for this row (last row's `+212`), then this row's `+212`: the + /// logged |COM v| (`com_speed`, logs since 2026-10-03 afternoon); for older logs |Δ COM + /// position| × 60 from the logged COM positions (`com_x/y/z`, the reckoning's followed point at + /// 4 decimals, so ±0.006 m/s; the graph saturates from 0.95 m/s); without either 0 (graph 1.0: + /// the impacts as logged). + fn com_216(&mut self, r: &Row) -> f32 { + let previous = self.com_212; + self.com_212 = if r.0.contains_key("com_speed") { + r.f("com_speed") + } else if r.0.contains_key("com_x") { + let at = [r.f("com_x"), r.f("com_y"), r.f("com_z")]; + let v = self.com_at.map_or(0.0, |p| { + let d = [at[0] - p[0], at[1] - p[1], at[2] - p[2]]; + (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() * 60.0 + }); + self.com_at = Some(at); + v + } else { + 0.0 + }; + previous + } + + pub(crate) fn riding(&mut self, r: &Row) -> Riding { + let speed = r.f("speed"); + let com_speed_216 = self.com_216(r); + self.x += speed / 60.0; + let state = r.i("state") as u32; + // As `skate_events::observe`: an unridden board reports no contacts (logs recorded before + // that gate still carry them). + let unridden = super::skate_events::board_unridden(state); + let mask = if unridden { 0 } else { r.i("wheels") as u32 }; + let contact: [bool; 4] = std::array::from_fn(|i| mask & (1 << i) != 0); + let wheels = contact.iter().filter(|c| **c).count() as u32; + // E2E_CONTACT_MATERIALS=1: the materials gated by contact, as the game did before 20:15. + let lines = if std::env::var("E2E_CONTACT_MATERIALS").is_ok_and(|v| v == "1") { + mask + } else if unridden { + 0 + } else if r.0.contains_key("lines") { + r.i("lines") as u32 + } else if mask != 0 { + 15 + } else { + 0 + }; + let tag = if unridden { 0 } else { r.i("tag") as u32 }; + let airborne = (200..300).contains(&state) && wheels == 0; + // `+236` as `skate_events::audio_state`. + let air_words = !std::env::var("E2E_AIR_WORDS").is_ok_and(|v| v == "0"); + self.air_time = super::skate_events::air_time_236(self.air_time, state, 1.0 / 60.0); + let grinding = r.i("grinding") != 0; + if grinding { + self.family = Some(r.i("family") as i32); + // The log's grind tag → `+692` (tag − 1, 0 → 143), as `skate_events::audio_state`. + self.material = Some(material_of_tag(r.i("grind_tag") as u32)); + } + let push = r.i("push") != 0; + // The push plant (`skate_events::push_plant`, session review #1): logs since 2026-10-03 carry + // State55 (`plant`), and the plant and its rise drive `+333 || +334` / `+335` and the bed's + // push count. Older logs (no `plant` column) use the animation's push edge (`push`) unless + // E2E_PLANT_FROM_FEET=1, which takes the plant from their foot-down bits on the ground states + // without the brake (State55 && (State57 || State56), the bridge's `+333 || +334`; a proof + // aid, not the game's input). + let new_log = r.0.contains_key("plant"); + let planted = if new_log { + Some(r.i("plant") != 0) + } else if std::env::var("E2E_PLANT_FROM_FEET").is_ok_and(|v| v == "1") && r.0.contains_key("foot_down") { + Some(r.i("foot_down") & 3 != 0 && r.i("brake") == 0 && (100..200).contains(&state)) + } else { + None + }; + let (push_planted, push_trigger) = match planted { + Some(now) => (now, now && !self.planted), + None => (push, push), + }; + self.planted = planted.unwrap_or(false); + self.pushes += u32::from(push_trigger); + let scorable = r.i("scorable"); + let jv = r.f("jv"); + if jv != 0.0 { + self.jump_velocity = skate_audio::player::state::jump_velocity(jv); + } + let feet = r.i("feet") as u32; + let h = if new_log { Self::harness(r) } else { AudioState::default() }; + let audio = AudioState { + dt: 1.0 / 60.0, + ground_speed: speed, + com_velocity: [speed, 0.0, 0.0], + com_position: [self.x, 1.0, 0.0], + board_position: [self.x, 0.1, 0.0], + board_velocity: [speed, 0.0, 0.0], + wheel_count: wheels, + wheel_contact: contact, + // The materials come from the wheel lines (`skate_events::audio_state`): logs since 20:15 + // carry their hit mask (`lines`); older logs and the scripts take every line as hitting + // while any wheel is down (retail keeps wheel 0's material on 100 % of 3-wheel frames). + wheel_material: std::array::from_fn(|i| if lines & (1 << i) != 0 { material_of_tag(tag) } else { 143 }), + // Wheel 0's pattern for the front pair, wheel 3's for the rear (logs since the seams + // port); scripted scenarios have none. + seam_pattern: if unridden { [0; 4] } else { + let (f, b) = (r.i("seam0") as u32, r.i("seam3") as u32); + std::array::from_fn(|i| if lines & (1 << i) == 0 { 0 } else if i < 2 { f } else { b }) + }, + // The wheels around wheel 0's logged position along the deck heading (track 0.2 m, + // wheelbase 0.6 m — the engine's board; only the grid crossings read them). + wheel_position: { + let (x0, z0, h) = (r.f("wheel_x"), r.f("wheel_z"), r.f("heading")); + let (ax, az, rx, rz) = (h.sin(), h.cos(), h.cos(), -h.sin()); + let offs = [(0.0, 0.0), (-0.2, 0.0), (0.0, -0.6), (-0.2, -0.6)]; + std::array::from_fn(|i| [x0 + rx * offs[i].0 + ax * offs[i].1, 0.0, z0 + rz * offs[i].0 + az * offs[i].1]) + }, + turn: r.f("turn"), + slope: r.f("slope"), + airborne, + air_time: self.air_time, + brake: r.i("brake") != 0, + manual_brake: r.i("manual") != 0, + balance: r.i("balance") != 0, + grinding, + trick_active: scorable != -1, + hippy_jump: airborne && scorable == 234, + bail: h.bail, + bail_end: h.bail_end, + // The bail, on-foot / off-board flags, the deck spin and the body regions come from the + // logs since 2026-10-03 (session review #10); older logs: off, as before. + on_foot: new_log && state == 500, + soft_wheels: h.soft_wheels, + push_planted, + push_trigger, + feet_in_deck_box: [feet & 1 != 0, feet & 2 != 0], + grind_family: self.family.unwrap_or(-1), + // `+692` = Grinds+216 − 1 (the packer `sub_827A1B78`), as the game publishes it. + grind_material: self.material.unwrap_or(143), + local: true, + jump_velocity: self.jump_velocity, + audio_trick: -1, + scorable: scorable as i32, + // The scenario's slip is the sine of the deck's turn off the roll (lateral / speed). + slip: if wheels > 0 { skate_audio::player::state::slip(r.f("slip") * speed) } else { 0.0 }, + revert: if new_log { r.i("revert") != 0 } else { state == 102 }, + offboard_308: h.offboard_308, + deck_tilt: r.f("tilt"), + deck_spin: h.deck_spin, + // `+240` / `+260` (Class_Treatment w8 / w9) from the rows' to_land / jump_height (both + // scenario kinds carry them); E2E_AIR_WORDS=0: 0 as before 2026-10-02 (no Treatments). + air_until_landing: if air_words { r.f("to_land") } else { 0.0 }, + jump_height: if air_words { r.f("jump_height") } else { 0.0 }, + // Real-play logs carry these (state_log.rs, appended columns); scripted scenarios + // leave them 0. + grind_impact: r.f("grind_impact"), + deck_impact: if unridden { 0.0 } else { r.f("deck_impact") }, + deck_material: if unridden { 143 } else { material_of_tag(r.i("deck_tag") as u32) }, + foot_speed_y: [r.f("foot_y0"), r.f("foot_y1")], + foot_speed_xz: [r.f("foot_xz0"), r.f("foot_xz1")], + // Off-board inputs (logs since 21:00; older logs: none). + foot_down: [r.i("foot_down") & 1 != 0, r.i("foot_down") & 2 != 0], + foot_material: if r.0.contains_key("foot_tag_a") { [r.i("foot_tag_a") as u32, r.i("foot_tag_b") as u32] } else { [143; 2] }, + hands_on_deck: [r.i("hands") & 1 != 0, r.i("hands") & 2 != 0], + footstep_strength: r.f("strength"), + foot_vertical_speed: [r.f("foot_vy_a"), r.f("foot_vy_b")], + // The skeleton inputs (logs since the skeleton-inputs stage; older logs: the defaults). + step_code: if r.0.contains_key("step") { r.i("step") as i32 } else { 1 }, + body_speed: r.f("body"), + limb_speed: r.f("limb"), + com_speed_216, + ..h + }; + // As `skate_events::observe`: the previous step's wheels (this row's on the first). + let wheels_before = self.wheels.unwrap_or(audio.wheel_position); + self.wheels = Some(audio.wheel_position); + Riding { + wheels_before, + board: bevy::math::Vec3::new(self.x, 0.1, 0.0), + speed, + surface: tag, + grinding, + braking: r.i("brake") != 0 && speed > 0.5, + wheels, + pushes: self.pushes, + audio, + // The loose-board inputs (logs since the skeleton-inputs stage; older: upright, no contact). + deck_up: if r.0.contains_key("deck_up") { r.f("deck_up") } else { 1.0 }, + deck_contact: r.i("deck_contact") != 0, + deck_material: material_of_tag(r.i("deck_tag") as u32), + } + } +} + +impl Script { + /// The state-log columns appended 2026-10-03 (session review #10). + fn harness(r: &Row) -> AudioState { + AudioState { + push_stroke: r.i("stroke") != 0, + deck_spin: r.f("deck_spin"), + deck_spin_xy: [r.f("spin_x"), r.f("spin_y")], + bail: r.i("bail") != 0, + bail_end: r.i("bail_end") != 0, + offboard_308: r.i("held") != 0, + offboard_air: r.i("offboard_air") != 0, + footplant: r.i("footplant") != 0, + soft_wheels: r.i("soft") != 0, + body_slide_flag: r.i("face") != 0, + body_impact: std::array::from_fn(|i| r.f(&format!("rimp{i}"))), + body_slide: std::array::from_fn(|i| r.f(&format!("rslide{i}"))), + body_tag: std::array::from_fn(|i| r.i(&format!("rtag{i}")) as u32), + ..AudioState::default() + } + } +} + + +/// The ghost NPC skater (spec `world-audio-hookin` §3.10; user choice 2026-10-03: a replay of the +/// user's own recorded riding): the audio states of a state log's window `[from, from + seconds)` +/// as the e2e replay publishes them (every row before the window is replayed too, so the latches +/// and rings are as they were), placed in the world: the logged board / COM / wheel positions +/// moved so the window's first board position lands on `anchor`, the velocities from the +/// positions' change per row (the log keeps positions, not velocities). Requires every row to +/// parse ([`try_read`]). +pub(crate) fn ghost_states(text: &str, from: f32, seconds: f32, anchor: [f32; 3]) -> Result, String> { + let rows = try_read(text)?; + let first = (from.max(0.0) * 60.0) as usize; + let count = (seconds.max(1.0) * 60.0) as usize; + if first >= rows.len() { + return Err(format!("the log has {:.1} s, the window starts at {from} s", rows.len() as f32 / 60.0)); + } + let end = (first + count).min(rows.len()); + let mut script = Script::default(); + let origin = [rows[first].f("board_x"), rows[first].f("board_y"), rows[first].f("board_z")]; + let place = |p: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| p[i] - origin[i] + anchor[i]) }; + let mut out = Vec::with_capacity(end - first); + let mut last: Option<([f32; 3], [f32; 3])> = None; + for (i, r) in rows[..end].iter().enumerate() { + let mut s = script.riding(r).audio; + if i < first { + continue; + } + let board = place([r.f("board_x"), r.f("board_y"), r.f("board_z")]); + let com = place([r.f("com_x"), r.f("com_y"), r.f("com_z")]); + let rate = |now: [f32; 3], then: Option<[f32; 3]>| then.map_or([0.0; 3], |t| std::array::from_fn(|k| (now[k] - t[k]) * 60.0)); + s.board_position = board; + s.board_velocity = rate(board, last.map(|l| l.0)); + s.com_position = com; + s.com_velocity = rate(com, last.map(|l| l.1)); + // The wheels around wheel 0's logged x, z along the deck heading (as the e2e replay), at + // the board's height. + let (h, w) = (r.f("heading"), place([r.f("wheel_x"), 0.0, r.f("wheel_z")])); + let (ax, az, rx, rz) = (h.sin(), h.cos(), h.cos(), -h.sin()); + let offs = [(0.0, 0.0), (-0.2, 0.0), (0.0, -0.6), (-0.2, -0.6)]; + s.wheel_position = std::array::from_fn(|k| [w[0] + rx * offs[k].0 + ax * offs[k].1, board[1], w[2] + rz * offs[k].0 + az * offs[k].1]); + s.local = false; + last = Some((board, com)); + out.push(s); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn log() -> String { + let cols = ["ms", "speed", "wheels", "tag", "state", "board_x", "board_y", "board_z", "com_x", "com_y", "com_z", "wheel_x", "wheel_z", "heading"]; + let mut t = cols.join("\t"); + for i in 0..240 { + let x = i as f32 * 0.1; + t.push_str(&format!("\n{}\t6\t15\t5\t100\t{x}\t0.1\t2\t{x}\t1\t2\t{x}\t2\t0", i as f32 * 16.7)); + } + t + } + + #[test] + fn a_ghost_window_is_placed_at_the_anchor_with_velocities() { + let states = ghost_states(&log(), 1.0, 2.0, [10.0, 0.0, -5.0]).unwrap(); + assert_eq!(states.len(), 120); + assert_eq!(states[0].board_position, [10.0, 0.0, -5.0]); + assert!((states[10].board_velocity[0] - 6.0).abs() < 1e-3); + assert_eq!(states[5].wheel_count, 4); + assert!(!states[0].local); + assert!(ghost_states(&log(), 10.0, 2.0, [0.0; 3]).is_err(), "window beyond the log"); + assert!(ghost_states("speed\tboard_x\n1\tx", 0.0, 1.0, [0.0; 3]).is_err(), "malformed"); + assert!(ghost_states("a\tb\n1\t2", 0.0, 1.0, [0.0; 3]).is_err(), "not a state log"); + } +} diff --git a/crates/skate-game/src/game_audio/world_bridge.rs b/crates/skate-game/src/game_audio/world_bridge.rs new file mode 100644 index 00000000..be206bfa --- /dev/null +++ b/crates/skate-game/src/game_audio/world_bridge.rs @@ -0,0 +1,425 @@ +//! The bridge from the engine-facing world audio components (`crate::world_audio`) to the hosts' +//! internal seams (`world_sources::WorldOwners`, `npc_skaters::NpcSkaters`), and the read-back +//! (`WorldAudioInstance`, `WorldAudioStats`). Runs before `native::mixmap_frame`; the read-back +//! after both hosts. Inert (returns at once, touches nothing) while no component exists and +//! nothing is held. +//! +//! What the bridge fills where the engine leaves a field open (retail rules from the recomp gap +//! runs G1 / G2, spec `world-audio-hookin` §7.3): +//! - velocity: [`AudioVelocity`] or the transform's change over the frame; +//! - vehicle speed: |velocity|; acceleration (`+144`): the speed change per second; +//! - ped `+68` footsteps on: the 3 nearest peds within 50 m; `+148` / `+156`: the 3-D distance to +//! the listener / 20 m; materials: 0 (retail's pavements); +//! - NPC skaters: the list order (remote players first, then `list_order`, then spawn order). +//! +//! Remote multiplayer players (non-retail extension, user decision 2026-10-03) get an +//! [`NpcSkaterAudio`] with a lite state ([`AudioState::rolling`]) from their root transform and +//! the ground's audio material under them ([`ground_material`]). +use std::collections::{HashMap, HashSet}; + +use bevy::prelude::*; +use skate_audio::world::traffic::{EngineRecord, VehicleState}; + +use super::world_sources::{PED_LIST_RADIUS, WorldHeld, WorldOwners}; +use crate::world_audio::*; + +/// Retail's footsteps-on rule: the first 3 of the nearest-first ped list (`S+68`, gap run G2; +/// `aud_speech/default` field `53364DFA09A499DD`). +pub(crate) const FOOTSTEP_PEDS: usize = 3; +/// The model's far threshold for regular peds (`S+156`, `aud_characteristics` `A27215A909135B62`). +pub(crate) const PED_FAR_THRESHOLD: f32 = 20.0; + +/// The bridge's publish step: publishers that write components each frame (the mod host, the +/// ghost) run before it. +#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct WorldAudioPublish; + +/// A ghost NPC skater (dev / mods, spec §3.10): replays recorded audio states +/// ([`super::state_replay::ghost_states`]) as its [`NpcSkaterAudio`] state, looping, 60 rows per +/// second of game time, the entity following the board. +#[derive(Component, Clone, Debug)] +pub(crate) struct GhostSkater { + pub(crate) states: Vec, + /// Rows played (fractional). + pub(crate) at: f64, +} + +impl GhostSkater { + /// Load a state log's window as a ghost (see `ghost_states`). + pub(crate) fn from_log(text: &str, from: f32, seconds: f32, anchor: [f32; 3]) -> Result { + let states = super::state_replay::ghost_states(text, from, seconds, anchor)?; + if states.is_empty() { + return Err("empty window".into()); + } + Ok(Self { states, at: 0.0 }) + } +} + +/// Advance the ghosts: the row for this frame, its one-step push pulse OR-ed over the rows the +/// frame passed (as `skate_events::latch_pulses` does for the local player). +fn ghost_step(time: Res