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.
This commit is contained in:
Hails committed 2026-10-03 16:21:12 -06:00
1 parent df012ec5dd
commit a4ec831a02
62 files changed
+4359 -1367

No files matched your search

+11 -2
View File
@@ -3,6 +3,8 @@
/logs/ /logs/
/.local/ /.local/
/settings/ /settings/
# TRACE_PLAY.bat's CPU trace output.
/trace-*.json
/data/ /data/
__pycache__/ __pycache__/
*.pyc *.pyc
@@ -48,8 +50,12 @@ __pycache__/
*.mus *.mus
*.mpf *.mpf
*.xma *.xma
*.wav # Decoded retail WAVs: setup and the research tools write them only into the private install
!/mods/Skyline_Drive_Mod/audio/*.wav # 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
.env.* .env.*
*.pem *.pem
@@ -100,3 +106,6 @@ __pycache__/
# Claude Code notes and skills (local only). # Claude Code notes and skills (local only).
/.claude/ /.claude/
# The world audio test mod's ghost input: the user's own recorded audio state logs (never committed).
mods/world-audio-test/logs/
+4
View File
@@ -38,6 +38,10 @@ pub const TWO_PI: f32 = std::f32::consts::TAU;
pub const INV_TWO_PI: f32 = f32::from_bits(0x3E22_F983); 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). /// The resampler's interpolation weight constant (≈ 1/65536, about one part in 2^21 off).
pub const WEIGHT: f32 = f32::from_bits(0x377F_FC9C); 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`). /// `XMVectorSin` coefficients for V³ … V²³ (`0x822F97C4` … `0x822F97EC`).
pub const SIN: [u32; 11] = [ 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, 0xBE2A_AAAB, 0x3C08_8889, 0xB950_0D01, 0x3638_EF1D, 0xB2D7_322B, 0x2F30_9231, 0xAB57_3F9F, 0x274A_963C, 0xA317_A4DA, 0x1EB8_DC78,
+2 -1
View File
@@ -177,10 +177,11 @@ mod tests {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn the_reverb_owner_gives_the_retail_free_skate_levels() { fn the_reverb_owner_gives_the_retail_free_skate_levels() {
use crate::mixmap::{MixMap, keys}; use crate::mixmap::{MixMap, keys};
let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/audio/aems/MixMapSK8.mxb")); 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 levels = |in5: i32| {
let mut m = MixMap::from_bytes(&bytes).unwrap(); let mut m = MixMap::from_bytes(&bytes).unwrap();
for _ in 0..30 { for _ in 0..30 {
+3 -2
View File
@@ -223,15 +223,16 @@ mod tests {
/// Spec worked gains (mono, focus = level = 1): A (deg), D, focus → L, C, R, Ls, Rs. /// Spec worked gains (mono, focus = level = 1): A (deg), D, focus → L, C, R, Ls, Rs.
#[test] #[test]
fn worked_gains_from_the_spec() { fn worked_gains_from_the_spec() {
use std::f32::consts::FRAC_1_SQRT_2;
let cases: &[(f32, f32, f32, [f32; 5])] = &[ let cases: &[(f32, f32, f32, [f32; 5])] = &[
(0.0, 1.0, 1.0, [0.0, 1.0, 0.0, 0.0, 0.0]), (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, [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]), (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]), (-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.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]), (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]), (-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]), (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]), (-90.0, 0.5, 1.0, [0.4927, 0.3226, 0.2477, 0.7437, 0.1970]),
+31 -15
View File
@@ -15,6 +15,8 @@ pub mod fmath;
pub mod ops; pub mod ops;
pub mod rng; pub mod rng;
pub mod symbols; pub mod symbols;
#[doc(hidden)]
pub mod synthetic;
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use std::sync::Arc; use std::sync::Arc;
@@ -145,6 +147,8 @@ pub struct Evaluator {
pub walks: u64, pub walks: u64,
/// When Some, every executed op is appended (tests and golden runs). /// When Some, every executed op is appended (tests and golden runs).
pub trace: Option<Vec<OpTrace>>, pub trace: Option<Vec<OpTrace>>,
/// The walk's snapshot of `order` (kept between walks: no allocation per walk).
walk_order: Vec<u32>,
} }
impl Default for Evaluator { impl Default for Evaluator {
@@ -173,6 +177,7 @@ impl Evaluator {
countdown: BLOCKS_PER_WALK, countdown: BLOCKS_PER_WALK,
walks: 0, walks: 0,
trace: None, trace: None,
walk_order: Vec::new(),
} }
} }
@@ -188,7 +193,10 @@ impl Evaluator {
/// must be installed first. Returns the bank id. /// must be installed first. Returns the bank id.
pub fn load_bank(&mut self, bank: Bank) -> usize { pub fn load_bank(&mut self, bank: Bank) -> usize {
let bank = Arc::new(bank); 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<LoadedModule> = bank let mut modules: Vec<LoadedModule> = bank
.modules .modules
.iter() .iter()
@@ -211,7 +219,11 @@ impl Evaluator {
self.registry.classes[c].constructors.push((id, m)); 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 id
} }
@@ -298,8 +310,8 @@ impl Evaluator {
/// Rewrite the payload of a held post (SetMemberData): only its ClassData clients rerun. /// Rewrite the payload of a held post (SetMemberData): only its ClassData clients rerun.
pub fn redeliver(&mut self, node: NodeId, payload: &[i32]) { pub fn redeliver(&mut self, node: NodeId, payload: &[i32]) {
let Some(n) = self.nodes.get(&node.0) else { return }; let Some(n) = self.nodes.get(&node.0) else { return };
for (inst, off) in n.class_data.clone() { for &(inst, off) in &n.class_data {
if let Some(i) = self.instance_mut(inst) { if let Some(Some(i)) = self.instances.get_mut(inst as usize) {
let count = u8_at(&i.mem, off as usize + 16) as usize; let count = u8_at(&i.mem, off as usize + 16) as usize;
for k in 0..count { for k in 0..count {
put_i32(&mut i.mem, off as usize + 20 + 4 * k, payload.get(k).copied().unwrap_or(0)); 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. /// Release a post: its instances see the ClassDestructor pulse; the poster's reference drops.
pub fn release(&mut self, node: NodeId) { pub fn release(&mut self, node: NodeId) {
let Some(n) = self.nodes.get_mut(&node.0) else { return }; let Some(n) = self.nodes.get(&node.0) else { return };
let destructors = n.destructors.clone(); for &(inst, off) in &n.destructors {
for (inst, off) in destructors { if let Some(Some(i)) = self.instances.get_mut(inst as usize) {
if let Some(i) = self.instance_mut(inst) {
put_i32(&mut i.mem, off as usize + 16, 1); 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 }; let Some(Some(lb)) = self.banks.get_mut(inst.bank) else { return };
lb.modules[inst.module].live -= 1; lb.modules[inst.module].live -= 1;
let module = &lb.bank.modules[inst.module]; // The bank's handle (a reference count, no copy of the lists): `self.release` below needs
let players: Vec<u32> = module.players().to_vec(); // `self` while the module's object lists are read.
let controllers: Vec<u32> = module.controllers().to_vec(); let bank = lb.bank.clone();
for p in players { let module = &bank.modules[inst.module];
for &p in module.players() {
let voice = u32_at(&inst.mem, p as usize + 8); let voice = u32_at(&inst.mem, p as usize + 8);
if voice != 0 { if voice != 0 {
host.release(voice); host.release(voice);
} }
} }
for c in controllers { for &c in module.controllers() {
let child = u32_at(&inst.mem, c as usize + 8); let child = u32_at(&inst.mem, c as usize + 8);
if child != 0 { if child != 0 {
self.release(NodeId(child)); self.release(NodeId(child));
@@ -475,12 +487,16 @@ impl Evaluator {
/// Run every instance's program once, newest first. /// Run every instance's program once, newest first.
pub fn walk(&mut self, host: &mut dyn VoiceHost) { pub fn walk(&mut self, host: &mut dyn VoiceHost) {
let order: Vec<u32> = self.order.iter().copied().collect(); // The order as the walk starts (instances created or destroyed meanwhile don't change it).
for id in order { 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() { if self.instance(id).is_some() {
self.run(id, host); self.run(id, host);
} }
} }
self.walk_order = order;
self.walks += 1; self.walks += 1;
} }
+219
View File
@@ -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<u32>,
pub template: Vec<u8>,
/// (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<u32>,
}
/// 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<u32>,
}
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<u8>, 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<u8> {
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],
}
}
+1 -215
View File
@@ -2,221 +2,7 @@
//! query / restart edge, release → destroy, capacity, and the function → global round trip with //! query / restart edge, release → destroy, capacity, and the function → global round trip with
//! its walk-order rule (spec §2, §5). //! its walk-order rule (spec §2, §5).
use super::*; use super::*;
use crate::formats::Project; use super::synthetic::*;
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<u32>,
template: Vec<u8>,
/// (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<u32>,
}
/// 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<u32>,
}
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<u8>, 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<u8> {
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],
}
}
#[derive(Default)] #[derive(Default)]
struct Log { struct Log {
+2 -1
View File
@@ -172,7 +172,8 @@ impl Bank {
class_data_state: None, class_data_state: None,
function_states: Vec::new(), 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")); 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)?; module.program = decode_program(name, d, module.program_offset as usize, module.data_size)?;
+2 -2
View File
@@ -225,11 +225,11 @@ mod tests {
/// The generated tables against the retail image's, when the private TU3 image is present. /// The generated tables against the retail image's, when the private TU3 image is present.
#[test] #[test]
#[ignore = "needs the private install data"]
fn tables_match_the_image_when_present() { fn tables_match_the_image_when_present() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../.local/tu3-image/default_82000000_011B0000.bin"); let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../.local/tu3-image/default_82000000_011B0000.bin");
let Ok(img) = std::fs::read(path) else { let Ok(img) = std::fs::read(path) else {
eprintln!("skipped: no TU3 image at {path}"); panic!("missing private data: no TU3 image at {path}");
return;
}; };
let word = |a: u32| u32::from_be_bytes(img[(a - 0x8200_0000) as usize..][..4].try_into().unwrap()); let word = |a: u32| u32::from_be_bytes(img[(a - 0x8200_0000) as usize..][..4].try_into().unwrap());
let t = Tables::generate(); let t = Tables::generate();
+4 -4
View File
@@ -91,10 +91,10 @@ fn disc_mxb() -> Option<Vec<u8>> {
/// The retail file instantiates exactly the spec's census (§2.4). /// The retail file instantiates exactly the spec's census (§2.4).
#[test] #[test]
#[ignore = "needs the private install data"]
fn disc_mixmap_census() { fn disc_mixmap_census() {
let Some(bytes) = disc_mxb() else { let Some(bytes) = disc_mxb() else {
eprintln!("skipped: no MixMapSK8.mxb in the dev install or .local/skate3-disc"); panic!("missing private data: no MixMapSK8.mxb in the dev install or .local/skate3-disc");
return;
}; };
let file = MixMapFile::parse(&bytes).unwrap(); let file = MixMapFile::parse(&bytes).unwrap();
assert_eq!(file.slots.len(), 14); 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 /// Free-skate steady state (Master.in1–4 = 32767, the rest 0): the spec's emitter and ambience
/// outputs (§6.2, §6.3). /// outputs (§6.2, §6.3).
#[test] #[test]
#[ignore = "needs the private install data"]
fn disc_mixmap_free_skate_outputs() { fn disc_mixmap_free_skate_outputs() {
let Some(bytes) = disc_mxb() else { let Some(bytes) = disc_mxb() else {
eprintln!("skipped: no MixMapSK8.mxb"); panic!("missing private data: no MixMapSK8.mxb");
return;
}; };
let mut m = MixMap::from_bytes(&bytes).unwrap(); let mut m = MixMap::from_bytes(&bytes).unwrap();
for id in 1..=4 { for id in 1..=4 {
+5 -3
View File
@@ -328,7 +328,7 @@ pub struct CollisionManager {
pub posts: u64, pub posts: u64,
pub starts: u64, pub starts: u64,
/// Play the voices through their Collision SubMix (`sub_824D25E0`: mono, env send, eEQChain /// 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, pub submix: bool,
} }
@@ -587,10 +587,11 @@ mod tests {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn a_message_starts_two_voices_then_follows_the_outputs() { fn a_message_starts_two_voices_then_follows_the_outputs() {
let t = tuning(); let t = tuning();
let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/audio/aems/MixMapSK8.mxb")); 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 m = MixMap::from_bytes(&bytes).unwrap();
let mut c = CollisionManager::default(); let mut c = CollisionManager::default();
let mut h = Log::default(); let mut h = Log::default();
@@ -641,10 +642,11 @@ mod tests {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn with_the_submix_each_voice_starts_on_its_mono_bus_at_the_category_send() { fn with_the_submix_each_voice_starts_on_its_mono_bus_at_the_category_send() {
let t = tuning(); let t = tuning();
let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/audio/aems/MixMapSK8.mxb")); 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 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 msg = Message { material: [95, 9], tier: [1, 1], position: [0.0; 3], level: [20000, 30000], local: true };
let run = |submix: bool| { let run = |submix: bool| {
+2 -1
View File
@@ -96,7 +96,8 @@ pub struct Grind {
pending: Vec<GrindHit>, pending: Vec<GrindHit>,
/// Rail level(5) as of the last update: the sounds' env send (`sub_82498140`). /// Rail level(5) as of the last update: the sounds' env send (`sub_82498140`).
env_level: i32, 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, pub onoff: bool,
/// On / off sounds started (diagnostics). /// On / off sounds started (diagnostics).
pub hit_starts: u64, pub hit_starts: u64,
+6 -6
View File
@@ -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 /// 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 /// 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 /// 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<usize>, pub body_calls: Option<usize>,
/// The deck-impact poster (`sub_824BD000`) the same way: `Some(n)` runs it n times in this /// 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 /// 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<usize>, pub deck_calls: Option<usize>,
pub deck_posts: u64, pub deck_posts: u64,
pub deck_digest: u64, pub deck_digest: u64,
/// The push foot's plant / lift (`sub_824BBB28`; `SKATE_AEMS_PLANT_LIFT=0` off) and the body /// The push foot's plant / lift (`sub_824BBB28`) and the body poster (`sub_824BC188`). The game's
/// poster (`sub_824BC188`; `SKATE_AEMS_BODY_IMPACTS=0` off). The host turns them on. /// host always turns them on; off only isolates the rest in tests.
pub plant_lift_on: bool, pub plant_lift_on: bool,
pub body_on: bool, pub body_on: bool,
/// The bridge's speed graph on the region impacts ([`ContactsTuning::body_speed_curve`] at /// 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 /// [`AudioState::com_speed_216`]; off: the impacts as the conditioner wrote them, the tests).
/// impacts as the conditioner wrote them, before 2026-10-03). The host turns it on. /// The game's host always turns it on.
pub body_speed_on: bool, pub body_speed_on: bool,
/// Diagnostics (the e2e harness): when `Some`, every body-poster message with its region and /// Diagnostics (the e2e harness): when `Some`, every body-poster message with its region and
/// the impact the poster read. /// the impact the poster read.
+2 -1
View File
@@ -133,10 +133,11 @@ mod tests {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn rates_are_signed_by_closing_and_flag_their_sign_flips() { 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")) { let bytes = match std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/audio/aems/MixMapSK8.mxb")) {
Ok(b) => b, 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 m = MixMap::from_bytes(&bytes).unwrap();
let key = crate::mixmap::keys::obj_pos2(0); let key = crate::mixmap::keys::obj_pos2(0);
+6 -3
View File
@@ -535,8 +535,9 @@ mod tests {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn grid_hits_once_per_axle_per_line_and_toggle_the_trigger_word() { 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 t = tuning();
let mut k = Seams::default(); let mut k = Seams::default();
let first = k.process(&rolling(0.1, 1), &t, &mut m); let first = k.process(&rolling(0.1, 1), &t, &mut m);
@@ -574,8 +575,9 @@ mod tests {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn a_material_change_fires_with_the_transition_surface_and_skips_the_grid() { 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 t = tuning();
let mut k = Seams::default(); let mut k = Seams::default();
k.process(&rolling(0.1, 1), &t, &mut m); k.process(&rolling(0.1, 1), &t, &mut m);
@@ -592,8 +594,9 @@ mod tests {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn distance_mode_fires_front_then_rear() { 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 t = tuning();
let mut k = Seams::default(); let mut k = Seams::default();
k.process(&rolling(0.0, 10), &t, &mut m); k.process(&rolling(0.0, 10), &t, &mut m);
+77
View File
@@ -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 { 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|. /// `+212`: |COM velocity|.
pub fn com_speed(&self) -> f32 { pub fn com_speed(&self) -> f32 {
let [x, y, z] = self.com_velocity; let [x, y, z] = self.com_velocity;
+8
View File
@@ -201,6 +201,14 @@ impl Runtime {
if self.read >= self.stereo.len() { if self.read >= self.stereo.len() {
self.render_block(); self.render_block();
routes::output_stereo(&self.bus, &mut self.stereo); 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; self.read = 0;
} }
let n = (self.stereo.len() - self.read).min(out.len() - at); let n = (self.stereo.len() - self.read).min(out.len() - at);
+4 -2
View File
@@ -61,8 +61,9 @@ fn disc_bank(name: &str) -> Option<Vec<u8>> {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn disc_banks_parse_and_the_contact_ids_resolve() { 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(); let bank = SpliceBank::parse(&d).unwrap();
assert!(bank.records.len() > 800 && !bank.containers.is_empty()); assert!(bank.records.len() > 800 && !bank.containers.is_empty());
let records = bank.records.len(); let records = bank.records.len();
@@ -91,8 +92,9 @@ fn tone(seconds: f32) -> Arc<Pcm> {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn a_record_plays_its_layers_and_ends_after_its_length() { 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 bank = SpliceBank::parse(&d).unwrap();
let pcm = (0..bank.samples).map(|_| Some(tone(0.5))).collect(); let pcm = (0..bank.samples).map(|_| Some(tone(0.5))).collect();
let mut mixer = Mixer::new(); let mut mixer = Mixer::new();
+14 -2
View File
@@ -100,6 +100,17 @@ impl Default for Slots {
} }
impl 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. /// The instance an NPC skater holds.
pub fn instance(&self, id: u64) -> Option<u32> { pub fn instance(&self, id: u64) -> Option<u32> {
self.holders.iter().position(|h| *h == Some(id)).map(|i| i as u32 + 1) self.holders.iter().position(|h| *h == Some(id)).map(|i| i as u32 + 1)
@@ -188,7 +199,7 @@ pub struct NpcSkater {
impl NpcSkater { impl NpcSkater {
/// `grind_onoff` / `plant_lift` / `body`: the session-review ports, as the local player's /// `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 { pub fn new(instance: u32, parts: Parts, grind_onoff: bool, plant_lift: bool, body: bool) -> Self {
let mut grind = Grind::default(); let mut grind = Grind::default();
grind.onoff = grind_onoff; grind.onoff = grind_onoff;
@@ -377,10 +388,11 @@ mod tests {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn npc_physics_in13_slews_toward_the_relative_speed() { 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")) { let bytes = match std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/audio/aems/MixMapSK8.mxb")) {
Ok(b) => b, 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 m = MixMap::from_bytes(&bytes).unwrap();
let mut npc = NpcSkater::new(1, Parts::default(), false, false, false); let mut npc = NpcSkater::new(1, Parts::default(), false, false, false);
+6 -6
View File
@@ -30,10 +30,10 @@ fn load(dir: &PathBuf) -> (Vec<Project>, Vec<Bank>) {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn every_disc_bank_parses_and_matches_the_spec_census() { fn every_disc_bank_parses_and_matches_the_spec_census() {
let Some(dir) = banks_dir() else { let Some(dir) = banks_dir() else {
eprintln!("skipped: no extracted banks"); panic!("missing private data: no extracted banks");
return;
}; };
let (projects, banks) = load(&dir); let (projects, banks) = load(&dir);
assert_eq!(projects.len(), 9); assert_eq!(projects.len(), 9);
@@ -104,10 +104,10 @@ fn every_disc_bank_parses_and_matches_the_spec_census() {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn exports_resolve_like_retail() { fn exports_resolve_like_retail() {
let Some(dir) = banks_dir() else { let Some(dir) = banks_dir() else {
eprintln!("skipped: no extracted banks"); panic!("missing private data: no extracted banks");
return;
}; };
let (projects, banks) = load(&dir); let (projects, banks) = load(&dir);
let mut eval = Evaluator::new(); 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 /// child ended itself; releasing the parent releases the child, and every instance and node goes
/// away. /// away.
#[test] #[test]
#[ignore = "needs the private install data"]
fn tazer_control_class_owns_and_releases_its_child() { fn tazer_control_class_owns_and_releases_its_child() {
let Some(dir) = banks_dir() else { let Some(dir) = banks_dir() else {
eprintln!("skipped: no extracted banks"); panic!("missing private data: no extracted banks");
return;
}; };
let mut eval = Evaluator::new(); let mut eval = Evaluator::new();
for name in std::fs::read_to_string(dir.join("csi_order.txt")).unwrap().lines() { for name in std::fs::read_to_string(dir.join("csi_order.txt")).unwrap().lines() {
+3 -4
View File
@@ -4,11 +4,11 @@
use skate_audio::grain::GrainFile; use skate_audio::grain::GrainFile;
#[test] #[test]
#[ignore = "needs the private install data"]
fn every_retail_grain_parses_with_a_single_entry_seek_table() { fn every_retail_grain_parses_with_a_single_entry_seek_table() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/audio/grains"); let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/audio/grains");
let Ok(entries) = std::fs::read_dir(dir) else { let Ok(entries) = std::fs::read_dir(dir) else {
eprintln!("skipped: no {dir}"); panic!("missing private data: no {dir}");
return;
}; };
let mut seen = Vec::new(); let mut seen = Vec::new();
for entry in entries.flatten() { 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)); seen.push((name, g.stream.samples, g.duration));
} }
if seen.is_empty() { if seen.is_empty() {
eprintln!("skipped: no .grain files in {dir} (run stage_grain_mixmap.py or setup)"); panic!("missing private data: no .grain files in {dir} (run stage_grain_mixmap.py or setup)");
return;
} }
seen.sort_by(|a, b| a.0.cmp(&b.0)); seen.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(seen.len(), 14); assert_eq!(seen.len(), 14);
+6 -6
View File
@@ -31,10 +31,10 @@ fn floats(words: &[&str]) -> Vec<f32> {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn resample_is_bit_exact_with_the_retail_kernel() { fn resample_is_bit_exact_with_the_retail_kernel() {
let Some(text) = vectors() else { let Some(text) = vectors() else {
eprintln!("skipped: no oracle vectors"); panic!("missing private data: no oracle vectors");
return;
}; };
let src = signal(16384, 7); let src = signal(16384, 7);
let (mut cases, mut samples, mut exact) = (0, 0, 0); let (mut cases, mut samples, mut exact) = (0, 0, 0);
@@ -61,10 +61,10 @@ fn resample_is_bit_exact_with_the_retail_kernel() {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn lowpass_coefficients_and_kernel_agree() { fn lowpass_coefficients_and_kernel_agree() {
let Some(text) = vectors() else { let Some(text) = vectors() else {
eprintln!("skipped: no oracle vectors"); panic!("missing private data: no oracle vectors");
return;
}; };
let mut poc_coeffs = std::collections::HashMap::new(); let mut poc_coeffs = std::collections::HashMap::new();
let (mut words, mut exact_words, mut max_ulps) = (0, 0, 0u32); let (mut words, mut exact_words, mut max_ulps) = (0, 0, 0u32);
@@ -185,10 +185,10 @@ fn fit_biquad_association() {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn gain_ramp_is_bit_exact_with_the_retail_kernel() { fn gain_ramp_is_bit_exact_with_the_retail_kernel() {
let Some(text) = vectors() else { let Some(text) = vectors() else {
eprintln!("skipped: no oracle vectors"); panic!("missing private data: no oracle vectors");
return;
}; };
let input = signal(256, 23); let input = signal(256, 23);
let (mut samples, mut exact, mut max_ulps) = (0, 0, 0u64); let (mut samples, mut exact, mut max_ulps) = (0, 0, 0u64);
+4 -3
View File
@@ -248,8 +248,9 @@ fn bank_gains<'a>(r: &'a Run, bank: &str) -> Vec<f32> {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn offboard_clothing_and_hands_play_their_retail_banks() { 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, // 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. // 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)); 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] #[test]
#[ignore] #[ignore]
fn footstep_patch_sample_groups() { fn footstep_patch_sample_groups() {
let Some(_) = harness() else { return eprintln!("skipped") }; let Some(_) = harness() else { panic!("missing private data") };
let base: Vec<i32> = 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 base: Vec<i32> = 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<i32>)> = vec![ let variants: Vec<(&str, usize, Vec<i32>)> = vec![
("surface w16", 16, (1..=7).collect()), ("surface w16", 16, (1..=7).collect()),
@@ -373,7 +374,7 @@ fn footstep_patch_sample_groups() {
#[test] #[test]
#[ignore] #[ignore]
fn owner_outputs_on_foot_and_on_board() { 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)] { 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 m = MixMap::from_bytes(&h.mxb).unwrap();
let mut physics = inputs::Physics::default(); let mut physics = inputs::Physics::default();
+19 -15
View File
@@ -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 /// 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. /// voices stay within unity and the flip whooshes land within 3 dB of retail's 0.35.
#[test] #[test]
#[ignore = "needs the private install data"]
fn tricks_play_their_retail_banks() { fn tricks_play_their_retail_banks() {
// Flips spin about the deck's At row, shove-its about Up, the ollie pitches about Ri. // Flips spin about the deck's At row, shove-its about Up, the ollie pitches about Ri.
let cases = [ let cases = [
@@ -257,7 +258,7 @@ fn tricks_play_their_retail_banks() {
let mut whooshes = Vec::new(); let mut whooshes = Vec::new();
for (name, id, id2, spin) in cases { for (name, id, id2, spin) in cases {
let Some(r) = run_tricks(150, |f| trick_state(f, id, id2, 13, 36, spin)) else { 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<String> = r.posts.iter().map(|(f, c, w)| format!("{f}:{c}:{}", if *c == "Class_Flips" { w[11] } else { w[9] })).collect(); let posts: Vec<String> = 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 { 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 /// 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. /// use) peaks at the gain the same sample reaches in the second.
#[test] #[test]
#[ignore = "needs the private install data"]
fn the_first_flip_sounds_like_the_second() { 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 { 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"] { for bank in ["Sk8_Air_Flip_Tricks", "Foley_Cloth"] {
let v = &r.voices[bank]; 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 /// with the `B+164` flag of PR #4's capture set, slots 0–12 (retail never plays them: the reset
/// values stay). /// values stay).
#[test] #[test]
#[ignore = "needs the private install data"]
fn treatment_plays_before_the_landing_at_the_retail_level() { fn treatment_plays_before_the_landing_at_the_retail_level() {
let Some((starts, posts)) = run_treatment(TreatmentGlobals::default()) else { 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]; let landings = [30 + 13 + 36, 230 + 13 + 36];
println!("Treatments voices (start frame, slot, peak gain), landings at frames {landings:?}:\n {starts:?}"); 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 { for (name, p) in profiles {
let Some((starts, _)) = run_treatment_with(TreatmentGlobals::default(), p) else { 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(); let slots: Vec<_> = starts.iter().filter(|v| v.1 >= 15).collect();
println!("{name:32} slots ≥ 15: {slots:?}"); 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] { 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 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 { 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); println!("w8 {v:5.2} s: {:?}", starts);
} }
@@ -429,7 +432,7 @@ fn treatment_slots_16_17_by_forced_word() {
} }
} }
for (word, value) in cases { 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 (t, g) = (TreatmentTuning::default(), Globals::default());
let mut k = Treatment::default(); let mut k = Treatment::default();
let mut starts: Vec<(usize, u16)> = Vec::new(); let mut starts: Vec<(usize, u16)> = Vec::new();
@@ -593,8 +596,8 @@ fn replay_treat(rows: &[TreatRow], shape: impl Fn(&AudioState, f64) -> AudioStat
#[test] #[test]
#[ignore = "diagnostic"] #[ignore = "diagnostic"]
fn treatment_replays_the_recomp_capture() { fn treatment_replays_the_recomp_capture() {
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 Some(voices) = replay_treat(&rows, |s, _| s.clone()) else { return eprintln!("skipped: no Treatments bank") }; let Some(voices) = replay_treat(&rows, |s, _| s.clone()) else { panic!("missing private data: no Treatments bank") };
let airs = treat_airs(&rows); let airs = treat_airs(&rows);
for &(a, b) in &airs { for &(a, b) in &airs {
let inside: Vec<_> = voices let inside: Vec<_> = voices
@@ -699,7 +702,7 @@ fn replay_treat_rows(rows: &[TreatRow], from: f64, to: f64, phase_ms: f64, stall
#[test] #[test]
#[ignore = "diagnostic"] #[ignore = "diagnostic"]
fn treatment_airs_by_walk_phase() { 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( let windows: Vec<(f64, f64, Option<(f64, f64)>)> = std::env::var("TREAT_AIRS").ok().map_or(
vec![ vec![
(101_400.0, 104_500.0, None), (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); 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:?}:"); println!("window {from}..{to} (first air row {first_air}), audio stall {stall:?}:");
for p in (0..32).step_by(2) { 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(); 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:?}"); 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 /// 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). /// (the session and the Treatments bank).
#[test] #[test]
#[ignore = "needs the private install data"]
fn treatment_replay_of_the_recomp_capture() { fn treatment_replay_of_the_recomp_capture() {
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 Some(voices) = replay_treat(&rows, |s, _| s.clone()) else { return eprintln!("skipped: no Treatments bank") }; 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(); let count = |slot: u16| voices.iter().filter(|v| v.slot == slot).count();
for (slot, recomp) in RECOMP_TREATMENT_VOICES { for (slot, recomp) in RECOMP_TREATMENT_VOICES {
println!("slot {slot}: {} voices (recomp {recomp})", count(slot)); 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; 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 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 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) // (ms, ground speed m/s, air)
let rows: Vec<(f64, f32, bool)> = text let rows: Vec<(f64, f32, bool)> = text
.lines() .lines()
@@ -794,9 +798,9 @@ fn sense_of_speed_replay_of_the_recomp_speeds() {
}) })
.collect(); .collect();
if rows.is_empty() { 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 k = SenseOfSpeed::default();
let mut voices: HashMap<u32, (u16, f64, f32)> = HashMap::new(); let mut voices: HashMap<u32, (u16, f64, f32)> = HashMap::new();
let (t0, t1) = (rows[0].0, rows[rows.len() - 1].0); let (t0, t1) = (rows[0].0, rows[rows.len() - 1].0);
+57 -5
View File
@@ -7,7 +7,6 @@
use std::alloc::{GlobalAlloc, Layout, System}; use std::alloc::{GlobalAlloc, Layout, System};
use std::cell::Cell; use std::cell::Cell;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use skate_audio::bus::env::{DEFAULT_PRESET, Preset}; use skate_audio::bus::env::{DEFAULT_PRESET, Preset};
use skate_audio::bus::flange::FlangePreset; use skate_audio::bus::flange::FlangePreset;
@@ -19,15 +18,24 @@ use skate_audio::runtime::Runtime;
struct Counting; struct Counting;
static ALLOCATIONS: AtomicU64 = AtomicU64::new(0);
thread_local! { thread_local! {
static COUNTING: Cell<bool> = const { Cell::new(false) }; static COUNTING: Cell<bool> = const { Cell::new(false) };
/// This thread's counted allocations (each test reads its own).
static ALLOCATIONS: Cell<u64> = 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 impl GlobalAlloc for Counting {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 { unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
if COUNTING.try_with(Cell::get).unwrap_or(false) { if COUNTING.try_with(Cell::get).unwrap_or(false) {
ALLOCATIONS.fetch_add(1, Ordering::Relaxed); count_one();
} }
unsafe { System.alloc(layout) } 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 { unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
if COUNTING.try_with(Cell::get).unwrap_or(false) { if COUNTING.try_with(Cell::get).unwrap_or(false) {
ALLOCATIONS.fetch_add(1, Ordering::Relaxed); count_one();
} }
unsafe { System.realloc(ptr, layout, new_size) } unsafe { System.realloc(ptr, layout, new_size) }
} }
@@ -84,6 +92,7 @@ fn scene() -> (Runtime, Vec<u32>) {
#[test] #[test]
fn steady_state_render_does_not_allocate() { fn steady_state_render_does_not_allocate() {
let (mut rt, ids) = scene(); let (mut rt, ids) = scene();
let before = allocations();
let mut measured = 0; let mut measured = 0;
for block in 0..400usize { for block in 0..400usize {
if block % 6 == 0 { if block % 6 == 0 {
@@ -103,7 +112,50 @@ fn steady_state_render_does_not_allocate() {
COUNTING.with(|c| c.set(false)); COUNTING.with(|c| c.set(false));
measured += usize::from(count); 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()); 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"); 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");
}
+20 -13
View File
@@ -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 /// 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`). /// list the bank slots opened. Diagnostic print (`--nocapture`).
#[test] #[test]
#[ignore = "needs the private install data"]
fn class_rolling_selector_census() { 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…"); println!("selector surface speed: bank slots…");
for selector in 0..16 { for selector in 0..16 {
for surface in [3, 7, 8, 10, 11, 12, 13] { 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). /// 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. /// Asserts that something plays and that no voice exceeds unity.
#[test] #[test]
#[ignore = "needs the private install data"]
fn rolling_layers_play_their_retail_banks() { 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() }; let mut tuning = PlayerTuning { surface_table: surface_table(), ..Default::default() };
if tuning.surface_table.len() < 95 { 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); tuning.surface_table.truncate(95);
// Materials (tag − 1) per rolling surface: 3 asphalt_smooth (tag 1), 7 (tag 10), 8 (tag 8), // 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) (s, pushes && f % 90 == 30)
}) else { }) 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 { for (bank, g) in &r.gains {
let audible: Vec<f32> = g.iter().copied().filter(|&x| x > 0.0).collect(); let audible: Vec<f32> = 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`. /// c_board_slide: the loose board (state `+780`) through `board_scrapes`.
#[test] #[test]
#[ignore = "needs the private install data"]
fn board_slide_plays_board_scrapes() { 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(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 { return eprintln!("skipped: board_scrapes not decoded") }; 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 m = MixMap::from_bytes(&mxb).unwrap();
let mut physics = skate_audio::player::inputs::Physics::default(); let mut physics = skate_audio::player::inputs::Physics::default();
let mut slide = BoardSlide::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, /// 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). /// 11 0.035, 1 0.026, 9 0.027, 6 0.022, 10 0.018).
#[test] #[test]
#[ignore = "needs the private install data"]
fn rattle_samples_by_surface_and_speed() { 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 …"); println!("surface speed: slot peak …");
for surface in 0..6 { for surface in 0..6 {
let mut line = String::new(); let mut line = String::new();
for speed in [0, 1500, 4000, 7000, 10000] { 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 m = MixMap::from_bytes(&mxb).unwrap();
let mut physics = skate_audio::player::inputs::Physics::default(); let mut physics = skate_audio::player::inputs::Physics::default();
let mut posts = 0; 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). /// The SkateBoard outputs these layers read, by speed (straight rolling, 4 wheels; and in the air).
#[test] #[test]
#[ignore = "needs the private install data"]
fn skateboard_outputs_by_speed() { 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"); 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 air in [false, true] {
for kmh in [0.0f32, 3.0, 8.0, 15.0, 25.0, 40.0] { 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 /// 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). /// air at the same speed (listening report: rolling continues in jumps).
#[test] #[test]
#[ignore = "needs the private install data"]
fn sense_of_speed_levels_ground_vs_air() { 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 air in [false, true] {
for kmh in [25.0f32, 31.0, 40.0] { for kmh in [25.0f32, 31.0, 40.0] {
let mut m = MixMap::from_bytes(&mxb).unwrap(); 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 /// 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. /// (level(6)/(7)/(9)/(1) are 0 without wheels). Prints the per-bank gain before / after takeoff.
#[test] #[test]
#[ignore = "needs the private install data"]
fn layers_go_quiet_after_takeoff() { 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() }; let mut tuning = PlayerTuning { surface_table: surface_table(), ..Default::default() };
if tuning.surface_table.len() < 95 { if tuning.surface_table.len() < 95 {
return eprintln!("skipped: no surface map"); panic!("missing private data: no surface map");
} }
tuning.surface_table.truncate(95); tuning.surface_table.truncate(95);
for material in [0u32, 7] { for material in [0u32, 7] {
let mut ground = BTreeMap::<&str, f32>::new(); let mut ground = BTreeMap::<&str, f32>::new();
let mut air = 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 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 m = MixMap::from_bytes(&mxb).unwrap();
let mut physics = skate_audio::player::inputs::Physics::default(); let mut physics = skate_audio::player::inputs::Physics::default();
let mut pos = [ObjPos::default(); 2]; let mut pos = [ObjPos::default(); 2];
+8 -4
View File
@@ -229,11 +229,12 @@ fn print_drive(name: &str, d: &Drive) {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn a_car_drives_past_through_its_own_engine_bank() { 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 // 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. // 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 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); 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 // Bank selection: every engine voice that sounds is C01's (the other banks' programs destroy
// their instances on the patch word). // their instances on the patch word).
@@ -266,13 +267,14 @@ fn a_car_drives_past_through_its_own_engine_bank() {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn engine_banks_answer_only_their_patch() { fn engine_banks_answer_only_their_patch() {
// Each patch through all eight loaded banks: which bank keeps voices (retail: the eight // 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). // banks are loaded together and every post sounds in exactly one).
let mut table = Vec::new(); let mut table = Vec::new();
for patch in 0..=9 { for patch in 0..=9 {
let record = EngineRecord { patch, ..Default::default() }; 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<String> = d.rows.iter().flat_map(|r| r.7.keys().filter(|b| b.starts_with("C0")).cloned()).collect(); let banks: std::collections::BTreeSet<String> = d.rows.iter().flat_map(|r| r.7.keys().filter(|b| b.starts_with("C0")).cloned()).collect();
table.push((patch, banks)); table.push((patch, banks));
} }
@@ -288,8 +290,9 @@ fn engine_banks_answer_only_their_patch() {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn a_ped_walks_past_and_steps_on_each_plant() { 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 l = listener();
let tuning = PedFootstepTuning::default(); let tuning = PedFootstepTuning::default();
let mut pt = PlayerTuning::default(); let mut pt = PlayerTuning::default();
@@ -354,9 +357,10 @@ fn speech_from_install() -> Option<(skate_audio::world::speech::SpeechIndex, Vec
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn a_bumped_ped_warns_with_a_line_of_its_voice() { fn a_bumped_ped_warns_with_a_line_of_its_voice() {
use skate_audio::world::speech::{SPEECH_BANK, Want, choose, reaction_cues}; 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). // 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 want in [Want::Warn, Want::SlamReaction, Want::NearbyCollisionReaction, Want::NearbySkaterTrick] {
for cue in reaction_cues(want) { for cue in reaction_cues(want) {
+5 -1
View File
@@ -240,7 +240,7 @@ macro_rules! data {
() => { () => {
match load() { match load() {
Some(d) => d, 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] #[test]
#[ignore = "needs the private install data"]
fn flag_one_names_the_far_lines_and_two_the_near_ones() { fn flag_one_names_the_far_lines_and_two_the_near_ones() {
let d = data!(); let d = data!();
let (mut agree, mut against) = (0, 0); let (mut agree, mut against) = (0, 0);
@@ -274,6 +275,7 @@ fn flag_one_names_the_far_lines_and_two_the_near_ones() {
} }
#[test] #[test]
#[ignore = "needs the private install data"]
fn voices_map_to_their_type_and_variant_bits() { fn voices_map_to_their_type_and_variant_bits() {
let d = data!(); 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))] { 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] #[test]
#[ignore = "needs the private install data"]
fn a_bumped_business_man_warns_far_or_near_and_cycles_his_takes() { fn a_bumped_business_man_warns_far_or_near_and_cycles_his_takes() {
let d = data!(); let d = data!();
let mut lib = Library::new(d.headers.clone()); 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] #[test]
#[ignore = "needs the private install data"]
fn every_free_roam_record_resolves_or_fails_closed() { fn every_free_roam_record_resolves_or_fails_closed() {
let d = data!(); let d = data!();
let mut missing = 0; let mut missing = 0;
+36 -308
View File
@@ -15,255 +15,11 @@ use std::sync::{Arc, Mutex};
use skate_audio::formats::{Bank, Project}; use skate_audio::formats::{Bank, Project};
use skate_audio::mixmap::{MixMap, keys}; 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 skate_audio::runtime::Runtime;
use super::player_audio::{BANKS, PlayerAudio}; use super::player_audio::{BANKS, PlayerAudio};
use super::skate_events::Riding;
struct Row(HashMap<String, f64>); use super::state_replay::{Script, read};
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<Row> {
let text = std::fs::read_to_string(path).unwrap();
let mut lines = text.lines();
let cols: Vec<String> = 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::<f64>().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<i32>,
material: Option<u32>,
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()
}
}
}
fn globals(m: &mut MixMap) { fn globals(m: &mut MixMap) {
for id in 1..=4 { for id in 1..=4 {
@@ -366,24 +122,14 @@ fn e2e_render() {
rt.mixer.buses.flange.set_presets(a, b); rt.mixer.buses.flange.set_presets(a, b);
} }
} }
// The FootStep SubMix graphs (E2E_FOOTSTEP_SUBMIX=0: the foot voices straight into SFX Master). // The FootStep SubMix graphs.
rt.mixer.buses.submix.enabled = !off("E2E_FOOTSTEP_SUBMIX"); rt.mixer.buses.submix.enabled = true;
// 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");
let shared = Arc::new(Mutex::new(rt)); let shared = Arc::new(Mutex::new(rt));
let mut m = MixMap::from_bytes(&mxb).unwrap(); let mut m = MixMap::from_bytes(&mxb).unwrap();
let mut p = PlayerAudio::new(library.player_tuning(), true); 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.contacts_on = contacts && std::env::var("E2E_CONTACTS").map_or(true, |v| v != "0");
p.contact_tuning = library.contacts_tuning(); p.contact_tuning = library.contacts_tuning();
p.footsteps_on = p.contacts_on && !off("E2E_FOOTSTEPS"); 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 (`<name>.ours.bodymsg.tsv`). // E2E_BODY_LOG=1: every body-poster message (`<name>.ours.bodymsg.tsv`).
let body_log = std::env::var("E2E_BODY_LOG").is_ok_and(|v| v == "1"); let body_log = std::env::var("E2E_BODY_LOG").is_ok_and(|v| v == "1");
p.set_body_log(body_log); 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.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]); (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"); 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 // E2E_SLEW_LOG=1: the bed's turn input, |COM v|, special, I and brake per call
// (`<name>.ours.slew.tsv`). // (`<name>.ours.slew.tsv`).
let mut slew_log = std::env::var("E2E_SLEW_LOG").is_ok_and(|v| v == "1").then(|| { 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 w
}); });
let seams = skate_audio::player::tuning::PlayerTuning { seam_wobbles: p.tuning.seam_wobbles.clone(), ..Default::default() }; 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 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()); 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(); 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 // 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 // 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 // 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 // board; the blocks spread evenly. 1 (default) = one call per row (the game's host at 60 fps).
// physics positions, as before.
let calls: usize = std::env::var("E2E_CALLS").ok().and_then(|v| v.parse().ok()).unwrap_or(1).max(1); 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; let per_row = substeps * calls;
// E2E_FPS=f (overrides E2E_CALLS): the game's host at a rendered frame rate f, as // 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 // 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. // elapsed 60 Hz step and rows without a call only render.
let fps: Option<f64> = std::env::var("E2E_FPS").ok().and_then(|v| v.parse().ok()); let fps: Option<f64> = 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 // 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 // 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 // there, the flag inputs held in between (2026-10-03: the only host; the per-row renders
// E2E_MIX_CONSOLE=0; E2E_MIX_CONSOLE=1 turns it on for the per-row renders too. // before it ran the old 60 Hz evaluation, see doc 11 "A/B switches removed").
let mix_console = match std::env::var("E2E_MIX_CONSOLE").ok().as_deref() { // The game's host clock (`native::HostClock`): each row is a physics step published as
Some("0") => false, // `skate_events::observe` publishes it (one-step pulses latched until a pass takes them);
Some("1") => true, // a pass takes the steps since the last one (capped), as `native::mixmap_frame`.
_ => fps.is_some(), let mut clock = super::native::HostClock::default();
}; let mut cues = super::skate_events::Cues::default();
let mut cadence = skate_audio::mixmap::cadence::Cadence::default(); super::native::hold_flag_inputs(&mut m);
if mix_console { let mut fps_acc = 0.0f64;
super::native::hold_flag_inputs(&mut m);
}
let (mut fps_acc, mut rows_since_call) = (0.0f64, 0usize);
for (frame, &i) in order.iter().enumerate() { for (frame, &i) in order.iter().enumerate() {
let row_riding = script.riding(&rows[i]); let row_riding = script.riding(&rows[i]);
rows_since_call += 1; cues.publish(row_riding);
let (n_row, calls) = match fps { let (n_row, calls) = match fps {
Some(f) => { Some(f) => {
fps_acc += f / 60.0; fps_acc += f / 60.0;
@@ -484,13 +222,12 @@ fn e2e_render() {
} }
} }
for call in 0..n_row { for call in 0..n_row {
// The console seams cadence (Listening test 9) whenever the host runs other than one // The console seams cadence (Listening test 9): every call is a rendered frame of 1/f s.
// call per row: every call is a rendered frame of 1/f s. {
if console {
p.seam_console = true;
p.seam_alpha = Some((call % calls) as f32 / calls as f32); 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; 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 { if call % calls != 0 {
owed += blocks_per_frame / per_row as f64; owed += blocks_per_frame / per_row as f64;
@@ -507,29 +244,32 @@ fn e2e_render() {
continue; continue;
} }
let t0 = std::time::Instant::now(); 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; let mut s = riding.audio;
if substeps > 1 { if substeps > 1 {
s.dt = sub_dt; s.dt = sub_dt;
} }
globals(&mut m); globals(&mut m);
if reverb_inputs { {
let v = shared.lock().unwrap().mixer.buses.env.reverb_inputs(); let v = shared.lock().unwrap().mixer.buses.env.reverb_inputs();
for (id, x) in v.into_iter().enumerate() { for (id, x) in v.into_iter().enumerate() {
m.set_input(keys::REVERB, id, x); m.set_input(keys::REVERB, id, x);
} }
} }
// Below 60 fps one call ticks once per elapsed 60 Hz step (`mixmap_frame`'s ticks loop), // Below 60 fps one call ticks once per physics step since the last call (the game's
// and the listener's velocity and the bed's frame span those steps (the game passes the // `HostClock`), and the listener's velocity and the bed's frame span those steps.
// real frame time). Before 2026-10-03 both took one row's dt there: E2E_FPS < 60 renders let ticks = pass.ticks;
// 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 };
let call_dt = sub_dt * ticks as f32; 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 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 }; let mix_calls = pass.calls;
p.jitter_steps = mix_console.then_some(mix_calls); p.jitter_steps = Some(mix_calls);
// As `mixmap_frame`: sub_82491180 (half 1) before the inputs, with the last walk's values. // 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()); shared.lock().unwrap().mixer.buses.eq.clear(p.eq_jitter());
} }
p.write_inputs(&mut m, &s, Some(&l)); p.write_inputs(&mut m, &s, Some(&l));
@@ -552,28 +292,16 @@ fn e2e_render() {
deck_seen = p.deck_trace(); deck_seen = p.deck_trace();
writeln!(deck, "{frame}\t{}\t{:016x}", deck_seen.0, deck_seen.1).unwrap(); writeln!(deck, "{frame}\t{}\t{:016x}", deck_seen.0, deck_seen.1).unwrap();
} }
if mix_console { for _ in 0..mix_calls {
for _ in 0..mix_calls { m.tick(skate_audio::mixmap::cadence::CONSOLE_DT);
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());
} }
let t3 = std::time::Instant::now(); let t3 = std::time::Instant::now();
p.update(&m, &s, &mut shared.lock().unwrap(), speed_scale, loose); 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))); 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 t4 = std::time::Instant::now();
let routed = p.rolling_on.then(|| (std::mem::take(&mut p.routed.grains), p.routed.primary)); 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); super::grain_bed::step(&mut bed, &library, &m, &shared, &riding, call_dt, &seams, routed);
if let Some(w) = slew_log.as_mut() { if let Some(w) = slew_log.as_mut() {
let (turn, brake) = bed.slews(); let (turn, brake) = bed.slews();
+55 -215
View File
@@ -20,55 +20,16 @@
//! emitter_payload`: w1 dry = out4 × level, w2 send = out8 × level, w3 pan = out0, w4 pitch = //! 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 //! 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) //! listener's distance and azimuth each frame. Redelivered every frame, released (state freed)
//! when the listener leaves; //! 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. //! Without the native runtime (an install without the AEMS data: `native.rs` logs the error) the
use super::{Category, Library, Play, Voices, library::Clip, native::Native, voices::VoiceId}; //! 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 bevy::prelude::*;
use skate_audio::eval::NodeId; use skate_audio::eval::NodeId;
/// CSTATEMGR_Emitter's pool size. /// CSTATEMGR_Emitter's pool size.
const MAX_ACTIVE: usize = 5; 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). /// The `.ems` file of a map (by its `.skate` file stem).
fn ems_file(map_stem: &str) -> Option<&'static str> { fn ems_file(map_stem: &str) -> Option<&'static str> {
@@ -263,23 +224,14 @@ struct Emitter {
shape: Shape, shape: Shape,
volume: f32, volume: f32,
falloff: i32, falloff: i32,
/// The measured table's profile (None when the native runtime plays the bank).
profile: Option<&'static Profile>,
bank: String, bank: String,
patch: i32, patch: i32,
} }
/// A started emitter: its voices and program state. /// A reached emitter: its native post and emitter state once started.
struct Node { struct Node {
record: usize, record: usize,
started: bool, 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<usize>; 2],
phase: (f32, f32),
/// The native runtime's post and emitter state (native mode only).
post: Option<NodeId>, post: Option<NodeId>,
state: Option<usize>, state: Option<usize>,
} }
@@ -290,9 +242,8 @@ pub(super) struct State {
emitters: Vec<Emitter>, emitters: Vec<Emitter>,
/// Reached records in discovery order (retail's node list). /// Reached records in discovery order (retail's node list).
nodes: Vec<Node>, nodes: Vec<Node>,
rng: u32,
/// The map's native emitter banks (prefetch, `native::prefetch`), each emitter's index into /// 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<String>, banks: Vec<String>,
emitter_bank: Vec<usize>, emitter_bank: Vec<usize>,
bank_status: Vec<BankStatus>, bank_status: Vec<BankStatus>,
@@ -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<usize>, 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)] #[allow(clippy::too_many_arguments)]
pub(super) fn update( pub(super) fn update(
mut state: Local<State>, mut state: Local<State>,
mut commands: Commands,
map: Res<crate::map_transition::CurrentMap>, map: Res<crate::map_transition::CurrentMap>,
library: Option<ResMut<Library>>, library: Option<Res<Library>>,
mut voices: ResMut<Voices>,
mut assets: ResMut<Assets<AudioSource>>,
listener: Query<&GlobalTransform, With<super::GameAudioListener>>, listener: Query<&GlobalTransform, With<super::GameAudioListener>>,
time: Res<Time<Real>>,
menu: Option<Res<crate::graphics_menu::Menu>>, menu: Option<Res<crate::graphics_menu::Menu>>,
replay: Res<crate::replay::Replay>, replay: Res<crate::replay::Replay>,
mut native: Option<ResMut<Native>>, native: Option<ResMut<Native>>,
cues: Res<super::skate_events::Cues>, cues: Res<super::skate_events::Cues>,
) { ) {
let _timing = super::timing::scope(&super::timing::EMITTERS); 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 state = &mut *state;
let identity = (map.name.clone(), map.generation); let identity = (map.name.clone(), map.generation);
if state.map.as_ref() != Some(&identity) { if state.map.as_ref() != Some(&identity) {
for node in state.nodes.drain(..) { for node in state.nodes.drain(..) {
for (id, ..) in node.voices { if let Some(post) = node.post {
voices.stop(id, STOP_FADE); native.release(post);
} }
if let Some(native) = native.as_deref_mut() { if let Some(g) = node.state {
if let Some(post) = node.post { native.release_emitter_state(g);
native.release(post);
}
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(""); 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 // 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 // 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 // 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. // 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 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| { state.emitters = records.iter().filter(|r| r.kind == 1 && r.flags == 0).filter_map(|r| {
let bank = r.bank.clone()?; let bank = r.bank.clone().filter(|b| native.has_bank(&library, b))?;
let profile = match native_banks {
Some(n) if n.has_bank(&library, &bank) => None,
Some(_) => return None,
None => Some(profile(&bank)?),
};
let s = r.scalars; let s = r.scalars;
Some(Emitter { 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] }, 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(); }).collect();
info!("World emitters: {} of {} records on {stem} have a played sound", state.emitters.len(), records.len()); info!("World emitters: {} of {} records on {stem} have a played sound", state.emitters.len(), records.len());
state.banks.clear(); state.banks.clear();
state.emitter_bank.clear(); state.emitter_bank.clear();
for e in &state.emitters { for e in &state.emitters {
let b = if e.profile.is_some() { let b = if let Some(b) = state.banks.iter().position(|s| *s == e.bank) {
usize::MAX
} else if let Some(b) = state.banks.iter().position(|s| *s == e.bank) {
b b
} else { } else {
state.banks.push(e.bank.clone()); state.banks.push(e.bank.clone());
@@ -447,12 +357,8 @@ pub(super) fn update(
let Ok(listener) = listener.single() else { return }; let Ok(listener) = listener.single() else { return };
let ear = listener.translation(); let ear = listener.translation();
let skater = cues.riding.board; 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 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). // Release nodes the listener left (or everything while silenced).
let reached: Vec<Option<f32>> = state.emitters.iter().map(|e| if silent { None } else { reach(&e.shape, ear) }).collect(); let reached: Vec<Option<f32>> = 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 { if node.started {
info!("AUDIO_EMITTER stop {} #{}", state.emitters[node.record].bank, node.record); info!("AUDIO_EMITTER stop {} #{}", state.emitters[node.record].bank, node.record);
} }
for (id, ..) in &node.voices { if let Some(post) = node.post {
voices.stop(*id, STOP_FADE); native.release(post);
} }
if let Some(native) = native.as_deref_mut() { if let Some(g) = node.state {
if let Some(post) = node.post { native.release_emitter_state(g);
native.release(post);
}
if let Some(g) = node.state {
native.release_emitter_state(g);
}
} }
} }
keep keep
@@ -479,12 +380,7 @@ pub(super) fn update(
// New hits join the node list in discovery order. // New hits join the node list in discovery order.
for (index, hit) in reached.iter().enumerate() { for (index, hit) in reached.iter().enumerate() {
if hit.is_some() && !state.nodes.iter().any(|n| n.record == index) { if hit.is_some() && !state.nodes.iter().any(|n| n.record == index) {
state.nodes.push(Node { state.nodes.push(Node { record: index, started: false, post: None, state: None });
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,
});
} }
} }
// Waiting nodes take free states in list order. // Waiting nodes take free states in list order.
@@ -496,84 +392,35 @@ pub(super) fn update(
node.started = true; node.started = true;
active += 1; active += 1;
let e = &state.emitters[node.record]; 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, info!("AUDIO_EMITTER start {} #{} at {:.1?} volume {:.2} (native)", e.bank, node.record, e.shape.position.to_array(), e.volume);
if e.profile.is_none() { " (native)" } else { "" }); match native.ensure_bank(&library, &e.bank) {
if e.profile.is_none() { Ok(_) => {
if let Some(native) = native.as_deref_mut() { if let Some(status) = state.emitter_bank.get(node.record).and_then(|&b| state.bank_status.get_mut(b)) {
match native.ensure_bank(&library, &e.bank) { *status = BankStatus::Loaded;
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),
} }
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) { for node in state.nodes.iter_mut().filter(|n| n.started) {
let emitter = &state.emitters[node.record]; let emitter = &state.emitters[node.record];
let Some(d) = reached[node.record] else { continue }; let (Some(d), Some(post)) = (reached[node.record], node.post) else { continue };
let Some(profile) = emitter.profile else { let level = emitter.volume * falloff(emitter.falloff, d);
// Native: the bank's program does the rest; only the game-side words change. if let Some(g) = node.state {
if let (Some(post), Some(native)) = (node.post, native.as_deref_mut()) { native.set_emitter_position(g, listener, skater, emitter.shape.position);
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 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)] #[cfg(test)]
@@ -585,18 +432,19 @@ mod tests {
/// banks, evaluator random state and blocks stay untouched; requested = the banks within /// 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. /// `AHEAD` m that are not loaded; far away everything is dropped. Prints its cost per call.
#[test] #[test]
#[ignore = "needs the private install data"]
fn the_prefetch_follows_the_listener_and_touches_no_runtime_state() { fn the_prefetch_follows_the_listener_and_touches_no_runtime_state() {
use super::super::native::prefetch::{AHEAD, EVICT}; use super::super::native::prefetch::{AHEAD, EVICT};
let root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets")); 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 Ok(mut native) = Native::start(&library) else { return eprintln!("skipped: no AEMS install") }; let Ok(mut native) = Native::start(&library) else { panic!("missing private data: no AEMS install") };
let mut state = State::default(); let mut state = State::default();
for r in ems_files("DownTown").iter().flat_map(|f| library.emitters(f)) { 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 Some(bank) = r.bank.clone().filter(|b| r.kind == 1 && r.flags == 0 && native.has_bank(&library, b)) else { continue };
let s = r.scalars; let s = r.scalars;
state.emitters.push(Emitter { 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] }, 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(|| { let b = state.banks.iter().position(|x| *x == bank).unwrap_or_else(|| {
state.banks.push(bank); state.banks.push(bank);
@@ -605,7 +453,7 @@ mod tests {
state.emitter_bank.push(b); state.emitter_bank.push(b);
} }
if state.emitters.is_empty() { 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()]; state.bank_status = vec![BankStatus::Idle; state.banks.len()];
let snapshot = |n: &Native| { let snapshot = |n: &Native| {
@@ -701,24 +549,17 @@ mod tests {
assert_eq!(falloff(7, 0.5), 1.0); 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<usize> = (0..10).map(|_| draw(&mut bag, 10, &mut rng)).collect();
seen.sort_unstable();
assert_eq!(seen, (0..10).collect::<Vec<_>>());
}
/// A DownTown reverb zone (attribute `1F94F2F815C00368` → reverb11) through the retail selector: /// 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 /// 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. /// MixMap's Reverb out4 (the global env scale) drops by F213's −400 mB.
#[test] #[test]
#[ignore = "needs the private install data"]
fn a_downtown_reverb_zone_selects_its_preset_and_raises_reverb_in5() { 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 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"); let records = zone_records(&library, "DownTown");
if records.is_empty() { 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 (presets, _) = library.bus_tuning();
let Some(zone) = records.iter().find(|z| z.attribute == 0x1F94_F2F8_15C0_0368) else { panic!("no 1F94F2F815C00368 zone") }; 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.target_key(), Some(0xBEEF_C8E3_DE04_FBAE));
assert_eq!(env.reverb_inputs(), [0, 0, 0, 0, 0, 32767, 0], "reverb11 → Reverb.in5"); 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 mut m = skate_audio::mixmap::MixMap::from_bytes(&library.read(&mxb).unwrap()).unwrap();
let out4 = |m: &mut skate_audio::mixmap::MixMap, inputs: [i32; 7]| { let out4 = |m: &mut skate_audio::mixmap::MixMap, inputs: [i32; 7]| {
for id in 1..=4 { for id in 1..=4 {
@@ -768,7 +609,6 @@ mod tests {
for map in ["University", "DownTown", "Industrial", "SkateSchool", "MegaPark"] { for map in ["University", "DownTown", "Industrial", "SkateSchool", "MegaPark"] {
assert!(ems_file(map).is_some(), "{map}"); assert!(ems_file(map).is_some(), "{map}");
} }
assert!(profile("water_fountain").is_some() && profile("trees_rustle").is_none());
} }
} }
+21 -22
View File
@@ -126,14 +126,11 @@ pub(crate) struct Bed {
/// The turn intensity (`sub_824C8588`) and brake slews: retail steps each once per call of the /// The turn intensity (`sub_824C8588`) and brake slews: retail steps each once per call of the
/// SkateBoard process, no dt. /// SkateBoard process, no dt.
slews: OwnerSlews, 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 /// 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, /// [`step`]; taken by it. The slews run on the console cadence (2026-10-03, user decision "lets
/// tests): the old per-frame slews. /// 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<usize>, pub(crate) slew_calls: Option<usize>,
/// Send, level ramp, wobbles and the gains each chain holds. /// Send, level ramp, wobbles and the gains each chain holds.
chain: ChainState, chain: ChainState,
@@ -182,7 +179,6 @@ impl Bed {
push_scale: PushEnvelope::default(), push_scale: PushEnvelope::default(),
shift: PushShift::default(), shift: PushShift::default(),
slews: OwnerSlews::default(), slews: OwnerSlews::default(),
slew_console: !std::env::var("SKATE_AEMS_SLEW_CONSOLE").is_ok_and(|v| v == "0"),
slew_calls: None, slew_calls: None,
chain: ChainState::default(), chain: ChainState::default(),
latches: Latches::default(), latches: Latches::default(),
@@ -220,7 +216,6 @@ impl Bed {
push_scale: PushEnvelope::default(), push_scale: PushEnvelope::default(),
shift: PushShift::default(), shift: PushShift::default(),
slews: OwnerSlews::default(), slews: OwnerSlews::default(),
slew_console: self.slew_console,
slew_calls: None, slew_calls: None,
chain: ChainState::default(), chain: ChainState::default(),
latches: Latches::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( pub(super) fn update(
native: Option<ResMut<super::native::Native>>, native: Option<ResMut<super::native::Native>>,
library: Option<Res<Library>>, library: Option<Res<Library>>,
cues: Res<super::skate_events::Cues>, cues: Res<super::skate_events::Cues>,
time: Res<Time<Real>>,
) { ) {
let _timing = super::timing::scope(&super::timing::GRAIN_BED); let _timing = super::timing::scope(&super::timing::GRAIN_BED);
let (Some(mut native), Some(library)) = (native, library) else { return }; let (Some(mut native), Some(library)) = (native, library) else { return };
let native = &mut *native; let native = &mut *native;
// The seam wobbles live with the player tuning. let ticks = native.frame_ticks;
let seams = skate_audio::player::tuning::PlayerTuning { if ticks == 0 {
seam_wobbles: native.player.as_ref().map(|p| p.tuning.seam_wobbles.clone()).unwrap_or_default(), return;
..Default::default() }
}; // The owner's routing (native rolling layers): its binds / stops since the last pass.
// The owner's routing (native rolling layers): its binds / stops since the last frame.
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)); 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<skate_audio::player::tuning::PlayerTuning> = 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 }; 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. /// 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 r = *r;
let s = r.audio; let s = r.audio;
let frames = (dt * 60.0).clamp(0.0, 6.0); 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(); let speed = r.speed.abs();
// Surface routing: the wanted binding per truck (None = stopped). // 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 /// 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. /// surface that plays a grain (1–6, 9). And every member has its tuning and recording.
#[test] #[test]
#[ignore = "needs the private install data"]
fn grain_for_matches_the_vault_surface_map_and_the_bed_loads() { 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 root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets"));
let Ok(library) = Library::load(root) else { let Ok(library) = Library::load(root) else {
eprintln!("skipped: no audio install under {}", root.display()); panic!("missing private data: no audio install under {}", root.display());
return;
}; };
let map = library.surface_map(); let map = library.surface_map();
if map.is_empty() { if map.is_empty() {
eprintln!("skipped: the install has no grain tuning (stage_grain_mixmap.py)"); panic!("missing private data: the install has no grain tuning (stage_grain_mixmap.py)");
return;
} }
assert_eq!(map.len(), 95); assert_eq!(map.len(), 95);
for tag in 0..128u32 { for tag in 0..128u32 {
+4 -8
View File
@@ -17,8 +17,8 @@ const MANIFEST_VERSIONS: std::ops::RangeInclusive<u32> = 3..=5;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct Entry { pub(crate) struct Entry {
pub file: String, pub file: String,
#[serde(default)] // Manifests also carry `seconds` (the sample length): read only by the removed measured
pub seconds: f32, // emitter relay, so it is ignored.
} }
/// One record of a map's `.ems` emitter file with its sound's attributes /// 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) 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). /// The records of an `.ems` emitter file (empty when absent).
pub(crate) fn emitters(&self, file: &str) -> &[EmitterRecord] { pub(crate) fn emitters(&self, file: &str) -> &[EmitterRecord] {
self.manifest.emitters.get(file).map_or(&[], Vec::as_slice) 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 /// of the user's own disc, class `6EBA5BCD3E38A98A` `default` field `8B164823E008749C`, a
/// `Sk8::PointNegGraphData8`: 16-byte header, x at +16, y at +48). /// `Sk8::PointNegGraphData8`: 16-byte header, x at +16, y at +48).
#[test] #[test]
#[ignore = "needs the private install data"]
fn the_body_speed_graph_is_the_stock_vault_record() { fn the_body_speed_graph_is_the_stock_vault_record() {
use skate_audio::player::contacts::SpeedGraph8; 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(); 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"); 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 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 { 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 all: serde_json::Value = serde_json::from_str(&text).unwrap();
let rec = all["collections"].as_array().unwrap().iter() let rec = all["collections"].as_array().unwrap().iter()
+17 -4
View File
@@ -24,10 +24,12 @@ mod npc_skaters;
mod player_audio; mod player_audio;
mod random_programs; mod random_programs;
mod random_sets; mod random_sets;
mod skate_events; pub(crate) mod skate_events;
mod state_log; mod state_log;
mod state_replay;
mod timing; mod timing;
mod voices; mod voices;
pub(crate) mod world_bridge;
mod world_sources; mod world_sources;
use bevy::{audio::Volume, prelude::*}; use bevy::{audio::Volume, prelude::*};
@@ -46,12 +48,16 @@ struct SavedSettings {
master: u32, master: u32,
ambience: u32, ambience: u32,
effects: 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 // 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. // tables) or the older `"native"`; unknown keys are ignored, so they still load.
} }
impl Default for SavedSettings { impl Default for SavedSettings {
fn default() -> Self { fn default() -> Self {
Self { master: 75, ambience: 100, effects: 100 } Self { master: 75, ambience: 100, effects: 100, more_audible_world: false }
} }
} }
impl SavedSettings { impl SavedSettings {
@@ -87,6 +93,10 @@ impl AudioSettings {
pub(crate) fn master(&self) -> f32 { pub(crate) fn master(&self) -> f32 {
if self.muted { 0.0 } else { self.saved.master as f32 / 100.0 } 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 { pub(crate) fn category(&self, category: Category) -> f32 {
let percent = match category { let percent = match category {
Category::Ambience => self.saved.ambience, Category::Ambience => self.saved.ambience,
@@ -160,6 +170,7 @@ impl Plugin for GameAudioPlugin {
.add_plugins(native::register) .add_plugins(native::register)
.add_plugins(world_sources::register) .add_plugins(world_sources::register)
.add_plugins(npc_skaters::register) .add_plugins(npc_skaters::register)
.add_plugins(world_bridge::register)
.add_systems( .add_systems(
PostUpdate, PostUpdate,
(apply_global_volume, follow_camera).before(bevy::transform::TransformSystems::Propagate), (apply_global_volume, follow_camera).before(bevy::transform::TransformSystems::Propagate),
@@ -215,7 +226,7 @@ mod tests {
#[test] #[test]
fn old_settings_files_with_interim_or_native_keys_still_load() { 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(); 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(); let opted_out: SavedSettings = serde_json::from_str(r#"{"master":50,"interim":true}"#).unwrap();
assert_eq!(opted_out, SavedSettings { master: 50, ..SavedSettings::default() }); assert_eq!(opted_out, SavedSettings { master: 50, ..SavedSettings::default() });
} }
@@ -224,7 +235,9 @@ mod tests {
fn defaults_are_quiet_and_saved_values_are_bounded() { fn defaults_are_quiet_and_saved_values_are_bounded() {
assert_eq!(SavedSettings::default().master, 75); assert_eq!(SavedSettings::default().master, 75);
let loaded: SavedSettings = serde_json::from_str(r#"{"master":400,"ambience":33,"effects":7}"#).unwrap(); 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] #[test]
+382 -132
View File
@@ -12,11 +12,12 @@
//! posts, redelivers and releases between blocks under the runtime's lock. //! 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) //! - The stream follows the master volume (the AEMS voices × ambience, the rolling bed × effects)
//! and pauses while the menu or a replay runs. //! and pauses while the menu or a replay runs.
//! - The MixMap mixer (`MixMapSK8.mxb`, `skate_audio::mixmap`) runs on the game thread once per //! - The MixMap mixer (`MixMapSK8.mxb`, `skate_audio::mixmap`) runs on the game thread, clocked by
//! 60 Hz frame (fixed steps): [`mixmap_frame`] writes the inputs we can supply (category gains, //! the physics steps ([`HostClock`]: one host tick per step since the last frame, none while
//! pause, the local player's physics and 3-D position, the emitter states' positions), ticks, //! nothing steps or the game is silenced): [`mixmap_frame`] writes the inputs we can supply
//! and the systems read its outputs (the `c_emitter` words; the rolling bed's levels, pitch, //! (category gains, the local player's physics and 3-D position, the emitter states' positions),
//! filters and pan). //! 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 //! 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 //! (`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; pub(crate) mod prefetch;
/// The host's step: inputs, the components' process and update run per 60 Hz physics step; the /// The host's step: one 60 Hz physics step. The host ticks once per physics step ([`HostClock`]);
/// MixMap evaluates on every second one (the console's 30 Hz, `skate_audio::mixmap::cadence`), or /// the MixMap evaluates on every second one (the console's 30 Hz, `skate_audio::mixmap::cadence`).
/// on each with `SKATE_AEMS_MIX_CONSOLE=0`. pub(crate) const MIX_STEP: f32 = 1.0 / 60.0;
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<Real>` 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<Pass> {
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. /// CSTATEMGR_Emitter's pool = the MixMap's Emitter instances.
pub(crate) const EMITTER_STATES: usize = 5; pub(crate) const EMITTER_STATES: usize = 5;
@@ -114,12 +164,18 @@ pub(crate) struct Native {
emitter_class: Option<usize>, emitter_class: Option<usize>,
/// The MixMap, when the install has `MixMapSK8.mxb`. /// The MixMap, when the install has `MixMapSK8.mxb`.
pub(crate) mixmap: Option<MixMap>, pub(crate) mixmap: Option<MixMap>,
mix_clock: f32, /// The host's clock (the physics steps).
/// MixMap frames run (the eEQChain clear runs on every second one; old 60 Hz cadence only). clock: HostClock,
mix_frames: u64, /// This frame's host ticks (0: no pass), for the systems after [`mixmap_frame`] (the bed).
/// The console's 30 Hz evaluation grid over the 60 Hz steps ([`mix_console_requested`]). pub(crate) frame_ticks: usize,
cadence: skate_audio::mixmap::cadence::Cadence, /// Camera cuts seen (teleport, map change; [`mixmap_frame`]): the world hosts reset their own
/// The flag inputs are held between evaluations (set once under the console cadence). /// 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, holds: bool,
/// The local player's MixMap inputs and components (None without a MixMap). /// The local player's MixMap inputs and components (None without a MixMap).
pub(crate) player: Option<super::player_audio::PlayerAudio>, pub(crate) player: Option<super::player_audio::PlayerAudio>,
@@ -129,10 +185,51 @@ pub(crate) struct Native {
pub(crate) bed: Option<super::grain_bed::Bed>, pub(crate) bed: Option<super::grain_bed::Bed>,
/// World emitter banks read and decoded ahead of need on a worker thread ([`prefetch`]). /// World emitter banks read and decoded ahead of need on a worker thread ([`prefetch`]).
pub(crate) prefetch: prefetch::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 { 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, String> { pub(super) fn start(library: &Library) -> Result<Self, String> {
Self::start_with(library, WorldInstances::RETAIL)
}
pub(super) fn start_with(library: &Library, world: WorldInstances) -> Result<Self, String> {
let files = library.aems(); let files = library.aems();
if files.projects.is_empty() { if files.projects.is_empty() {
return Err("this install has no AEMS banks (run setup to refresh the audio)".into()); 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 { let mixmap = match &files.mixmap {
Some(file) => { Some(file) => {
let bytes = library.read(file).map_err(|e| format!("{file}: {e}"))?; 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()); info!("Game audio: MixMap {} controllers ({} output blocks)", m.controller_count(), m.output_blocks());
Some(m) Some(m)
} }
@@ -167,14 +268,18 @@ impl Native {
banks: HashMap::new(), banks: HashMap::new(),
emitter_class: None, emitter_class: None,
mixmap, mixmap,
mix_clock: 0.0, clock: HostClock::default(),
mix_frames: 0, frame_ticks: 0,
cadence: Default::default(), cuts: 0,
cut_seen: None,
cut_wheels: None,
holds: false, holds: false,
player, player,
emitter_states: [false; EMITTER_STATES], emitter_states: [false; EMITTER_STATES],
bed, bed,
prefetch: Default::default(), prefetch: Default::default(),
map_epoch: 0,
world,
}; };
// The environment (reverb) network and the eEQChain buses (optional install data). // The environment (reverb) network and the eEQChain buses (optional install data).
let (presets, eq) = library.bus_tuning(); let (presets, eq) = library.bus_tuning();
@@ -191,9 +296,9 @@ impl Native {
runtime.mixer.buses.flange.set_presets(a, b); runtime.mixer.buses.flange.set_presets(a, b);
} }
// The FootStep SubMix graphs (`sub_82494188`: each foot sound through its EQ record, env // 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() { 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.ensure_bank(library, "emitter_utility")?;
native.load_player_banks(library); 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<usize> {
self.banks.get(stem).copied()
}
/// Whether the runtime holds the bank. /// Whether the runtime holds the bank.
pub(crate) fn bank_loaded(&self, stem: &str) -> bool { pub(crate) fn bank_loaded(&self, stem: &str) -> bool {
self.banks.contains_key(stem) 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. /// Unload every bank but the utility and the player's (map change); forget the prefetched ones.
pub(crate) fn unload_map_banks(&mut self) { pub(crate) fn unload_map_banks(&mut self) {
self.map_epoch += 1;
self.prefetch.clear(); self.prefetch.clear();
let Ok(mut runtime) = self.shared.lock() else { return }; let Ok(mut runtime) = self.shared.lock() else { return };
self.banks.retain(|stem, id| { 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); 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<bool> = 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 /// 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 /// 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 /// 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). /// The host pass of a frame (spec §1: inputs → components' process → tick → components' update),
fn seam_pulse_requested() -> bool { /// on frames that took physics steps ([`HostClock::pass`]: as many host ticks as steps, at most
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new(); /// [`MAX_STEPS_PER_FRAME`]; none on a frame without a step, none while silenced). Written here:
*ON.get_or_init(|| !std::env::var("SKATE_AEMS_SEAM_PULSE").is_ok_and(|v| v == "0")) /// 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( pub(super) fn mixmap_frame(
native: Option<ResMut<Native>>, native: Option<ResMut<Native>>,
cues: Res<super::skate_events::Cues>, mut cues: ResMut<super::skate_events::Cues>,
listener: Query<&GlobalTransform, With<super::GameAudioListener>>, listener: Query<&GlobalTransform, With<super::GameAudioListener>>,
time: Res<Time<Real>>, time: Res<Time<Virtual>>,
fixed: Res<Time<Fixed>>, fixed: Res<Time<Fixed>>,
menu: Option<Res<crate::graphics_menu::Menu>>, menu: Option<Res<crate::graphics_menu::Menu>>,
replay: Res<crate::replay::Replay>, replay: Res<crate::replay::Replay>,
cuts: (Option<Res<crate::presentation::Presentation>>, Option<Res<crate::map_transition::CurrentMap>>),
) { ) {
let _timing = super::timing::scope(&super::timing::MIXMAP_FRAME); 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; 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<Virtual>`: still while paused).
let Some(m) = &mut native.mixmap else { return }; 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 { for id in 1..=4 {
m.set_input(keys::MASTER, id, 32767); 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 // 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). // 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. // (Without the runtime lock: in5 = 32767 and no scale this pass.)
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(); let reverb = super::timing::lock(&native.shared, &super::timing::GAME_LOCK).ok().map(|r| r.mixer.buses.env.reverb_inputs());
match reverb { match reverb {
Some(v) => { Some(v) => {
for (id, x) in v.into_iter().enumerate() { 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), None => m.set_input(keys::REVERB, 5, 32767),
} }
let silenced = super::silenced(menu.as_deref(), &replay); m.set_input(keys::PAUSE, 0, 0);
m.set_input(keys::PAUSE, 0, if silenced { 32767 } else { 0 }); let (ticks, calls) = (pass.ticks, pass.calls);
// The bed's turn / brake slews step once per console evaluation (`grain_bed::Bed::slew_calls`).
// 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();
if let Some(bed) = &mut native.bed { 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 if !native.holds {
// 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 {
hold_flag_inputs(m); hold_flag_inputs(m);
native.holds = true; native.holds = true;
} }
// sub_82491180 in half 1 of the audio manager: every console frame (both halves run when the // sub_82491180 (half 1): the jittered eEQChain buses take the walk's values and every bus may
// frame is longer than 20 ms); the old cadence: every second 60 Hz frame. The jittered eEQChain // re-roll again.
// buses take the walk's values and every bus may re-roll again. if pass.clear_eq {
native.mix_frames += 1;
if if console { calls > 0 } else { native.mix_frames % 2 == 0 || ticks > 1 } {
let jitter = native.player.as_ref().and_then(|p| p.eq_jitter()); let jitter = native.player.as_ref().and_then(|p| p.eq_jitter());
if let Ok(mut runtime) = native.shared.lock() { if let Ok(mut runtime) = native.shared.lock() {
runtime.mixer.buses.eq.clear(jitter); runtime.mixer.buses.eq.clear(jitter);
@@ -566,7 +670,7 @@ pub(super) fn mixmap_frame(
let dt = ticks as f32 * MIX_STEP; 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)); 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). // 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()); player.write_inputs(m, &s, l.as_ref());
} }
// With the native rolling layers the owner's surface routing (player::rolling) writes // 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)) { 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); player.process(m, &s, &mut runtime, speed_scale, loose);
} }
if console { for _ in 0..calls {
for _ in 0..calls { m.tick(skate_audio::mixmap::cadence::CONSOLE_DT);
m.tick(skate_audio::mixmap::cadence::CONSOLE_DT);
}
} else {
for _ in 0..ticks {
m.tick(MIX_STEP);
}
} }
if let (Some(player), Ok(mut runtime)) = (&mut native.player, super::timing::lock(&native.shared, &super::timing::GAME_LOCK)) { 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); player.update(m, &s, &mut runtime, speed_scale, loose);
@@ -608,11 +706,13 @@ fn start(
library: Option<Res<Library>>, library: Option<Res<Library>>,
mut streams: ResMut<Assets<NativeStream>>, mut streams: ResMut<Assets<NativeStream>>,
) { ) {
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 // 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"). // once, here, before the first render (doc 11 "Hardware FMA dispatch").
info!("AUDIO_DSP {}", skate_audio::dsp::init_fma()); 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) => { Ok(native) => {
info!("Game audio: native AEMS runtime on ({} projects)", library.aems().projects.len()); info!("Game audio: native AEMS runtime on ({} projects)", library.aems().projects.len());
let handle = streams.add(NativeStream { shared: native.shared.clone() }); let handle = streams.add(NativeStream { shared: native.shared.clone() });
@@ -642,11 +742,11 @@ fn follow_volume(
let Some(settings) = settings else { return }; let Some(settings) = settings else { return };
// rodio 0.20's spatial source gives the ear FARTHER from the source the larger factor // 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 // (((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 // puts them every Bevy positional sound is mirrored: the ears are swapped so Bevy sounds come
// ears so Bevy sounds come from the side native voices (Pan2D1) put them on. // from the side native voices (Pan2D1) put them on.
for mut listener in &mut listeners { for mut listener in &mut listeners {
let gap = listener.left_ear_offset.distance(listener.right_ear_offset); 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 { if listener.left_ear_offset != want {
listener.left_ear_offset = want; listener.left_ear_offset = want;
listener.right_ear_offset = -want; listener.right_ear_offset = -want;
@@ -654,19 +754,18 @@ fn follow_volume(
} }
let silenced = super::silenced(menu.as_deref(), &replay); let silenced = super::silenced(menu.as_deref(), &replay);
let volume = settings.master().clamp(0.0, 1.0); let volume = settings.master().clamp(0.0, 1.0);
// The measured world layers (zone beds, location sets, crossfades) play at measured retail level // The measured world layers (zone beds, location sets, crossfades) are tuned at measured retail
// × RETAIL_SCALE; with the native player sounds (retail level) they drop back to the measured // level × RETAIL_SCALE; against the native voices (retail level) they play at the measured
// level. // level. Bevy and native voices share the ears: the Bevy ones fold like native voices. (Before
let native_player = native.as_deref().is_some_and(|n| n.player.as_ref().is_some_and(|p| p.components)); // 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 { 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 { if voices.scale != scale {
voices.scale = scale; voices.scale = scale;
} }
// Bevy and native voices share the ears: fold the Bevy ones like native voices. if !voices.native_fold {
let fold = native.is_some(); voices.native_fold = true;
if voices.native_fold != fold {
voices.native_fold = fold;
} }
} }
if let Some(native) = native { if let Some(native) = native {
@@ -709,8 +808,8 @@ pub(super) fn reverb_frame(
let env = &mut runtime.mixer.buses.env; let env = &mut runtime.mixer.buses.env;
if env.enabled() { if env.enabled() {
// SFXObj_Reverb's update (`sub_824DE548`) in retail's order, with the reverb-zone // 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). // emitters the listener is in (`emitters::reverb_zones`).
let zones = if reverb_zones_requested() { &zones.zones[..] } else { &[] }; let zones = &zones.zones[..];
env.update(time.delta_secs().clamp(0.0, 0.25), key, zones, camera.as_ref()); env.update(time.delta_secs().clamp(0.0, 0.25), key, zones, camera.as_ref());
} }
} }
@@ -747,17 +846,167 @@ mod tests {
banks: HashMap::new(), banks: HashMap::new(),
emitter_class: None, emitter_class: None,
mixmap, mixmap,
mix_clock: 0.0, clock: HostClock::default(),
mix_frames: 0, frame_ticks: 0,
cadence: Default::default(), cuts: 0,
cut_seen: None,
cut_wheels: None,
holds: false, holds: false,
player: None, player: None,
emitter_states: [false; EMITTER_STATES], emitter_states: [false; EMITTER_STATES],
bed: None, bed: None,
prefetch: Default::default(), 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<Virtual>`'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<Virtual>` 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<u32>,
/// 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<Cues>, mut log: ResMut<Log>| {
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<Cues>, mut log: ResMut<Log>, mut host: ResMut<Host>| {
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::<Fixed>::from_duration(period))
.init_resource::<Cues>()
.init_resource::<Log>()
.init_resource::<Host>()
.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::<Time<Virtual>>();
if paused { v.pause() } else { v.unpause() }
}
let mut h = app.world_mut().resource_mut::<Host>();
(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<Virtual> 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::<Log>();
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::<usize>();
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] #[test]
fn emitter_words_without_a_mixmap_keep_the_old_defaults() { fn emitter_words_without_a_mixmap_keep_the_old_defaults() {
let n = native(None); 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 /// 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. /// send rolls off with camera distance (4 → 70 m) and the filter stays open.
#[test] #[test]
#[ignore = "needs the private install data"]
fn emitter_words_from_the_retail_mixmap() { fn emitter_words_from_the_retail_mixmap() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/audio/aems/MixMapSK8.mxb"); let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/audio/aems/MixMapSK8.mxb");
let Ok(bytes) = std::fs::read(path) else { let Ok(bytes) = std::fs::read(path) else {
eprintln!("skipped: no {path}"); panic!("missing private data: no {path}");
return;
}; };
let mut n = native(Some(MixMap::from_bytes(&bytes).unwrap())); let mut n = native(Some(MixMap::from_bytes(&bytes).unwrap()));
let g = n.claim_emitter_state().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). /// dev install's manifest with parts removed (the data folders are linked, not copied).
/// Data-gated: skipped without the install. /// Data-gated: skipped without the install.
#[test] #[test]
#[ignore = "needs the private install data"]
fn missing_install_parts_fall_back_without_breaking() { fn missing_install_parts_fall_back_without_breaking() {
let real = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/audio")); 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(); let full: serde_json::Value = serde_json::from_str(&text).unwrap();
if full["aems"]["projects"].as_array().is_none_or(|p| p.is_empty()) { 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 dir = std::env::temp_dir().join(format!("skate-audio-fallback-{}", std::process::id()));
let audio = dir.join("private/audio"); let audio = dir.join("private/audio");
@@ -862,7 +1112,7 @@ mod tests {
if !out.as_ref().is_ok_and(|o| o.status.success()) { if !out.as_ref().is_ok_and(|o| o.status.success()) {
let _ = std::fs::remove_dir_all(&dir); 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)); 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); links.push(link);
} }
@@ -874,7 +1124,7 @@ mod tests {
let r = Native::start(&library); let r = Native::start(&library);
println!("{name}: {}", match &r { 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))), 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 r
}; };
@@ -242,11 +242,12 @@ mod tests {
/// and takes far less time; a map change (`unload_map_banks`) forgets the prefetch and the next /// 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. /// start loads on the game thread as before.
#[test] #[test]
#[ignore = "needs the private install data"]
fn prefetched_banks_are_identical_and_not_decoded_at_the_start() { fn prefetched_banks_are_identical_and_not_decoded_at_the_start() {
let root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets")); 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() { if library.aems().projects.is_empty() {
return eprintln!("skipped: no AEMS banks"); panic!("missing private data: no AEMS banks");
} }
let mut stems: Vec<String> = Vec::new(); let mut stems: Vec<String> = Vec::new();
for file in ["sfx_downtown", "sfx_university"] { for file in ["sfx_downtown", "sfx_university"] {
@@ -259,7 +260,7 @@ mod tests {
} }
} }
if stems.is_empty() { 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. // Identity: worker result vs the game thread's load.
let mut p = Prefetch::default(); let mut p = Prefetch::default();
@@ -314,7 +315,8 @@ mod tests {
v.sort_by(f64::total_cmp); v.sort_by(f64::total_cmp);
format!("median {:.3} ms, max {:.3} ms", v[v.len() / 2], v[v.len() - 1]) 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)); println!("ensure_bank over {} emitter banks: game-thread load {}; prefetched {}", stems.len(), stat(&mut sync), stat(&mut pre));
assert!(pre.last() < sync.last());
} }
} }
+68 -23
View File
@@ -28,7 +28,6 @@ use std::collections::HashMap;
use bevy::prelude::*; use bevy::prelude::*;
use skate_audio::eval::NodeId; use skate_audio::eval::NodeId;
use skate_audio::mixmap::cadence::CONSOLE_DT;
use skate_audio::player::components::{Command, Slot}; use skate_audio::player::components::{Command, Slot};
use skate_audio::player::objpos::Listener; use skate_audio::player::objpos::Listener;
use skate_audio::world::skaters::{self, NpcSkater, NpcSkaterAudioState, Parts, Slots, Tuning}; 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")) *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)] #[derive(Resource, Default)]
pub(crate) struct NpcHost { pub(crate) struct NpcHost {
slots: Slots, slots: Slots,
@@ -59,11 +54,15 @@ pub(crate) struct NpcHost {
nodes: HashMap<(u64, Slot), NodeId>, nodes: HashMap<(u64, Slot), NodeId>,
classes: HashMap<&'static str, usize>, classes: HashMap<&'static str, usize>,
last_tick: u64, 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, announced: bool,
/// The held skater's grain bed (instance 1) and its push-plant count (`+335` rises; the bed's /// 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. /// push envelopes and SkateBoard input 4 follow it), with the native rolling layers on.
beds: HashMap<u64, (super::grain_bed::Bed, u32)>, beds: HashMap<u64, (super::grain_bed::Bed, u32)>,
/// `Native::map_epoch` this host last ran in (None: never ran; [`NpcHost::reset`]).
epoch: Option<u64>,
} }
pub(crate) fn register(app: &mut App) { 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). /// Release every packet a skater holds (it lost its instance).
fn release_all(&mut self, rt: &mut skate_audio::runtime::Runtime, owner: u64) { 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(); 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)] #[allow(clippy::too_many_arguments)]
fn frame( pub(super) fn frame(
library: Option<Res<super::Library>>, library: Option<Res<super::Library>>,
native: Option<ResMut<Native>>, native: Option<ResMut<Native>>,
published: Res<NpcSkaters>, published: Res<NpcSkaters>,
mut host: ResMut<NpcHost>, mut host: ResMut<NpcHost>,
mut held: ResMut<super::world_sources::WorldHeld>,
cues: Res<super::skate_events::Cues>, cues: Res<super::skate_events::Cues>,
listener: Query<&GlobalTransform, With<super::GameAudioListener>>, listener: Query<&GlobalTransform, With<super::GameAudioListener>>,
) { ) {
@@ -132,8 +163,20 @@ fn frame(
} }
let Some(mut native) = native else { return }; let Some(mut native) = native else { return };
let Ok(camera) = listener.single() else { return }; let Ok(camera) = listener.single() else { return };
let host = &mut *host; run(&mut host, &published, &mut native, library.as_deref(), (camera.translation().to_array(), camera.forward().as_vec3().to_array()), &cues.riding.audio);
let Native { mixmap, player, shared, bed: local_bed, .. } = &mut *native; 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 }; let (Some(m), Some(player)) = (mixmap.as_mut(), player.as_mut()) else { return };
if !player.components { if !player.components {
return; return;
@@ -141,20 +184,20 @@ fn frame(
if m.ticks == host.last_tick { if m.ticks == host.last_tick {
return; return;
} }
let evaluations = m.ticks - host.last_tick; let evaluations = m.ticks.saturating_sub(host.last_tick).max(1);
host.last_tick = m.ticks; 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 { if !host.announced {
info!("AUDIO_NPC on: {} NPC skaters published", published.skaters.len()); info!("AUDIO_NPC on: {} NPC skaters published", published.skaters.len());
host.announced = true; host.announced = true;
} }
let cam = camera.translation().to_array(); let (cam, view) = camera;
let cam_velocity = host.last_camera.map_or([0.0; 3], |last| std::array::from_fn(|i| (cam[i] - last[i]) / dt)); 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); host.last_camera = Some((cam, *cuts));
let local = cues.riding.audio; let local = *local;
let l = Listener { let l = Listener {
camera: cam, camera: cam,
view: camera.forward().as_vec3().to_array(), view,
camera_velocity: cam_velocity, camera_velocity: cam_velocity,
followed: local.com_position, followed: local.com_position,
facing: local.com_velocity, 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 }; let parts = Parts { rolling: player.rolling_on, rattle: player.rattle_on, contacts: player.contacts_on };
for (id, g) in assignment.claimed { 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); 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)); host.beds.insert(id, (bed.for_instance(g as u32), 0));
} }
info!("AUDIO_NPC claim skater {id} (instance {g})"); 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 // 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 // from its outputs), with the binds / stops of the routing's last process; then its owner
// inputs 2 / 3 / 4 for the next evaluation. // 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)); *pushes = pushes.wrapping_add(u32::from(s.push_trigger));
let r = super::skate_events::Riding { let r = super::skate_events::Riding {
board: Vec3::from_array(s.board_position), 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)); 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. // 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)); super::grain_bed::step_with(bed, library, m, &r, dt, tuning.player, routed, |apply| apply(&mut *rt));
bed.write_inputs(m, &s, false); bed.write_inputs(m, &s, false);
} }
npc.write_inputs(m, &s, &l, local.com_velocity, tuning.player); 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`). // The body / deck posters once per console evaluation, as the local player's.
npc.set_body_calls((player.body_console && player.jitter_steps.is_some()).then_some(evaluations as usize)); npc.set_body_calls(Some(evaluations as usize));
npc.set_deck_calls((player.deck_console && player.jitter_steps.is_some()).then_some(evaluations as usize)); npc.set_deck_calls(Some(evaluations as usize));
let cmds = npc.process(m, &s, tuning, &mut rt.splice_host()); let cmds = npc.process(m, &s, tuning, &mut rt.splice_host());
host.apply(rt, id, cmds); host.apply(rt, id, cmds);
// The routing's binds wait for the bed's next step (dropped without a bed). // The routing's binds wait for the bed's next step (dropped without a bed).
@@ -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 /// play Skate_Collisions, Seams_Bank, Skate_Metal, GRINDS and WHEEL_SKID_BANK above their session
/// rates (`.local/research/npc-skater-audio/instance1_voices.py`). /// rates (`.local/research/npc-skater-audio/instance1_voices.py`).
#[test] #[test]
#[ignore = "needs the private install data"]
fn npc_skater_rolls_and_grinds_past_the_listener() { 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 { 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); let p = pass(&library, &mxb, false);
println!(" t dist held RMS dBFS banks (voices opened, max gain)"); 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 /// 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). /// per-0.5 s table (distance, held, member, voices, record A gain, bed RMS).
#[test] #[test]
#[ignore = "needs the private install data"]
fn npc_skater_rolls_on_its_own_grain_bed() { 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((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 { return eprintln!("skipped: no grain recordings / tuning") }; 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); let (mut rt, _, parts) = runtime(&library);
if !parts.rolling { 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"); assert!(rt.npc_grains.is_none(), "inert until an NPC bed binds");
let tuning = library.player_tuning(); 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!(near > far && far >= 0.0, "louder near the camera ({near} vs {far})");
assert_eq!(free_voices, 0, "stopped a second after the release"); 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<usize>, 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");
}
@@ -1,12 +1,12 @@
//! The local player's sounds on the native runtime (`skate_audio::player`), hosted per 60 Hz //! The local player's sounds on the native runtime (`skate_audio::player`), hosted by
//! frame by `native::mixmap_frame`: //! `native::mixmap_frame` once per frame that took physics steps (`native::HostClock`):
//! 1. inputs: PlayerPhysics, the two 3DObjPos blocks, Jitter, Contacts, Rail, OffBoard; //! 1. inputs: PlayerPhysics, the two 3DObjPos blocks, Jitter, Contacts, Rail, OffBoard;
//! 2. components' process (posts / releases) — before the MixMap tick; //! 2. components' process (posts / releases) — before the MixMap tick;
//! 3. components' update (packets rewritten from the MixMap outputs, redelivered) — after it. //! 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 //! 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 //! cue tables and their opt-outs are gone; without the banks the skater's sounds are silent and the
//! the banks the skater's sounds are silent and the host logs an error). //! host logs an error).
use std::collections::HashMap; use std::collections::HashMap;
use skate_audio::eval::NodeId; 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 /// console cadence (n console evaluations in this pass, `native::mixmap_frame`), `None`: one
/// step per call (the old 60 Hz host and the tests). /// step per call (the old 60 Hz host and the tests).
pub(crate) jitter_steps: Option<usize>, pub(crate) jitter_steps: Option<usize>,
/// 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], positions: [ObjPos; 2],
was_grinding: bool, was_grinding: bool,
last_camera: Option<[f32; 3]>, last_camera: Option<[f32; 3]>,
@@ -64,9 +55,6 @@ pub(crate) struct PlayerAudio {
pub(crate) seam_alpha: Option<f32>, pub(crate) seam_alpha: Option<f32>,
/// The last two physics samples of the wheel positions (previous, current). /// The last two physics samples of the wheel positions (previous, current).
seam_wheels: Option<([[f32; 3]; 4], [[f32; 3]; 4])>, 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 /// `SFXObj_Contacts`' Splice one-shots (pops, landings, touchdowns); on when the install has
/// the `Skate_Collisions` patch tree ([`PlayerAudio::contacts_on`]). /// the `Skate_Collisions` patch tree ([`PlayerAudio::contacts_on`]).
board: board_contacts::Contacts, board: board_contacts::Contacts,
@@ -123,29 +111,23 @@ impl PlayerAudio {
pub(crate) fn new(tuning: PlayerTuning, components: bool) -> Self { pub(crate) fn new(tuning: PlayerTuning, components: bool) -> Self {
let jitter = Jitter::new(&tuning.jitter, skate_audio::grain::GrainBed::SEED); let jitter = Jitter::new(&tuning.jitter, skate_audio::grain::GrainBed::SEED);
// The collision voices play through their Collision SubMix (`sub_824D25E0`: mono, env send // 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 // at the category's Collision output, eEQChain bus).
// SFX Master as before.
let mut collision = CollisionManager::default(); let mut collision = CollisionManager::default();
collision.submix = !std::env::var("SKATE_AEMS_SUBMIX").is_ok_and(|v| v == "0"); collision.submix = true;
let on = |var: &str| !std::env::var(var).is_ok_and(|v| v == "0");
let mut board = board_contacts::Contacts::default(); 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 // 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. // grind on / off sounds (#5); the bridge's speed graph on the body impacts (`sub_824B0DA8`).
board.plant_lift_on = on("SKATE_AEMS_PLANT_LIFT"); board.plant_lift_on = true;
board.body_on = on("SKATE_AEMS_BODY_IMPACTS"); board.body_on = true;
// The bridge's speed graph on the body impacts (`sub_824B0DA8`, 2026-10-03); board.body_speed_on = true;
// `SKATE_AEMS_BODY_CURVE=0`: the conditioner's impacts as before.
board.body_speed_on = on("SKATE_AEMS_BODY_CURVE");
let mut grind = Grind::default(); let mut grind = Grind::default();
grind.onoff = on("SKATE_AEMS_GRIND_ONOFF"); grind.onoff = true;
Self { Self {
tuning, tuning,
physics: Physics::default(), physics: Physics::default(),
contacts: Contacts::default(), contacts: Contacts::default(),
jitter, jitter,
jitter_steps: None, jitter_steps: None,
body_console: on("SKATE_AEMS_BODY_CONSOLE"),
deck_console: on("SKATE_AEMS_DECK_CONSOLE"),
positions: [ObjPos::default(); 2], positions: [ObjPos::default(); 2],
was_grinding: false, was_grinding: false,
last_camera: None, last_camera: None,
@@ -158,7 +140,6 @@ impl PlayerAudio {
seams: Default::default(), seams: Default::default(),
seam_alpha: None, seam_alpha: None,
seam_wheels: None, seam_wheels: None,
seam_console: false,
board, board,
contact_tuning: ContactsTuning::default(), contact_tuning: ContactsTuning::default(),
contacts_on: false, contacts_on: false,
@@ -204,21 +185,6 @@ impl PlayerAudio {
(self.board.deck_posts, self.board.deck_digest) (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. /// Diagnostics (the e2e harness's `E2E_BODY_LOG`): keep every body-poster message.
#[cfg(test)] #[cfg(test)]
pub(crate) fn set_body_log(&mut self, on: bool) { 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.nodes.insert(slot, rt.post(id, &words));
self.posts += 1; 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 } => { Command::Redeliver { slot, words } => {
if let Some(&node) = self.nodes.get(&slot) { if let Some(&node) = self.nodes.get(&slot) {
@@ -315,7 +283,9 @@ impl PlayerAudio {
Command::Release { slot } => { Command::Release { slot } => {
if let Some(node) = self.nodes.remove(&slot) { if let Some(node) = self.nodes.remove(&slot) {
rt.release(node); 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)); rolling_cmds.extend(self.slide.process(loose, &self.tuning.rolling));
} }
let seam_state = self.seam_state(s); let seam_state = self.seam_state(s);
let seams = if self.seam_console { // Class_Seams' process runs on the console cadence in `seam_frame`; the tick only creates
Self::seam_commands(self.seams.process_tick(&seam_state, &self.tuning, m_mut)) // the packets and writes Cracks.in0.
} else { let seams = Self::seam_commands(self.seams.process_tick(&seam_state, &self.tuning, m_mut));
Self::seam_commands(self.seams.process(&seam_state, &self.tuning, m_mut))
};
let m: &MixMap = m_mut; let m: &MixMap = m_mut;
let rail = Owner { mixmap: m, key: keys::rail(0) }; let rail = Owner { mixmap: m, key: keys::rail(0) };
let mut cmds = self.grind.process(s, &self.tuning, &rail); 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). // The grind on / off contact sounds of this frame's Rail posts (Splice).
self.grind.sounds(s, &self.tuning, &mut rt.splice_host()); self.grind.sounds(s, &self.tuning, &mut rt.splice_host());
let before = self.board.starts; let before = self.board.starts;
self.board.body_calls = if self.body_console { self.jitter_steps } else { None }; // The body / deck posters run once per console evaluation of this pass (their 15 / 6-frame
self.board.deck_calls = if self.deck_console { self.jitter_steps } else { None }; // 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.board.process(s, self.contacts.buckets(), &self.tuning, &self.contact_tuning, &mut rt.splice_host());
self.posts += self.board.starts - before; self.posts += self.board.starts - before;
// The collision manager runs after the player's components (its own state manager). // 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 /// 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. /// board (the last two physics samples interpolated), else the physics sample.
fn seam_state(&mut self, s: &AudioState) -> AudioState { fn seam_state(&mut self, s: &AudioState) -> AudioState {
let Some(alpha) = self.seam_alpha else { return *s }; let Some(alpha) = self.seam_alpha else { return *s };
let now = s.wheel_position; let now = s.wheel_position;
let (prev, cur) = match self.seam_wheels { let (prev, cur) = match self.seam_wheels {
Some((_, cur)) if cur != now => (cur, now), // The host's pair for this sample ([`Self::step_wheels`]).
Some(track) => track, Some(pair) if pair.1 == now => pair,
Some((_, cur)) => (cur, now),
None => (now, now), None => (now, now),
}; };
self.seam_wheels = Some((prev, cur)); 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 /// 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 /// `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 /// 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 /// per console frame; the tick only creates the packets and writes Cracks.in0.
/// [`Self::seam_console`].
pub(crate) fn seam_frame(&mut self, m: &MixMap, s: &AudioState, dt: f32, rt: &mut Runtime) { 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; return;
} }
let seam_state = self.seam_state(s); let seam_state = self.seam_state(s);
@@ -520,11 +505,7 @@ impl PlayerAudio {
let board = Owner { mixmap: m, key: keys::skateboard(0) }; let board = Owner { mixmap: m, key: keys::skateboard(0) };
cmds.extend(self.skid.update(s, &self.tuning, &board)); cmds.extend(self.skid.update(s, &self.tuning, &board));
cmds.extend(self.squeaks.update(s, &board)); cmds.extend(self.squeaks.update(s, &board));
let cracks = Owner { mixmap: m, key: keys::cracks(0) }; // The seams' update runs in `seam_frame` (the console cadence).
// 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)));
}
if self.rolling_on { if self.rolling_on {
cmds.extend(self.rolling.update(s, &self.tuning, &RollingInputs { speed_scale }, &board)); 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<bool> = 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. /// The banks the components post into.
pub(crate) const BANKS: &[&str] = components::BANKS; pub(crate) const BANKS: &[&str] = components::BANKS;
/// Optional component banks: a component runs when its first bank loads (the rest add layers). /// 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 /// 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. /// lowers level(1) a little at most and never raises it.
#[test] #[test]
#[ignore = "needs the private install data"]
fn bed_gain_a_with_the_full_inputs() { 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(); let tuning = library.player_tuning();
if tuning.jitter.is_empty() { 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"); 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)"); 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 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) } 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 rt, names) = runtime(&library);
let mut m = MixMap::from_bytes(&base).unwrap(); let mut m = MixMap::from_bytes(&base).unwrap();
let mut p = PlayerAudio::new(library.player_tuning(), true); let mut p = PlayerAudio::new(library.player_tuning(), true);
@@ -732,6 +721,8 @@ mod tests {
globals(&mut m); 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); 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)); 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); p.process(&mut m, &s, &mut rt, None, 0);
m.tick(1.0 / 60.0); m.tick(1.0 / 60.0);
p.update(&m, &s, &mut rt, None, 0); p.update(&m, &s, &mut rt, None, 0);
@@ -796,6 +787,8 @@ mod tests {
globals(&mut m); 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); 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)); 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); p.process(&mut m, &s, &mut rt, None, 0);
m.tick(1.0 / 60.0); m.tick(1.0 / 60.0);
p.update(&m, &s, &mut rt, None, 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 /// past 0.001 s), then the board in water (1198). Both sound (their levels come from OffBoard
/// outputs 13 / 16, printed with 14 / 15). /// outputs 13 / 16, printed with 14 / 15).
#[test] #[test]
#[ignore = "needs the private install data"]
fn the_water_splash_plays_through_the_real_mixmap() { 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); let (mut rt, _) = runtime(&library);
for stem in SPLICE_BANKS { 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; let r = &mut rt;
r.splice.load_bank(stem, bank, pcm, &mut r.mixer); r.splice.load_bank(stem, bank, pcm, &mut r.mixer);
} }
@@ -846,6 +840,8 @@ mod tests {
globals(&mut m); 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); 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)); 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); p.process(&mut m, &s, &mut rt, None, 0);
m.tick(1.0 / 60.0); m.tick(1.0 / 60.0);
p.update(&m, &s, &mut rt, None, 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_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`). /// Class_foot_drag FOOT_DRAG ≤ 0.28 (medians 0.002–0.07)). Prints the table (`--nocapture`).
#[test] #[test]
#[ignore = "needs the private install data"]
fn components_play_their_retail_banks() { 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)) { 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 metal = skate_audio::player::state::material_of_tag(9);
let concrete = skate_audio::player::state::material_of_tag(3); 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 /// 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). /// rule: the bank is decoded at start, the same path plays every hit).
#[test] #[test]
#[ignore = "needs the private install data"]
fn seams_play_their_retail_bank() { 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(); let tuning = library.player_tuning();
if tuning.seam_patterns.len() < 16 || !library.aems().banks.contains_key("Seams_Bank") { 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"); 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)] { 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 /// hits spread over both eight-sample blocks (32–39 and 80–87, retail 180430), not one sample
/// per block (Listening test 8). /// per block (Listening test 8).
#[test] #[test]
#[ignore = "needs the private install data"]
fn seam_hits_shuffle_their_samples() { 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 tuning = library.player_tuning();
let banks = &library.aems().banks; 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) { 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 v = 30.0f32 / 3.6;
let (mut rt, names) = runtime(&library); let (mut rt, names) = runtime(&library);
@@ -957,6 +956,8 @@ mod tests {
globals(&mut m); 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); 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)); 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); p.process(&mut m, &s, &mut rt, None, 0);
m.tick(1.0 / 60.0); m.tick(1.0 / 60.0);
p.update(&m, &s, &mut rt, None, 0); p.update(&m, &s, &mut rt, None, 0);
@@ -981,7 +982,7 @@ mod tests {
#[test] #[test]
#[ignore = "diagnostic print"] #[ignore = "diagnostic print"]
fn seam_samples_by_pattern() { 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 material: u32 = std::env::var("SEAM_MATERIAL").ok().and_then(|v| v.parse().ok()).unwrap_or(65);
let kmh = 30.0f32; let kmh = 30.0f32;
let v = kmh / 3.6; let v = kmh / 3.6;
@@ -1025,7 +1026,7 @@ mod tests {
#[test] #[test]
#[ignore = "diagnostic print"] #[ignore = "diagnostic print"]
fn seam_samples_by_words() { 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. // 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") 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()) .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, /// 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. /// 30 km/h 730, 35 km/h 1194, 40 km/h 2017): within 1 dB.
#[test] #[test]
#[ignore = "needs the private install data"]
fn wind_level_matches_the_retail_medians() { 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)] { 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 m = MixMap::from_bytes(&mxb).unwrap();
let mut p = PlayerAudio::new(library.player_tuning(), false); let mut p = PlayerAudio::new(library.player_tuning(), false);
+191 -122
View File
@@ -19,7 +19,7 @@ const BRAKE_TIMEOUT: f32 = 0.1;
/// Continuous state sampled at the last physics tick. /// Continuous state sampled at the last physics tick.
#[derive(Clone, Copy, Debug, Default)] #[derive(Clone, Copy, Debug, Default)]
pub(super) struct Riding { pub(crate) struct Riding {
pub board: Vec3, pub board: Vec3,
pub speed: f32, pub speed: f32,
/// Majority audio surface tag under the wheels in contact (0 with none). /// 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_up: f32,
pub deck_contact: bool, pub deck_contact: bool,
pub deck_material: u32, 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)] #[derive(Resource, Default)]
pub(super) struct Cues { pub(crate) struct Cues {
pub riding: Riding, 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)] #[derive(Default)]
pub(super) struct Seen { pub(crate) struct Seen {
started: bool, /// The audio-state builder's per-skater memory.
push: bool, skater: SkaterAudioMemory,
/// Last tick's audio-state push plant (`+333 || +334`, see [`push_plant`]).
planted: bool,
/// Foot braking, time since the last foot-down event, and the last event (+1/-1/0). /// Foot braking, time since the last foot-down event, and the last event (+1/-1/0).
braking: bool, braking: bool,
brake_time: f32, brake_time: f32,
brake_event: i32, brake_event: i32,
on_rail: bool, on_rail: bool,
trace: Option<bool>, trace: Option<bool>,
/// 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`). /// Seconds in the current air phase (audio state `+236`).
air_time: f32, air_time: f32,
/// Latched grind family / material (`+192` / `+692`). /// Latched grind family / material (`+192` / `+692`).
@@ -78,8 +133,6 @@ pub(super) struct Seen {
com_speed_212: f32, com_speed_212: f32,
/// The conditioner's step code (`+740`, `sub_827729B8`). /// The conditioner's step code (`+740`, `sub_827729B8`).
step_code: skate_audio::player::footsteps::StepCode, 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 { 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)) (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<GamePhysics>, physics: Res<GamePhysics>,
mut skater: ResMut<SkaterRuntime>, mut skater: ResMut<SkaterRuntime>,
mut cues: ResMut<Cues>, mut cues: ResMut<Cues>,
@@ -139,7 +279,7 @@ pub(super) fn observe(
if brake_event != 0 && brake_event != seen.brake_event { 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" }); 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}"); 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() }; 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" }, 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); grinds.audio_surface_216, grinds.is_ledge_320, grinds.grinding_316, grinds.leaving_317);
} }
let unridden = board_unridden(state); let started = seen.skater.started;
let (wheels, surface) = if unridden { (0, 0) } else { wheel_surface(&physics) }; let built = build(&physics, &skater, &mut seen.skater, time.delta_secs(), audio.push);
// The animation's push contact edge (the state log's `push` column). debug_assert_eq!((built.state_id, built.on_rail, built.board), (state, on_rail, board));
let push_edge = seen.started && audio.push && !seen.push; let Built { state: audio_state, wheels, surface, plant_edge, push_edge, state55, unridden, .. } = built;
let state55 = skater.player_state.state_flags.get(55 - 52).copied().unwrap_or(false); let com_speed_212 = seen.skater.com_speed_212;
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();
if super::state_log::enabled() { if super::state_log::enabled() {
let p = &skater.player_input.physical; let p = &skater.player_input.physical;
let s = &audio_state; 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`. // 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)); 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, board,
speed: physics.riding.motion.ground_speed, speed: physics.riding.motion.ground_speed,
surface, surface,
@@ -293,41 +393,22 @@ pub(super) fn observe(
deck_up: deck_up(&physics, &skater), deck_up: deck_up(&physics, &skater),
deck_contact: skater.player_input.physical.collision.flag_3475 != 0, 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]), deck_material: skate_audio::player::state::material_of_tag(physics.riding.ground.part_audio_surfaces[2]),
}; wheels_before,
*seen = Seen { });
started: true, seen.braking = braking;
push: audio.push, planted, braking, brake_time, brake_event, on_rail, trace: Some(trace), seen.brake_time = brake_time;
air_time: seen.air_time, seen.brake_event = brake_event;
grind_family: seen.grind_family, seen.on_rail = on_rail;
grind_material: seen.grind_material, seen.log_frame += u64::from(super::state_log::enabled());
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()),
};
} }
/// The audio state's `+236` (Air+176, the time in the air *state*) after this tick. It counts /// 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, /// 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 /// `+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 /// 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, /// same one-tick hold as `+240`).
/// 0 on the landing tick). pub(super) fn air_time_236(previous: f32, state: u32, dt: f32) -> f32 {
pub(super) fn air_time_236(previous: f32, state: u32, airborne: bool, dt: f32, follow_state: bool) -> f32 { if (200..300).contains(&state) { previous + dt } else { 0.0 }
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<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| !std::env::var("SKATE_AEMS_AIR_TIME_STATE").is_ok_and(|v| v == "0"))
} }
/// The audio state's push plant `+333 || +334` and its edge `+335` (the bridge `sub_824B0DA8`): /// 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 /// (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 /// 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 /// 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 /// land +548 ms after the stroke (`+337`); the animation's push contact fires near the stroke.
/// the stroke. `on` false (`SKATE_AEMS_PUSH_PLANT=0`): the animation's push contact, as before. /// Returns (planted, edge) from this tick's State55 and last tick's plant.
/// Returns (planted, edge) from this tick's State55 / push contact and last tick's values. pub(super) fn push_plant(state55: bool, was_planted: bool) -> (bool, bool) {
pub(super) fn push_plant(on: bool, state55: bool, push: bool, was_planted: bool, was_push: bool) -> (bool, bool) { (state55, state55 && !was_planted)
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<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| !std::env::var("SKATE_AEMS_PUSH_PLANT").is_ok_and(|v| v == "0"))
} }
/// What [`audio_state`] needs beyond the physics resources. /// 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 lines = &physics.riding.wheel_lines;
let wheel_contact: [bool; 4] = std::array::from_fn(|i| !f.unridden && ground.parts[i].in_contact); 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; 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; let grinding = f.on_rail;
if p.grinds.grinding_316 != 0 || grinding { if p.grinds.grinding_316 != 0 || grinding {
*f.grind_family = Some(p.grinds.words_136_140[0] as i32); *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, /// `+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 /// 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] #[test]
fn air_time_holds_through_the_landing_tick() { fn air_time_holds_through_the_landing_tick() {
let dt = 1.0 / 60.0; let dt = 1.0 / 60.0;
// (state, wheels down) per tick: rolling, takeoff, 3 air ticks, landing tick, rolling. // (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 ticks = [(103, true), (201, false), (201, false), (201, false), (201, false), (201, true), (100, true)];
let run = |follow: bool| { let mut t = 0.0f32;
let mut t = 0.0f32; let run: Vec<i32> = ticks.iter().map(|&(state, _down)| {
ticks.iter().map(|&(state, down)| { t = air_time_236(t, state, dt);
t = air_time_236(t, state, (200..300).contains(&state) && !down, dt, follow); (t * 60.0).round() as i32
(t * 60.0).round() as i32 }).collect();
}).collect::<Vec<_>>() assert_eq!(run, [0, 1, 2, 3, 4, 5, 0]);
};
assert_eq!(run(true), [0, 1, 2, 3, 4, 5, 0]);
assert_eq!(run(false), [0, 1, 2, 3, 4, 0, 0]);
} }
/// The push plant follows State55 and fires its edge once per plant; off: the animation's push /// The push plant follows State55 (not the animation's push contact) and fires its edge once
/// contact as before. /// per plant.
#[test] #[test]
fn the_push_plant_is_state55_and_its_rise() { 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. // (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 ticks = [(false, true), (false, true), (false, false), (true, false), (true, false), (false, false)];
let run = |on: bool| { let mut planted = false;
let (mut planted, mut push) = (false, false); let run: Vec<(bool, bool)> = ticks.iter().map(|&(s55, _push)| {
ticks.iter().map(|&(s55, p)| { let r = push_plant(s55, planted);
let r = push_plant(on, s55, p, planted, push); planted = r.0;
(planted, push) = (r.0, p); r
r }).collect();
}).collect::<Vec<_>>() assert_eq!(run, [(false, false), (false, false), (false, false), (true, true), (true, false), (false, false)]);
};
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)]);
} }
#[test] #[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 /// 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. /// rounding. The signed form misses the lines with Δv·n < 0.
#[test] #[test]
#[ignore = "needs the private install data"]
fn region_impacts_match_the_recomps_bail_lines() { fn region_impacts_match_the_recomps_bail_lines() {
use skate_core::physics::skeleton_body::ContactRegion; use skate_core::physics::skeleton_body::ContactRegion;
let root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../.local/recomp/sessions")); let root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../.local/recomp/sessions"));
let mut lines = Vec::new(); let mut lines = Vec::new();
for run in ["bailrun_t2_20261003_100040", "bailrun_ok_20261003_100226", "bailrun_ok2_20261003_100514", "bailrun_ok3_20261003_101354"] { 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 { 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")) { for l in text.lines().filter(|l| l.starts_with("BAILREG\t")) {
let f: Vec<&str> = l.split('\t').collect(); let f: Vec<&str> = l.split('\t').collect();
@@ -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<String, f64>);
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<Vec<Row>, String> {
let mut lines = text.lines();
let cols: Vec<String> = 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<Vec<f64>, _> = l.split('\t').map(str::parse::<f64>).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<Row> {
let text = std::fs::read_to_string(path).unwrap();
let mut lines = text.lines();
let cols: Vec<String> = 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::<f64>().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<i32>,
material: Option<u32>,
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<Vec<AudioState>, 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");
}
}
@@ -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<AudioState>,
/// 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<Self, String> {
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<Time>, mut ghosts: Query<(&mut GhostSkater, &mut NpcSkaterAudio, &mut Transform, &mut GlobalTransform)>) {
for (mut ghost, mut npc, mut t, mut g) in &mut ghosts {
let n = ghost.states.len();
let before = ghost.at as usize;
ghost.at += f64::from(time.delta_secs()) * 60.0;
let now = ghost.at as usize;
let mut s = ghost.states[now % n];
s.push_trigger |= (before + 1..now).any(|i| ghost.states[i % n].push_trigger);
if ghost.at >= (n * 1000) as f64 {
ghost.at -= (n * 1000) as f64;
}
t.translation = Vec3::from_array(s.board_position);
*g = GlobalTransform::from(*t);
npc.state = Some(s);
}
}
pub(crate) fn register(app: &mut App) {
app.init_resource::<LivingWorldAudio>()
.init_resource::<WorldAudioStats>()
.init_resource::<Bridge>()
.add_message::<PedSpeechEvent>()
.add_message::<VehicleHorn>()
.add_message::<VehicleAlarm>()
.add_systems(Update, (tag_remote_players, ghost_step, publish.in_set(WorldAudioPublish)).chain().before(super::native::mixmap_frame).after(crate::app::FrameSet::Animation))
.add_systems(Update, read_back.after(super::world_sources::frame).after(super::npc_skaters::frame));
}
/// The bridge's memory between frames.
#[derive(Resource, Default)]
pub(crate) struct Bridge {
/// Last frame's position and speed per entity (velocity / acceleration from the change).
last: HashMap<Entity, ([f32; 3], f32)>,
/// Horn holds from [`VehicleHorn`] / [`VehicleAlarm`]: (state, seconds left).
horns: HashMap<Entity, (HornState, f32)>,
/// Spawn order for skaters without a list order.
order: HashMap<Entity, u32>,
next_order: u32,
/// Engine records by name, and the names already reported unknown.
engines: HashMap<String, EngineRecord>,
unknown: HashSet<String>,
/// Entities that carry a `WorldAudioInstance` now.
tagged: HashSet<Entity>,
/// Something was published last frame (the owners must be cleared once when it stops).
active: bool,
}
/// The ground's audio material under a point (`material_of_tag` of the first surface a 1.5 m line
/// down from 0.5 m above it hits; `NO_MATERIAL` when none): the same stock line query the wheel
/// lines use.
pub(crate) fn ground_material(physics: &crate::physics::GamePhysics, at: Vec3) -> u32 {
use skate_core::math::Vector3;
let start = Vector3::new(at.x, at.y + 0.5, at.z);
let end = Vector3::new(at.x, at.y - 1.0, at.z);
match physics.world().query_thin_line(start, end) {
Ok(Some(hit)) => skate_audio::player::state::material_of_tag(hit.tag & 0x7F),
_ => skate_audio::player::state::NO_MATERIAL,
}
}
fn velocity(bridge: &Bridge, e: Entity, at: [f32; 3], given: Option<&AudioVelocity>, dt: f32) -> [f32; 3] {
if let Some(v) = given {
return v.0.to_array();
}
match bridge.last.get(&e) {
Some((last, _)) if dt > 0.0 => std::array::from_fn(|i| (at[i] - last[i]) / dt),
_ => [0.0; 3],
}
}
fn length(v: [f32; 3]) -> f32 {
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
}
/// The +Z axis of a transform (the game's forward).
fn forward(t: &GlobalTransform) -> [f32; 3] {
let z = t.affine().matrix3.z_axis;
let n = z.length();
if n > 1e-6 { (z / n).to_array() } else { [0.0, 0.0, 1.0] }
}
/// Remote multiplayer players take the NPC skater instance(s) (non-retail extension): tag their
/// roots once; [`publish`] fills their lite state.
fn tag_remote_players(mut commands: Commands, remotes: Query<Entity, (With<crate::multiplayer::appearance::RemoteCharacter>, Without<NpcSkaterAudio>)>) {
for e in &remotes {
commands.entity(e).insert(NpcSkaterAudio { remote: true, ..Default::default() });
}
}
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn publish(
mut bridge: ResMut<Bridge>,
mut owners: ResMut<WorldOwners>,
mut skaters: ResMut<super::npc_skaters::NpcSkaters>,
living: Res<LivingWorldAudio>,
library: Option<Res<super::Library>>,
physics: Option<Res<crate::physics::GamePhysics>>,
time: Res<Time>,
listener: Query<&GlobalTransform, With<super::GameAudioListener>>,
vehicles: Query<(Entity, &GlobalTransform, &TrafficAudio, Option<&AudioVelocity>)>,
mut peds: Query<(Entity, &GlobalTransform, &mut PedAudio, Option<&AudioVelocity>)>,
mut npcs: Query<(Entity, &GlobalTransform, &mut NpcSkaterAudio, Option<&AudioVelocity>)>,
mut speech: MessageReader<PedSpeechEvent>,
mut horns: MessageReader<VehicleHorn>,
mut alarms: MessageReader<VehicleAlarm>,
) {
let any = !vehicles.is_empty() || !peds.is_empty() || !npcs.is_empty();
if owners.expected != living.expected {
owners.expected = living.expected;
}
if !any && !bridge.active && speech.is_empty() && horns.is_empty() && alarms.is_empty() {
return;
}
let bridge = &mut *bridge;
let dt = time.delta_secs();
for e in speech.read() {
if let Ok((_, _, mut ped, _)) = peds.get_mut(e.ped) {
ped.speech_value = e.value.0;
}
}
for h in horns.read() {
bridge.horns.insert(h.vehicle, (HornState::Honk(h.kind.clamp(1, 5)), h.seconds.max(0.0)));
}
for a in alarms.read() {
bridge.horns.insert(a.vehicle, (HornState::Alarm, ALARM_SECONDS));
}
for (_, left) in bridge.horns.values_mut() {
*left -= dt;
}
bridge.horns.retain(|e, (_, left)| *left > 0.0 && vehicles.contains(*e));
let camera = listener.single().map(|t| t.translation()).unwrap_or(Vec3::ZERO);
// Vehicles.
owners.vehicles.clear();
let mut seen = Vec::with_capacity(vehicles.iter().len());
for (e, t, car, given) in &vehicles {
let at = t.translation().to_array();
let v = velocity(bridge, e, at, given, dt);
let speed = car.speed.unwrap_or_else(|| length(v)).max(0.0);
let last_speed = bridge.last.get(&e).map_or(speed, |l| l.1);
let load = car.load.unwrap_or(if dt > 0.0 { (speed - last_speed) / dt } else { 0.0 });
let record = engine_record(bridge, library.as_deref(), &car.engine);
let horn = bridge.horns.get(&e).map_or(car.horn, |h| h.0);
owners.vehicles.insert(
e.to_bits(),
VehicleState { position: at, velocity: v, direction: forward(t), speed, load, horn: horn.word(), skid: i32::from(car.skidding), engine: record },
);
seen.push((e, at, speed));
}
// Peds: the nearest-first list within 50 m gives the footsteps-on rule.
owners.peds.clear();
let mut list: Vec<(f32, Entity)> = peds.iter().map(|(e, t, _, _)| (t.translation().distance(camera), e)).filter(|(d, _)| *d < PED_LIST_RADIUS).collect();
list.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.to_bits().cmp(&b.1.to_bits())));
let nearest: HashSet<Entity> = list.iter().take(FOOTSTEP_PEDS).map(|x| x.1).collect();
for (e, t, ped, given) in &peds {
let at = t.translation().to_array();
let v = velocity(bridge, e, at, given, dt);
let distance = t.translation().distance(camera);
let (measure, limit) = ped.speech_distance.unwrap_or((distance, PED_FAR_THRESHOLD));
owners.peds.insert(
e.to_bits(),
skate_audio::world::peds::PedState {
position: at,
velocity: v,
speed: length(v),
feet: ped.feet_down,
footsteps: ped.footsteps_on.unwrap_or_else(|| nearest.contains(&e)),
materials: ped.foot_materials.unwrap_or([0, 0]),
speech_value: ped.speech_value,
class: i32::from(ped.shoe_class.clamp(1, 5)),
weight: i32::from(ped.weight.clamp(1, 5)),
close: ped.close_range,
speech_measure: measure,
speech_limit: limit,
},
);
seen.push((e, at, 0.0));
}
// NPC / remote skaters, in list order.
skaters.skaters.clear();
let mut list: Vec<(bool, u32, u32, u64, AudioState)> = Vec::new();
for (e, t, mut npc, given) in &mut npcs {
let at = t.translation().to_array();
if npc.remote {
let v = velocity(bridge, e, at, given, dt);
let f = forward(t);
let material = physics.as_deref().map_or(skate_audio::player::state::NO_MATERIAL, |p| ground_material(p, t.translation()));
npc.state = Some(AudioState::rolling(&LiteSkater { position: at, velocity: v, heading: f[0].atan2(f[2]), material, dt: dt.max(1e-4), ..Default::default() }));
}
seen.push((e, at, 0.0));
let Some(state) = npc.state else { continue };
let spawn = *bridge.order.entry(e).or_insert_with(|| {
bridge.next_order += 1;
bridge.next_order
});
list.push((!npc.remote, if npc.list_order == 0 { u32::MAX } else { npc.list_order }, spawn, e.to_bits(), state));
}
list.sort_by(|a, b| (a.0, a.1, a.2).cmp(&(b.0, b.1, b.2)));
skaters.skaters.extend(list.into_iter().map(|(_, _, _, id, state)| skate_audio::world::skaters::NpcSkaterAudioState { id, state }));
bridge.last.clear();
for (e, at, speed) in seen {
bridge.last.insert(e, (at, speed));
}
bridge.order.retain(|e, _| npcs.contains(*e));
bridge.active = any;
}
fn engine_record(bridge: &mut Bridge, library: Option<&super::Library>, name: &str) -> EngineRecord {
if let Some(r) = bridge.engines.get(name) {
return *r;
}
let Some(library) = library else { return EngineRecord::default() };
let tuning = library.world_tuning();
let record = match tuning.engine(&name.to_ascii_lowercase()) {
Some(r) => r,
None => {
if bridge.unknown.insert(name.to_owned()) {
warn!("AUDIO_WORLD unknown traffic engine record {name:?}: the default record (silent)");
}
tuning.engine("default").unwrap_or_default()
}
};
bridge.engines.insert(name.to_owned(), record);
record
}
/// `WorldAudioInstance` on the holders, and the stats.
fn read_back(
mut commands: Commands,
mut bridge: ResMut<Bridge>,
held: Res<WorldHeld>,
owners: Res<WorldOwners>,
skaters: Res<super::npc_skaters::NpcSkaters>,
native: Option<Res<super::native::Native>>,
mut stats: ResMut<WorldAudioStats>,
instances: Query<&WorldAudioInstance>,
) {
if !bridge.active && bridge.tagged.is_empty() {
return;
}
let mut now: HashMap<Entity, WorldAudioInstance> = HashMap::new();
for (list, slot) in [(&held.traffic, WorldAudioSlot::Traffic), (&held.peds, WorldAudioSlot::Ped), (&held.skaters, WorldAudioSlot::PlayerSlot)] {
for &(id, instance) in list {
now.insert(Entity::from_bits(id), WorldAudioInstance { slot, instance });
}
}
for e in bridge.tagged.clone() {
if !now.contains_key(&e) {
if let Ok(mut ec) = commands.get_entity(e) {
ec.try_remove::<WorldAudioInstance>();
}
bridge.tagged.remove(&e);
}
}
for (e, tag) in now {
if instances.get(e).ok() != Some(&tag) {
if let Ok(mut ec) = commands.get_entity(e) {
ec.try_insert(tag);
bridge.tagged.insert(e);
}
}
}
let world = native.as_deref().map_or(super::native::WorldInstances::RETAIL, |n| n.world);
let next = WorldAudioStats {
vehicles: owners.vehicles.len(),
peds: owners.peds.len(),
skaters: skaters.skaters.len(),
traffic_held: held.traffic.len(),
peds_held: held.peds.len(),
skaters_held: held.skaters.len(),
instances: (world.traffic, world.peds, world.npc),
more_audible: world != super::native::WorldInstances::RETAIL,
};
if *stats != next {
*stats = next;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<WorldOwners>().init_resource::<super::super::npc_skaters::NpcSkaters>().init_resource::<WorldHeld>();
app.init_resource::<LivingWorldAudio>().init_resource::<WorldAudioStats>().init_resource::<Bridge>();
app.add_message::<PedSpeechEvent>().add_message::<VehicleHorn>().add_message::<VehicleAlarm>();
app.add_systems(Update, (publish, read_back).chain());
app.world_mut().spawn((super::super::GameAudioListener, Transform::default(), GlobalTransform::default()));
app
}
fn at(x: f32, z: f32) -> (Transform, GlobalTransform) {
let t = Transform::from_xyz(x, 0.0, z);
(t, GlobalTransform::from(t))
}
/// Components become owners (ids = entity bits), events hold their states for the right
/// time, despawning releases, and the read-back tags the holders.
#[test]
fn components_become_owners_and_events_hold_states() {
let mut app = app();
let car = app.world_mut().spawn((TrafficAudio::new("c04_taxi01"), at(0.0, 10.0), AudioVelocity(Vec3::new(0.0, 0.0, 8.0)))).id();
let peds: Vec<Entity> = (0..5).map(|i| app.world_mut().spawn((PedAudio { voice: Some(59), ..Default::default() }, at(1.0, 2.0 + i as f32))).id()).collect();
let far = app.world_mut().spawn((PedAudio::default(), at(0.0, 60.0))).id();
let npc = app.world_mut().spawn((NpcSkaterAudio { state: Some(AudioState::default()), ..Default::default() }, at(3.0, 3.0))).id();
let remote_like = app.world_mut().spawn((NpcSkaterAudio { state: Some(AudioState::default()), remote: true, ..Default::default() }, at(4.0, 3.0))).id();
app.update();
{
let owners = app.world().resource::<WorldOwners>();
let v = owners.vehicles[&car.to_bits()];
assert_eq!(v.velocity, [0.0, 0.0, 8.0]);
assert_eq!(v.speed, 8.0);
assert_eq!(v.direction, [0.0, 0.0, 1.0]);
assert_eq!(owners.peds.len(), 6);
// The 3 nearest within 50 m step; the others (and the one beyond 50 m) don't.
let on: Vec<bool> = peds.iter().map(|p| owners.peds[&p.to_bits()].footsteps).collect();
assert_eq!(on, [true, true, true, false, false]);
assert!(!owners.peds[&far.to_bits()].footsteps);
assert_eq!(owners.peds[&far.to_bits()].speech_limit, PED_FAR_THRESHOLD);
// Remote players first in the list order.
let s = app.world().resource::<super::super::npc_skaters::NpcSkaters>();
assert_eq!(s.skaters.iter().map(|s| s.id).collect::<Vec<_>>(), vec![remote_like.to_bits(), npc.to_bits()]);
}
// Events.
app.world_mut().write_message(VehicleAlarm { vehicle: car });
app.world_mut().write_message(PedSpeechEvent { ped: peds[0], value: SpeechValue::WARN });
app.update();
assert_eq!(app.world().resource::<WorldOwners>().vehicles[&car.to_bits()].horn, 6);
assert_eq!(app.world().get::<PedAudio>(peds[0]).unwrap().speech_value, 11);
app.world_mut().write_message(VehicleHorn { vehicle: car, kind: 3, seconds: 0.0 });
app.update();
assert_eq!(app.world().resource::<WorldOwners>().vehicles[&car.to_bits()].horn, 0, "a zero-length horn ends at once");
// Read-back.
app.world_mut().resource_mut::<WorldHeld>().traffic = vec![(car.to_bits(), 2)];
app.update();
assert_eq!(app.world().get::<WorldAudioInstance>(car), Some(&WorldAudioInstance { slot: WorldAudioSlot::Traffic, instance: 2 }));
app.world_mut().resource_mut::<WorldHeld>().traffic.clear();
app.update();
assert!(app.world().get::<WorldAudioInstance>(car).is_none());
// Despawn = release: the owner disappears.
app.world_mut().despawn(car);
app.update();
assert!(app.world().resource::<WorldOwners>().vehicles.is_empty());
for p in peds.into_iter().chain([far, npc, remote_like]) {
app.world_mut().despawn(p);
}
app.update();
assert!(app.world().resource::<WorldOwners>().peds.is_empty());
assert!(app.world().resource::<super::super::npc_skaters::NpcSkaters>().skaters.is_empty());
}
/// No component, nothing held: the bridge does not touch the owners (no change detection).
#[test]
fn inert_without_components() {
let mut app = app();
app.update();
app.update();
assert!(app.world().resource::<WorldOwners>().vehicles.is_empty());
assert!(!app.world().resource::<Bridge>().active);
}
}
+210 -22
View File
@@ -185,7 +185,7 @@ fn prefetch_world_banks(p: &mut WorldPrefetch, native: &mut Native, library: &Li
} }
#[derive(Resource)] #[derive(Resource)]
struct WorldHost { pub(crate) struct WorldHost {
/// None: not tried yet; Some(false): some world banks are missing (warned once). /// None: not tried yet; Some(false): some world banks are missing (warned once).
banks: Option<bool>, banks: Option<bool>,
traffic: Pool, traffic: Pool,
@@ -198,8 +198,12 @@ struct WorldHost {
rng: Lcg, rng: Lcg,
ped_tuning: Option<PedFootstepTuning>, ped_tuning: Option<PedFootstepTuning>,
player_tuning: Option<skate_audio::player::tuning::PlayerTuning>, player_tuning: Option<skate_audio::player::tuning::PlayerTuning>,
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)>,
prefetch: WorldPrefetch, prefetch: WorldPrefetch,
/// `Native::map_epoch` this host last ran in (None: never ran; [`WorldHost::reset`]).
epoch: Option<u64>,
} }
impl Default for WorldHost { impl Default for WorldHost {
@@ -218,12 +222,35 @@ impl Default for WorldHost {
player_tuning: None, player_tuning: None,
last_camera: None, last_camera: None,
prefetch: WorldPrefetch::default(), prefetch: WorldPrefetch::default(),
epoch: None,
} }
} }
} }
/// Which owners hold a MixMap instance after the last evaluation (the hosts write it; the
/// engine-facing bridge turns it into `world_audio::WorldAudioInstance`).
#[derive(Resource, Default, Debug, Clone, PartialEq)]
pub(crate) struct WorldHeld {
/// (owner, instance) of the Traffic / Pedestrian pools.
pub(crate) traffic: Vec<(u64, u32)>,
pub(crate) peds: Vec<(u64, u32)>,
/// (owner, Player-slot instance ≥ 1) of the NPC / remote skaters (`npc_skaters.rs`).
pub(crate) skaters: Vec<(u64, u32)>,
}
/// Retail's traffic list is cut at 40 m horizontal distance to the listener (vehicle record
/// `+152`, `sub_824B2A28`; recomp gap run G1: never above 39.994 m), and its 4 instances went to
/// the 4 nearest (horizontal) in 311 / 311 holder-seconds.
pub(crate) const TRAFFIC_LIST_RADIUS: f32 = 40.0;
/// Retail's ped list is sorted nearest first (3-D distance, `+148`) and cut at 50 m; the 15
/// instances = its first 15 (manager `sub_824F2890`, gap run G2).
pub(crate) const PED_LIST_RADIUS: f32 = 50.0;
pub(crate) fn register(app: &mut App) { pub(crate) fn register(app: &mut App) {
app.init_resource::<WorldOwners>().init_resource::<WorldHost>().add_systems(Update, frame.after(super::native::mixmap_frame)); app.init_resource::<WorldOwners>()
.init_resource::<WorldHost>()
.init_resource::<WorldHeld>()
.add_systems(Update, frame.after(super::native::mixmap_frame));
} }
fn apply(host: &mut WorldHost, rt: &mut skate_audio::runtime::Runtime, cmds: Vec<WorldCommand>) { fn apply(host: &mut WorldHost, rt: &mut skate_audio::runtime::Runtime, cmds: Vec<WorldCommand>) {
@@ -257,10 +284,78 @@ fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
} }
fn frame( fn horizontal(a: [f32; 3], b: [f32; 3]) -> f32 {
((a[0] - b[0]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
/// A candidate inside the list radius keeps its distance; one outside never gets an instance.
fn within(d: f32, radius: f32) -> f32 {
if d < radius { d } else { f32::INFINITY }
}
/// The seconds one MixMap evaluation covers (`native::mixmap_frame`: the console cadence, 1/30).
pub(super) fn evaluation_dt() -> f32 {
CONSOLE_DT
}
impl WorldHost {
/// The map changed (`Native::map_epoch`, bumped by `unload_map_banks`), or the host runs for
/// the first time: the unload destroyed every instance of the world banks, so every held node
/// is released (harmless on a dead node; it frees them), the pools forget their holders, the
/// held instances' 3DObjPos blocks go inactive, the ped Splice steps stop, and the per-owner
/// objects are dropped. An owner that is still published is claimed again and posts afresh.
/// The banks reload at the next owner (`ensure_bank`, through the prefetch when expected).
/// The pools take the MixMap's instance counts (`Native::world`).
fn reset(&mut self, native: &mut Native) {
self.epoch = Some(native.map_epoch);
let traffic = self.traffic.clear();
let peds = self.peds.clear();
let Native { mixmap, shared, world, .. } = native;
if !self.nodes.is_empty() || !self.ped_objects.is_empty() {
if let Ok(mut runtime) = super::timing::lock(shared, &super::timing::GAME_LOCK) {
let rt = &mut *runtime;
for (owner, (mut sfx, _, _)) in std::mem::take(&mut self.ped_objects) {
let _ = sfx.release(owner, &mut rt.splice_host());
}
for (_, node) in self.nodes.drain() {
rt.release(node);
}
}
}
self.ped_objects.clear();
self.vehicles.clear();
self.nodes.clear();
if let Some(m) = mixmap.as_mut() {
let l = Listener::default();
for (_, g) in traffic {
Positions::new(&[keys::traffic_pos(g as u32, 1), keys::traffic_pos(g as u32, 2), keys::traffic_pos(g as u32, 3)]).deactivate(m, &l);
}
for (_, g) in peds {
Positions::new(&[keys::ped_pos(g as u32)]).deactivate(m, &l);
}
self.last_tick = self.last_tick.min(m.ticks);
}
if self.traffic.len() != world.traffic {
self.traffic = Pool::new(world.traffic);
}
if self.peds.len() != world.peds {
self.peds = Pool::new(world.peds);
}
self.banks = None;
self.last_camera = None;
}
fn held(&self) -> (Vec<(u64, u32)>, Vec<(u64, u32)>) {
(self.traffic.holders().map(|(g, o)| (o, g as u32)).collect(), self.peds.holders().map(|(g, o)| (o, g as u32)).collect())
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn frame(
native: Option<ResMut<Native>>, native: Option<ResMut<Native>>,
owners: Res<WorldOwners>, owners: Res<WorldOwners>,
mut host: ResMut<WorldHost>, mut host: ResMut<WorldHost>,
mut held: ResMut<WorldHeld>,
library: Option<Res<Library>>, library: Option<Res<Library>>,
cues: Res<super::skate_events::Cues>, cues: Res<super::skate_events::Cues>,
listener: Query<&GlobalTransform, With<super::GameAudioListener>>, listener: Query<&GlobalTransform, With<super::GameAudioListener>>,
@@ -274,17 +369,33 @@ fn frame(
if !requested() { if !requested() {
return; return;
} }
let host = &mut *host; let camera = listener.single().ok().map(|t| (t.translation().to_array(), t.forward().as_vec3().to_array()));
if prefetch_on() { run(&mut host, &owners, &mut native, &library, camera, &cues.riding.audio);
prefetch_world_banks(&mut host.prefetch, &mut native, &library, owners.expected); let (traffic, peds) = host.held();
if held.traffic != traffic || held.peds != peds {
held.traffic = traffic;
held.peds = peds;
} }
}
/// One frame of the host (after the inert checks): the prefetch, the map-change reset, the banks,
/// and per MixMap evaluation the instance assignment, update and process. `camera` = the listener
/// (position, forward); `local` = the local player's audio state (the followed point).
pub(crate) fn run(host: &mut WorldHost, owners: &WorldOwners, native: &mut Native, library: &Library, camera: Option<([f32; 3], [f32; 3])>, local: &skate_audio::player::AudioState) {
if prefetch_on() {
prefetch_world_banks(&mut host.prefetch, native, library, owners.expected);
}
if host.epoch != Some(native.map_epoch) {
host.reset(native);
}
let idle = owners.vehicles.is_empty() && owners.peds.is_empty() && host.vehicles.is_empty() && host.ped_objects.is_empty();
if idle { if idle {
return; return;
} }
// The world banks (cheap when loaded; a map change unloads them, `native::unload_map_banks`). // The world banks (cheap when loaded; a map change unloads them, `native::unload_map_banks`).
let mut missing = false; let mut missing = false;
for stem in TRAFFIC_BANKS.iter().chain(PED_BANKS) { for stem in TRAFFIC_BANKS.iter().chain(PED_BANKS) {
if let Err(e) = native.ensure_bank(&library, stem) { if let Err(e) = native.ensure_bank(library, stem) {
if host.banks.is_none() { if host.banks.is_none() {
warn!("Game audio: world sources: {e} (rerun setup to refresh the audio)"); warn!("Game audio: world sources: {e} (rerun setup to refresh the audio)");
} }
@@ -297,22 +408,23 @@ fn frame(
info!("AUDIO_WORLD on: {} vehicles, {} peds published", owners.vehicles.len(), owners.peds.len()); info!("AUDIO_WORLD on: {} vehicles, {} peds published", owners.vehicles.len(), owners.peds.len());
} }
host.banks = Some(!missing); host.banks = Some(!missing);
let Ok(camera) = listener.single() else { return }; let Some((cam, view)) = camera else { return };
let native = &mut *native; let native = &mut *native;
let Some(m) = native.mixmap.as_mut() else { return }; let Some(m) = native.mixmap.as_mut() else { return };
if m.ticks == host.last_tick { if m.ticks == host.last_tick {
return; return;
} }
let evaluations = m.ticks - host.last_tick; // The MixMap is built once and never rebuilt, so its tick count only grows; the saturating
// difference keeps a host that ran ahead (a reset, a new MixMap in a test) from underflowing.
let evaluations = m.ticks.saturating_sub(host.last_tick).max(1);
host.last_tick = m.ticks; host.last_tick = m.ticks;
let dt = CONSOLE_DT * evaluations.min(4) as f32; let dt = evaluation_dt() * evaluations.min(4) as f32;
let cam = camera.translation().to_array(); let cam_velocity = host.last_camera.filter(|l| l.1 == native.cuts).map_or([0.0; 3], |(last, _)| std::array::from_fn(|i| (cam[i] - last[i]) / dt));
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, native.cuts));
host.last_camera = Some(cam); let s = local;
let s = &cues.riding.audio;
let l = Listener { let l = Listener {
camera: cam, camera: cam,
view: camera.forward().as_vec3().to_array(), view,
camera_velocity: cam_velocity, camera_velocity: cam_velocity,
followed: s.com_position, followed: s.com_position,
facing: s.com_velocity, facing: s.com_velocity,
@@ -324,9 +436,10 @@ fn frame(
let ped_tuning = host.ped_tuning.take().unwrap_or_default(); let ped_tuning = host.ped_tuning.take().unwrap_or_default();
let player_tuning = host.player_tuning.take().unwrap_or_default(); let player_tuning = host.player_tuning.take().unwrap_or_default();
// Instances by distance (provisional rule, `owners::Pool`). // Instances: the nearest N inside retail's list radii (traffic: horizontal, 40 m; peds: 3-D,
let traffic_candidates: Vec<(u64, f32)> = owners.vehicles.iter().map(|(&id, v)| (id, distance(v.position, cam))).collect(); // 50 m). `owners::Pool`.
let ped_candidates: Vec<(u64, f32)> = owners.peds.iter().map(|(&id, p)| (id, distance(p.position, cam))).collect(); let traffic_candidates: Vec<(u64, f32)> = owners.vehicles.iter().map(|(&id, v)| (id, within(horizontal(v.position, cam), TRAFFIC_LIST_RADIUS))).collect();
let ped_candidates: Vec<(u64, f32)> = owners.peds.iter().map(|(&id, p)| (id, within(distance(p.position, cam), PED_LIST_RADIUS))).collect();
let traffic = host.traffic.assign(&traffic_candidates); let traffic = host.traffic.assign(&traffic_candidates);
let peds = host.peds.assign(&ped_candidates); let peds = host.peds.assign(&ped_candidates);
for (owner, _) in traffic.released { for (owner, _) in traffic.released {
@@ -423,14 +536,15 @@ mod tests {
/// map change (`unload_map_banks`) makes them ask again, and an unexpected world drops the /// map change (`unload_map_banks`) makes them ask again, and an unexpected world drops the
/// unused ones. /// unused ones.
#[test] #[test]
#[ignore = "needs the private install data"]
fn world_banks_are_prefetched_when_expected_and_load_without_decoding() { fn world_banks_are_prefetched_when_expected_and_load_without_decoding() {
use super::super::library::WAV_DECODES; use super::super::library::WAV_DECODES;
let root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets")); 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 Ok(mut native) = Native::start(&library) else { return eprintln!("skipped: no AEMS install") }; let Ok(mut native) = Native::start(&library) else { panic!("missing private data: no AEMS install") };
let stems: Vec<&str> = TRAFFIC_BANKS.iter().chain(PED_BANKS).copied().filter(|s| library.bank_source(s).is_ok()).collect(); let stems: Vec<&str> = TRAFFIC_BANKS.iter().chain(PED_BANKS).copied().filter(|s| library.bank_source(s).is_ok()).collect();
if stems.is_empty() { if stems.is_empty() {
return eprintln!("skipped: no world banks in the install"); panic!("missing private data: no world banks in the install");
} }
let snapshot = |n: &Native| { let snapshot = |n: &Native| {
let rt = n.shared.lock().unwrap(); let rt = n.shared.lock().unwrap();
@@ -504,4 +618,78 @@ mod tests {
native.ensure_bank(&library, stems[0]).unwrap(); native.ensure_bank(&library, stems[0]).unwrap();
assert_eq!(WAV_DECODES.with(|n| n.get()) - decodes, library.bank_pcm(stems[0]).len() as u64, "decoded here without a prefetch"); assert_eq!(WAV_DECODES.with(|n| n.get()) - decodes, library.bank_pcm(stems[0]).len() as u64, "decoded here without a prefetch");
} }
/// The map-change regression (spec §2.1, data-gated): a vehicle and a ped publish, their
/// instances post and the C04 engine sounds; `unload_map_banks` destroys the world banks'
/// instances; the same ids keep publishing and must post again (before the epoch reset the
/// engine kept redelivering to its dead node and stayed silent for the rest of its life).
/// Also: with no owners a map change costs nothing but the epoch bookkeeping.
#[test]
#[ignore = "needs the private install data"]
fn world_owners_post_again_after_a_map_change() {
use skate_audio::world::peds::PedState;
let root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets"));
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 Some(engine) = library.world_tuning().engine("c04_taxi01") else { panic!("missing private data: no world tuning") };
let mut host = WorldHost::default();
let mut owners = WorldOwners::default();
let local = skate_audio::player::AudioState::default();
let camera = Some(([0.0, 1.5, 0.0], [0.0, 0.0, 1.0]));
// Zero owners: the first run only takes the epoch.
run(&mut host, &owners, &mut native, &library, camera, &local);
assert_eq!(host.epoch, Some(native.map_epoch));
assert!(host.nodes.is_empty() && host.vehicles.is_empty());
let (car, ped) = (7u64, 9u64);
let engine_voices = |native: &Native| {
let Some(bank) = native.bank_id("C04_taxi01") else { return 0 };
native.shared.lock().unwrap().mixer.snapshot().iter().filter(|v| v.bank == bank).count()
};
let step = |native: &mut Native, host: &mut WorldHost, owners: &mut WorldOwners, frames: usize| {
let mut most = 0;
for f in 0..frames {
let z = 8.0 + (f % 60) as f32 * 0.2;
owners.vehicles.insert(car, VehicleState { position: [3.0, 0.5, z], velocity: [0.0, 0.0, 12.0], speed: 12.0, engine, ..Default::default() });
owners.peds.insert(ped, PedState { position: [1.5, 0.0, 4.0], velocity: [0.0, 0.0, 1.3], speed: 1.3, feet: [f % 20 < 10, f % 20 >= 10], class: 2, weight: 1, ..Default::default() });
let m = native.mixmap.as_mut().unwrap();
// As `native::mixmap_frame`: the category gains, then the tick.
for id in 1..=4 {
m.set_input(skate_audio::mixmap::keys::MASTER, id, 32767);
}
for id in [1, 2, 5] {
m.set_input(skate_audio::mixmap::keys::MUSIC, id, 32767);
}
m.set_input(skate_audio::mixmap::keys::REVERB, 5, 32767);
m.tick(CONSOLE_DT);
run(host, owners, native, &library, camera, &local);
{
let mut rt = native.shared.lock().unwrap();
for _ in 0..7 {
rt.render_block();
}
}
most = most.max(engine_voices(native));
}
most
};
assert!(step(&mut native, &mut host, &mut owners, 40) > 0, "the engine sounds before the map change");
assert!(host.nodes.contains_key(&(car, WorldSlot::Engine)));
let before = host.nodes[&(car, WorldSlot::Engine)];
native.unload_map_banks();
assert!(!native.bank_loaded("C04_taxi01"));
assert!(step(&mut native, &mut host, &mut owners, 40) > 0, "the same owner sounds again after the map change");
assert_ne!(host.nodes[&(car, WorldSlot::Engine)], before, "posted afresh, not redelivered to the dead node");
assert_eq!(host.peds.instance(ped), Some(0), "the ped holds an instance again");
assert!(host.nodes.keys().any(|k| k.0 == ped), "the ped's footsteps posted again");
// The owners go: everything is released.
owners.vehicles.clear();
owners.peds.clear();
native.mixmap.as_mut().unwrap().tick(CONSOLE_DT);
run(&mut host, &owners, &mut native, &library, camera, &local);
assert!(host.nodes.is_empty() && host.vehicles.is_empty() && host.ped_objects.is_empty());
// A map change with nothing held.
native.unload_map_banks();
run(&mut host, &owners, &mut native, &library, camera, &local);
assert_eq!(host.epoch, Some(native.map_epoch));
}
} }
+1
View File
@@ -54,6 +54,7 @@ mod skate_world;
mod map_validation; mod map_validation;
mod water_splash; mod water_splash;
mod game_audio; mod game_audio;
pub(crate) mod world_audio;
mod water_bodies; mod water_bodies;
fn main() -> bevy::app::AppExit { fn main() -> bevy::app::AppExit {
+12
View File
@@ -17,6 +17,7 @@ mod camera_stream;
pub(crate) use participation::{player_suspended, peer_suspended}; pub(crate) use participation::{player_suspended, peer_suspended};
mod session; mod session;
mod volumes; mod volumes;
mod world_audio;
mod capture; mod capture;
pub(crate) mod player_physics; pub(crate) mod player_physics;
@@ -198,6 +199,7 @@ impl Plugin for ModdingPlugin {
.init_resource::<ModMenu>(); .init_resource::<ModMenu>();
menu::install(app); menu::install(app);
audio::install(app); audio::install(app);
world_audio::install(app);
graphics_dynamic::install(app); graphics_dynamic::install(app);
capture::install(app); capture::install(app);
app.add_systems( app.add_systems(
@@ -528,6 +530,8 @@ fn snapshot_ro(world: &World, mods: &mut Mods, camera: Option<[f32; 3]>) -> serd
"volumes": mods.manager.packages.keys().map(|owner| { "volumes": mods.manager.packages.keys().map(|owner| {
(owner.clone(), volumes::snapshot(mods, owner, &local_id, &observation::local(world), &mods.skater_remote)) (owner.clone(), volumes::snapshot(mods, owner, &local_id, &observation::local(world), &mods.skater_remote))
}).collect::<serde_json::Map<String, Value>>(), }).collect::<serde_json::Map<String, Value>>(),
"world_audio": mods.manager.packages.keys().map(|owner| (owner.clone(), world_audio::snapshot(world, owner))).collect::<serde_json::Map<String, Value>>(),
"world_audio_info": world_audio::info(world),
"session": session::lua(mods, net.get("active").and_then(Value::as_bool).unwrap_or(false), &local_id, host, &host_id, &players), "session": session::lua(mods, net.get("active").and_then(Value::as_bool).unwrap_or(false), &local_id, host, &host_id, &players),
"attach": mods.attach.as_ref().map(|a| json!({"body": a.body, "owner": a.owner})), "attach": mods.attach.as_ref().map(|a| json!({"body": a.body, "owner": a.owner})),
"detach_error": mods.detach_error, "detach_error": mods.detach_error,
@@ -730,6 +734,7 @@ fn update(world: &mut World) {
fn clear_runtime(world: &mut World, mods: &mut Mods) { fn clear_runtime(world: &mut World, mods: &mut Mods) {
replication::reset(world,mods); replication::reset(world,mods);
audio::clear(world); audio::clear(world);
world_audio::clear(world);
graphics_dynamic::clear(world); graphics_dynamic::clear(world);
canvas::clear_owner(world, &mut mods.canvases, None); canvas::clear_owner(world, &mut mods.canvases, None);
detach_player(world, mods, true); detach_player(world, mods, true);
@@ -796,6 +801,7 @@ fn apply(world: &mut World, mods: &mut Mods) {
mods.command_results.retain(|(owner,_),_|owner!=id); mods.command_results.retain(|(owner,_),_|owner!=id);
mods.input_overrides.retain(|_,(owner,_)|owner!=id); mods.input_overrides.retain(|_,(owner,_)|owner!=id);
audio::stop_owner(world, id, true); audio::stop_owner(world, id, true);
world_audio::clear_owner(world, id);
graphics_dynamic::clear_owner(world, id); graphics_dynamic::clear_owner(world, id);
volumes::clear_owner(world, mods, id); volumes::clear_owner(world, mods, id);
capture::clear_owner(world, id); capture::clear_owner(world, id);
@@ -868,6 +874,7 @@ fn apply(world: &mut World, mods: &mut Mods) {
mods.manager.fail(&id, e); mods.manager.fail(&id, e);
mods.input_overrides.retain(|_,(owner,_)|owner!=&id); mods.input_overrides.retain(|_,(owner,_)|owner!=&id);
audio::stop_owner(world, &id, true); audio::stop_owner(world, &id, true);
world_audio::clear_owner(world, &id);
graphics_dynamic::clear_owner(world, &id); graphics_dynamic::clear_owner(world, &id);
volumes::clear_owner(world, mods, &id); volumes::clear_owner(world, mods, &id);
capture::clear_owner(world, &id); capture::clear_owner(world, &id);
@@ -893,6 +900,7 @@ fn apply(world: &mut World, mods: &mut Mods) {
mods.manager.fail(&id, e); mods.manager.fail(&id, e);
mods.input_overrides.retain(|_,(owner,_)|owner!=&id); mods.input_overrides.retain(|_,(owner,_)|owner!=&id);
audio::stop_owner(world, &id, true); audio::stop_owner(world, &id, true);
world_audio::clear_owner(world, &id);
graphics_dynamic::clear_owner(world, &id); graphics_dynamic::clear_owner(world, &id);
volumes::clear_owner(world, mods, &id); volumes::clear_owner(world, mods, &id);
capture::clear_owner(world, &id); capture::clear_owner(world, &id);
@@ -962,6 +970,10 @@ fn apply_one(
Command::AudioUpdate { key, options } => audio::update_voice(world, id, &key, options), Command::AudioUpdate { key, options } => audio::update_voice(world, id, &key, options),
Command::AudioStop { key, fade_out } => audio::stop(world, id, &key, fade_out), Command::AudioStop { key, fade_out } => audio::stop(world, id, &key, fade_out),
Command::AudioStopAll {} => audio::stop_owner(world, id, false), Command::AudioStopAll {} => audio::stop_owner(world, id, false),
Command::WorldAudioSpawn { key, object, options } => world_audio::spawn(world, mods, id, key, object, options)?,
Command::WorldAudioUpdate { key, options } => world_audio::update(world, mods, id, &key, options)?,
Command::WorldAudioEvent { key, event, options } => world_audio::event(world, id, &key, &event, options)?,
Command::WorldAudioRemove { key } => world_audio::remove(world, id, &key),
Command::GraphicsMeshBuffer { key, options } => { Command::GraphicsMeshBuffer { key, options } => {
graphics_dynamic::mesh_buffer(world, mods, id, key, options)?; graphics_dynamic::mesh_buffer(world, mods, id, key, options)?;
} }
@@ -0,0 +1,313 @@
//! Mod-owned world audio objects (API 2, world audio extension 1; `skate_mods::world_audio`):
//! each `sdk.world_audio.spawn` key becomes an entity with the same engine-facing components a
//! game system would add (`crate::world_audio`), so mods and engine systems are equal publishers
//! of the retail world audio. Keys belong to the calling mod; 16 objects per mod, 64 in all; an
//! object not updated for 0.5 s is parked (speed 0, feet up, horn off; ghosts never park);
//! everything is despawned when the mod is disabled, reloaded or fails, and on a runtime reset.
use super::Mods;
use crate::world_audio::*;
use bevy::prelude::*;
use serde_json::{Value, json};
use skate_mods::world_audio::{MAX_OBJECTS_PER_MOD, MAX_OBJECTS_TOTAL, ObjectKind, PARK_SECONDS, WorldAudioEventOptions, WorldAudioOptions};
use std::collections::BTreeMap;
type Key = (String, String);
struct Object {
entity: Entity,
kind: ObjectKind,
/// The merged spawn + update fields (the object's current description).
state: WorldAudioOptions,
/// `Time<Real>` seconds of the last spawn / update.
updated: f64,
/// A ghost (driven by its recorded states, never parked).
ghost: bool,
/// The last position and the derived velocity (lite skaters).
last: Option<Vec3>,
}
#[derive(Resource, Default)]
pub(super) struct ModWorldAudio {
objects: BTreeMap<Key, Object>,
}
pub(super) fn install(app: &mut App) {
app.init_resource::<ModWorldAudio>();
app.add_systems(
Update,
sync.after(super::update).before(crate::game_audio::world_bridge::WorldAudioPublish),
);
}
fn merge(into: &mut WorldAudioOptions, from: WorldAudioOptions) {
macro_rules! take {
($($f:ident),*) => { $( if from.$f.is_some() { into.$f = from.$f; } )* };
}
take!(position, velocity, heading, body, engine, speed, load, horn, skidding, voice, shoe_class, weight, close_range, feet, materials, footsteps, source, from, seconds, wheels, material, grinding, grind_material, air);
}
/// The ghost's states from `logs/<name>.tsv` in the mod, or `SKATE_AUDIO_STATE_LOGS/<name>.tsv`.
fn ghost(mods: &Mods, owner: &str, name: &str, opts: &WorldAudioOptions, anchor: Vec3) -> Result<crate::game_audio::world_bridge::GhostSkater, String> {
const MAX_LOG: u64 = 64 * 1024 * 1024;
let file = format!("logs/{name}.tsv");
let root = &mods.manager.packages.get(owner).ok_or("Missing world audio owner")?.root;
let bytes = skate_mods::read_bounded(root, &file, MAX_LOG).or_else(|e| {
let dir = std::env::var_os("SKATE_AUDIO_STATE_LOGS").ok_or(e)?;
skate_mods::read_bounded(std::path::Path::new(&dir), &format!("{name}.tsv"), MAX_LOG)
})?;
let text = String::from_utf8(bytes).map_err(|_| format!("{file}: not text"))?;
crate::game_audio::world_bridge::GhostSkater::from_log(&text, opts.from.unwrap_or(0.0), opts.seconds.unwrap_or(20.0), anchor.to_array())
.map_err(|e| format!("{file}: {e}"))
}
pub(super) fn spawn(world: &mut World, mods: &Mods, owner: &str, key: String, kind: ObjectKind, opts: WorldAudioOptions) -> Result<(), String> {
if !opts.validate_for(kind) {
return Err("Invalid world audio options".into());
}
if let Some(body) = &opts.body {
super::resolve_body(mods, owner, body)?;
}
let slot = (owner.to_owned(), key);
let ghost_source = opts.source.as_deref().and_then(|s| s.strip_prefix("state_log:")).map(str::to_owned);
let now = world.resource::<Time<Real>>().elapsed_secs_f64();
world.resource_scope(|world, mut audio: Mut<ModWorldAudio>| {
if !audio.objects.contains_key(&slot)
&& (audio.objects.len() >= MAX_OBJECTS_TOTAL || audio.objects.keys().filter(|(o, _)| o == owner).count() >= MAX_OBJECTS_PER_MOD)
{
return Err(format!("World audio object limit reached ({MAX_OBJECTS_PER_MOD}/mod, {MAX_OBJECTS_TOTAL} total)"));
}
let position = Vec3::from_array(opts.position.unwrap_or([0.0; 3]));
let ghost = match &ghost_source {
Some(name) => Some(ghost(mods, owner, name, &opts, position)?),
None => None,
};
if let Some(old) = audio.objects.remove(&slot) {
world.despawn(old.entity);
}
let t = Transform::from_translation(position).with_rotation(Quat::from_rotation_y(opts.heading.unwrap_or(0.0)));
let mut e = world.spawn((t, GlobalTransform::from(t), Name::new(format!("world audio {}:{}", slot.0, slot.1))));
match kind {
ObjectKind::Traffic => {
e.insert(TrafficAudio::new(opts.engine.clone().unwrap_or_else(|| "default".into())));
}
ObjectKind::Ped => {
e.insert(PedAudio::default());
}
ObjectKind::Skater => {
e.insert(NpcSkaterAudio::default());
}
}
let is_ghost = ghost.is_some();
if let Some(g) = ghost {
e.insert(g);
}
let entity = e.id();
audio.objects.insert(slot, Object { entity, kind, state: opts, updated: now, ghost: is_ghost, last: None });
Ok(())
})
}
pub(super) fn update(world: &mut World, mods: &Mods, owner: &str, key: &str, opts: WorldAudioOptions) -> Result<(), String> {
if let Some(body) = &opts.body {
super::resolve_body(mods, owner, body)?;
}
let now = world.resource::<Time<Real>>().elapsed_secs_f64();
let mut audio = world.resource_mut::<ModWorldAudio>();
let Some(obj) = audio.objects.get_mut(&(owner.to_owned(), key.to_owned())) else {
return Err(format!("unknown world audio object {key}"));
};
if !opts.validate_for(obj.kind) {
return Err(format!("Invalid world audio update for {key}"));
}
merge(&mut obj.state, opts);
obj.updated = now;
Ok(())
}
pub(super) fn event(world: &mut World, owner: &str, key: &str, event: &str, opts: WorldAudioEventOptions) -> Result<(), String> {
let audio = world.resource::<ModWorldAudio>();
let Some(obj) = audio.objects.get(&(owner.to_owned(), key.to_owned())) else {
return Err(format!("unknown world audio object {key}"));
};
let (entity, kind) = (obj.entity, obj.kind);
match (event, kind) {
("horn", ObjectKind::Traffic) => {
world.write_message(VehicleHorn { vehicle: entity, kind: opts.kind.unwrap_or(1), seconds: opts.seconds.unwrap_or(0.6) });
}
("alarm", ObjectKind::Traffic) => {
world.write_message(VehicleAlarm { vehicle: entity });
}
("speech", ObjectKind::Ped) => {
let value = match &opts.value {
Some(Value::Number(n)) => n.as_i64().map(|v| SpeechValue(v as i32)),
Some(Value::String(s)) => SpeechValue::from_name(s),
_ => None,
}
.ok_or_else(|| format!("unknown speech value {:?}", opts.value))?;
world.write_message(PedSpeechEvent { ped: entity, value });
}
_ => return Err(format!("event {event} does not apply to a {kind:?} object")),
}
Ok(())
}
pub(super) fn remove(world: &mut World, owner: &str, key: &str) {
let obj = world.resource_mut::<ModWorldAudio>().objects.remove(&(owner.to_owned(), key.to_owned()));
if let Some(obj) = obj {
world.despawn(obj.entity);
}
}
pub(super) fn clear_owner(world: &mut World, owner: &str) {
let mut audio = world.resource_mut::<ModWorldAudio>();
let keys: Vec<Key> = audio.objects.keys().filter(|(o, _)| o == owner).cloned().collect();
let entities: Vec<Entity> = keys.iter().filter_map(|k| audio.objects.remove(k)).map(|o| o.entity).collect();
for e in entities {
world.despawn(e);
}
}
pub(super) fn clear(world: &mut World) {
let objects = std::mem::take(&mut world.resource_mut::<ModWorldAudio>().objects);
for (_, o) in objects {
world.despawn(o.entity);
}
}
/// `snapshot.world_audio[owner]`: per key `{kind, audible, instance}`.
pub(super) fn snapshot(world: &World, owner: &str) -> Value {
let audio = world.resource::<ModWorldAudio>();
let mut out = serde_json::Map::new();
for ((o, key), obj) in &audio.objects {
if o != owner {
continue;
}
let held = world.get::<WorldAudioInstance>(obj.entity);
out.insert(key.clone(), json!({
"kind": match obj.kind { ObjectKind::Traffic => "traffic", ObjectKind::Ped => "ped", ObjectKind::Skater => "skater" },
"audible": held.is_some(),
"instance": held.map(|h| h.instance),
"parked": world.resource::<Time<Real>>().elapsed_secs_f64() - obj.updated > PARK_SECONDS && !obj.ghost,
}));
}
Value::Object(out)
}
/// `snapshot.world_audio_info`: the instance layout and the published counts.
pub(super) fn info(world: &World) -> Value {
let s = world.get_resource::<WorldAudioStats>().cloned().unwrap_or_default();
json!({
"more_audible": s.more_audible,
"instances": {"traffic": s.instances.0, "peds": s.instances.1, "skaters": s.instances.2},
"published": {"traffic": s.vehicles, "peds": s.peds, "skaters": s.skaters},
"audible": {"traffic": s.traffic_held, "peds": s.peds_held, "skaters": s.skaters_held},
})
}
/// Write every object's components from its description: body following, parking, the lite
/// skater's state.
#[allow(clippy::type_complexity)]
fn sync(
mut audio: ResMut<ModWorldAudio>,
mods: Res<Mods>,
time: Res<Time<Real>>,
game_time: Res<Time>,
physics: Option<Res<crate::physics::GamePhysics>>,
mut q: Query<(&mut Transform, &mut GlobalTransform, Option<&mut TrafficAudio>, Option<&mut PedAudio>, Option<&mut NpcSkaterAudio>, Option<&mut AudioVelocity>)>,
mut commands: Commands,
) {
if audio.objects.is_empty() {
return;
}
let now = time.elapsed_secs_f64();
let dt = game_time.delta_secs();
for ((owner, _), obj) in audio.objects.iter_mut() {
if obj.ghost {
continue;
}
let Ok((mut t, mut g, car, ped, npc, vel)) = q.get_mut(obj.entity) else { continue };
let parked = now - obj.updated > PARK_SECONDS;
let s = &obj.state;
// Where: a body of this mod, or the given position / heading.
let mut velocity = s.velocity.map(Vec3::from_array);
if let Some(read) = s.body.as_deref().and_then(|b| super::resolve_body(&mods, owner, b).ok()).and_then(|id| mods.world.read(id)) {
t.translation = Vec3::from_array(read.position);
let q = read.rotation;
t.rotation = Quat::from_xyzw(q[0], q[1], q[2], q[3]);
velocity = Some(Vec3::from_array(read.linvel));
} else {
if let Some(p) = s.position {
t.translation = Vec3::from_array(p);
}
if let Some(h) = s.heading {
t.rotation = Quat::from_rotation_y(h);
}
}
*g = GlobalTransform::from(*t);
let derived = match (obj.last, dt > 0.0) {
(Some(last), true) => (t.translation - last) / dt,
_ => Vec3::ZERO,
};
obj.last = Some(t.translation);
let v = if parked { Some(Vec3::ZERO) } else { velocity };
match (v, vel) {
(Some(v), Some(mut a)) => a.0 = v,
(Some(v), None) => {
commands.entity(obj.entity).insert(AudioVelocity(v));
}
(None, Some(_)) => {
commands.entity(obj.entity).remove::<AudioVelocity>();
}
(None, None) => {}
}
if let Some(mut car) = car {
let want = TrafficAudio {
engine: s.engine.clone().unwrap_or_else(|| car.engine.clone()),
speed: if parked { Some(0.0) } else { s.speed },
load: if parked { Some(0.0) } else { s.load },
horn: if parked { HornState::None } else { HornState::from_word(s.horn.unwrap_or(0)) },
skidding: !parked && s.skidding.unwrap_or(false),
};
if *car != want {
*car = want;
}
}
if let Some(mut ped) = ped {
let want = PedAudio {
voice: s.voice.filter(|v| *v != 0),
shoe_class: s.shoe_class.unwrap_or(2),
weight: s.weight.unwrap_or(1),
close_range: s.close_range.unwrap_or(false),
feet_down: if parked { [false; 2] } else { s.feet.unwrap_or([false; 2]) },
foot_materials: s.materials,
footsteps_on: s.footsteps,
speech_distance: None,
speech_value: ped.speech_value,
};
if *ped != want {
*ped = want;
}
}
if let Some(mut npc) = npc {
let v = if parked { Vec3::ZERO } else { velocity.unwrap_or(derived) };
let v = match s.speed {
Some(speed) if !parked && v.length() < 1e-4 => t.rotation * Vec3::Z * speed,
_ => v,
};
let material = s.material.unwrap_or_else(|| physics.as_deref().map_or(skate_audio::player::state::NO_MATERIAL, |p| crate::game_audio::world_bridge::ground_material(p, t.translation)));
let f = t.rotation * Vec3::Z;
npc.state = Some(AudioState::rolling(&LiteSkater {
position: t.translation.to_array(),
velocity: v.to_array(),
heading: f.x.atan2(f.z),
wheels: s.wheels.unwrap_or([true; 4]),
material,
grinding: s.grinding.unwrap_or(false),
grind_material: s.grind_material.unwrap_or(skate_audio::player::state::NO_MATERIAL),
airborne: s.air.unwrap_or(false),
air_time: 0.0,
dt: dt.max(1e-4),
}));
}
}
}
+4
View File
@@ -572,6 +572,10 @@ mod wipeout_tests;
#[path = "tests/water_drop.rs"] #[path = "tests/water_drop.rs"]
mod water_drop_tests; mod water_drop_tests;
#[cfg(test)]
#[path = "tests/audio_state_capture.rs"]
mod audio_state_capture_tests;
fn present( fn present(
physics: Res<GamePhysics>, physics: Res<GamePhysics>,
history: Res<crate::presentation::Presentation>, history: Res<crate::presentation::Presentation>,
+4
View File
@@ -25,6 +25,9 @@ pub(crate) struct Presentation {
pair: Option<(Snapshot, Snapshot)>, pair: Option<(Snapshot, Snapshot)>,
generation: u64, generation: u64,
period: std::time::Duration, period: std::time::Duration,
/// Snaps so far (teleports, camera cuts, cadence changes): the audio host resets its
/// camera velocity (Doppler) on a change (`game_audio::native::mixmap_frame`).
pub cuts: u64,
} }
impl Presentation { impl Presentation {
pub fn view<'a>(&'a self, replay: &'a crate::replay::Replay, alpha: f32) pub fn view<'a>(&'a self, replay: &'a crate::replay::Replay, alpha: f32)
@@ -61,6 +64,7 @@ fn capture(skater: Res<SkaterRuntime>, camera: Res<CameraRuntime>,
|| skater.player_input.processed.flags_2472 & (1 << 10) != 0 || skater.player_input.processed.flags_2472 & (1 << 10) != 0
|| history.period != time.timestep(); || history.period != time.timestep();
replay.record(next.clone(), time.delta_secs_f64(), reset); replay.record(next.clone(), time.delta_secs_f64(), reset);
history.cuts += u64::from(reset);
if let Some((previous, current)) = history.pair.as_mut() { if let Some((previous, current)) = history.pair.as_mut() {
if reset || current.bones.len() != next.bones.len() { if reset || current.bones.len() != next.bones.len() {
*previous = next.clone(); *previous = next.clone();
@@ -0,0 +1,92 @@
//! The local player's audio state through the production frame (headless, data-gated): a scripted
//! controller (push, carves, ollies, a powerslide, a long roll) drives `frame::advance`, and the
//! audio bridge `game_audio::skate_events::observe` runs after every tick as in the game. Every
//! published sample (`Cues::riding`, the whole `AudioState` included) is written as one Debug line.
//!
//! SKATE3_ASSET_ROOT=<repo>/assets/private AUDIO_STATE_CAPTURE=<file>
//! cargo test -p skate-game --release --bin skate3rust --locked -- --ignored audio_state_capture --nocapture
//!
//! A missing file is written (the baseline); an existing one is compared line by line, so a refactor
//! of the bridge is proved identical by running this once before and once after it (doc 11 "World
//! audio hook-in"). The test also checks that the per-skater builder
//! (`skate_events::skater_audio_state`) on its own memory gives the same state as `observe`.
use super::*;
use bevy::prelude::{Time, World};
#[test]
#[ignore = "needs the private install data"]
fn audio_state_capture() {
let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("missing private data: set SKATE3_ASSET_ROOT");
let root = std::path::Path::new(&root);
let assets = skate_data::GameAssets::load(root).expect("missing private data: assets");
let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap();
let physics = GamePhysics::load(root).unwrap();
let skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap();
let mut controls = PlayerControls::default();
let mut input = crate::input::ControllerInput::default();
let mut camera = crate::camera::CameraRuntime::load(root).unwrap();
let dt = physics.settings.step.simulation.time_step;
let mut world = World::new();
world.insert_resource(physics);
world.insert_resource(skater);
world.insert_resource(crate::game_audio::skate_events::Cues::default());
world.insert_resource(Time::<()>::default());
let observe = world.register_system(crate::game_audio::skate_events::observe);
let mut lines = Vec::new();
// The per-skater builder on its own memory: what an AI-skater system would call.
let mut builder = crate::game_audio::skate_events::SkaterAudioMemory::default();
// No host takes the steps here, so `Cues::publish` keeps OR-latching the one-step pulses
// (`latch_pulses`): the builder's own sample gets the same latch for the comparison.
let mut latched = false;
for tick in 0..1500u32 {
// Push (A) from 12 to 300, carve right / left, two ollies (right stick down → up), a
// powerslide, then roll out.
let (buttons, left, right) = match tick {
12..=300 => (0x1000, [0, 0], [0, 0]),
301..=420 => (0, [16000, 0], [0, 0]),
421..=540 => (0, [-16000, 0], [0, 0]),
560..=566 | 760..=766 => (0, [0, 0], [0, -32767]),
567..=572 | 767..=772 => (0, [0, 0], [0, 32767]),
900..=1000 => (0x1000, [0, 0], [0, 0]),
1012..=1023 => (0, [32767, 0], [0, 0]),
1036..=1095 => (0, [22000, 32767], [0, 0]),
_ => (0, [0, 0], [0, 0]),
};
input.sample_raw_for_test(skate_core::input::xbox::XboxState { buttons, triggers: [0; 2], left, right });
let mut actions = input.player_actions();
world.resource_scope(|world, mut physics: bevy::prelude::Mut<GamePhysics>| {
let mut skater = world.resource_mut::<SkaterRuntime>();
controls.update(&mut actions, dt, physics.settings.input_magnitude_threshold, skater.player_input.physical.scoring.capabilities_204);
frame::advance(&mut physics, &mut skater, &mut controls, &graphs, &mut actions, true, &mut camera)
.unwrap_or_else(|e| panic!("tick {tick}: {e}"));
});
world.resource_mut::<Time>().advance_by(std::time::Duration::from_secs_f32(dt));
let mut built = {
let dt = world.resource::<Time>().delta_secs();
crate::game_audio::skate_events::skater_audio_state(world.resource::<GamePhysics>(), world.resource::<SkaterRuntime>(), &mut builder, dt)
};
latched |= built.push_trigger;
built.push_trigger = latched;
world.run_system(observe).unwrap();
let cues = world.resource::<crate::game_audio::skate_events::Cues>();
assert_eq!(format!("{built:?}"), format!("{:?}", cues.riding.audio), "tick {tick}: the builder differs from observe");
lines.push(format!("{tick}\t{:?}", cues.riding));
}
let text = lines.join("\n");
let Some(path) = std::env::var_os("AUDIO_STATE_CAPTURE") else { return };
let path = std::path::Path::new(&path);
match std::fs::read_to_string(path) {
Ok(base) => {
let (a, b): (Vec<&str>, Vec<&str>) = (base.lines().collect(), text.lines().collect());
assert_eq!(a.len(), b.len(), "sample count");
for (x, y) in a.iter().zip(&b) {
assert_eq!(x, y, "the published audio state changed");
}
eprintln!("AUDIO_STATE_CAPTURE identical: {} samples", b.len());
}
Err(_) => {
std::fs::write(path, text).unwrap();
eprintln!("AUDIO_STATE_CAPTURE written: {} samples", lines.len());
}
}
}
+300
View File
@@ -0,0 +1,300 @@
//! **Hooking up world audio** (engine-facing surface; doc `docs/hails-additions/15-world-audio.md`).
//!
//! The game has no living world yet (no traffic, pedestrians or AI skaters). Retail's audio for
//! them is ported (`skate_audio::world`, hosted by `game_audio::{world_sources, npc_skaters}`) and
//! waits for publishers. A future engine system (or a Lua mod, `sdk.world_audio`) only adds
//! components to its own entities and sends a few messages; it never sees AEMS, MixMap keys,
//! packets or banks:
//!
//! - a vehicle: [`TrafficAudio`] (engine record name, horn, skid; speed / acceleration optional);
//! - a pedestrian: [`PedAudio`] (voice, shoe class, foot plants, materials, speech value);
//! - an NPC (AI) skater or a remote multiplayer player: [`NpcSkaterAudio`] with an
//! [`AudioState`] filled like the local player's (`skate_events::skater_audio_state` for a
//! skater simulated with the player's physics, [`AudioState::rolling`] for anything else);
//! - optional [`AudioVelocity`] for objects that teleport or are kinematic proxies (Doppler and
//! the 3-D rates read the velocity; by default it is the transform's change per frame).
//!
//! Position and heading come from the entity's `GlobalTransform` (heading = its +Z axis, the
//! game's forward; retail's vehicle `+112` is the world matrix's forward row, recomp gap run G1).
//! **Lifetime = the entity:** insert the component to publish, despawn or remove it to release.
//! Ids are `Entity::to_bits()`, so a reused index is a new owner.
//!
//! **The audio decides who is audible** with retail's limits, applied by the hosts: the 4 nearest
//! vehicles within 40 m (horizontal), the 15 nearest peds within 50 m (footsteps for the 3
//! nearest), one NPC skater within 30 m of the camera (the first in list order, held until 30 m).
//! The opt-in non-retail "more audible" setting raises these to 8 / 24 / 3. The bridge inserts
//! [`WorldAudioInstance`] on the entities that hold an instance, so an engine system can skip
//! per-frame audio work (an NPC's `AudioState`) for the others.
//!
//! Messages: [`PedSpeechEvent`] (a state graph's `SendSpeechEvent`), [`VehicleHorn`] (hold a horn
//! kind for the caller's time), [`VehicleAlarm`] (retail's 8 s alarm). Set [`LivingWorldAudio`]
//! `expected` at map load when the system will publish, so the world banks decode ahead of need.
//!
//! Everything stays inert when nothing is published. `SKATE_AEMS_WORLD=0` /
//! `SKATE_AEMS_NPC_SKATERS=0` turn the hosts off.
// An API for systems the engine does not have yet: not every item has a caller in the game.
#![allow(dead_code)]
use bevy::prelude::*;
pub use skate_audio::player::AudioState;
pub use skate_audio::player::state::LiteSkater;
/// A vehicle's horn state (`+156`): what the traffic AI decides. `Honk(1..=5)` = the horn kind
/// (which kind a model uses is the AI's; retail packs it per vehicle), `Alarm` = the car alarm
/// (state 6).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum HornState {
#[default]
None,
Honk(u8),
Alarm,
}
impl HornState {
/// The record word (0 none, 1..=5 kind, 6 alarm).
pub fn word(self) -> i32 {
match self {
Self::None => 0,
Self::Honk(k) => i32::from(k.clamp(1, 5)),
Self::Alarm => 6,
}
}
/// From the record word (anything outside 1..=6 = none).
pub fn from_word(word: i32) -> Self {
match word {
1..=5 => Self::Honk(word as u8),
6 => Self::Alarm,
_ => Self::None,
}
}
}
/// A traffic vehicle's audio (retail `SFXObj_TrafficEngine` / `TrafficHorn` / `TrafficSkids`;
/// the vehicle audio record `+144..+168`, `sub_824B2A28`).
#[derive(Component, Clone, Debug, PartialEq)]
pub struct TrafficAudio {
/// The model's `aud_traffic_engine` record name (`+168`): `c01_family01` (sedans, hatchback),
/// `c03_sports01` (sports, muscle), `c04_taxi01` (taxi, patrol), `c05_truck01` (SUV, pickup,
/// minivan); the patch override picks c06 / c07 / c08 itself. `c00_heavy01` exists but retail
/// never uses it. Unknown or `default` = patch 2, silent (logged once).
pub engine: String,
/// `+148` speed (m/s, ≥ 0). `None` = |velocity|.
pub speed: Option<f32>,
/// `+144`: the driver's signed acceleration in m/s² (gap run G1: −15.6 through a hard stop,
/// up to +3 pulling away, 0 cruising), × 3000 into the engine / skid words. `None` = derived
/// from the speed change.
pub load: Option<f32>,
/// `+156`.
pub horn: HornState,
/// `+160`: the tyres skid.
pub skidding: bool,
}
impl TrafficAudio {
pub fn new(engine: impl Into<String>) -> Self {
Self { engine: engine.into(), speed: None, load: None, horn: HornState::None, skidding: false }
}
}
/// A pedestrian's audio (retail `SFXObj_PedestrianSFX` footsteps and `SFXObj_PedestrianSpeech`
/// requests; the ped audio state S, gap run G2).
#[derive(Component, Clone, Debug, PartialEq)]
pub struct PedAudio {
/// The model = the speech voice id 41–96 (`S+84`; the clip names' voice). None = no speech.
pub voice: Option<u32>,
/// `S+132` shoe class 2..=5 (`aud_characteristics` per model; 2 for most, 1 = silent and used
/// by no model).
pub shoe_class: u8,
/// `[obj+28]+144` (1..=5): dynamic per ped in retail (1 beyond ~12 m, 2–5 near; meaning open).
pub weight: u8,
/// `S+96 == 64`: a security guard (the close-range footstep levels).
pub close_range: bool,
/// `S+74` / `S+73`: the walk animation's foot plants (A, B).
pub feet_down: [bool; 2],
/// `S+140` / `S+144`: the material under each foot (audio surface material). `None` = 0
/// (retail's pavements read 0 in every recomp line).
pub foot_materials: Option<[u32; 2]>,
/// `S+68` footsteps on. `None` = retail's rule: the 3 nearest peds of the list.
pub footsteps_on: Option<bool>,
/// `S+148` / `S+156`: distance to the listener and the model's far threshold (the `_f` lines
/// beyond it). `None` = retail: the 3-D distance and 20 m.
pub speech_distance: Option<(f32, f32)>,
/// `S+136`: the speech value the ped's state graph last sent. Engine systems normally send
/// [`PedSpeechEvent`] instead; the footstep packets read it too (jump 4/5, collision 6/7).
pub speech_value: i32,
}
impl Default for PedAudio {
fn default() -> Self {
Self {
voice: None,
shoe_class: 2,
weight: 1,
close_range: false,
feet_down: [false; 2],
foot_materials: None,
footsteps_on: None,
speech_distance: None,
speech_value: 0,
}
}
}
/// An NPC (AI) skater's board audio (the MixMap Player slot's second instance) — or a remote
/// multiplayer player's (non-retail extension, user decision 2026-10-03: another real player
/// nearby takes the same instance; remote players come first in the list order).
#[derive(Component, Clone, Debug, Default, PartialEq)]
pub struct NpcSkaterAudio {
/// The skater list position (retail walks its list in order; spawn order is fine). 0 = the
/// bridge numbers it by spawn order.
pub list_order: u32,
/// The full audio state of this frame, filled like the local player's. Needed while the
/// skater is within 35 m of the camera (a claim at 30 m reads it in the same pass); `None`
/// = not published this frame.
pub state: Option<AudioState>,
/// A remote multiplayer player (sorted before the NPCs).
pub remote: bool,
}
/// Overrides the velocity the bridge derives from the transform (m/s, world).
#[derive(Component, Clone, Copy, Debug, Default, PartialEq)]
pub struct AudioVelocity(pub Vec3);
/// Which pool an instance belongs to.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum WorldAudioSlot {
Traffic,
Ped,
/// The MixMap Player slot (instance ≥ 1; 0 is the local player's).
PlayerSlot,
}
/// Read back: the entity holds a MixMap instance (it is audible). Inserted and removed by the
/// bridge after the hosts ran.
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
pub struct WorldAudioInstance {
pub slot: WorldAudioSlot,
pub instance: u32,
}
/// The living world's audio switches.
#[derive(Resource, Clone, Debug, Default, PartialEq)]
pub struct LivingWorldAudio {
/// The living world will publish on this map: the world banks are read and decoded on the
/// prefetch worker. Set it at map load / spawner start, clear it when the world stops.
pub expected: bool,
}
/// Debug counts (read only; written by the bridge every frame).
#[derive(Resource, Clone, Debug, Default, PartialEq)]
pub struct WorldAudioStats {
pub vehicles: usize,
pub peds: usize,
pub skaters: usize,
/// Holders per pool after the last evaluation.
pub traffic_held: usize,
pub peds_held: usize,
pub skaters_held: usize,
/// The instance counts (traffic, peds, NPC skaters): retail 4 / 15 / 1.
pub instances: (usize, usize, usize),
/// The opt-in non-retail "more audible" layout is on (settings/audio.json).
pub more_audible: bool,
}
/// A speech value (`S+136`, the state graphs' `SendSpeechEvent speechvalue=N`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SpeechValue(pub i32);
impl SpeechValue {
pub const WARN: Self = Self(11);
pub const NEARBY_REACTION: Self = Self(10);
pub const FLEE: Self = Self(20);
pub const LONG_CHEER: Self = Self(23);
pub const STOP_CHEER: Self = Self(25);
/// By name: the state graph's name without the `Pedestrian` prefix, case and `_` ignored
/// (`DoWarning`, `long_cheer`, `Flee`, …), the short aliases `warn`, `cheer`, `slam`, `flee`,
/// `knockdown`, `nearby`, or a number.
pub fn from_name(name: &str) -> Option<Self> {
let key: String = name.chars().filter(|c| *c != '_' && *c != ' ').flat_map(char::to_lowercase).collect();
if let Ok(n) = key.parse::<i32>() {
return (0..=127).contains(&n).then_some(Self(n));
}
let alias = match key.as_str() {
"warn" | "warning" => Some(11),
"cheer" => Some(23),
"slam" => Some(25),
"flee" => Some(20),
"knockdown" => Some(6),
"nearby" => Some(10),
"none" => Some(0),
_ => None,
};
if let Some(v) = alias {
return Some(Self(v));
}
skate_audio::world::speech::SPEECH_VALUES.iter().find_map(|(v, n)| {
n.split(" / ").any(|part| {
let p: String = part.trim_start_matches("Pedestrian").trim_start_matches("Pedstrian").chars().flat_map(char::to_lowercase).collect();
p == key || part.to_lowercase() == key
})
.then_some(Self(*v))
})
}
}
/// A ped's state graph sent a speech value: the ped's `PedAudio::speech_value` takes it, so
/// PedestrianSpeech sees the change and requests the line.
#[derive(Message, Clone, Copy, Debug)]
pub struct PedSpeechEvent {
pub ped: Entity,
pub value: SpeechValue,
}
/// Hold horn kind `kind` (1..=5) for `seconds` (the AI's choice; not retail data), then back to
/// the component's own horn state.
#[derive(Message, Clone, Copy, Debug)]
pub struct VehicleHorn {
pub vehicle: Entity,
pub kind: u8,
pub seconds: f32,
}
/// Retail's car alarm: horn state 6 for 8 s (vehicle `+3716`, `npc-livingworld-re.md` §6).
#[derive(Message, Clone, Copy, Debug)]
pub struct VehicleAlarm {
pub vehicle: Entity,
}
/// The car alarm's length (s).
pub const ALARM_SECONDS: f32 = 8.0;
// Reserved for the next pass (spec §3.4, P3): `PedKnockDown` (PedBodyFall), `PedTazer` (Tazer,
// needs AEMS op 38), `NpcSkaterBail` (the NPC bail grunt through its SkaterSpeech).
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn speech_values_by_name() {
assert_eq!(SpeechValue::from_name("warn"), Some(SpeechValue::WARN));
assert_eq!(SpeechValue::from_name("DoWarning"), Some(SpeechValue(11)));
assert_eq!(SpeechValue::from_name("long_cheer"), Some(SpeechValue(23)));
assert_eq!(SpeechValue::from_name("PedestrianFlee"), Some(SpeechValue(20)));
assert_eq!(SpeechValue::from_name("PictureTaking"), Some(SpeechValue(29)));
assert_eq!(SpeechValue::from_name("63"), Some(SpeechValue(63)));
assert_eq!(SpeechValue::from_name("nonsense"), None);
assert_eq!(SpeechValue::from_name("400"), None);
}
#[test]
fn horn_words_round_trip() {
for w in 0..=6 {
assert_eq!(HornState::from_word(w).word(), w);
}
assert_eq!(HornState::Honk(9).word(), 5);
}
}
+27
View File
@@ -287,6 +287,33 @@ function sdk.audio.stop(key, fade_out)
end end
function sdk.audio.stop_all() submit{kind="audio_stop_all"} end function sdk.audio.stop_all() submit{kind="audio_stop_all"} end
-- World audio extension 1 (backward-compatible with API 2): publish traffic vehicles, peds and
-- skaters to the game's retail world audio (the same path engine systems use). Keys are scoped
-- to the calling mod; 16 objects per mod, 64 in all; an object not updated for 0.5 s is parked;
-- everything is removed when the mod is disabled or reloaded. The retail limits decide which
-- objects sound (4 nearest cars within 40 m, 15 nearest peds within 50 m, 1 skater within 30 m).
sdk.world_audio = { version = 1 }
function sdk.world_audio.spawn(key, kind, opts)
submit{kind="world_audio_spawn",key=key,object=kind,options=opts or {}}
end
function sdk.world_audio.update(key, opts)
submit{kind="world_audio_update",key=key,options=opts or {}}
end
-- event: 'horn' {kind=1..5, seconds=s}, 'alarm' (retail's 8 s), 'speech' {value=name or number}
function sdk.world_audio.event(key, event, opts)
submit{kind="world_audio_event",key=key,event=event,options=opts or {}}
end
function sdk.world_audio.remove(key) submit{kind="world_audio_remove",key=key} end
-- {kind=..., audible=bool, instance=n or nil} for one of this mod's objects (nil if unknown).
function sdk.world_audio.read(key)
local owners = as_table(sdk.snapshot.world_audio) or {}
return (owners[sdk.mod_id] or {})[key]
end
-- {more_audible=bool, instances={traffic=4, peds=15, skaters=1}, published={...}}
function sdk.world_audio.info()
return as_table(sdk.snapshot.world_audio_info) or {more_audible=false,instances={traffic=4,peds=15,skaters=1}}
end
sdk.player = {} sdk.player = {}
function sdk.player.suspend(suspended) submit{kind="player_suspend",suspended=suspended} end function sdk.player.suspend(suspended) submit{kind="player_suspend",suspended=suspended} end
function sdk.player.physics() return as_table(sdk.snapshot.player_physics) or {} end function sdk.player.physics() return as_table(sdk.snapshot.player_physics) or {} end
+1
View File
@@ -1,6 +1,7 @@
//! Lua mod packages (API 2): low-level physics/graphics hooks, no Vehicle class. //! Lua mod packages (API 2): low-level physics/graphics hooks, no Vehicle class.
mod archive; mod archive;
pub mod audio; pub mod audio;
pub mod world_audio;
pub mod graphics_dynamic; pub mod graphics_dynamic;
pub mod presentation; pub mod presentation;
pub mod scene; pub mod scene;
+83
View File
@@ -132,6 +132,25 @@ pub enum Command {
fade_out: f32, fade_out: f32,
}, },
AudioStopAll {}, AudioStopAll {},
/// World audio extension 1: publish a traffic vehicle / ped / skater to the retail world audio.
WorldAudioSpawn {
key: String,
object: crate::world_audio::ObjectKind,
#[serde(default)]
options: crate::world_audio::WorldAudioOptions,
},
WorldAudioUpdate {
key: String,
#[serde(default)]
options: crate::world_audio::WorldAudioOptions,
},
WorldAudioEvent {
key: String,
event: String,
#[serde(default)]
options: crate::world_audio::WorldAudioEventOptions,
},
WorldAudioRemove { key: String },
GraphicsMeshBuffer { GraphicsMeshBuffer {
key: String, key: String,
options: crate::graphics_dynamic::MeshBufferOptions, options: crate::graphics_dynamic::MeshBufferOptions,
@@ -385,6 +404,10 @@ impl Command {
&& (0.0..=2.0).contains(fade_out) && (0.0..=2.0).contains(fade_out)
} }
Self::AudioStopAll {} => true, Self::AudioStopAll {} => true,
Self::WorldAudioSpawn { key, object, options } => crate::schema::valid_id(key) && options.validate_for(*object),
Self::WorldAudioUpdate { key, options } => crate::schema::valid_id(key) && options.validate() && options.source.is_none(),
Self::WorldAudioEvent { key, event, options } => crate::schema::valid_id(key) && options.validate(event),
Self::WorldAudioRemove { key } => crate::schema::valid_id(key),
Self::GraphicsMeshBuffer { key, options } => { Self::GraphicsMeshBuffer { key, options } => {
crate::schema::valid_id(key) && options.validate() crate::schema::valid_id(key) && options.validate()
} }
@@ -628,6 +651,10 @@ fn command_kind(command: &Command) -> &'static str {
Command::AudioUpdate { .. } => "audio_update", Command::AudioUpdate { .. } => "audio_update",
Command::AudioStop { .. } => "audio_stop", Command::AudioStop { .. } => "audio_stop",
Command::AudioStopAll {} => "audio_stop_all", Command::AudioStopAll {} => "audio_stop_all",
Command::WorldAudioSpawn { .. } => "world_audio_spawn",
Command::WorldAudioUpdate { .. } => "world_audio_update",
Command::WorldAudioEvent { .. } => "world_audio_event",
Command::WorldAudioRemove { .. } => "world_audio_remove",
Command::GraphicsMeshBuffer { .. } => "graphics_mesh_buffer", Command::GraphicsMeshBuffer { .. } => "graphics_mesh_buffer",
Command::GraphicsMeshBufferWrite { .. } => "graphics_mesh_buffer_write", Command::GraphicsMeshBufferWrite { .. } => "graphics_mesh_buffer_write",
Command::GraphicsMeshBufferAppend { .. } => "graphics_mesh_buffer_append", Command::GraphicsMeshBufferAppend { .. } => "graphics_mesh_buffer_append",
@@ -922,6 +949,7 @@ impl Vm {
capabilities.set("volumes", 1)?; capabilities.set("volumes", 1)?;
capabilities.set("capture", 1)?; capabilities.set("capture", 1)?;
capabilities.set("multiplayer_debug", 1)?; capabilities.set("multiplayer_debug", 1)?;
capabilities.set("world_audio", 1)?;
sdk.set("_native_capabilities", capabilities)?; sdk.set("_native_capabilities", capabilities)?;
sdk.set("mod_id", manifest.id.clone())?; sdk.set("mod_id", manifest.id.clone())?;
sdk.set( sdk.set(
@@ -1711,3 +1739,58 @@ mod deformation_api_tests {
assert!(command.validate()); assert!(command.validate());
} }
} }
#[cfg(test)]
mod world_audio_tests {
use super::*;
use serde_json::json;
/// The world audio wrappers cross the serde boundary, and the bundled dev test publisher
/// (`mods/world-audio-test`) runs frames without a Lua error or an invalid command, within the
/// 128-commands-per-callback limit.
#[test]
fn world_audio_wrappers_and_the_test_publisher_run() {
let root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../mods/world-audio-test"));
let manifest: Manifest = serde_json::from_slice(&std::fs::read(root.join("mod.json")).unwrap()).unwrap();
manifest.validate().unwrap();
let settings: BTreeMap<String, Value> = manifest.settings.iter().map(|(k, s)| (k.clone(), s.default.clone())).collect();
let mut vm = Vm::new(root, &manifest, &settings, &Value::Null).unwrap();
let mut spawned = 0;
for frame in 0..400u32 {
let t = frame as f32 / 60.0;
let snapshot = json!({"tick": frame, "player": {"position": [t * 4.0, 0.0, 0.0], "speed": 4.0,
"landing_seq": frame / 100, "bail_seq": frame / 250},
"keys": {"F8": frame == 200, "F9": frame == 300},
"world_audio": {"dev-world-audio-test": {"car1": {"kind": "traffic", "audible": true, "instance": 0}}},
"command_results": {"dev-world-audio-test": {"ghost": {"token": 1, "ok": false, "error": "logs/x.tsv: missing"}}}});
let out = vm.call("on_update", json!({"dt": 1.0 / 60.0}), &snapshot).unwrap_or_else(|e| panic!("frame {frame}: {e}"));
assert!(out.len() <= 128);
for c in &out {
assert!(c.validate(), "frame {frame}: invalid {c:?}");
if matches!(c, Command::WorldAudioSpawn { .. }) {
spawned += 1;
}
}
}
assert_eq!(spawned, 2 * 36, "16 cars and 20 peds, twice (F9 re-centres); the ghost goes through a request");
}
#[test]
fn world_audio_commands_deserialize() {
for value in [
json!({"kind":"world_audio_spawn","key":"car1","object":"traffic","options":{"engine":"c04_taxi01","position":[0,0,0]}}),
json!({"kind":"world_audio_update","key":"car1","options":{"speed":3,"load":-2}}),
json!({"kind":"world_audio_event","key":"car1","event":"alarm"}),
json!({"kind":"world_audio_event","key":"ped1","event":"speech","options":{"value":"warn"}}),
json!({"kind":"world_audio_remove","key":"car1"}),
] {
let c: Command = serde_json::from_value(value.clone()).unwrap();
assert!(c.validate(), "{value}");
}
let c: Command = serde_json::from_value(json!({"kind":"world_audio_spawn","key":"x","object":"ped","options":{"engine":"c04_taxi01"}})).unwrap();
assert!(!c.validate(), "a traffic field on a ped");
assert!(serde_json::from_value::<Command>(json!({"kind":"world_audio_spawn","key":"x","object":"bus"})).is_err());
let c: Command = serde_json::from_value(json!({"kind":"world_audio_update","key":"x","options":{"source":"lite"}})).unwrap();
assert!(!c.validate(), "the source is fixed at spawn");
}
}
+224
View File
@@ -0,0 +1,224 @@
//! World audio commands (API 2, world audio extension 1): mods publish traffic vehicles,
//! pedestrians and skaters to the game's retail world audio (the same components an engine
//! system adds, `skate-game` `world_audio.rs`), fire their one-shots (horn, alarm, speech) and read
//! back whether an object is audible. Keys belong to the calling mod; the host enforces 16
//! objects per mod and 64 in all, parks an object that has not been updated for 0.5 s, and removes
//! everything a mod published when it is disabled or reloaded.
use serde::Deserialize;
/// Objects per mod / in all.
pub const MAX_OBJECTS_PER_MOD: usize = 16;
pub const MAX_OBJECTS_TOTAL: usize = 64;
/// An object not updated for this long is parked (speed 0, feet up, horn off).
pub const PARK_SECONDS: f64 = 0.5;
/// What kind of object a key publishes.
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ObjectKind {
Traffic,
Ped,
Skater,
}
/// Every field a spawn or an update may carry; each kind reads its own and ignores none silently
/// (a field of another kind is rejected by [`WorldAudioOptions::validate_for`]).
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct WorldAudioOptions {
// ---- all kinds
/// World position (m). Ignored while `body` is set.
#[serde(default)]
pub position: Option<[f32; 3]>,
/// World velocity (m/s); without it the host derives it from the position change.
#[serde(default)]
pub velocity: Option<[f32; 3]>,
/// Heading (rad about +Y; 0 = +Z).
#[serde(default)]
pub heading: Option<f32>,
/// Follow one of this mod's physics bodies (position, rotation and velocity from it).
#[serde(default)]
pub body: Option<String>,
// ---- traffic
/// The `aud_traffic_engine` record (`c01_family01`, `c03_sports01`, `c04_taxi01`,
/// `c05_truck01`, …): a mod car opts in to the retail traffic engine sound (not retail).
#[serde(default)]
pub engine: Option<String>,
#[serde(default)]
pub speed: Option<f32>,
/// The driver's signed acceleration (m/s²); without it, from the speed change.
#[serde(default)]
pub load: Option<f32>,
/// 0 none, 1..=5 a horn kind, 6 the alarm (prefer the `horn` / `alarm` events).
#[serde(default)]
pub horn: Option<i32>,
#[serde(default)]
pub skidding: Option<bool>,
// ---- peds
/// Speech voice id 41..=96 (0 = none).
#[serde(default)]
pub voice: Option<u32>,
#[serde(default)]
pub shoe_class: Option<u8>,
#[serde(default)]
pub weight: Option<u8>,
#[serde(default)]
pub close_range: Option<bool>,
/// Foot plants (A, B).
#[serde(default)]
pub feet: Option<[bool; 2]>,
/// Audio surface materials under the feet (A, B).
#[serde(default)]
pub materials: Option<[u32; 2]>,
/// Footsteps on (default: retail's 3-nearest rule).
#[serde(default)]
pub footsteps: Option<bool>,
// ---- skaters
/// `lite` (the default: a rolling-only state from these fields) or `state_log:<name>` (a
/// ghost replaying a recorded audio state log, `logs/<name>.tsv` in the mod or
/// `SKATE_AUDIO_STATE_LOGS`).
#[serde(default)]
pub source: Option<String>,
/// Ghost window: start (s) and length (s) of the log to loop.
#[serde(default)]
pub from: Option<f32>,
#[serde(default)]
pub seconds: Option<f32>,
/// Lite skater: wheels down (FL, FR, RL, RR), the material under the board (default: the
/// ground's), grinding and its material, in the air.
#[serde(default)]
pub wheels: Option<[bool; 4]>,
#[serde(default)]
pub material: Option<u32>,
#[serde(default)]
pub grinding: Option<bool>,
#[serde(default)]
pub grind_material: Option<u32>,
#[serde(default)]
pub air: Option<bool>,
}
/// One-shot options (`horn`: kind, seconds; `speech`: value).
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct WorldAudioEventOptions {
#[serde(default)]
pub kind: Option<u8>,
#[serde(default)]
pub seconds: Option<f32>,
/// A speech value: a number or a name (`warn`, `cheer`, `slam`, `flee`, `DoWarning`, …).
#[serde(default)]
pub value: Option<serde_json::Value>,
}
fn finite(v: &[f32]) -> bool {
v.iter().all(|x| x.is_finite())
}
fn point(p: &[f32; 3]) -> bool {
finite(p) && p.iter().all(|x| x.abs() <= 100_000.0)
}
fn velocity(v: &[f32; 3]) -> bool {
finite(v) && v.iter().all(|x| x.abs() <= 200.0)
}
fn name(s: &str) -> bool {
!s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.') && !s.contains("..")
}
/// `lite`, or `state_log:<name>` with a safe file name.
pub fn valid_source(s: &str) -> bool {
s == "lite" || s.strip_prefix("state_log:").is_some_and(name)
}
impl WorldAudioOptions {
/// Bounds shared by every kind.
pub fn validate(&self) -> bool {
self.position.as_ref().is_none_or(point)
&& self.velocity.as_ref().is_none_or(velocity)
&& self.heading.is_none_or(|h| h.is_finite() && h.abs() <= 1000.0)
&& self.body.as_deref().is_none_or(crate::schema::valid_id)
&& self.engine.as_deref().is_none_or(name)
&& self.speed.is_none_or(|v| v.is_finite() && (0.0..=200.0).contains(&v))
&& self.load.is_none_or(|v| v.is_finite() && v.abs() <= 100.0)
&& self.horn.is_none_or(|v| (0..=6).contains(&v))
&& self.voice.is_none_or(|v| v == 0 || (41..=96).contains(&v))
&& self.shoe_class.is_none_or(|v| (1..=5).contains(&v))
&& self.weight.is_none_or(|v| (1..=5).contains(&v))
&& self.materials.is_none_or(|m| m.iter().all(|x| *x <= 143))
&& self.source.as_deref().is_none_or(valid_source)
&& self.from.is_none_or(|v| v.is_finite() && (0.0..=36_000.0).contains(&v))
&& self.seconds.is_none_or(|v| v.is_finite() && (1.0..=600.0).contains(&v))
&& self.material.is_none_or(|v| v <= 143)
&& self.grind_material.is_none_or(|v| v <= 143)
}
/// The fields of other kinds are rejected (so a typo'd kind is an error, not silence).
pub fn validate_for(&self, kind: ObjectKind) -> bool {
let traffic = self.engine.is_some() || self.speed.is_some() || self.load.is_some() || self.horn.is_some() || self.skidding.is_some();
let ped = self.voice.is_some() || self.shoe_class.is_some() || self.weight.is_some() || self.close_range.is_some() || self.feet.is_some() || self.materials.is_some() || self.footsteps.is_some();
let skater = self.source.is_some() || self.from.is_some() || self.seconds.is_some() || self.wheels.is_some() || self.material.is_some() || self.grinding.is_some() || self.grind_material.is_some() || self.air.is_some();
self.validate()
&& match kind {
ObjectKind::Traffic => !ped && !skater,
ObjectKind::Ped => !traffic && !skater,
// A lite skater takes its speed from `speed` too.
ObjectKind::Skater => !ped && self.engine.is_none() && self.load.is_none() && self.horn.is_none() && self.skidding.is_none(),
}
}
}
impl WorldAudioEventOptions {
pub fn validate(&self, event: &str) -> bool {
match event {
"horn" => self.kind.is_none_or(|k| (1..=5).contains(&k)) && self.seconds.is_none_or(|s| s.is_finite() && (0.0..=30.0).contains(&s)) && self.value.is_none(),
"alarm" => self.kind.is_none() && self.seconds.is_none() && self.value.is_none(),
"speech" => {
self.kind.is_none()
&& self.seconds.is_none()
&& match &self.value {
Some(serde_json::Value::Number(n)) => n.as_i64().is_some_and(|v| (0..=127).contains(&v)),
Some(serde_json::Value::String(s)) => !s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ' ' || c == '/'),
_ => false,
}
}
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn options_validate_per_kind() {
let car: WorldAudioOptions = serde_json::from_value(json!({"engine":"c04_taxi01","position":[1,2,3],"speed":12,"load":-3.5,"skidding":true})).unwrap();
assert!(car.validate_for(ObjectKind::Traffic));
assert!(!car.validate_for(ObjectKind::Ped));
let ped: WorldAudioOptions = serde_json::from_value(json!({"voice":59,"shoe_class":3,"feet":[true,false],"materials":[0,0]})).unwrap();
assert!(ped.validate_for(ObjectKind::Ped) && !ped.validate_for(ObjectKind::Traffic));
let ghost: WorldAudioOptions = serde_json::from_value(json!({"source":"state_log:state_20261003_143434","from":30,"seconds":20,"position":[0,0,0]})).unwrap();
assert!(ghost.validate_for(ObjectKind::Skater));
let lite: WorldAudioOptions = serde_json::from_value(json!({"source":"lite","speed":5,"wheels":[true,true,true,true],"air":false})).unwrap();
assert!(lite.validate_for(ObjectKind::Skater));
for bad in [json!({"voice":12}), json!({"shoe_class":0}), json!({"horn":7}), json!({"source":"state_log:../x"}), json!({"source":"record"}),
json!({"engine":"a b"}), json!({"position":[0,1e9,0]}), json!({"velocity":[1000,0,0]}), json!({"seconds":0})] {
let o: WorldAudioOptions = serde_json::from_value(bad.clone()).unwrap();
assert!(!o.validate(), "accepted {bad}");
}
assert!(serde_json::from_value::<WorldAudioOptions>(json!({"typo":1})).is_err());
}
#[test]
fn events_validate() {
let horn: WorldAudioEventOptions = serde_json::from_value(json!({"kind":3,"seconds":1.2})).unwrap();
assert!(horn.validate("horn") && !horn.validate("alarm") && !horn.validate("bark"));
assert!(WorldAudioEventOptions::default().validate("alarm"));
let speech: WorldAudioEventOptions = serde_json::from_value(json!({"value":"warn"})).unwrap();
assert!(speech.validate("speech"));
let speech: WorldAudioEventOptions = serde_json::from_value(json!({"value":23})).unwrap();
assert!(speech.validate("speech"));
let bad: WorldAudioEventOptions = serde_json::from_value(json!({"value":500})).unwrap();
assert!(!bad.validate("speech"));
}
}
+204 -4
View File
@@ -362,9 +362,7 @@ this keeps retail's bed-to-player balance. Before this the beds were 11 dB too l
### Dev tools ### Dev tools
- `py -3.13 tools/audio_audition.py` → `.local/audio-audition/index.html`: every cue's current samples - (Removed 2026-10-03: `tools/audio_audition.py`, the cue audition page; see "PR #32 review fixes, step 1".)
(parsed from `cues.rs`), each bank in full grouped into runs of similar clips, grains and beds; players at
25 % volume; links the installed WAVs, copies nothing.
- `cargo run -p skate-data --release --example audio_surfaces -- <map.skate>` lists audio surface IDs per - `cargo run -p skate-data --release --example audio_surfaces -- <map.skate>` lists audio surface IDs per
map; with `AT=x,y,z[,r]` it lists the collision surfaces (audio ID, physics type, slope) within r m map; with `AT=x,y,z[,r]` it lists the collision surfaces (audio ID, physics type, slope) within r m
(default 3) of a point, e.g. a HUD position the user reports. (default 3) of a point, e.g. a HUD position the user reports.
@@ -453,7 +451,7 @@ Each step came from the user's report of an in-play session plus the `AUDIO_*` l
## Files ## Files
- New: `tools/asset_pipeline/audio_formats.py`, `audio_export.py`, `test_audio_formats.py`, - New: `tools/asset_pipeline/audio_formats.py`, `audio_export.py`, `test_audio_formats.py`,
`tools/audio_audition.py`, `crates/skate-game/src/game_audio/{mod,library,voices,ambience,cues,skate_events}.rs`, `tools/audio_audition.py` (removed 2026-10-03), `crates/skate-game/src/game_audio/{mod,library,voices,ambience,cues,skate_events}.rs`,
`crates/skate-data/examples/audio_surfaces.rs`. `crates/skate-data/examples/audio_surfaces.rs`.
- 2026-10-02 world audio: - 2026-10-02 world audio:
- `game_audio/emitters.rs` replaces `water.rs`; - `game_audio/emitters.rs` replaces `water.rs`;
@@ -3438,6 +3436,9 @@ Research for the native audio runtime (specs in progress, 2026-10-02) also draws
[BurnoutDecomp/b5-decomp](https://github.com/BurnoutDecomp/b5-decomp): RenderWare Audio plug-in [BurnoutDecomp/b5-decomp](https://github.com/BurnoutDecomp/b5-decomp): RenderWare Audio plug-in
identities, the SndPlayer1 and filter/resampler details, opcode names. b5-decomp has no licence, so it identities, the SndPlayer1 and filter/resampler details, opcode names. b5-decomp has no licence, so it
is reference only. is reference only.
- XNA Math / [DirectXMath](https://github.com/microsoft/DirectXMath) (Microsoft, MIT License): the
`XMVectorSin` / `XMVectorCos` polynomials the FrequencyShiftSsb oscillator uses (`skate-audio-fma`); the
coefficients are read from the shipped image.
- [mitsevox/tw2004](https://github.com/mitsevox/tw2004) (CC0): Tiger Woods 07 RenderWare Audio function - [mitsevox/tw2004](https://github.com/mitsevox/tw2004) (CC0): Tiger Woods 07 RenderWare Audio function
listings. listings.
No code from these is included. No code from these is included.
@@ -4403,3 +4404,202 @@ change surface too (tag 3: surface 0 → 1; tag 5 was metal, now concrete).
### Riding body-collision posts settled (2026-10-03, recomp session `audiox_ride_20261003_141046`, 0 malformed) ### Riding body-collision posts settled (2026-10-03, recomp session `audiox_ride_20261003_141046`, 0 malformed)
User ride (~4.5 min, 2 bails). Outside the bails (+3 s): body-poster posts from NPC skaters 163 (0.70/s, local72 0), from the local rider 49 (0.21/s), and the local ones are only three bursts, no steady riding posts: 45.9 s (4), 172.7 s (19; screenshot: landing a 7.5 ft stair gap) and 202.6 s (26; pushing into a parked car, vehicle material 36). So steady riding gives 0 local body posts in retail, as in ours; the earlier ~1.7/s was NPC skaters plus such events. Follow-up: check that ours posts body hits on a big-drop landing and on bumping a car (a state-log session with both). User ride (~4.5 min, 2 bails). Outside the bails (+3 s): body-poster posts from NPC skaters 163 (0.70/s, local72 0), from the local rider 49 (0.21/s), and the local ones are only three bursts, no steady riding posts: 45.9 s (4), 172.7 s (19; screenshot: landing a 7.5 ft stair gap) and 202.6 s (26; pushing into a parked car, vehicle material 36). So steady riding gives 0 local body posts in retail, as in ours; the earlier ~1.7/s was NPC skaters plus such events. Follow-up: check that ours posts body hits on a big-drop landing and on bumping a car (a state-log session with both).
### PR #32 review fixes, step 1: the host clock is the physics steps; guards, tests, cleanups (2026-10-03, headless)
**Problem (code review of PR #32).** `skate_events::observe` runs once per physics step (FixedUpdate, Virtual
time, 16.6666 ms) and overwrote `Cues.riding` each step. `native::mixmap_frame` (Update) ran its own `Time<Real>`
accumulator (`mix_clock += delta`, ticks = mix_clock / (1/60), cap 4) and ran inputs / process / update on whatever
sample was in `Cues`. So the two grids drifted:
- a frame with 2 physics steps and 1 mix tick lost the first step's one-step pulse (`+335`, the push plant's rise);
- a frame with 1 mix tick and 0 steps re-processed a stale sample;
- while the menu paused `Time<Virtual>`, `mixmap_frame` and the grain bed kept ticking on the stale sample;
- below 60 fps the components saw every second step (still true, see below: that is retail's behaviour too).
**Root cause.** Two clocks for one stream of samples. Retail's audio manager (`sub_82485190`) reads the record the
game steps wrote; it never runs on a sample twice and never on a frame the game didn't step.
**Change.**
- `skate_events::Cues` counts the steps published since the host last took them (`Cues::publish` /
`take_steps`). While steps are pending, the one-step pulses of the new step are OR-ed with the pending one
(`latch_pulses`: `push_trigger` = `+335`). Every other field is a level (the latest step's value is what retail's
per-frame manager reads too), a latch (`+228`, `+468`, `+192` / `+692`), a 4-frame max (`+668`, `+496..`) or a
counter (`Riding::pushes`); the components find their own edges (landing, bail, grind) against the last sample
they processed, so those survive a 2-step frame without latching.
- `native::HostClock::pass` (used by `mixmap_frame` and the e2e harness): takes the frame's steps. No step, or the
game silenced (menu, replay, the multiplayer menu that doesn't pause): no pass (no inputs, process, tick or
update; a silenced frame drops its steps and pulses, as the stream is paused). Otherwise ticks = steps, at most
`MAX_STEPS_PER_FRAME` = 4; the console cadence advances by those ticks.
- **The cap (4 steps = 2 console evaluations).** Retail runs its audio manager once per rendered frame and never
catches up: a long frame is one call with a long dt. Our host counts steps to stay on the console's 30 Hz grid at any
frame rate, so it bounds the catch-up instead. 4 keeps frame-rate independence down to 15 fps (the old accumulator's
cap) while a hitch (Bevy runs up to 15 physics steps after a 250 ms frame) can't release a burst of evaluations,
Jitter steps or poster calls. The pulses of the dropped steps stay latched.
- `Native::frame_ticks`: the grain bed (`grain_bed::update`) runs only on frames with a pass, dt = ticks × 1/60 (as
the e2e harness always did), not every rendered frame on `Time<Real>`. Pause no longer advances its envelopes.
- Class_Seams' console cadence (`seam_frame`) still runs on every rendered frame (its own 30 Hz grid), now on game
time (`Time<Virtual>`, still while paused) and not while silenced. The rendered board's interpolation pair is the
last two physics steps (`Riding::wheels_before`, `PlayerAudio::step_wheels`), so a frame that takes two steps
interpolates between the right two.
- Pause.in0 is no longer written as 32767: nothing ticks while silenced, so no evaluation would read it.
- **Camera cuts.** `Presentation::cuts` counts the engine's snaps (teleport flag, camera discontinuity, cadence
change). On a change, or a map change (`CurrentMap::generation`), `mixmap_frame` resets the listener's last camera
(`PlayerAudio::reset_listener`: no Doppler velocity from the jump) and the seam pair (no interpolation from the old
place), and counts `Native::cuts`; the world / NPC hosts drop their camera velocity on a change of it.
**Evidence / proof.**
- e2e bench (`optimisation/tools/e2e_bench.sh`, 13 scenarios + 4 whole sessions; baseline built from `git archive
HEAD` in `.local/clk-base-src`): `row`, `fps60`, `fps144`, `fps300` byte-identical to the baseline (audio and voice
logs). `fps30` / `fps45` differ, as intended. Attribution: with the latch and the step pair both disabled in a
throw-away build, fps30 / fps45 are byte-identical to the baseline too, so these two are the only differences.
- The latch: in the 4 real sessions, 16 of 34 logged push edges fell on a row without a pass at 30 fps and were
dropped before; now each reaches one pass (also `ollies_log`'s one push).
- The step pair: at < 60 fps the old change-tracking pair spanned two steps.
- The e2e harness already modelled the ideal host at ≥ 60 fps (one pass per row); it now uses `HostClock` and
`Cues` itself, so the harness and the game share the scheduling code.
- Unit test `native::tests::the_host_clock_takes_every_physics_step_once`: a Bevy app with `TimePlugin`, the physics
period and `TimeUpdateStrategy::ManualDuration` frame times (alternating 1 / 2 steps, frames without a step, 30 fps,
a 300 ms hitch = 15 steps, a paused second, a silenced stretch that still steps). Every step is taken by exactly one
frame; a pass sees a pulse iff one of its steps had one (the hitch frame merges three); ticks = steps capped at 4;
no pass while paused / silenced or without a step; console evaluations = ticks / 2.
**Other review items in this step.**
- Removed `trace-downtown-lag.json` (now ignored as `/trace-*.json`, `TRACE_PLAY.bat` writes it) and
`tools/audio_audition.py` (the audition pages are gone; the "Dev tools" and "Files" entries above are updated).
- **No fallback, for real.** `emitters.rs` no longer plays the measured `PROFILES` table through Bevy voices when the
native runtime is absent: without it the emitters are silent (the runtime's start logs why). Removed with it: the
relay / loop patterns, the shuffle bag, `Library::sample_seconds` and the manifest's `seconds` field read.
`native::follow_volume`: Bevy voices always play at 1 / `RETAIL_SCALE` with the native fold and swapped ears (before,
an install without the runtime kept ×2 and Bevy's mirrored panning). Identical with the runtime.
- Data-gated tests: 68 tests in `skate-audio` and `game_audio` (96 skip sites, incl. the world / NPC ones) are
`#[ignore = "needs the private install data"]`, and a missing piece now panics ("missing private data: …") instead
of passing silently. Run them with `-- --ignored` (or `--include-ignored`). On this machine every ignored test passes
(skate-audio 51, game_audio 21, older diagnostics included). The wall-clock assertion in the prefetch test is gone (timings are printed).
- `AUDIO_NATIVE post` / `release` lines (one per component post, under the runtime lock) only with
`SKATE_AUDIO_TRACE=1`. No tool reads them.
- Guards: `formats::abk` template end is a checked add; `Runtime::fill_stereo` turns non-finite samples into 0 for
the device (host-side safety, not a parity change: finite samples pass untouched; e2e reads the bus before it).
- Allocations: the evaluator reuses unloaded bank slots (map changes no longer grow the list); `Evaluator::walk`
reuses its order snapshot; `redeliver` / `release` read their client lists in place; `destroy` reads the module's
object lists through the bank's `Arc` (it copied them). `grain_bed::update` no longer clones the seam wobbles and
builds a `PlayerTuning` every frame. `tests/render_alloc.rs` gains a hand-built AEMS bank (`eval::synthetic`, the
evaluator tests' builder made public and hidden from docs) with a playing program, a redeliver every console frame
and a release: 0 allocations in 480 warm blocks; the allocation counter is per thread now. All e2e outputs of
these changes are byte-identical (bench `clk_final` vs `clk_new`, every mode).
- clippy `approx_constant` errors in the `dsp::pan` tests (`FRAC_1_SQRT_2`); 0 clippy errors in `skate-audio`.
- `.gitignore`: `*.wav` is scoped to the folders retail audio is written to (`/assets/private`, `/data`,
`/maps/private`, `/.local`); no untracked WAV became visible. Mods and the SDK may ship WAVs.
- Credit: the FSS `sin` / `cos` polynomials are XNA Math's `XMVectorSin` / `XMVectorCos` (successor DirectXMath,
Microsoft, MIT); see Credits.
**Files.** `game_audio/{skate_events,native,grain_bed,player_audio,e2e,emitters,library,world_sources,npc_skaters}.rs`,
`game_audio/native/prefetch.rs`, `presentation.rs` (the cut counter), `skate-audio/src/{eval/mod.rs,eval/synthetic.rs,
eval/tests.rs,formats/abk.rs,runtime.rs,dsp/pan.rs}`, `skate-audio/tests/render_alloc.rs`, every data-gated test file,
`skate-audio-fma/src/lib.rs`, `.gitignore`.
**Open questions.**
- Slow motion: the physics timer period changes (`physics/clock.rs`), so the host now ticks slower in real time with
it (it followed real time before). What retail's audio manager does in slow motion is not traced (`+220` time
scale is written 1.0).
- The world / NPC hosts keep NodeIds across `unload_map_banks` (not fixed here; the world build-out step).
- Needs the user's listening check at a low or uneven frame rate (pushes) and after a menu pause.
### World audio hook-in, phases P0–P2: the map-change fix, the engine / mod surface, the dev test mod (2026-10-03, headless)
Full write-up, field tables and examples: [doc 15](15-world-audio.md). Design: `.claude/notes/world-audio-hookin-spec.md`.
- **P0, map change.** `Native::unload_map_banks` destroyed every instance of the 13 world banks, but `WorldHost`
kept the dead nodes and "already posted" objects, so an owner that survived the change was silent for the rest of
its life. Now `Native::map_epoch` is bumped by the unload. On a change, `WorldHost` / `NpcHost` release every node,
clear the pools / records, deactivate the 3DObjPos blocks, stop the ped Splice steps and the NPC bed, and drop
their objects; the owners still published are claimed and posted afresh. The evaluation count is a
`saturating_sub`. The world hosts' `dt` is now 1/30 per console evaluation and 1/60 per tick with
`SKATE_AEMS_MIX_CONSOLE=0` (it was `CONSOLE_DT` either way, double the real time in the non-console mode; that mode
went with the A/B switches, below, so it is 1/30 now).
Regression test `world_owners_post_again_after_a_map_change`, which fails without the reset.
- **Retail limits from the gap runs** (spec §7.3): traffic list cut at 40 m horizontal (4 nearest); ped list cut at
50 m (15 nearest); ped footsteps for the 3 nearest (`S+68`); `S+148` / `S+156` = the distance / 20 m. Applied by
the hosts (radii) and the bridge (footsteps-on, the pair).
- **P1, the engine surface** `crate::world_audio`: `TrafficAudio`, `PedAudio`, `NpcSkaterAudio`, `AudioVelocity`,
the read-back `WorldAudioInstance`, `LivingWorldAudio`, `WorldAudioStats`, the messages `PedSpeechEvent` /
`VehicleHorn` / `VehicleAlarm` (8 s). Bridge: `game_audio/world_bridge.rs`.
- The per-skater builder `skate_events::skater_audio_state` + `SkaterAudioMemory` is a pure move of `observe`'s
code. 1,500 production physics steps (`audio_state_capture`) gave a published state identical line for line
to the pre-refactor capture, and the builder equals `observe` on every step.
- `AudioState::rolling(&LiteSkater)` is the documented minimal fill for skaters not simulated with the player's
physics.
- **User decisions included** (2026-10-03):
- mod cars may opt in to retail traffic engine sounds (`body=`);
- remote multiplayer players take the NPC skater instance, first in the list (non-retail; a lite state with the
ground's material);
- opt-in non-retail "more audible" (`settings/audio.json` `"more_audible_world": true`: 8 / 24 / 3 instances;
the MixMap is built with them; instance 0 unchanged);
- no traffic-light sounds.
- **Mods:** `sdk.world_audio.spawn / update / event / remove / read / info` (capability `world_audio` = 1). 16
objects per mod, 64 in all; an object is parked after 0.5 s without updates; everything is cleaned up on disable,
reload or failure. The existing `sdk.audio.*` is unchanged.
- **P2, the dev test publisher** `mods/world-audio-test/`:
- 16 cars (c00–c08 records, honks, skids, F8 alarm);
- 20 peds (walk / jog / run, warn / cheer / slam);
- a ghost NPC skater replaying a window of one of the user's state logs (`logs/<name>.tsv` or
`SKATE_AUDIO_STATE_LOGS`);
- debug boxes by audibility.
The e2e state-log replay moved unchanged from `e2e.rs` to `game_audio/state_replay.rs` for the ghost.
- **Proof for the local player:** the e2e bench is byte-identical before / after (13 scenarios + 4 sessions, row and
fps300). Tests: the bridge unit tests, the ghost through the real host (claim, release at range, a map change), and
the skate-mods validation plus a 400-frame run of the test mod.
- **Learned for the next pass (P3), from G3:** for an NPC, Wheels runs (layers 0 / 1) and Clothing runs; Tricks and
Treatment are local only; OffBoard creates packets but plays no steps. Instance 1 changes hands often (12× in
74 s).
### A/B switches removed (2026-10-03, user decision; separate change after the world audio P0–P2)
**Decision (the user):** remove the 15 verdict-settled A/B env switches. Their ON behaviour becomes the only
behaviour.
**Removed:**
- `SKATE_AEMS_SEAM_PULSE`, `_MIX_CONSOLE`, `_PUSH_PLANT`, `_PLANT_LIFT`, `_BODY_IMPACTS`, `_GRIND_ONOFF`,
`_AIR_TIME_STATE`, `_SUBMIX`, `_FOOTSTEP_SUBMIX`, `_REVERB_INPUTS`, `_REVERB_ZONES`, `_BODY_CONSOLE`,
`_DECK_CONSOLE`, `_BODY_CURVE`, `_SLEW_CONSOLE`;
- their e2e equivalents: `E2E_MIX_CONSOLE`, `_BODY_CONSOLE`, `_DECK_CONSOLE`, `_BODY_CURVE`, `_PLANT_LIFT`,
`_BODY_IMPACTS`, `_GRIND_ONOFF`, `_SLEW_CONSOLE`, `_FOOTSTEP_SUBMIX`, `_REVERB_INPUTS`, `_PUSH_PLANT`,
`_AIR_TIME_STATE`;
- the dead comment-only names `SKATE_AEMS_PLAYER` / `SKATE_AEMS_FOOTSTEPS`.
**Kept:** `SKATE_AEMS_WORLD`, `_WORLD_PREFETCH`, `_NPC_SKATERS`, `SKATE_AUDIO_FMA`, the tools (`SKATE_AUDIO_TRACE` /
`TIMING` / `STATE_LOG` / `SET`, `SKATE_AEMS_BANKS`), and the e2e harness's part switches (`E2E_ROLLING`,
`E2E_CONTACTS`, `E2E_BUSES`, …).
**Dead code removed with them (game crate):**
- the old 60 Hz MixMap evaluation (`HostClock::pass` has no `console` parameter; every pass is on the console
grid, the eEQChain clear included);
- the per-tick Class_Seams process (`PlayerAudio::seam_console`; the seams run in `seam_frame`);
- the fixed Reverb.in5 path;
- the zone-less reverb;
- the per-call body / deck posters (`PlayerAudio::body_console` / `deck_console`);
- the dt-scaled bed slews (`Bed::slew_console`);
- the air-flag `+236` count and the animation-contact push plant (`air_time_236` / `push_plant` lost their switch
parameters);
- `set_review_ports` / `set_body_curve`;
- the e2e harness's non-console row mode.
**Kept on purpose:** the `skate-audio` components' own on / off fields (`Contacts::plant_lift_on` / `body_on` /
`body_speed_on`, `Grind::onoff`, `CollisionManager::submix`, `Option` body / deck / slew calls). The game always
turns them on. Their unit tests use "off" to isolate one part, so these are component configuration, not switches.
Their docs no longer name env vars.
**The e2e `row` mode is now the game's host at 60 fps.** Before, it ran the removed old 60 Hz evaluation and the
per-tick seams.
**Proof (byte-identical):**
- e2e bench before (`wa_new` row / fps300, `sw_base` fps30 / fps60 / fps144) vs after (`sw_new`, all five
modes): fps30, fps60, fps144 and fps300 IDENTICAL for the 13 scenarios and the 4 whole sessions (26 + 8 outputs
each);
- the new `row` renders are identical to the old `fps60` renders, which is what the game ran at 60 fps;
- `audio_state_capture` (1,500 production physics steps through `observe`) is identical to its capture from before
both changes;
- every unit and data test passes.
- two data tests (`seams_play_their_retail_bank`, `seam_hits_shuffle_their_samples`) drove the removed per-tick seams.
They now call `seam_frame` before each pass, as `mixmap_frame` does, and pass with the same picture (pattern 11
at 10 / 20 / 30 km/h: 39 / 67 / 93 voices; brick: all 16 samples).
+271
View File
@@ -0,0 +1,271 @@
# Hooking up world audio (traffic, pedestrians, NPC skaters)
Part of PR #32 (branch `gameplay/audio`; doc [11](11-audio.md) has the ported audio itself).
## Problem
Skate 3's living world has its own sounds: traffic engines with Doppler, horns, skids and car
alarms; pedestrians' footsteps and speech; and the board sounds of an AI skater near the camera. The
native audio port (`crates/skate-audio`, `skate_audio::world`) already plays all of them the way
retail does. But the engine has no traffic, pedestrian or AI-skater system yet. Nothing upstream or
in any fork has one, and no issue asks for one (prior-work check, 2026-10-03). The sounds therefore
had no way in.
The goal (the user, 2026-10-03): "build out everything that can then easily be hooked to by the game
engine once they are added", in this PR, and moddable like the rest of the engine.
## The change in one paragraph
A future engine system adds **components** to its own entities: `TrafficAudio`, `PedAudio`,
`NpcSkaterAudio`, plus an optional `AudioVelocity`. It sends a few **messages**: `PedSpeechEvent`,
`VehicleHorn` and `VehicleAlarm`. A **bridge** turns these into the audio hosts' inputs every frame.
The hosts apply **retail's limits** to decide who is audible, and the bridge marks the audible
entities with `WorldAudioInstance`. Lua mods reach the same path through `sdk.world_audio`. A dev
mod, `mods/world-audio-test`, makes it all audible now.
## Engine surface (`crates/skate-game/src/world_audio.rs`)
Position and heading come from the entity's `GlobalTransform`: its +Z axis is the forward
direction, the game's convention, and retail's vehicle `+112` is likewise the world matrix's forward
row (recomp gap run G1). Velocity comes from the transform's change per frame unless the entity has
an `AudioVelocity`. Give one to anything that teleports or is a kinematic proxy, because Doppler and
the 3-D rates read it.
**Lifetime = the entity.** To publish, insert the component. To release, despawn the entity or
remove the component. Owner ids are `Entity::to_bits()`, so a reused index counts as a new owner.
### `TrafficAudio` (retail `SFXObj_TrafficEngine` / `TrafficHorn` / `TrafficSkids`)
| field | retail | meaning | who fills it | default |
|---|---|---|---|---|
| `engine` | vehicle record `+168` | the `aud_traffic_engine` record: sedans / hatchback `c01_family01`, sports / muscle `c03_sports01`, taxi / patrol `c04_taxi01`, SUV / pickup / minivan `c05_truck01` (entity → vehicle spec → record, G1). The engine's patch override picks c06 / c07 / c08 itself. | the vehicle system, from the model | — (an unknown name is logged once and stays silent) |
| `speed` | `+148` | m/s | the vehicle system | the length of the velocity |
| `load` | `+144` | the driver's signed acceleration in m/s². G1 measured −15.6 in a hard stop, up to +3 pulling away, and 0 while cruising. It goes into the engine and skid words × 3000. | the driving AI | derived from the speed change |
| `horn` | `+156` | `None`, `Honk(1..=5)` or `Alarm` | the AI (honk decisions) | `None` |
| `skidding` | `+160` | the tyres skid | the AI (hard braking or a swerve) | false |
### `PedAudio` (retail `SFXObj_PedestrianSFX` / `PedestrianSpeech`, ped state S, G2)
| field | retail | meaning | default |
|---|---|---|---|
| `voice` | `S+84` | the model = the speech voice id 41–96 | none (no speech) |
| `shoe_class` | `S+132` | 1–5 per model (`aud_characteristics`). Most models are 2; no model uses 1, which is silent. | 2 |
| `weight` | `[obj+28]+144` | 1–5. It changes per ped in retail (1 beyond about 12 m, 2–5 nearer) and its meaning is open. | 1 |
| `close_range` | `S+96 == 64` | the security guards' close-range footstep levels | false |
| `feet_down` | `S+74` / `S+73` | the walk animation's foot plants (A, B) | up |
| `foot_materials` | `S+140` / `S+144` | the audio surface materials under the feet | 0 (retail's pavements read 0 in every line) |
| `footsteps_on` | `S+68` | footsteps on | retail's rule: the 3 nearest peds in the list |
| `speech_distance` | `S+148` / `S+156` | distance to the listener / the model's far threshold. Beyond the threshold the far `_f` lines are used. | the 3-D distance / 20 m (retail for regular peds) |
| `speech_value` | `S+136` | the state graph's speech value | 0. Send `PedSpeechEvent` rather than writing it. |
### `NpcSkaterAudio` (the MixMap Player slot's second instance)
| field | meaning | default |
|---|---|---|
| `list_order` | the skater list position (retail walks its list in order) | spawn order |
| `state` | this frame's `AudioState`. A skater simulated with the player's physics uses `game_audio::skate_events::skater_audio_state(physics, skater, &mut memory, dt)`, the same builder the local player uses (proved identical, below). Anything else uses `AudioState::rolling(&LiteSkater { .. })`: speed, wheels, materials, grind / air flags. With that fill, rolling, surfaces, seams, grinds and landings sound; tricks and foot / body foley stay silent. | none (not published) |
| `remote` | a remote multiplayer player (see below) | false |
### Messages, resources and the read-back
- `PedSpeechEvent { ped, value: SpeechValue }`: sets the ped's speech value, so PedestrianSpeech sees
the change and requests a line. `SpeechValue::from_name` accepts the state-graph names (`DoWarning`,
`LongCheer`, …), the short names `warn` / `cheer` / `slam` / `flee` / `knockdown` / `nearby`, and
numbers.
- `VehicleHorn { vehicle, kind, seconds }`: holds horn kind `kind` for the caller's time. The length
is the AI's choice, not retail data.
- `VehicleAlarm { vehicle }`: retail's alarm, horn state 6 for **8 s**.
- `LivingWorldAudio { expected }`: set it at map load when the system will publish. The 13 world
banks then decode on the prefetch worker instead of the game thread.
- `WorldAudioInstance { slot, instance }` (read-back): the bridge inserts it on the entities that
hold a MixMap instance. Only those are audible. An engine system can use it to skip per-frame
audio work for the others, such as an NPC's `AudioState`.
- `WorldAudioStats`: published and audible counts, the instance layout, and whether the
"more audible" setting is on.
### Who is audible (retail's limits, applied by the hosts)
| pool | instances | rule | source |
|---|---|---|---|
| Traffic | 4 | the 4 nearest within **40 m**, measured horizontally | G1: every holder was among the 4 nearest in 311 of 311 holder-seconds; the list is cut at 39.994 m |
| Pedestrians | 15 | the 15 nearest within **50 m**; footsteps for the **3** nearest | G2: manager `sub_824F2890`; `+68 == (index < 3)` in 2,803 of 2,803 lines |
| NPC skater | 1 | the first in list order within **30 m** of the camera, held until it is 30 m away or more | `sub_824F1FB0` / `sub_824F8EF8` |
**Opt-in "more audible" setting (not retail; user decision 2026-10-03):** `settings/audio.json`
`"more_audible_world": true`, or `SKATE_AUDIO_MORE_AUDIBLE=1` for one run. The MixMap is then built
with 8 traffic, 24 pedestrian and 4 Player instances (3 NPC / remote skaters). The extra objects
get retail's lookups, curves and posts; only their number is not retail. Instance 0 of every slot
is unchanged. The setting is read at start. The runtime has one NPC grain bed, so only NPC instance
1 has granular rolling. Default: off (retail).
**Remote multiplayer players (not retail; user decision 2026-10-03):** another real player nearby
takes the NPC skater instance, ahead of the NPCs in the list order. Retail's online behaviour is
not traced. The remote state carries only the body and pose, so the bridge gives remote players a
lite state from their root transform. The material under them comes from the same stock line query
the wheel lines use.
**No traffic-light or crossing sounds:** Skate 3 has none (the user, 2026-10-03).
### Example: an engine system
```rust
use crate::world_audio::*;
// A traffic system spawning a taxi (heading = the entity's +Z):
commands.spawn((Transform::from_xyz(10.0, 0.0, 4.0), TrafficAudio::new("c04_taxi01")));
// Its driving AI each frame:
fn drive(mut cars: Query<(&mut Transform, &mut TrafficAudio, &Car)>) {
for (mut t, mut audio, car) in &mut cars {
t.translation = car.position;
audio.load = Some(car.acceleration); // m/s², or None to derive it
audio.skidding = car.acceleration < -8.0;
}
}
// The AI honks at a skater in the lane, a car is hit:
honks.write(VehicleHorn { vehicle, kind: 2, seconds: 0.8 });
alarms.write(VehicleAlarm { vehicle });
// A ped system: walk animation foot plants, reactions from its state graph:
commands.spawn((transform, PedAudio { voice: Some(59), shoe_class: 5, ..Default::default() }));
speech.write(PedSpeechEvent { ped, value: SpeechValue::WARN });
// An AI skater simulated with the player's physics, near the camera:
npc.state = Some(skate_events::skater_audio_state(&physics, &skater, &mut memory, dt));
```
## Mod surface (`sdk.world_audio`, API 2, capability `world_audio` = 1)
Mods publish through the same components: each key becomes an entity. The rules follow the existing
mod rules. Keys belong to the calling mod. The limits are **16 objects per mod and 64 in all**.
Options are validated before anything is allocated, and a field of another kind is an error. An
object that is not updated for **0.5 s is parked** (speed 0, feet up, horn off). Disabling,
reloading or a failing mod removes everything it published. The existing `sdk.audio.*` (the mod's
own WAVs) is unchanged.
```lua
sdk.world_audio.spawn('car1', 'traffic', {engine='c04_taxi01', position=p, heading=h})
sdk.world_audio.update('car1', {position=p, velocity=v, speed=12, load=-3, skidding=false})
sdk.world_audio.event('car1', 'horn', {kind=3, seconds=1.2}) -- or 'alarm' (8 s)
sdk.world_audio.spawn('taxi', 'traffic', {engine='c04_taxi01', body='chassis'}) -- a mod car opts in to a retail engine sound
sdk.world_audio.spawn('ped1', 'ped', {voice=59, shoe_class=3, position=p})
sdk.world_audio.update('ped1', {position=p, feet={true,false}})
sdk.world_audio.event('ped1', 'speech', {value='warn'}) -- name or number
sdk.world_audio.spawn('npc1', 'skater', {position=p, speed=6}) -- lite: rolling from these fields
sdk.world_audio.spawn('ghost', 'skater', {source='state_log:state_20261003_143434', from=30, seconds=20, position=p})
local r = sdk.world_audio.read('car1') -- {kind='traffic', audible=true, instance=2, parked=false}
local info = sdk.world_audio.info() -- {more_audible=false, instances={traffic=4, peds=15, skaters=1}, ...}
sdk.world_audio.remove('car1')
```
`body=` makes an object follow one of the mod's physics bodies (position, rotation, velocity). This
is how a mod car opts in to the retail traffic engine sound; that choice is the user's
(2026-10-03) and is not retail. A **ghost** replays one of the user's recorded audio state logs
(`SKATE_AUDIO_STATE_LOG` writes them) at full fidelity. It reads `logs/<name>.tsv` in the mod
folder, or `<name>.tsv` in the folder named by `SKATE_AUDIO_STATE_LOGS`. A log with any malformed
line is refused.
## The dev test publisher (`mods/world-audio-test/`)
The mod is dev-only: it should not ship upstream unless the user asks. It drives everything around
the spot where the mod starts:
- **16 cars** on four rectangular lanes, cycling through the nine engine records `c00`–`c08`. Each
car accelerates at +2.5 m/s², cruises at 8–15 m/s for 3–8 s, then brakes: hard (−12 m/s², with
skids) or soft (−4). One car honks every 10–15 s with kind 1–5. One taxi is parked, and **F8**
fires its alarm.
- **20 peds** on back-and-forth lines: 16 walk (1.3 m/s), 3 jog (4) and 1 runs (8), so the three
`sk8_foley` step ids play. A gait clock alternates the feet; it is dev-only, not retail. Shoe
classes are 2–5, and the voices cycle through 41–96. Reactions:
- warn (11) when you pass within 1.5 m at more than 3 m/s;
- cheer (23) on a landed trick within 10 m;
- slam (25) on a bail within 10 m.
Speech requests are logged (`AUDIO_WORLD speech`). Playback comes in the next pass.
- **A ghost NPC skater** replays the 20 s window of a recorded state log, 5 m beside the start.
Settings: `ghost_log`, `ghost_from`.
- Debug **boxes**: green = holds an instance (audible), grey = not. An on-screen line shows the
audible counts, the limits and the ghost's status. **F9** re-centres everything on the skater.
## Proofs and tests
- **The map-change bug (P0).** `unload_map_banks` (a map change) destroyed every instance of the 13
world banks. `WorldHost` kept its dead nodes and "already posted" objects, so an owner that
survived the change redelivered to a dead node and stayed silent. The fix:
- `Native::map_epoch` is bumped by `unload_map_banks`;
- on a change both hosts release every node, clear their pools, deactivate the 3DObjPos blocks,
stop the ped Splice steps and the NPC bed, and drop their objects;
- the evaluation count is `saturating_sub`;
- the evaluation `dt` follows the MixMap cadence: 1/30 per console evaluation. The old 60 Hz
mode, where `CONSOLE_DT` doubled it, was removed with the A/B switches the same day (doc 11).
Test `world_owners_post_again_after_a_map_change` (data-gated): a taxi and a ped publish, the C04
engine sounds, the map changes, and the same ids post afresh and sound again. Without the epoch
reset the test fails ("the same owner sounds again after the map change"). The ghost test below
checks the NPC host's reset the same way.
- **The per-skater builder** (`skater_audio_state`, `SkaterAudioMemory`) is a pure move of
`observe`'s code. Test `audio_state_capture` (data-gated) drives 1,500 production physics steps
headless (push, carves, two ollies, a powerslide, a roll-out) and runs `observe` after each. Its
published samples are **identical line for line** to a capture taken before the refactor. On
every step the builder, run on its own memory, also gives the same `AudioState` as `observe`.
- **The local player's output:** the e2e bench (13 scenarios and 4 whole sessions, row and fps300)
is **byte-identical** before and after this work (`wa_base` vs `wa_new`). The state-log replay
that e2e uses was moved, unchanged, into `game_audio/state_replay.rs` so the ghost can use it.
- Bridge unit tests (a minimal Bevy app):
- components become owners;
- the ped footsteps-on and far-threshold rules hold, and remote players come first;
- the alarm, horn and speech messages work;
- the read-back inserts and removes `WorldAudioInstance`;
- despawning releases, and nothing happens without components.
- `a_ghost_claims_instance_1_releases_and_survives_a_map_change` (data-gated): a real user log as a
ghost holds Player instance 1 within 30 m and sounds. It is released at 400 m, and after a map
change it claims instance 1 and sounds again.
- skate-mods:
- command validation per kind, and the event options;
- the Lua wrappers cross the serde boundary;
- the bundled test mod runs 400 frames with no Lua error or invalid command, stays within 128
commands per callback, and its F9 re-centre spawns everything again.
`check_mod` validates the mod.
## Files
- New: `crates/skate-game/src/world_audio.rs`, `game_audio/world_bridge.rs`,
`game_audio/state_replay.rs`, `crates/skate-game/src/modding/world_audio.rs`,
`crates/skate-mods/src/world_audio.rs`, `crates/skate-game/src/tests/audio_state_capture.rs`,
`mods/world-audio-test/{mod.json,main.lua}`.
- Changed:
- `game_audio/{world_sources,npc_skaters,native,skate_events,e2e,mod}.rs`;
- `skate_audio::{player::state (LiteSkater, AudioState::rolling), world::skaters (Slots::with_records)}`;
- `skate-mods` `vm.rs`, `api.lua`, `lib.rs`;
- `modding/mod.rs`;
- `sdk/skate.lua`;
- `.gitignore` (`mods/world-audio-test/logs/`).
## Open questions and the next pass (P3–P5)
- **Speech playback** in the host: the manager, the library and the streams. The level and pan
mapping is still open.
- **The NPC instance's components.** G3 (2026-10-03) settled which ones run for an NPC:
- Wheels runs (spin streams on layers 0 / 1; layer 2 and the post-start parameter writes are
local only);
- Clothing runs (`sk8_foley` 73 / 74; body slide / cloth falls on bails; the start-block float is
0 for non-local; the eq-chain pick takes `local72`);
- Tricks and Treatment are local only;
- OffBoard creates its footstep packets but plays no steps for NPCs;
- the bail grunt `sub_824BF5F8` posts speech.
Instance 1 changes hands often (12 times in 74 s), so claims and releases must stay cheap.
- **PedBodyFall** (decode the object), **Tazer** (needs AEMS op 38).
- **Traffic bindings:**
- the `TrafficCarPhysics.in0` writer (|v_car − v_listener| clamped to 35, slewed by 100 /s,
× 32767 / 35; opens the A11 near boost);
- the 3DObjPos 4.1 / 4.2 points (front and rear ±1 m along the heading, R+80 / R+96, likely);
- retail's frozen record when a held car leaves the 40 m list (recorded behaviour, not ported).
- The per-model tables from `aud_characteristics` (shoe class, kind, far threshold 20 / 30 m) as a
setup export, so the engine names a model and gets the rest. The horn kinds per model are open.
- Moving process / update into `mixmap_frame` (retail's split; the inputs are one console frame
old today).
- The wider mod surface (P4): content overrides (banks, samples, programs, speech lines), mod
emitters on the native voice graph, tuning read / write, `sdk.audio.post`, and audio event hooks.
+2 -2
View File
@@ -16,9 +16,9 @@ verification, open questions).
| F | Tooling | Streaming map validator (`--validate-maps`) wired into setup | [06](06-map-validator.md) | `18f41a3` | E (speed only) | Draft upstream [#28](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/28) | | F | Tooling | Streaming map validator (`--validate-maps`) wired into setup | [06](06-map-validator.md) | `18f41a3` | E (speed only) | Draft upstream [#28](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/28) |
| H | Gameplay + rendering | Water: detection and bail, shallow water solid / deep water floats, board floats, water camera + vignette, entry splash, water look (family 33 cube blur + tint, anti-tiling, body-size calming, slower waves), animated water (frame-state buffer fix, ocean PCA from default.xex, XEX2 unpacker), ripple scale | [09](09-water.md) | `gameplay/water` (`af1b350`…`55776dc`), upstream branch `water/retail-water` (`8a6e328`, one commit on `60efdef`) | — (carries #28's `install.py` `spawn()` split verbatim) | Draft upstream [#30](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/30) | | H | Gameplay + rendering | Water: detection and bail, shallow water solid / deep water floats, board floats, water camera + vignette, entry splash, water look (family 33 cube blur + tint, anti-tiling, body-size calming, slower waves), animated water (frame-state buffer fix, ocean PCA from default.xex, XEX2 unpacker), ripple scale | [09](09-water.md) | `gameplay/water` (`af1b350`…`55776dc`), upstream branch `water/retail-water` (`8a6e328`, one commit on `60efdef`) | — (carries #28's `install.py` `spawn()` split verbatim) | Draft upstream [#30](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/30) |
| I | Test fix | `sky_shader_validates`: add the `VERTEX_POSITIONS` shader def Bevy sets for real meshes | [10](10-sky-shader-test.md) | `b7865d0` | — | Not opened; tiny, can go upstream alone | | I | Test fix | `sky_shader_validates`: add the `VERTEX_POSITIONS` shader def Bevy sets for real meshes | [10](10-sky-shader-test.md) | `b7865d0` | — | Not opened; tiny, can go upstream alone |
| J | Feature (audio) | Game audio: setup group `audio` extracts the disc's audio (vgmstream r2117) and the native data (AEMS banks, MixMap, grains, Splice banks, vault tuning); a native port of the retail audio runtime (`crates/skate-audio`, `crates/skate-audio-fma`) plays all world and player audio; volume settings and `--mute` | [11](11-audio.md) | `gameplay/audio` (latest pushed `870940e`); upstream PR branch `audio/retail-audio` mirrors it exactly (`843cca9`). History: batch commit `f438017` "New audio who dis" (merged as `ca7b4f3`, 2026-10-02), then follow-up commits | H (branched from `gameplay/water`; uses its water detection — `collision_feedback.flags.material_12`, `is_water_tag`). Overlaps upstream #4/#1 (retail-exact player audio via transcribed game code + memory image; not adopted — see doc 11): both edit `tools/asset_pipeline/asset_exports.py` and `install.py`, different asset layouts | Draft upstream [#32](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/32), **Work in Progress**. State 2026-10-03: native runtime is the only path (interim cue tables removed); every retail player component ported; world emitters, location sets and zone ambience from retail data; water splash from retail's mechanism; console-cadence posters, |Δv·n|, the NPC grain bed and the bail speed curve (bridge graph on the body impacts; uncommitted). User: "basically retail, if there is a difference i couldn't tell". Open: the turn / brake slews' console cadence (user's call; the recomp's fast release is its frame rate), world / NPC stages waiting on ped / vehicle systems. Doc 11 has the full history; #32's description is kept current (standing rule). Commits only when the user asks. | | J | Feature (audio) | Game audio: setup group `audio` extracts the disc's audio (vgmstream r2117) and the native data (AEMS banks, MixMap, grains, Splice banks, vault tuning); a native port of the retail audio runtime (`crates/skate-audio`, `crates/skate-audio-fma`) plays all world and player audio; volume settings and `--mute` | [11](11-audio.md), [15](15-world-audio.md) | `gameplay/audio` (latest pushed `870940e`); upstream PR branch `audio/retail-audio` mirrors it exactly (`843cca9`). History: batch commit `f438017` "New audio who dis" (merged as `ca7b4f3`, 2026-10-02), then follow-up commits | H (branched from `gameplay/water`, #30, uses its water detection) and **#35** (50 solver iterations; needed for retail-like pops — #32 carries the same commit until #35 merges). Overlaps upstream #4/#1 (not adopted; doc 11) | Draft upstream [#32](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/32), **Work in Progress**. State 2026-10-03: native runtime is the only path; every retail player component ported; user: "basically retail". Road to ready (`.claude/todo/pr32-ready.md`): code-review fixes (host clock = physics steps, stray files, data tests, fallback) → world / NPC audio built out in #32 with an engine- and mod-facing hook-in surface (P0–P2 done 2026-10-03, uncommitted: map-change fix, `world_audio` components + `sdk.world_audio`, dev mod `mods/world-audio-test`; doc 15; P3–P5 next) → publish the specs, repoint `.claude/` references → moddability pass → second optimisation pass → move the non-audio tools to L → full regression check. #32's description is kept current (standing rule). Commits only when the user asks. |
| G | Performance (setup) | Skip decoding duplicate stream copies; run the customiser beside the maps; parallel clothing library and pro roster, plus a native RefPack DLL in `Build.ps1` | [08](08-setup-performance.md) | `8327e94` (+ overlap hunks of `install.py` in `18f41a3`) | — | Draft upstream [#29](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/29) | | G | Performance (setup) | Skip decoding duplicate stream copies; run the customiser beside the maps; parallel clothing library and pro roster, plus a native RefPack DLL in `Build.ps1` | [08](08-setup-performance.md) | `8327e94` (+ overlap hunks of `install.py` in `18f41a3`) | — | Draft upstream [#29](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/29) |
| K | Gameplay fix (physics) | Board/skater solver: 50 constraint iterations (retail live value) instead of `RWMaxIterations` = 25 | [12](12-solver-iterations.md) | uncommitted (on `gameplay/audio` working tree) | — (one-file change in `physics/settings.rs`) | Ready apart from the asset-gated `customiser_equipment_reaches_ground_force_and_torque` question (doc 12) | | K | Gameplay fix (physics) | Board/skater solver: 50 constraint iterations (retail live value) instead of `RWMaxIterations` = 25 | [12](12-solver-iterations.md) | `physics/solver-iterations` (`3f6e989`, cherry-picked from `gameplay/audio`'s `7a9e6dc` onto `main` `7ae67f2`) | — (one-file change in `physics/settings.rs`) | Open upstream: [#35](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/35) (2026-10-03). #32 depends on it for retail-like pops and carries the same commit until #35 merges. Open question in the PR: the asset-gated `customiser_equipment_reaches_ground_force_and_torque` test (doc 12). Tests: 309 pass; the 2 failures (`pipelines_accept_…`, `sky_shader_validates`) also fail on `main`. |
| L | Tooling | Development and research tools in `tools/<name>/` (regression checks, setup equivalence, collision / world-stream / audio-file / vault readers, audio e2e and bench; recomp trace and code search, reference only) | [14](14-published-tools.md) | uncommitted (on `gameplay/audio` working tree) | `audio-e2e` / `audio-bench` / `audio-file-inspect` need J's native audio and setup modules; `setup-equivalence/compare_refpack.py` needs G's RefPack DLL; `regression-checks/check_maps.py` uses F's `--validate-maps` (falls back to `--check-assets` without it) | Not opened; the generic folders could go first, the audio ones with or after J | | L | Tooling | Development and research tools in `tools/<name>/` (regression checks, setup equivalence, collision / world-stream / audio-file / vault readers, audio e2e and bench; recomp trace and code search, reference only) | [14](14-published-tools.md) | uncommitted (on `gameplay/audio` working tree) | `audio-e2e` / `audio-bench` / `audio-file-inspect` need J's native audio and setup modules; `setup-equivalence/compare_refpack.py` needs G's RefPack DLL; `regression-checks/check_maps.py` uses F's `--validate-maps` (falls back to `--check-assets` without it) | Not opened; the generic folders could go first, the audio ones with or after J |
Opened 2026-10-01 as drafts from per-PR branches cut from upstream `60efdef` Opened 2026-10-01 as drafts from per-PR branches cut from upstream `60efdef`
+1
View File
@@ -20,6 +20,7 @@ Each document stands alone. File paths are relative to the repository root.
| 12 | [Board solver: 50 constraint iterations](12-solver-iterations.md) | Game physics (Rust) | Done; maps identical, water traces as expected; one asset-gated customiser test fails (open question) | | 12 | [Board solver: 50 constraint iterations](12-solver-iterations.md) | Game physics (Rust) | Done; maps identical, water traces as expected; one asset-gated customiser test fails (open question) |
| 13 | [Research hooks for the recompilation](13-recomp-research-hooks.md) | Research tooling (external: skate3recomp `research-hooks` branch) | Published; reference and information gathering only (the recompilation is not a perfect copy of the console game) | | 13 | [Research hooks for the recompilation](13-recomp-research-hooks.md) | Research tooling (external: skate3recomp `research-hooks` branch) | Published; reference and information gathering only (the recompilation is not a perfect copy of the console game) |
| 14 | [Published development and research tools](14-published-tools.md) | Tooling (`tools/<name>/`, Python and shell) | Done (uncommitted); scripts compile and print their usage; the two `recomp-*` folders are for the recomp's research hooks, reference only | | 14 | [Published development and research tools](14-published-tools.md) | Tooling (`tools/<name>/`, Python and shell) | Done (uncommitted); scripts compile and print their usage; the two `recomp-*` folders are for the recomp's research hooks, reference only |
| 15 | [Hooking up world audio](15-world-audio.md) | Game audio (Rust), mod API (Lua) | Part of #32 (uncommitted). Engine-facing components (`TrafficAudio`, `PedAudio`, `NpcSkaterAudio`) and `sdk.world_audio` for mods feed the ported retail traffic / ped / NPC-skater audio with retail's limits; the map-change bug fixed; a dev test mod (cars, peds, a ghost skater) makes it audible now |
## Environment used for verification ## Environment used for verification
+263
View File
@@ -0,0 +1,263 @@
-- World Audio Test (dev only, PR #32; spec .claude/notes/world-audio-hookin-spec.md §3.10 / §8.5).
-- Publishes traffic, pedestrians and a ghost NPC skater through sdk.world_audio around the spot
-- where the mod starts (F9 re-centres on the skater). The game decides who is audible with
-- retail's limits (4 nearest cars within 40 m, 15 nearest peds within 50 m with footsteps for
-- the nearest 3, one NPC skater within 30 m); the boxes show it: green = audible, grey = not.
-- Not retail behaviour: the motion, the gait clock, the honk timing and the reactions are this
-- script's; only the sounds and their rules are the game's port of retail.
local ENGINES = { "c00_heavy01", "c01_family01", "c03_sports01", "c04_taxi01", "c05_truck01",
"c06_sports02", "c07_family02", "c08_family03", "c01_family01" }
local LANES = 4
local CARS_PER_LANE = 4 -- the last lane's last car is parked (the alarm car)
local PEDS = 20
local anchor = nil -- {x, y, z}
local started = false
local cars, peds = {}, {}
local held = {}
local honk_timer = 12
local seed = 12345
local shown = {} -- key -> last box colour state
local ghost_status = ""
local last_landing, last_bail = nil, nil
local warned = {} -- ped key -> seconds until it may warn again
local function rand()
seed = (seed * 1103515245 + 12345) % 2147483648
return seed / 2147483648
end
local function setting(name, default)
local v = sdk.settings and sdk.settings[name]
if v == nil then return default end
return v
end
local function dist(a, b)
local dx, dy, dz = a[1] - b[1], a[2] - b[2], a[3] - b[3]
return math.sqrt(dx * dx + dy * dy + dz * dz)
end
-- A lane is a rectangle around the anchor; s = metres along its perimeter.
local function lane_size(l) return 26 + 7 * l, 16 + 6 * l end
local function lane_point(l, s)
local hx, hz = lane_size(l)
local w, h = 2 * hx, 2 * hz
local p = 2 * (w + h)
s = s % p
local x, z, heading
if s < w then x, z, heading = -hx + s, -hz, math.pi / 2
elseif s < w + h then x, z, heading = hx, -hz + (s - w), 0
elseif s < 2 * w + h then x, z, heading = hx - (s - w - h), hz, -math.pi / 2
else x, z, heading = -hx, hz - (s - 2 * w - h), math.pi end
if l % 2 == 1 then heading = heading end
return { anchor[1] + x, anchor[2] + 0.5, anchor[3] + z }, heading, p
end
local function box(key, position, heading, scale, audible)
if not setting("boxes", true) then return end
local state = audible and 1 or 0
local half = heading / 2
local rotation = { 0, math.sin(half), 0, math.cos(half) }
if shown[key] ~= state then
shown[key] = state
sdk.graphics.mesh("box_" .. key, { path = "", position = position, rotation = rotation, scale = scale,
color = audible and { 0.2, 0.9, 0.3 } or { 0.5, 0.5, 0.5 }, opacity = 0.6 })
else
sdk.graphics.set_transform("box_" .. key, { position = position, rotation = rotation })
end
end
local function spawn_all()
local player = sdk.player.read()
anchor = { player.position[1], player.position[2], player.position[3] }
started = true
cars, peds, shown, warned = {}, {}, {}, {}
if setting("cars", true) then
local n = 0
for l = 0, LANES - 1 do
local _, _, perimeter = lane_point(l, 0)
for c = 0, CARS_PER_LANE - 1 do
n = n + 1
local key = "car" .. n
local parked = (l == LANES - 1 and c == CARS_PER_LANE - 1)
local car = { key = key, lane = l, s = perimeter * c / CARS_PER_LANE, speed = 0,
cruise = (8 + 7 * rand()) * setting("speed_scale", 1), phase = "accelerate", timer = 0,
engine = ENGINES[(n - 1) % #ENGINES + 1], parked = parked, perimeter = perimeter }
local p, h = lane_point(l, car.s)
if parked then
p = { anchor[1] + 8, anchor[2] + 0.5, anchor[3] + 6 }
h = 0
car.engine = "c04_taxi01"
end
car.position, car.heading = p, h
sdk.world_audio.spawn(key, "traffic", { engine = car.engine, position = p, heading = h, speed = 0 })
cars[#cars + 1] = car
end
end
end
if setting("peds", true) then
for i = 1, PEDS do
local kind = (i == PEDS) and "run" or ((i >= PEDS - 3) and "jog" or "walk")
local speed = ({ walk = 1.3, jog = 4.0, run = 8.0 })[kind]
local angle = i * 2.399
local radius = 4 + (i % 7) * 3
local start = { anchor[1] + math.cos(angle) * radius, anchor[2], anchor[3] + math.sin(angle) * radius }
local key = "ped" .. i
local ped = { key = key, start = start, dir = { math.cos(angle + 1.3), 0, math.sin(angle + 1.3) },
length = 6 + (i % 4) * 3, t = rand() * 10, speed = speed,
step = ({ walk = 0.55, jog = 0.36, run = 0.27 })[kind], voice = 41 + (i * 7) % 56,
shoe = 2 + i % 4, x = 0, sign = 1 }
sdk.world_audio.spawn(key, "ped", { voice = ped.voice, shoe_class = ped.shoe, position = start })
peds[#peds + 1] = ped
end
end
if setting("ghost", true) then
local name = tostring(setting("ghost_log", "state_20261003_143434"))
-- Through a request: a missing log is reported here instead of failing the mod.
sdk.commands.request("ghost", { kind = "world_audio_spawn", key = "ghost", object = "skater",
options = { source = "state_log:" .. name, from = setting("ghost_from", 30), seconds = 20,
position = { anchor[1] + 5, anchor[2], anchor[3] } } })
ghost_status = "Ghost: loading " .. name
end
end
local function remove_all()
for _, c in ipairs(cars) do sdk.world_audio.remove(c.key); sdk.graphics.remove("box_" .. c.key) end
for _, p in ipairs(peds) do sdk.world_audio.remove(p.key); sdk.graphics.remove("box_" .. p.key) end
sdk.world_audio.remove("ghost")
sdk.graphics.remove("box_ghost")
cars, peds, shown = {}, {}, {}
end
local function drive(car, dt)
if car.parked then
sdk.world_audio.update(car.key, { position = car.position, heading = car.heading, speed = 0, load = 0, skidding = false })
return
end
-- Accelerate to cruise, cruise 3-8 s, brake hard to 3 m/s (skids below -8 m/s²), repeat.
local load = 0
car.timer = car.timer - dt
if car.phase == "accelerate" then
load = 2.5
if car.speed >= car.cruise then car.phase, car.timer = "cruise", 3 + 5 * rand() end
elseif car.phase == "cruise" then
if car.timer <= 0 then car.phase = "brake"; car.brake = (rand() < 0.4) and -12 or -4 end
else
load = car.brake
if car.speed <= 3 then car.phase = "accelerate" end
end
car.speed = math.max(0, math.min(car.cruise, car.speed + load * dt))
car.s = car.s + car.speed * dt
local p, h = lane_point(car.lane, car.s)
local v = { math.sin(h) * car.speed, 0, math.cos(h) * car.speed }
car.position, car.heading = p, h
sdk.world_audio.update(car.key, { position = p, heading = h, velocity = v, speed = car.speed, load = load,
skidding = load < -8 and car.speed > 4 })
end
local function walk(ped, dt, player)
ped.t = ped.t + dt
ped.x = ped.x + ped.sign * ped.speed * dt
if ped.x > ped.length then ped.x, ped.sign = ped.length, -1 elseif ped.x < 0 then ped.x, ped.sign = 0, 1 end
local p = { ped.start[1] + ped.dir[1] * ped.x, ped.start[2], ped.start[3] + ped.dir[3] * ped.x }
local v = { ped.dir[1] * ped.speed * ped.sign, 0, ped.dir[3] * ped.speed * ped.sign }
-- Gait clock (dev only, not retail): alternate feet, each planted for 60 % of its step.
local phase = (ped.t % (2 * ped.step)) / ped.step
local feet = { phase < 0.6, phase >= 1 and phase < 1.6 }
ped.position = p
sdk.world_audio.update(ped.key, { position = p, velocity = v, heading = math.atan(v[1], v[3]), feet = feet })
-- Warn (11) when the skater passes within 1.5 m above 3 m/s.
warned[ped.key] = math.max(0, (warned[ped.key] or 0) - dt)
if warned[ped.key] == 0 and (player.speed or 0) > 3 and dist(p, player.position) < 1.5 then
sdk.world_audio.event(ped.key, "speech", { value = "warn" })
warned[ped.key] = 5
end
end
local function react(player)
-- A landed trick within 10 m: cheer (23); a bail: slam (25).
local landing, bail = player.landing_seq or 0, player.bail_seq or 0
local event = nil
if last_landing ~= nil and landing ~= last_landing then event = "cheer" end
if last_bail ~= nil and bail ~= last_bail then event = "slam" end
last_landing, last_bail = landing, bail
if not event then return end
for _, ped in ipairs(peds) do
if ped.position and dist(ped.position, player.position) < 10 then
sdk.world_audio.event(ped.key, "speech", { value = event })
end
end
end
local function pressed(key)
local down = sdk.input.down(key) == true
local edge = down and not held[key]
held[key] = down
return edge
end
return {
on_update = function(event)
local dt = (event and tonumber(event.dt)) or 0
if not started then
if not (sdk.capabilities.world_audio and sdk.capabilities.world_audio >= 1) then
sdk.ui.text("world-audio-test", "World audio test: this game has no sdk.world_audio")
started = true
return
end
spawn_all()
return
end
if not anchor then return end
local player = sdk.player.read()
if pressed("F9") then remove_all(); spawn_all(); return end
if pressed("F8") then
for _, c in ipairs(cars) do if c.parked then sdk.world_audio.event(c.key, "alarm") end end
end
for _, car in ipairs(cars) do drive(car, dt) end
for _, ped in ipairs(peds) do walk(ped, dt, player) end
react(player)
-- One moving car honks every 10-15 s (kind 1-5).
honk_timer = honk_timer - dt
if honk_timer <= 0 and #cars > 1 then
honk_timer = 10 + 5 * rand()
local car = cars[1 + math.floor(rand() * (#cars - 1))]
sdk.world_audio.event(car.key, "horn", { kind = 1 + math.floor(rand() * 5), seconds = 0.4 + rand() })
end
-- Boxes and the readout.
local audible = { traffic = 0, ped = 0, skater = 0 }
for _, c in ipairs(cars) do
local r = sdk.world_audio.read(c.key)
local on = r and r.audible
if on then audible.traffic = audible.traffic + 1 end
box(c.key, c.position, c.heading, { 1.8, 1.4, 4.2 }, on)
end
for _, p in ipairs(peds) do
local r = sdk.world_audio.read(p.key)
local on = r and r.audible
if on then audible.ped = audible.ped + 1 end
if p.position then box(p.key, { p.position[1], p.position[2] + 0.9, p.position[3] }, 0, { 0.5, 1.8, 0.5 }, on) end
end
local result = sdk.commands.result("ghost")
if result then
if result.ok then
local r = sdk.world_audio.read("ghost")
if r and r.audible then audible.skater = 1 end
ghost_status = "Ghost: " .. ((r and r.audible) and "audible" or "out of range (30 m)")
else
ghost_status = "Ghost: " .. tostring(result.error)
end
end
local info = sdk.world_audio.info()
sdk.ui.text("world-audio-test", string.format(
"World audio test: cars %d/%d audible, peds %d/%d, skater %d (limits %d/%d/%d%s) %s [F8 alarm, F9 re-centre]",
audible.traffic, #cars, audible.ped, #peds, audible.skater,
info.instances and info.instances.traffic or 4, info.instances and info.instances.peds or 15,
info.instances and info.instances.skaters or 1, info.more_audible and ", more audible" or "", ghost_status))
end,
on_unload = function()
sdk.ui.text("world-audio-test", "")
end,
}
+59
View File
@@ -0,0 +1,59 @@
{
"id": "dev-world-audio-test",
"api": 2,
"name": "World Audio Test (dev)",
"version": "0.1.0",
"author": "Hails",
"description": "Dev only (PR #32): publishes traffic, pedestrians and a ghost NPC skater around the spot where it is enabled, through sdk.world_audio, so the retail world audio can be heard before the engine has a living world. Cars loop on four lanes (C00-C08 engine records) with honks and skids; F8 fires the parked car's alarm. Peds walk, jog and run, and react to you (warn, cheer, slam). The ghost replays one of your recorded audio state logs (logs/<name>.tsv in this folder, or SKATE_AUDIO_STATE_LOGS). F9 re-centres everything on you. Boxes show who is audible (green) or not (grey).",
"entry": "main.lua",
"settings": {
"cars": {
"type": "boolean",
"label": "Cars",
"description": "Sixteen cars on four lanes (one parked, for the alarm).",
"default": true
},
"peds": {
"type": "boolean",
"label": "Pedestrians",
"description": "Twenty pedestrians walking, jogging and running on lines.",
"default": true
},
"ghost": {
"type": "boolean",
"label": "Ghost skater",
"description": "A ghost NPC skater replaying a recorded audio state log.",
"default": true
},
"ghost_log": {
"type": "string",
"label": "Ghost log",
"description": "The state log's name without .tsv (logs/<name>.tsv here, or in SKATE_AUDIO_STATE_LOGS).",
"default": "state_20261003_143434"
},
"ghost_from": {
"type": "number",
"label": "Ghost window start (s)",
"description": "Where in the log the ghost's 20 s loop starts.",
"default": 30,
"min": 0,
"max": 3600,
"step": 5
},
"boxes": {
"type": "boolean",
"label": "Boxes",
"description": "Debug boxes: green = holds an audio instance (audible), grey = not.",
"default": true
},
"speed_scale": {
"type": "number",
"label": "Traffic speed",
"description": "Scales the cars' 8-15 m/s cruising speeds.",
"default": 1,
"min": 0.25,
"max": 2,
"step": 0.25
}
}
}
+53
View File
@@ -562,3 +562,56 @@ function sdk.graphs.read(graph) end
---@param index integer runtime ID from the catalog; zero-based ---@param index integer runtime ID from the catalog; zero-based
---@param enabled? boolean nil restores the original gate ---@param enabled? boolean nil restores the original gate
function sdk.graphs.set_enabled(graph, target, index, enabled) end function sdk.graphs.set_enabled(graph, target, index, enabled) end
-- World audio extension 1 (capability `world_audio`): publish traffic vehicles, pedestrians and
-- skaters to the game's retail world audio, exactly as an engine system would (doc
-- docs/hails-additions/15-world-audio.md). Keys belong to this mod; 16 objects per mod, 64 in all;
-- an object not updated for 0.5 s is parked; everything is removed on disable / reload. The game
-- decides who is audible with retail's limits (4 nearest cars within 40 m, 15 nearest peds within
-- 50 m, footsteps for the nearest 3, one skater within 30 m; 8 / 24 / 3 with the opt-in
-- non-retail "more audible" setting).
---@class WorldAudioOptions
---@field position? Vec3 world position (ignored while body is set)
---@field velocity? Vec3 m/s; default: from the position change (give it for teleporting objects)
---@field heading? number rad about +Y, 0 = +Z
---@field body? string follow one of this mod's physics bodies (position, rotation, velocity)
---@field engine? string traffic: aud_traffic_engine record (c01_family01, c03_sports01, c04_taxi01, c05_truck01, c00_heavy01, c06_sports02, c07_family02, c08_family03); unknown = silent
---@field speed? number traffic / lite skater: m/s (default |velocity|)
---@field load? number traffic: the driver's signed acceleration m/s² (default: from the speed change; hard stop ≈ -15)
---@field horn? integer traffic: 0 none, 1..5 horn kind, 6 alarm (prefer the events)
---@field skidding? boolean traffic: the tyres skid
---@field voice? integer ped: speech voice id 41..96 (0 none)
---@field shoe_class? integer ped: 1..5 (default 2; 1 is silent)
---@field weight? integer ped: 1..5 (default 1)
---@field close_range? boolean ped: a security guard's close-range footstep levels
---@field feet? boolean[] ped: {foot A planted, foot B planted}
---@field materials? integer[] ped: audio surface materials under the feet (default 0)
---@field footsteps? boolean ped: footsteps on (default: retail's 3-nearest rule)
---@field source? string skater (spawn only): 'lite' (default) or 'state_log:<name>' (a ghost replaying logs/<name>.tsv of this mod or SKATE_AUDIO_STATE_LOGS/<name>.tsv)
---@field from? number ghost: window start (s)
---@field seconds? number ghost: window length (s, default 20), looped
---@field wheels? boolean[] lite skater: wheels down {FL, FR, RL, RR}
---@field material? integer lite skater: material under the board (default: the ground's)
---@field grinding? boolean lite skater
---@field grind_material? integer lite skater
---@field air? boolean lite skater: in the air
sdk.world_audio = {}
---@param key string
---@param kind 'traffic'|'ped'|'skater'
---@param opts? WorldAudioOptions
function sdk.world_audio.spawn(key, kind, opts) end
---Merge fields into the object's description (a field of another kind is an error).
---@param key string
---@param opts WorldAudioOptions
function sdk.world_audio.update(key, opts) end
---@param key string
---@param event 'horn'|'alarm'|'speech'
---@param opts? {kind?:integer, seconds?:number, value?:string|integer} horn: kind 1..5 for seconds; alarm: retail's 8 s; speech: value name (warn, cheer, slam, flee, nearby, DoWarning, LongCheer, ...) or number
function sdk.world_audio.event(key, event, opts) end
---@param key string
function sdk.world_audio.remove(key) end
---@param key string
---@return {kind:string, audible:boolean, instance?:integer, parked:boolean}|nil
function sdk.world_audio.read(key) end
---@return {more_audible:boolean, instances:{traffic:integer,peds:integer,skaters:integer}, published:table, audible:table}
function sdk.world_audio.info() end
-96
View File
@@ -1,96 +0,0 @@
"""Write a local audition page for the decoded game audio (dev tool).
py -3.13 tools/audio_audition.py [--assets assets] [--out .local/audio-audition/index.html]
The page lists every cue in crates/skate-game/src/game_audio/cues.rs with its
current samples, then each bank in full, the rolling grains and ambience beds.
Players start at 25% volume. It links the WAVs in the local install; nothing
is copied, and the page belongs in the gitignored .local folder.
"""
import argparse
import html
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CUES = ROOT/'crates/skate-game/src/game_audio/cues.rs'
def cue_table():
"""(name, bank, [indices], comment) for each `Cue` constant in cues.rs."""
text = CUES.read_text(encoding='utf-8')
pattern = re.compile(r'((?:///[^\n]*\n)*)pub\(super\) const (\w+): Cue = Cue \{ bank: "(\w+)", samples: ([^,]+(?:, \d+)*\]?), level')
out = []
for doc, name, bank, samples in pattern.findall(text):
found = re.match(r'range!\((\d+)\.\.=(\d+)\)', samples)
indices = list(range(int(found[1]), int(found[2]) + 1)) if found else [int(n) for n in re.findall(r'\d+', samples)]
out.append((name, bank, indices, ' '.join(line.strip('/ ') for line in doc.splitlines())))
return out
def groups(entries):
"""(first, last) runs of consecutive clips with similar length (within 25%)."""
runs, start = [], 0
for i in range(1, len(entries) + 1):
if i == len(entries) or abs(entries[i]['seconds'] - entries[i - 1]['seconds']) > 0.25 * max(entries[i - 1]['seconds'], 0.05):
runs.append((start, i - 1))
start = i
return runs
def player(path: Path, label: str, note: str = '') -> str:
return (f'<div class="s"><span class="l">{html.escape(label)}</span>'
f'<audio controls preload="none" src="{path.as_uri()}"></audio>'
f'<span class="n">{html.escape(note)}</span></div>')
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--assets', type=Path, default=ROOT/'assets')
parser.add_argument('--out', type=Path, default=ROOT/'.local/audio-audition/index.html')
args = parser.parse_args()
audio = (args.assets/'private/audio').resolve()
manifest = json.loads((audio/'audio_manifest.json').read_text(encoding='utf-8'))
parts = ['<h1>Skate 3 audio audition</h1><p>Players start at 25% volume. Note the bank and index of '
'the sounds that fit each cue.</p><h2>Current cue picks</h2>']
for name, bank, indices, doc in cue_table():
parts.append(f'<h3>{name} <small>{bank} {indices[0]}..{indices[-1]} — {html.escape(doc)}</small></h3>')
for i in indices:
entry = manifest['banks'][bank][i]
parts.append(player(audio/entry['file'], f'{bank} {i}', f"{entry['seconds']:.2f}s"))
parts.append('<h2>Rolling grains</h2><p>Each surface: speed bands from slow (0) to fast.</p>')
for name, grain in manifest['grains'].items():
for band, entry in enumerate(grain['bands']):
parts.append(player(audio/entry['file'], f'{name} band {band}', f"{entry['seconds']:.1f}s loop"))
parts.append('<h2>Ambience beds</h2>')
for name, entry in manifest['ambience'].items():
parts.append(player(audio/entry['file'], name, f"{entry['seconds']:.0f}s"))
parts.append('<h2>Bank groups</h2><p>Runs of similar clips (one player each; the range is the '
'whole run). Use these to find candidates quickly.</p>')
for bank, entries in manifest['banks'].items():
runs = groups(entries)
parts.append(f'<details><summary>{bank}: {len(runs)} groups</summary>')
for first, last in runs:
e = entries[first]
label = f'{bank} {first}' + (f'–{last}' if last > first else '')
parts.append(player(audio/e['file'], label, f"{e['seconds']:.2f}s, {last - first + 1} clip(s)"))
parts.append('</details>')
parts.append('<h2>Banks (every clip)</h2>')
for bank, entries in manifest['banks'].items():
parts.append(f'<details><summary>{bank} ({len(entries)})</summary>')
parts.extend(player(audio/e['file'], f'{bank} {i}', f"{e['seconds']:.2f}s") for i, e in enumerate(entries))
parts.append('</details>')
page = ('<!doctype html><meta charset="utf-8"><title>Audio audition</title><style>'
'body{font:14px system-ui;margin:24px;background:#111;color:#ddd}h3 small{color:#999;font-weight:normal}'
'.s{display:flex;gap:12px;align-items:center;margin:2px 0}.l{width:260px}.n{color:#999}'
'audio{height:28px}summary{cursor:pointer;margin:6px 0}</style>' + ''.join(parts) +
'<script>for(const a of document.querySelectorAll("audio")){a.volume=0.25;'
'a.addEventListener("play",()=>{for(const b of document.querySelectorAll("audio"))if(b!==a)b.pause()})}</script>')
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(page, encoding='utf-8')
print(args.out)
if __name__ == '__main__':
main()
-5
View File
@@ -1,5 +0,0 @@
[
{"args":{"value":"University.skate"},"cat":"skate","name":"map","ph":"M","pid":1,"tid":0,"ts":0},
{"args":{"value":"ff2faf99a19b220e"},"cat":"skate","name":"map_fingerprint","ph":"M","pid":1,"tid":0,"ts":0},
{"args":{"value":"easy"},"cat":"skate","name":"difficulty","ph":"M","pid":1,"tid":0,"ts":0}
]