mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
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:
62 files changed
+4359
-1367
No files matched your search
+11
-2
@@ -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/
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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]),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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| {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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()
|
|
||||||
@@ -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}
|
|
||||||
]
|
|
||||||
Reference in new issue
Block a user