From 418dcecd4135ddfef6ac6151a2da2f414ac878e4 Mon Sep 17 00:00:00 2001 From: Hails <26784904+Hailey-Ross@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:20:08 -0600 Subject: [PATCH] Audio: interim cue tables retired, native water splash, published tools - The interim cue tables and their opt-outs are gone; the native runtime is the only audio path. Old settings/audio.json files still load ("interim" / "native" ignored). Missing native data logs an error instead of falling back. - Water splash from retail's mechanism (SFXObj_OffBoard start / update): Skate_Collisions 1187 / 1197 once per water stay, 1198 when the board is in water. - The random interim riding bed is removed; riding collision and cloth sounds compared with the recomp (doc 11). Native output otherwise byte-identical (13 scenarios + 5 sessions). - Published tools under tools/ (one folder per tool with a README; the recomp ones labelled for use with the research hooks), doc 14 and PR plan bucket L. - Docs: doc 11 (retirement, bail density measured against the recomp, collision posts), doc 13 (usage guide). --- crates/skate-audio/src/player/footsteps.rs | 153 +++- crates/skate-audio/src/player/state.rs | 14 + crates/skate-game/src/game_audio/ambience.rs | 4 +- crates/skate-game/src/game_audio/cues.rs | 390 --------- crates/skate-game/src/game_audio/e2e.rs | 10 +- crates/skate-game/src/game_audio/emitters.rs | 4 +- crates/skate-game/src/game_audio/grain_bed.rs | 53 +- crates/skate-game/src/game_audio/library.rs | 75 +- crates/skate-game/src/game_audio/mod.rs | 56 +- crates/skate-game/src/game_audio/native.rs | 63 +- .../skate-game/src/game_audio/player_audio.rs | 67 +- .../skate-game/src/game_audio/skate_events.rs | 781 +----------------- crates/skate-game/src/game_audio/state_log.rs | 2 +- crates/skate-game/src/game_audio/voices.rs | 20 +- crates/skate-game/src/physics.rs | 14 +- docs/hails-additions/11-audio.md | 313 ++++++- .../13-recomp-research-hooks.md | 12 +- docs/hails-additions/14-published-tools.md | 82 ++ docs/hails-additions/PULL-REQUESTS.md | 1 + docs/hails-additions/README.md | 1 + tools/README.md | 24 + tools/audio-bench/README.md | 51 ++ tools/audio-bench/audio_timing_summary.py | 31 + tools/audio-bench/compare_runs.sh | 20 + tools/audio-bench/e2e_bench.sh | 35 + tools/audio-bench/emitter_bank_memory.py | 87 ++ tools/audio-bench/prefetch_memory_sim.py | 63 ++ tools/audio-bench/timing_summary.py | 44 + tools/audio-e2e/README.md | 56 ++ tools/audio-e2e/bank_rate.py | 32 + tools/audio-e2e/compare.py | 150 ++++ tools/audio-e2e/frame_levels.py | 45 + tools/audio-e2e/render_diff.py | 38 + tools/audio-e2e/scenarios.py | 136 +++ tools/audio-e2e/voices_summary.py | 22 + tools/audio-e2e/wet_level.py | 40 + tools/audio-file-inspect/README.md | 63 ++ tools/audio-file-inspect/aems_survey.py | 233 ++++++ tools/audio-file-inspect/bank_layout_check.py | 81 ++ .../audio-file-inspect/decode_bank_samples.py | 50 ++ tools/audio-file-inspect/ems_dump.py | 62 ++ tools/audio-file-inspect/grain_survey.py | 348 ++++++++ .../audio-file-inspect/multichannel_census.py | 59 ++ tools/audio-file-inspect/splc_fields.py | 81 ++ tools/collision-inspect/README.md | 48 ++ tools/collision-inspect/analyse_spawn.py | 47 ++ tools/collision-inspect/collision_attrs.py | 41 + tools/collision-inspect/map_collision.py | 56 ++ tools/collision-inspect/scan_surfaceless.py | 60 ++ tools/collision-inspect/unsigned_collision.py | 31 + tools/recomp-code-search/README.md | 55 ++ tools/recomp-code-search/callctx.sh | 13 + tools/recomp-code-search/fn.sh | 7 + tools/recomp-code-search/grepfn.sh | 8 + tools/recomp-code-search/ppcxref.py | 167 ++++ tools/recomp-trace/README.md | 65 ++ tools/recomp-trace/first_pass.py | 53 ++ tools/recomp-trace/grain_trace_stats.py | 69 ++ tools/recomp-trace/grec_clean.py | 65 ++ tools/recomp-trace/grec_level.py | 94 +++ tools/recomp-trace/grec_material.py | 57 ++ tools/recomp-trace/resolve_trace.py | 116 +++ tools/recomp-trace/retail_relay.py | 80 ++ tools/recomp-trace/retail_voices.py | 211 +++++ tools/recomp-trace/retail_windows.py | 157 ++++ tools/recomp-trace/send_vectors.py | 57 ++ tools/recomp-trace/trace.py | 120 +++ tools/recomp-trace/veh_trace.py | 91 ++ tools/regression-checks/README.md | 64 ++ tools/regression-checks/check_customiser.py | 82 ++ tools/regression-checks/check_maps.py | 92 +++ tools/regression-checks/check_spawns.py | 65 ++ tools/regression-checks/smoke_maps.sh | 27 + tools/setup-equivalence/README.md | 41 + tools/setup-equivalence/compare_refpack.py | 65 ++ tools/setup-equivalence/compare_streams.py | 47 ++ tools/vault-inspect/README.md | 42 + tools/vault-inspect/find_field.py | 35 + tools/vault-inspect/vault_fields.py | 65 ++ tools/vault-inspect/vault_layout.py | 55 ++ tools/world-stream-inspect/README.md | 42 + tools/world-stream-inspect/arenas.py | 89 ++ tools/world-stream-inspect/big_list.py | 37 + tools/world-stream-inspect/sim_types.py | 52 ++ tools/world-stream-inspect/volumes_dump.py | 86 ++ 85 files changed, 5372 insertions(+), 1348 deletions(-) delete mode 100644 crates/skate-game/src/game_audio/cues.rs create mode 100644 docs/hails-additions/14-published-tools.md create mode 100644 tools/README.md create mode 100644 tools/audio-bench/README.md create mode 100644 tools/audio-bench/audio_timing_summary.py create mode 100644 tools/audio-bench/compare_runs.sh create mode 100644 tools/audio-bench/e2e_bench.sh create mode 100644 tools/audio-bench/emitter_bank_memory.py create mode 100644 tools/audio-bench/prefetch_memory_sim.py create mode 100644 tools/audio-bench/timing_summary.py create mode 100644 tools/audio-e2e/README.md create mode 100644 tools/audio-e2e/bank_rate.py create mode 100644 tools/audio-e2e/compare.py create mode 100644 tools/audio-e2e/frame_levels.py create mode 100644 tools/audio-e2e/render_diff.py create mode 100644 tools/audio-e2e/scenarios.py create mode 100644 tools/audio-e2e/voices_summary.py create mode 100644 tools/audio-e2e/wet_level.py create mode 100644 tools/audio-file-inspect/README.md create mode 100644 tools/audio-file-inspect/aems_survey.py create mode 100644 tools/audio-file-inspect/bank_layout_check.py create mode 100644 tools/audio-file-inspect/decode_bank_samples.py create mode 100644 tools/audio-file-inspect/ems_dump.py create mode 100644 tools/audio-file-inspect/grain_survey.py create mode 100644 tools/audio-file-inspect/multichannel_census.py create mode 100644 tools/audio-file-inspect/splc_fields.py create mode 100644 tools/collision-inspect/README.md create mode 100644 tools/collision-inspect/analyse_spawn.py create mode 100644 tools/collision-inspect/collision_attrs.py create mode 100644 tools/collision-inspect/map_collision.py create mode 100644 tools/collision-inspect/scan_surfaceless.py create mode 100644 tools/collision-inspect/unsigned_collision.py create mode 100644 tools/recomp-code-search/README.md create mode 100644 tools/recomp-code-search/callctx.sh create mode 100644 tools/recomp-code-search/fn.sh create mode 100644 tools/recomp-code-search/grepfn.sh create mode 100644 tools/recomp-code-search/ppcxref.py create mode 100644 tools/recomp-trace/README.md create mode 100644 tools/recomp-trace/first_pass.py create mode 100644 tools/recomp-trace/grain_trace_stats.py create mode 100644 tools/recomp-trace/grec_clean.py create mode 100644 tools/recomp-trace/grec_level.py create mode 100644 tools/recomp-trace/grec_material.py create mode 100644 tools/recomp-trace/resolve_trace.py create mode 100644 tools/recomp-trace/retail_relay.py create mode 100644 tools/recomp-trace/retail_voices.py create mode 100644 tools/recomp-trace/retail_windows.py create mode 100644 tools/recomp-trace/send_vectors.py create mode 100644 tools/recomp-trace/trace.py create mode 100644 tools/recomp-trace/veh_trace.py create mode 100644 tools/regression-checks/README.md create mode 100644 tools/regression-checks/check_customiser.py create mode 100644 tools/regression-checks/check_maps.py create mode 100644 tools/regression-checks/check_spawns.py create mode 100644 tools/regression-checks/smoke_maps.sh create mode 100644 tools/setup-equivalence/README.md create mode 100644 tools/setup-equivalence/compare_refpack.py create mode 100644 tools/setup-equivalence/compare_streams.py create mode 100644 tools/vault-inspect/README.md create mode 100644 tools/vault-inspect/find_field.py create mode 100644 tools/vault-inspect/vault_fields.py create mode 100644 tools/vault-inspect/vault_layout.py create mode 100644 tools/world-stream-inspect/README.md create mode 100644 tools/world-stream-inspect/arenas.py create mode 100644 tools/world-stream-inspect/big_list.py create mode 100644 tools/world-stream-inspect/sim_types.py create mode 100644 tools/world-stream-inspect/volumes_dump.py diff --git a/crates/skate-audio/src/player/footsteps.rs b/crates/skate-audio/src/player/footsteps.rs index 5874a871..9045225c 100644 --- a/crates/skate-audio/src/player/footsteps.rs +++ b/crates/skate-audio/src/player/footsteps.rs @@ -8,7 +8,7 @@ //! foot-down copies (`+52` = state `+724`, `+236` = `+725`); on the footplant's falling edge //! (state `+768`) the countdown of the foot that was down (A first) is set to 10; both count down; //! then the poster, the walking voices (on foot), the jump voices, (remote players only: -//! `sub_824EBA08`, not modelled) and the water splash (`sub_824EBB58`, not ported here); +//! `sub_824EBA08`, not modelled) and the water splash ([`Splash`], `sub_824EBB58`); //! - poster `sub_824E9FD8`: two `playercharacter_footstep` packets (25 words, constructor //! `sub_824B73E0`), B (`+220`) then A (`+36`), posted once and held; for the local player, on a //! foot-down edge: the foot's four Splice sounds stop (`sub_82494B80`) and up to four start, each @@ -25,7 +25,7 @@ //! sound held; //! - update `sub_824E9628`: the packets (`sub_824EAEA8`) and the foot sounds' blocks //! (`sub_82494C08`), the walking (`sub_824EA9C8` level 3, `sub_824EAC38` level 12) and jump -//! voices (`sub_824E9AB8` levels 9 / 10), the splash (`sub_824EBE78`, not ported). +//! voices (`sub_824E9AB8` levels 9 / 10), the splash sounds ([`Splash::update`], `sub_824EBE78`). //! //! The FootStep SubMix (per foot sound slot, mono): `Sub0 → HI20 → LI20 → PI20 → Sen0 (env bus, //! level) → Pn21 (azimuth) → Sen0 (SFX Master)`; its parameters are latched at each start of the @@ -235,6 +235,12 @@ pub struct FootstepTuning { pub eq_other: Vec<(i32, SubmixEq)>, /// Materials 0..142 (setup export; empty: no material footstep layer). pub materials: Vec, + /// The water splash (`sub_824EBB58`): the AudioSurface-class (`923CCB46EF5BF5BA`) record + /// `water`'s Skate_Collisions ids — under the surface `35D3B06292CDA10B` (1187), or + /// `C17485220849574D` (1197) once the time in water reaches `9CD13431903E3719` (0.001 s); the + /// board in water `BA81E93AE985D1C7` (1198). + pub splash_ids: [u32; 3], + pub splash_time: f32, } const fn eq(gain: u32, q: u32, peak_gain: u32, peak_freq: u32, low_pass: u32, high_pass: u32) -> SubmixEq { @@ -315,6 +321,8 @@ impl Default for FootstepTuning { eq_collision: vec![(962, e962), (960, e960), (961, e961), (959, e959), (518, e518), (519, e519)], eq_other: vec![(459, m459), (460, m460), (455, m455), (456, m456), (457, m455), (458, m456), (323, m323), (324, m324)], materials: Vec::new(), + splash_ids: [1187, 1197, 1198], + splash_time: f32::from_bits(0x3A83_126F), } } } @@ -473,6 +481,8 @@ pub struct Footsteps { was_hippy: bool, jump_bucket: u32, from_feet: bool, + /// The water splash (`sub_824EBB58` / `sub_824EBE78`). + pub splash: Splash, /// Sounds started (diagnostics). pub starts: u64, } @@ -501,6 +511,7 @@ impl Default for Footsteps { was_hippy: false, jump_bucket: 0, from_feet: false, + splash: Splash::default(), starts: 0, } } @@ -557,6 +568,7 @@ impl Footsteps { self.was_footplant = s.footplant; self.was_down = s.foot_down; self.was_offboard_air = s.offboard_air; + self.splash.process(s, ft, host); cmds } @@ -791,10 +803,110 @@ impl Footsteps { self.apex = None; } } + self.splash.update(s, out, host); cmds } } +/// OffBoard's water splash (`sub_824EBB58`, at the end of the process, every frame for an active +/// record; `sub_824EBE78`, at the end of the update). Owner offsets in the field docs. +/// +/// - `+811` in water (state `+81`): on its rise the time in water `+480` starts at 0, then counts +/// `dt` per frame; on its fall both latches clear. +/// - `+812` under the surface, once per water stay (`+478`): unless `+224`, the entry sound `+484` +/// is stopped and Skate_Collisions [`FootstepTuning::splash_ids`] 0 (1187) starts — or 1 (1197) +/// when the time in water has reached [`FootstepTuning::splash_time`], i.e. from the frame after +/// the water contact on. (With `+224` the latch is still taken: no sound for this stay.) +/// - `+813` the board in water, once per rise (`+492`): `+488` is stopped and id 2 (1198) starts. +/// +/// Both through the collision Splice object (`sub_82497F48`: SFX Master, start block +/// [0, 1, 0, 0, 1, 1]); updated with [level(13) (`+484`) / level(16) (`+488`) / 32767, +/// pitch(14) / 4096, raw(0) × 360/65535, dt, 0, 1] and the env send level(15) / 32767 +/// (`sub_82498140`; as the grind on / off sounds, the env level of the last update is latched at +/// each start). A sound that ended is released. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Splash { + /// `+477` in water last frame, `+480` the time in water, `+478` the entry sound taken. + in_water: bool, + time: f32, + entered: bool, + /// `+492` the board was in water last frame. + board: bool, + /// `+484` the entry sound, `+488` the board's. + entry: Option, + board_sound: Option, + /// level(15) as of the last update. + env_level: i32, + /// Sounds started, and the id of the last (diagnostics). + pub starts: u64, + pub last_id: u32, +} + +impl Splash { + pub fn process(&mut self, s: &AudioState, ft: &FootstepTuning, host: &mut dyn SpliceHost) { + if s.in_water { + if self.in_water { + self.time += s.dt; + } else { + self.in_water = true; + self.time = 0.0; + } + } else if self.in_water { + self.in_water = false; + self.entered = false; + } + if s.under_water && !self.entered { + if !s.global_224 { + if let Some(old) = self.entry.take() { + host.release(old); + } + let id = if self.time >= ft.splash_time { ft.splash_ids[1] } else { ft.splash_ids[0] }; + self.entry = self.start(id, host); + } + self.entered = true; + } + if s.board_in_water { + if !self.board { + if let Some(old) = self.board_sound.take() { + host.release(old); + } + self.board_sound = self.start(ft.splash_ids[2], host); + } + self.board = true; + } else { + self.board = false; + } + } + + fn start(&mut self, id: u32, host: &mut dyn SpliceHost) -> Option { + host.set_route(crate::bus::Route { + output: crate::bus::Output::Master, + create: false, + owner_env: self.env_level as f32 * LEVEL, + mono: true, + }); + let sound = host.start("Skate_Collisions", id, [0.0, 1.0, 0.0, 0.0, 1.0, 1.0]); + if sound.is_some() { + self.starts += 1; + self.last_id = id; + } + sound + } + + pub fn update(&mut self, s: &AudioState, out: &dyn Outputs, host: &mut dyn SpliceHost) { + self.env_level = out.level(15); + for (sound, level) in [(&mut self.entry, 13usize), (&mut self.board_sound, 16)] { + let Some(id) = *sound else { continue }; + if host.alive(id) { + host.update(id, [out.level(level) as f32 * LEVEL, out.pitch(14) as f32 * PITCH, out.raw(0) as f32 * DEGREES, s.dt, 0.0, 1.0]); + } else { + host.release(id); + *sound = None; + } + } + } +} + /// The audio conditioner's step code (`sub_827729B8` → state `+740`): which foot planted higher /// than the other (by more than 7 cm) at their last plants, and whether the surface under foot /// B is physics class 8. Feed it every frame. @@ -979,6 +1091,43 @@ mod tests { AudioState { on_foot: true, com_velocity: [1.5, 0.0, 0.0], foot_down: foot, foot_material: [m; 2], ..Default::default() } } + /// `sub_824EBB58`: one entry sound per water stay (1187 on the water contact's first frame, + /// 1197 later; none with `+224`), the board's 1198 once per rise, updates at levels 13 / 16. + #[test] + fn the_splash_plays_once_per_water_stay_by_its_time_in_water() { + let ft = FootstepTuning::default(); + let mut sp = Splash::default(); + let mut h = Log::default(); + let st = |in_water: bool, under: bool, board: bool| AudioState { in_water, under_water: under, board_in_water: board, ..Default::default() }; + // Dry, then in water at once under the surface: 1187. + sp.process(&st(false, false, false), &ft, &mut h); + sp.process(&st(true, true, false), &ft, &mut h); + assert_eq!(h.started, [("Skate_Collisions".to_owned(), 1187)]); + // Staying under: no second sound; the update drives the gain at level(13). + sp.process(&st(true, true, false), &ft, &mut h); + sp.update(&st(true, true, false), &Out, &mut h); + assert_eq!(h.started.len(), 1); + assert_eq!(h.updates.last().unwrap().0, 1187); + assert!((h.updates.last().unwrap().1[0] - 10013.0 / 32767.0).abs() < 1e-6); + // Out, back in water on the surface for a frame, then under: 1197 (time in water > 0.001). + sp.process(&st(false, false, false), &ft, &mut h); + sp.process(&st(true, false, false), &ft, &mut h); + sp.process(&st(true, true, false), &ft, &mut h); + assert_eq!(h.started.last().unwrap().1, 1197); + // The board: once per rise. + sp.process(&st(true, true, true), &ft, &mut h); + sp.process(&st(true, true, true), &ft, &mut h); + assert_eq!(h.started.iter().filter(|s| s.1 == 1198).count(), 1); + sp.update(&st(true, true, true), &Out, &mut h); + assert!(h.updates.iter().any(|u| u.0 == 1198 && (u.1[0] - 10016.0 / 32767.0).abs() < 1e-6)); + // +224: the stay's latch is taken without a sound. + let n = h.started.len(); + sp.process(&st(false, false, false), &ft, &mut h); + sp.process(&AudioState { global_224: true, ..st(true, true, false) }, &ft, &mut h); + sp.process(&st(true, true, false), &ft, &mut h); + assert_eq!(h.started.len(), n); + } + #[test] fn the_curves_interpolate_like_retail() { let ft = FootstepTuning::default(); diff --git a/crates/skate-audio/src/player/state.rs b/crates/skate-audio/src/player/state.rs index 6c9db33d..bd298872 100644 --- a/crates/skate-audio/src/player/state.rs +++ b/crates/skate-audio/src/player/state.rs @@ -163,6 +163,17 @@ pub struct AudioState { /// `+688` / `+689`: hand limb 2 (part 3) / 3 (part 7) on the deck: Skeleton `+602` / `+603` /// ([`super::step_on::hands_on_deck`]). pub hands_on_deck: [bool; 2], + // ---- water (`player::footsteps::Splash`, OffBoard's `sub_824EBB58`; the bridge `sub_824B0DA8` + // copies the packed record's `+172` bits 30 / 29 / 28, written by the conditioner `sub_827A1B78`). + /// `+811` in water: the current state's `+81` (Wipeout300's special surface, i.e. its water + /// contact; the engine's `state_flags[81 − 52]`). + pub in_water: bool, + /// `+812` under the surface: `+811` and the state's surface height (`+32`) above the Y of any + /// of the PhysOut Skeleton points `+128` / `+112` / `+32` (part 15's and part 19's pose applied + /// to a per-part local point, and part 1's pose translation). + pub under_water: bool, + /// `+813` the board in water: Collision `+16` (the board's surface vote, `choose_surface`) = 12. + pub board_in_water: bool, } impl Default for AudioState { @@ -237,6 +248,9 @@ impl Default for AudioState { body_slide_flag: false, body_impact: [0.0; 6], hands_on_deck: [false; 2], + in_water: false, + under_water: false, + board_in_water: false, } } } diff --git a/crates/skate-game/src/game_audio/ambience.rs b/crates/skate-game/src/game_audio/ambience.rs index 6b4b9a40..b1944b50 100644 --- a/crates/skate-game/src/game_audio/ambience.rs +++ b/crates/skate-game/src/game_audio/ambience.rs @@ -18,7 +18,7 @@ const CROSSFADE: f32 = 2.0; /// The Ambience MixMap's bed level carries a −11 dB base before any ducking (mixmap-spec.md; out0). /// Verified against retail captures at four zones (dt_open, dt_main, dt_rez, indu_quarry): predicted /// with the base within 0.6 dB, without it 11 dB too loud (tools/check_bed_level.py). Our levels run at -/// `cues::RETAIL_SCALE` × retail, so the bed gets the same scale to keep retail's balance. +/// `voices::RETAIL_SCALE` × retail, so the bed gets the same scale to keep retail's balance. const BED_BASE: f32 = 0.281_838_3; // 10^(-11/20) /// Directional crossfade voices sit this far from the listener (Bevy attenuation stays 1). const PAN_DISTANCE: f32 = 8.0; @@ -185,7 +185,7 @@ pub(super) fn update( let phase = state.phase.unwrap_or(Phase::Silent); let zone = library.zone(state.current).cloned(); if let (Some((id, _)), Some(zone)) = (&state.bed, &zone) { - let gain = zone.volume * BED_BASE * super::cues::RETAIL_SCALE * (1.0 - attenuation(phase, state.t, zone.time_b, zone.time_a)); + let gain = zone.volume * BED_BASE * super::voices::RETAIL_SCALE * (1.0 - attenuation(phase, state.t, zone.time_b, zone.time_a)); voices.set(*id, gain, 1.0, None); } if let Ok(ear) = listener.single() { diff --git a/crates/skate-game/src/game_audio/cues.rs b/crates/skate-game/src/game_audio/cues.rs deleted file mode 100644 index 4b7c9c81..00000000 --- a/crates/skate-game/src/game_audio/cues.rs +++ /dev/null @@ -1,390 +0,0 @@ -//! Which decoded retail sounds play for which gameplay cue. Retail picks them -//! through compiled AEMS event scripts we do not run. Since 2026-10-01 the -//! impact/contact cues use the retail patches (SPLC records/containers) that a -//! local recompiled build was measured playing for the same moments, at levels -//! in retail's measured ratios (docs/hails-additions/11-audio.md, "Measured -//! retail mapping"). The rest are our own picks, made by ear. Bank names and -//! indices refer to assets/private/audio/audio_manifest.json. -//! -//! **Native runtime (`SKATE_AEMS=1`):** these tables are the interim path only. With the native -//! player components running, the retired ones never play (`skate_events::play` gates them): -//! - retired, user-confirmed (19:19 build): `POP`, `POP_TAIL`, `LAND`, `LAND_NORMAL`, -//! `LAND_HOLLOW`, `LAND_KIND_CHANCE`, `land_tier`, `land_scale`, `LAND_CLOTH`, `POWERSLIDE*`; -//! - retired, native replacement ready but not user-confirmed yet: `GRIND_START`, `GRIND_PIECES`, -//! `GRIND_METAL*`, `AIR_WHEELS`, `BOARD_DOWN_KNOCK`, `BOARD_DOWN_TOUCH`, `CATCH`, `FLIP`, -//! `FOOT_DRAG*`, and the `BED_CUES` / `BED_RECORDS` entries the native components play -//! (sense_of_speed, Seams_Bank, Rolling_Rattles, PatchBank_Rolling_Surfaces, the knocks -//! 1112 / 1115 / 1119 and the scuffs `sk8_foley` 94 / 95). -//! They stay for the default path (native runtime off) until that path is retired as a whole. -//! -//! Levels are linear and deliberately low: many raw samples peak at 0 dBFS -//! (retail mixes them down at runtime). Final gain = level x effects x master. -//! Retail-measured cues use level = measured retail level x RETAIL_SCALE, -//! calibrated so rolling (retail p90 0.065) lands on our tested ROLL_LEVEL. - -/// A set of interchangeable samples: one is picked at random per play. -pub(super) struct Cue { - pub bank: &'static str, - pub samples: &'static [usize], - pub level: f32, -} - -/// A retail patch to play as its data describes (layers, probabilities, gain -/// ranges): record `id` of an SPLC bank, or a container (id >= record count), -/// which picks one of its records each time. -pub(super) struct Record { - pub bank: &'static str, - pub id: usize, - pub level: f32, - /// Seconds after the event (retail spreads a pop's layers over ~60 ms). - pub delay: f32, - /// Retail's gain over time for this layer (see the *_ENV curves). - pub envelope: Option<&'static [(f32, f32)]>, -} -impl Record { - const fn after(self, delay: f32) -> Self { - Self { delay, ..self } - } - const fn shaped(self, envelope: &'static [(f32, f32)]) -> Self { - Self { envelope: Some(envelope), ..self } - } -} - -// Retail's per-layer gain over time (relative to the layer's peak), measured -// from the recomp's gain modules for ~2,300 pop/landing voices in a clean -// session (music and speech off): long samples are faded down within ~100 ms, -// which is what makes retail's impacts short and punchy instead of ringing. -/// Deck knocks and truck/wheel contacts: full for 50 ms, ~0.35 by 300 ms. -pub(super) const KNOCK_ENV: &[(f32, f32)] = - &[(0.0, 1.0), (0.05, 1.0), (0.1, 0.88), (0.15, 0.58), (0.2, 0.45), (0.3, 0.35), (0.5, 0.3)]; -/// Tail impacts and the hollow (deep) sets: held longer. -pub(super) const TAIL_ENV: &[(f32, f32)] = &[(0.0, 1.0), (0.1, 1.0), (0.15, 0.84), (0.2, 0.75), (0.3, 0.75), (0.5, 0.6)]; -/// The body set: a 50 ms swell, gone by 150 ms. -pub(super) const BODY_ENV: &[(f32, f32)] = &[(0.0, 0.4), (0.05, 1.0), (0.1, 0.2), (0.15, 0.04)]; -/// Wheels-down (~1 s samples): cut to a fifth within 100 ms. -pub(super) const WHEELS_ENV: &[(f32, f32)] = &[(0.0, 1.0), (0.02, 0.6), (0.05, 0.5), (0.1, 0.2), (0.15, 0.18)]; -/// The pop crack: a click, down to 14 % after 20 ms. -pub(super) const CRACK_ENV: &[(f32, f32)] = &[(0.0, 1.0), (0.02, 0.14), (0.1, 0.08)]; - -macro_rules! range { - ($a:literal..=$b:literal) => {{ - const N: usize = $b - $a + 1; - const R: [usize; N] = { - let mut out = [0; N]; - let mut i = 0; - while i < N { - out[i] = $a + i; - i += 1; - } - out - }; - &R - }}; -} - -/// Measured retail level -> our level (ROLL_LEVEL 0.15 / retail rolling p90 0.065, rounded down). -pub(super) const RETAIL_SCALE: f32 = 2.0; -const fn retail(bank: &'static str, id: usize, measured: f32) -> Record { - Record { bank, id, level: measured * RETAIL_SCALE, delay: 0.0, envelope: None } -} -const SC: &str = "Skate_Collisions"; - -// Measured sequence for an ollie/kickflip (scripted recomp run, times from the -// flick): +0.24 s pop = containers 1096 + 1098/1099 with the flip object; -// +0.3-0.6 s board knocks 1112/1115/1118 (feet catching the board); +0.95 s -// landing = 1095 + one of the 1052/1055/1058 impact families + wheel skid. - -// Pop and landing recipes from two recomp sessions (57 pops, 83 landings) and -// the real game sound captured with them (.local/recomp/compare_events.py): -// which retail sets play at each moment, and their median level there. -// (Earlier versions picked sets by their loudest moments, then by per-layer -// medians without the low thuds; both "did not sound close" to the user — -// the captured retail mix showed the missing low end.) - -/// Pop, all at once: deck knock (1112, 548-552, ~0.42, the click), body -/// (1111, 173-182, ~0.16), low thuds (876: 199-201 ~0.18, 878: 193-195 ~0.08, -/// 891: 936/937 ~0.16), the pop crack (1096: 259/260 + 623-626, ~0.10) and one -/// of three tail impacts (1097/1098/1099, ~0.16 each). -/// Delays are retail's typical offsets from the knock (traced pops): ground -/// thud 193-195 at once, pop crack and tail impact ~+20 ms, body ~+30 ms, -/// low ground thud 199-201 ~+40 ms. -pub(super) const POP: &[Record] = &[ - retail(SC, 1112, 0.42).shaped(KNOCK_ENV), retail(SC, 878, 0.08), retail(SC, 891, 0.16), - retail(SC, 1096, 0.10).after(0.02).shaped(CRACK_ENV), retail(SC, 1111, 0.16).after(0.03).shaped(BODY_ENV), - retail(SC, 876, 0.18).after(0.04), -]; -pub(super) const POP_TAIL: &[Record] = - &[ - retail(SC, 1097, 0.16).after(0.02).shaped(TAIL_ENV), - retail(SC, 1098, 0.16).after(0.02).shaped(TAIL_ENV), - retail(SC, 1099, 0.16).after(0.02).shaped(TAIL_ENV), - ]; -/// Feet catching the board (1115: 548-552 + 530-535, ~0.43): retail plays it -/// ~0.2-0.3 s after the pop (scripted ollie: pop +0.24 s, knocks +0.42/+0.52 s). -pub(super) const CATCH: Record = retail(SC, 1115, 0.40).after(0.22).shaped(KNOCK_ENV); -/// Flip/spin whoosh when a trick registers: retail's Class_Flips plays -/// Sk8_Air_Flip_Tricks 1-3 (measured ~0.15-0.3). -pub(super) const FLIP: Cue = Cue { bank: "Sk8_Air_Flip_Tricks", samples: range!(1..=3), level: 0.45 }; -/// Landing: the wheels-down set (1095, 396-404, ~0.09) always, plus retail's -/// board contact sets, ported from the game's own rule (recomp traces -/// 2026-10-01, 340 picks; read as reference only): -/// - a table per tier: tier 2 when the surface under the board is hollow -/// (observed: Wood_1 park ramps; the game reads a per-material flag, we take -/// the wood materials), tier 0 otherwise (tier 1 = during a bail); -/// - per contact kind (which wheels/part touched) three variants (light / -/// medium / hard). At a landing retail fires ~1.5 kinds: kind 0 44 %, -/// kind 1 47 %, kind 2 16 %, kind 3 46 % of landings; -/// - variant: retail uses the heaviest per-wheel impact class (not decoded); -/// ours follows the impact speed (`land_tier`). -/// Levels: the set's median level at landings in a clean session (music and -/// speech off, 118 landings; 1052 carries 22 % of the energy) x RETAIL_SCALE. -pub(super) const LAND: Record = retail(SC, 1095, 0.17).shaped(WHEELS_ENV); -/// [kind][variant] for tier 0 (normal) and tier 2 (hollow wood). -pub(super) const LAND_NORMAL: [[Record; 3]; 4] = [ - [retail(SC, 1051, 0.32).shaped(KNOCK_ENV), retail(SC, 1052, 0.48).shaped(KNOCK_ENV), retail(SC, 1053, 0.35).shaped(KNOCK_ENV)], - [retail(SC, 1054, 0.18).shaped(KNOCK_ENV), retail(SC, 1055, 0.41).shaped(KNOCK_ENV), retail(SC, 1056, 0.31).shaped(KNOCK_ENV)], - [retail(SC, 1057, 0.19).shaped(KNOCK_ENV), retail(SC, 1058, 0.33).shaped(KNOCK_ENV), retail(SC, 1059, 0.30).shaped(KNOCK_ENV)], - [retail(SC, 1060, 0.22).shaped(KNOCK_ENV), retail(SC, 1061, 0.42).shaped(KNOCK_ENV), retail(SC, 1127, 0.30).shaped(KNOCK_ENV)], -]; -pub(super) const LAND_HOLLOW: [[Record; 3]; 4] = [ - [retail(SC, 1062, 0.26).shaped(TAIL_ENV), retail(SC, 1063, 0.47).shaped(TAIL_ENV), retail(SC, 1053, 0.35).shaped(TAIL_ENV)], - [retail(SC, 1065, 0.16).shaped(TAIL_ENV), retail(SC, 1066, 0.25).shaped(TAIL_ENV), retail(SC, 1056, 0.31).shaped(TAIL_ENV)], - [retail(SC, 1068, 0.19).shaped(TAIL_ENV), retail(SC, 1069, 0.25).shaped(TAIL_ENV), retail(SC, 1070, 0.25).shaped(TAIL_ENV)], - [retail(SC, 1071, 0.19).shaped(TAIL_ENV), retail(SC, 1131, 0.30).shaped(TAIL_ENV), retail(SC, 1132, 0.30).shaped(TAIL_ENV)], -]; -/// Chance each contact kind sounds at a landing (retail, 100 landings). -pub(super) const LAND_KIND_CHANCE: [f32; 4] = [0.44, 0.47, 0.16, 0.46]; -/// Cloth with the landing (sk8_foley 44-57, retail ~0.11). -pub(super) const LAND_CLOTH: Record = retail("sk8_foley", 95, 0.11).after(0.04); -/// Variant: light < 3, medium < 6, hard from 6 m/s impact (ours). -pub(super) fn land_tier(impact: f32) -> usize { - match impact { - i if i >= 6.0 => 2, - i if i >= 3.0 => 1, - _ => 0, - } -} -/// Retail contact loudness rises gently with board speed (median 0.19-0.23 -/// slow, ~0.33 above 11 m/s): 0.7 standing to 1.0 from 11 m/s. -pub(super) fn land_scale(speed: f32) -> f32 { - let speed = if speed.is_finite() { speed.abs() } else { 0.0 }; - 0.7 + 0.3 * (speed / 11.0).min(1.0) -} -/// Hollow surfaces for the tier-2 contact table: the wood materials -/// (Wood_Ramp 6, Plywood 7, Wood_1-4 41-46, Wood_5 90 in our audio ids; -/// retail's numbering is ours - 1). -pub(super) fn hollow(audio_surface: u32) -> bool { - matches!(audio_surface, 6 | 7 | 41..=46 | 90) -} -/// Putting the board down / stepping on (measured when getting on the board): -/// the foot-on-deck set (1126: 422-426, 0.23-0.76) with a deck knock (1119) and -/// a light wheel touch (1054); jumping on (caveman) adds the landing set 1095. -pub(super) const BOARD_DOWN: Record = retail(SC, 1126, 0.30); -pub(super) const BOARD_DOWN_KNOCK: Record = retail(SC, 1119, 0.27).shaped(KNOCK_ENV); -pub(super) const BOARD_DOWN_TOUCH: Record = retail(SC, 1054, 0.14); -/// Grind start on ledges and other non-metal surfaces (954: 880-882) ... -pub(super) const GRIND_START: Record = retail(SC, 954, 0.25); -/// ... then retail re-fires short scrape pieces (955: 202-206, 0.04-0.24 s) -/// about every 63 ms while grinding (measured median gap), instead of a loop. -pub(super) const GRIND_PIECES: Record = retail(SC, 955, 0.15); -pub(super) const GRIND_PIECE_INTERVAL: f32 = 0.063; -/// Metal rails: the rail object's start (1146: 953-959, measured 0.49) ... -pub(super) const GRIND_METAL_START: Record = retail(SC, 1146, 0.40); -/// ... over the GRINDS metal loop (retail's Class_grind plays GRINDS quietly, -/// <= 0.13). 54-56 are the most tonal, brightest loops in the bank. -pub(super) const GRIND_METAL: Cue = Cue { bank: "GRINDS", samples: range!(54..=56), level: 0.26 }; - -/// Whether a grind's audio surface (`grinds.audio_surface_216`) is metal: -/// Metal (9), the solid/hollow/square/complex metal shapes (11-40), manhole -/// and grates (67-69), DMORail (85), Metal_Rail_4 (89), Metal_Ramp (91). -pub(super) fn metal(audio_surface: u32) -> bool { - matches!(audio_surface, 9 | 11..=40 | 67..=69 | 85 | 89 | 91) -} -/// Powerslide wheel skid: short pieces played back to back ("shuffle"). -pub(super) const POWERSLIDE: Cue = Cue { bank: "WHEEL_SKID_BANK", samples: range!(0..=7), level: 0.2 }; -/// Foot dragging to brake: ~0.2 s scuffs (0.1 s ones sounded choppy), overlapped. -pub(super) const FOOT_DRAG: Cue = Cue { bank: "FOOT_DRAG", samples: &[36, 37, 38, 39, 44, 45, 46, 47, 60, 61, 62, 63, 64], level: 0.25 }; -/// (seconds between shuffled pieces, fade-in of each piece). -pub(super) const POWERSLIDE_SHUFFLE: (f32, f32) = (0.08, 0.01); -pub(super) const FOOT_DRAG_SHUFFLE: (f32, f32) = (0.11, 0.05); -/// Bail: retail's body hits (1074: records 667-671, samples 215-219 + 861-874, -/// measured <= 0.26) ... -pub(super) const BAIL_HIT: Record = retail(SC, 1074, 0.26); -/// ... with the Bodyslide layer under them (retail <= 0.19: an addition to the -/// fall, as heard in play). 0-15 come in three blocks of five — one longer -/// slide, four hits — taken as soft/medium/hard tiers; 15 joins the hard tier. -pub(super) const BAIL: Cue = Cue { bank: "Bodyslide", samples: range!(0..=15), level: 0.25 }; -pub(super) const BAIL_TIERS: [&[usize]; 3] = [range!(0..=4), range!(5..=9), range!(10..=15)]; -/// Body speed (m/s) at the start of a bail for the medium and hard tiers. -pub(super) fn bail_tier(speed: f32) -> usize { - if speed >= 7.0 { 2 } else if speed >= 3.5 { 1 } else { 0 } -} - -/// Falling into water. The user found Skate_Collisions 476 by ear; the bank's -/// patch tree puts 476-478 in record 869 with two more layers: 169 (played 75 % -/// of the time) and one of 171/172 — retail's full splash. Level follows the -/// fall speed. (Rejected: water_misc 0/4, Skate_Collisions 741-762, 473-475.) -pub(super) const SPLASH: Record = Record { bank: SC, id: 869, level: 0.5, delay: 0.0, envelope: None }; -/// Pushing: foot hitting the ground. -pub(super) const PUSH: Cue = Cue { bank: "fstep_skateshoe1_sm", samples: range!(74..=84), level: 0.6 }; -/// On-foot steps (walk) and heavier steps (run, landing on foot). -// These recordings peak ~11 dB below full scale and average ~28 dB below, so -// they are raised (the voice pool caps each clip at its own full-scale peak). -pub(super) const STEP: Cue = Cue { bank: "fstep_skateshoe1_sm", samples: range!(74..=84), level: 0.85 }; -pub(super) const RUN_STEP: Cue = Cue { bank: "fstep_skateshoe1_sm", samples: range!(6..=17), level: 0.68 }; - -/// The riding bed: what retail plays continuously while rolling, away from -/// pops and landings (clean recomp session, 618 s; rates are starts per second -/// at typical riding speed, scaled by `bed_rate`; levels = median x -/// RETAIL_SCALE). Without it impacts sat on silence ("just a sound playing"). -/// Surface collision pieces (SFXObj_Collision; ~2.1/s), cloth/body foley -/// (sk8_foley, ~2.9/s) and the odd deck knock. -pub(super) const BED_RECORDS: &[(Record, f32)] = &[ - (retail(SC, 955, 0.09), 0.50), (retail(SC, 954, 0.13), 0.45), (retail(SC, 956, 0.15), 0.42), - (retail(SC, 958, 0.07), 0.23), (retail(SC, 947, 0.11), 0.19), (retail(SC, 952, 0.14), 0.17), - (retail(SC, 968, 0.24), 0.17), (retail(SC, 948, 0.15), 0.11), - (retail("sk8_foley", 95, 0.06), 0.78), (retail("sk8_foley", 94, 0.06), 0.52), (retail("sk8_foley", 74, 0.26), 0.32), - (retail("sk8_foley", 73, 0.21), 0.12), (retail("sk8_foley", 62, 0.20), 0.33), (retail("sk8_foley", 84, 0.18), 0.17), - (retail("sk8_foley", 88, 0.16), 0.12), (retail("sk8_foley", 85, 0.23), 0.17), (retail("sk8_foley", 89, 0.07), 0.12), - (retail(SC, 1112, 0.19).shaped(KNOCK_ENV), 0.11), (retail(SC, 1115, 0.26).shaped(KNOCK_ENV), 0.08), - (retail(SC, 1119, 0.32).shaped(KNOCK_ENV), 0.02), -]; -/// Bed records the native contacts play from the retail mechanism (`player::contacts` foot taps -/// 1112..1120 and shoe scuffs `sk8_foley` 94 / 95): silent in the interim bed when those run. -pub(super) fn bed_record_native(record: &Record) -> bool { - matches!((record.bank, record.id), (SC, 1112 | 1115 | 1119) | ("sk8_foley", 94 | 95)) -} -/// Object-bank layers of the bed (retail's most used samples): truck rattles, -/// seams/cracks, speed wind and rolling-surface patches. -pub(super) const BED_CUES: &[(Cue, f32)] = &[ - (Cue { bank: "Rolling_Rattles", samples: &[11, 8, 7, 14, 9, 13], level: 0.44 }, 0.31), - (Cue { bank: "Seams_Bank", samples: &[44, 144, 46, 40, 156, 140, 47, 161, 159, 153, 42, 36], level: 0.09 }, 0.31), - (Cue { bank: "sense_of_speed", samples: &[4, 3, 0, 16, 1], level: 0.08 }, 0.5), - (Cue { bank: "PatchBank_Rolling_Surfaces", samples: &[11, 1, 15, 13, 3], level: 0.14 }, 0.14), -]; -/// Bed rate multiplier for board speed (m/s): none below 0.5, 1.0 at 6 m/s. -pub(super) fn bed_rate(speed: f32) -> f32 { - let speed = if speed.is_finite() { speed.abs() } else { 0.0 }; - if speed < 0.5 { 0.0 } else { (speed / 6.0).min(1.5) } -} - -/// Every one-shot/loop sample cue, for preloading. -pub(super) const ALL: &[&Cue] = &[&FLIP, &GRIND_METAL, &POWERSLIDE, &FOOT_DRAG, &BAIL, &PUSH, &STEP, &RUN_STEP]; -/// Every retail patch cue, for preloading (all samples they can pick). -pub(super) const RECORDS: &[&Record] = &[ - &POP[0], &POP[1], &POP[2], &POP[3], &POP[4], &POP[5], &POP_TAIL[0], &POP_TAIL[1], &POP_TAIL[2], - &CATCH, &LAND, &LAND_CLOTH, - &LAND_NORMAL[0][0], &LAND_NORMAL[0][1], &LAND_NORMAL[0][2], &LAND_NORMAL[1][0], &LAND_NORMAL[1][1], &LAND_NORMAL[1][2], - &LAND_NORMAL[2][0], &LAND_NORMAL[2][1], &LAND_NORMAL[2][2], &LAND_NORMAL[3][0], &LAND_NORMAL[3][1], &LAND_NORMAL[3][2], - &LAND_HOLLOW[0][0], &LAND_HOLLOW[0][1], &LAND_HOLLOW[1][0], &LAND_HOLLOW[1][1], &LAND_HOLLOW[2][0], &LAND_HOLLOW[2][1], - &LAND_HOLLOW[2][2], &LAND_HOLLOW[3][0], &LAND_HOLLOW[3][1], &LAND_HOLLOW[3][2], - &BOARD_DOWN, &BOARD_DOWN_KNOCK, &BOARD_DOWN_TOUCH, &GRIND_START, &GRIND_PIECES, &GRIND_METAL_START, - &BAIL_HIT, &SPLASH, -]; - -/// Shortest time between two plays of one cue (s): a safety net against -/// state flags that stay set for many ticks. -pub(super) fn min_gap(cue: &str) -> f32 { - match cue { - "step" => 0.2, - "run_step" => 0.15, - "push" => 0.3, - "bail" => 0.5, - "splash" => 1.0, - _ => 0.12, - } -} - -/// Rolling loop level at full speed. -pub(super) const ROLL_LEVEL: f32 = 0.15; -/// Wheel spin after leaving the ground. -pub(super) const AIR_WHEELS: (&str, f32) = ("Whls_spins_Jump_1", 0.15); - -/// Rolling grain for an audio material ID (low 7 bits of the collision -/// surface tag; names from the map tooling's audio surface table). Retail was -/// measured playing these "hard" grains too. -/// Rolling grain for a wheel's 7-bit audio surface tag, as retail maps it: tag → material (tag − 1) → -/// the vault's `Sk8::AudioSurfaceMap` → rolling surface 1–14 → grain member -/// (.claude/notes/grain-player-spec.md §1.4; corrected 2026-10-02, it used to be an own guess). -/// Only the hard-wheel members play: what selects the soft ones is not decoded yet. -pub(super) fn grain_for(audio_surface: u32) -> &'static str { - match audio_surface & 0x7F { - 2 => "asphalt_rough_hard", // surface 1 - 4 | 66 => "concrete_rough_hard", // surface 2 - 3 | 51..=54 | 57 | 60 | 63..=65 | 92 | 93 => "concrete_smooth_hard", // surface 4 - 6 | 7 | 41..=50 | 58 | 59 | 94 => "wood_ramp_hard", // surface 5 - 5 => "concrete_aggregate_hard", // surface 6 - 9 | 11..=36 | 38..=40 | 84 | 85 | 89 | 91 => "metal_smooth_hard", // surface 9 - // INTERIM stand-ins. Retail plays no grain on these: rolling surfaces 7 (tags 10, 70), - // 8 (tag 8), 10 (67), 12 (68, 69) and 13 (37) post a per-surface `Class_rolling` patch - // (selectors 1, 2, 10, 11, 9) that this engine cannot play until the AEMS evaluator exists. - 8 | 10 | 70 => "concrete_aggregate_hard", - 37 | 67..=69 => "metal_smooth_hard", - // UNCERTAIN: tag 90 maps to rolling surface 0, which has no grain member; kept as before. - 90 => "wood_ramp_hard", - // Surface 3 asphalt_smooth: tags 1, 55, 56, 61, 62, 71–83, 86–88, ≥ 95 and 0 (no material). - _ => "asphalt_smooth_hard", - } -} - -/// Whether retail plays a grain on this tag (false: a `Class_rolling` patch, or uncertain). -#[cfg(test)] -fn retail_plays_grain(audio_surface: u32) -> bool { - !matches!(audio_surface & 0x7F, 8 | 10 | 70 | 37 | 67..=69 | 90) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rolling_grains_follow_the_retail_surface_map() { - // Spot checks against the vault's AudioSurfaceMap (grain-player-spec.md §1.4). - for (tag, grain) in [ - (2, "asphalt_rough_hard"), (66, "concrete_rough_hard"), (1, "asphalt_smooth_hard"), - (55, "asphalt_smooth_hard"), (0, "asphalt_smooth_hard"), (100, "asphalt_smooth_hard"), - (3, "concrete_smooth_hard"), (93, "concrete_smooth_hard"), (47, "wood_ramp_hard"), - (94, "wood_ramp_hard"), (5, "concrete_aggregate_hard"), (84, "metal_smooth_hard"), - (36, "metal_smooth_hard"), (130, "asphalt_rough_hard"), - ] { - assert_eq!(grain_for(tag), grain, "tag {tag}"); - } - for tag in [8, 10, 70, 37, 67, 68, 69, 90] { - assert!(!retail_plays_grain(tag), "tag {tag} is a Class_rolling / uncertain surface"); - } - assert!(retail_plays_grain(1) && retail_plays_grain(9)); - } - - #[test] - fn cue_ranges_and_levels_are_sane() { - assert_eq!(FLIP.samples, &[1, 2, 3]); - assert_eq!(STEP.samples.len(), 11); - for cue in ALL { - assert!(!cue.samples.is_empty()); - assert!(cue.level > 0.0 && cue.level <= 4.0, "{} out of range", cue.bank); - } - for record in RECORDS { - assert!(record.level > 0.0 && record.level <= 1.0, "{}:{} out of range", record.bank, record.id); - } - } - - #[test] - fn tiers_rise_with_impact_and_speed() { - let all: Vec = BAIL_TIERS.iter().flat_map(|t| t.iter().copied()).collect(); - assert_eq!(all, BAIL.samples); - assert_eq!((bail_tier(1.0), bail_tier(4.0), bail_tier(9.0)), (0, 1, 2)); - assert_eq!((land_tier(1.0), land_tier(4.0), land_tier(8.0)), (0, 1, 2)); - assert!(land_scale(0.0) == 0.7 && land_scale(11.0) == 1.0 && land_scale(f32::NAN) == 0.7); - assert!(hollow(41) && hollow(6) && !hollow(3) && !hollow(66)); - assert!(bed_rate(0.2) == 0.0 && bed_rate(6.0) == 1.0 && bed_rate(30.0) == 1.5); - } - - #[test] - fn every_surface_has_a_grain() { - assert_eq!(grain_for(3), "concrete_smooth_hard"); - assert_eq!(grain_for(6), "wood_ramp_hard"); - assert_eq!(grain_for(31), "metal_smooth_hard"); - // Retail: tags ≥ 95 map to asphalt_smooth (was concrete_smooth before the 2026-10-02 correction). - assert_eq!(grain_for(127), "asphalt_smooth_hard"); - } -} diff --git a/crates/skate-game/src/game_audio/e2e.rs b/crates/skate-game/src/game_audio/e2e.rs index c7171bc1..fb45b44e 100644 --- a/crates/skate-game/src/game_audio/e2e.rs +++ b/crates/skate-game/src/game_audio/e2e.rs @@ -1,7 +1,7 @@ //! End-to-end render of the native player audio (MixMap inputs → components → AEMS voices → //! granular bed → 6-channel bus) for the scripted per-frame situations of -//! `.claude/skills/aems-port/tools/e2e/scenarios.py`, headless. The same scripts drive the PoC's -//! oracle probe; `.claude/skills/aems-port/tools/e2e/compare.py` compares the two renders. +//! `tools/audio-e2e/scenarios.py`, headless. The same scripts can drive another renderer (e.g. the +//! PoC's oracle probe); `tools/audio-e2e/compare.py` compares two renders. //! //! set E2E_DIR=...\.local\audio-re\e2e (optional E2E_ONLY=roll20,grind_metal) //! cargo test -p skate-game --release --bin skate3rust -- --ignored e2e_render --nocapture @@ -199,12 +199,8 @@ impl Script { Riding { board: bevy::math::Vec3::new(self.x, 0.1, 0.0), speed, - rolling: wheels > 0, surface: tag, - airborne, grinding, - grind_surface: r.i("grind_tag") as u32, - sliding: state == 101, braking: r.i("brake") != 0 && speed > 0.5, wheels, pushes: self.pushes, @@ -558,7 +554,7 @@ fn e2e_render() { let (hits, transitions) = p.seam_hits(); eprintln!("{name}: {} frames (+60 settle); Class_Seams hits {hits} ({transitions} material changes)", rows.len()); if timing && !game_us.is_empty() { - // The raw times for the optimisation bench (`.claude/skills/optimisation/tools/`): + // The raw times for the optimisation bench (`tools/audio-bench/`): // one line per measured game-thread call and per rendered block. let mut raw = std::io::BufWriter::new(std::fs::File::create(dir.join(format!("{name}.ours.timing.tsv"))).unwrap()); writeln!(raw, "kind\tus").unwrap(); diff --git a/crates/skate-game/src/game_audio/emitters.rs b/crates/skate-game/src/game_audio/emitters.rs index c27b82b6..b6394fcc 100644 --- a/crates/skate-game/src/game_audio/emitters.rs +++ b/crates/skate-game/src/game_audio/emitters.rs @@ -14,14 +14,14 @@ //! //! What each bank then plays (relay of short pieces or one loop, and its slow //! level/pitch movement) is retail's patch program for the bank: -//! - with the native AEMS runtime on (`native.rs`, `SKATE_AEMS=1`), every record whose bank is in +//! - with the native AEMS runtime running (`native.rs`), every record whose bank is in //! the install takes one of the 5 emitter states (= MixMap Emitter instances), posts `c_emitter` //! and the bank's own program plays it. The payload comes from the MixMap (`Native:: //! emitter_payload`: w1 dry = out4 × level, w2 send = out8 × level, w3 pan = out0, w4 pitch = //! out5, w5 low-pass = out6, w8 = the attribute patch = selector); the state's 3-D input gets the //! listener's distance and azimuth each frame. Redelivered every frame, released (state freed) //! when the listener leaves; -//! - otherwise `PROFILES` holds those programs' measured behaviour (PoC evaluator runs, notes +//! - otherwise (the runtime could not start) `PROFILES` holds those programs' measured behaviour (PoC evaluator runs, notes //! ems-emitters-re.md) and banks without a profile are not played. use super::{Category, Library, Play, Voices, library::Clip, native::Native, voices::VoiceId}; use bevy::prelude::*; diff --git a/crates/skate-game/src/game_audio/grain_bed.rs b/crates/skate-game/src/game_audio/grain_bed.rs index 2274cb72..b7cb7624 100644 --- a/crates/skate-game/src/game_audio/grain_bed.rs +++ b/crates/skate-game/src/game_audio/grain_bed.rs @@ -1,11 +1,12 @@ //! The game side of the native granular rolling bed (`skate_audio::grain`, spec //! `.claude/notes/grain-player-spec.md`), on when the native runtime runs with a MixMap and the //! install has the whole grain recordings and their vault tuning (`audio_export.grain_whole` / -//! `grain_tuning`). Otherwise the interim speed-band loop in `skate_events.rs` plays. +//! `grain_tuning`). Without them rolling is silent and the host logs an error (2026-10-03: the +//! interim speed-band loop is gone). //! //! Per frame, after the MixMap tick (`native::mixmap_frame`): //! - **Surface routing** (§1.4, one sounding truck): the wheels' majority surface → grain member -//! (`cues::grain_for`, the retail AudioSurfaceMap); a change stops both players and binds the new +//! ([`grain_for`], the retail AudioSurfaceMap); a change stops both players and binds the new //! member (pulsing SkateBoard input 0); grinding (surface 14) stops them. In the air the last //! member keeps playing and the MixMap's no-contact duck silences it (what retail does with the //! wheel material in the air is UNCERTAIN, spec §2.10). @@ -38,7 +39,7 @@ use skate_audio::mixmap::{MixMap, keys}; use super::Library; -/// Every member `cues::grain_for` can name, plus the rocket layer. +/// Every member [`grain_for`] can name, plus the rocket layer. const MEMBERS: &[&str] = &[ "asphalt_rough_hard", "concrete_rough_hard", @@ -58,6 +59,31 @@ const SOFT_MEMBERS: &[&str] = &[ "asphalt_smooth_soft", ]; const ROCKET: &str = "x_jet_rolling"; + +/// Rolling grain for a wheel's 7-bit audio surface tag, as retail maps it: tag → material (tag − 1) → +/// the vault's `Sk8::AudioSurfaceMap` → rolling surface 1–14 → grain member +/// (.claude/notes/grain-player-spec.md §1.4). Only the hard-wheel members: [`Bed::member`] swaps in +/// the soft one. The bed's own one-truck routing uses it (the native rolling layers route with +/// `player::rolling`). +pub(super) fn grain_for(audio_surface: u32) -> &'static str { + match audio_surface & 0x7F { + 2 => "asphalt_rough_hard", // surface 1 + 4 | 66 => "concrete_rough_hard", // surface 2 + 3 | 51..=54 | 57 | 60 | 63..=65 | 92 | 93 => "concrete_smooth_hard", // surface 4 + 6 | 7 | 41..=50 | 58 | 59 | 94 => "wood_ramp_hard", // surface 5 + 5 => "concrete_aggregate_hard", // surface 6 + 9 | 11..=36 | 38..=40 | 84 | 85 | 89 | 91 => "metal_smooth_hard", // surface 9 + // Stand-ins: retail plays no grain on these. Rolling surfaces 7 (tags 10, 70), 8 (tag 8), + // 10 (67), 12 (68, 69) and 13 (37) post a per-surface `Class_rolling` patch (the native + // rolling layers, `player::rolling`). + 8 | 10 | 70 => "concrete_aggregate_hard", + 37 | 67..=69 => "metal_smooth_hard", + // UNCERTAIN: tag 90 maps to rolling surface 0, which has no grain member. + 90 => "wood_ramp_hard", + // Surface 3 asphalt_smooth: tags 1, 55, 56, 61, 62, 71–83, 86–88, ≥ 95 and 0 (no material). + _ => "asphalt_smooth_hard", + } +} /// The brake slew step per 60 Hz frame (§2.7). const BRAKE_STEP: f32 = 0.05; @@ -156,7 +182,7 @@ impl Bed { /// The member for a wheel surface tag: the soft member while the wheels are soft and the /// install has it (`sub_824C8370`). fn member(&self, tag: u32, soft: bool) -> &'static str { - let hard = super::cues::grain_for(tag); + let hard = grain_for(tag); if soft { if let Some(&name) = SOFT_MEMBERS.iter().find(|n| n.strip_suffix("_soft") == hard.strip_suffix("_hard")) { if self.tunings.contains_key(name) { @@ -404,7 +430,22 @@ pub(super) fn step( mod tests { use super::*; - /// `cues::grain_for` against the install's own `Sk8::AudioSurfaceMap` (when present): tag → + #[test] + fn rolling_grains_follow_the_retail_surface_map() { + // Spot checks against the vault's AudioSurfaceMap (grain-player-spec.md §1.4). + for (tag, grain) in [ + (2, "asphalt_rough_hard"), (66, "concrete_rough_hard"), (1, "asphalt_smooth_hard"), + (55, "asphalt_smooth_hard"), (0, "asphalt_smooth_hard"), (100, "asphalt_smooth_hard"), + (3, "concrete_smooth_hard"), (93, "concrete_smooth_hard"), (47, "wood_ramp_hard"), + (94, "wood_ramp_hard"), (5, "concrete_aggregate_hard"), (84, "metal_smooth_hard"), + (36, "metal_smooth_hard"), (130, "asphalt_rough_hard"), (6, "wood_ramp_hard"), + (31, "metal_smooth_hard"), (127, "asphalt_smooth_hard"), + ] { + assert_eq!(grain_for(tag), grain, "tag {tag}"); + } + } + + /// [`grain_for`] against the install's own `Sk8::AudioSurfaceMap` (when present): tag → /// material (tag − 1; 0 = none → 143 → surface 3) → rolling surface → grain member, for every /// surface that plays a grain (1–6, 9). And every member has its tuning and recording. #[test] @@ -433,7 +474,7 @@ mod tests { 9 => "metal_smooth_hard", _ => continue, // Class_rolling surfaces and surface 0: stand-ins }; - assert_eq!(super::super::cues::grain_for(tag), want, "tag {tag} (surface {surface})"); + assert_eq!(grain_for(tag), want, "tag {tag} (surface {surface})"); } let bed = Bed::new(&library).expect("every member has its recording and tuning"); assert_eq!(bed.tunings.len(), MEMBERS.len() + SOFT_MEMBERS.len() + 1, "hard and soft members and the `default` collection"); diff --git a/crates/skate-game/src/game_audio/library.rs b/crates/skate-game/src/game_audio/library.rs index 7cbbf773..366b3c63 100644 --- a/crates/skate-game/src/game_audio/library.rs +++ b/crates/skate-game/src/game_audio/library.rs @@ -67,11 +67,10 @@ pub(crate) struct AemsFiles { pub splice: BTreeMap, } -/// A rolling grain: its slow-to-fast recording cut into loopable speed bands (the interim loop), -/// plus the whole recording and its raw `.grain` member for the native grain player. +/// A rolling grain member: the whole recording and its raw `.grain` member for the native grain +/// player (the manifest's speed `bands`, the removed interim loop's, are ignored). #[derive(Debug, Deserialize)] struct Grain { - bands: Vec, #[serde(default)] file: Option, #[serde(default)] @@ -198,22 +197,6 @@ impl SurfaceJson { } } -/// One layer of a retail patch record: members as -/// (sample, gain, gain range, pitch, probability) — see audio_formats.splc_patches. -#[derive(Debug, Deserialize)] -pub(crate) struct Group { - pub mode: u8, - pub members: Vec<(usize, f32, f32, f32, f32)>, -} - -/// Retail SPLC patch tree of one bank: records (layers played together) and -/// containers (pick one record). -#[derive(Debug, Default, Deserialize)] -pub(crate) struct Patches { - pub records: Vec>, - pub containers: Vec>, -} - /// One sound of a location set (audio_export.random_sets). #[derive(Debug, Clone, Deserialize)] pub(crate) struct RandomSound { @@ -319,8 +302,6 @@ struct Manifest { grains: BTreeMap, wheels: BTreeMap, banks: BTreeMap>, - #[serde(default)] - patches: BTreeMap, /// Emitter file stem (`sfx_university`, ...) -> its records. #[serde(default)] emitters: BTreeMap>, @@ -657,7 +638,7 @@ pub(crate) struct Clip { pub handle: Handle, pub key: Arc, pub peak: f32, - /// Channel count from the WAV header (1 if unreadable): the native fold of an interim voice + /// Channel count from the WAV header (1 if unreadable): the native fold of a Bevy voice /// depends on it (`voices::native_fold_gain`). pub channels: u16, } @@ -852,7 +833,7 @@ impl Library { if !MANIFEST_VERSIONS.contains(&manifest.version) { return Err(format!("{}: unsupported version {}", path.display(), manifest.version)); } - let files = manifest.ambience.values().chain(manifest.grains.values().flat_map(|g| &g.bands)).chain(manifest.wheels.values()) + let files = manifest.ambience.values().chain(manifest.wheels.values()) .chain(manifest.banks.values().flatten()); let aems = manifest.aems.projects.iter().chain(manifest.aems.banks.values()).chain(manifest.aems.mixmap.iter()) .chain(manifest.aems.splice.values()) @@ -897,42 +878,12 @@ impl Library { self.clip(assets, &file) } - /// Number of speed bands of a rolling grain (0 if absent). - pub(crate) fn grain_bands(&self, name: &str) -> usize { - self.manifest.grains.get(name).map_or(0, |g| g.bands.len()) - } - - pub(crate) fn grain(&mut self, assets: &mut Assets, name: &str, band: usize) -> Option { - let entry = self.manifest.grains.get(name)?.bands.get(band)?; - let file = entry.file.clone(); - self.clip(assets, &file) - } - - pub(crate) fn wheels(&mut self, assets: &mut Assets, name: &str) -> Option { - let entry = self.manifest.wheels.get(name)?; - let file = entry.file.clone(); - self.clip(assets, &file) - } - pub(crate) fn sample(&mut self, assets: &mut Assets, bank: &str, index: usize) -> Option { let entry = self.manifest.banks.get(bank)?.get(index)?; let file = entry.file.clone(); self.clip(assets, &file) } - /// Read every grain band, wheel spin and listed bank sample now, so - /// gameplay never waits on the disk (~20 MB). Returns the clip count. - pub(crate) fn preload(&mut self, assets: &mut Assets, samples: &[(&str, &[usize])]) -> usize { - let mut files: Vec = self.manifest.grains.values().flat_map(|g| &g.bands) - .chain(self.manifest.wheels.values()).map(|e| e.file.clone()).collect(); - for (bank, indices) in samples { - if let Some(entries) = self.manifest.banks.get(*bank) { - files.extend(indices.iter().filter_map(|&i| entries.get(i)).map(|e| e.file.clone())); - } - } - files.iter().filter(|file| self.clip(assets, file).is_some()).count() - } - /// Number of samples in a bank (0 when the bank was not exported). pub(crate) fn bank_len(&self, bank: &str) -> usize { self.manifest.banks.get(bank).map_or(0, Vec::len) @@ -975,24 +926,6 @@ impl Library { .and_then(|(k, s)| Some((u64::from_str_radix(k, 16).ok()?, s))) } - /// A bank's retail patch tree (SPLC banks only). - pub(crate) fn patches(&self, bank: &str) -> Option<&Patches> { - self.manifest.patches.get(bank) - } - - /// Every sample patch `id` of `bank` can play (all records of a container, - /// every layer member), for preloading. - pub(crate) fn patch_samples(&self, bank: &str, id: usize) -> Vec { - let Some(patches) = self.patches(bank) else { return Vec::new() }; - let records = patches.records.len(); - let ids = if id < records { vec![id] } else { patches.containers.get(id - records).cloned().unwrap_or_default() }; - let mut samples: Vec = ids.iter().filter_map(|&r| patches.records.get(r)).flatten() - .flat_map(|group| group.members.iter().map(|member| member.0)).collect(); - samples.sort_unstable(); - samples.dedup(); - samples - } - /// The native AEMS runtime's files (empty before manifest v5). pub(crate) fn aems(&self) -> &AemsFiles { &self.manifest.aems diff --git a/crates/skate-game/src/game_audio/mod.rs b/crates/skate-game/src/game_audio/mod.rs index 2b6bb17e..6ee74f01 100644 --- a/crates/skate-game/src/game_audio/mod.rs +++ b/crates/skate-game/src/game_audio/mod.rs @@ -2,11 +2,10 @@ //! played from the owned disc's sounds that setup decodes to PCM WAV //! (tools/asset_pipeline/audio_export.py -> assets/private/audio). //! -//! Gameplay events have no event bus, so each cue is found by comparing this -//! frame's physics state with the last one, as the mod observation layer does. -//! Which retail sample plays for which event is our own hand-made table -//! (docs/hails-additions/11-audio.md); EA's AEMS event runtime is not -//! re-implemented. +//! The skater's sounds, the world emitters and the rolling bed are the native AEMS runtime +//! (`crates/skate-audio`, `native.rs`): retail's patch programs, MixMap and player components fed +//! from the physics state (`skate_events::observe`). The zone beds, location sets and crossfades +//! still play measured layers through Bevy voices (docs/hails-additions/11-audio.md). //! //! Loudness is deliberately conservative: master volume defaults to 75%, //! a cue may raise a quiet clip only until the clip's own peak reaches full @@ -15,7 +14,6 @@ //! the menu is open or a replay runs. `--mute` silences game and mod audio. mod ambience; mod crossfade_groups; -mod cues; #[cfg(test)] mod e2e; mod emitters; @@ -48,14 +46,12 @@ struct SavedSettings { master: u32, ambience: u32, effects: u32, - /// Use the interim measured cue tables instead of the native AEMS runtime (crates/skate-audio), - /// which is the default. `SKATE_AEMS=0` / `SKATE_AEMS=1` override it (native.rs). The old - /// `native` key (saved as `false` while the tables were the default) is ignored. - interim: bool, + // Files saved before 2026-10-03 may hold `"interim"` (the opt-out to the removed interim cue + // tables) or the older `"native"`; unknown keys are ignored, so they still load. } impl Default for SavedSettings { fn default() -> Self { - Self { master: 75, ambience: 100, effects: 100, interim: false } + Self { master: 75, ambience: 100, effects: 100 } } } impl SavedSettings { @@ -91,11 +87,6 @@ impl AudioSettings { pub(crate) fn master(&self) -> f32 { if self.muted { 0.0 } else { self.saved.master as f32 / 100.0 } } - /// Whether `settings/audio.json` keeps the native AEMS runtime (the default) rather than the - /// interim tables. - pub(crate) fn native(&self) -> bool { - !self.saved.interim - } pub(crate) fn category(&self, category: Category) -> f32 { let percent = match category { Category::Ambience => self.saved.ambience, @@ -163,7 +154,7 @@ impl Plugin for GameAudioPlugin { .add_systems(Startup, setup) .add_systems(FixedUpdate, skate_events::observe.after(crate::app::SimulationSet::Physics)) .add_systems(Update, (native::mixmap_frame, grain_bed::update, emitters::reverb_zones, native::reverb_frame).chain().before(CueSet).after(crate::app::FrameSet::Animation)) - .add_systems(Update, (ambience::update, skate_events::play, emitters::update, random_sets::update).in_set(CueSet).after(crate::app::FrameSet::Animation)) + .add_systems(Update, (ambience::update, emitters::update, random_sets::update).in_set(CueSet).after(crate::app::FrameSet::Animation)) .add_systems(Update, voices::sync.after(CueSet)) .add_systems(Update, timing::report) .add_plugins(native::register) @@ -176,23 +167,11 @@ impl Plugin for GameAudioPlugin { } } -fn setup(mut commands: Commands, config: Res, mut assets: ResMut>) { +fn setup(mut commands: Commands, config: Res) { commands.insert_resource(AudioSettings::load(&config)); commands.spawn((GameAudioListener, SpatialListener::new(0.2), Transform::default())); match Library::load(&config.asset_root) { - Ok(mut library) => { - let records: Vec<(&str, Vec)> = cues::RECORDS.iter().copied() - .chain(cues::BED_RECORDS.iter().map(|(r, _)| r)) - .map(|r| (r.bank, library.patch_samples(r.bank, r.id))).collect(); - let mut samples: Vec<(&str, &[usize])> = cues::ALL.iter().copied() - .chain(cues::BED_CUES.iter().map(|(c, _)| c)) - .map(|c| (c.bank, c.samples)).collect(); - samples.extend(records.iter().map(|(bank, ids)| (*bank, ids.as_slice()))); - let started = std::time::Instant::now(); - let count = library.preload(&mut assets, &samples); - info!("Game audio: preloaded {count} clips in {:.0} ms", started.elapsed().as_secs_f64() * 1000.0); - commands.insert_resource(library); - } + Ok(library) => commands.insert_resource(library), Err(error) => info!("Game audio unavailable (run setup to extract it): {error}"), } } @@ -231,20 +210,21 @@ mod tests { } } + /// Settings files from before the interim tables were removed still load; their `"interim"` / + /// `"native"` keys are ignored. #[test] - fn native_is_the_default_even_with_an_old_saved_native_false() { - assert!(!SavedSettings::default().interim); - let old: SavedSettings = serde_json::from_str(r#"{"master":75,"ambience":75,"effects":75,"native":false}"#).unwrap(); - assert!(!old.interim); - let opted_out: SavedSettings = serde_json::from_str(r#"{"interim":true}"#).unwrap(); - assert!(opted_out.interim); + fn old_settings_files_with_interim_or_native_keys_still_load() { + let old: SavedSettings = serde_json::from_str(r#"{"master":60,"ambience":75,"effects":75,"native":false}"#).unwrap(); + assert_eq!(old, SavedSettings { master: 60, ambience: 75, effects: 75 }); + let opted_out: SavedSettings = serde_json::from_str(r#"{"master":50,"interim":true}"#).unwrap(); + assert_eq!(opted_out, SavedSettings { master: 50, ..SavedSettings::default() }); } #[test] fn defaults_are_quiet_and_saved_values_are_bounded() { assert_eq!(SavedSettings::default().master, 75); let loaded: SavedSettings = serde_json::from_str(r#"{"master":400,"ambience":33,"effects":7}"#).unwrap(); - assert_eq!(loaded.validated(), SavedSettings { master: 100, ambience: 30, effects: 5, interim: false }); + assert_eq!(loaded.validated(), SavedSettings { master: 100, ambience: 30, effects: 5 }); } #[test] diff --git a/crates/skate-game/src/game_audio/native.rs b/crates/skate-game/src/game_audio/native.rs index 738a4b1d..5abf062d 100644 --- a/crates/skate-game/src/game_audio/native.rs +++ b/crates/skate-game/src/game_audio/native.rs @@ -1,6 +1,8 @@ //! Host for the native AEMS runtime (`crates/skate-audio`): retail's own patch programs and voice -//! graph instead of our measured tables. Off by default; `SKATE_AEMS=1` or `"native": true` in -//! `settings/audio.json` turns it on (docs/hails-additions/11-audio.md, "Native AEMS runtime"). +//! graph. Always on (2026-10-03: the interim cue tables and their opt-outs `SKATE_AEMS=0` / +//! `"interim": true` are gone; docs/hails-additions/11-audio.md). An install without the data +//! (AEMS banks, MixMap, grain recordings, Splice trees) logs an error naming what is missing and +//! those sounds stay silent: there is no fallback. //! //! - At startup every Csis project is installed and `emitter_utility.abk` is loaded and posted //! (retail posts `c_emitter_utility` once at boot; it feeds the `*_snd` / `random_*_gbl` globals). @@ -16,9 +18,9 @@ //! and the systems read its outputs (the `c_emitter` words; the rolling bed's levels, pitch, //! filters and pan). //! -//! Which systems use it so far: the `.ems` world emitters (`emitters.rs`) and, when the install -//! has the whole grain recordings, the granular rolling bed (`grain_bed.rs`). Location sets, zone -//! beds, crossfades and the other skate cues still use their measured tables. +//! 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 +//! crossfades still play measured layers through Bevy voices. use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -123,7 +125,7 @@ pub(crate) struct Native { pub(crate) player: Option, /// Which emitter states (MixMap Emitter instances 0..4) are taken. emitter_states: [bool; EMITTER_STATES], - /// The granular rolling bed's game-side state (None: the interim rolling loop plays). + /// The granular rolling bed's game-side state (None: no rolling sound; an error is logged). pub(crate) bed: Option, /// World emitter banks read and decoded ahead of need on a worker thread ([`prefetch`]). pub(crate) prefetch: prefetch::Prefetch, @@ -149,16 +151,16 @@ impl Native { Some(m) } None => { - warn!("Game audio: this install has no MixMapSK8.mxb (run setup to refresh the audio); emitter words use defaults"); + error!("Game audio: this install has no MixMapSK8.mxb: the skater's sounds and rolling are silent and emitter words use defaults (run setup to refresh the audio)"); None } }; let bed = if mixmap.is_some() { super::grain_bed::Bed::new(library) } else { None }; let player = mixmap.as_ref().map(|_| { - super::player_audio::PlayerAudio::new(library.player_tuning(), super::player_audio::components_requested()) + super::player_audio::PlayerAudio::new(library.player_tuning(), true) }); if bed.is_none() { - info!("Game audio: no MixMap, whole grain recordings or grain tuning in this install; the interim rolling loop plays"); + error!("Game audio: no MixMap, whole grain recordings or grain tuning in this install: rolling is silent (run setup to refresh the audio)"); } let mut native = Self { shared: Arc::new(Mutex::new(runtime)), @@ -242,7 +244,7 @@ impl Native { } /// The player components' banks, in the player volume group; without them the components - /// stay off and their interim cues play. + /// stay off and the skater's sounds are silent. fn load_player_banks(&mut self, library: &Library) { if !self.player.as_ref().is_some_and(|p| p.components) { return; @@ -255,7 +257,7 @@ impl Native { } } Err(e) => { - warn!("Game audio: native player sounds off ({e}); the measured cues play"); + error!("Game audio: the skater's sounds are silent ({e}; run setup to refresh the audio)"); if let Some(p) = &mut self.player { p.components = false; } @@ -277,7 +279,7 @@ impl Native { first |= i == 0; } } - None => warn!("Game audio: {stem} has no patch tree in this install (run setup to refresh the audio); its native sounds stay off"), + None => error!("Game audio: {stem} has no patch tree in this install: its sounds are silent (run setup to refresh the audio)"), } } // SFXObj_Wheels' spin-down recordings, decoded now (first trigger = later triggers). @@ -289,7 +291,7 @@ impl Native { if let Some(p) = &mut self.player { p.contacts_on = first; p.set_footstep_materials(library.footstep_materials()); - p.footsteps_on = first && !std::env::var("SKATE_AEMS_FOOTSTEPS").is_ok_and(|v| v == "0"); + p.footsteps_on = first; p.contact_tuning = library.contacts_tuning(); p.wheels_on = wheels; if p.contacts_on { @@ -416,15 +418,6 @@ impl Native { } } -/// Whether to run the native runtime: on by default; `SKATE_AEMS=0` (or `"interim": true` in -/// `settings/audio.json`) keeps the interim tables. -pub(crate) fn requested(settings: &super::AudioSettings) -> bool { - match std::env::var("SKATE_AEMS") { - Ok(v) => !(v == "0" || v.eq_ignore_ascii_case("false")), - Err(_) => settings.native(), - } -} - pub(crate) fn register(app: &mut App) { app.add_audio_source::() .add_systems(PostStartup, start) @@ -607,10 +600,7 @@ fn start( library: Option>, mut streams: ResMut>, ) { - let (Some(settings), Some(library)) = (settings, library) else { return }; - if !requested(&settings) { - return; - } + let (Some(_), Some(library)) = (settings, library) else { return }; // Which compiled copy of the DSP loops runs (hardware FMA or plain; same output bits): chosen // once, here, before the first render (doc 11 "Hardware FMA dispatch"). info!("AUDIO_DSP {}", skate_audio::dsp::init_fma()); @@ -626,7 +616,7 @@ fn start( commands.spawn((NativeOutput, AudioPlayer(handle), PlaybackSettings::ONCE.with_volume(Volume::Linear(0.0)))); commands.insert_resource(native); } - Err(error) => warn!("Game audio: native AEMS runtime unavailable, using the measured tables: {error}"), + Err(error) => error!("Game audio: the native AEMS runtime could not start, so the skater's sounds, the world emitters and rolling are silent: {error}"), } } @@ -644,8 +634,8 @@ fn follow_volume( let Some(settings) = settings else { return }; // rodio 0.20's spatial source gives the ear FARTHER from the source the larger factor // (((d_left - d_right) / gap + 1) / 4 + 0.5 on the left channel), so with the ears where Bevy - // puts them every interim positional sound is mirrored. With the native host on, swap the - // ears so interim sounds come from the side native voices (Pan2D1) put them on. + // puts them every Bevy positional sound is mirrored. With the native host on, swap the + // ears so Bevy sounds come from the side native voices (Pan2D1) put them on. for mut listener in &mut listeners { let gap = listener.left_ear_offset.distance(listener.right_ear_offset); let want = if native.is_some() { Vec3::X * gap / 2.0 } else { Vec3::X * gap / -2.0 }; @@ -656,15 +646,16 @@ fn follow_volume( } let silenced = super::silenced(menu.as_deref(), &replay); let volume = settings.master().clamp(0.0, 1.0); - // The interim cues are measured retail level × RETAIL_SCALE (anchored to the interim rolling - // loop); with the native player sounds (retail level) they drop back to the measured level. + // The measured world layers (zone beds, location sets, crossfades) play at measured retail level + // × RETAIL_SCALE; with the native player sounds (retail level) they drop back to the measured + // level. let native_player = native.as_deref().is_some_and(|n| n.player.as_ref().is_some_and(|p| p.components)); if let Some(mut voices) = voices { - let scale = if native_player { 1.0 / super::cues::RETAIL_SCALE } else { 1.0 }; + let scale = if native_player { 1.0 / super::voices::RETAIL_SCALE } else { 1.0 }; if voices.scale != scale { voices.scale = scale; } - // Interim and native voices share the ears: fold the interim ones like native voices. + // Bevy and native voices share the ears: fold the Bevy ones like native voices. let fold = native.is_some(); if voices.native_fold != fold { voices.native_fold = fold; @@ -836,8 +827,8 @@ mod tests { assert_eq!((decoder.channels(), decoder.sample_rate()), (2, 48000)); } - /// Native is the default: an install that lacks some of its data must still start (or fall - /// back to the measured tables with a warning), never panic. Each case loads a copy of the + /// An install that lacks some of its data must still start (or log an error and leave those + /// sounds silent), never panic. Each case loads a copy of the /// dev install's manifest with parts removed (the data folders are linked, not copied). /// Data-gated: skipped without the install. #[test] @@ -891,7 +882,7 @@ mod tests { let n = start("no MixMap", &|m| m["aems"]["mixmap"] = serde_json::Value::Null).expect("starts without a MixMap"); assert!(n.player.is_none() && n.bed.is_none()); let n = start("no GRINDS bank", &remove_bank("GRINDS")).expect("starts without the player banks"); - assert!(!n.player.as_ref().unwrap().components, "the components hand back to the measured cues"); + assert!(!n.player.as_ref().unwrap().components, "the components stay off (silent, error logged)"); let n = start("no Treatments bank", &remove_bank("Treatments")).expect("starts without Treatments"); let p = n.player.as_ref().unwrap(); assert!(p.components && p.tricks_on && !p.treatment_on); diff --git a/crates/skate-game/src/game_audio/player_audio.rs b/crates/skate-game/src/game_audio/player_audio.rs index 0a083105..fd4e25f4 100644 --- a/crates/skate-game/src/game_audio/player_audio.rs +++ b/crates/skate-game/src/game_audio/player_audio.rs @@ -4,10 +4,9 @@ //! 2. components' process (posts / releases) — before the MixMap tick; //! 3. components' update (packets rewritten from the MixMap outputs, redelivered) — after it. //! -//! The player components (Class_grind, SenseOfSpeed rattle / wind, Class_foot_drag) run when the -//! native runtime is on unless `SKATE_AEMS_PLAYER=0`; their interim cues in `skate_events.rs` -//! (the metal GRINDS loop, the sense_of_speed bed cue, the foot-drag pieces) are then silent. The -//! default path (native off) is unchanged. +//! The player components run whenever their banks are in the install (2026-10-03: the interim +//! cue tables and the `SKATE_AEMS_PLAYER=0` / `SKATE_AEMS_FOOTSTEPS=0` opt-outs are gone; without +//! the banks the skater's sounds are silent and the host logs an error). use std::collections::HashMap; use skate_audio::eval::NodeId; @@ -29,11 +28,6 @@ use skate_audio::player::wheels::{Wheels, WheelsTuning}; use skate_audio::player::{AudioState, Owner}; use skate_audio::runtime::Runtime; -/// Whether the native player components run (with the native runtime on). -pub(crate) fn components_requested() -> bool { - !std::env::var("SKATE_AEMS_PLAYER").is_ok_and(|v| v == "0" || v.eq_ignore_ascii_case("false")) -} - pub(crate) struct PlayerAudio { pub(crate) tuning: PlayerTuning, physics: Physics, @@ -99,7 +93,7 @@ pub(crate) struct PlayerAudio { globals: Globals, /// SFXObj_OffBoard's footsteps (packets + the foot-down / walking / jump Splice sounds) and the /// Clothing component (cloth falls, push foley, body slide): on with the board contacts (their - /// Splice banks) — `SKATE_AEMS_FOOTSTEPS=0` keeps the interim steps. + /// Splice banks), plus OffBoard's water splash. footsteps: Footsteps, footstep_tuning: FootstepTuning, clothing: Clothing, @@ -193,11 +187,6 @@ impl PlayerAudio { self.grind.onoff = grind_onoff; } - /// Whether the body poster runs (the interim bail cues stay silent then). - pub(crate) fn body_impacts_on(&self) -> bool { - self.components && self.contacts_on && self.board.body_on - } - /// Collision messages of another Player-slot owner (an NPC skater, `npc_skaters.rs`): retail /// has one `CSTATEMGR_Collision`, so they join the local player's (processed at its next /// `process`). No messages, no change. @@ -405,7 +394,12 @@ impl PlayerAudio { self.collision.process(m_mut, self.last_listener.as_ref()); } if self.footsteps_on { + let splashes = self.footsteps.splash.starts; let mut cmds = self.footsteps.process(s, &self.tuning, &self.footstep_tuning, &mut rt.splice_host()); + if self.footsteps.splash.starts != splashes { + // OffBoard's water splash (`player::footsteps::Splash`, retail `sub_824EBB58`). + bevy::log::info!("AUDIO_EVENT splash native Skate_Collisions:{}", self.footsteps.splash.last_id); + } cmds.extend(self.clothing.process(s, &self.tuning, &self.clothing_tuning, &mut rt.splice_host())); self.apply(rt, cmds); } @@ -780,6 +774,49 @@ mod tests { (v.get(v.len() / 2).copied().unwrap_or(0.0), v.last().copied().unwrap_or(0.0)) } + /// OffBoard's water splash (`player::footsteps::Splash`) through the real MixMap, Splice banks + /// and runtime: dry, in water on the surface for a frame, under it (1197: the time in water is + /// past 0.001 s), then the board in water (1198). Both sound (their levels come from OffBoard + /// outputs 13 / 16, printed with 14 / 15). + #[test] + fn the_water_splash_plays_through_the_real_mixmap() { + let Some((library, mxb)) = install() else { return eprintln!("skipped: no install with a MixMap") }; + let (mut rt, _) = runtime(&library); + for stem in SPLICE_BANKS { + let Some((bank, pcm)) = library.splice_bank(stem) else { return eprintln!("skipped: no {stem} patch tree") }; + let r = &mut rt; + r.splice.load_bank(stem, bank, pcm, &mut r.mixer); + } + let mut m = MixMap::from_bytes(&mxb).unwrap(); + let mut p = PlayerAudio::new(library.player_tuning(), true); + p.footsteps_on = true; + let mut out = vec![0.0f32; 1600]; + let mut energy = [0.0f64; 3]; + for f in 0..150 { + let mut s = rolling(0.0, 0.0); + s.in_water = f >= 30; + s.under_water = f >= 31; + s.board_in_water = f >= 90; + globals(&mut m); + let l = p.listener([s.com_position[0] - 3.5, 2.4, s.com_position[2]], [1.0, -0.3, 0.0], 1.0 / 60.0, &s); + p.write_inputs(&mut m, &s, Some(&l)); + p.process(&mut m, &s, &mut rt, None, 0); + m.tick(1.0 / 60.0); + p.update(&m, &s, &mut rt, None, 0); + rt.fill_stereo(&mut out); + let e: f64 = out.iter().map(|&x| f64::from(x) * f64::from(x)).sum(); + energy[if f < 30 { 0 } else if f < 90 { 1 } else { 2 }] += e; + if f == 40 { + let k = keys::off_board(0); + println!("OffBoard level 13 / 15 / 16 = {} / {} / {}, pitch 14 = {}", m.level(k, 13), m.level(k, 15), m.level(k, 16), m.pitch_4096(k, 14)); + } + } + let splash = &p.footsteps.splash; + println!("splash starts {} (last {}), energy dry / entry / board {energy:?}", splash.starts, splash.last_id); + assert_eq!((splash.starts, splash.last_id), (2, 1198)); + assert!(energy[1] > 10.0 * energy[0].max(1e-9) && energy[2] > 0.0, "{energy:?}"); + } + /// The ported components through the real banks, MixMap and voice graph, headless: what they /// post, how many voices open and at which gain (master × dry), against the retail trace /// (session 20261001_211347 report: per-sample audible level = gain × send, median / max: diff --git a/crates/skate-game/src/game_audio/skate_events.rs b/crates/skate-game/src/game_audio/skate_events.rs index 6dfdf422..22ff50b8 100644 --- a/crates/skate-game/src/game_audio/skate_events.rs +++ b/crates/skate-game/src/game_audio/skate_events.rs @@ -1,9 +1,9 @@ -//! Skateboarding sounds. `observe` runs after every physics tick and turns -//! state changes into cues (some flags, like the ollie launch, only last one -//! tick); `play` turns them into voices each frame and keeps the continuous -//! loops (rolling, grind, powerslide, foot drag, wheel spin) following the board. -//! Every one-shot cue is logged as `AUDIO_CUE` so play sessions can be checked. -use super::{Library, Play, Voices, cues, library::Clip, voices::VoiceId}; +//! The skater's audio inputs. `observe` runs after every physics tick and publishes the retail +//! audio-state record (`skate_audio::player::AudioState`, [`audio_state`]) and the board state the +//! native runtime reads ([`Riding`]: the granular bed, the player components, the emitters' and +//! ambience's listener position), writes the state log, and logs the animation's push / brake +//! events and grind starts / stops as `AUDIO_EVENT` lines. Every sound is the native runtime's +//! (2026-10-03: the interim cue tables and their `play` system are gone; doc 11). use crate::physics::{GamePhysics, SkaterRuntime}; use bevy::prelude::*; use skate_core::{ @@ -11,71 +11,20 @@ use skate_core::{ player::state::PhysicalStateId, }; -/// Board ground speed (m/s) mapped to the fastest rolling band. -const FULL_ROLL_SPEED: f32 = 10.0; -/// Horizontal speed (m/s) above which on-foot steps use the running set. -const RUN_SPEED: f32 = 3.0; -/// Downward speed (m/s) a body needs for a water-entry splash, and the -/// shortest time between two splashes (floating bodies touch water every tick). -const SPLASH_SPEED: f32 = 1.0; -const SPLASH_COOLDOWN: f32 = 1.5; -/// Rolling: speed smoothing time constant, shortest time on one band, and how -/// long a new surface must stay under the wheels before the sound changes. -const ROLL_SPEED_SMOOTHING: f32 = 0.25; -const BAND_HOLD: f32 = 1.0; -const SURFACE_HOLD: f32 = 0.25; -/// Seconds off the ground after which the rolling loop stops (restarting -/// cleanly on the surface it lands on). -const ROLL_OFF_STOP: f32 = 0.15; -/// Foot drag: the board must move this fast (m/s) to make a sound. The -/// foot-down brake event repeats every tick while the foot brakes (then -/// foot-up repeats while it lifts), so braking ends this long (s) after the -/// last foot-down. +/// Foot braking (the bed's brake input, `grain_bed.rs`): the board must move this fast (m/s). The +/// foot-down brake event repeats every tick while the foot brakes (then foot-up repeats while it +/// lifts), so braking ends this long (s) after the last foot-down. const DRAG_MIN_SPEED: f32 = 0.5; const BRAKE_TIMEOUT: f32 = 0.1; -#[derive(Clone, Copy, Debug)] -pub(super) enum Event { - Pop(Vec3), - Flip(Vec3), - Land { at: Vec3, impact: f32 }, - /// Wheels touch down after stepping onto the board (an Air phase that began on foot). - BoardDown { at: Vec3, impact: f32 }, - Bail { at: Vec3, speed: f32 }, - Push(Vec3), - /// `level` 0..1 from the animation's AudibleFootStepStrength (walk ~0.6, run 1.0). - Step { at: Vec3, run: bool, level: f32 }, - Splash { at: Vec3, speed: f32 }, -} - -impl Event { - fn name(&self) -> &'static str { - match self { - Event::Pop(_) => "pop", - Event::Flip(_) => "flip", - Event::Land { .. } => "land", - Event::BoardDown { .. } => "board_down", - Event::Bail { .. } => "bail", - Event::Push(_) => "push", - Event::Step { run: false, .. } => "step", - Event::Step { run: true, .. } => "run_step", - Event::Splash { .. } => "splash", - } - } -} - /// Continuous state sampled at the last physics tick. #[derive(Clone, Copy, Debug, Default)] pub(super) struct Riding { pub board: Vec3, pub speed: f32, - pub rolling: bool, + /// Majority audio surface tag under the wheels in contact (0 with none). pub surface: u32, - pub airborne: bool, pub grinding: bool, - /// Audio surface of the grind (`grinds.audio_surface_216`). - pub grind_surface: u32, - pub sliding: bool, pub braking: bool, /// Wheels in contact (0..4) and pushes since start (the native rolling bed reads these). pub wheels: u32, @@ -94,36 +43,20 @@ pub(super) struct Riding { #[derive(Resource, Default)] pub(super) struct Cues { - pub events: Vec, pub riding: Riding, } #[derive(Default)] pub(super) struct Seen { started: bool, - launched: bool, - filtered: u32, - state: u32, - trick_seq: u32, - in_water: bool, - splash_cooldown: f32, push: bool, /// Last tick's audio-state push plant (`+333 || +334`, see [`push_plant`]). planted: bool, - feet: [FootStrike; 2], - /// The current Air phase began on foot (stepping onto the board). - air_from_foot: bool, - /// Seconds since the skater was last on foot (BipedGround/BipedAir). - since_foot: f32, /// Foot braking, time since the last foot-down event, and the last event (+1/-1/0). braking: bool, brake_time: f32, brake_event: i32, on_rail: bool, - /// Seconds since the last splash (a wipeout right after one is the water entry). - since_splash: f32, - /// Last trick announced in the current air phase. - air_trick: Option, trace: Option, /// Seconds in the current air phase (audio state `+236`). air_time: f32, @@ -146,41 +79,6 @@ pub(super) struct Seen { log_frame: u64, } -/// Foot-strike detector on one foot's height above the ground under it. The -/// resting height (ankle above sole) is learned: it follows the lowest height -/// and drifts back up slowly. A strike is the foot coming back down to rest -/// after having lifted clearly above it. -#[derive(Clone, Copy, Debug, Default)] -pub(super) struct FootStrike { - rest: Option, - lifted: bool, -} -impl FootStrike { - const LIFT: f32 = 0.05; - const STRIKE: f32 = 0.02; - const DRIFT: f32 = 2.0; - - /// Feed this tick's height (None: no ground found); true on a strike. - pub(super) fn update(&mut self, height: Option, dt: f32) -> bool { - let Some(height) = height.filter(|h| h.is_finite()) else { - self.lifted = false; - return false; - }; - let rest = match self.rest { - Some(rest) if height >= rest => rest + (height - rest) * (1.0 - (-dt / Self::DRIFT).exp()), - _ => height, - }; - self.rest = Some(rest); - if height > rest + Self::LIFT { - self.lifted = true; - } else if self.lifted && height < rest + Self::STRIKE { - self.lifted = false; - return true; - } - false - } -} - fn vec(v: skate_core::math::Vector3) -> Vec3 { Vec3::new(v.x, v.y, v.z) } @@ -223,20 +121,8 @@ pub(super) fn observe( let filtered = p.filtered_state_0; let state = skater.player_state.current() as u32; let deck = physics.board.bodies()[BodyId::Deck.index()].rates; - let root = skater.skeleton.bodies()[0].rates; - let (board, body) = (vec(deck.position), vec(root.position)); - let launched = skater.ground_animation.launched; - let trick_seq = skater.scoring.trick_seq(); - let in_water = skater.collision_feedback.flags.material_12; - let air = FilteredCategory::Air as u32; + let board = vec(deck.position); let ground = FilteredCategory::Ground as u32; - let wipeout = PhysicalStateId::WipeoutGround as u32; - let mut cooldown = (seen.splash_cooldown - time.delta_secs()).max(0.0); - let mut since_splash = seen.since_splash + time.delta_secs(); - // The physical state leaves BipedGround a few ticks before the filtered - // category reaches Air, so "began on foot" means on foot very recently. - let since_foot = if matches!(state, 500 | 501) { 0.0 } else { seen.since_foot + time.delta_secs() }; - let air_from_foot = if filtered == air && seen.filtered != air { seen.since_foot < 0.5 } else { seen.air_from_foot }; let trace = *seen.trace.get_or_insert_with(|| std::env::var_os("SKATE_AUDIO_TRACE").is_some_and(|v| v != "0")); if trace && (audio.footstep > 0.0 || audio.push || audio.brake_down || audio.brake_up) { info!("AUDIO_TRACE footstep={:.3} push={} brake_down={} brake_up={} state={state}", @@ -259,67 +145,6 @@ pub(super) fn observe( braking = false; brake_time = BRAKE_TIMEOUT; } - if seen.started { - let events = &mut cues.events; - if launched && !seen.launched { - events.push(Event::Pop(board)); - } - if seen.filtered == air && filtered == ground { - let impact = physics.riding.ground.maximum_closing_speed; - events.push(if air_from_foot { - Event::BoardDown { at: board, impact } - } else { - Event::Land { at: board, impact } - }); - } - // The scoring re-announces a held grab (trick_seq keeps rising), so the - // whoosh plays only for a different trick than the last in this air. - if trick_seq != seen.trick_seq && filtered == air { - let name = skater.scoring.trick_name(); - if seen.air_trick.as_deref() != Some(name) { - events.push(Event::Flip(board)); - seen.air_trick = Some(name.to_owned()); - } - } - // A water entry is also a wipeout; the splash covers it (no body slide). - let watery = in_water || seen.since_splash < 0.5; - // Wading in and falling (feet already wet, slow drop) never gives a - // fresh fast contact below, so a wipeout in water splashes by itself. - if state == wipeout && seen.state != wipeout && in_water && seen.since_splash > 1.0 { - events.push(Event::Splash { at: body, speed: vec(root.linear_velocity).length().max(2.0) }); - since_splash = 0.0; - cooldown = SPLASH_COOLDOWN; - } - if state == wipeout && seen.state != wipeout && !watery { - events.push(Event::Bail { at: body, speed: vec(root.linear_velocity).length() }); - } - if audio.push && !seen.push { - events.push(Event::Push(board)); - } - // AudibleFootStepStrength is a held loudness level (2.5-4), not a - // step event; steps come from each foot coming down to the ground. - if matches!(state, 500 | 501) { - let horizontal = Vec2::new(root.linear_velocity.x, root.linear_velocity.z).length(); - let clearance = crate::physics::foot_clearance(&skater); - if trace { - let h = clearance.map(|c| c.map_or(f32::NAN, |(height, _)| height)); - info!("AUDIO_TRACE feet left={:.3} right={:.3} speed={horizontal:.2}", h[0], h[1]); - } - for (foot, clearance) in seen.feet.iter_mut().zip(clearance) { - if foot.update(clearance.map(|(height, _)| height), time.delta_secs()) && horizontal > 0.3 { - let level = if audio.footstep > 0.0 { (audio.footstep / 4.0).clamp(0.3, 1.0) } else { 0.7 }; - events.push(Event::Step { at: body, run: horizontal > RUN_SPEED, level }); - } - } - } else { - seen.feet = Default::default(); - } - if in_water && !seen.in_water && -root.linear_velocity.y > SPLASH_SPEED && cooldown == 0.0 { - events.push(Event::Splash { at: body, speed: -root.linear_velocity.y }); - cooldown = SPLASH_COOLDOWN; - since_splash = 0.0; - } - } // Grind: the selector's grinding flag can lead the named grind state, and // `leaving` marks coming off before the state changes. let grinds = &skater.player_input.physical.grinds; @@ -330,8 +155,7 @@ pub(super) fn observe( } let unridden = board_unridden(state); let (wheels, surface) = if unridden { (0, 0) } else { wheel_surface(&physics) }; - let rolling = wheels > 0; - // The animation's push contact edge (the interim push cue and the state log's `push` column). + // The animation's push contact edge (the state log's `push` column). let push_edge = seen.started && audio.push && !seen.push; let state55 = skater.player_state.state_flags.get(55 - 52).copied().unwrap_or(false); let (planted, plant_edge) = push_plant(push_plant_on(), state55, audio.push, seen.planted, seen.push); @@ -452,12 +276,8 @@ pub(super) fn observe( cues.riding = Riding { board, speed: physics.riding.motion.ground_speed, - rolling, surface, - airborne: filtered == air, grinding: on_rail, - grind_surface: grinds.audio_surface_216, - sliding: state == PhysicalStateId::SlideGround as u32, braking: braking && speed > DRAG_MIN_SPEED, wheels, pushes, @@ -467,9 +287,8 @@ pub(super) fn observe( deck_material: skate_audio::player::state::material_of_tag(physics.riding.ground.part_audio_surfaces[2]), }; *seen = Seen { - started: true, launched, filtered, state, trick_seq, in_water, splash_cooldown: cooldown, - push: audio.push, planted, feet: seen.feet, air_from_foot, since_foot, braking, brake_time, brake_event, on_rail, since_splash, trace: Some(trace), - air_trick: if filtered == air { seen.air_trick.take() } else { None }, + started: true, + push: audio.push, planted, braking, brake_time, brake_event, on_rail, trace: Some(trace), air_time: seen.air_time, grind_family: seen.grind_family, grind_material: seen.grind_material, @@ -701,6 +520,15 @@ fn audio_state(physics: &GamePhysics, skater: &SkaterRuntime, f: AudioFrame) -> *f.region_at = (*f.region_at + 1) % 4; let body_impact: [f32; 6] = std::array::from_fn(|i| f.region_ring.iter().map(|r| r[i]).fold(0.0f32, |m, x| if x > m { x } else { m })); let xz = |v: [f32; 4]| if v[0].abs() - v[2].abs() >= 0.0 { v[0].abs() } else { v[2].abs() }; + // Water (`sub_827A1B78` → record `+172` bits 30 / 29 / 28 → `+811` / `+812` / `+813`): + // in water = the state's `+81` (Wipeout300's water contact); under the surface = in water and + // the state's surface height `+32` above the Y of PhysOut Skeleton `+128` / `+112` / `+32`. + // `+32` is part 1's physical pose translation (Skeleton+8128). UNCERTAIN: `+128` / `+112` are + // parts 15 / 19 (the toes) with a per-part local point (Skeleton+2560 / +2816, translation at + // +48) that the engine does not publish; the toes' pose translations stand in for them. + let in_water = flag(81); + let surface = p.state.surface_height_32; + let under_water = in_water && [1usize, 15, 19].iter().any(|&part| surface > record.pose[part][3][1]); AudioState { dt: f.dt, ground_speed: physics.riding.motion.ground_speed, @@ -794,519 +622,12 @@ fn audio_state(physics: &GamePhysics, skater: &SkaterRuntime, f: AudioFrame) -> body_slide_flag: fb.specific[1].current, body_impact, hands_on_deck: skate_audio::player::step_on::hands_on_deck(hands, f.state == 500, p.off_board.flag_311 != 0), + in_water, + under_water, + board_in_water: crate::physics::board_surface(physics) == 12, } } -/// A looping voice and the clip it plays. -#[derive(Default)] -struct Loop { - voice: Option<(VoiceId, Clip)>, -} -impl Loop { - fn stop(&mut self, voices: &mut Voices, fade: f32) { - if let Some((id, _)) = self.voice.take() { - voices.stop(id, fade); - } - } - /// Forget a voice that ended on its own (or was refused). - fn prune(&mut self, voices: &Voices) { - if self.voice.as_ref().is_some_and(|(id, _)| !voices.playing(*id)) { - self.voice = None; - } - } -} - -#[derive(Default)] -pub(super) struct Loops { - roll: Loop, - /// Grain and band the rolling loop plays, and how long it has played. - roll_band: Option<(&'static str, usize)>, - roll_held: f32, - /// Board speed low-passed for band choice (landings and pushes jump it). - roll_speed: f32, - /// Surface grain under the wheels, switched to only once stable (seams, - /// curbs and tile edges flicker between surfaces for a few ticks). - roll_grain: Option<&'static str>, - roll_candidate: Option<(&'static str, f32)>, - /// Seconds the wheels have been off the ground (or the board stopped). - roll_off: f32, - grind: Loop, - /// Grinding last tick (start patch on the rising edge), and the countdown - /// to the next re-fired scrape piece on non-metal grinds. - was_grinding: bool, - grind_piece_next: f32, - /// Countdown to the next shuffled powerslide / foot-drag piece. - slide_next: f32, - drag_next: f32, - wheels: Loop, - was_airborne: bool, - rng: u32, - /// Last member picked per (bank, record, layer), for sequential/shuffled layers. - patch_order: std::collections::HashMap<(&'static str, usize, usize), usize>, - /// Last start time per one-shot cue, for `cues::min_gap`. - last: std::collections::HashMap<&'static str, f64>, - /// Delayed retail layers: (due time, name, record, scale, position). - delayed: Vec<(f64, &'static str, &'static cues::Record, f32, Vec3)>, -} - -fn random(rng: &mut u32) -> f32 { - if *rng == 0 { - *rng = 0x2545_f491; - } - *rng ^= *rng << 13; - *rng ^= *rng >> 17; - *rng ^= *rng << 5; - (*rng >> 8) as f32 / (1u32 << 24) as f32 -} - -/// (soft sample scale, landing-layer scale) for a board touch-down at `impact` m/s. -fn board_down_levels(impact: f32) -> (f32, f32) { - let impact = if impact.is_finite() { impact.max(0.0) } else { 0.0 }; - (0.3 + 0.7 * (impact / 5.0).min(1.0), ((impact - 2.5) / 4.0).clamp(0.0, 1.0)) -} - -/// Speed band for `fraction` (0..1) with hysteresis around the current band. -fn band_for(fraction: f32, bands: usize, current: Option) -> usize { - let scaled = fraction.clamp(0.0, 1.0) * (bands - 1) as f32; - match current { - // Switch only a full band away: on ramps speed changes constantly, and - // every switch cross-fades two different recordings ("plays twice"). - Some(band) if (scaled - band as f32).abs() < 1.0 => band, - _ => (scaled.round() as usize).min(bands - 1), - } -} - -/// Play a retail layer now, or queue it for its retail offset (`Record::delay`). -#[allow(clippy::too_many_arguments)] -fn layer(player: &mut Player, loops: &mut Loops, now: f64, name: &'static str, record: &'static cues::Record, - scale: f32, at: Vec3, rng: &mut u32) { - if record.delay > 0.0 { - loops.delayed.push((now + f64::from(record.delay), name, record, scale, at)); - } else { - player.play_record(name, record, scale, at, rng, &mut loops.patch_order); - } -} - -struct Player<'a, 'w, 's> { - commands: Commands<'w, 's>, - library: &'a mut Library, - voices: &'a mut Voices, - assets: &'a mut Assets, - now: f64, -} -impl Player<'_, '_, '_> { - fn pick(&mut self, cue: &cues::Cue, rng: &mut u32) -> Option<(usize, Clip)> { - let index = cue.samples[(random(rng) * cue.samples.len() as f32) as usize % cue.samples.len()]; - self.library.sample(self.assets, cue.bank, index).map(|clip| (index, clip)) - } - /// One-shot with a small random pitch spread (+-4%). - fn once(&mut self, name: &str, cue: &cues::Cue, scale: f32, at: Vec3, rng: &mut u32) { - self.once_pitched(name, cue, scale, 1.0, at, rng); - } - /// Play retail patch `id` of `bank` the way its data describes: an id past - /// the records is a container that picks one record; each of the record's - /// groups is a layer, one member each, chosen by the group mode (0 random, - /// 1 in sequence, 2 shuffled without an immediate repeat), played if its - /// probability allows, at its gain +- its random range and a pitch between - /// 1/s and s for a pitch spread s > 1. (Semantics as documented in - /// upstream PRs #1/#4; implemented here independently.) - fn play_record(&mut self, name: &str, record: &cues::Record, scale: f32, at: Vec3, rng: &mut u32, - order: &mut std::collections::HashMap<(&'static str, usize, usize), usize>) { - let Some(patches) = self.library.patches(record.bank) else { return }; - let records = patches.records.len(); - let id = if record.id < records { - record.id - } else { - let Some(choices) = patches.containers.get(record.id - records).filter(|c| !c.is_empty()) else { return }; - choices[(random(rng) * choices.len() as f32) as usize % choices.len()] - }; - let Some(groups) = patches.records.get(id) else { return }; - let mut layers = Vec::new(); - for (index, group) in groups.iter().enumerate() { - let count = group.members.len(); - if count == 0 { - continue; - } - let state = order.entry((record.bank, id, index)).or_insert(usize::MAX); - let pick = match (count, group.mode) { - (1, _) => 0, - (_, 0) => (random(rng) * count as f32) as usize % count, - (_, 2) => { - // Shuffle: never the same member twice in a row. - let offset = 1 + (random(rng) * (count - 1) as f32) as usize % (count - 1); - if *state < count { (*state + offset) % count } else { (random(rng) * count as f32) as usize % count } - } - _ => if *state < count { (*state + 1) % count } else { 0 }, - }; - *state = pick; - let (sample, gain, gain_range, spread, probability) = group.members[pick]; - if random(rng) > probability { - continue; - } - let gain = (gain + gain_range * (2.0 * random(rng) - 1.0)).max(0.0); - let pitch = if spread > 1.0 && spread.is_finite() { - let (low, high) = (1.0 / spread, spread); - low + (high - low) * random(rng) - } else { - 1.0 - }; - layers.push((sample, gain, pitch)); - } - let mut played = Vec::new(); - for (sample, gain, pitch) in layers { - let Some(clip) = self.library.sample(self.assets, record.bank, sample) else { continue }; - let volume = record.level * scale.clamp(0.0, 1.0) * gain; - let mut play = Play::effect(volume, at); - play.pitch = pitch; - play.envelope = record.envelope; - if self.voices.play(&mut self.commands, &clip, play, self.now).is_some() { - played.push(sample); - } - } - if !name.is_empty() { - info!("AUDIO_CUE {name} {}:record{id} samples={played:?}", record.bank); - } - } - - /// One-shot at `pitch` (playback speed), with the same small random spread. - fn once_pitched(&mut self, name: &str, cue: &cues::Cue, scale: f32, pitch: f32, at: Vec3, rng: &mut u32) { - let Some((index, clip)) = self.pick(cue, rng) else { return }; - let volume = cue.level * scale.clamp(0.0, 1.0); - let mut play = Play::effect(volume, at); - play.pitch = pitch * (0.96 + 0.08 * random(rng)); - let played = self.voices.play(&mut self.commands, &clip, play, self.now).is_some(); - if !name.is_empty() { - info!("AUDIO_CUE {name} {}:{index} volume={volume:.2}{}", cue.bank, if played { "" } else { " (voice limit)" }); - } - } - /// While `active`, a random piece of `cue` every `interval` s, each fading in - /// over `fade` so overlapping pieces blend into one continuous sound. - #[allow(clippy::too_many_arguments)] - fn shuffle(&mut self, name: &str, next: &mut f32, active: bool, dt: f32, cue: &cues::Cue, (interval, fade): (f32, f32), scale: f32, at: Vec3, rng: &mut u32) { - if !active { - *next = 0.0; - return; - } - if *next == 0.0 { - info!("AUDIO_LOOP start {name} ({})", cue.bank); - } - *next -= dt; - if *next <= 0.0 { - if let Some((_, clip)) = self.pick(cue, rng) { - let mut play = Play::effect(cue.level * scale.clamp(0.0, 1.0), at); - play.pitch = 0.96 + 0.08 * random(rng); - play.fade_in = fade; - self.voices.play(&mut self.commands, &clip, play, self.now); - } - *next = interval * (0.8 + 0.4 * random(rng)); - } - } - fn start_loop(&mut self, slot: &mut Loop, clip: Clip, volume: f32, pitch: f32, at: Vec3, fade: f32) { - let mut play = Play::effect(volume, at); - play.looping = true; - play.pitch = pitch; - play.fade_in = fade; - if let Some(id) = self.voices.play(&mut self.commands, &clip, play, self.now) { - info!("AUDIO_LOOP start {}", clip.key); - slot.voice = Some((id, clip)); - } - } - /// Keep `slot` playing one of `cue`'s samples while `active`. - fn hold(&mut self, slot: &mut Loop, active: bool, cue: &cues::Cue, volume: f32, at: Vec3, rng: &mut u32) { - slot.prune(self.voices); - if !active { - slot.stop(self.voices, 0.06); - } else if let Some((id, _)) = &slot.voice { - self.voices.set(*id, volume, 1.0, Some(at)); - } else if let Some((_, clip)) = self.pick(cue, rng) { - self.start_loop(slot, clip, volume, 1.0, at, 0.03); - } - } -} - -pub(super) fn play( - commands: Commands, - mut cues: ResMut, - library: Option>, - mut voices: ResMut, - mut assets: ResMut>, - mut loops: Local, - time: Res>, - menu: Option>, - replay: Res, - native: Option>, -) { - let _timing = super::timing::scope(&super::timing::PLAY); - let events = std::mem::take(&mut cues.events); - // The native granular bed (grain_bed.rs) replaces the interim speed-band loop when it runs. - let native_bed = native.as_deref().is_some_and(|n| n.bed.is_some()); - // The native player components (player_audio.rs) replace the metal GRINDS loop, the - // sense_of_speed bed cue and the foot-drag pieces when they run. - let native_player = native.as_deref().is_some_and(|n| n.player.as_ref().is_some_and(|p| p.components)); - // The native board contacts (Splice) replace the interim pop layers and landing impacts. - let native_contacts = native.as_deref().is_some_and(|n| n.player.as_ref().is_some_and(|p| p.components && p.contacts_on)); - // SFXObj_Wheels (player_audio.rs) replaces the interim air wheel spin. - let native_wheels = native.as_deref().is_some_and(|n| n.player.as_ref().is_some_and(|p| p.components && p.wheels_on)); - // The native Tricks component (Class_Flips) replaces the interim flip whoosh; the native - // rolling layers / rattle replace the interim bed's PatchBank_Rolling_Surfaces / Rolling_Rattles. - let native_part = |f: fn(&super::player_audio::PlayerAudio) -> bool| native.as_deref().is_some_and(|n| n.player.as_ref().is_some_and(|p| p.components && f(p))); - let native_tricks = native_part(|p| p.tricks_on); - // SFXObj_OffBoard's footsteps (on foot, and the push plants on the board) replace the interim - // steps and push; the Contacts' hands-on-deck sounds (1124 / 1125 / 1126) replace the interim - // "foot on deck" 1126 at a board put-down. - let native_footsteps = native_part(|p| p.footsteps_on); - let native_rolling = native_part(|p| p.rolling_on); - let native_rattle = native_part(|p| p.rattle_on); - // The body poster (`player::contacts`, `sub_824BC188`) plays the bail's body impacts through - // the collision manager; the interim bail_hit / bail_soft..hard cues stay silent then. - let native_body = native_part(|p| p.body_impacts_on()); - let Some(mut library) = library else { return }; - // Cues raised while silenced are dropped, never played late in a burst. - if super::silenced(menu.as_deref(), &replay) { - return; - } - let loops = &mut *loops; - let mut rng = loops.rng; - let dt = time.delta_secs().clamp(0.0, 0.25); - let now = time.elapsed_secs_f64(); - let mut player = Player { commands, library: &mut library, voices: &mut voices, assets: &mut assets, now }; - // Layers whose retail offset has come due (frame-granular, <= one frame late). - let due: Vec<_> = loops.delayed.iter().filter(|d| d.0 <= now).cloned().collect(); - loops.delayed.retain(|d| d.0 > now); - for (_, name, record, scale, at) in due { - player.play_record(name, record, scale, at, &mut rng, &mut loops.patch_order); - } - for event in events { - // Safety net: a cue never repeats faster than its minimum gap. - let name = event.name(); - if loops.last.get(name).is_some_and(|t| now - t < f64::from(cues::min_gap(name))) { - continue; - } - loops.last.insert(name, now); - match event { - // Retail's measured layers per moment (cues.rs): pop crack + tail - // impact + deck knock; flip whoosh + feet catching the board; the - // wheels-down set + an impact family on landing. - Event::Pop(_) if native_contacts => {} - Event::Pop(at) => { - for record in cues::POP { - layer(&mut player, loops, now, "pop", record, 1.0, at, &mut rng); - } - let tail = &cues::POP_TAIL[(random(&mut rng) * 3.0) as usize % 3]; - layer(&mut player, loops, now, "pop_tail", tail, 1.0, at, &mut rng); - } - Event::Flip(at) => { - if !native_tricks { - player.once("flip", &cues::FLIP, 1.0, at, &mut rng); - } - // The feet catching the board are the native foot taps (`player::contacts`, - // 1112..1120) when the native contacts run. - if !native_contacts { - layer(&mut player, loops, now, "catch", &cues::CATCH, 1.0, at, &mut rng); - } - } - // RETIRED from the native path (user-confirmed 19:19 build): the native contacts play the - // landing impact pair, touchdowns and the shoe scuffs (`sk8_foley` 94 / 95, which the - // interim LAND_CLOTH was); nothing interim plays at a landing then. - Event::Land { .. } if native_contacts => {} - Event::Land { at, impact } => { - // Retail's board contact rule (cues.rs): hollow (wood) table or - // normal, each contact kind with its retail chance, variant by - // impact, gentle loudness rise with board speed. - let riding = cues.riding; - let hollow = cues::hollow(riding.surface); - let table = if hollow { &cues::LAND_HOLLOW } else { &cues::LAND_NORMAL }; - let variant = cues::land_tier(impact); - let scale = cues::land_scale(riding.speed); - let mut kinds: Vec = (0..4).filter(|&k| random(&mut rng) < cues::LAND_KIND_CHANCE[k]).collect(); - if kinds.is_empty() { - kinds.push(1); - } - info!("AUDIO_EVENT land impact={impact:.2} variant={variant} hollow={hollow} kinds={kinds:?} scale={scale:.2}"); - for kind in kinds { - layer(&mut player, loops, now, "land_impact", &table[kind][variant], scale, at, &mut rng); - } - layer(&mut player, loops, now, "land_cloth", &cues::LAND_CLOTH, scale, at, &mut rng); - player.play_record("land", &cues::LAND, scale, at, &mut rng, &mut loops.patch_order); - } - Event::BoardDown { at, impact } => { - // Set down gently: foot on deck, knock, light wheel touch. - // Jumped on (caveman): louder, plus the landing set. - let (soft, heavy) = board_down_levels(impact); - if !native_contacts { - player.play_record("board_down", &cues::BOARD_DOWN, soft, at, &mut rng, &mut loops.patch_order); - } - // With the native contacts the wheels' touchdowns (Contacts sets) and the feet's - // deck taps (1112..1120) play from the retail mechanism; the interim knock and - // touch would double them. (The foot-on-deck set stays interim: retail's step-on - // `sub_824B85B0` needs Skeleton+602/+603, which the engine does not publish.) - if !native_contacts { - player.play_record("board_down_knock", &cues::BOARD_DOWN_KNOCK, soft, at, &mut rng, &mut loops.patch_order); - player.play_record("board_down_touch", &cues::BOARD_DOWN_TOUCH, soft, at, &mut rng, &mut loops.patch_order); - } - if heavy > 0.0 && !native_contacts { - player.play_record("board_down_heavy", &cues::LAND, heavy, at, &mut rng, &mut loops.patch_order); - } - } - Event::Bail { .. } if native_body => {} - Event::Bail { at, speed } => { - let tier = cues::bail_tier(speed); - let scale = [0.6, 0.8, 1.0][tier]; - player.play_record("bail_hit", &cues::BAIL_HIT, scale, at, &mut rng, &mut loops.patch_order); - let cue = cues::Cue { samples: cues::BAIL_TIERS[tier], ..cues::BAIL }; - player.once(["bail_soft", "bail_medium", "bail_hard"][tier], &cue, 1.0, at, &mut rng); - } - Event::Push(_) | Event::Step { .. } if native_footsteps => {} - Event::Push(at) => player.once("push", &cues::PUSH, 1.0, at, &mut rng), - Event::Step { at, run, level } => { - player.once(if run { "run_step" } else { "step" }, if run { &cues::RUN_STEP } else { &cues::STEP }, level, at, &mut rng) - } - Event::Splash { at, speed } => { - let scale = 0.4 + 0.6 * (speed / 8.0).min(1.0); - player.play_record("splash", &cues::SPLASH, scale, at, &mut rng, &mut loops.patch_order); - } - } - } - - let r = cues.riding; - let fraction = (r.speed.abs() / FULL_ROLL_SPEED).clamp(0.0, 1.0); - - // Rolling: the grain band recorded at about this speed. The recordings get - // louder with speed by themselves, so the level only fades in from a stop. - let rolling = r.rolling && !r.grinding && r.speed.abs() > 0.3; - loops.roll_speed += (r.speed.abs() - loops.roll_speed) * (1.0 - (-dt / ROLL_SPEED_SMOOTHING).exp()); - let touching = cues::grain_for(r.surface); - // Off the ground for a moment (air, coping, ramp transition): stop the - // loop, and on touching down start straight on the surface underneath — - // the stability wait below is only for surface changes while rolling. - loops.roll_off = if rolling { 0.0 } else { loops.roll_off + dt }; - if loops.roll_off > ROLL_OFF_STOP && loops.roll.voice.is_some() { - loops.roll.stop(player.voices, 0.1); - loops.roll_band = None; - loops.roll_grain = None; - loops.roll_candidate = None; - } - let grain = match (loops.roll_grain, loops.roll_candidate) { - _ if loops.roll.voice.is_none() => touching, - (None, _) => touching, - (Some(current), _) if current == touching => current, - (Some(current), Some((candidate, held))) if candidate == touching => { - if held + dt >= SURFACE_HOLD { touching } else { loops.roll_candidate = Some((candidate, held + dt)); current } - } - (Some(current), _) => { loops.roll_candidate = Some((touching, 0.0)); current } - }; - if Some(grain) != loops.roll_grain || grain == touching { - loops.roll_candidate = None; - } - loops.roll_grain = Some(grain); - let bands = player.library.grain_bands(grain); - loops.roll.prune(player.voices); - loops.roll_held += dt; - if native_bed && loops.roll.voice.is_some() { - loops.roll.stop(player.voices, 0.1); - loops.roll_band = None; - } - if bands > 0 && !native_bed { - let current = loops.roll_band.filter(|(g, _)| *g == grain).map(|(_, b)| b); - let wanted = band_for((loops.roll_speed / FULL_ROLL_SPEED).clamp(0.0, 1.0), bands, current); - // A band plays for at least BAND_HOLD before the next speed change. - let band = match current { - Some(band) if loops.roll_held < BAND_HOLD && loops.roll.voice.is_some() => band, - _ => wanted, - }; - let volume = if rolling { cues::ROLL_LEVEL * (r.speed.abs() / 1.5).min(1.0) } else { 0.0 }; - // Within a band, follow speed with a slight pitch change (+-6%). - let scaled = (loops.roll_speed / FULL_ROLL_SPEED).clamp(0.0, 1.0) * (bands - 1) as f32; - let pitch = 1.0 + 0.06 * (scaled - band as f32).clamp(-1.0, 1.0); - if rolling && loops.roll_band != Some((grain, band)) { - loops.roll_held = 0.0; - loops.roll.stop(player.voices, 0.15); - if let Some(clip) = player.library.grain(player.assets, grain, band) { - info!("AUDIO_EVENT rolling surface={} grain={grain} band={band}", r.surface); - player.start_loop(&mut loops.roll, clip, volume, pitch, r.board, 0.15); - loops.roll_band = Some((grain, band)); - } - } else if let Some((id, _)) = &loops.roll.voice { - player.voices.set(*id, volume, pitch, Some(r.board)); - } - } - - // Grinds, powerslides and foot drag loop while the state lasts. Braking is - // reported in pulses, so it is held briefly. - let dragging = r.braking && !r.airborne && !r.grinding; - // Riding bed: retail's continuous small sounds while rolling (cues.rs), - // each source a random process at its measured rate x board speed. - let bed = cues::bed_rate(r.speed) * if r.rolling && !r.grinding && !r.airborne { 1.0 } else { 0.0 }; - if bed > 0.0 { - for (record, rate) in cues::BED_RECORDS { - if native_contacts && cues::bed_record_native(record) { - continue; - } - if random(&mut rng) < rate * bed * dt { - player.play_record("", record, 1.0, r.board, &mut rng, &mut loops.patch_order); - } - } - for (cue, rate) in cues::BED_CUES { - // sense_of_speed: SenseOfSpeed rattle / wind; Seams_Bank: Class_Seams; Rolling_Rattles / - // PatchBank_Rolling_Surfaces: the native rattle (posted on pushes) and Class_rolling's - // continuous layers replace these random one-shots. - if native_player && matches!(cue.bank, "sense_of_speed" | "Seams_Bank") - || native_rolling && cue.bank == "PatchBank_Rolling_Surfaces" - || native_rattle && cue.bank == "Rolling_Rattles" - { - continue; - } - if random(&mut rng) < rate * bed * dt { - player.once_pitched("", cue, 1.0, 1.0, r.board, &mut rng); - } - } - } - - // Grinds: retail's start patch, then on metal the GRINDS loop; on ledges - // and other surfaces short scrape pieces re-fired every ~63 ms. - let metal = cues::metal(r.grind_surface); - // With the native contacts the collision manager plays the grind start (board / truck against - // the grind material); the interim start and scrape pieces (Skate_Collisions 954 / 955 are the - // skin / denim body-impact sounds, not grind layers) stay silent. - if r.grinding && !loops.was_grinding && !native_contacts { - let start = if metal { &cues::GRIND_METAL_START } else { &cues::GRIND_START }; - player.play_record("grind_start", start, 0.5 + 0.5 * fraction, r.board, &mut rng, &mut loops.patch_order); - loops.grind_piece_next = cues::GRIND_PIECE_INTERVAL; - } - loops.was_grinding = r.grinding; - player.hold(&mut loops.grind, r.grinding && metal && !native_player, &cues::GRIND_METAL, cues::GRIND_METAL.level * (0.5 + 0.5 * fraction), r.board, &mut rng); - if r.grinding && !metal && !native_contacts { - loops.grind_piece_next -= dt; - if loops.grind_piece_next <= 0.0 { - loops.grind_piece_next += cues::GRIND_PIECE_INTERVAL; - player.play_record("", &cues::GRIND_PIECES, 0.5 + 0.5 * fraction, r.board, &mut rng, &mut loops.patch_order); - } - } - let sliding = r.sliding && !r.grinding; - // The native Class_wheels_skid / Class_Squeaks (player_audio.rs) replace the interim pieces. - player.shuffle("powerslide", &mut loops.slide_next, sliding && !native_player, dt, &cues::POWERSLIDE, cues::POWERSLIDE_SHUFFLE, fraction, r.board, &mut rng); - player.shuffle("foot_drag", &mut loops.drag_next, dragging && !native_player, dt, &cues::FOOT_DRAG, cues::FOOT_DRAG_SHUFFLE, 0.3 + 0.7 * fraction, r.board, &mut rng); - - // Wheels spin down after take-off; cut when they touch again. - loops.wheels.prune(player.voices); - if r.airborne && !loops.was_airborne && fraction > 0.1 && !native_wheels { - loops.wheels.stop(player.voices, 0.05); - if let Some(clip) = player.library.wheels(player.assets, cues::AIR_WHEELS.0) { - let mut play = Play::effect(cues::AIR_WHEELS.1 * fraction, r.board); - play.fade_in = 0.05; - if let Some(id) = player.voices.play(&mut player.commands, &clip, play, player.now) { - loops.wheels.voice = Some((id, clip)); - } - } - } else if !r.airborne { - loops.wheels.stop(player.voices, 0.05); - } else if let Some((id, _)) = &loops.wheels.voice { - player.voices.set(*id, cues::AIR_WHEELS.1 * fraction, 1.0, Some(r.board)); - } - loops.was_airborne = r.airborne; - loops.rng = rng; -} - #[cfg(test)] mod tests { use super::*; @@ -1370,56 +691,6 @@ mod tests { assert_eq!(r[3], f32::from_bits(0x3A83_126F), "away from the surface: the floor 0.001"); } - #[test] - fn random_stays_in_unit_range() { - let mut rng = 0; - for _ in 0..10_000 { - let x = random(&mut rng); - assert!((0.0..1.0).contains(&x)); - } - } - - #[test] - fn speed_bands_have_hysteresis() { - assert_eq!(band_for(0.0, 6, None), 0); - assert_eq!(band_for(1.0, 6, None), 5); - assert_eq!(band_for(2.0, 6, None), 5); - // 0.5 of 6 bands = 2.5: stays on the current band near the boundary. - assert_eq!(band_for(0.5, 6, Some(2)), 2); - assert_eq!(band_for(0.5, 6, Some(3)), 3); - assert_eq!(band_for(0.75, 6, Some(2)), 4); - } - - #[test] - fn one_strike_per_step_with_a_learned_resting_height() { - let mut foot = FootStrike::default(); - let dt = 1.0 / 60.0; - let mut strikes = 0; - // Ankle rests 0.09 m above the ground; each step lifts it ~0.12 m. - for step in 0..4 { - for tick in 0..30 { - let phase = tick as f32 / 30.0; - let lift = if phase < 0.5 { (phase * std::f32::consts::TAU).sin().abs() * 0.12 } else { 0.0 }; - strikes += foot.update(Some(0.09 + lift + step as f32 * 0.001), dt) as u32; - } - } - assert_eq!(strikes, 4); - // Standing still or losing the ground never strikes. - let mut still = FootStrike::default(); - assert!((0..120).all(|_| !still.update(Some(0.09), dt))); - assert!(!still.update(None, dt)); - } - - #[test] - fn board_down_follows_the_impact() { - assert_eq!(board_down_levels(0.0), (0.3, 0.0)); - let (soft, heavy) = board_down_levels(2.0); - assert!(soft > 0.3 && soft < 1.0 && heavy == 0.0); - let (soft, heavy) = board_down_levels(6.5); - assert_eq!((soft, heavy), (1.0, 1.0)); - assert_eq!(board_down_levels(f32::NAN), (0.3, 0.0)); - } - #[test] fn grind_states_are_the_six_grinds() { assert!(grinding(PhysicalStateId::GrindBoardslide as u32)); diff --git a/crates/skate-game/src/game_audio/state_log.rs b/crates/skate-game/src/game_audio/state_log.rs index 9309bdfd..f324c141 100644 --- a/crates/skate-game/src/game_audio/state_log.rs +++ b/crates/skate-game/src/game_audio/state_log.rs @@ -1,6 +1,6 @@ //! `SKATE_AUDIO_STATE_LOG=`: record the player's per-frame audio situation while playing, as //! one TSV row per physics/audio frame in the columns of the e2e scenarios -//! (`.claude/skills/aems-port/tools/e2e/scenarios.py`) plus the elapsed time and the board / COM +//! (`tools/audio-e2e/scenarios.py`) plus the elapsed time and the board / COM //! world positions, so real play can be cut into windows and replayed headless into our native //! stack and the PoC oracle (`scenarios.py --from-log`). Off (no cost) when unset; buffered, //! flushed every 6 rows (~100 ms). diff --git a/crates/skate-game/src/game_audio/voices.rs b/crates/skate-game/src/game_audio/voices.rs index 1e76c34e..721676dc 100644 --- a/crates/skate-game/src/game_audio/voices.rs +++ b/crates/skate-game/src/game_audio/voices.rs @@ -46,6 +46,7 @@ pub(crate) struct Play { /// linear interpolation (retail's per-layer fader curves); None = flat. pub envelope: Option<&'static [(f32, f32)]>, } +#[cfg(test)] impl Play { pub(crate) fn effect(volume: f32, position: Vec3) -> Self { Self { category: Category::Effects, volume, pitch: 1.0, position: Some(position), looping: false, fade_in: MIN_FADE, envelope: None } @@ -103,12 +104,12 @@ pub(crate) struct Voices { /// last `sync`, so one-shots can start at their final level. mix: [f32; 2], silenced: bool, - /// Scale on every interim voice: 1 normally; `1 / cues::RETAIL_SCALE` while the native player - /// sounds run (`native::follow_volume`), so the retail-measured interim cues sit at their - /// measured level against the native voices (which play at retail level) instead of the - /// interim mix's rolling-anchored ×2. + /// Scale on every Bevy voice: 1 normally; `1 / RETAIL_SCALE` while the native player sounds run + /// (`native::follow_volume`), so the retail-measured layers (zone beds, location sets, + /// crossfades) sit at their measured level against the native voices (which play at retail + /// level) instead of the old rolling-anchored ×2. pub(crate) scale: f32, - /// True while the native host runs (`native::follow_volume`): every interim voice then gets + /// True while the native host runs (`native::follow_volume`): every Bevy voice then gets /// [`native_fold_gain`], so it reaches the ears as a native voice of the same per-voice gain /// would (Pan2D1 + the output stage's stereo fold) instead of Bevy's own panning. pub(crate) native_fold: bool, @@ -116,6 +117,11 @@ pub(crate) struct Voices { view: Option, } +/// Measured retail level → Bevy-voice level of the measured world layers (the ambience beds): +/// ×2, anchored in 2026-10-01 to the former interim rolling loop (its level 0.15 / retail rolling +/// p90 0.065, rounded down). [`Voices::scale`] takes it back out while the native player runs. +pub(super) const RETAIL_SCALE: f32 = 2.0; + #[derive(Clone, Copy, Debug)] struct ListenerView { to_local: bevy::math::Affine3A, @@ -128,7 +134,7 @@ fn fold_row(row: &[f32; 6]) -> (f32, f32) { (G707 * row[0] + 0.5 * row[1] + 0.5 * row[3], G707 * row[2] + 0.5 * row[1] + 0.5 * row[4]) } -/// Gain that makes an interim (Bevy) voice reach the ears with the power a native voice of the +/// Gain that makes a Bevy voice (the measured world layers) reach the ears with the power a native voice of the /// same per-voice gain gets through Pan2D1 and the output stage's stereo fold (aems-voice-graph-spec /// §4.9, §6.3; `examples/pan_fold_probe.rs`). /// @@ -397,7 +403,7 @@ mod tests { // Side: native 0.72 / 0.26 (L/Ls pair), rodio 1.0 / 0.5 (power 1.25). let side = native_fold_gain(1, Some(Vec3::new(10.0, 0.0, 0.0)), ears); assert!(side > 0.6 && side < 0.75, "{side}"); - // Off by default: the interim path is unchanged without the native host. + // Off by default: Bevy voices are unchanged without the native host. assert_eq!(Voices::default().fold_gain(1, None), 1.0); } diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs index d17f2f14..ed55e3f8 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -408,15 +408,11 @@ impl Plugin for PhysicsPlugin { } } -/// Height of each animated foot (left, right) above the ground line test under -/// it, with that line's surface tag; None where the line found no ground. -/// Read-only, for game_audio footsteps. -pub(crate) fn foot_clearance(skater: &SkaterRuntime) -> [Option<(f32, u32)>; 2] { - let input = biped_ground::services::feet_input(skater); - std::array::from_fn(|i| { - let line = &input.lines[i]; - line.valid.then(|| (input.world_foot_pairs[i][0][1] - line.position[1], line.surface)) - }) +/// Collision+16 low 16 bits: the board's surface vote (82C08818, 12 when a board +/// contact is water), as respawn reads it. Read-only, for game_audio (the audio +/// record's `+813`, the board in water). +pub(crate) fn board_surface(physics: &GamePhysics) -> u32 { + ground_runtime::active_surface(&physics.riding, &physics.board) & 0xffff } pub(crate) fn advance( diff --git a/docs/hails-additions/11-audio.md b/docs/hails-additions/11-audio.md index f70f4c5a..9c1406f2 100644 --- a/docs/hails-additions/11-audio.md +++ b/docs/hails-additions/11-audio.md @@ -3126,8 +3126,8 @@ CALL IT ALMOST OR EXACTLY RETAIL"; the footsteps while running / walking "sounde - Bodyslide: on 33 / 38, 1.8 voices, Σg² 0.006. - Collision-manager posts: mean 137, p50 133. - Of these, ids 968 / 969 (7.9 + 3.5 per bail) are world / ped collisions, not the rider's body. -- *Ours, rendered: NOT DONE.* The headless e2e build (own target `.local/check-target`) was stopped mid-compile for a PC - restart. To finish it: +- *Ours, rendered:* done after the restart; results in "Bail density: ours rendered" below. The original note: + the headless e2e build (own target `.local/check-target`) was stopped mid-compile for a PC restart. To finish it: - The scenario is already cut: `.local/audio-re/check0847/e2e/real_084712.tsv` (`scenarios.py --from-log … --cut r0-999999`). - Render: `CARGO_TARGET_DIR=.local/check-target E2E_DIR=.local/audio-re/check0847/e2e E2E_FPS=60 cargo test -q -p @@ -3138,6 +3138,158 @@ CALL IT ALMOST OR EXACTLY RETAIL"; the footsteps while running / walking "sounde (ids 1030–1032). If ours lacks them, compare `skate_events::region_impacts` (Δv · normal · mass × 10) against the recomp before changing the poster. +#### Bail density: ours rendered, the impact scale checked (2026-10-03, analysis only; no code change) + +Session 084712 rendered headless through the e2e path (target `.local/fma-target`, `E2E_FPS=60`; `E2E_DIR` must be an +**absolute** path, a relative one fails with "path not found" because the test runs from the crate folder). The +state log has 0 malformed lines. `bails_cmp.py` took the time label from a missing `ms` column; it now uses row / 60. + +| per bail, window −0.5…+2.5 s | the recomp (38 bails) | ours bail 1 (18.2 s) | ours bail 2 (36.2 s) | +|---|---|---|---| +| Skate_Collisions voices | 207.7 | 46 | 45 | +| Skate_Collisions Σg² | 4.68 | 1.89 | 1.53 | +| Skate_Collisions peak | p50 0.61 | 0.72 | 0.72 | +| three banks (Collisions + Metal + Bodyslide) Σg² | ≈ 5.1 | 1.95 | 1.84 | +| collision-manager posts | 137 (p50 133) | ≈ 30 (estimate: voices / 1.5, the recomp's voices per post) | ≈ 30 | +| tier-2 body ids 1030 / 1031 / 1032 (+ torso 951) | 1.2 / 2.5 / 1.3 (+ 1.8) | 0 | 0 | + +**The gap is confirmed, and it is wider than tier 2.** Ours plays no tier-1 or tier-2 body id at all except the +head's 953 (3 in bail 2). The recomp's posts by material and tier (`fixes/bail_splc.py`, nine sessions): + +| region material (bands: tier 1 > b8, tier 2 > b0; floor b12) | tier 0 | tier 1 | tier 2 | ours (both bails, voices) | +|---|---|---|---|---| +| head 97 (0.05 / 0.375; 0.002) | 952: 2.9 | 953: 3.1 | 1032: 1.3 | 952: 3, 953: 3 | +| torso 98 (0.1 / 0.25; 0.002) | 949: 3.9 | 950: 3.2 | 951: 1.8 | 949: 7 | +| legs 99 (0.1 / 0.35; 0.002) | 947: 13.0 | 948: 6.0 | 1031: 2.5 | 947: 9 | +| arms 100 (0.2 / 0.65; 0.005) | 956: 19.2 | 957: < 0.6 | 1030: 1.2 | 956: 6 | +| skin 107 / denim 108–109 (cloth, tier 0) | 954: 20.7 / 955: 25.4 | | | 954: 7 / 955: 25 | +| concrete 3 (1.0 / 1.85; 0.12) | 958: 8.7 | 1047: none | 991: none | 958: 0 | + +In the recomp 40–60 % of the head / torso / leg posts are tier 1 or 2, so its region impacts are often above 0.1 and +several times per bail above 0.25–0.65. Ours never exceeds 0.21 in the whole log (bail 1 max 0.086). + +**What scale retail expects: [0, 1], the scale we already have.** The thresholds are not in an AEMS program. The poster +picks the id by the collision material's impact bands (the AudioSurface records, `impact_band`), and the AEMS program +only plays the id. The body bands sit inside [0, 1] (floors 0.002–0.005, tier 2 at 0.25–0.65). The concrete record's +tier 1 / 2 (> 1.0 / > 1.85) cannot be reached by a clamped region impact, and the recomp never posts 1047 / 991 in a +bail, while 958 (> 0.12) plays 8.7 times. So retail's impacts are clamped to [0, 1] too. Re-read of `sub_82BD60C8`'s +region loop (0x82BD68F0…0x82BD69D4) matches `skate_events::region_impacts` term by term: +- part = SkeletonCollision `+1200 + 4i` (−1 = none); +- Δv = SkeletonState `+4048 + 16·part`; +- the region normal is `+1008 + 16i`; +- mass = SkeletonState `+4560 + 4·part`; +- × config `+164` (10); +- floor 0x82063A48 (0.001), then clamp to [0, 1]; +- written to Collision `+80 + 4i`, slide speed to `+112 + 4i`. + +`+4560` is the raw bone-box product that `82BEBAA8` normalises (`SkeletonAnimationMasses::part_weights`). Same +indices, same constants. **So the poster and the impact formula are not the gap. The input is:** ours has small +per-step velocity changes of the ragdoll parts along the contact normal. Rough sizes (full-extent bone boxes ≈ 0.003 +arm … 0.015 torso m³): tier 2 needs Δv·n per 1/60 s step of about 1.7 m/s (torso) to 4–5 m/s (head, legs). Ours peaks +near 2 m/s, though the regions touch the ground for 1–2 s per bail (58–114 contact frames, sliding at 3–7 m/s). In +our ragdoll the parts land and slide smoothly. Retail's give sharp per-step stops and bounces. + +**Not changed:** the mechanism on the audio side is already retail. Scaling the impacts or the bands would be a guessed +weighting. The cause is in the physics (the ragdoll's contact response / part velocities during a bail), not in +`contacts::body`. + +Caveats: +- Two bails only (from off-board air at 6.5 m/s, and from 500 → 300 at 7.6 m/s after an air). The recomp's 38 are of + mixed heights. +- The recomp's post count is inflated by its frame rate. It runs the poster at ~345 fps, so the 15-frame body cooldown + is ≈ 43 ms there, against 0.5 s on the 30 fps console. + +**Open (needs a decision or data):** +1. Measure retail's impacts directly: a recomp hook logging Collision `+80..+100` (or the audio state `+496..+516`) + and the touched parts' SkeletonState `+4048` Δv per physics step during bails. This would run alongside the + planned `PLAY_TRACE_AUDIOX.bat bail` session (the user's session). It would say whether retail's per-step Δv is + really 2–3× ours, and whether that comes from the contact solve or from the bail drives. + *Hook built 2026-10-03 (recomp `research-hooks`, category `audiox`; session pending).* Kinds `BAILSTEP` / `BAILREG` + log per pass of `sub_82BD60C8`, only during the local player's bails. For each of the **8** regions with a part they + log the impact the game wrote (Collision `+80 + 4i`; the loop runs 8 times, so `+80..+108`), |Δv·n|, the signed + normal velocity before and after, |Δv|, the normal, the mass, the slide speed and the tag. They also log the update + interval. The local block comes from the skater-entry update `sub_827A1B78`: entry `+152` bit 31, X = + `[[character + 1808] + 1800]`. Found on the way: the SkeletonState velocities are finite differences × 60 + (`sub_82BEBD28`, constant `0x822F860C` = 60; `+3216` position, `+3632` velocity, `+4048` Δv), so Δv is per update and + assumes 1/60 s. Summary: `.claude/skills/recomp-research/tools/bail_impacts.py `. + *First session (the user's `audiox_bail_20261003_094336`, 3 min, 0 malformed lines): no BAIL\* lines at all.* Cause: + the hook accepted only entries inside the global table `*(0x83083C38) + 0x2F070`, but neither caller of + `sub_827A1B78` passes a table entry. The per-skater loop `sub_827A11B0` (return address `0x827A13FC`) passes a + stack array (its `r1 + 1064 − 152`, stride 544), and `sub_827A1030` (return `0x827A10A0`) a single zeroed stack entry + with r6 = 1. So the "local entry" test never held, BAILLOCAL never logged and no pass matched the local X. Entry + `+152` bit 31 is the r6 argument (in the loop: the skater's controller test). Fixed in the recomp (uncommitted): the + hook accepts only the loop's call (`lr == 0x827A13FC`) with bit 31 set, and a bail is active when the entry's bits + or `+676` / `+677` of a GREC-local audio state are set. A rate-limited `BAILCAND` line (7 fields) shows the lookup's + raw inputs (first 32 calls; each new X of the output pass), so a failed lookup is visible in the next trace. + *What the session shows without the BAIL\* lines* (`.local/research/bail/bail_session_summary.py`): + - **Two GREC owners pass the local test** in this session (board objects `40C33020` from the start, `40C34020` from + 22.9 s), each once per frame. FIRSTHIT kept one set of last values, so the +676 / +677 bytes "alternated" 1 / 0 + every call: one owner was bailing and the other was not. `40C34020` bailed once (154.7 s) with B+16 still 0, so it + is not the local skater. `40C33020` is. FIRSTHIT now keeps its last values per state. Earlier sessions had one + owner. GREC / GRECX / TREAT / SEAMPAT readers should filter by owner (GREC's published local state alternates + too, so the TREAT / SEAMPAT local-mask bit 2 is unreliable when a second owner exists). + - **10 local bails**, 3.6–7.9 s from +676 rising to clearing. +677 (end) rises 1.4–4.0 s in and clears ≈ 1.0 s before + +676 clears. + - **B+16 rises in the same frame as +676** in all 10 bails, and clears 33–34 ms before +677 clears. So the first-hit + byte is "bail in progress" for the whole bail, not a single first contact. Its rising edge is the bail start. + - **B+24 is not 0.0000.** It resets to 0 at the bail start and then rises monotonically in steps about 17 ms apart + (8–41 steps per bail) to a final value of 0.42–1.0 (clamped at 1.0 in 3 bails). It reaches that value 0.5–4.0 s in + and holds it until the next bail. It is an accumulated bail strength, not a per-hit value. + - **Body posts per bail** (SPLC, window bail start −0.5 s … +676 clear; ids by tier as in the table above): + + | bail (s) | posts | tier 0 / 1 / 2 | 1030 / 1031 / 1032 | 951 | 958 / 1047 / 991 | B+24 final | + |---|---|---|---|---|---|---| + | 39.8 | 149 | 106 / 27 / 16 | 2 / 7 / 4 | 3 | 4 / 2 / 0 | 0.68 | + | 81.2 | 67 | 55 / 8 / 4 | 1 / 0 / 2 | 1 | 8 / 1 / 0 | 0.42 | + | 91.9 | 49 | 35 / 9 / 5 | 0 / 2 / 2 | 0 | 2 / 3 / 1 | 0.56 | + | 101.8 | 108 | 74 / 17 / 17 | 5 / 6 / 4 | 1 | 11 / 3 / 1 | 1.00 | + | 121.1 | 45 | 29 / 10 / 6 | 1 / 3 / 0 | 2 | 0 / 0 / 0 | 1.00 | + | 136.0 | 61 | 38 / 13 / 10 | 1 / 3 / 2 | 0 | 3 / 4 / 4 | 0.96 | + | 144.4 | 67 | 46 / 13 / 8 | 0 / 6 / 1 | 1 | 2 / 0 / 0 | 0.64 | + | 157.4 | 46 | 32 / 9 / 5 | 0 / 2 / 2 | 1 | 0 / 0 / 0 | 0.55 | + | 168.7 | 43 | 27 / 7 / 9 | 6 / 0 / 2 | 0 | 1 / 4 / 1 | 0.82 | + | 184.2 | 79 | 52 / 16 / 11 | 2 / 4 / 2 | 1 | 3 / 6 / 2 | 1.00 | + + Mean per bail: 71 posts; 1030 / 1031 / 1032 = 1.8 / 3.3 / 2.1 (all 74 posts of those ids fall in the 10 bails); + head 952 / 953 = 2.4 / 3.4, torso 949 / 950 / 951 = 2.1 / 2.1 / 1.0, legs 947 / 948 = 7.1 / 4.3, arms 956 / 957 = + 8.8 / 0.8, skin 954 10.8, denim 955 14.8, concrete 958 3.4. **Concrete tier 1 / 2 (1047 / 991) do occur here: 2.3 / + 0.9 per bail**, unlike the nine earlier sessions, so some impacts against that surface went above 1.0 / 1.85. Since + the region impacts are clamped to [0, 1], those posts come from a different impact value than the clamped region + impact. Open: which (e.g. the deck or another surface's bands). + *Measured with the fixed hook (2026-10-03, scripted background runs, 0 malformed lines).* Script + `.local/recomp/scripts/bail_ledge.txt`: the Super-Ultra Mega-Park spawn → off the board → a session marker → per attempt + go to the marker, run off the drop and use the bail input. Runs `bailrun_t2_100040` (2 bails), `bailrun_ok_100226` (4 full + bails), `bailrun_ok2_100514` (6 bails, cut ≈ 2.2 s in by the get-up press; the script now waits 6 s) and + `bailrun_ok3_101354` (5 local bails of 6 attempts with the COLLPOST build; one window was an NPC skater's bail and is + flagged by `bail_impacts.py`). No bail had an update > 20 ms, so no recomp hitch fell inside a bail. At spawn + BAILLOCAL logs once per load with `how` 1 and entry index 0 (one skater in the loop). Summary + `bail_impacts.py `: + - **The impact formula is confirmed line by line.** In 4763 / 4763 BAILREG lines, the impact the game wrote equals + clamp01(max(0.001, |Δv·n| × mass × 10)) within 0.002, with Δv = SS `+4048`, n = SC `+1008`, mass = SS `+4560` and + cfg `+164` = 10. + - **Update interval.** The ragdoll step runs at 60 Hz in the recomp too: step p50 16.6 ms (p10–p90 15.9–17.4) and + the SS `+5220` dt is always 0.01667. So retail's per-step Δv and ours (1/60 s) compare directly, despite the + recomp's ~345 fps render. + - **|Δv·n| per step, the recomp vs ours.** The recomp, all bails of the ok runs, per region: p90 0.01–1.7 m/s, **p99 1.8–13 + m/s, max 7.6–20 m/s** (e.g. ok run: regions 3 / 5 / 6 / 7 p99 5.6 / 6.4 / 4.2 / 3.9, max 17 / 9.9 / 14.8 / 20). + Ours peaks near 2 m/s. The tier-2 needs (≈ 1.7 m/s torso, 4–5 m/s head / legs at our masses) are reached + several times per bail in the recomp. The largest steps are contact stops: in the top 10 % per region, the + into-surface v_old·n (−2 … −20 m/s) mostly goes to near 0 or reverses in one step ("stop" share 25–100 %). So the + gap is the physics: retail's parts hit the ground fast and stop in one step, while ours land and slide. + - **Masses (SS `+4560`, the recomp)** by part: 1 0.0052, 3 / 7 0.00034, 4 / 8 0.0017, 5 0.0031, 9 0.0031, 6 / 10 0.0015, + 13 0.0058, 15 / 19 0.00031, 16 / 20 0.0010, 17 / 21 0.0051, 18 / 22 0.0067, 23 0.0098. They are smaller than + the ≈ 0.003–0.015 bone-box estimate above, so they are worth checking against ours (`part_weights`) too. + - The bail / end bits of the stack entry match +676 / +677 (BAILSTEP fields). With the GREC-state gate, a bail of the + second owner `40C34020` also opens the window for the local X (one 0.6 s stretch with bits 0 / 0 in run ok2). Filter + those passes by the bits. + - Posts per bail in these runs (higher drop; window bail start −0.5 s … +676 clear): ok run 84 / 399 / 324 / 144, + tier 2 = 7 / 42 / 30 / 10 (legs 1031 4 / 17 / 20 / 8 lead). Every full bail has tier-2 posts. +2. Then a physics-side investigation of the ragdoll's contact Δv (outside the audio port). +3. Frame-rate dependence (separate from the gap): `Contacts::body` decrements its 15-frame cooldown and the 4-frame + region max per rendered frame. At 60 fps that is 0.25 s / 67 ms; on the console it is 0.5 s / 133 ms. Under the + console-cadence rule both should run on the 30 fps cadence. That change would lower our post count further (up + to 2× at 60 fps). Not done here: it is not the asked gap, and it changes the output. + ### Session-review leftovers: the lift level, the grind-off lag, world-bank prefetch, the first-hit gate (2026-10-03, overnight) Four open items from "Session-review fixes". "The recomp" = skate3recomp run locally: its program logic is evidence, @@ -3241,8 +3393,18 @@ stall windows excluded. Tools: `.local/audio-re/left/`. - *Hook added (2026-10-03):* `FIRSTHIT` in the recomp's research hooks (category `audiox`, sampled from the board update hook, local player): B, the B+16 byte, the B+24 float and the audio state's bail / end bytes (`+676` / `+677`), logged when any of them changes plus once a second. It gives the timing and strength but not the writer (the hook - framework has no write-watch). A short bail session (4–5 bails of different heights on one spot) is queued. Until then the first-hit message and the Contacts.in7 pulse stay + framework has no write-watch). A short bail session (4–5 different bails in the same area) is queued. Until then the first-hit message and the Contacts.in7 pulse stay unported (our Contacts.in7 / in8 stay 0, as with B+16 clear). +- *Measured 2026-10-03* (the user's session 094336 and the scripted bail runs; details under "Bail density … Open 1"): + - B+16 rises in the same frame as the audio state's +676 at every bail start. It clears 33–34 ms before +677 clears, + so it means "bail in progress". + - B+24 resets to 0 at the bail start and accumulates in ~17 ms steps to 0.42–1.0 (clamped). + - The first-hit torso post (COLLPOST from `sub_824BCEB0`, return `0x824BCFC8`) fires **once per bail**: torso 98 + against 143, tiers 1 / 1, 0.16–0.28 s after the bail start (1.77 s once). It happens for an NPC skater's bail too + (local72 0). + - Port: on the rising edge of our bail flag, latch; at the first region contact with region ≠ 1 and tier ≥ 1, post + 98 / 143 once and pulse Contacts.in7. In8 = B+24 × 32767 needs the accumulated strength, whose writer is still + unknown. **Files.** - `crates/skate-game/src/game_audio/world_sources.rs`, `game_audio/native/prefetch.rs`. @@ -3392,6 +3554,9 @@ holder fields, the owner bus builder and the Class_Seams function map. No code f ### Native runtime is the default (2026-10-02) +(Superseded 2026-10-03: the tables and these opt-outs are removed; see "Interim cue tables retired; the water +splash is native".) + - The native AEMS runtime is now the default. `settings/audio.json` `"interim": true` or `SKATE_AEMS=0` selects the interim measured tables; any other `SKATE_AEMS` value, or none, selects native. - The old `native` setting key is ignored. Installs saved `"native": false` while the tables were the default, and @@ -3407,3 +3572,145 @@ The remaining 2–5 dB gap between our seam hits and the recomp's capture (after mechanism. Pitch, per-voice level, bus, voices per hit and the bed spectrum all match. The user closed it as a likely recomp artefact: the recomp's frame rate is uncapped, its audio thread stalls, and its capture includes ambience and the other seam voices. The user hears the seams as "WAY BETTER". No levels were changed. + +### Interim cue tables retired; the water splash is native (2026-10-03, `player::footsteps::Splash`) + +The user, after the listening check of build 04:20: "go ahead and remove the interim cues", then, on hearing some were +still in use: "oh.. if they are still being used then we aren't done." Decisions: splash "port it natively first"; +the riding bed: remove it, then check it against the recomp; installs without the data: require it (no fallback). + +**1. The water splash, from retail's mechanism (not the carried-over sound).** `SFXObj_OffBoard`'s process ends with +`sub_824EBB58`; its update with `sub_824EBE78`. Read from the TU3 recompilation (reference only): +- Inputs. The bridge `sub_824B0DA8` copies record `+172` bits 30 / 29 / 28 into audio state `+811` / `+812` / `+813`. + The conditioner `sub_827A1B78` writes them: + - `+811` in water = the current state's byte `+81`. In our engine that is Wipeout300's `special_surface_81` + (its water contact), already published into `state_flags`. + - `+812` under the surface = `+811` and the state's surface height `+32` (`surface_height_32`) above the Y of any + of the PhysOut Skeleton points `+128` / `+112` / `+32`. Skeleton::FillPhysOut (`sub_82BE1AE8`) writes them: part + 15's and part 19's pose matrices applied to a per-part local point (Skeleton `+2560` / `+2816`, translation at + `+48`), and part 1's pose translation (Skeleton `+8128`). + - `+813` the board in water = Collision `+16` = 12. That is the board's surface vote `82C08818`, which forces 12 + when a board contact is water: `physics::board_surface`, the value respawn already reads. +- Poster (every frame, record active; no local or on-foot gate). On `+811`'s rise the time in water `+480` starts at + 0, then counts dt; on its fall the latches `+477` / `+478` clear. On `+812` once per stay (`+478`), unless the + game global `+224` is set (0 in free skate), the entry sound `+484` is stopped and a new one starts. The ids are + vault fields of the AudioSurface-class record `water` (`923CCB46EF5BF5BA` / `B2BD1F28DDE601B0`): + - Skate_Collisions **1187** (`35D3B06292CDA10B`) on the water contact's first frame; + - **1197** (`C17485220849574D`) once the time in water has reached 0.001 s (`9CD13431903E3719`), i.e. from the + next frame on; + - `+813` once per rise (`+492`): **1198** (`BA81E93AE985D1C7`) into `+488`. +- Sound path: the collision Splice object `sub_82497F48` into SFX Master, start block [0, 1, 0, 0, 1, 1]. The update + `sub_82498140` sends [OffBoard level(13) (entry) / level(16) (board) / 32767, pitch(14) / 4096, raw(0) × 360/65535, + dt, 0, 1] and the env send level(15) / 32767 (as the grind on / off sounds: the last update's env level is latched + at each start). A sound that ended is released. +- Cross-check: 1197 is the container of **record 869**, which is exactly the sound the user found by ear (476–478, + with 169 at 75 % and one of 171 / 172). 1187 → record 868 (170–172 + 169); 1198 → record 870 (473–475, the set the + user had rejected for small body falls: retail plays it for the board). The 2026-10-01 measured retail splash + (475 + 478 + 172 + 169) is 1197 + 1198. +- Port: `skate_audio::player::footsteps::Splash` (state `in_water` / `under_water` / `board_in_water`, set in + `skate_events::audio_state`; the ids and threshold in `FootstepTuning`). Logged in game as + `AUDIO_EVENT splash native Skate_Collisions:`. +- **UNCERTAIN:** the engine does not publish the per-part local points at Skeleton `+2560` / `+2816`, so the toes' + pose translations stand in for `+128` / `+112`. That can only shift the entry frame by the local offset (a few + cm of depth). +- Through the real MixMap (test `the_water_splash_plays_through_the_real_mixmap`): OffBoard level 13 = 14585 + (≈ 0.45), 16 = 14401, env 15 = 862, pitch 14 = 4086. + +**2. The riding bed removed, checked against the recomp.** Under native the random bed still played Skate_Collisions +947–968 and sk8_foley 62 / 73 / 74 / 84 / 85 / 88 / 89 as interim voices. All of that is gone (below). Riding = GREC +/ state-log context roll + carve, audible voices (> 0.002), first-voice levels +(`.local/audio-re/retire/bed_vs_recomp.py`, `riding_posters.py`): + +| per riding second | the recomp (6 sessions, 682 s) | ours native (5 user sessions, 229 s) | the removed bed (expected, same frames) | ours before (native + bed) | +|---|---|---|---|---| +| Skate_Collisions voices | 8.95 | 2.80 | 5.78 | 8.58 | +| Skate_Collisions Σg² | 0.424 | 0.218 | 0.120 | 0.338 | +| sk8_foley voices | 5.93 | 2.54 | 1.90 | 4.44 | +| sk8_foley Σg² | 0.229 | 0.103 | 0.079 | 0.182 | + +(Recomp: all_20261002_180430, 223306, 223613, 214346, 214002, 222155. Ours: the e2e renders of 213757, 214224, +215843, 224747 (row) and 084712 (`E2E_FPS=60`). The native renders are byte-identical before and after this change.) + +So without the bed ours is −2.9 dB (Skate_Collisions) and −3.5 dB (sk8_foley) below the recomp while riding. By +poster (the recomp, riding): +- **Skate_Collisions:** the collision manager (`sub_824D1F68` ← `sub_824D2318`) plays 3.3 posts/s while riding. + Most are the body materials **955 / 954 / 956 / 947** (denim / skin / arm / leg, 1.7/s, p50 ≈ 0.1); the rest are + 969 / 958 / 968 / 1038 (≈ 0.6/s, mostly quiet). Ours: the body poster (`sub_824BC188`, ported) runs in every state, + but our skeleton publishes almost no body-region contacts while riding (4 of 1,770 riding rows in 084712, impacts ≤ + 0.011). The manager is shared, though: NPC skaters' Contacts and physics props (`sub_824E3BE0`) post into it too, + and the session review already put 969 / 958 / 968 down to world objects. The SPLC caller chain stops at the + manager's update, and no hook records the messages (`sub_82486EF0`: materials, tiers, levels, poster). So whether + the local rider's regions touch something while riding (and what) is **not established**. Not changed (no + guessed weighting). + *Measured 2026-10-03 (recomp hook `COLLPOST` on `sub_82486EF0`, category `audiox`; run `riderun_20261003_101135`, + scripted: three ~10 s pushing segments from a session marker at the Super-Ultra Mega-Park spawn, straight / carving; + 0 malformed lines; summary `.claude/skills/recomp-research/tools/collision_posts.py`).* Each line has the posting + function (from the return address), its object, the object's audio state `[object+32]`, the GREC owner of that state, + `[[object+28]+72]` (local72), the materials, the tiers and the caller chain. Findings: + - **Clean pushing and carving on open ground: no body posts from the local rider.** Segment 1 (straight, 25.6–35.6 + s) had none. Every local body-poster post (31, local72 = 1, owner `40C33020`) came in bursts at 52.7 s and + 70.5–74.8 s, when the rider brushed and hit a rock wall at the edge of the park (screenshots). They paired legs 99 + (tiers 0–2) against materials 2 / 65 / 143 with the denim cloth 108 / 143. + - **The other owner is an NPC skater.** The second GREC owner `40C34020` (doc above) posts with local72 = 0 through its + own Contacts object. Its bail at ≈ 38 s produced the run's body burst (39 posts, 34 body SPLC 956 / 954 / 955 / + 947 at 38.2–38.4 s) while the local rider rode normally. So `[[object+28]+72]` tells the local rider from other + skaters, where GREC's `[[owner+16]+72]` does not. + - Deck impacts (`sub_824BD000`, deck 95 against the ground): local 1.28/s, the NPC 0.34/s. + - The 48-byte message's +32 / +36 words are not SPLC ids: mostly 0, otherwise 4-hex-digit values, e.g. 0x6590. The + sounds come later from the manager. + - Conclusion: while riding, retail's body-material sounds come from other skaters' bails and from the rider touching + walls, not from riding contacts. Ours having ~none on open ground matches. The 1.7/s in the user's riding + sessions likely include NPC skaters (no COLLPOST in those sessions; the session review's PCU / park sessions had + AI skaters). To check in a user session: `PLAY_TRACE_AUDIOX.bat` now logs COLLPOST. +- **sk8_foley:** every poster is ported. The rates differ with riding style. The recomp's riding has 0.42 plant / + lift posts per second; ours 0.19 / 0.16 (the user: "the 19 plants are correct, i didn't ride fast"). Push-driven + sounds scale with that: the Clothing push foley 74 / 73 (ours 0.16/s, the recomp 0.79/s) and the on-board + footsteps. Shoe scuffs 94 / 95: ours 1.61/s, the recomp 2.71/s (both p50 0.061). The pedestrians' sk8_foley 62 / 63 + (0.53/s in the recomp, `SFXObj_PedestrianSFX`) are a world layer we can't have yet. No mechanism gap found; not + changed. +- **Needed to settle the Skate_Collisions part:** a recomp hook on the collision message post `sub_82486EF0` + (caller chain, the owner's local flag, materials A / B, tiers, levels), plus a short riding session by the user + (flat ground, some pushing and carving, no bails). Then compare the local rider's body-region messages with our + region contacts (`rimp*` / `rtag*` in the state log). + +**3. Native data required.** No fallback any more: +- no AEMS banks → the runtime does not start and an error says that the skater's sounds, the world emitters and + rolling are silent; +- no MixMap → error, no player sounds, no rolling; +- no grain recordings / tuning → error, rolling silent; +- missing player banks or Splice trees → error, those sounds silent. + +The zone beds, location sets and crossfades still play their measured layers through Bevy voices. + +**4. The interim path removed.** +- `cues.rs` deleted. `grain_for` moved to `grain_bed.rs` (with its test); `RETAIL_SCALE` moved to `voices.rs` + (ambience and the Bevy-voice scale still use it). +- `skate_events.rs`: the `Event` enum, the event detectors (pop, landing, flip, bail, push, step, splash, foot + strikes), the `play` system and its loops (rolling bands, bed, grinds, powerslide, foot drag, wheel spin) are + gone. `observe` keeps the audio state, `Riding` (minus the fields only `play` read), the state log and the + `AUDIO_EVENT` brake / push / grind lines. +- `physics::foot_clearance` removed (only the interim steps used it). +- Opt-outs removed: `SKATE_AEMS=0`, `SKATE_AEMS_PLAYER=0`, `SKATE_AEMS_FOOTSTEPS=0`. `"interim"` / `"native"` keys in + `settings/audio.json` still load and are ignored (test `old_settings_files_with_interim_or_native_keys_still_load`). + The other `SKATE_AEMS_*` switches (they pick between native variants) stay. +- `library.rs`: the grain speed bands, the wheel-spin clips, cue preloading and the patch trees (`Patches` / + `Group`) are gone. Old manifests still load: unknown keys are ignored. +- `voices.rs`: the fold / scale stay, because the measured world layers still play through Bevy voices. + `Play::effect` is test-only now. +- Gone with the tables: the `AUDIO_CUE` / `AUDIO_LOOP` lines and `AUDIO_EVENT land` / `rolling` (interim-only). + +**Verification.** +- `cargo test -p skate-audio --locked`: all pass, including `the_splash_plays_once_per_water_stay_by_its_time_in_water`. +- `game_audio::` (release): 54 pass, 4 ignored (60 before; the 8 interim tests went, 2 added). +- `cargo build --locked` (dev): OK. The 3 warnings are items that were already test-only at 45c6e65. +- e2e byte-identical to the 45c6e65 renders (`opt/runs/fma_plain3`): 13 scenarios + 4 real sessions, row and + `E2E_FPS=300` (`opt/runs/retire1`), and 084712 at `E2E_FPS=60` against `check0847`. The e2e path never ran the + interim layer, and the splash inputs are false there. +- Not run here: the muted 5-map smoke test (it opens a window) and bin\ staging. + +**For the user's check in game:** +- a fall into deep water: one 1197 splash per entry, plus 1198 when the board lands in the water; +- riding on flat ground without the random bed (Skate_Collisions / sk8_foley pieces), against the recomp. + +Rerun: `py -3.13 .local/audio-re/retire/bed_vs_recomp.py `, +`py -3.13 .local/audio-re/retire/riding_posters.py ` (PYTHONIOENCODING=utf-8). diff --git a/docs/hails-additions/13-recomp-research-hooks.md b/docs/hails-additions/13-recomp-research-hooks.md index 53382b65..a43b9fb6 100644 --- a/docs/hails-additions/13-recomp-research-hooks.md +++ b/docs/hails-additions/13-recomp-research-hooks.md @@ -41,12 +41,16 @@ The recompilation is **not** a perfect copy of the console game, and its traces - the input script and record variables; - `SKATE3_BACKGROUND`. - The branch's `src/research/README.md` lists every variable and line kind. + The branch's `src/research/README.md` lists every variable and line kind, and + [`src/research/USAGE.md`](https://github.com/Hailey-Ross/skate3recomp/blob/research-hooks/src/research/USAGE.md) + is a step-by-step guide: a first trace, choosing categories, reading and checking a trace, watch lists, input + scripts and the audio capture. 4. Check every trace for malformed lines before using it; there should be none. -The analysis scripts we used (trace readers, per-bank level and voice tools, the e2e comparisons) are tied to -this fork's layout and paths. Anyone who wants to use them needs to build the recomp and set up the paths -themselves. +The analysis scripts we used are published in `tools/recomp-trace/` (trace readers, per-bank level and voice +tools, rolling-bed and send analysis) and `tools/recomp-code-search/` (searching the recompiled sources and the +memory image); see [14](14-published-tools.md). They are reference only: anyone who wants to use them needs to +build the recomp and set up the paths themselves. ## Credits diff --git a/docs/hails-additions/14-published-tools.md b/docs/hails-additions/14-published-tools.md new file mode 100644 index 00000000..9c5158f9 --- /dev/null +++ b/docs/hails-additions/14-published-tools.md @@ -0,0 +1,82 @@ +# 14. Published development and research tools + +## Problem + +The fork's work (map spawns, collision volumes, setup speed-ups, the native audio) was checked with +many small scripts: regression baselines, equivalence proofs, format readers, audio render analysis, +trace analysis. They lived in a local, untracked workspace with paths hard-wired to one machine, so +nobody else could rerun the checks behind the documents in this folder or build on the readers. + +## Change + +The generally useful scripts are now in `tools/`, one folder per tool, each with a `README.md` (what it +does, inputs, usage, example output, requirements). An index is in [`tools/README.md`](../../tools/README.md). + +| Folder | Contents | +|---|---| +| `tools/regression-checks/` | `check_maps.py` (`--validate-maps` + collision triangle counts), `check_spawns.py` (baked spawns), `check_customiser.py` (customiser output snapshot), `smoke_maps.sh` (muted crash smoke test per map). | +| `tools/setup-equivalence/` | `compare_streams.py` (stream loader byte equivalence), `compare_refpack.py` (native RefPack DLL against the Python decoder). | +| `tools/collision-inspect/` | `map_collision.py`, `analyse_spawn.py`, `collision_attrs.py`, `scan_surfaceless.py`, `unsigned_collision.py`. | +| `tools/world-stream-inspect/` | `big_list.py`, `sim_types.py`, `volumes_dump.py`, `arenas.py` (shared arena reader). | +| `tools/audio-file-inspect/` | `aems_survey.py`, `bank_layout_check.py`, `decode_bank_samples.py`, `multichannel_census.py`, `splc_fields.py`, `ems_dump.py`, `grain_survey.py`. | +| `tools/vault-inspect/` | `find_field.py`, `vault_fields.py`, `vault_layout.py`. | +| `tools/audio-e2e/` | `scenarios.py`, `render_diff.py`, `compare.py`, `frame_levels.py`, `voices_summary.py`, `bank_rate.py`, `wet_level.py`. | +| `tools/audio-bench/` | `e2e_bench.sh`, `compare_runs.sh`, `timing_summary.py`, `audio_timing_summary.py`, `emitter_bank_memory.py`, `prefetch_memory_sim.py`. | +| `tools/recomp-trace/` | For the recomp's research hooks: `trace.py`, `first_pass.py`, `resolve_trace.py`, `retail_voices.py`, `retail_relay.py`, `grec_level.py`, `grec_clean.py`, `grec_material.py`, `send_vectors.py`, `grain_trace_stats.py`, `retail_windows.py`, `veh_trace.py`. | +| `tools/recomp-code-search/` | For the recomp's research hooks: `fn.sh`, `grepfn.sh`, `callctx.sh`, `ppcxref.py`. | + +What changed against the working copies: +- Hard-coded paths became arguments with defaults relative to the repository: `--disc` (extracted disc + root, or `$SKATE3_DISC`, default `.local/skate3-disc`), `--assets`, `--vault`, `--work`, `--baseline`, + `--exe`, and environment variables for the shell scripts (`SKATE_EXE`, `BENCH_DIR`, + `RECOMP_GENERATED`). Work output goes under `.local/` (gitignored). +- The regression baselines are not shipped. Each check records its own on a known-good install + (`--update` / `--save`) and stores it in `.local/regression/` by default. +- Game-derived constants became inputs: `retail_windows.py --contact-callers`, + `grec_material.py --no-contact`, `emitter_bank_memory.py --group`, `vault_layout.py --name`, + `grain_trace_stats.py --exclude`; `ppcxref.py` takes the image, function list, base and code range as + arguments. +- `sim_types.py` / `volumes_dump.py` now share a small arena reader (`arenas.py`) instead of depending + on a local research script. + +## Recomp tools + +The two `recomp-*` folders only work with traces and sources from the research hooks of the Skate 3 +recompilation: +[Hailey-Ross/skate3recomp, branch `research-hooks`](https://github.com/Hailey-Ross/skate3recomp/tree/research-hooks) +(see [13](13-recomp-research-hooks.md)). Their READMEs, and the index, label them as such and say they +are reference only: users must build the recomp from their own copy of the game and set up the paths +themselves. The recompilation is not a perfect copy of the console game (uncapped frame rate, different +timing and threading), so its traces are evidence for values and logic, not for timings. + +## Left out + +- Anything that embeds game code or data: disassembly listings, golden scripts generated from the game, + the MixMap reference evaluator (it carries a name table taken from the game and reads tables at fixed + image addresses), and the image readers built around fixed address lists (registries, vtables, type + tables, image constants). +- Tools tied to one local setup: the comparisons against the local audio proof-of-concept worktree, + the dev-install staging shortcuts (setup now does the same steps), and the upstream-sync helpers for + this fork's branch workflow. +- One-off investigation scripts (NPC speech, pedestrian chases, census layers, single-question surveys). + +## Verification + +- Every published Python script compiles (`python -m py_compile`) and prints its usage with `--help`. +- Every shell script passes `bash -n`. +- No private paths, names or tool-specific notes remain in the published files (searched). +- The tools were not rerun against game data while publishing; their logic is unchanged from the + working copies apart from the argument handling listed above. + +## Open questions + +- `crates/skate-game/src/game_audio/e2e.rs` still names the old local script paths in its header + comment; it could point to `tools/audio-e2e/` instead. +- None of this is offered upstream yet; it would make a tooling PR of its own (see the PR plan). + +## Credits + +[skate3recomp](https://github.com/mchughalex/skate3recomp) by @mchughalex, the +[rexglue SDK](https://github.com/rexglue/rexglue-sdk), [Xenia](https://github.com/xenia-project/xenia)'s +Xbox 360 research, [vgmstream](https://github.com/vgmstream/vgmstream) for audio decoding, and the +vendored map extraction tools in `tools/vendor/`. diff --git a/docs/hails-additions/PULL-REQUESTS.md b/docs/hails-additions/PULL-REQUESTS.md index 6d66cc53..ea3319a3 100644 --- a/docs/hails-additions/PULL-REQUESTS.md +++ b/docs/hails-additions/PULL-REQUESTS.md @@ -19,6 +19,7 @@ verification, open questions). | J | Feature (audio) | Game audio: setup group `audio` extracts/decodes the disc's audio (vgmstream r2117), per-map ambience, water emitters, rolling by surface and speed band, trick/landing/grind/bail/splash cues, footsteps from foot strikes, volume settings and `--mute` | [11](11-audio.md) | `gameplay/audio` (`98c52b6`, `d9a1818`); upstream PR branch `audio/retail-audio`, which mirrors `gameplay/audio` exactly (source of truth; batch commit `f438017` "New audio who dis", merged as `ca7b4f3`, 2026-10-02) | 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**. Branch has the first version; the world-audio work (.ems emitters, location-set one-shots/sirens, zone ambience) is done locally and goes up with the next batch commit; native AEMS runtime (`crates/skate-audio`, new workspace crate) implemented 2026-10-02 behind `SKATE_AEMS=1` (world emitters only), then the MixMap mixer (emitter words) and the granular rolling bed (`grain/`, `game_audio/grain_bed.rs`; setup keeps the whole grains, copies `MixMapSK8.mxb`, exports the grain tuning), then the player inputs and the first native player components (`skate_audio::player`, `game_audio/player_audio.rs`: Class_grind, SenseOfSpeed, Class_foot_drag; setup exports `player_tuning`; `skate-core` `WheelLineState.seam_patterns`), then the Splice board contacts / skid / squeaks and the collision manager, wheel spin and foot taps / scuffs (`player/{contacts,collision,wheels}.rs`; `skate-core` `BoardGroundState.part_audio_surfaces`; setup `player_tuning.collision`), then the buses (reverb network, eEQChain, flanger returns, collision submix) and Class_Seams, the board layers (`player/rolling.rs`), the grain bus chain (`grain/chain.rs`, `dsp/{fss,shelf}.rs`), tricks / treatment, and footsteps / clothing / hands on the deck (`player/{tricks,treatment,footsteps,clothing,step_on}.rs`; setup adds four Class_rolling banks, `bus_tuning.flange`, `player_tuning.audio_tricks_2`, per-material footstep fields and 16 grain-chain vault keys). All uncommitted, all on #32's checklist (updated 2026-10-02 21:30). The seam sample shuffle is in (Listening test 8: retail's boot utility `Start_up_Play_ctl`; setup now copies `Common.abk`). Listening: landings, pops, footsteps and the hand pick-up / put-down confirmed; rolling "is much better, it just has issues with certain surfaces"; after the shuffle, concrete and brick "both sound better but still need work". Plan, in order: (1) surface parity from the paired sessions (ours / recomp at the Aletown brick and the University PCU Library); (2) footstep submix, remaining engine inputs, reverb-zone emitters (1,040 records), Reverb inputs / global env-send scale, utility boot order (done headless 2026-10-02, doc 11 "FootStep SubMix, the skeleton inputs, …"; needs the listening check; setup exports the zones' `reverb` preset); (3) native as the default, interim cue tables removed; (4) the optimisation pass; (5) world / NPC audio stages as follow-ups. #32's description carries the same status and plan (updated 2026-10-02 evening). Latest listening (build 21:57): "rolling sounds better, griding sounds better, walking sounds better"; a University fountain's "doubling/restarting" is retail's relay of short pieces, with the repeat rate being analysed from the user's WATERRELAY retail session. Seams: retail runs them per rendered frame; per the user's decision ours targets the 360's ~30 fps cadence, frame-rate independent (done, default; user: "seams sound much better. overall much better <3"). Plan step (1) done; (2) Treatments 16/17 from the TREAT trace settled 2026-10-02: the recomp plays them too (their samples equal sense_of_speed 3/4, so the trace tool misread them); `+236` now counts through the landing tick, and the e2e scenarios pass the air words (doc 11 "Treatments slots 16 / 17"). Since the push: native is the default (2026-10-02 late; `interim`/`SKATE_AEMS=0` opt-out, tables kept), uncommitted. Overnight 2026-10-03: the MixMap, the Jitter walk and the eEQChain clear on the console's 30 Hz cadence (`mixmap::cadence`, flag inputs held; `SKATE_AEMS_MIX_CONSOLE=0` = old), frame-rate independent 60–365 fps, uncommitted (doc 11 "The MixMap on the console cadence"); the seam-prominence gap re-measured (the recomp capture lags ~110 ms), no change. Overnight plan: remaining small items, then the optimisation pass, then ped speech / traffic / NPC skaters' boards. World / NPC foundation built overnight 2026-10-03, uncommitted (doc 11 "World sound sources"): `skate_audio::world` (traffic engine / horn / alarm / skid, ped footsteps, speech index and reaction cues), `game_audio/world_sources.rs` (inert until a ped / traffic system publishes owners), setup `tools/asset_pipeline/world_audio.py` (13 world banks, `world_tuning`, speech index; speech PCM opt-in `SKATE_SETUP_SPEECH=1`). NPC skaters' boards foundation 2026-10-03, uncommitted (doc 11 "NPC skaters' board sounds"): `world::skaters` (retail gives the Player slot's 2nd instance to one NPC within 30 m of the camera; the local player's components run for it), `game_audio/npc_skaters.rs` (inert until an AI-skater system publishes; `SKATE_AEMS_NPC_SKATERS=0`); e2e byte-identical. Optimisation pass (first round) done 2026-10-03, uncommitted: render per block -39 % (p50 210 -> 130 us), byte-identical e2e outputs, no audio-thread allocations; hardware FMA and emitter-bank prefetch left for the user (doc 11 "Optimisation pass"). Session-review fixes 2026-10-03, uncommitted (doc 11 "Session-review fixes"): push plant from State55, the Contacts' push-foot plant / lift, the body poster (+ setup export fix for name-keyed AudioSurface records), grind on / off sounds, new state-log columns; each behind `SKATE_AEMS_*=0`, e2e byte-identical with them off. FMA (user decision 2026-10-03): runtime dispatch in one exe, done 2026-10-03, uncommitted (doc 11 "Hardware FMA dispatch"): new workspace crate `crates/skate-audio-fma` (the per-sample `mul_add` loops compiled plain and with `#[target_feature(enable = "fma")]`, one guarded `unsafe` call; no external deps), skate-audio stays forbid(unsafe_code); `SKATE_AUDIO_FMA=0` forces plain; e2e byte-identical on both paths; render p50 129 → ~55 µs. Keep #32's description current (standing rule) When #32 is ready for merge, all of it goes up as ONE commit titled `New audio who dis`, only with the user's explicit permission at that time. | | 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) | +| L | Tooling | Development and research tools in `tools//` (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` (code only; `docs/hails-additions` stays in the fork). #29 (G) is stacked on diff --git a/docs/hails-additions/README.md b/docs/hails-additions/README.md index 5352b345..4f2e8ee0 100644 --- a/docs/hails-additions/README.md +++ b/docs/hails-additions/README.md @@ -19,6 +19,7 @@ Each document stands alone. File paths are relative to the repository root. | 11 | [Game audio](11-audio.md) | Setup (Python, vgmstream), game audio (Rust) | Work in progress (draft #32). Player cues tuned against retail traces; retail world audio done: .ems emitters, location-set one-shots (sirens; 1,859/1,859 retail match over 15 locations), zone ambience beds. Native AEMS runtime (`crates/skate-audio`: evaluator + voice graph) implemented behind `SKATE_AEMS=1` for the world emitters; 234/235 PoC-oracle scripts identical, DSP kernels bit-exact; MixMap + granular rolling bed; player MixMap inputs and the first native player components (grind, wind/rattle, foot drag; wind level within 0.5 dB of retail); needs a listening check. | | 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) | +| 14 | [Published development and research tools](14-published-tools.md) | Tooling (`tools//`, Python and shell) | Done (uncommitted); scripts compile and print their usage; the two `recomp-*` folders are for the recomp's research hooks, reference only | ## Environment used for verification diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 00000000..3b62bfd8 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,24 @@ +# Tools + +The top-level scripts here are the repository's own tooling: setup and asset preparation (`setup.py`, +`prepare_assets.py`, `asset_pipeline/`, `owned_game/`), the updater, release and build helpers, and their +tests. They are documented where they are used (the main README and the scripts' own help). + +The folders below are standalone helper tools for development and research. Each has a `README.md` +with what it does, its inputs, usage, example output and requirements. They read your own copy of the +game; none of them contain game code or data. Default work folders are under `.local/` (gitignored). + +| Folder | What it is for | +|---|---| +| [`regression-checks/`](regression-checks/README.md) | Map validation and collision counts, baked spawns, customiser outputs against your own baselines; a muted crash smoke test per map. | +| [`setup-equivalence/`](setup-equivalence/README.md) | Prove a faster setup path gives identical bytes (district stream loader, native RefPack DLL). | +| [`collision-inspect/`](collision-inspect/README.md) | Extract a district's collision for analysis: triangles, surfaces at a point, surface ids and flags, surfaceless meshes. | +| [`world-stream-inspect/`](world-stream-inspect/README.md) | List / extract `.big` archives; survey the RW4 arenas of the district simulation streams; dump named trigger volumes. | +| [`audio-file-inspect/`](audio-file-inspect/README.md) | Readers for the audio formats: ABKC banks and MOIR projects, SPLC banks, `.ems` emitter files, `.grain` data; decode bank samples. | +| [`vault-inspect/`](vault-inspect/README.md) | Look up fields in the attribute database (converted skater collections) and class layouts in the schema. | +| [`audio-e2e/`](audio-e2e/README.md) | Scripted scenarios for the headless audio render and analysis of the renders (diffs, levels, voices, bus share). | +| [`audio-bench/`](audio-bench/README.md) | Audio performance: hashed e2e bench runs, timing summaries, emitter bank memory. | +| [`recomp-trace/`](recomp-trace/README.md) | **For use with the Skate 3 recomp's research hooks** ([`research-hooks` branch](https://github.com/Hailey-Ross/skate3recomp/tree/research-hooks)): read and analyse trace sessions. Reference only; you build the recomp and set up the paths yourself. | +| [`recomp-code-search/`](recomp-code-search/README.md) | **For use with the Skate 3 recomp's research hooks** ([`research-hooks` branch](https://github.com/Hailey-Ross/skate3recomp/tree/research-hooks)): search the recompiled sources and the memory image. Reference only; you build the recomp and set up the paths yourself. | + +See `docs/hails-additions/14-published-tools.md` for the background. diff --git a/tools/audio-bench/README.md b/tools/audio-bench/README.md new file mode 100644 index 00000000..96e7dc65 --- /dev/null +++ b/tools/audio-bench/README.md @@ -0,0 +1,51 @@ +# Audio bench + +Tools for speeding up the native audio without changing what it does: measure first, prove the output +is identical, then compare timings. Built on the headless e2e render (see `tools/audio-e2e`). + +| Script | What it does | +|---|---| +| `e2e_bench.sh` | Renders every scenario (and optional real-play sessions) headless with timing on, in one or more host modes (`row`: one call per 60 Hz row; `fpsN`: the game's host at N fps), and writes SHA-256 hashes of every output plus the timing log under a label. Uses its own cargo target folder so it never invalidates your main build. | +| `compare_runs.sh` | Compares two labels: output hashes (must be IDENTICAL) and the timing summaries side by side. Exit 1 on any difference. | +| `timing_summary.py` | Pools the per-frame (game thread) and per-block (render) times of one or more runs: p50 / p90 / p99 / p99.9 / max and total. | +| `audio_timing_summary.py` | Summarises the `AUDIO_TIMING` lines a real play session logs with `SKATE_AUDIO_TIMING=1`: per audio system, the average, the per-second maximum (median / p90 / worst) and calls per second. | +| `emitter_bank_memory.py` | Resident memory of the world emitter banks per `.ems` emitter file (or per group of files, e.g. a map's), from the install's audio manifest. | +| `prefetch_memory_sim.py` | Simulates a distance prefetch of emitter banks over a play session's audio state log and reports loaded and prefetched memory peaks. | + +## Inputs + +- `BENCH_DIR` (default `.local/audio-bench`): `scen/*.tsv` from `tools/audio-e2e/scenarios.py BENCH_DIR/scen`, + optional `real/*.tsv` from `scenarios.py --from-log LOG --cut r0-999999 NAME BENCH_DIR/real`. +- An installation with the native audio data (setup group `audio`). +- Game logs (`logs/game-*.stderr.log`) from a session run with `SKATE_AUDIO_TIMING=1`. +- Audio state logs from a session run with `SKATE_AUDIO_STATE_LOG=`. + +## Usage + +``` +bash tools/audio-bench/e2e_bench.sh base # before the change (keeps the renders) +bash tools/audio-bench/e2e_bench.sh new row fps30 fps300 +bash tools/audio-bench/compare_runs.sh base new +py -3.13 tools/audio-bench/audio_timing_summary.py logs/game-20260101-120000.stderr.log +py -3.13 tools/audio-bench/emitter_bank_memory.py --group University=sfx_university,music_university +py -3.13 tools/audio-bench/prefetch_memory_sim.py my_state_log.tsv sfx_university --ahead 60 --evict 90 +``` + +`KEEP=1` keeps the `.f32` renders of a non-base run; `CARGO_TARGET_DIR` overrides the bench target folder. + +## Example output + +`compare_runs.sh` (made-up timings): + +``` +scen-row: IDENTICAL (26 files) +scen-fps300: IDENTICAL (26 files) +scen-row block: + base: n 41000 p50 210.0 p90 260.0 p99 340.0 p99.9 520.0 max 900.0 sum 8610.0 ms + new: n 41000 p50 130.0 p90 170.0 p99 230.0 p99.9 400.0 max 700.0 sum 5330.0 ms +``` + +## Requirements + +- Git Bash (`sha256sum`, `diff`), the Rust toolchain. +- Python 3.13 (standard library). `compare_runs.sh` calls `python` (set `PYTHON` to change it). diff --git a/tools/audio-bench/audio_timing_summary.py b/tools/audio-bench/audio_timing_summary.py new file mode 100644 index 00000000..07c88cf4 --- /dev/null +++ b/tools/audio-bench/audio_timing_summary.py @@ -0,0 +1,31 @@ +"""Summarise the AUDIO_TIMING lines of real play (SKATE_AUDIO_TIMING=1) per audio system: lines, the average +of the per-second averages, the per-second maximum (median / p90 / worst) and calls per second. + + py -3.13 tools/audio-bench/audio_timing_summary.py logs/game-YYYYMMDD-HHMMSS.stderr.log [...] +""" +import re +import statistics +import sys + +PAT = re.compile(r'(\w+)=(\d+)/(\d+)us×(\d+)') + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'): + sys.exit(__doc__) + for f in sys.argv[1:]: + stats = {} + for line in open(f, encoding='utf-8', errors='replace'): + if 'AUDIO_TIMING' not in line: + continue + for name, mx, avg, n in PAT.findall(line): + stats.setdefault(name, []).append((int(mx), int(avg), int(n))) + print(f) + for k, v in stats.items(): + mx = sorted(x[0] for x in v) + print(f' {k:16s} lines {len(v):4d} avg {statistics.mean(x[1] for x in v):8.1f} us per-second max: median ' + f'{mx[len(mx) // 2]:6d} p90 {mx[int(len(mx) * .9)]:6d} worst {mx[-1]:7d} calls/s {statistics.mean(x[2] for x in v):7.1f}') + + +if __name__ == '__main__': + main() diff --git a/tools/audio-bench/compare_runs.sh b/tools/audio-bench/compare_runs.sh new file mode 100644 index 00000000..5c8e496d --- /dev/null +++ b/tools/audio-bench/compare_runs.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Compare two e2e_bench.sh runs: output hashes (must be identical) and the timing summaries. +# tools/audio-bench/compare_runs.sh BASE NEW (runs under BENCH_DIR/runs, default .local/audio-bench/runs) +# Exit 1 if any output differs. +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +R="${BENCH_DIR:-$ROOT/.local/audio-bench}/runs" +[ $# -eq 2 ] || { echo "usage: compare_runs.sh BASE NEW"; exit 2; } +status=0 +for d in "$R/$2"/*/; do + s=$(basename "$d") + if [ ! -f "$R/$1/$s/hashes.txt" ]; then echo "$s: no baseline"; continue; fi + if diff -q "$R/$1/$s/hashes.txt" "$d/hashes.txt" > /dev/null; then + echo "$s: IDENTICAL ($(wc -l < "$d/hashes.txt") files)" + else + echo "$s: DIFFERENT"; diff "$R/$1/$s/hashes.txt" "$d/hashes.txt" | head -20; status=1 + fi +done +"${PYTHON:-python}" "$ROOT/tools/audio-bench/timing_summary.py" "$R/$1" "$R/$2" +exit $status diff --git a/tools/audio-bench/e2e_bench.sh b/tools/audio-bench/e2e_bench.sh new file mode 100644 index 00000000..359f7358 --- /dev/null +++ b/tools/audio-bench/e2e_bench.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Native-audio bench: renders the e2e scenarios (and optional real-play replays) headless, records +# per-frame / per-block timing, and hashes every output, so a speed-up can be proven to change nothing. +# +# tools/audio-bench/e2e_bench.sh LABEL [MODE...] +# +# MODE: row (one host call per 60 Hz row, the default e2e), fpsN (the game's host at N fps: E2E_FPS=N; +# e.g. fps30, fps300, fps1000). Default: row fps300. +# Inputs (BENCH_DIR, default .local/audio-bench): +# scen/*.tsv the scripted scenarios (tools/audio-e2e/scenarios.py BENCH_DIR/scen) +# real/*.tsv optional whole play sessions (scenarios.py --from-log LOG --cut r0-999999 NAME BENCH_DIR/real) +# Output: BENCH_DIR/runs/LABEL/-/ with timing.txt and hashes.txt (sha256 of every .ours.f32 and +# .ours.voices.tsv). KEEP=1 keeps the .f32 renders (else deleted after hashing, except for LABEL=base). +# Compare two labels with compare_runs.sh A B. +# Uses its own target dir (CARGO_TARGET_DIR, default .local/bench-target) so it never invalidates the main build. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +BENCH="${BENCH_DIR:-$ROOT/.local/audio-bench}" +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/.local/bench-target}" +LABEL="${1:?usage: e2e_bench.sh LABEL [MODE...]}"; shift +MODES=("$@"); [ ${#MODES[@]} -eq 0 ] && MODES=(row fps300) +for set in scen real; do + ls "$BENCH/$set/"*.tsv >/dev/null 2>&1 || { echo "skip $set: no $BENCH/$set/*.tsv"; continue; } + for mode in "${MODES[@]}"; do + out="$BENCH/runs/$LABEL/$set-$mode" + rm -rf "$out"; mkdir -p "$out" + cp "$BENCH/$set/"*.tsv "$out/" + envs=(E2E_DIR="$out" E2E_TIMING=1) + case "$mode" in fps*) envs+=(E2E_FPS="${mode#fps}");; esac + (cd "$ROOT" && env "${envs[@]}" cargo test -q -p skate-game --release --bin skate3rust --locked -- --ignored --exact game_audio::e2e::e2e_render --nocapture) > "$out/timing.txt" 2>&1 + (cd "$out" && sha256sum *.ours.f32 *.ours.voices.tsv > hashes.txt) + if [ "$LABEL" != base ] && [ "${KEEP:-0}" != 1 ]; then rm -f "$out"/*.ours.f32; fi + echo "$LABEL $set-$mode: $(wc -l < "$out/hashes.txt") outputs hashed" + done +done diff --git a/tools/audio-bench/emitter_bank_memory.py b/tools/audio-bench/emitter_bank_memory.py new file mode 100644 index 00000000..a146d53f --- /dev/null +++ b/tools/audio-bench/emitter_bank_memory.py @@ -0,0 +1,87 @@ +"""Resident memory of the world emitter banks, per .ems emitter file of the install (or per group of +files): the banks its played records use (kind 1, flags 0, bank in the native install) and their size when +decoded the way the native runtime keeps them (16-bit WAV -> planar f32 = 2x the data chunk) plus the +.abk bytes it keeps. + +usage: py -3.13 tools/audio-bench/emitter_bank_memory.py [--assets DIR] [--group NAME=ems1,ems2 ...] + --assets the asset root (default $SKATE_ASSETS or assets, the dev junction) + --group sum several .ems files under one name (e.g. a map's sfx_/music_/reverb_ files); repeatable. + Without --group, one row per .ems file in the manifest. +""" +import argparse +import json +import os +import struct +import sys + +_cache = {} +root = None +m = None + + +def load(assets): + """Read the install's audio manifest (call before bank_size / records).""" + global root, m + root = os.path.join(assets, 'private', 'audio') + with open(os.path.join(root, 'audio_manifest.json')) as f: + m = json.load(f) + _cache.clear() + + +def data_bytes(path): + with open(path, 'rb') as f: + b = f.read() + at = 12 + while at + 8 <= len(b): + size = struct.unpack_from(' (needs board_x/y/z columns). + EMS: the map's .ems emitter files, as named in the manifest (e.g. sfx_university,music_university). +""" +import argparse +import csv +import math +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import emitter_bank_memory as ebm # noqa: E402 + + +def main(): + parser = argparse.ArgumentParser(description='Simulate a distance prefetch of world emitter banks over a state log.') + parser.add_argument('log', help='SKATE_AUDIO_STATE_LOG file') + parser.add_argument('ems', help='comma-separated .ems files of the map') + parser.add_argument('--ahead', type=float, default=60.0, help='prefetch distance beyond reach, m (default: %(default)s)') + parser.add_argument('--evict', type=float, default=90.0, help='drop distance beyond reach, m (default: %(default)s)') + parser.add_argument('--assets', default=os.environ.get('SKATE_ASSETS', 'assets'), help='asset root (default: %(default)s)') + args = parser.parse_args() + ebm.load(args.assets) + recs = ebm.records([f for f in args.ems.split(',') if f]) + loaded, pending = set(), set() + peak_pending = peak_total = 0.0 + rows = 0 + + def mib(s): + return sum(ebm.bank_size(b) for b in s) / 2**20 + with open(args.log, newline='') as f: + for row in csv.DictReader(f, delimiter='\t'): + try: + p = (float(row['board_x']), float(row['board_y']), float(row['board_z'])) + except (KeyError, ValueError, TypeError): + continue + rows += 1 + want, keep = set(), set() + for r in recs: + d = math.dist(p, r['position']) + radius = max(r['extent']) + if d <= radius: + loaded.add(r['bank']) + if d <= radius + args.ahead: + want.add(r['bank']) + if d <= radius + args.evict: + keep.add(r['bank']) + pending = {b for b in (pending | want) if b not in loaded and b in keep} + peak_pending = max(peak_pending, mib(pending)) + peak_total = max(peak_total, mib(pending | loaded)) + print(f'{os.path.basename(args.log)} rows {rows} ahead {args.ahead:g} m evict {args.evict:g} m: loaded {len(loaded)} banks ' + f'{mib(loaded):.1f} MiB; prefetched-not-loaded peak {peak_pending:.1f} MiB; loaded+prefetched peak {peak_total:.1f} MiB') + + +if __name__ == '__main__': + main() diff --git a/tools/audio-bench/timing_summary.py b/tools/audio-bench/timing_summary.py new file mode 100644 index 00000000..3d0ca701 --- /dev/null +++ b/tools/audio-bench/timing_summary.py @@ -0,0 +1,44 @@ +"""Summarise e2e_bench.sh timing: per run dir, the game-thread call and render-block times pooled +over every scenario (p50 / p90 / p99 / p99.9 / max, µs, and the total), side by side for each +run given. + + py -3.13 tools/audio-bench/timing_summary.py RUN_DIR [RUN_DIR...] +""" +import sys +from pathlib import Path + + +def load(d: Path): + out = {} + for sub in sorted(p for p in d.iterdir() if p.is_dir()): + kinds = {'frame': [], 'block': []} + for f in sub.glob('*.ours.timing.tsv'): + for line in f.read_text().splitlines()[1:]: + k, us = line.split('\t') + kinds[k].append(float(us)) + out[sub.name] = kinds + return out + + +def q(v, p): + return v[min(len(v) - 1, int((len(v) - 1) * p))] + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'): + sys.exit(__doc__) + runs = [(Path(a).name, load(Path(a))) for a in sys.argv[1:]] + sets = sorted({s for _, r in runs for s in r}) + for s in sets: + for kind in ('block', 'frame'): + print(f'{s} {kind}:') + for name, r in runs: + v = sorted(r.get(s, {}).get(kind, [])) + if not v: + continue + print(f' {name:>16}: n {len(v):6d} p50 {q(v, .5):7.1f} p90 {q(v, .9):7.1f} p99 {q(v, .99):7.1f}' + f' p99.9 {q(v, .999):7.1f} max {v[-1]:8.1f} sum {sum(v) / 1e3:9.1f} ms') + + +if __name__ == '__main__': + main() diff --git a/tools/audio-e2e/README.md b/tools/audio-e2e/README.md new file mode 100644 index 00000000..2a95e906 --- /dev/null +++ b/tools/audio-e2e/README.md @@ -0,0 +1,56 @@ +# Audio end-to-end renders + +The engine can render its native player audio headless, frame by frame, from a scripted scenario: the +test `game_audio::e2e::e2e_render` in `crates/skate-game` (it is `#[ignore]`d, so it only runs when asked). +These scripts write the scenarios and analyse the renders, so an audio change can be checked by +numbers (and proved byte-identical when it should be) without playing. + +| Script | What it does | +|---|---| +| `scenarios.py` | Writes the scripted scenarios (one TSV per scenario, one row per 60 Hz frame: speed, turn, wheels down, surface tag, air, grind, brake, manual, push, …). `--from-log` cuts a real play session's audio state log into a scenario instead. | +| `render_diff.py` | Compares two folders of renders: per scenario bit-identical or not, largest sample difference, level of each and the level change. | +| `compare.py` | Compares two renders of the same scenario in one folder (`.ours.f32` against `..f32`) per segment: level, octave bands, spectral centroid, pitch, onsets, envelope lag and correlation. `--json` writes the report. | +| `frame_levels.py` | Per frame: the scenario state, the output level (dBFS) and the summed gain of each voice group (grain bed, wheels, one-shots, each bank). | +| `voices_summary.py` | Per frame: summed voice gain by bank, from `.ours.voices.tsv`. | +| `bank_rate.py` | Voice starts per second and summed gain of one bank, per surface tag and seam pattern, across one or more render folders. | +| `wet_level.py` | The bus (reverb / effects) contribution: RMS of (wet render − dry render) against the dry render, over the rolling frames. | + +## Inputs + +- Scenario TSVs (from `scenarios.py`) in a folder, default `.local/audio-e2e/`. +- The renders the test writes next to them: `.ours.f32` (raw float32, 6 channels L R C LFE Ls Rs, + 48 kHz, one settle second first) and `.ours.voices.tsv`. +- An installation with the native audio data (setup group `audio`). +- Real-play logs for `--from-log`: run the game with `SKATE_AUDIO_STATE_LOG=`. + +## Usage + +``` +py -3.13 tools/audio-e2e/scenarios.py .local/audio-e2e +set E2E_DIR=\.local\audio-e2e +cargo test -p skate-game --release --bin skate3rust --locked -- --ignored e2e_render --nocapture + +py -3.13 tools/audio-e2e/scenarios.py --from-log my_session.tsv --cut 12-40 roll_session .local/audio-e2e +py -3.13 tools/audio-e2e/render_diff.py .local/audio-e2e-before .local/audio-e2e +py -3.13 tools/audio-e2e/compare.py .local/audio-e2e --a ours --b other --only roll20 +py -3.13 tools/audio-e2e/frame_levels.py .local/audio-e2e roll20 0 600 30 +py -3.13 tools/audio-e2e/wet_level.py .local/e2e-dry .local/e2e-wet roll20 +``` + +`E2E_ONLY=a,b` limits the test to some scenarios. To compare against another build or another audio +stack, render into a second folder and use `render_diff.py`, or rename its renders to +`.