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).
This commit is contained in:
Hails committed 2026-10-03 10:20:08 -06:00
1 parent 6499b7dea0
commit 418dcecd41
85 files changed
+5372 -1348

No files matched your search

+151 -2
View File
@@ -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<FootstepMaterial>,
/// 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<SoundId>,
board_sound: Option<SoundId>,
/// 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<SoundId> {
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();
+14
View File
@@ -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,
}
}
}
+2 -2
View File
@@ -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() {
-390
View File
@@ -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<usize> = 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");
}
}
+3 -7
View File
@@ -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();
+2 -2
View File
@@ -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::*;
+47 -6
View File
@@ -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");
+4 -71
View File
@@ -67,11 +67,10 @@ pub(crate) struct AemsFiles {
pub splice: BTreeMap<String, String>,
}
/// 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<Entry>,
#[serde(default)]
file: Option<String>,
#[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<Vec<Group>>,
pub containers: Vec<Vec<usize>>,
}
/// 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<String, Grain>,
wheels: BTreeMap<String, Entry>,
banks: BTreeMap<String, Vec<Entry>>,
#[serde(default)]
patches: BTreeMap<String, Patches>,
/// Emitter file stem (`sfx_university`, ...) -> its records.
#[serde(default)]
emitters: BTreeMap<String, Vec<EmitterRecord>>,
@@ -657,7 +638,7 @@ pub(crate) struct Clip {
pub handle: Handle<AudioSource>,
pub key: Arc<str>,
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<AudioSource>, name: &str, band: usize) -> Option<Clip> {
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<AudioSource>, name: &str) -> Option<Clip> {
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<AudioSource>, bank: &str, index: usize) -> Option<Clip> {
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<AudioSource>, samples: &[(&str, &[usize])]) -> usize {
let mut files: Vec<String> = 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<usize> {
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<usize> = 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
+18 -38
View File
@@ -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<crate::config::Config>, mut assets: ResMut<Assets<AudioSource>>) {
fn setup(mut commands: Commands, config: Res<crate::config::Config>) {
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<usize>)> = 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]
+27 -36
View File
@@ -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<super::player_audio::PlayerAudio>,
/// 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<super::grain_bed::Bed>,
/// 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::<NativeStream>()
.add_systems(PostStartup, start)
@@ -607,10 +600,7 @@ fn start(
library: Option<Res<Library>>,
mut streams: ResMut<Assets<NativeStream>>,
) {
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);
@@ -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:
+26 -755
View File
@@ -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<Event>,
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<String>,
trace: Option<bool>,
/// 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<f32>,
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<f32>, 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>) -> 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<AudioSource>,
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<Cues>,
library: Option<ResMut<Library>>,
mut voices: ResMut<Voices>,
mut assets: ResMut<Assets<AudioSource>>,
mut loops: Local<Loops>,
time: Res<Time<Real>>,
menu: Option<Res<crate::graphics_menu::Menu>>,
replay: Res<crate::replay::Replay>,
native: Option<Res<super::native::Native>>,
) {
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<usize> = (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));
@@ -1,6 +1,6 @@
//! `SKATE_AUDIO_STATE_LOG=<path>`: 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).
+13 -7
View File
@@ -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<ListenerView>,
}
/// 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);
}
+5 -9
View File
@@ -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(
+310 -3
View File
@@ -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 <trace>`.
*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 <trace>`:
- **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:<id>`.
- **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 <recomp sessions> <DIR:NAME,…>`,
`py -3.13 .local/audio-re/retire/riding_posters.py <recomp sessions>` (PYTHONIOENCODING=utf-8).
@@ -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
@@ -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/`.
+1
View File
@@ -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), `gaLine truncated
| 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/<name>/` (regression checks, setup equivalence, collision / world-stream / audio-file / vault readers, audio e2e and bench; recomp trace and code search, reference only) | [14](14-published-tools.md) | uncommitted (on `gameplay/audio` working tree) | `audio-e2e` / `audio-bench` / `audio-file-inspect` need J's native audio and setup modules; `setup-equivalence/compare_refpack.py` needs G's RefPack DLL; `regression-checks/check_maps.py` uses F's `--validate-maps` (falls back to `--check-assets` without it) | Not opened; the generic folders could go first, the audio ones with or after J |
Opened 2026-10-01 as drafts from per-PR branches cut from upstream `60efdef`
(code only; `docs/hails-additions` stays in the fork). #29 (G) is stacked on
+1
View File
@@ -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/<name>/`, Python and shell) | Done (uncommitted); scripts compile and print their usage; the two `recomp-*` folders are for the recomp's research hooks, reference only |
## Environment used for verification
+24
View File
@@ -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.
+51
View File
@@ -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=<path>`.
## 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).
+31
View File
@@ -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()
+20
View File
@@ -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
+35
View File
@@ -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/<set>-<mode>/ 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
+87
View File
@@ -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('<I', b, at + 4)[0]
if b[at:at + 4] == b'data':
return min(size, len(b) - at - 8)
at += 8 + size + (size & 1)
return 0
def bank_size(stem):
if stem not in _cache:
pcm = sum(2 * data_bytes(os.path.join(root, e['file'])) for e in m['banks'].get(stem, [])
if os.path.exists(os.path.join(root, e['file'])))
_cache[stem] = pcm + os.path.getsize(os.path.join(root, m['aems']['banks'][stem]))
return _cache[stem]
def records(ems_files):
"""The played native emitter records of these .ems files (kind 1, flags 0, bank in the install)."""
return [r for f in ems_files for r in m['emitters'].get(f, [])
if r['kind'] == 1 and r['flags'] == 0 and r.get('bank') and r['bank'] in m['aems']['banks']]
def table(groups):
for name, files in groups.items():
banks = list(dict.fromkeys(r['bank'] for r in records(files)))
if not banks:
continue
total = sum(bank_size(b) for b in banks)
big = sorted(((bank_size(b), b) for b in banks), reverse=True)[:3]
print(f'{name:24s} {len(banks):3d} banks {total / 2**20:7.1f} MiB largest: '
+ ', '.join(f'{b} {s / 2**20:.1f}' for s, b in big))
def parse_groups(values):
groups = {}
for v in values:
name, _, files = v.partition('=')
groups[name] = [f for f in files.split(',') if f]
return groups
def main():
parser = argparse.ArgumentParser(description='Resident memory of the world emitter banks per .ems file or group.')
parser.add_argument('--assets', default=os.environ.get('SKATE_ASSETS', 'assets'), help='asset root (default: %(default)s)')
parser.add_argument('--group', action='append', default=[], metavar='NAME=ems1,ems2', help='repeatable')
args = parser.parse_args()
load(args.assets)
groups = parse_groups(args.group) or {f: [f] for f in sorted(m.get('emitters', {}))}
table(groups)
if __name__ == '__main__':
sys.exit(main())
+63
View File
@@ -0,0 +1,63 @@
"""Simulate a distance prefetch of world emitter banks over a play session's audio state log (board
position as the listener) and report resident memory:
loaded = banks loaded into the runtime (on first start, kept until map change);
pending = banks prefetched (decoded) but not loaded yet = the extra memory of the prefetch.
Reach is approximated by the record's largest extent (the game's ellipsoid test is tighter, so this
over-counts loaded banks slightly).
usage: py -3.13 tools/audio-bench/prefetch_memory_sim.py STATE_LOG EMS[,EMS...] [--ahead M] [--evict M] [--assets DIR]
STATE_LOG: a log written with SKATE_AUDIO_STATE_LOG=<path> (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()
+44
View File
@@ -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()
+56
View File
@@ -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 (`<name>.ours.f32` against `<name>.<other>.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 `<name>.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: `<name>.ours.f32` (raw float32, 6 channels L R C LFE Ls Rs,
48 kHz, one settle second first) and `<name>.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=<path>`.
## Usage
```
py -3.13 tools/audio-e2e/scenarios.py .local/audio-e2e
set E2E_DIR=<repo>\.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
`<name>.<label>.f32` in the same folder and use `compare.py --b <label>`.
## Example output
`render_diff.py` (made-up values):
```
scenario same max|d| A dBFS B dBFS B-A dB
roll20 True 0 -24.31 -24.31 +0.00
grind_metal False 0.00012 -21.80 -21.75 +0.05
```
## Requirements
Python 3.13; `numpy` for `compare.py` and `render_diff.py`. Rust toolchain for the render test.
+32
View File
@@ -0,0 +1,32 @@
"""Voice starts per second and summed gain of one bank in e2e renders, per (tag, seam pattern) of the
scenario, for before/after comparisons (e.g. Class_Seams on a surface).
usage: py -3.13 bank_rate.py BANK DIR NAME [DIR ...] (a voice start = a frame where the bank has more
voices than in the frame before, counted by the increase)"""
import collections
import csv
import sys
if len(sys.argv) < 4 or sys.argv[1] in ('-h', '--help'):
sys.exit(__doc__)
bank, name, dirs = sys.argv[1], sys.argv[3], [sys.argv[2]] + sys.argv[4:]
for d in dirs:
rows = list(csv.DictReader(open(f'{d}/{name}.tsv'), delimiter='\t'))
count = collections.Counter()
gain = collections.defaultdict(float)
for r in csv.reader(open(f'{d}/{name}.ours.voices.tsv'), delimiter='\t'):
if r[0] != 'frame' and r[1] == bank:
count[int(r[0])] += 1
gain[int(r[0])] += float(r[3])
agg = collections.defaultdict(lambda: [0, 0, 0.0])
for f, r in enumerate(rows):
if float(r['speed']) < 2 or r['state'] != '100':
continue
k = (r['tag'], r.get('seam0', '?'))
a = agg[k]
a[0] += 1
a[1] += max(0, count[f] - count[f - 1]) if f else count[f]
a[2] += gain[f]
print(d)
for k, (n, s, g) in sorted(agg.items(), key=lambda x: -x[1][0]):
if n >= 30:
print(f' tag {k[0]:>3} pattern {k[1]:>2}: {n / 60:5.1f} s starts {s / (n / 60):5.1f}/s mean summed gain {g / n:.4f}')
+150
View File
@@ -0,0 +1,150 @@
"""End-to-end audio comparison: compare two headless renders of the same scenario in one folder
(`<name>.<A>.f32` and `<name>.<B>.f32`, raw f32, 6 channels L, R, C, LFE, Ls, Rs, 48 kHz, one settle
second first) per segment of the scenario (rows with the same state/air/grind/brake/manual/balance/speed
bucket form one segment). A = `ours` (the engine's e2e render); B = any other render of the same
scenario, renamed to `<name>.<B>.f32` (another build, or another audio stack such as upstream PR #4's
proof of concept).
Per segment and side: RMS (dBFS of the common fold L + 0.707*C + 0.5*Ls, no output gain),
octave-band levels 63 Hz .. 16 kHz, spectral centroid, dominant pitch (autocorrelation, 60–2000 Hz)
and the 10 ms envelope's onset count. Also the lag (ms) that best aligns the two envelopes and
their correlation (rolling "sync").
usage: py -3.13 tools/audio-e2e/compare.py [DIR] [--only a,b] [--a ours] [--b other] [--json OUT]
DIR defaults to .local/audio-e2e.
"""
import json
import math
import sys
from pathlib import Path
import numpy as np
DIR = Path(__file__).resolve().parents[2] / '.local/audio-e2e'
RATE = 48000
FRAME = 800 # samples per 60 Hz frame
BANDS = [63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
def load(path):
d = np.fromfile(path, dtype='<f4')
d = d[: len(d) // 6 * 6].reshape(-1, 6)
left = d[:, 0] + 0.707 * d[:, 2] + 0.5 * d[:, 4]
right = d[:, 1] + 0.707 * d[:, 2] + 0.5 * d[:, 5]
return np.stack([left, right], 1)[RATE:] # drop the settle second
def rows(tsv):
lines = tsv.read_text().splitlines()
cols = lines[0].split('\t')
return [dict(zip(cols, map(float, l.split('\t')))) for l in lines[1:]]
def segments(rs):
def key(r):
return (int(r['state']), int(r['air']), int(r['grinding']), int(r['brake']), int(r['balance']),
int(r['wheels']), round(r['speed'] * 3.6 / 5))
out, start = [], 0
for i in range(1, len(rs) + 1):
if i == len(rs) or key(rs[i]) != key(rs[start]):
out.append((start, i, rs[start]))
start = i
# merge tiny segments (< 0.25 s) into the previous one
merged = []
for s in out:
if merged and s[1] - s[0] < 15:
merged[-1] = (merged[-1][0], s[1], merged[-1][2])
else:
merged.append(s)
return merged
def db(x):
return 10 * math.log10(max(float(x), 1e-20))
def metrics(x):
if len(x) < 1024:
return None
mono = x.mean(1)
rms = db(np.mean(x * x))
spec = np.abs(np.fft.rfft(mono * np.hanning(len(mono)))) ** 2
freqs = np.fft.rfftfreq(len(mono), 1 / RATE)
total = spec.sum() + 1e-30
bands = []
for f in BANDS:
sel = (freqs >= f / math.sqrt(2)) & (freqs < f * math.sqrt(2))
bands.append(db(spec[sel].sum() / total))
centroid = float((freqs * spec).sum() / total)
# pitch: autocorrelation of the mono signal (first 0.5 s max)
seg = mono[: min(len(mono), RATE // 2)]
seg = seg - seg.mean()
ac = np.fft.irfft(np.abs(np.fft.rfft(seg, 2 * len(seg))) ** 2)[: len(seg)]
lo, hi = RATE // 2000, RATE // 60
pitch = float('nan')
if ac[0] > 0 and hi < len(ac):
k = lo + int(np.argmax(ac[lo:hi]))
if ac[k] / ac[0] > 0.3:
pitch = RATE / k
env = np.sqrt(np.mean(x[: len(x) // 480 * 480].reshape(-1, 480, 2) ** 2, axis=(1, 2)) + 1e-20)
e = 20 * np.log10(env)
onsets = int(np.sum((e[1:] - e[:-1]) > 6.0))
return dict(rms=rms, bands=bands, centroid=centroid, pitch=pitch, onsets=onsets, env=e)
def lag(ea, eb):
n = min(len(ea), len(eb))
if n < 20:
return float('nan'), float('nan')
a, b = ea[:n] - ea[:n].mean(), eb[:n] - eb[:n].mean()
best, bk = -2, 0
for k in range(-30, 31): # ±300 ms in 10 ms steps
if k >= 0:
c = np.corrcoef(a[k:], b[: n - k])[0, 1] if n - k > 10 else 0
else:
c = np.corrcoef(a[: n + k], b[-k:])[0, 1] if n + k > 10 else 0
if c > best:
best, bk = c, k
return bk * 10.0, float(best)
def main():
if '-h' in sys.argv or '--help' in sys.argv:
sys.exit(__doc__)
d = Path(sys.argv[1]) if len(sys.argv) > 1 and not sys.argv[1].startswith('--') else DIR
only = set(sys.argv[sys.argv.index('--only') + 1].split(',')) if '--only' in sys.argv else None
sa = sys.argv[sys.argv.index('--a') + 1] if '--a' in sys.argv else 'ours'
sb = sys.argv[sys.argv.index('--b') + 1] if '--b' in sys.argv else 'other'
report = {}
for tsv in sorted(d.glob('*.tsv')):
name = tsv.stem
if name.endswith('voices') or (only and name not in only):
continue
pa, pb = d / f'{name}.{sa}.f32', d / f'{name}.{sb}.f32'
if not (pa.exists() and pb.exists()):
continue
xa, xb = load(pa), load(pb)
rs = rows(tsv)
print(f'\n== {name} ({sa} vs {sb})')
print(f'{"segment":34s} {"RMS dB":>15s} {"d":>6s} {"centroid Hz":>13s} {"pitch Hz":>13s} onsets lag ms/corr octave bands d (63..16k, dB rel. total)')
report[name] = []
for a, b, r in segments(rs):
ma, mb = metrics(xa[a * FRAME:b * FRAME]), metrics(xb[a * FRAME:b * FRAME])
if not ma or not mb:
continue
label = f'{a / 60:4.1f}-{b / 60:4.1f}s st{int(r["state"])} {r["speed"] * 3.6:3.0f}kmh' + \
('' if not r['grinding'] else ' grind') + (' brake' if r['brake'] else '') + (' manual' if r['balance'] else '') + \
(' air' if r['air'] else '')
lg, corr = lag(ma['env'], mb['env'])
bd = ' '.join(f'{x - y:+5.1f}' for x, y in zip(ma['bands'], mb['bands']))
print(f'{label:34s} {ma["rms"]:6.1f} / {mb["rms"]:6.1f} {ma["rms"] - mb["rms"]:+6.1f} {ma["centroid"]:5.0f} / {mb["centroid"]:5.0f} '
f'{ma["pitch"]:5.0f} / {mb["pitch"]:5.0f} {ma["onsets"]:3d}/{mb["onsets"]:3d} {lg:+5.0f}/{corr:4.2f} {bd}')
report[name].append(dict(segment=label, rms=[ma['rms'], mb['rms']], centroid=[ma['centroid'], mb['centroid']],
pitch=[ma['pitch'], mb['pitch']], onsets=[ma['onsets'], mb['onsets']], lag=lg, corr=corr,
bands=[ma['bands'], mb['bands']]))
if '--json' in sys.argv:
Path(sys.argv[sys.argv.index('--json') + 1]).write_text(json.dumps(report, indent=1))
if __name__ == '__main__':
main()
+45
View File
@@ -0,0 +1,45 @@
"""Per-frame level of an e2e render next to the scenario's state: the folded stereo RMS (dBFS, the
title's 0.707/0.5/0.5 fold) and the summed per-voice gain of each voice group (grain bed, wheel
spin, splice one-shots, each AEMS bank), so you can see which voice is audible when.
usage: py -3.13 frame_levels.py DIR NAME [FROM TO STEP] (frames of the scenario; default all, step 3)
Reads DIR/NAME.tsv (scenario), DIR/NAME.ours.f32 (6 ch: L R C LFE Ls Rs), DIR/NAME.ours.voices.tsv."""
import csv
import math
import struct
import sys
if len(sys.argv) < 3 or sys.argv[1] in ('-h', '--help'):
sys.exit(__doc__)
from collections import defaultdict
from pathlib import Path
d, name = Path(sys.argv[1]), sys.argv[2]
a, b, step = (int(x) for x in sys.argv[3:6]) if len(sys.argv) > 5 else (0, 10 ** 9, 3)
rows = list(csv.DictReader(open(d / f'{name}.tsv'), delimiter='\t'))
raw = (d / f'{name}.ours.f32').read_bytes()
n = len(raw) // 24
pcm = struct.unpack(f'<{n * 6}f', raw[:n * 24])
SETTLE = 60
spf = 800 # 48000 / 60
voices = defaultdict(lambda: defaultdict(float))
groups = set()
for r in csv.DictReader(open(d / f'{name}.ours.voices.tsv'), delimiter='\t'):
bank = r['bank']
key = 'grain' if bank.startswith('grain') or bank == 'rocket' else ('splice' if bank.startswith('splice:') else bank)
key = 'direct' if key == '?' else key # Splice / stream voices as the mixer lists them
voices[int(r['frame'])][key] += float(r['gain'])
groups.add(key)
groups = sorted(groups)
print('frame state air wh speed dBFS ' + ' '.join(f'{g[:10]:>10}' for g in groups))
for f in range(a, min(b, len(rows)), step):
s0 = (SETTLE + f) * spf
acc = 0.0
for k in range(s0, min(s0 + spf, n)):
L, R, C, _, Ls, Rs = pcm[6 * k:6 * k + 6]
lo = 0.707 * L + 0.5 * C + 0.5 * Ls
ro = 0.707 * R + 0.5 * C + 0.5 * Rs
acc += lo * lo + ro * ro
rms = math.sqrt(acc / (2 * spf)) if acc > 0 else 0.0
db = 20 * math.log10(rms) if rms > 0 else -120.0
r = rows[f]
print(f"{f:5d} {r['state']:>5} {r['air']:>3} {r['wheels']:>3} {float(r['speed']):6.2f} {db:6.1f} "
+ ' '.join(f'{voices[f].get(g, 0):10.4f}' for g in groups))
+38
View File
@@ -0,0 +1,38 @@
"""Compare two directories of e2e renders (`<name>.ours.f32`, interleaved 6-channel f32 at 48 kHz):
per scenario bit-identical / max |difference| / RMS level of each (dBFS over the six channels) and
the level change. Headless; reads files only.
usage: py -3.13 tools/audio-e2e/render_diff.py DIR_A DIR_B
"""
import math
import sys
from pathlib import Path
import numpy as np
def rms_db(x):
r = float(np.sqrt(np.mean(np.square(x, dtype=np.float64)))) if x.size else 0.0
return 20 * math.log10(r) if r > 0 else -200.0
def main():
if len(sys.argv) != 3 or sys.argv[1] in ('-h', '--help'):
sys.exit(__doc__)
a, b = Path(sys.argv[1]), Path(sys.argv[2])
print(f"{'scenario':14} {'same':>5} {'max|d|':>10} {'A dBFS':>8} {'B dBFS':>8} {'B-A dB':>7}")
for fa in sorted(a.glob('*.ours.f32')):
fb = b / fa.name
if not fb.exists():
continue
xa = np.fromfile(fa, dtype='<f4')
xb = np.fromfile(fb, dtype='<f4')
n = min(xa.size, xb.size)
same = xa.size == xb.size and xa.tobytes() == xb.tobytes()
d = float(np.max(np.abs(xa[:n] - xb[:n]))) if n else 0.0
la, lb = rms_db(xa), rms_db(xb)
print(f"{fa.name.split('.')[0]:14} {str(same):>5} {d:10.3g} {la:8.2f} {lb:8.2f} {lb - la:+7.2f}")
if __name__ == '__main__':
main()
+136
View File
@@ -0,0 +1,136 @@
"""End-to-end audio, step 1: write the scripted per-frame player situations the headless render plays
(`cargo test -p skate-game --release --bin skate3rust -- --ignored e2e_render`, with E2E_DIR=OUT).
Any other audio stack that reads the same TSVs can render them too, for compare.py. One TSV per
scenario in OUT (default .local/audio-e2e/), one row per 60 Hz frame, columns:
frame speed turn wheels(mask, bit i = wheel i down) tag air air_time to_land jump_height jv
grinding family grind_tag brake manual balance scorable push slope tilt slip feet state
speed m/s along +x; tag = the wheels' 7-bit audio surface tag; air = in known air; jv = jump
velocity delta (m/s, Air+112 y, written while the air flag 440 holds); scorable = EScorableID (-1
none, 128 ollie); state = the physical state id (100 ground, 201 known air, 400 grind, 101 slide).
usage: py -3.13 tools/audio-e2e/scenarios.py [OUT]
py -3.13 tools/audio-e2e/scenarios.py --from-log LOG --cut A-B NAME [OUT]
--from-log: cut a real-play log (`SKATE_AUDIO_STATE_LOG=<path>` while playing; same columns plus ms
and positions) to a scenario: A-B in seconds of the log's own clock (column ms / 1000); frames
renumbered from 0.
--cut rA-B NAME: rows A..B of the log instead (its clock restarts at map loads).
"""
import math
import sys
from pathlib import Path
OUT = Path(sys.argv[1]) if len(sys.argv) > 1 and not sys.argv[1].startswith('--') else Path(__file__).resolve().parents[2] / '.local/audio-e2e'
COLS = ['frame', 'speed', 'turn', 'wheels', 'tag', 'air', 'air_time', 'to_land', 'jump_height', 'jv', 'grinding',
'family', 'grind_tag', 'brake', 'manual', 'balance', 'scorable', 'push', 'slope', 'tilt', 'slip', 'feet', 'state']
CONCRETE = 3 # concrete (rolling grain asphalt/concrete family; both stacks resolve the tag themselves)
METAL = 9
LEDGE = 3
def row(**kw):
r = dict(speed=0.0, turn=0.0, wheels=15, tag=CONCRETE, air=0, air_time=0.0, to_land=0.0, jump_height=0.0, jv=0.0,
grinding=0, family=0, grind_tag=0, brake=0, manual=0, balance=0, scorable=-1, push=0, slope=0.0,
tilt=0.0, slip=0.0, feet=3, state=100)
r.update(kw)
return r
def ramp(frames, a, b):
return [a + (b - a) * i / max(1, frames - 1) for i in range(frames)]
def scenarios():
out = {}
for kmh in (10, 20, 30, 45):
out[f'roll{kmh}'] = [row(speed=kmh / 3.6) for _ in range(6 * 60)]
# Carve at 20 km/h: turn input sine ±0.8, period 2 s, after 1 s straight.
out['carve20'] = [row(speed=20 / 3.6, turn=0.0 if f < 60 else 0.8 * math.sin(2 * math.pi * (f - 60) / 120)) for f in range(6 * 60)]
# Speed sweep 0 → 40 km/h over 6 s then back (rolling sync / pitch tracking).
sweep = ramp(360, 0.0, 40 / 3.6) + ramp(240, 40 / 3.6, 0.0)
out['sweep'] = [row(speed=v) for v in sweep]
# Ollie at 20 km/h: 1.5 s roll, takeoff (ollie scorable, jump velocity), 0.7 s air, land, 2 s roll.
seq = [row(speed=20 / 3.6) for _ in range(90)]
air = 42
for i in range(air):
t = i / 60
seq.append(row(speed=20 / 3.6, wheels=0, air=1, air_time=t, to_land=max(0.0, air / 60 - t), jump_height=0.6,
jv=3.2 if i == 0 else 0.0, scorable=128, state=201))
seq += [row(speed=20 / 3.6) for _ in range(120)]
out['ollie20'] = seq
# Rail grind (metal, family 1 = 50-50) and ledge grind (concrete, family 0) at 6 m/s for 2.5 s.
for name, tag, fam in (('grind_metal', METAL, 1), ('grind_ledge', LEDGE, 0)):
seq = [row(speed=6.0) for _ in range(60)]
seq += [row(speed=6.0, wheels=0, grinding=1, family=fam, grind_tag=tag, state=400) for _ in range(150)]
seq += [row(speed=6.0) for _ in range(60)]
out[name] = seq
# Manual at 15 km/h: back wheels only, balance flag.
seq = [row(speed=15 / 3.6) for _ in range(60)]
seq += [row(speed=15 / 3.6, wheels=0b1100, balance=1, state=100) for _ in range(150)]
seq += [row(speed=15 / 3.6) for _ in range(60)]
out['manual15'] = seq
# Foot brake from 20 km/h to 2 km/h over 2.5 s.
seq = [row(speed=20 / 3.6) for _ in range(60)]
seq += [row(speed=v, brake=1) for v in ramp(150, 20 / 3.6, 2 / 3.6)]
seq += [row(speed=2 / 3.6) for _ in range(30)]
out['brake20'] = seq
# Powerslide at 20 km/h: lateral slip, tilt, slide state.
seq = [row(speed=20 / 3.6) for _ in range(60)]
seq += [row(speed=v, slip=0.8, tilt=0.25, state=101) for v in ramp(90, 20 / 3.6, 8 / 3.6)]
seq += [row(speed=8 / 3.6) for _ in range(60)]
out['slide20'] = seq
return out
# Optional real-play columns (state_log.rs): copied when the log has them.
EXTRA = ['grind_impact', 'deck_impact', 'deck_tag', 'foot_y0', 'foot_y1', 'foot_xz0', 'foot_xz1',
'seam0', 'seam3', 'wheel_x', 'wheel_z', 'heading', 'lines', 'foot_down', 'foot_tag_a', 'foot_tag_b', 'hands',
'strength', 'foot_vy_a', 'foot_vy_b', 'step', 'body', 'limb', 'slide', 'deck_up', 'deck_contact',
# the push plant, flags, deck spin, body regions.
'plant', 'stroke', 'deck_spin', 'spin_x', 'spin_y', 'bail', 'bail_end', 'held', 'offboard_air', 'footplant',
'revert', 'soft', 'face'] + [f'{k}{i}' for k in ('rimp', 'rslide', 'rtag') for i in range(6)]
def from_log(log, cut, name, out):
lines = Path(log).read_text().splitlines()
cols = lines[0].split('\t')
rows = [dict(zip(cols, l.split('\t'))) for l in lines[1:] if l.count('\t') == len(cols) - 1]
if cut.startswith('r'):
# rA-B: rows A..B (0-based, inclusive), for logs whose clock restarts at map loads.
a, b = (int(x) for x in cut[1:].split('-'))
rows = rows[a:b + 1]
else:
a, b = (float(x) for x in cut.split('-'))
t0 = float(rows[0]['ms'])
rows = [r for r in rows if a <= (float(r['ms']) - t0) / 1000 <= b]
out.mkdir(parents=True, exist_ok=True)
cols_out = COLS + [c for c in EXTRA if c in cols]
with (out / f'{name}.tsv').open('w', newline='\n') as f:
f.write('\t'.join(cols_out) + '\n')
for i, r in enumerate(rows):
r['frame'] = str(i)
f.write('\t'.join(r[c] for c in cols_out) + '\n')
print(name, len(rows), 'frames from', log)
def main():
if '-h' in sys.argv or '--help' in sys.argv:
sys.exit(__doc__)
if '--from-log' in sys.argv:
i = sys.argv.index('--from-log')
log, cut, name = sys.argv[i + 1], sys.argv[sys.argv.index('--cut') + 1], sys.argv[sys.argv.index('--cut') + 2]
rest = [x for x in sys.argv[1:] if x not in (log, cut, name, '--from-log', '--cut')]
from_log(log, cut, name, Path(rest[0]) if rest else OUT)
return
OUT.mkdir(parents=True, exist_ok=True)
for name, rows in scenarios().items():
with (OUT / f'{name}.tsv').open('w', newline='\n') as f:
f.write('\t'.join(COLS) + '\n')
for i, r in enumerate(rows):
r['frame'] = i
f.write('\t'.join(f'{r[c]:.6f}' if isinstance(r[c], float) else str(r[c]) for c in COLS) + '\n')
print(name, len(rows), 'frames')
if __name__ == '__main__':
main()
+22
View File
@@ -0,0 +1,22 @@
"""Per-frame voice gains by bank from an e2e render's <name>.ours.voices.tsv.
usage: py -3.13 voices_summary.py VOICES_TSV [FROM TO STEP] (frames of the scenario)."""
import csv
import sys
if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'):
sys.exit(__doc__)
from collections import defaultdict
rows = list(csv.DictReader(open(sys.argv[1]), delimiter='\t'))
a, b, step = (int(x) for x in sys.argv[2:5]) if len(sys.argv) > 4 else (0, 10 ** 9, 6)
by = defaultdict(lambda: defaultdict(float))
banks = set()
for r in rows:
f = int(r['frame'])
key = r['bank'] if not r['bank'].startswith('splice:') else 'splice'
key = 'grain' if r['slot'].startswith('grain') else key
by[f][key] += float(r['gain'])
banks.add(key)
banks = sorted(banks)
print('frame ' + ' '.join(f'{k[:14]:>14}' for k in banks))
for f in range(a, min(b, max(by) + 1 if by else 0), step):
print(f'{f:5d} ' + ' '.join(f'{by[f].get(k, 0):14.4f}' for k in banks))
+40
View File
@@ -0,0 +1,40 @@
"""The bus (wet) contribution of an e2e render: RMS of (wet render − dry render) against the dry RMS, over
the scenario's rolling frames, folded to stereo with the title's 0.707/0.5/0.5 table.
usage: py -3.13 wet_level.py DRY_DIR WET_DIR NAME [WET_DIR ...]"""
import csv
import math
import struct
import sys
if len(sys.argv) < 4 or sys.argv[1] in ('-h', '--help'):
sys.exit(__doc__)
dry_dir, name, wets = sys.argv[1], sys.argv[3], [sys.argv[2]] + sys.argv[4:]
def load(d):
raw = open(f'{d}/{name}.ours.f32', 'rb').read()
return struct.unpack(f'<{len(raw) // 4}f', raw)
rows = list(csv.DictReader(open(f'{dry_dir}/{name}.tsv'), delimiter='\t'))
dry = load(dry_dir)
SETTLE, SPF = 60, 800
frames = [f for f, r in enumerate(rows) if float(r['speed']) > 2 and r['state'] == '100']
def fold(p, k):
L, R, C, _, Ls, Rs = p[6 * k:6 * k + 6]
return 0.707 * L + 0.5 * C + 0.5 * Ls, 0.707 * R + 0.5 * C + 0.5 * Rs
for d in wets:
wet = load(d)
n = min(len(wet), len(dry)) // 6
ed = ew = 0.0
for f in frames:
for k in range((SETTLE + f) * SPF, min((SETTLE + f + 1) * SPF, n)):
dl, dr = fold(dry, k)
wl, wr = fold(wet, k)
ed += dl * dl + dr * dr
ew += (wl - dl) ** 2 + (wr - dr) ** 2
print(f'{d}: dry {10 * math.log10(ed / (2 * SPF * len(frames))):.1f} dBFS, bus part {10 * math.log10(ew / ed):+.1f} dB re dry')
+63
View File
@@ -0,0 +1,63 @@
# Audio file inspection
Readers for Skate 3's audio formats, run against your own disc. They check the layouts the native
audio runtime (`crates/skate-audio`) and the audio setup (`tools/asset_pipeline/audio_export.py`) rely
on, and let you look inside a bank, an emitter file or the grain data. All read-only, all headless.
| Script | What it does |
|---|---|
| `aems_survey.py` | Surveys every ABKC bank (`.abk`) and MOIR project (`.csi`) in `audiofiles.big`: header invariants, records per bank, capacities, program opcode census, export kinds, voice objects, curve types, and a failure list. `--dump BANK.abk` prints one bank's records, instance layout and program. |
| `bank_layout_check.py` | Checks that each bank's S10A slot order matches the stream scan setup uses (so exported WAV index = slot index), and whether the rebase / interface lists lie after the sample data (they do, so the runtime needs the whole `.abk`). `--extract DIR` also writes every `.abk` / `.csi` (plus `csi_order.txt`) for the tools below. |
| `decode_bank_samples.py` | Decodes chosen banks' samples to WAVs the way setup does (`<out>/<stem>/NNNN.wav`, slot order), for banks the install does not export. Uses vgmstream-cli. |
| `multichannel_census.py` | Which sample-group entries of the banks' player objects use multichannel (2/4/6-channel) samples, and their azimuth bytes. |
| `splc_fields.py` | Census of SPLC (`.bnk`) record, group and member fields: value ranges per 4-byte offset (as f32 and u32). With a bank and record id, dumps that record raw. |
| `ems_dump.py` | Dumps the world emitter files (`.ems`): records per file, the most used sounds (64-bit name ids resolved against the archive's member names), and with `--records` every record's position, extent, scalars and gains. |
| `grain_survey.py` | Surveys the granular rolling-bed data: per `.grain` member the header, seek table and EAAC stream header; per grain class the vault tuning (from the install's converted database). `--rms` adds a loudness / spectral-centroid profile per member. Writes JSON. |
## Inputs
- Your extracted disc: `--disc DIR` (the folder holding `data/`), default `$SKATE3_DISC` or `.local/skate3-disc`.
- Extracted banks for `decode_bank_samples.py` / `multichannel_census.py`: run
`bank_layout_check.py --extract .local/audio-file-inspect/banks` first (the default location).
- `.bnk` files for `splc_fields.py`: extract them with
`tools/world-stream-inspect/big_list.py <disc>/data/audio/audiofiles.big "\.bnk$" --out DIR`.
- `grain_survey.py` vault part: `assets/private/stock/skater-collections.json` from setup (`--vault`).
Outputs go to `.local/audio-file-inspect/` (gitignored). They are decoded game data: keep them local,
never commit them.
## Usage
```
py -3.13 tools/audio-file-inspect/aems_survey.py
py -3.13 tools/audio-file-inspect/aems_survey.py --dump SomeBank.abk
py -3.13 tools/audio-file-inspect/bank_layout_check.py --extract .local/audio-file-inspect/banks
py -3.13 tools/audio-file-inspect/decode_bank_samples.py SomeBank OtherBank
py -3.13 tools/audio-file-inspect/multichannel_census.py
py -3.13 tools/audio-file-inspect/splc_fields.py .local/bnk
py -3.13 tools/audio-file-inspect/ems_dump.py sfx_ --records
py -3.13 tools/audio-file-inspect/grain_survey.py --rms
```
## Example output
`bank_layout_check.py` (made-up counts):
```
120 banks; slot order mismatches 0; banks whose rebase/interface lists lie after the sample data (so the whole file is needed, not just the resident part): 120
```
`ems_dump.py` (made-up names):
```
data/audio/sfx_example.ems: 42 records; top: [('water_loop', 12), ('?0123456789ABCDEF', 3)]
records 42, sound ids resolved 39
```
## Requirements
- Python 3.13; `numpy` only for `grain_survey.py --rms`.
- The repository's setup modules (`tools/owned_game`, `tools/asset_pipeline/audio_formats.py`,
`audio_export.py`).
- vgmstream-cli for `decode_bank_samples.py` and `grain_survey.py --rms`: setup downloads it to
`data/tools/vgmstream-cli/`; or pass `--vgmstream` / set `VGMSTREAM`.
+233
View File
@@ -0,0 +1,233 @@
"""Independent survey of every ABKC bank (.abk) and MOIR project (.csi) in audiofiles.big: checks the
bank layout the native AEMS runtime (crates/skate-audio) relies on and prints histograms (records,
capacities, program opcodes, export kinds, voice objects, curve types). Our own reader.
usage: py -3.13 tools/audio-file-inspect/aems_survey.py [--dump BANK.abk] [--disc DIR]
Prints invariant checks and histograms; with --dump, a readable dump of one bank's records and programs.
"""
import argparse
import os
import struct
import sys
from collections import Counter, defaultdict
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO))
from tools.owned_game.big import BigArchive # noqa: E402
def u8(d, o): return d[o]
def u16(d, o): return struct.unpack_from('>H', d, o)[0]
def s16(d, o): return struct.unpack_from('>h', d, o)[0]
def u32(d, o): return struct.unpack_from('>I', d, o)[0]
def s32(d, o): return struct.unpack_from('>i', d, o)[0]
def f32(d, o): return struct.unpack_from('>f', d, o)[0]
def cstr(d, o):
e = d.index(b'\0', o)
return d[o:e].decode('latin1')
class Csi:
def __init__(self, name, d):
self.name, self.d = name, d
assert d[:4] == b'MOIR'
self.counts = (u16(d, 0x0A), u16(d, 0x0C), u16(d, 0x0E))
self.project = u16(d, 0x10)
self.tables = []
at = 0x28
for t, n in enumerate(self.counts):
stride = 16 if t == 2 else 12
rows = []
for i in range(n):
r = at + stride * i
if t == 2:
rows.append(dict(off=r, head=u32(d, r), value=s32(d, r + 4), name=cstr(d, u32(d, r + 8)),
id=u16(d, r + 12), gen=u16(d, r + 14)))
else:
rows.append(dict(off=r, head=u32(d, r), name=cstr(d, u32(d, r + 4)), id=u16(d, r + 8),
gen=u16(d, r + 10)))
self.tables.append(rows)
at += stride * n
self.pool = at
def rebased(d):
"""Apply the bank's rebase list with base 0, so rebased words read as bank offsets."""
b = bytearray(d)
rl = u32(d, 0x34)
n = u32(d, rl)
sites = set()
for i in range(n):
site = u32(d, rl + 4 + 4 * i)
sites.add(site)
return bytes(b), sites
def records(d):
n = u16(d, 0x0A)
at = u32(d, 0x1C)
out = []
for _ in range(n):
r = dict(at=at, sym=u32(d, at + 4), symid=u32(d, at + 8), live=u16(d, at + 28), cap=u16(d, at + 30),
nsubs=u16(d, at + 32), nbc=u16(d, at + 34), nvoice=u8(d, at + 36), release=u8(d, at + 37),
payload=u8(d, at + 38), nheld=u8(d, at + 39), prog=u32(d, at + 40), tmpl=u32(d, at + 44),
tsize=u32(d, at + 48), triple=u32(d, at + 52), livehead=u32(d, at + 56))
k = r['nvoice'] + r['nheld']
r['objs'] = [u32(d, at + 60 + 4 * i) for i in range(k)]
out.append(r)
at += 60 + 4 * k
return out
def program(d, r):
pc, blk, ops = r['prog'], 24, []
while d[pc] != 255:
op, np_ = d[pc], d[pc + 1]
pairs = [(s32(d, pc + 4 + 8 * p), s32(d, pc + 8 + 8 * p)) for p in range(np_)]
adv = s32(d, pc + 4 + 8 * np_)
ops.append((op, blk, pairs, adv, u16(d, pc + 2)))
blk += adv
pc += 8 + 8 * np_
return ops, blk
def entries_layout(d, r):
"""Walk the instance entries from +24 the way the allocator does; return the end offset."""
t = r['tmpl']
e = 24
lay = []
if r['release']:
lay.append(('release', e, 20)); e += 20
for _ in range(r['nsubs']):
lay.append(('varsub', e, 28)); e += 28
if r['payload']:
n = u8(d, t + e + 16)
lay.append(('payload', e, (n + 5) * 4, n)); e += (n + 5) * 4
for _ in range(r['nbc']):
n = u8(d, t + e + 24)
lay.append(('bcast', e, (n + 7) * 4, n)); e += (n + 7) * 4
return lay, e
def main():
parser = argparse.ArgumentParser(description='Survey every ABKC bank and MOIR project in audiofiles.big.')
parser.add_argument('--dump', metavar='BANK.abk', help="dump one bank's records and programs")
parser.add_argument('--disc', type=Path, default=Path(os.environ.get('SKATE3_DISC', REPO / '.local/skate3-disc')),
help='extracted disc root, the folder holding data/ (default: %(default)s)')
args = parser.parse_args()
big = BigArchive(args.disc / 'data/audio/audiofiles.big')
csis = {}
banks = {}
for e in big.entries:
n = Path(e.path).name
if n.endswith('.csi'):
csis[n] = Csi(n, big.read(e))
elif n.endswith('.abk'):
banks[n] = big.read(e)
symbol_names = {}
for c in csis.values():
for t, rows in enumerate(c.tables):
for row in rows:
symbol_names[(t, row['id'], row['name'])] = c.name
if args.dump:
dump(banks[args.dump])
return
print(f'{len(banks)} banks, {len(csis)} csi')
for c in csis.values():
print(f' {c.name:28s} project {c.project:#06x} tables {c.counts} pool@{c.pool:#x} heads0='
f'{sum(r["head"] for t in c.tables for r in t)} gens0={sum(r["gen"] for t in c.tables for r in t)}')
hdr = Counter(); nrec = Counter(); ops = Counter(); caps = Counter(); fails = []
kinds = Counter(); kinds_by_suffix = defaultdict(Counter)
curve_types = Counter(); curve_scale_one = Counter(); param_ids = Counter(); flags1517 = Counter()
desc_counts = Counter(); end_ok = 0; first_ops = Counter(); last_ops = Counter(); entry_ok = 0
payload_words = Counter(); triple_ok = 0; obj_after_entries = 0; voice_obj_sizes = Counter()
adv_neg = 0; unread = Counter()
for name, d in banks.items():
hdr[(u32(d, 4), u16(d, 8))] += 1
if u32(d, 0x14) != len(d): fails.append((name, 'size'))
if u32(d, 0x18) != u32(d, 0x20): fails.append((name, 's10a twice'))
if u32(d, 0x1C) != 0x5C: fails.append((name, 'first record'))
if d[u32(d, 0x18):u32(d, 0x18) + 4] != b'S10A': fails.append((name, 'S10A'))
if u32(d, u32(d, 0x30)) != 0: fails.append((name, 'code fixups non-empty'))
nrec[u16(d, 0x0A)] += 1
ex = u32(d, 0x38)
for i in range(u32(d, ex)):
tgt, rec, kind = struct.unpack_from('>III', d, ex + 4 + 12 * i)
nm = cstr(d, rec + 4)
kinds[kind >> 24] += 1
suf = nm.rsplit('_', 1)[-1] if '_' in nm else nm
kinds_by_suffix[kind >> 24][suf if suf in ('msg', 'snd', 'vol', 'gbl', 'sel') else
('c_/Class' if nm.lower().startswith(('c_', 'class')) else 'other')] += 1
for r in records(d):
caps[r['cap']] += 1
if r['live'] != 0: fails.append((name, 'live nonzero on disk'))
o, endblk = program(d, r)
for op, blk, pairs, adv, un in o:
ops[op] += 1
unread[un] += 1
if adv < 0: adv_neg += 1
first_ops[tuple(x[0] for x in o[:3])] += 1
last_ops[tuple(x[0] for x in o[-2:])] += 1
if endblk == r['tsize'] - 16 + 0 or endblk == r['triple'] + 16:
end_ok += 1
if r['triple'] == r['tsize'] - 16:
triple_ok += 1
lay, eend = entries_layout(d, r)
entry_ok += 1
for L in lay:
if L[0] == 'payload': payload_words[L[3]] += 1
t = r['tmpl']
for i, off in enumerate(r['objs'][:r['nvoice']]):
obj = t + off
tab = u32(d, obj + 4)
n = u32(d, tab)
desc_counts[n] += 1
nrec_ = u8(d, obj + 14)
flags1517[(u8(d, obj + 15), u8(d, obj + 17))] += 1
for k in range(nrec_):
param_ids[u8(d, obj + 28 + 12 * k)] += 1
if off < eend: obj_after_entries += 1
for op, blk, pairs, adv, un in o:
if op == 15:
c = u32(d, t + blk)
curve_types[u8(d, c)] += 1
curve_scale_one[f32(d, c + 12) == 1.0] += 1
print('header (word 4, u16 at 8):', dict(hdr))
print('records per bank:', dict(nrec), 'total', sum(k * v for k, v in nrec.items()))
print('capacities:', dict(caps.most_common()))
print('opcode census:', dict(sorted(ops.items())))
print('unread u16 field values:', dict(unread))
print('negative block advances:', adv_neg)
print('program end block == triple+16:', end_ok, ' triple == tsize-16:', triple_ok)
print('first three ops:', first_ops.most_common(5))
print('last two ops:', last_ops.most_common(5))
print('export kind top byte:', dict(kinds))
for k, c in sorted(kinds_by_suffix.items()):
print(f' kind {k}:', dict(c.most_common()))
print('payload word counts:', dict(sorted(payload_words.items())))
print('voice objects: descriptor counts', dict(sorted(desc_counts.items())))
print('voice objects: (+15 time copy-back, +17 extra copy-back):', dict(flags1517))
print('voice param ids:', dict(sorted(param_ids.items())))
print('voice objects placed inside the entry area (should be 0):', obj_after_entries)
print('curve sample types:', dict(curve_types), ' scale==1.0 (nearest):', dict(curve_scale_one))
print('failures:', fails[:20], len(fails))
def dump(d):
for r in records(d):
print({k: (hex(v) if isinstance(v, int) else v) for k, v in r.items()})
print(' entries:', entries_layout(d, r))
o, e = program(d, r)
for op, blk, pairs, adv, un in o:
ps = ', '.join(f'R->{blk + dst}' if src == -1 else f'{blk + src}->{blk + dst}' for src, dst in pairs)
print(f' op{op:<2} blk {blk:<5} [{ps}] adv {adv}')
if __name__ == '__main__':
main()
@@ -0,0 +1,81 @@
"""Check facts the native AEMS runtime relies on, over every ABKC bank in audiofiles.big:
- the S10A slot order equals the order audio_formats.scan_snr finds the streams in (so the exported
WAV index = the S10A slot index);
- whether the rebase and interface lists lie below residentsize (on every bank they sit AFTER the
sample data, at the end of the file, so the runtime needs the whole .abk).
Optionally extracts every .abk/.csi to a folder (input for the other tools here): --extract DIR, which also
writes csi_order.txt (the .csi projects in archive order).
usage: py -3.13 tools/audio-file-inspect/bank_layout_check.py [--extract DIR] [--verbose] [--disc DIR]
"""
import argparse
import os
import struct
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO))
from tools.owned_game.big import BigArchive # noqa: E402
from tools.asset_pipeline.audio_formats import scan_snr # noqa: E402
def u32(d, o):
return struct.unpack_from('>I', d, o)[0]
def main():
parser = argparse.ArgumentParser(description='Check the ABKC bank layout facts the native AEMS runtime relies on.')
parser.add_argument('--extract', type=Path, metavar='DIR', help='also extract every .abk/.csi here')
parser.add_argument('--verbose', action='store_true', help='list every bank whose lists lie after the sample data')
parser.add_argument('--disc', type=Path, default=Path(os.environ.get('SKATE3_DISC', REPO / '.local/skate3-disc')),
help='extracted disc root, the folder holding data/ (default: %(default)s)')
args = parser.parse_args()
extract = args.extract
if extract:
extract.mkdir(parents=True, exist_ok=True)
big = BigArchive(args.disc / 'data/audio/audiofiles.big')
banks = bad_order = bad_resident = 0
if extract:
# .csi projects in archive order (the install order; lookup ties go newest first).
order = [Path(e.path).name for e in big.entries if e.path.lower().endswith('.csi')]
(extract / 'csi_order.txt').write_text(''.join(name + '\n' for name in order), encoding='utf-8')
for e in big.entries:
name = Path(e.path).name
if not name.lower().endswith(('.abk', '.csi')):
continue
d = big.read(e)
if extract:
(extract / name).write_bytes(d)
if d[:4] != b'ABKC':
continue
banks += 1
resident = u32(d, 0x18)
sfx = u32(d, 0x20)
assert d[sfx:sfx + 4] == b'S10A', name
cap = u32(d, sfx + 8)
slots = []
for i in range(cap):
off = u32(d, sfx + 12 + 4 * i)
if off == 0xFFFFFFFF:
break
slots.append(sfx + off)
scanned = [s.offset for s in scan_snr(d, sfx)]
if scanned[:len(slots)] != slots or len(scanned) != len(slots):
bad_order += 1
print(f'ORDER {name}: slots {len(slots)} scanned {len(scanned)} first diff '
f'{next((i for i, (a, b) in enumerate(zip(slots, scanned)) if a != b), None)}')
rl, il = u32(d, 0x34), u32(d, 0x38)
n = u32(d, rl)
ends = [rl + 4 + 4 * n, il + 4 + 12 * u32(d, il)]
if max(ends) > resident or sfx != resident:
bad_resident += 1
if args.verbose:
print(f'RESIDENT {name}: resident {resident:#x} sfx {sfx:#x} rebase end {ends[0]:#x} iface end {ends[1]:#x}')
print(f'{banks} banks; slot order mismatches {bad_order}; banks whose rebase/interface lists lie after the '
f'sample data (so the whole file is needed, not just the resident part): {bad_resident}')
if __name__ == '__main__':
main()
@@ -0,0 +1,50 @@
"""Decode a disc bank's samples to WAVs the way setup does (S10A slot i = <out>/<stem>/<i:04d>.wav),
for banks the install does not export (e.g. PatchBank_SpiderCracks). Headless, with vgmstream-cli.
usage: py -3.13 tools/audio-file-inspect/decode_bank_samples.py STEM [STEM...] [--banks DIR] [--out DIR] [--vgmstream EXE]
--banks extracted disc banks (default .local/audio-file-inspect/banks, from bank_layout_check.py --extract)
--out output root (default .local/audio-file-inspect/bank-wavs)
--vgmstream vgmstream-cli (default: the one setup downloads, data/tools/vgmstream-cli/vgmstream-cli.exe)
Reference data for local tests only; never commit the output."""
import argparse
import subprocess
import sys
import tempfile
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO))
from tools.asset_pipeline.audio_export import _streams # noqa: E402
from tools.asset_pipeline.audio_formats import standalone # noqa: E402
VGMSTREAM = REPO / 'data/tools/vgmstream-cli/vgmstream-cli.exe'
def main():
parser = argparse.ArgumentParser(description="Decode a disc bank's samples to WAVs the way setup does.")
parser.add_argument('stems', nargs='+', help='bank stems, e.g. PatchBank_SpiderCracks')
parser.add_argument('--banks', type=Path, default=REPO / '.local/audio-file-inspect/banks')
parser.add_argument('--out', type=Path, default=REPO / '.local/audio-file-inspect/bank-wavs')
parser.add_argument('--vgmstream', type=Path, default=VGMSTREAM)
args = parser.parse_args()
banks, out = args.banks, args.out
for stem in args.stems:
data = (banks / f'{stem}.abk').read_bytes()
streams = _streams(stem, data)
target = out / stem
target.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
for i, stream in enumerate(streams):
snr = work / f'{i:04d}.snr'
snr.write_bytes(standalone(data, stream))
wav = target / f'{i:04d}.wav'
done = subprocess.run([str(args.vgmstream), '-i', '-o', str(wav), str(snr)], capture_output=True,
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
if done.returncode or not wav.is_file():
sys.exit(f'{stem} #{i}: vgmstream failed: {done.stderr.decode(errors="replace")[-400:]}')
print(f'{stem}: {len(streams)} samples -> {target}')
if __name__ == '__main__':
main()
+62
View File
@@ -0,0 +1,62 @@
"""Dump the world emitter files (.ems) in audiofiles.big: per file the record count and the most used
sounds, and with --records every record (index, flags, position, extent, scalars, sound, gains).
Sound ids are 64-bit name ids (tools/asset_pipeline/audio_formats.name_id); they are resolved against
the stems of every member of the same archive (as-is and lower case), unresolved ids print as ?<hex>.
usage: py -3.13 tools/audio-file-inspect/ems_dump.py [FILTER ...] [--records] [--disc DIR]
FILTER: substrings of the .ems path (e.g. sfx_university); default every .ems file.
"""
import argparse
import os
import sys
from collections import Counter
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO))
from tools.owned_game.big import BigArchive # noqa: E402
from tools.asset_pipeline.audio_formats import ems_emitters, name_id # noqa: E402
def main():
parser = argparse.ArgumentParser(description='Dump the .ems world emitter files of audiofiles.big.')
parser.add_argument('filters', nargs='*', help='substrings of the .ems path (default: all)')
parser.add_argument('--records', action='store_true', help='print every record')
parser.add_argument('--disc', type=Path, default=Path(os.environ.get('SKATE3_DISC', REPO / '.local/skate3-disc')),
help='extracted disc root, the folder holding data/ (default: %(default)s)')
args = parser.parse_args()
big = BigArchive(args.disc / 'data/audio/audiofiles.big')
lookup = {}
for e in big.entries:
stem = Path(e.path).stem
for v in (stem, stem.lower()):
try:
lookup[name_id(v)] = stem
except UnicodeEncodeError:
pass
total = resolved = 0
for e in big.entries:
if not e.path.lower().endswith('.ems'):
continue
if args.filters and not any(f.lower() in e.path.lower() for f in args.filters):
continue
records = ems_emitters(big.read(e))
names = Counter()
for r in records:
name = lookup.get(r['sound_id'], '?%016X' % r['sound_id'])
names[name] += 1
total += 1
resolved += not name.startswith('?')
print(f'{e.path}: {len(records)} records; top: {names.most_common(10)}')
if args.records:
for r in records:
name = lookup.get(r['sound_id'], '?%016X' % r['sound_id'])
print(f" #{r['index']:<4} flags {r['flags']:<3} {name:32} pos {[round(v, 2) for v in r['position']]} "
f"ext {[round(v, 2) for v in r['extent']]} scalars {[round(v, 3) for v in r['scalars']]} "
f"gains {[round(v, 3) for v in r['gains']]}")
print(f'records {total}, sound ids resolved {resolved}')
if __name__ == '__main__':
main()
+348
View File
@@ -0,0 +1,348 @@
"""Survey of Skate 3's granular rolling-bed data (grains.big + the vault tuning).
Our own reader (no code copied from the game). Writes a small JSON summary to
.local/audio-file-inspect/grain_survey.json (--out DIR):
- per .grain member: header length, stored duration, seek-table header, every seek-table row
(byte step, side step, samples, key flag), side-data size, EAAC header (codec, channels,
rate, samples) and whether row 0 spans the whole stream;
- per grain-class vault collection (class 0x7AB23C11B6ADA2DE): filename, max km/h, Bezier
control points, GrainParams, intensity cap, boost/shift values, push envelope values;
- the owner/rocket tuning (class 0x6E878344774A7999 'default').
Optional --rms: decode each member with vgmstream-cli (VGMSTREAM env, PATH or the one setup downloads)
and add an RMS-vs-normalised-position profile (20 bins) so the "slow -> fast sweep" can be quantified.
The vault part reads the install's converted database (assets/private/stock/skater-collections.json, --vault).
Usage (repo root):
py -3.13 tools/audio-file-inspect/grain_survey.py [--rms] [--disc DIR] [--vault JSON] [--out DIR]
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import struct
import subprocess
import sys
import tempfile
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO))
from tools.owned_game.big import BigArchive # noqa: E402
DISC_AUDIO = Path(os.environ.get('SKATE3_DISC', REPO / '.local/skate3-disc')) / 'data' / 'audio'
VAULT = REPO / 'assets' / 'private' / 'stock' / 'skater-collections.json'
OUT = REPO / '.local' / 'audio-file-inspect'
GRAIN_CLASS = 'Hash_7AB23C11B6ADA2DE'
OWNER_CLASS = 'Hash_6E878344774A7999'
# Vault field hashes of the grain class (descriptive names, as used by the engine's grain player).
GRAIN_FIELDS = {
'Hash_2C073BF8BC45063B': 'grain_file',
'Hash_4890392C91829954': 'max_kmh',
'Hash_CEC749561306022A': 'b_slope_gain',
'Hash_D380D303C64CF6F8': 'b_slope_ramp_kmh',
'Hash_1F459FC797B2C6BA': 'a_shift_per_slope_hz',
'Hash_5C9AA28695C17004': 'turn_intensity_cap',
'Hash_145D8340A9440DA3': 'b_base_shift_hz',
'Hash_7FFF3A8AD44809EF': 'b_shift_per_slope_hz',
'Hash_281FF01081475899': 'turn_rise_step',
'Hash_63764B8C7EB9EC9B': 'turn_fall_step',
'Hash_6BDC44AE7C3C79D0': 'special_gain_a',
'Hash_F62BC5EBD8E5DDE8': 'special_shift_a_hz',
'Hash_2D751DEB89BB5E33': 'push_ramp_kmh',
'Hash_E239B03F0E890686': 'push_scale_low',
'Hash_B87ECDDAAB0F8404': 'push_scale_high',
'Hash_C658A7923FC7B99E': 'push_shift_low_hz',
'Hash_A15AD56E225ADBA6': 'push_shift_high_hz',
'Hash_B3D7468820AFC661': 'push_scale_attack_ms',
'Hash_DAC9DA910EF0316C': 'push_scale_hold_ms',
'Hash_0C3D5DBC262ED276': 'push_scale_return_ms',
'Hash_09A5CC79BA2178E7': 'push_shift_attack_ms',
'Hash_3206FD96427EA4D2': 'push_shift_hold_ms',
'Hash_DB597F672CA47138': 'push_shift_return_ms',
'Hash_57A78D3BE8D47BB3': 'slope_down_divisor',
'Hash_8DD4C3FC8DAF4059': 'slope_up_divisor',
'Hash_12275AA8AC4A63FB': 'rattle_kmh',
}
OWNER_FIELDS = {
'Hash_7508154FF73DDCED': 'rocket_start_kmh',
'Hash_1185E9A69919B051': 'rocket_top_kmh',
'Hash_9BC13FA19CC4DF00': 'rocket_gain_word',
'Hash_E64C04ED542DABC8': 'graph3_clip',
'Hash_55BEB30353F244A9': 'graph3_shelf_hz',
'Hash_45516395725ED16B': 'graph3_shelf_gain',
'Hash_0D665393E2EDC605': 'g3_send_low_kmh',
'Hash_28E708782445747F': 'g3_send_high_kmh',
'Hash_D900C07BE7C5450F': 'g3_send_level',
'Hash_88AA96B08FD16914': 'g1_level_start_kmh',
'Hash_3FFB5107C82BA3E0': 'g1_level_end_kmh',
'Hash_D3E8894CA25A4F71': 'g1_level_floor',
'Hash_281A501B22B6CCDF': 'wobble_low_kmh',
'Hash_54CDE019E31FC04E': 'wobble_high_kmh',
'Hash_36AE41817640FE04': 'wobble0_ms_low',
'Hash_71EE27313BD30F21': 'wobble0_ms_high',
'Hash_437D128B53669C34': 'wobble0_gain_low',
'Hash_02885338DD5D7DCA': 'wobble0_gain_high',
'Hash_2055BBF39C152FA9': 'wobble1_ms_low',
'Hash_F5240AFADA3B3FFC': 'wobble1_ms_high',
'Hash_F916E153393C5F24': 'wobble1_gain_low',
'Hash_0A36F90732016D85': 'wobble1_gain_high',
}
def be32(b: bytes, o: int) -> int:
return struct.unpack_from('>I', b, o)[0]
def f32(word: str) -> float:
return struct.unpack('>f', bytes.fromhex(word))[0]
class Cursor:
def __init__(self, data: bytes, pos: int):
self.data, self.pos = data, pos
def varint(self) -> int:
"""Signed variable-length integer (1..5 bytes, form chosen by the first byte)."""
d, p = self.data, self.pos
b0 = d[p]
if b0 < 0xC0:
mag, sign, n = b0 >> 1, b0 & 1, 1
elif b0 < 0xF0:
w = ((b0 << 8) | d[p + 1]) >> 1
mag, sign, n = (w & ~0x6000) + 96, d[p + 1] & 1, 2
elif b0 < 0xFC:
w = (((b0 << 8) | d[p + 1]) & ~0xF000) << 8 | d[p + 2]
mag, sign, n = (w >> 1) + 6240, d[p + 2] & 1, 3
elif b0 < 0xFF:
w = ((b0 & 3) << 24) | (d[p + 1] << 16) | (d[p + 2] << 8) | (d[p + 3] & 0xFE)
mag, sign, n = (w >> 1) + 0x60000 + 6240, d[p + 3] & 1, 4
else:
self.pos += 5
v = be32(d, p + 1)
return v - (1 << 32) if v & 0x80000000 else v
self.pos += n
return -1 - mag if sign else mag
class Column:
"""Run-length column: a header h >= 0 repeats one delta for h+1 rows; h < 0 gives 1-h
rows that each read their own delta. The column value is the running sum of deltas."""
def __init__(self, cur: Cursor):
self.cur, self.value, self.left, self.repeat = cur, 0, 0, False
def next(self) -> int:
if self.left <= 0:
h = self.cur.varint()
if h >= 0:
self.left, self.repeat = h + 1, True
self.value += self.cur.varint()
else:
self.left, self.repeat = 1 - h, False
if not self.repeat:
self.value += self.cur.varint()
self.left -= 1
return self.value
def seek_rows(table: bytes, limit: int = 4096) -> list[list[int]]:
cur = Cursor(table, 8)
cols = [Column(cur) for _ in range(4)]
rows = []
while len(rows) < limit and cur.pos < len(table):
try:
row = [c.next() for c in cols]
except IndexError:
break
rows.append(row)
if row[2] < 0:
break
return rows
def eaac(b: bytes, at: int) -> dict:
w0, w1 = be32(b, at), be32(b, at + 4)
return {
'version': w0 >> 28, 'codec': (w0 >> 24) & 0xF, 'channels': ((w0 >> 18) & 0x3F) + 1,
'rate': w0 & 0x3FFFF, 'type': w1 >> 30, 'loop': (w1 >> 29) & 1, 'samples': w1 & 0x1FFFFFFF,
}
def survey_member(name: str, b: bytes) -> dict:
h = be32(b, 0)
duration = struct.unpack_from('>f', b, 4)[0]
table = b[8:h]
side_off = be32(table, 4)
s = eaac(b, h)
rows = seek_rows(table[:side_off] if side_off else table)
first = rows[0] if rows else None
return {
'name': name, 'bytes': len(b), 'header_len': h,
'duration_word': f'{be32(b, 4):08X}', 'duration_s': duration,
'seek_kind': table[0], 'seek_layout': table[1] >> 4, 'seek_low': table[1] & 0xF,
'preroll': struct.unpack_from('>H', table, 2)[0], 'side_offset': side_off,
'side_bytes': (len(table) - side_off) if side_off else 0,
'rows_read': len(rows), 'row0': first,
'row0_spans_stream': bool(first and first[2] == s['samples'] and first[3] == 1),
'stream_bytes_after_header': len(b) - h,
'eaac': s, 'samples_over_rate': s['samples'] / s['rate'],
'duration_ulps_off': abs(struct.unpack('>i', struct.pack('>f', duration))[0]
- struct.unpack('>i', struct.pack('>f', s['samples'] / s['rate']))[0]),
}
def decode_value(field: dict):
t, data = field.get('type', ''), field.get('data', '')
if t.endswith('Float'):
return f32(data)
if t.endswith('Int32'):
v = int(data, 16)
return v - (1 << 32) if v & 0x80000000 else v
if t.endswith('Text'):
return data
return data
def vault_rows():
rows = json.loads(VAULT.read_text())['collections']
return [r for r in rows if r['class'] in (GRAIN_CLASS, OWNER_CLASS)]
def resolve(rows, cls, key, field):
by_key = {r['key']: r for r in rows if r['class'] == cls}
seen = 0
while key and key in by_key and seen < 32:
r = by_key[key]
if field in r['fields']:
return r['fields'][field], key
key, seen = r.get('parent', ''), seen + 1
return None, None
def grain_params(field: dict) -> list[list[float]]:
items = field['array']['items']
return [[f32(it[k * 8:k * 8 + 8]) for k in range(5)] for it in items]
def survey_vault() -> dict:
rows = vault_rows()
out = {'grain_class': {}, 'owner_default': {}}
for r in (r for r in rows if r['class'] == GRAIN_CLASS):
key = r['key']
entry = {'parent': r.get('parent', '')}
for h, name in GRAIN_FIELDS.items():
field, src = resolve(rows, GRAIN_CLASS, key, h)
if field is not None:
entry[name] = decode_value(field)
if src != key:
entry.setdefault('inherited', []).append(name)
m, _ = resolve(rows, GRAIN_CLASS, key, 'Hash_A985FBAA9326718D')
if m:
vals = [f32(m['data'][i * 8:i * 8 + 8]) for i in range(16)]
entry['matrix_rows'] = [vals[i * 4:i * 4 + 4] for i in range(4)]
# column 1 of rows 0..3 = end, 2nd, 1st, start control point of the position curve
entry['bezier_p0_to_p3'] = [vals[13], vals[9], vals[5], vals[1]]
entry['matrix_col0_rows'] = [vals[0], vals[4], vals[8], vals[12]]
gp, _ = resolve(rows, GRAIN_CLASS, key, 'Hash_D18D1174735E5CDE')
if gp:
entry['grain_params_A_B'] = grain_params(gp)
out['grain_class'][key] = entry
for h, name in OWNER_FIELDS.items():
field, _ = resolve(rows, OWNER_CLASS, 'default', h)
if field is not None:
out['owner_default'][name] = decode_value(field)
# Sk8::AudioSurfaceMap (holder class, key C489459A0C07D154, field 4CA607558B1CF440): one
# 18-word element per collision material 0..94; word 1 (+4) = rolling surface 1..14.
allrows = json.loads(VAULT.read_text())['collections']
smap = next((r for r in allrows if r['class'] == 'Hash_C1831BDB6CB1B1EA'
and r['key'] == 'Hash_C489459A0C07D154'), None)
if smap and 'Hash_4CA607558B1CF440' in smap['fields']:
items = smap['fields']['Hash_4CA607558B1CF440']['array']['items']
table = {}
for i, it in enumerate(items):
hx = ''.join(it.split())
table[i] = int(hx[8:16], 16)
out['material_to_rolling_surface'] = table
gp, _ = resolve(rows, OWNER_CLASS, 'default', 'Hash_D18D1174735E5CDE')
if gp:
out['owner_default']['rocket_grain_params'] = (
grain_params(gp) if 'array' in gp else [f32(gp['data'][k * 8:k * 8 + 8]) for k in range(5)])
return out
def rms_profile(name: str, b: bytes, bins: int = 20) -> dict | None:
exe = os.environ.get('VGMSTREAM') or shutil.which('vgmstream-cli')
bundled = REPO / 'data' / 'tools' / 'vgmstream-cli' / 'vgmstream-cli.exe'
if not exe and bundled.is_file():
exe = str(bundled)
if not exe:
return None
import wave
import numpy
h = be32(b, 0)
with tempfile.TemporaryDirectory() as tmp:
src, dst = Path(tmp) / f'{Path(name).stem}.snr', Path(tmp) / 'out.wav'
src.write_bytes(b[h:])
if subprocess.run([exe, '-o', str(dst), str(src)], capture_output=True).returncode:
return None
with wave.open(str(dst)) as w:
x = numpy.frombuffer(w.readframes(w.getnframes()), '<i2').astype(numpy.float64) / 32768
rate = w.getframerate()
size = len(x) // bins
rms, centroid = [], []
for i in range(bins):
seg = x[i * size:(i + 1) * size]
rms.append(round(float(20 * numpy.log10(numpy.sqrt(numpy.mean(seg ** 2)) + 1e-12)), 2))
spec = numpy.abs(numpy.fft.rfft(seg * numpy.hanning(len(seg))))
freqs = numpy.fft.rfftfreq(len(seg), 1 / rate)
centroid.append(round(float((spec * freqs).sum() / (spec.sum() + 1e-12)), 1))
return {'rms_db': rms, 'centroid_hz': centroid, 'decoded_frames': len(x)}
def main() -> None:
global DISC_AUDIO, VAULT, OUT
parser = argparse.ArgumentParser(description="Survey the granular rolling-bed data (grains.big + vault tuning).")
parser.add_argument('--rms', action='store_true', help='decode each member and add an RMS / centroid profile')
parser.add_argument('--disc', type=Path, help='extracted disc root, the folder holding data/')
parser.add_argument('--vault', type=Path, default=VAULT, help='converted skater-collections.json (default: %(default)s)')
parser.add_argument('--out', type=Path, default=OUT, help='output folder (default: %(default)s)')
args = parser.parse_args()
if args.disc:
DISC_AUDIO = args.disc / 'data' / 'audio'
VAULT, OUT = args.vault, args.out
OUT.mkdir(parents=True, exist_ok=True)
big = BigArchive(DISC_AUDIO / 'grains.big')
members = []
for e in big.entries:
if not e.path.lower().endswith('.grain'):
continue
b = big.read(e)
m = survey_member(e.path, b)
if args.rms:
m['profile'] = rms_profile(e.path, b)
members.append(m)
result = {'members': members, 'vault': survey_vault()}
(OUT / 'grain_survey.json').write_text(json.dumps(result, indent=1))
for m in members:
print(f"{m['name']:34} H={m['header_len']:3} dur={m['duration_s']:7.3f}s "
f"rate={m['eaac']['rate']} n={m['eaac']['samples']} rows={m['rows_read']} "
f"row0={m['row0']} spans={m['row0_spans_stream']} side={m['side_bytes']} "
f"ulps={m['duration_ulps_off']}")
if m.get('profile'):
print(' rms_db', m['profile']['rms_db'])
print(' centroid_hz', m['profile']['centroid_hz'])
for k, v in result['vault']['grain_class'].items():
print(k, v.get('grain_file'), v.get('max_kmh'), [round(p, 4) for p in v.get('bezier_p0_to_p3', [])],
v.get('turn_intensity_cap'), v.get('b_slope_gain'), v.get('b_slope_ramp_kmh'),
v.get('a_shift_per_slope_hz'), v.get('b_base_shift_hz'))
print('owner', result['vault']['owner_default'])
if __name__ == '__main__':
main()
@@ -0,0 +1,59 @@
"""Census: which sample-group entries of the banks' player objects reference multichannel samples, and
their azimuth bytes (which descriptor word feeds which panner parameter for 2/4/6-channel voices).
usage: py -3.13 tools/audio-file-inspect/multichannel_census.py [banks dir]
default .local/audio-file-inspect/banks (from bank_layout_check.py --extract)
"""
import struct
import sys
from collections import Counter
from pathlib import Path
if len(sys.argv) > 1 and sys.argv[1] in ('-h', '--help'):
sys.exit(__doc__)
BANKS = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).resolve().parents[2] / '.local/audio-file-inspect/banks'
def u8(d, o): return d[o]
def u16(d, o): return struct.unpack_from('>H', d, o)[0]
def u32(d, o): return struct.unpack_from('>I', d, o)[0]
def main():
channels = Counter()
examples = {}
for path in sorted(BANKS.glob('*.abk')):
d = path.read_bytes()
sfx = u32(d, 0x20)
cap = u32(d, sfx + 8)
slots = []
for i in range(cap):
off = u32(d, sfx + 12 + 4 * i)
if off == 0xFFFFFFFF:
break
h = u32(d, sfx + off)
slots.append(((h >> 18) & 0x3F) + 1)
at = u32(d, 0x1C)
for _ in range(u16(d, 0x0A)):
nplayers, nctl = u8(d, at + 0x24), u8(d, at + 0x27)
tmpl = u32(d, at + 0x2C)
for p in range(nplayers):
obj = tmpl + u32(d, at + 0x3C + 4 * p)
group = u32(d, obj + 4)
for k in range(u32(d, group)):
e = group + 4 + 12 * k
s = struct.unpack_from('>h', d, e)[0]
if s < 0 or s >= len(slots):
continue
ch = slots[s]
channels[ch] += 1
if ch > 1:
examples.setdefault((path.name, ch), []).append(list(d[e + 3:e + 9]))
at += 0x3C + 4 * (nplayers + nctl)
print('entries by channel count:', dict(channels))
for (name, ch), azs in sorted(examples.items()):
print(f'{name} ch={ch} entries={len(azs)} az bytes (first 3): {azs[:3]}')
if __name__ == '__main__':
main()
+81
View File
@@ -0,0 +1,81 @@
r"""Census of SPLC (.bnk) record/group/member fields on the disc banks (our own reading tool).
usage: py -3.13 tools/audio-file-inspect/splc_fields.py <dir with .bnk files> [bank stem] [record id]
(extract the .bnk files with tools/world-stream-inspect/big_list.py audiofiles.big "\.bnk$" --out DIR)
Prints value ranges for every 4-byte member offset (as f32 and u32), group header words, record
header words; with a record id, dumps that record's groups and members raw."""
import struct, sys
from collections import Counter, defaultdict
from pathlib import Path
def u32(d, o):
return struct.unpack_from('>I', d, o)[0]
def f32(d, o):
return struct.unpack_from('>f', d, o)[0]
def walk(d):
records, containers = u32(d, 12), u32(d, 16)
cbase = 60 + 36 * records
cursor = cbase + 72 * containers
out = []
for r in range(records):
at = 60 + 36 * r
groups = []
for _ in range(d[at + 7]):
g = cursor
count = d[g + 8]
cursor += 12
members = []
for _ in range(count):
members.append(cursor)
cursor += 72
groups.append((g, members))
out.append((at, groups))
return out, cbase, containers
def main():
root = Path(sys.argv[1])
only = sys.argv[2] if len(sys.argv) > 2 else None
rec = int(sys.argv[3]) if len(sys.argv) > 3 else None
stats = defaultdict(Counter)
for p in sorted(root.glob('*.bnk')):
d = p.read_bytes()
if d[:4] != b'SPLC' or (only and p.stem != only):
continue
recs, cbase, nc = walk(d)
if rec is not None:
at, groups = recs[rec] if rec < len(recs) else (None, None)
if at is None:
c = cbase + 72 * (rec - len(recs))
print('container', d[c:c + 72].hex(' '))
return
print('record', d[at:at + 36].hex(' '))
for g, ms in groups:
print(' group', d[g:g + 12].hex(' '))
for m in ms:
print(' member', ' '.join(f'{o}:{f32(d, m + o):.4g}/{u32(d, m + o):#x}' for o in range(0, 72, 4)))
return
for at, groups in recs:
for o in range(0, 36, 4):
stats[f'rec+{o}'][u32(d, at + o)] += 1
for g, ms in groups:
for o in (4, 8):
stats[f'grp+{o}'][u32(d, g + o)] += 1
for m in ms:
for o in range(0, 72, 4):
stats[f'mem+{o}'][round(f32(d, m + o), 4) if o not in (0, 56, 60, 68) else hex(u32(d, m + o))] += 1
for c in range(nc):
stats['cont+0'][u32(d, cbase + 72 * c)] += 1
stats['cont+68'][hex(u32(d, cbase + 72 * c + 68))] += 1
for k, c in stats.items():
print(k, len(c), c.most_common(12))
if __name__ == '__main__':
if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'):
sys.exit(__doc__)
main()
+48
View File
@@ -0,0 +1,48 @@
# Collision inspection
Extract a district's collision from your own disc and look at it, without touching the installation.
Used to place map spawns and to find the invisible trigger volumes (see
`docs/hails-additions/04-map-spawns.md` and `05-collision-volumes.md`).
| Script | What it does |
|---|---|
| `map_collision.py` | Extracts `world<District>.big` into `<work>/<District>/raw` (once), runs the map converter's collision pass and saves every triangle to `collision.npy` (N x 3 x 3 float64, Y-up metres). Prints the triangle count, bounds and the spawn the current spawn rule would choose. |
| `analyse_spawn.py` | Lists every collision surface at a point's X/Z (height, normal-up sign, area), plus the area-weighted centre of the flat surfaces. Nothing below a spawn = void; a much lower surface under it = roof or platform. Needs `map_collision.py` first. |
| `collision_attrs.py` | Saves the triangles with their surface id, unit flags (0x80 = has a surface id), mesh flags and mesh index to `collision_attrs.npy`. Needs the extracted district. |
| `scan_surfaceless.py` | For each district: counts collision meshes that are surfaced, mixed or surfaceless (no surface ids at all) and lists the non-surfaced ones with their size and bounds. |
| `unsigned_collision.py` | Imported by the others: makes the vendored collision decoder read 16-bit vertex deltas unsigned, as the game does. Without it, clusters spanning more than 32.8 m wrap by 65.536 m. |
## Inputs
- Your extracted disc: `--disc DIR` (the folder holding `data/`), default `$SKATE3_DISC` or `.local/skate3-disc`.
- District names without the `DIST_` prefix: `MegaPark`, `StartPark`, `BlackBoxPark`, `SkateSchool`,
`IndustrialSkatePark`, `DownTownSkatePark`, `MaloofMoneyCup`, `University`, `Industrial`, `DownTown`.
- Work folder: `--work DIR`, default `.local/collision` (gitignored).
## Usage
```
py -3.13 tools/collision-inspect/map_collision.py MegaPark
py -3.13 tools/collision-inspect/analyse_spawn.py MegaPark 21.7 21.6 -19.7
py -3.13 tools/collision-inspect/collision_attrs.py SkateSchool
py -3.13 tools/collision-inspect/scan_surfaceless.py SkateSchool StartPark
```
Parks take about a second; the city districts take minutes and a lot of memory.
## Example output
(made-up values)
```
triangles 12000
bounds min [-120.0 -5.2 -130.0] max [140.0 40.1 125.0]
spawn (current rule) [10.5 0.1 6.0]
MapA surfaces at (21.7, -19.7) : [(0.0, 1.0, 12.5), (6.3, 1.0, 3.1)]
```
## Requirements
Python 3.13 with `numpy`; the repository's setup modules (`tools/asset_pipeline`) and the vendored
map extraction tools (`tools/vendor/university`, `tools/vendor/utt`).
+47
View File
@@ -0,0 +1,47 @@
"""List every collision surface height at a spawn's XZ (orientation ignored).
Usage (repo root, after map_collision.py):
py -3.13 tools/collision-inspect/analyse_spawn.py MegaPark 21.742 21.605 -19.683 [--work DIR]
Output: (height, normal-y sign, area m2) for each surface under/over the point, plus the
area-weighted centre of flat surfaces. Nothing below the spawn height = void; a much
lower surface under it = roof/platform.
"""
import argparse
from pathlib import Path
import numpy as np
REPO = Path(__file__).resolve().parents[2]
parser = argparse.ArgumentParser(description="List every collision surface height at a point's XZ.")
parser.add_argument('district', help='district name without DIST_')
parser.add_argument('x', type=float)
parser.add_argument('y', type=float, help='ignored (every height at x, z is listed)')
parser.add_argument('z', type=float)
parser.add_argument('--work', type=Path, default=REPO / '.local/collision', help='work folder (default: %(default)s)')
args = parser.parse_args()
name, x, z = args.district, args.x, args.z
t = np.load(args.work / name / 'collision.npy')
a, b, c = t[:, 0], t[:, 1], t[:, 2]
ab, ac = b - a, c - a
cross = np.cross(ab, ac)
area = np.linalg.norm(cross, axis=1) / 2
up = cross[:, 1] / np.maximum(2 * area, 1e-9)
def surfaces(px, pz):
dx, dz = px - a[:, 0], pz - a[:, 2]
det = ab[:, 0] * ac[:, 2] - ac[:, 0] * ab[:, 2]
ok = np.abs(det) > 1e-12
d = np.where(ok, det, 1.0)
u = (dx * ac[:, 2] - ac[:, 0] * dz) / d
v = (ab[:, 0] * dz - dx * ab[:, 2]) / d
inside = ok & (u >= -1e-5) & (v >= -1e-5) & (u + v <= 1.00001)
height = a[:, 1] + u * ab[:, 1] + v * ac[:, 1]
return sorted({(round(float(height[i]), 2), round(float(up[i]), 2), round(float(area[i]), 1))
for i in np.nonzero(inside)[0]})
print(name, 'surfaces at', (x, z), ':', surfaces(x, z))
centre = (a + b + c) / 3
flat = (np.abs(up) >= .9) & (area >= 2)
w = area[flat]
cx = float((centre[flat, 0] * w).sum() / w.sum())
cz = float((centre[flat, 2] * w).sum() / w.sum())
print('flat-area centre', (round(cx, 1), round(cz, 1)), ':', surfaces(cx, cz)[:6])
@@ -0,0 +1,41 @@
"""Capture a district's collision triangles with surface ids, unit flags and mesh flags.
Usage (repo root, after map_collision.py extracted the district):
py -3.13 tools/collision-inspect/collision_attrs.py SkateSchool [--work DIR]
Writes <work>/<District>/collision_attrs.npy (default work .local/collision) with rows:
9 vertex floats, surface id, unit flags (0x80 = has surface id), mesh flags, mesh index.
"""
import argparse, sys
from pathlib import Path
import numpy as np
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO))
from tools.asset_pipeline import install
sys.path.insert(0, str(install.TOOLS / 'vendor/university/tools/vanilla_map_extraction/tools'))
from prepare_hawaiian_dream import prepare
from prepare_university import EXCLUDED_NORMAL_TEXTURE_IDS
parser = argparse.ArgumentParser(description="Capture a district's collision triangles with surface ids and flags.")
parser.add_argument('district', nargs='?', default='SkateSchool', help='district name without DIST_ (default: %(default)s)')
parser.add_argument('--work', type=Path, default=REPO / '.local/collision', help='work folder (default: %(default)s)')
args = parser.parse_args()
name = args.district
district = 'DIST_' + name
work = args.work / name
stream = work / 'raw/data/content/world/stream' / district
rows = []
mesh_index = [0]
def consume(meshes):
for mesh in meshes:
for t in mesh.triangles:
rows.append((*t.a, *t.b, *t.c, t.surface, t.unit_flags, mesh.mesh_flags, mesh_index[0]))
mesh_index[0] += 1
prepare(stream_directory=stream, output_root=work / 'intermediate2', utt_root=install.TOOLS / 'vendor/utt',
district_name=district, map_name=name, package_name='analysis', cache_format='skate3-rust-map-v1',
texture_stream_names=('Tex',) if any(stream.glob('cTex_*.xsf')) else (),
excluded_normal_texture_ids=EXCLUDED_NORMAL_TEXTURE_IDS, raw_texture_cache=True,
collision_consumer=consume, write_render_sources=False)
a = np.asarray(rows, dtype=np.float64)
np.save(work / 'collision_attrs.npy', a)
print('triangles', len(a), 'meshes', mesh_index[0])
+56
View File
@@ -0,0 +1,56 @@
"""Extract one district's collision triangles for analysis (does not touch the install).
Usage (repo root): py -3.13 tools/collision-inspect/map_collision.py MegaPark [--disc DIR] [--work DIR]
Extracts world<District>.big from the disc into <work>/<District>/raw if needed, then writes
<work>/<District>/collision.npy (N x 3 x 3 float64, Y-up metres) and
prints the bounds and the spawn the current map_writer rule would choose.
Parks take ~1 s; city districts take minutes and a lot of memory.
"""
import argparse
import os
import sys
from pathlib import Path
import numpy as np
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO))
from tools.asset_pipeline import install
from tools.asset_pipeline.map_writer import SpawnSelector
sys.path.insert(0, str(install.TOOLS / 'vendor/university/tools/vanilla_map_extraction/tools'))
sys.path.insert(0, str(Path(__file__).resolve().parent))
import unsigned_collision # noqa: F401 (unsigned vertex deltas, as the game reads them)
from prepare_hawaiian_dream import prepare
from prepare_university import EXCLUDED_NORMAL_TEXTURE_IDS
parser = argparse.ArgumentParser(description="Extract one district's collision triangles for analysis.")
parser.add_argument('district', nargs='?', default='MegaPark', help='district name without DIST_ (default: %(default)s)')
parser.add_argument('--disc', type=Path, default=Path(os.environ.get('SKATE3_DISC', REPO / '.local/skate3-disc')),
help='extracted disc root, the folder holding data/ (default: %(default)s)')
parser.add_argument('--work', type=Path, default=REPO / '.local/collision', help='work folder (default: %(default)s)')
args = parser.parse_args()
name = args.district
district = 'DIST_' + name
archive = args.disc / 'data/content' / f'world{district}.big'
work = args.work / name
if not (work / 'raw').is_dir():
install.extract(archive, work / 'raw')
stream = work / 'raw/data/content/world/stream' / district
spawn = SpawnSelector(district)
triangles = []
def consume(meshes):
spawn.consider(meshes)
for mesh in meshes:
triangles.extend((t.a, t.b, t.c) for t in mesh.triangles)
prepare(stream_directory=stream, output_root=work / 'intermediate', utt_root=install.TOOLS / 'vendor/utt',
district_name=district, map_name=name, package_name='analysis', cache_format='skate3-rust-map-v1',
texture_stream_names=('Tex',) if any(stream.glob('cTex_*.xsf')) else (),
excluded_normal_texture_ids=EXCLUDED_NORMAL_TEXTURE_IDS, raw_texture_cache=True,
collision_consumer=consume, write_render_sources=False)
t = np.asarray(triangles, dtype=np.float64)
np.save(work / 'collision.npy', t)
points = t.reshape(-1, 3)
print('triangles', len(t))
print('bounds min', points.min(0).round(1), 'max', points.max(0).round(1))
print('spawn (current rule)', np.round(spawn.result(name), 3))
@@ -0,0 +1,60 @@
"""For every district: find collision meshes with no surface ids and describe them.
Usage (repo root): py -3.13 tools/collision-inspect/scan_surfaceless.py [District ...] [--disc DIR] [--work DIR]
Extracts each district into <work>/<District>/ (default .local/collision) if needed (cities take minutes),
then prints per-mesh counts: surfaced / mixed (some units lack surface ids) / surfaceless.
"""
import argparse, collections, json, os, sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO))
from tools.asset_pipeline import install
TOOLS = install.TOOLS / 'vendor/university/tools/vanilla_map_extraction/tools'
sys.path.insert(0, str(TOOLS))
from prepare_hawaiian_dream import prepare
from prepare_university import EXCLUDED_NORMAL_TEXTURE_IDS
sys.path.insert(0, str(Path(__file__).resolve().parent))
import unsigned_collision # noqa: F401 (unsigned vertex deltas, as the game reads them)
from retail_collision_mesh import decode_rx2_clustered_meshes
parser = argparse.ArgumentParser(description='Find collision meshes with no surface ids and describe them.')
parser.add_argument('districts', nargs='*', help='district names without DIST_ (default: all ten)')
parser.add_argument('--disc', type=Path, default=Path(os.environ.get('SKATE3_DISC', REPO / '.local/skate3-disc')),
help='extracted disc root, the folder holding data/ (default: %(default)s)')
parser.add_argument('--work', type=Path, default=REPO / '.local/collision', help='work folder (default: %(default)s)')
args = parser.parse_args()
names = args.districts or ['MegaPark', 'IndustrialSkatePark', 'StartPark', 'BlackBoxPark', 'DownTownSkatePark',
'MaloofMoneyCup', 'SkateSchool', 'University', 'Industrial', 'DownTown']
for name in names:
district = 'DIST_' + name
work = args.work / name
if not (work / 'raw').is_dir():
install.extract(args.disc / f'data/content/world{district}.big', work / 'raw')
stream = work / 'raw/data/content/world/stream' / district
out = work / 'scan'
if not (out / 'manifest.json').is_file():
prepare(stream_directory=stream, output_root=out, utt_root=install.TOOLS / 'vendor/utt',
district_name=district, map_name=name, package_name='analysis', cache_format='skate3-rust-map-v1',
texture_stream_names=('Tex',) if any(stream.glob('cTex_*.xsf')) else (),
excluded_normal_texture_ids=EXCLUDED_NORMAL_TEXTURE_IDS, raw_texture_cache=True,
collision_consumer=lambda meshes: None, write_render_sources=False)
manifest = json.loads((out / 'manifest.json').read_text())
total = collections.Counter()
special = []
for entry in manifest['simulation_assets']:
if not entry.get('collision_meshes'):
continue
for mesh in decode_rx2_clustered_meshes((out / entry['rx2']).read_bytes()):
flags = {bool(t.unit_flags & 0x80) for t in mesh.triangles}
kind = 'surfaced' if flags == {True} else 'surfaceless' if flags == {False} else 'mixed'
total[kind] += 1
if kind != 'surfaced':
size = [round(mesh.bounds_max[k] - mesh.bounds_min[k], 1) for k in range(3)]
lo = [round(v, 2) for v in mesh.bounds_min]
hi = [round(v, 2) for v in mesh.bounds_max]
special.append(f"{kind} {entry['stream_file']} tris={len(mesh.triangles)} size={size} "
f"min={lo} max={hi}")
print(f'== {name}: {dict(total)}', flush=True)
for line in special:
print(' ', line, flush=True)
@@ -0,0 +1,31 @@
"""Make the vendored collision decoder read 16-bit vertex deltas UNSIGNED, as the game does.
The vendored `retail_collision_mesh._decode_vertices` unpacks compression-1 vertex deltas with `>3h`
(signed). The game's own decoder (crates/skate-data/src/retail_collision.rs, `be16` → u16) treats them as
unsigned, as the game does. With signed deltas, vertices of clusters spanning more than 32.8 m wrap by
65.536 m (granularity 0.001); see docs/hails-additions/05-collision-volumes.md.
Analysis tools only: `import unsigned_collision` before using `decode_rx2_clustered_meshes`. The converter
(map_writer.spawn_point, prepare_hawaiian_dream bounds) still uses the vendored behaviour.
"""
import struct
import retail_collision_mesh as _rcm
def _decode_vertices_unsigned(cluster, vertex_count, compression, granularity, vertex_data_end):
if compression != 1:
return _original(cluster, vertex_count, compression, granularity, vertex_data_end)
_rcm._require(vertex_data_end >= 28, "compressed cluster omits its base")
base = struct.unpack_from(">3i", cluster, _rcm.CLUSTER_HEADER_SIZE)
vertices = []
for index in range(vertex_count):
offset = _rcm.CLUSTER_HEADER_SIZE + 12 + index * 6
_rcm._require(offset + 6 <= vertex_data_end, "16-bit compressed vertex extends into the unit stream")
delta = struct.unpack_from(">3H", cluster, offset)
vertices.append(tuple(_rcm._f32((base[axis] + delta[axis]) * granularity) for axis in range(3)))
return vertices
_original = _rcm._decode_vertices
_rcm._decode_vertices = _decode_vertices_unsigned
+55
View File
@@ -0,0 +1,55 @@
# Recomp code search
**For use with the Skate 3 recomp's research hooks:**
[Hailey-Ross/skate3recomp, branch `research-hooks`](https://github.com/Hailey-Ross/skate3recomp/tree/research-hooks)
(described in [`docs/hails-additions/13-recomp-research-hooks.md`](../../docs/hails-additions/13-recomp-research-hooks.md)).
> **Reference only.** These scripts search the recompiled sources and the game's memory image that
> *you* generate from your own legally owned copy of the game. You must build the recomp yourself and
> set up the paths below for your machine; nothing from the game is included here. Use what you find
> as a reference for re-implementing behaviour, never copy game code into the engine.
Finding the code behind a behaviour is the first step before hooking it (see the research-hooks
branch for the hooks themselves).
| Script | What it does |
|---|---|
| `fn.sh ADDR` | Prints the disassembly comments of the recompiled function `sub_ADDR`. |
| `grepfn.sh 'ERE'` | Prints `sub_XXXXXXXX: <asm line>` for every disassembly line matching the pattern, with its function. |
| `callctx.sh ADDR [before] [after]` | Every call site of `sub_ADDR` (first 20), with the surrounding disassembly and the calling function. |
| `ppcxref.py` | Cross-references in a big-endian PowerPC memory image: code that builds an address (`lis` + `addi` / load / store), strings and their users, callers of a function, the function containing an address, and data words pointing at an address (vtables, tables). |
## Inputs
- `RECOMP_GENERATED`: your skate3recomp build's `generated/` folder (the code generator's output,
`skate3_recomp.*.cpp`), for the shell scripts.
- For `ppcxref.py`: `--image` (or `PPC_IMAGE`), a dump of the executable's loaded memory image from
your own copy, base `0x82000000` by default (`--base`); `--funcs` (or `PPC_FUNCS`), a list of function
starts built from your generated sources (the command is in the script's help); optional
`--code LO-HI` to scan only the code section.
## Usage
```
export RECOMP_GENERATED=/path/to/skate3recomp/generated
sh tools/recomp-code-search/fn.sh 82XXXXXX
sh tools/recomp-code-search/grepfn.sh 'lfs .*,-?[0-9]+\(r31\)'
sh tools/recomp-code-search/callctx.sh 82XXXXXX 6 14
py -3.13 tools/recomp-code-search/ppcxref.py --image image.bin --funcs funcs.txt str some_string
py -3.13 tools/recomp-code-search/ppcxref.py --image image.bin --funcs funcs.txt calls 82XXXXXX
```
## Example output
`ppcxref.py calls` (made-up addresses):
```
82123450 in sub_82123400
82345670 in sub_82345600
```
## Requirements
Git Bash or any POSIX shell with `grep` / `awk` for the shell scripts; Python 3.13 (standard library)
for `ppcxref.py`.
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# usage: RECOMP_GENERATED=<dir> callctx.sh ADDR [before=6] [after=14] -> every call site of sub_X (first 20)
# with the surrounding asm and the calling function
B=${2:-6}; A=${3:-14}
# Needs your own skate3recomp build: RECOMP_GENERATED = its generated/ folder (the code generator's output).
: "${RECOMP_GENERATED:?set RECOMP_GENERATED to your skate3recomp generated/ folder}"
cd "$RECOMP_GENERATED" || exit 1
x=$(echo "$1" | tr 'A-F' 'a-f')
grep -n -E "DEFINE_REX_FUNC\(sub_|^ // bl 0x$x\$" skate3_recomp.*.cpp | awk -F: '/DEFINE_REX_FUNC/{f=$1; fn=$0; next} {print f":"$2}' | head -20 | while IFS=: read file line; do
fnl=$(awk -v L=$line 'NR<=L && /DEFINE_REX_FUNC/{l=$0} NR==L{print l; exit}' $file | grep -o 'sub_[0-9A-F]*')
echo "=== in $fnl"
awk -v L=$line -v B=$B -v A=$A 'NR>=L-B*4 && NR<=L+A*4 && (/^\t\/\/ /||/^loc_/){sub(/^\t\/\/ /," ");print}' $file
done
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# usage: RECOMP_GENERATED=<dir> fn.sh ADDR -> prints the asm comments of function sub_ADDR (8 hex digits, upper case)
# Needs your own skate3recomp build: RECOMP_GENERATED = its generated/ folder (the code generator's output).
: "${RECOMP_GENERATED:?set RECOMP_GENERATED to your skate3recomp generated/ folder}"
cd "$RECOMP_GENERATED" || exit 1
f=$(grep -l "DEFINE_REX_FUNC(sub_$1)" skate3_recomp.*.cpp | head -1)
awk -v s="DEFINE_REX_FUNC(sub_$1)" 'index($0,s){p=1;print;next} p&&/^DEFINE_REX_FUNC/{exit} p&&(/^\t\/\/ /||/^loc_/){sub(/^\t\/\/ /," ");print}' "$f"
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# usage: RECOMP_GENERATED=<dir> grepfn.sh 'ERE' -> prints "sub_XXXX: asm line" for every asm line matching (grep -E syntax)
# Needs your own skate3recomp build: RECOMP_GENERATED = its generated/ folder (the code generator's output).
: "${RECOMP_GENERATED:?set RECOMP_GENERATED to your skate3recomp generated/ folder}"
cd "$RECOMP_GENERATED" || exit 1
grep -n -E "DEFINE_REX_FUNC\(sub_|^ // .*($1)" skate3_recomp.*.cpp | awk '
/DEFINE_REX_FUNC\(sub_/ { match($0,/sub_[0-9A-F]+/); f=substr($0,RSTART,RLENGTH); next }
{ sub(/^[^\t]*\t\/\/ /,""); print f": "$0 }'
+167
View File
@@ -0,0 +1,167 @@
"""Cross-reference helper for a big-endian PowerPC memory image of the game (the Xbox 360 executable's
loaded image, which you dump yourself from your own copy; base address 0x82000000 by default).
usage:
py -3.13 tools/recomp-code-search/ppcxref.py --image IMG --funcs FUNCS addr ADDR [ADDR ...] # code that builds the address (lis + addi/load/store)
py -3.13 tools/recomp-code-search/ppcxref.py --image IMG --funcs FUNCS str TEXT [TEXT ...] # find strings, then xref each hit
py -3.13 tools/recomp-code-search/ppcxref.py --image IMG --funcs FUNCS calls ADDR [ADDR ...] # callers (bl) of a function
py -3.13 tools/recomp-code-search/ppcxref.py --image IMG --funcs FUNCS func ADDR [ADDR ...] # containing function start
py -3.13 tools/recomp-code-search/ppcxref.py --image IMG --funcs FUNCS ptr ADDR [ADDR ...] # data words equal to the address
# (vtables, binding tables)
Options: --base (image base, default 0x82000000), --code LO-HI (code range to scan, hex; default the whole image).
Environment: PPC_IMAGE, PPC_FUNCS stand in for --image / --funcs.
FUNCS lists function starts, one hex address first on each line. Build it from your recomp's generated
sources:
cd <skate3recomp>/generated && grep -o "DEFINE_REX_FUNC(sub_[0-9A-F]*)" skate3_recomp.*.cpp \\
| sed 's/:DEFINE_REX_FUNC(sub_/ /;s/)//' | awk '{print $2, $1}' | sort > funcs.txt
Heuristic: a lis value is valid for 48 instructions in the same function.
"""
import argparse
import bisect
import os
import re
import struct
import sys
from pathlib import Path
IMG = b''
BASE = 0x82000000
CODE_LO = CODE_HI = 0
FUNCS = []
# D-form opcodes whose rA+simm is an address: addi 14, lwz 32, lwzu 33, lbz 34, stw 36, stb 38, lhz 40,
# sth 44, lfs 48, lfd 50, stfs 52, stfd 54, ld/ldu 58, std 62
DFORM = {14, 32, 33, 34, 35, 36, 37, 38, 39, 40, 42, 44, 48, 50, 52, 54, 58, 62}
def func_of(a):
i = bisect.bisect_right(FUNCS, a) - 1
return FUNCS[i] if i >= 0 else 0
_W = None
def W():
global _W
if _W is None:
n = (CODE_HI - CODE_LO) // 4
_W = struct.unpack_from('>%dI' % n, IMG, CODE_LO - BASE)
return _W
def xrefs(targets, slack=0):
"""targets: addresses (or (lo, hi) ranges). Returns a list of (pc, ea, func)."""
w = W()
out = []
ranges = [(t, t + slack) if isinstance(t, int) else t for t in targets]
lis = {}
fstarts = set(FUNCS)
for i, ins in enumerate(w):
pc = CODE_LO + 4 * i
if pc in fstarts:
lis.clear()
op = ins >> 26
rd = (ins >> 21) & 31
ra = (ins >> 16) & 31
imm = ins & 0xFFFF
simm = imm - 0x10000 if imm & 0x8000 else imm
if op == 15 and ra == 0: # lis
lis[rd] = ((simm << 16) & 0xFFFFFFFF, i)
continue
if op in DFORM and ra in lis and i - lis[ra][1] < 48:
d = simm & ~3 if op in (58, 62) else simm
ea = (lis[ra][0] + d) & 0xFFFFFFFF
for lo, hi in ranges:
if lo <= ea <= hi:
out.append((pc, ea, func_of(pc)))
break
if op not in (14, 15) and rd in lis and op in (14, 32, 33, 34, 40, 58, 31):
lis.pop(rd, None) # destination overwritten
return out
def callers(target):
out = []
for i, ins in enumerate(W()):
if ins >> 26 == 18 and ins & 1: # bl
li = ins & 0x03FFFFFC
if li & 0x02000000:
li -= 0x04000000
pc = CODE_LO + 4 * i
t = li if ins & 2 else pc + li
if t & 0xFFFFFFFF == target:
out.append((pc, func_of(pc)))
return out
def find_str(s):
return [m.start() + BASE for m in re.finditer(re.escape(s.encode('latin1')), IMG)]
def cstr(a):
o = a - BASE
return IMG[o:IMG.index(b'\0', o)].decode('latin1')
def ptrs(a):
b = struct.pack('>I', a)
return [m.start() + BASE for m in re.finditer(re.escape(b), IMG) if m.start() % 4 == 0]
def main():
global IMG, BASE, CODE_LO, CODE_HI, FUNCS
parser = argparse.ArgumentParser(description='Cross-reference helper for a big-endian PowerPC memory image.',
epilog=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('cmd', choices=('addr', 'str', 'calls', 'func', 'ptr'))
parser.add_argument('args', nargs='+')
parser.add_argument('--image', default=os.environ.get('PPC_IMAGE'), help='memory image file')
parser.add_argument('--funcs', default=os.environ.get('PPC_FUNCS'), help='function start list')
parser.add_argument('--base', default='0x82000000', help='image base address (hex)')
parser.add_argument('--code', help='code range LO-HI (hex); default the whole image')
a = parser.parse_args()
if not a.image or not a.funcs:
parser.error('--image and --funcs (or PPC_IMAGE / PPC_FUNCS) are required')
IMG = Path(a.image).read_bytes()
BASE = int(a.base, 16)
if a.code:
lo, hi = a.code.split('-')
CODE_LO, CODE_HI = int(lo, 16), int(hi, 16)
else:
CODE_LO, CODE_HI = BASE, BASE + len(IMG) // 4 * 4
FUNCS = sorted(int(l.split()[0], 16) for l in Path(a.funcs).read_text().splitlines() if l.strip())
if a.cmd == 'addr':
for pc, ea, f in xrefs([int(x, 16) for x in a.args]):
print(f'{pc:08X} -> {ea:08X} in sub_{f:08X}')
elif a.cmd == 'str':
starts = set()
for s in a.args:
for h in find_str(s):
o = h - BASE
while o > 0 and 0x20 <= IMG[o - 1] < 0x7f:
o -= 1
starts.add(o + BASE)
res = xrefs(sorted(starts))
for st in sorted(starts):
print(f'{st:08X} "{cstr(st)[:80]}"')
for pc, ea, f in res:
if ea == st:
print(f' {pc:08X} in sub_{f:08X}')
elif a.cmd == 'calls':
for x in a.args:
for pc, f in callers(int(x, 16)):
print(f'{pc:08X} in sub_{f:08X}')
elif a.cmd == 'func':
for x in a.args:
print(f'sub_{func_of(int(x, 16)):08X}')
elif a.cmd == 'ptr':
for x in a.args:
addr = int(x, 16)
for p in ptrs(addr):
nb = struct.unpack_from('>4I', IMG, p - BASE - 4)
print(f'{addr:08X} @ {p:08X} [-4..+12] ' + ' '.join(f'{v:08X}' for v in nb))
if __name__ == '__main__':
sys.exit(main())
+65
View File
@@ -0,0 +1,65 @@
# Recomp trace analysis
**For use with the Skate 3 recomp's research hooks:**
[Hailey-Ross/skate3recomp, branch `research-hooks`](https://github.com/Hailey-Ross/skate3recomp/tree/research-hooks)
(described in [`docs/hails-additions/13-recomp-research-hooks.md`](../../docs/hails-additions/13-recomp-research-hooks.md)).
> **Reference only.** These scripts read traces written by that branch's hooks. You must build the
> recomp yourself (from your own legally owned copy of the game), apply its SDK patch, and set up the
> trace paths and the paths below for your machine. The recompilation is not a perfect copy of the
> console game: it runs uncapped, and its timing and threading differ. Treat logged values, data and
> program logic as evidence; treat timings and per-frame rates as specific to the recomp.
The hooks write a tab-separated `trace.tsv` (`KIND <tab> <ms> <tab> fields…`, all on one time base),
optionally a float32 stereo capture of the mixed output, and screenshots. A session folder holds
`trace.tsv` (and `trace.f32` / `audio.f32`, `shots/`).
| Script | What it does |
|---|---|
| `trace.py` | Shared reader (`from trace import Trace`): lines per kind, MARK stops, screenshot nearest a time, capture windows. Run alone: line counts per kind, malformed fixed-layout lines (should be zero), stops. |
| `first_pass.py` | Summarises first-pass raw-register hook lines per kind: call counts and the distinct values of each register, so constant fields (pointers, flags) stand apart from data. |
| `resolve_trace.py` | Finds each played sample's payload in your own disc's audio archives and names the bank member and stream. Used by `retail_voices.py`. |
| `retail_voices.py` | Joins each voice's PLAY with its MOD (pitch, filters), GAIN and SEND lines: per bank, voices, pitch / rate quantiles, filter cutoffs and level. `--csv` per voice; `--modules` lists the buses. Caches resolutions in the session folder. |
| `retail_relay.py` | How one emitter bank's voices follow each other: starts, slots, pitch, gaps, overlaps, repeats and the most voices at once. Needs `play_resolve.json` from `retail_voices.py`. |
| `grec_level.py` | Grain-bed (rolling) levels from GREC lines per speed band: straight rolling and carving. |
| `grec_clean.py` | Grain-bed gain on clean straight rolling only (settled, no brake / push / balance / trick), per speed band and truck. |
| `grec_material.py` | Wheel material against wheel count from GREC lines: how often the material changes while rolling, and how often it reads the "no contact" value. |
| `send_vectors.py` | Per-channel send gains from SEND lines: power sums, LFE share, most common pan shapes. |
| `grain_trace_stats.py` | Grain voice starts (PLAY lines matching a `.grain` member of your disc): starts per member, per second and inter-start intervals. |
| `retail_windows.py` | Straight four-wheel rolling windows of the session's capture per 5 km/h band (level, octave bands), next to the engine's e2e renders folded the same way (`tools/audio-e2e`). |
| `veh_trace.py` | Traffic summary from the traffic hook lines: per vehicle speed, target speed, manoeuvre and lane changes, stops, plus the horn / skid / engine lines. |
## Inputs
- A session folder or `trace.tsv` from the research-hooks build.
- Your extracted disc for `resolve_trace.py`, `retail_voices.py`, `grain_trace_stats.py`: `--disc DIR`
(the folder holding `data/`), default `$SKATE3_DISC` or `.local/skate3-disc`.
- An installation's decoded banks for `retail_relay.py` (`--assets`, default `assets`).
- Hook-specific values that depend on your build are inputs, not built in: e.g.
`retail_windows.py --contact-callers` (the caller prefixes of the board-contact SPLC lines in your trace).
## Usage
```
py -3.13 tools/recomp-trace/trace.py sessions/my_session/trace.tsv
py -3.13 tools/recomp-trace/retail_voices.py sessions/my_session --bank SomeBank --csv voices.csv
py -3.13 tools/recomp-trace/retail_relay.py sessions/my_session SomeBank --from 60000 --to 120000
py -3.13 tools/recomp-trace/grec_level.py sessions/my_session/trace.tsv
py -3.13 tools/recomp-trace/retail_windows.py sessions/my_session .local/audio-e2e --contact-callers 82AAAA,82BBBB
py -3.13 tools/recomp-trace/first_pass.py sessions/my_session/trace.tsv SOMEKIND
```
## Example output
`trace.py` (made-up counts):
```
{'MARK': 12, 'PLAY': 5400, 'MOD': 31000, 'GAIN': 18000, 'CAPTURE': 900}
malformed (fixed-layout kinds): {'TREAT': 0, 'SEAMPAT': 0, 'SEAMHIT': 0, 'GRECX': 0, 'FIRSTHIT': 0, 'SKID': 0, 'EMITSLOT': 0}
stop_a 12.0 .. 95.5 s
```
## Requirements
Python 3.13; `numpy` for `retail_windows.py` and `Trace.capture`. The repository's `tools/owned_game`
and `tools/asset_pipeline/audio_formats.py` for the resolvers.
+53
View File
@@ -0,0 +1,53 @@
"""Summarise first-pass raw-register hook lines (hooks_npc.cpp format) per kind.
usage: py -3.13 tools/recomp-trace/first_pass.py <trace.tsv> [KIND ...]
Line format: KIND ms [tag] skipped=N | r3 r4 r5 r6 r7 r8 | f1 f2 | ret r3 | ret f1 | callers.
For each kind (and tag), prints the call count (logged + skipped), then per field the number of distinct
values and the most common ones: constant fields are object pointers or flags, varying ones are data.
"""
import sys
from collections import Counter, defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from trace import Trace # noqa: E402
FIELDS = ['r3', 'r4', 'r5', 'r6', 'r7', 'r8', 'f1', 'f2', 'ret', 'retf', 'callers']
def rows(trace: Trace, kind: str):
for ms, f in trace.lines.get(kind, []):
tag = ''
if f and not f[0].startswith('skipped='):
tag, f = f[0], f[1:]
if len(f) < 6 or not f[0].startswith('skipped='):
continue # interleaved / truncated line
regs = f[1].split()
floats = f[2].split()
if len(regs) != 6 or len(floats) != 2:
continue
yield tag, ms, int(f[0][8:]), dict(zip(FIELDS, regs + floats + [f[3], f[4], f[5]]))
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'):
sys.exit(__doc__)
trace = Trace(sys.argv[1])
kinds = sys.argv[2:] or sorted(k for k in trace.lines if trace.lines[k] and
trace.lines[k][0][1][-1:] and '<' in trace.lines[k][0][1][-1])
for kind in kinds:
groups = defaultdict(list)
for tag, ms, skipped, values in rows(trace, kind):
groups[tag].append((ms, skipped, values))
for tag, items in groups.items():
total = len(items) + sum(s for _, s, _ in items)
span = (items[-1][0] - items[0][0]) / 1000 if len(items) > 1 else 0
print(f'== {kind} {tag} logged={len(items)} calls~{total} over {span:.0f}s')
for field in FIELDS:
counts = Counter(v[field] for _, _, v in items)
top = ', '.join(f'{k}×{n}' for k, n in counts.most_common(4))
print(f' {field:7s} distinct={len(counts):5d} {top}')
if __name__ == '__main__':
main()
+69
View File
@@ -0,0 +1,69 @@
"""Grain voice starts in a recomp audio trace (PLAY lines whose sample header matches a .grain stream of
your own disc's grains.big).
usage: py -3.13 tools/recomp-trace/grain_trace_stats.py <trace.tsv> [--disc DIR] [--exclude MEMBER,...] [--out JSON]
--disc extracted disc root, the folder holding data/ (default $SKATE3_DISC or .local/skate3-disc)
--exclude members left out of the pooled interval histogram (e.g. a non-board grain)
--out JSON output (default .local/recomp-trace/grain_stats_<session>.json)
Prints per-member start counts, starts per second in 1 s windows (histogram) and the inter-start
interval histogram (all grain members pooled, and per member).
"""
import argparse
import collections
import json
import os
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO))
from tools.owned_game.big import BigArchive # noqa: E402
def hist(ts):
h = collections.Counter()
for a, b in zip(ts, ts[1:]):
h[min(int((b - a) // 25) * 25, 1000)] += 1
return dict(sorted(h.items()))
def main():
parser = argparse.ArgumentParser(description='Grain voice starts in a recomp audio trace.')
parser.add_argument('trace', type=Path)
parser.add_argument('--disc', type=Path, default=Path(os.environ.get('SKATE3_DISC', REPO / '.local/skate3-disc')))
parser.add_argument('--exclude', default='', help='comma-separated members left out of the pooled histogram')
parser.add_argument('--out', type=Path)
args = parser.parse_args()
exclude = {m for m in args.exclude.split(',') if m}
big = BigArchive(args.disc / 'data/audio/grains.big')
pref = {}
for e in big.entries:
b = big.read(e)
h = int.from_bytes(b[:4], 'big')
pref[b[h:h + 12].hex()] = Path(e.path).name
starts = [] # (ms, member, player, level)
for line in args.trace.open(encoding='utf-8', errors='replace'):
if not line.startswith('PLAY'):
continue
f = line.rstrip('\n').split('\t')
m = pref.get(f[-1][:24])
if m and len(f) > 5:
starts.append((float(f[1]), m, f[2], f[5]))
per = collections.Counter(s[1] for s in starts)
sec = collections.Counter(int(s[0] // 1000) for s in starts)
out = {'trace': str(args.trace), 'starts': len(starts), 'per_member': dict(per),
'starts_per_second_hist': dict(sorted(collections.Counter(sec.values()).items())),
'interval_ms_hist_pooled': hist([s[0] for s in starts if s[1] not in exclude]),
'interval_ms_hist_per_member': {m: hist([s[0] for s in starts if s[1] == m]) for m in per},
'levels_sample': collections.Counter(s[3] for s in starts).most_common(8)}
dst = args.out or REPO / '.local/recomp-trace' / f'grain_stats_{args.trace.parent.name}.json'
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(json.dumps(out, indent=1))
print(json.dumps({k: out[k] for k in ('starts', 'per_member', 'starts_per_second_hist', 'interval_ms_hist_pooled',
'levels_sample')}, indent=0))
for m, _ in per.most_common(3):
print(m, out['interval_ms_hist_per_member'][m])
if __name__ == '__main__':
main()
+65
View File
@@ -0,0 +1,65 @@
"""Retail grain-bed gain A on CLEAN straight rolling from GREC lines (see grec_level.py for the fields).
usage: py -3.13 tools/recomp-trace/grec_clean.py <trace.tsv> [--settle FRAMES] [--material M]
Clean = four wheels down, not in the air, |turn input| <= 0.02, I and Bk ~ 0, the +336 word 0 (no brake /
push event / manual brake) and the +340 word 0 (no balance, grind, trick), held for --settle consecutive
GREC lines (default 30). Prints per speed band the gain A of every running truck (median, p10, p90), the
two trucks' gains when both run, and the owner floats +1456 / +1028 (unknown roles) so a per-truck factor
shows up.
"""
import sys
from collections import defaultdict
sys.path.insert(0, __import__('os').path.dirname(__file__))
from grec_level import parse, q # noqa: E402
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'):
sys.exit(__doc__)
path = sys.argv[1]
settle = int(sys.argv[sys.argv.index('--settle') + 1]) if '--settle' in sys.argv else 30
only = int(sys.argv[sys.argv.index('--material') + 1]) if '--material' in sys.argv else None
bands = defaultdict(lambda: defaultdict(list))
run = 0
for raw in open(path, encoding='utf-8', errors='replace'):
if not raw.startswith('GREC\t'):
continue
r = parse(raw)
if r is None:
continue
s = raw.rstrip('\r\n').split('\t')[6].split()
o = [float(x) for x in raw.rstrip('\r\n').split('\t')[5].split()]
clean = (r['wheels'] == 4 and not r['air'] and abs(r['turn']) <= 0.02 and abs(r['I']) < 0.02 and r['Bk'] < 0.02
and s[4] == '00000000' and s[5] == '00000000' and (only is None or int(s[6]) == only))
run = run + 1 if clean else 0
if run < settle:
continue
band = int(r['speed'] * 3.6 // 5) * 5
d = bands[band]
running = [t for t in r['trucks'] if t['running']]
for t in running:
d['a'].append(t['a_gain'])
if len(running) == 2:
d['both'].append((running[0]['a_gain'], running[1]['a_gain']))
d['o1456'].append(o[4])
d['o1028'].append(o[6])
d['mat'].append(int(s[6]))
print(f'CLEAN STRAIGHT ROLL (settled {settle} lines)')
print(' km/h n gainA med [p10 p90] both-running n t0/t1 med +1456 med +1028 med materials')
for band in sorted(bands):
d = bands[band]
if len(d['a']) < 20:
continue
both = d['both']
mats = defaultdict(int)
for m in d['mat']:
mats[m] += 1
top = ' '.join(f'{m}:{c}' for m, c in sorted(mats.items(), key=lambda x: -x[1])[:4])
print(f" {band:3d}-{band + 5:<3d} {len(d['a']):6d} {q(d['a'], .5):.3f} [{q(d['a'], .1):.3f} {q(d['a'], .9):.3f}]"
f" {len(both):6d} {q([a for a, _ in both], .5) if both else float('nan'):.3f}/{q([b for _, b in both], .5) if both else float('nan'):.3f}"
f" {q(d['o1456'], .5):.3f} {q(d['o1028'], .5):.3f} {top}")
if __name__ == '__main__':
main()
+94
View File
@@ -0,0 +1,94 @@
"""Retail grain-bed levels from GREC lines (the grain-bed owner hook, local player, once per update).
usage: py -3.13 tools/recomp-trace/grec_level.py <trace.tsv> [--turn-max 0.02]
GREC fields (tab-separated): owner | truck0 "A: gain pitch pos active | B: gain pitch pos active | running" |
truck1 (same) | owner floats +1160 I(turn intensity) +1164 +1168 Bk(brake slew) +1508 downhill +1456 +1464
+1028 +1032 +1152 +1156 | state: +204 turn input, +208 ground speed, +200 wheels, +332 air (byte),
+336 brake (hex word), +340 balance (hex word), +620 material.
Straight roll = |turn input| <= --turn-max, air 0, wheels > 0, truck running, I and Bk ~ 0. For each speed band
(km/h) prints: frames, gain A / gain B / pitch A of the running truck (median, p10, p90), and
level(1) = gain A / (1 - max(|I|, Bk)) — the quantity the native port's bed gain A must match. Also a carve
section (|turn input| > 0.3): B vs turn and A dip, and a manual check is left to the caller.
"""
import sys
from collections import defaultdict
def q(values, p):
v = sorted(values)
return v[min(len(v) - 1, int(len(v) * p))] if v else float('nan')
def parse(line):
f = line.rstrip('\r\n').split('\t')
if len(f) < 7 or f[0] != 'GREC':
return None
trucks = []
for t in (f[3], f[4]):
parts = [p.strip() for p in t.split('|')]
if len(parts) != 3:
return None
a, b = parts[0].split(), parts[1].split()
if len(a) != 4 or len(b) != 4 or '-' in a[0]:
return None
trucks.append(dict(a_gain=float(a[0]), a_pitch=float(a[1]), a_on=int(a[3]), b_gain=float(b[0]),
b_pitch=float(b[1]), b_on=int(b[3]), running=int(parts[2])))
o = [float(x) for x in f[5].split()]
s = f[6].split()
if len(o) != 10 or len(s) != 7:
return None
return dict(ms=float(f[1]), trucks=trucks, I=o[0], Bk=o[2], downhill=o[3], turn=float(s[0]), speed=float(s[1]),
wheels=int(s[2]), air=int(s[3]))
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'):
sys.exit(__doc__)
path = sys.argv[1]
turn_max = float(sys.argv[sys.argv.index('--turn-max') + 1]) if '--turn-max' in sys.argv else 0.02
straight = defaultdict(lambda: defaultdict(list))
carve = defaultdict(list)
n = bad = 0
for raw in open(path, encoding='utf-8', errors='replace'):
if not raw.startswith('GREC\t'):
continue
r = parse(raw)
if r is None:
bad += 1
continue
n += 1
running = [t for t in r['trucks'] if t['running']]
if not running or r['air'] or r['wheels'] == 0:
continue
t = running[0]
kmh = r['speed'] * 3.6
band = int(kmh // 5) * 5
if abs(r['turn']) <= turn_max and abs(r['I']) < 0.02 and r['Bk'] < 0.02:
d = straight[band]
d['a'].append(t['a_gain'])
d['b'].append(t['b_gain'])
d['pitch'].append(t['a_pitch'])
d['level'].append(t['a_gain'] / max(1e-6, 1 - max(abs(r['I']), r['Bk'])))
elif abs(r['turn']) > 0.3:
carve[band].append((abs(r['turn']), abs(r['I']), t['a_gain'], t['b_gain']))
print(f'GREC lines parsed {n}, unparsable {bad}')
print('STRAIGHT ROLL (|turn| <= %.2f, I, Bk ~ 0)' % turn_max)
print(' km/h frames gainA med [p10 p90] gainB med pitchA med level(1) med [p10 p90]')
for band in sorted(straight):
d = straight[band]
if len(d['a']) < 30:
continue
print(f" {band:3d}-{band + 5:<3d} {len(d['a']):7d} {q(d['a'], .5):.3f} [{q(d['a'], .1):.3f} {q(d['a'], .9):.3f}]"
f" {q(d['b'], .5):.3f} {q(d['pitch'], .5):.3f} {q(d['level'], .5):.3f} [{q(d['level'], .1):.3f} {q(d['level'], .9):.3f}]")
print('CARVE (|turn| > 0.3)')
print(' km/h frames |turn| med I med gainA med gainB med')
for band in sorted(carve):
rows = carve[band]
if len(rows) < 30:
continue
print(f" {band:3d}-{band + 5:<3d} {len(rows):7d} {q([x[0] for x in rows], .5):.2f} {q([x[1] for x in rows], .5):.2f}"
f" {q([x[2] for x in rows], .5):.3f} {q([x[3] for x in rows], .5):.3f}")
if __name__ == '__main__':
main()
+57
View File
@@ -0,0 +1,57 @@
"""Retail wheel-0 material (+620) vs wheel count (+200) from GREC lines: does the material go to the
"no contact" value when a wheel loses contact, and how often does it change while rolling? (Class_Seams
fires a hit on every material change.)
usage: py -3.13 tools/recomp-trace/grec_material.py SESSION_DIR [SESSION_DIR...] [--no-contact N]
--no-contact: the material value logged without ground contact (default 143, as seen in the traces)."""
import collections
import sys
from pathlib import Path
if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'):
sys.exit(__doc__)
args = sys.argv[1:]
NO_CONTACT = 143
if '--no-contact' in args:
i = args.index('--no-contact')
NO_CONTACT = int(args[i + 1])
del args[i:i + 2]
for d in args:
rows = []
for line in (Path(d) / 'trace.tsv').open(errors='replace'):
if not line.startswith('GREC\t'):
continue
f = line.rstrip('\n').split('\t')
if len(f) < 7:
continue
s = f[6].split()
if len(s) != 7:
continue
try:
rows.append((float(f[1]), float(s[1]), int(s[2]), int(s[3]), int(s[6])))
except ValueError:
continue
# The hook can log several calls per game frame (a few ms apart): keep one row per frame.
kept, last = [], -1e9
for r in rows:
if r[0] - last >= 10:
kept.append(r)
last = r[0]
rows = kept
by = collections.Counter()
changes = moving = 0
prev = None
for ms, v, wheels, air, mat in rows:
if abs(v) > 2 and not air:
moving += 1
by[(wheels, mat == NO_CONTACT)] += 1
if prev is not None and prev[0] > ms - 40 and mat != prev[1]:
changes += 1
prev = (ms, mat)
print(d, f'{moving / 60:.0f} s rolling (>2 m/s, not air)')
if not moving:
continue
for w in range(5):
a, b = by[(w, False)], by[(w, True)]
if a + b:
print(f' wheels {w}: {a + b} frames, material {NO_CONTACT} in {b} ({100 * b / (a + b):.0f} %)')
print(f' wheel-0 material changes: {changes / (moving / 60):.2f} /s')
+116
View File
@@ -0,0 +1,116 @@
"""Resolve sample payloads in a recomp audio trace to (archive, bank member, stream index), by finding
the payload bytes in your own extracted disc's audio archives. Used by retail_voices.py; also runs alone
over the trace's XMA lines.
usage: py -3.13 tools/recomp-trace/resolve_trace.py <trace.tsv> <out.json> [--disc DIR]
--disc: extracted disc root, the folder holding data/ (default $SKATE3_DISC or .local/skate3-disc).
Writes {"events": [[ms, context, [archive, member, stream] | null], ...]} for every XMA line.
"""
import bisect
import json
import os
import sys
from multiprocessing import Pool
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO))
from tools.owned_game.big import BigArchive # noqa: E402
from tools.asset_pipeline.audio_formats import scan_snr, splc_streams, grain # noqa: E402
ROOT = Path(os.environ.get('SKATE3_DISC', REPO / '.local/skate3-disc')) / 'data/audio'
FILES = ['audiofiles.big', 'ambience.big', 'grains.big', 'wheels.big', 'post.big']
_data = {}
def set_disc(disc: Path):
"""Point the resolver at another extracted disc root."""
global ROOT
ROOT = Path(disc) / 'data/audio'
def _init(root=None):
"""Pool initialiser: load the archives into this process (root = the data/audio folder)."""
base = Path(root) if root else ROOT
for n in FILES:
_data[n] = (base / n).read_bytes()
def _find(payload):
key = bytes.fromhex(payload)
for n in FILES:
i = _data[n].find(key)
if i >= 0:
return payload, n, i
return payload, None, -1
def stream_index():
"""Per archive: sorted (entry offset, stored size, member name, stream byte ranges)."""
out = {}
for n in FILES:
a = BigArchive(ROOT / n)
entries = []
for e in a.entries:
d = a.read(e) if e.compression == 0 else None
name = Path(e.path).name
ranges = []
if d is not None:
try:
if name.endswith('.bnk'):
ss = splc_streams(d)
elif name.endswith('.grain'):
ss = [grain(d).stream]
elif name.endswith('.sns'):
ss = [] # ambience bodies: one stream per file
else:
ss = scan_snr(d)
ranges = [(s.offset, s.end) for s in ss]
except Exception:
ranges = []
entries.append((e.offset, e.stored_size, name, ranges))
entries.sort()
out[n] = entries
return out
def locate(index, n, pos):
entries = index[n]
i = bisect.bisect_right([e[0] for e in entries], pos) - 1
if i < 0:
return None
off, size, name, ranges = entries[i]
if pos >= off + size:
return None
rel = pos - off
for k, (a, b) in enumerate(ranges):
if a <= rel < b:
return name, k
return name, None
def main():
args = sys.argv[1:]
if len(args) < 2 or args[0] in ('-h', '--help'):
sys.exit(__doc__)
if '--disc' in args:
i = args.index('--disc')
set_disc(Path(args[i + 1]))
del args[i:i + 2]
trace, out = Path(args[0]), Path(args[1])
lines = [l.rstrip('\n').split('\t') for l in trace.open(encoding='utf-8', errors='replace')]
xma = [l for l in lines if l[0] == 'XMA' and len(l) > 6]
uniq = sorted({l[6] for l in xma})
with Pool(processes=8, initializer=_init, initargs=(str(ROOT),)) as pool:
hits = pool.map(_find, uniq, chunksize=64)
index = stream_index()
resolved = {}
for payload, n, pos in hits:
resolved[payload] = (n,) + locate(index, n, pos) if n and locate(index, n, pos) else None
events = [(float(l[1]), int(l[2]), resolved.get(l[6])) for l in xma]
out.write_text(json.dumps({'events': events}))
print('xma lines', len(events), 'resolved', sum(1 for e in events if e[2]))
if __name__ == '__main__':
main()
+80
View File
@@ -0,0 +1,80 @@
"""How a world-emitter bank's voices follow each other in a recomp session: every PLAY of the bank
(resolved through the session's play_resolve.json cache, written by retail_voices.py), its player,
sample slot, pitch (MOD PITCH v3) and wall-clock end (sample seconds / pitch), the gap to the
previous start, the overlap with the voice before, back-to-back repeats of a slot, and the most
voices of the bank sounding at once. For "doubling / restarting" listening reports.
usage: py -3.13 tools/recomp-trace/retail_relay.py <session dir> <bank> [--quiet] [--from MS] [--to MS] [--assets DIR]
--from/--to keep one emitter run (e.g. a stretch where the player stood still). The summary also
splits the starts by relay turn (1st, 3rd, … vs 2nd, 4th, …) = the two players' shuffle bags.
Sample lengths come from the install's decoded WAVs (<assets>/private/audio/banks/<bank>/; --assets,
default the repo's assets junction).
Reference reading of our own traces; nothing is shipped.
"""
import json
import sys
import wave
from collections import defaultdict
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
def main():
if len(sys.argv) < 3 or sys.argv[1] in ('-h', '--help'):
sys.exit(__doc__)
session, bank = Path(sys.argv[1]), sys.argv[2]
quiet = '--quiet' in sys.argv
arg = lambda k, d: float(sys.argv[sys.argv.index(k) + 1]) if k in sys.argv else d
lo, hi = arg('--from', float('-inf')), arg('--to', float('inf'))
cache = json.loads((session / 'play_resolve.json').read_text())
seconds = {}
assets = Path(sys.argv[sys.argv.index('--assets') + 1]) if '--assets' in sys.argv else REPO / 'assets'
for wav in sorted((assets / 'private/audio/banks' / bank).glob('*.wav')):
with wave.open(str(wav)) as w:
seconds[int(wav.stem)] = w.getnframes() / w.getframerate()
plays, pitch = [], defaultdict(list)
for raw in (session / 'trace.tsv').open(encoding='utf-8', errors='replace'):
f = raw.rstrip('\n').split('\t')
if len(f) < 4:
continue
if f[0] == 'PLAY' and len(f) > 7:
hit = cache.get(f[7])
if hit and hit[0] == bank and lo <= float(f[1]) <= hi:
plays.append((float(f[1]), f[2], int(hit[1])))
elif f[0] == 'MOD' and f[2] == 'PITCH' and len(f) > 7:
try:
pitch[f[3]].append((float(f[1]), float(f[7])))
except ValueError:
pass
if not plays:
print(f'{session.name}: no {bank} plays')
return
rows, live, most, repeats = [], [], 0, 0
prev = None
for ms, player, slot in plays:
p = [v for t, v in pitch[player] if ms - 1 <= t <= ms + 400 and v > 0]
ratio = p[-1] if p else 1.0
end = ms + 1000.0 * seconds.get(slot, 0.0) / max(ratio, 0.25)
live = [e for e in live if e > ms]
live.append(end)
most = max(most, len(live))
gap = ms - prev[0] if prev else float('nan')
overlap = prev[3] - ms if prev else float('nan')
same = prev is not None and prev[2] == slot
repeats += same
rows.append((ms, player, slot, ratio, end))
if not quiet:
print(f'{ms / 1000:9.3f}s {player} slot {slot:2d} pitch {ratio:.3f} lasts {(end - ms) / 1000:.3f}s '
f'gap {gap / 1000:6.3f}s overlap {overlap / 1000:6.3f}s{" SAME SLOT" if same else ""}')
prev = (ms, player, slot, end)
span = (plays[-1][0] - plays[0][0]) / 1000
players = sorted({r[1] for r in rows})
print(f'{session.name} {bank}: {len(plays)} starts over {span:.1f} s, players {len(players)}, '
f'most at once {most}, back-to-back same slot {repeats}')
slots = [r[2] for r in rows]
print(f' by relay turn: A {slots[0::2]} B {slots[1::2]}')
if __name__ == '__main__':
main()
+211
View File
@@ -0,0 +1,211 @@
"""Join a recomp session's per-voice lines: PLAY (sample start, resolved to bank/stream) with the MOD
(PITCH / LPF / HPF / SHELF / PEAK), GAIN and SEND lines of the same player until its next PLAY.
Reference reading of our own traces; nothing is shipped.
usage: py -3.13 tools/recomp-trace/retail_voices.py <session dir> [--bank NAME[,NAME]] [--csv OUT] [--modules]
[--no-disambiguate] [--disc DIR]
The session dir holds trace.tsv. Sample payloads are resolved against your own extracted disc
(--disc, default $SKATE3_DISC or .local/skate3-disc) with resolve_trace.py.
Writes/reads a cache of resolved sample payloads (<session>/play_resolve.json, all matches in
play_resolve_all.json). Byte-identical samples in several banks (Treatments 16/17 = sense_of_speed 3/4)
are attributed by sample address (otherwise Treatments 16/17 read as sense_of_speed).
Output per bank: voices, pitch ratio (PITCH v3) and requested rate (v1) / scale (v2) quantiles,
LPF / HPF cutoffs, gain (product of GAIN targets) × strongest SEND target; --modules also lists the
non-voice module owners (buses) with their PEAK/SHELF settings.
"""
import bisect
import json
import statistics as st
import sys
from collections import Counter, defaultdict
from multiprocessing import Pool
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import resolve_trace as rt # noqa: E402
def load(session: Path):
lines = defaultdict(list)
for raw in (session / 'trace.tsv').open(encoding='utf-8', errors='replace'):
f = raw.rstrip('\n').split('\t')
if len(f) < 3 or f[0] not in ('PLAY', 'MOD', 'GAIN', 'SEND', 'ASTATE', 'SKATEB', 'CAPTURE', 'POST', 'SPLC'):
continue
try:
ms = float(f[1])
except ValueError:
continue
lines[f[0]].append((ms, f[2:]))
return lines
def resolve(session: Path, plays):
cache_path = session / 'play_resolve.json'
cache = json.loads(cache_path.read_text()) if cache_path.exists() else {}
todo = sorted({f[5] for _, f in plays if len(f) > 5} - set(cache))
if todo:
index = rt.stream_index()
with Pool(8, initializer=rt._init, initargs=(str(rt.ROOT),)) as pool:
for payload, n, pos in pool.imap_unordered(rt._find, todo, chunksize=64):
hit = rt.locate(index, n, pos) if n else None
cache[payload] = [Path(hit[0]).stem, hit[1]] if hit else None
cache_path.write_text(json.dumps(cache))
return cache
def _find_all(payload):
"""Every bank/stream whose data contains this payload (byte-identical samples exist across banks)."""
key = bytes.fromhex(payload)
hits = []
for n in rt.FILES:
data = rt._data[n]
i = data.find(key)
while i >= 0:
hits.append((n, i))
i = data.find(key, i + 1)
return payload, hits
def disambiguate(session: Path, plays, cache):
"""Byte-identical samples in two banks (Treatments 16/17 = sense_of_speed 3/4) resolve to the
first bank in the archive. Re-attribute such plays by the voice's sample address (PLAY field 3):
each bank's samples sit together in memory, so pick the candidate bank whose unambiguous plays
lie nearest. Cache: <session>/play_resolve_all.json."""
all_path = session / 'play_resolve_all.json'
found = json.loads(all_path.read_text()) if all_path.exists() else {}
todo = sorted({f[5] for _, f in plays if len(f) > 5 and cache.get(f[5])} - set(found))
if todo:
index = rt.stream_index()
with Pool(8, initializer=rt._init, initargs=(str(rt.ROOT),)) as pool:
for payload, hits in pool.imap_unordered(_find_all, todo, chunksize=64):
cands = {tuple(c) for c in (rt.locate(index, n, pos) for n, pos in hits) if c}
found[payload] = sorted([Path(c[0]).stem, c[1]] for c in cands)
all_path.write_text(json.dumps(found))
ambiguous = {p for p, c in found.items() if len({b for b, _ in c}) > 1}
if not ambiguous:
return {}
homes = defaultdict(list) # bank -> sample addresses of its unambiguous plays
for _, f in plays:
if len(f) > 5 and cache.get(f[5]) and f[5] not in ambiguous:
homes[cache[f[5]][0]].append(int(f[2], 16))
for v in homes.values():
v.sort()
def dist(bank, addr):
v = homes.get(bank)
if not v:
return float('inf')
i = bisect.bisect_left(v, addr)
return min(abs(v[j] - addr) for j in (i - 1, i) if 0 <= j < len(v))
fixed = {}
for _, f in plays:
if len(f) > 5 and f[5] in ambiguous:
addr = int(f[2], 16)
fixed[(f[0], f[2], f[5])] = min(found[f[5]], key=lambda c: dist(c[0], addr))
return fixed
def q(v, p):
v = sorted(v)
return v[min(len(v) - 1, int(p * (len(v) - 1) + 0.5))] if v else float('nan')
def main():
if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'):
sys.exit(__doc__)
if '--disc' in sys.argv:
rt.set_disc(Path(sys.argv[sys.argv.index('--disc') + 1]))
session = Path(sys.argv[1])
banks = None
if '--bank' in sys.argv:
banks = set(sys.argv[sys.argv.index('--bank') + 1].split(','))
lines = load(session)
plays = lines['PLAY']
cache = resolve(session, plays)
fixed = disambiguate(session, plays, cache) if '--no-disambiguate' not in sys.argv else {}
# per player: sorted PLAY times, to bound each voice's lines
starts = defaultdict(list)
for ms, f in plays:
starts[f[0]].append(ms)
per_player = defaultdict(lambda: defaultdict(list)) # player -> kind -> [(ms, fields)]
voice_players = set(starts)
for ms, f in lines['MOD']:
owner = f[1].lower().replace('0x', '0x')
per_player[f[1]]['MOD'].append((ms, f))
for ms, f in lines['GAIN']:
per_player[f[0]]['GAIN'].append((ms, f))
for ms, f in lines['SEND']:
per_player[f[0]]['SEND'].append((ms, f))
out = defaultdict(lambda: defaultdict(list))
rows = []
for ms, f in plays:
player = f[0]
hit = cache.get(f[5]) if len(f) > 5 else None
hit = fixed.get((f[0], f[2], f[5]), hit) if len(f) > 5 else hit
bank, stream = (hit if hit else (None, None))
if banks and bank not in banks:
continue
nxt = starts[player]
i = bisect.bisect_right(nxt, ms)
end = nxt[i] if i < len(nxt) else ms + 30000
rec = {'ms': ms, 'bank': bank, 'stream': stream, 'pitch': [], 'rate': [], 'scale': [], 'lpf': [], 'hpf': [], 'gain': {}, 'send': 0.0}
for kind in ('MOD', 'GAIN', 'SEND'):
for t, g in per_player[player][kind]:
if t < ms - 1 or t >= end:
continue
if kind == 'MOD':
if g[0] == 'PITCH':
rec['rate'].append(float(g[3]))
rec['scale'].append(float(g[4]))
rec['pitch'].append(float(g[5]))
elif g[0] == 'LPF':
rec['lpf'].append(float(g[3]))
elif g[0] == 'HPF':
rec['hpf'].append(float(g[3]))
elif kind == 'GAIN':
rec['gain'][g[1]] = max(rec['gain'].get(g[1], 0.0), float(g[2]))
else:
rec['send'] = max(rec['send'], float(g[2]))
rows.append(rec)
o = out[bank]
o['n'].append(1)
o['pitch'] += [p for p in rec['pitch'] if p > 0]
o['rate'] += rec['rate']
o['scale'] += rec['scale']
o['lpf'] += rec['lpf']
o['hpf'] += rec['hpf']
g = 1.0
for v in rec['gain'].values():
g *= v
o['level'].append(g * rec['send'])
o['streams'].append(stream)
print(f'{"bank":28s} voices pitch p10/p50/p90 scale p10/p50/p90 rate modal LPF p50 HPF p50 level p50/p90')
for bank, o in sorted(out.items(), key=lambda kv: -len(kv[1]['n'])):
rate = Counter(round(r) for r in o['rate']).most_common(2)
print(f'{str(bank):28s} {len(o["n"]):6d} {q(o["pitch"], .1):.3f}/{q(o["pitch"], .5):.3f}/{q(o["pitch"], .9):.3f} '
f'{q(o["scale"], .1):.3f}/{q(o["scale"], .5):.3f}/{q(o["scale"], .9):.3f} {rate} '
f'{q(o["lpf"], .5):8.0f} {q(o["hpf"], .5):7.0f} {q(o["level"], .5):.3f}/{q(o["level"], .9):.3f} '
f'streams {Counter(o["streams"]).most_common(6)}')
if '--csv' in sys.argv:
import csv
with open(sys.argv[sys.argv.index('--csv') + 1], 'w', newline='') as fh:
w = csv.writer(fh)
w.writerow(['ms', 'bank', 'stream', 'pitch_p50', 'scale_p50', 'rate', 'lpf_p50', 'hpf_p50', 'gain', 'send'])
for r in rows:
g = 1.0
for v in r['gain'].values():
g *= v
w.writerow([r['ms'], r['bank'], r['stream'], q(r['pitch'], .5), q(r['scale'], .5), q(r['rate'], .5), q(r['lpf'], .5), q(r['hpf'], .5), g, r['send']])
if '--modules' in sys.argv:
non_voice = Counter()
for ms, f in lines['MOD']:
if f[1] not in voice_players:
non_voice[(f[0], f[1])] += 1
for (kind, owner), n in non_voice.most_common(40):
vals = [tuple(g[3:6]) for t, g in per_player[owner]['MOD'] if g[0] == kind]
print(kind, owner, n, Counter(vals).most_common(3))
if __name__ == '__main__':
main()
+157
View File
@@ -0,0 +1,157 @@
"""End-to-end check against retail: straight-rolling windows of a recomp session's capture
(audio.f32 = the recomp host's stereo fold 0.4·(L + Ls + 0.5·C) per side, CAPTURE-aligned) against
the e2e renders (tools/audio-e2e) folded the same way.
Windows: SKATEB samples with the four wheels down (no truck/deck contact), the horizontal deck
speed steady (±12 %) for >= 1.0 s and no SPLC line from the given board-contact callers inside
(--contact-callers: comma-separated caller address prefixes of the SPLC lines that mark pops, landings,
touchdowns and foot taps; find them in your own trace; default none). Per
5 km/h bin: median RMS (dBFS), octave bands (dB rel. total) and the standing-still floor (speed
< 0.3 m/s windows: ambience + emitters, what the capture holds besides the board).
usage: py -3.13 tools/recomp-trace/retail_windows.py SESSION_DIR [E2E_DIR] [--contact-callers P1,P2]
[--sides ours,other]
E2E_DIR defaults to .local/audio-e2e; --sides are the render suffixes compared (<name>.<side>.f32).
"""
import math
import sys
from bisect import bisect_left
from collections import defaultdict
from pathlib import Path
import numpy as np
RATE = 48000
# SPLC caller prefixes of the board contacts (--contact-callers): a window holding one is not plain rolling.
CONTACT_CALLERS = ()
BANDS = [63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
def load_trace(path):
sk, splc, caps = [], [], []
# SKATEB interleaves every skater's board: the player's is the one logged most.
from collections import Counter
boards = Counter(l.split('\t')[2] for l in path.open(encoding='utf-8', errors='replace') if l.startswith('SKATEB ') and l.count('\t') > 3)
player = boards.most_common(1)[0][0] if boards else None
for raw in path.open(encoding='utf-8', errors='replace'):
f = raw.rstrip('\n').split('\t')
if len(f) < 3:
continue
try:
ms = float(f[1])
except ValueError:
continue
if f[0] == 'SKATEB' and len(f) >= 6:
if f[2] != player:
continue
try:
v = [float(x) for x in f[5].split()]
sk.append((ms, f[3], math.hypot(v[0], v[2])))
except (ValueError, IndexError):
pass
elif f[0] == 'SPLC' and len(f) >= 5 and CONTACT_CALLERS and f[4].upper().startswith(CONTACT_CALLERS):
splc.append(ms)
elif f[0] == 'CAPTURE':
try:
caps.append((ms, int(f[2])))
except ValueError:
pass
return sk, sorted(splc), caps
def capture(session, caps, a, b):
ms = np.array([c[0] for c in caps])
frames = np.array([c[1] for c in caps])
fa, fb = (int(np.interp(t, ms, frames)) for t in (a, b))
data = np.memmap(session / 'audio.f32', dtype='<f4', mode='r')
return np.asarray(data[2 * fa:2 * fb]).reshape(-1, 2)
def metrics(x):
mono = x.mean(1)
rms = 10 * math.log10(max(float(np.mean(x * x)), 1e-20))
spec = np.abs(np.fft.rfft(mono * np.hanning(len(mono)))) ** 2
freqs = np.fft.rfftfreq(len(mono), 1 / RATE)
total = spec.sum() + 1e-30
bands = [10 * math.log10(max(spec[(freqs >= f / math.sqrt(2)) & (freqs < f * math.sqrt(2))].sum() / total, 1e-20)) for f in BANDS]
return rms, bands
def windows(sk, splc, min_s=1.0):
out = []
i = 0
while i < len(sk):
ms, contacts, v = sk[i]
if contacts[:4] != '1111' or contacts[4:] != '000':
i += 1
continue
j = i
while j + 1 < len(sk) and sk[j + 1][1] == contacts and abs(sk[j + 1][2] - v) <= 0.12 * max(v, 0.3) and sk[j + 1][0] - sk[j][0] < 400:
j += 1
a, b = ms, sk[j][0]
if b - a >= min_s * 1000:
k = bisect_left(splc, a)
if not (k < len(splc) and splc[k] <= b):
out.append((a, b, float(np.mean([s[2] for s in sk[i:j + 1]]))))
i = j + 1
return out
def fold_capture(path):
d = np.fromfile(path, dtype='<f4')
d = d[: len(d) // 6 * 6].reshape(-1, 6) # L, R, C, LFE, Ls, Rs
left = 0.4 * (d[:, 0] + d[:, 4] + 0.5 * d[:, 2])
right = 0.4 * (d[:, 1] + d[:, 5] + 0.5 * d[:, 2])
return np.stack([left, right], 1)[RATE:]
def main():
global CONTACT_CALLERS
args = sys.argv[1:]
if not args or args[0] in ('-h', '--help'):
sys.exit(__doc__)
sides = ('ours', 'other')
for flag in ('--contact-callers', '--sides'):
if flag in args:
i = args.index(flag)
values = tuple(v.strip().upper() if flag == '--contact-callers' else v.strip() for v in args[i + 1].split(',') if v.strip())
if flag == '--contact-callers':
CONTACT_CALLERS = values
else:
sides = values
del args[i:i + 2]
session = Path(args[0])
e2e = Path(args[1]) if len(args) > 1 else Path(__file__).resolve().parents[2] / '.local/audio-e2e'
sk, splc, caps = load_trace(session / 'trace.tsv')
ws = windows(sk, splc)
bins = defaultdict(list)
for a, b, v in ws:
x = capture(session, caps, a, b)
if len(x) < RATE // 2:
continue
kmh = v * 3.6
key = 0 if kmh < 1 else int(round(kmh / 5) * 5)
bins[key].append(metrics(x))
print(f'{session.name}: {len(ws)} straight four-wheel windows')
print('bin km/h n retail RMS p50 (p10..p90) octave bands p50 (63..16k)')
retail = {}
for k in sorted(bins):
r = sorted(m[0] for m in bins[k])
b = np.median(np.array([m[1] for m in bins[k]]), axis=0)
retail[k] = (r[len(r) // 2], b)
print(f'{k:4d} {len(r):3d} {r[len(r) // 2]:6.1f} ({r[len(r) // 10]:6.1f}..{r[(len(r) * 9) // 10]:6.1f}) ' + ' '.join(f'{x:5.1f}' for x in b))
print('\nrenders in the capture fold (straight roll scenarios, after the settle second):')
for name, kmh in (('roll10', 10), ('roll20', 20), ('roll30', 30), ('roll45', 45)):
for side in sides:
p = e2e / f'{name}.{side}.f32'
if not p.exists():
continue
rms, bands = metrics(fold_capture(p))
ref = retail.get(kmh)
delta = f'{rms - ref[0]:+5.1f} dB vs retail' if ref else 'no retail bin'
bd = ' '.join(f'{x - y:+5.1f}' for x, y in zip(bands, ref[1])) if ref else ''
print(f'{name:7s} {side:5s} RMS {rms:6.1f} ({delta}) bands d {bd}')
if __name__ == '__main__':
main()
+57
View File
@@ -0,0 +1,57 @@
"""Summarise retail per-channel send gains from a recomp audio trace (SEND lines).
SEND <ms> <owner> <object> <target> <channels> <cell0,cell1,...> (send hook; change-only)
The cells are the send's per-channel gains (pan matrix row x target). For the voice graph's final
6-channel send, cells/target is the panner output for that voice, so this checks a pan law:
power sum, LFE share, most common shapes.
Usage: py -3.13 tools/recomp-trace/send_vectors.py <trace.tsv> [channels=6] [min_target=0.05]
"""
import collections
import sys
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'):
sys.exit(__doc__)
path = sys.argv[1]
want = int(sys.argv[2]) if len(sys.argv) > 2 else 6
floor = float(sys.argv[3]) if len(sys.argv) > 3 else 0.05
sums, lfe, shapes, peaks = [], [], collections.Counter(), []
with open(path, encoding="utf-8", errors="replace") as fh:
for line in fh:
if not line.startswith("SEND\t"):
continue
f = line.rstrip("\n").split("\t")
if len(f) < 7 or not f[5].isdigit() or int(f[5]) != want:
continue
try:
target = float(f[4])
cells = [float(x) for x in f[6].split(",")]
except ValueError:
continue
if target < floor or len(cells) != want:
continue
r = [c / target for c in cells]
sums.append(sum(x * x for x in r))
peaks.append(max(r))
if want == 6:
lfe.append(r[3])
shapes[tuple(round(x, 3) for x in r)] += 1
def pct(xs, p):
xs = sorted(xs)
return xs[min(len(xs) - 1, int(p * len(xs)))] if xs else float("nan")
print(f"vectors {len(sums)} (channels {want}, target >= {floor})")
print("sum of squares p1/p50/p99: %.4f %.4f %.4f" % (pct(sums, .01), pct(sums, .5), pct(sums, .99)))
print("max cell p1/p50/p99: %.4f %.4f %.4f" % (pct(peaks, .01), pct(peaks, .5), pct(peaks, .99)))
if lfe:
print("LFE share p50/p99/max: %.4f %.4f %.4f" % (pct(lfe, .5), pct(lfe, .99), max(lfe)))
print("most common shapes (cells / target):")
for shape, n in shapes.most_common(30):
print(f" {n:6d} {shape}")
if __name__ == "__main__":
main()
+120
View File
@@ -0,0 +1,120 @@
"""Shared reader for skate3recomp research traces (`trace.tsv` from the hooks in src/research/*).
Lines are `KIND <tab> <ms> <tab> fields…`, all on one time base. Special kinds:
- MARK <ms> <label>: script steps, e.g. "at <stop>", "listen <stop>", "done";
- CLOCK <ms> <unix ms>: aligns trace time with wall clock (screenshot names);
- CAPTURE <ms> <frames>: aligns trace time with the float32 stereo capture (`trace.f32`).
from trace import Trace
t = Trace('sessions/my_session/trace.tsv')
for stop, lines in t.by_stop('WATCH'): # lines are (ms, fields)
...
t.shot_for(ms) # nearest screenshot path
t.capture(a_ms, b_ms) # numpy (n, 2) float32 window
"""
from __future__ import annotations
from bisect import bisect_left
from collections import defaultdict
from pathlib import Path
from typing import Iterator
# Fixed field counts (after KIND and ms) for kinds whose hooks write a fixed layout; `Trace.malformed()` checks
# them. Hook documentation: the header comment above each hook in skate3recomp src/research/hooks_*.cpp.
FIELD_COUNTS = {
'TREAT': 8, # +236 +240 +260 +224 +332 +200 object local (Class_Treatment)
'SEAMPAT': 11, # +636 +648 +620 +208 frame_ms w0 w1 w2 w3 ("x y z") object local (Class_Seams)
'SEAMHIT': 5, # wheel single transition object local (seam hit)
# Category audiox. Space-separated groups count as one field.
'GRECX': 7, # owner w332(hex) slip232 rev690 counter1516 cam("x y z") view("x y z") (grain-bed owner hook, local player)
'FIRSTHIT': 6, # B b16(u8) f24 bail676 end677 why(1 byte 2 float 4 bail/end 8 heartbeat) (GREC hook)
'SKID': 7, # owner holder handle w0..w17(18 ints, or "-" on release) counter1516 slip232 rev690 (skid hook)
'EMITSLOT': 11, # object state info0(patch) info4(positional) info8(level) info12 info16 mixkey handle w0..w8 out0..out9(hex) (emitter slot hook)
}
class Trace:
def __init__(self, path: str | Path):
self.path = Path(path)
self.lines: dict[str, list[tuple[float, list[str]]]] = defaultdict(list)
self.marks: list[tuple[float, str]] = []
self.clock: float | None = None # unix ms at trace ms 0
self.captures: list[tuple[float, int]] = []
for raw in self.path.open(encoding='utf-8', errors='replace'):
f = raw.rstrip('\n').split('\t')
if len(f) < 2:
continue
try:
ms = float(f[1])
except ValueError:
continue
kind = f[0]
try: # a few lines can be split/merged by the two trace writers; skip what doesn't parse
if kind == 'MARK':
self.marks.append((ms, f[2] if len(f) > 2 else ''))
elif kind == 'CLOCK' and self.clock is None and len(f) > 2:
self.clock = int(f[2]) - ms
elif kind == 'CAPTURE' and len(f) > 2:
self.captures.append((ms, int(f[2])))
except ValueError:
continue
self.lines[kind].append((ms, f[2:]))
def malformed(self) -> dict[str, int]:
"""Lines per kind whose field count differs from FIELD_COUNTS (should be all zero)."""
return {k: sum(1 for _, f in self.lines.get(k, []) if len(f) != n) for k, n in FIELD_COUNTS.items()}
def kinds(self) -> dict[str, int]:
return {k: len(v) for k, v in self.lines.items()}
def stops(self) -> list[tuple[str, float, float]]:
"""(name, start ms, end ms) for each `at <name>` mark, ending at the next `at`/`done` mark."""
starts = [(ms, label[3:].split('#')[0].strip()) for ms, label in self.marks if label.startswith('at ')]
ends = [ms for ms, label in self.marks if label.startswith(('at ', 'done'))]
out = []
for ms, name in starts:
later = [e for e in ends if e > ms]
out.append((name, ms, later[0] if later else float('inf')))
return out
def by_stop(self, kind: str) -> Iterator[tuple[str, list[tuple[float, list[str]]]]]:
for name, a, b in self.stops():
yield name, [(ms, f) for ms, f in self.lines.get(kind, []) if a <= ms < b]
def shot_for(self, ms: float) -> Path | None:
"""Nearest screenshot (`<trace stem>_shots/` or `shots/`, `shot_<unix ms>.jpg|png`) to trace time `ms`."""
if self.clock is None:
return None
# scripted runs: <trace stem>_shots/; passive sessions (PLAY_TRACE_*.bat): shots/
folders = [self.path.with_name(self.path.stem + '_shots'), self.path.with_name('shots')]
folder = next((f for f in folders if f.exists()), None)
shots = sorted(folder.glob('shot_*.*')) if folder else []
if not shots:
return None
stamps = [int(p.stem.split('_')[1]) for p in shots]
target = self.clock + ms
i = bisect_left(stamps, target)
best = min((j for j in (i - 1, i) if 0 <= j < len(stamps)), key=lambda j: abs(stamps[j] - target))
return shots[best]
def capture(self, a_ms: float, b_ms: float):
"""Stereo float32 frames of `trace.f32` between two trace times (numpy array (n, 2))."""
import numpy
ms = numpy.array([c[0] for c in self.captures])
frames = numpy.array([c[1] for c in self.captures])
a, b = (int(numpy.interp(t, ms, frames)) for t in (a_ms, b_ms))
data = numpy.memmap(self.path.with_suffix('.f32'), dtype='<f4', mode='r')
return numpy.asarray(data[2 * a:2 * b]).reshape(-1, 2)
if __name__ == '__main__':
import sys
if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'):
sys.exit('usage: py -3.13 tools/recomp-trace/trace.py <trace.tsv> (line counts per kind, malformed '
'fixed-layout lines, MARK stops)')
t = Trace(sys.argv[1])
print(t.kinds())
print('malformed (fixed-layout kinds):', t.malformed())
for name, a, b in t.stops():
print(f'{name:28s} {a / 1000:8.1f} .. {b / 1000:8.1f} s')
+91
View File
@@ -0,0 +1,91 @@
"""Traffic summary from a trace with hooks_traffic.cpp lines (VEHSTATE, TRAF*, PEDHONKED).
usage: py -3.13 tools/recomp-trace/veh_trace.py <trace.tsv> [--vehicle ADDR]
Per vehicle: samples, time span, horizontal speed from positions (median / p90 / max), target speed (+3408)
range, manoeuvre (+4396) and target-lane (+4380) changes, stops (speed < 0.3 m/s for ≥ 1 s). Then the
first-pass TRAF* lines with times (horn, skid, engine, light, phase lengths) and screenshot names.
--vehicle prints one vehicle's samples (t, target, manoeuvre, lane, speed, position).
"""
import math
import sys
from collections import defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from trace import Trace # noqa: E402
def samples(t: Trace):
out = defaultdict(list)
for ms, f in t.lines.get('VEHSTATE', []):
if len(f) < 9:
continue
try:
pos = [float(v) for v in f[7].split()]
out[f[0]].append((ms / 1000, float(f[1]), int(f[2]), int(f[3]), pos))
except (ValueError, IndexError):
continue
return out
def pct(values, q):
v = sorted(values)
return v[min(len(v) - 1, int(len(v) * q))] if v else float('nan')
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'):
sys.exit(__doc__)
t = Trace(sys.argv[1])
vehicles = samples(t)
if '--vehicle' in sys.argv:
key = sys.argv[sys.argv.index('--vehicle') + 1].upper()
prev = None
for s in vehicles.get(key, []):
speed = math.dist(s[4][::2], prev[4][::2]) / (s[0] - prev[0]) if prev and s[0] > prev[0] else 0
print(f'{s[0]:8.2f} target {s[1]:6.2f} man {s[2]} lane {s[3]} speed {speed:6.2f} pos {s[4]}')
prev = s
return
print(f'{len(vehicles)} vehicles')
all_speeds = []
for key, ss in sorted(vehicles.items(), key=lambda kv: -len(kv[1])):
speeds, stops, still_since = [], [], None
for a, b in zip(ss, ss[1:]):
dt = b[0] - a[0]
if not 0.1 < dt < 1.0:
continue
v = math.dist(a[4][::2], b[4][::2]) / dt
if v > 60:
continue # spawn / teleport jump
speeds.append(v)
if v < 0.3:
still_since = still_since if still_since is not None else a[0]
else:
if still_since is not None and a[0] - still_since >= 1.0:
stops.append((still_since, a[0]))
still_since = None
all_speeds += speeds
mans = [(s[0], s[2]) for i, s in enumerate(ss) if i and s[2] != ss[i - 1][2]]
lanes = [(s[0], s[3]) for i, s in enumerate(ss) if i and s[3] != ss[i - 1][3]]
targets = [s[1] for s in ss]
print(f'== {key}: {len(ss)} samples {ss[0][0]:.1f}-{ss[-1][0]:.1f} s; speed median {pct(speeds, .5):.2f} '
f'p90 {pct(speeds, .9):.2f} max {max(speeds, default=0):.2f} m/s; target {min(targets):.2f}..{max(targets):.2f}')
for a, b in stops:
print(f' stop {a:.1f}-{b:.1f} s ({b - a:.1f} s)')
for when, m in mans:
print(f' manoeuvre -> {m} at {when:.2f} s')
for when, l in lanes:
print(f' target lane -> {l} at {when:.2f} s')
moving = [v for v in all_speeds if v > 0.5]
print(f'all vehicles moving: n {len(moving)} median {pct(moving, .5):.2f} p90 {pct(moving, .9):.2f} m/s')
for kind in ('TRAFHORN', 'TRAFSKID', 'TRAFLIGHT', 'TRAFPHASE', 'PEDHONKED'):
lines = t.lines.get(kind, [])
print(f'-- {kind}: {len(lines)}')
for ms, f in lines[:30]:
fl = f[2].split() if len(f) > 2 else []
shot = t.shot_for(ms)
print(f' {ms / 1000:8.2f} f1 {fl[0] if fl else "?"} callers {f[-1] if f else ""}' + (f' [{shot.name}]' if shot else ''))
if __name__ == '__main__':
main()
+64
View File
@@ -0,0 +1,64 @@
# Regression checks
Checks to run after changing the asset pipeline, the converter, input, native dependencies, build
scripts or game code, before calling a change done. Each one compares the current install or build
against a baseline you record yourself on a known-good state.
| Script | What it checks |
|---|---|
| `check_maps.py` | Loads every installed map in one game process (`skate3rust --validate-maps`) and compares each map's loaded collision triangle count with the baseline. Fails on any map error or warning (spawn support, startup, invisible floor). Falls back to one `--check-assets` launch per map on builds without `--validate-maps`. |
| `check_spawns.py` | Reads the spawn baked into every installed `.skate` map and compares it with the baseline (0.01 m tolerance). |
| `check_customiser.py` | Snapshots the character customiser outputs (every stage receipt: path, size, SHA-256 of each file) and compares a later install with it. Ignores differences that are only the random set id embedded in JSON paths. |
| `smoke_maps.sh` | Muted crash smoke test: launches the game on each map for N seconds (windowed, `--mute`) and counts panics, `ERROR` lines, non-finite physics and render-ready lines in the log. |
## Inputs
- An installation set up by `tools/setup.py` (`data/installation.json`, `data/installations/<id>/`).
- `bin/skate3rust.exe` for `check_maps.py` (`--exe` for another build) and `smoke_maps.sh` (`SKATE_EXE`).
- Baselines live in `.local/regression/` (gitignored) unless you pass `--baseline`; the customiser
snapshot in `.local/customiser-baseline/` (`--snapshot`).
## Usage
From the repository root:
```
# once, on a known-good build / install
py -3.13 tools/regression-checks/check_maps.py --update
py -3.13 tools/regression-checks/check_spawns.py --update
py -3.13 tools/regression-checks/check_customiser.py --save
# after a change
py -3.13 tools/regression-checks/check_maps.py
py -3.13 tools/regression-checks/check_spawns.py
py -3.13 tools/regression-checks/check_customiser.py
# smoke test (Git Bash): 25 s per map, default maps StartPark University DownTown MegaPark
bash tools/regression-checks/smoke_maps.sh 25 StartPark Industrial
```
Every check exits 1 on a difference. The smoke test opens a game window per map; don't run it while
someone is playing on the same machine.
## Example output
`check_maps.py` (made-up numbers):
```
TEST_WORLD ...
MapA 12345 ok (0.41s)
MapB 67890 CHANGED (baseline 67880, delta 10)
```
`smoke_maps.sh` (from a real run; exit 124 = stopped by the timeout, as intended):
```
StartPark exit=124 panics=0 errors=0 nonfinite=0 render_ready=1 native_lines=7
University exit=124 panics=0 errors=0 nonfinite=0 render_ready=1 native_lines=7
logs: <repo>/.local/smoke/20261003_090413
```
## Requirements
- Windows, Python 3.13 (standard library only).
- `smoke_maps.sh`: Git Bash (`timeout`, `taskkill`).
@@ -0,0 +1,82 @@
"""Snapshot / compare the character customiser outputs of the current installation.
Usage (repo root):
py -3.13 tools/regression-checks/check_customiser.py --save # snapshot the current install
py -3.13 tools/regression-checks/check_customiser.py # compare current install to snapshot
Snapshot (default .local/customiser-baseline/, --snapshot DIR): every stage receipt (*-complete.json: relative path ->
size + sha256 of each output file), every JSON file of the set, and the set id. JSON files may embed
the random set id in paths, so a JSON file whose hash differs is compared again with the set id
replaced. Exit 1 on any difference. Use after changing anything the customiser could touch
(stage scheduling, shared parsers, PIL/numpy versions, ...).
"""
import argparse
import json
import shutil
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
SNAPSHOT = REPO / '.local/customiser-baseline'
def current_set():
marker = json.loads((REPO / 'data/installation.json').read_text())
custom = REPO / 'data' / marker['directory'] / 'assets/private/customisation'
set_id = json.loads((custom / 'current.json').read_text())['set']
return set_id, custom / 'sets' / set_id
def receipts(directory):
files = {}
for path in sorted(directory.glob('*-complete.json')):
files.update(json.loads(path.read_text())['files'])
return files
def save():
set_id, directory = current_set()
if SNAPSHOT.exists():
shutil.rmtree(SNAPSHOT)
(SNAPSHOT / 'json').mkdir(parents=True)
for path in directory.glob('*-complete.json'):
shutil.copy2(path, SNAPSHOT / path.name)
for path in directory.rglob('*.json'):
target = SNAPSHOT / 'json' / path.relative_to(directory)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
(SNAPSHOT / 'SET_ID').write_text(set_id)
print(f'snapshot of set {set_id}: {len(receipts(directory))} receipted files')
return 0
def compare():
set_id, directory = current_set()
if not (SNAPSHOT / 'SET_ID').is_file():
print(f'no snapshot in {SNAPSHOT}: run once with --save on a known-good install')
return 1
old_id = (SNAPSHOT / 'SET_ID').read_text().strip()
old, new = receipts(SNAPSHOT), receipts(directory)
problems = []
for name in sorted(set(old) | set(new)):
if name not in new or name not in old:
problems.append(f'{"missing" if name not in new else "new"}: {name}')
elif old[name]['sha256'] != new[name]['sha256']:
saved = SNAPSHOT / 'json' / name
if name.endswith('.json') and saved.is_file():
if saved.read_text(encoding='utf-8').replace(old_id, set_id) == (directory / name).read_text(encoding='utf-8'):
continue # differs only by the embedded set id
problems.append(f'changed: {name}')
print(f'compared {len(old)} snapshot files with {len(new)} current files (set {old_id} -> {set_id})')
for problem in problems[:40]:
print(' ' + problem)
print('IDENTICAL' if not problems else f'{len(problems)} DIFFERENCES')
return 1 if problems else 0
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Snapshot / compare the character customiser outputs of the current installation.')
parser.add_argument('--save', action='store_true', help='snapshot the current install (else compare with the snapshot)')
parser.add_argument('--snapshot', type=Path, default=SNAPSHOT, help='snapshot folder (default: %(default)s)')
args = parser.parse_args()
SNAPSHOT = args.snapshot
raise SystemExit(save() if args.save else compare())
+92
View File
@@ -0,0 +1,92 @@
"""Validate every installed map in ONE game process and compare against baselines.
Usage (repo root, after building/staging bin\\skate3rust.exe):
py -3.13 tools/regression-checks/check_maps.py --update # first, on a known-good build: record the baseline
py -3.13 tools/regression-checks/check_maps.py # later: compare against it
Uses `skate3rust --validate-maps` (crates/skate-game/src/map_validation.rs): TEST_WORLD plus
each map. Fails if a map is not ok, has warnings (spawn support / startup / invisible floor),
or its loaded collision triangle count differs from the baseline
(default .local/regression/collision_baseline.json; --baseline PATH, --exe PATH).
Falls back to one `--check-assets` launch per map if the exe has no --validate-maps.
"""
import argparse
import json
import re
import subprocess
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
BASELINE = REPO / '.local/regression/collision_baseline.json'
def validate(exe, maps):
process = subprocess.Popen([str(exe), '--assets', str(REPO / 'assets'), '--validate-maps'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, cwd=REPO)
if not process.stdout.readline().startswith('SKATE_VALIDATOR_READY'):
process.kill()
return None
results = {}
for request in ['TEST_WORLD', *map(str, maps)]:
process.stdin.write(request + '\n'); process.stdin.flush()
line = process.stdout.readline()
if not line.startswith('SKATE_MAP_CHECK '):
raise RuntimeError(f'validator stopped at {request}')
result = json.loads(line[len('SKATE_MAP_CHECK '):])
results['TEST_WORLD' if request == 'TEST_WORLD' else Path(request).stem] = result
process.stdin.close(); process.wait()
return results
def check_assets(exe, maps):
results = {}
for path in maps:
run = subprocess.run([str(exe), '--assets', str(REPO / 'assets'), '--map', str(path), '--check-assets'],
capture_output=True, text=True, errors='replace', cwd=REPO)
match = re.search(r'SKATE_RWCM_READY triangles=(\d+)', run.stdout + run.stderr)
results[path.stem] = {'ok': run.returncode == 0 and 'SKATE_ASSETS_READY' in run.stdout + run.stderr,
'errors': [], 'warnings': [], 'collision_triangles': int(match.group(1)) if match else None}
return results
def main():
parser = argparse.ArgumentParser(description='Validate every installed map and compare collision triangle counts '
'with a baseline.')
parser.add_argument('--update', action='store_true', help='write the current values as the new baseline')
parser.add_argument('--baseline', type=Path, default=BASELINE, help='baseline JSON (default: %(default)s)')
parser.add_argument('--exe', type=Path, default=REPO / 'bin/skate3rust.exe', help='game exe (default: %(default)s)')
args = parser.parse_args()
marker = json.loads((REPO / 'data/installation.json').read_text())
maps = sorted((REPO / 'data' / marker['directory'] / 'maps').glob('*.skate'))
exe = args.exe
results = validate(exe, maps)
if results is None:
print('(no --validate-maps in this exe: using --check-assets per map, no spawn checks)')
results = check_assets(exe, maps)
current = {name: r['collision_triangles'] for name, r in results.items() if name != 'TEST_WORLD'}
failed = False
for name, r in results.items():
if not r['ok'] or r['warnings']:
failed = True
print(f"{name:20} NOT OK errors={r['errors']} warnings={r['warnings']}")
if args.update:
args.baseline.parent.mkdir(parents=True, exist_ok=True)
args.baseline.write_text(json.dumps(current, indent=2) + '\n')
print(f'baseline updated with {len(current)} maps from {marker["directory"]}')
return 1 if failed else 0
if not args.baseline.is_file():
print(f'no baseline at {args.baseline}: run once with --update on a known-good build')
return 1
baseline = json.loads(args.baseline.read_text())
for name in sorted(set(baseline) | set(current)):
want, got = baseline.get(name), current.get(name)
status = 'ok' if want == got else f'CHANGED (baseline {want}, delta {None if None in (want, got) else got - want})'
failed |= want != got
seconds = results.get(name, {}).get('seconds')
print(f'{name:20} {got} {status}' + (f' ({seconds:.2f}s)' if seconds is not None else ''))
return 1 if failed else 0
if __name__ == '__main__':
raise SystemExit(main())
+65
View File
@@ -0,0 +1,65 @@
"""Compare every installed map's baked spawn with the known-good baseline.
Usage (from the repo root):
py -3.13 tools/regression-checks/check_spawns.py --update # first, on a known-good install: record the baseline
py -3.13 tools/regression-checks/check_spawns.py # later: compare against it
The installation is read from data/installation.json; the baseline defaults to
.local/regression/spawn_baseline.json (--baseline PATH). Exit code 1 if any map differs.
"""
import argparse
import json
import struct
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
BASELINE = REPO / '.local/regression/spawn_baseline.json'
TOLERANCE = 0.01
def read_spawn(path):
# SKATE14 header: magic, u32, length-prefixed name, then 3 x f32 spawn.
with open(path, 'rb') as f:
magic = f.read(8)
if not magic.startswith(b'SKATE'):
raise ValueError(f'{path.name}: not a .skate file')
f.read(4)
(length,) = struct.unpack('<I', f.read(4))
name = f.read(length).decode()
return name, list(struct.unpack('<3f', f.read(12)))
def main():
parser = argparse.ArgumentParser(description="Compare every installed map's baked spawn with a baseline.")
parser.add_argument('--update', action='store_true', help='write the current values as the new baseline')
parser.add_argument('--baseline', type=Path, default=BASELINE, help='baseline JSON (default: %(default)s)')
args = parser.parse_args()
marker = json.loads((REPO / 'data/installation.json').read_text())
maps = REPO / 'data' / marker['directory'] / 'maps'
current = dict(read_spawn(p) for p in sorted(maps.glob('*.skate')))
if args.update:
args.baseline.parent.mkdir(parents=True, exist_ok=True)
args.baseline.write_text(json.dumps({k: [round(v, 4) for v in s] for k, s in current.items()}, indent=2) + '\n')
print(f'baseline updated with {len(current)} maps from {marker["directory"]}')
return 0
if not args.baseline.is_file():
print(f'no baseline at {args.baseline}: run once with --update on a known-good install')
return 1
baseline = json.loads(args.baseline.read_text())
failed = False
for name in sorted(set(baseline) | set(current)):
want, got = baseline.get(name), current.get(name)
if want is None or got is None:
status = 'NEW MAP' if want is None else 'MISSING'
failed = True
elif all(abs(a - b) <= TOLERANCE for a, b in zip(want, got)):
status = 'ok'
else:
status = f'CHANGED (baseline {want})'
failed = True
print(f'{name:20} {[round(v, 2) for v in got] if got else "-"} {status}')
return 1 if failed else 0
if __name__ == '__main__':
raise SystemExit(main())
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Muted crash smoke test: run the game on each map for N seconds (windowed, --mute), then grep the log.
# usage: tools/regression-checks/smoke_maps.sh [seconds] [map ...]
# default 25 s; default maps: StartPark University DownTown MegaPark
# Environment: SKATE_EXE (default bin/skate3rust.exe), SMOKE_OUT (default .local/smoke).
# Output: <SMOKE_OUT>/<stamp>/<map>.log and one summary line per map (also in summary.txt).
# Runs from Git Bash on Windows (uses taskkill to make sure the game is gone between maps).
set -u
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
EXE="${SKATE_EXE:-$ROOT/bin/skate3rust.exe}"
SECS="${1:-25}"; shift || true
MAPS=("$@"); [ ${#MAPS[@]} -eq 0 ] && MAPS=(StartPark University DownTown MegaPark)
INST=$(ls -d "$ROOT"/data/installations/*/ 2>/dev/null | head -1)
if [ -z "$INST" ] || [ ! -x "$EXE" ]; then echo "need an installation under data/installations and $EXE"; exit 2; fi
OUT="${SMOKE_OUT:-$ROOT/.local/smoke}/$(date +%Y%m%d_%H%M%S)"; mkdir -p "$OUT"
for m in "${MAPS[@]}"; do
log="$OUT/$m.log"
SKATE_REPORT_CHILD=1 SKATE3_MODS="$ROOT/mods" timeout "$SECS" "$EXE" --assets "$ROOT/assets" --map "$INST/maps/$m.skate" --mute >"$log" 2>&1
code=$?
taskkill //F //IM "$(basename "$EXE")" >/dev/null 2>&1
clean=$(sed 's/\x1b\[[0-9;]*m//g' "$log")
panics=$(grep -ciE "panicked|panic" <<<"$clean"); errors=$(grep -c " ERROR " <<<"$clean")
nonfinite=$(grep -cE "NONFINITE|Non-finite|physics_failed:true" <<<"$clean")
ready=$(grep -c "SKATE_RENDER_READY" <<<"$clean"); native=$(grep -c "Game audio: native" <<<"$clean")
echo "$m exit=$code panics=$panics errors=$errors nonfinite=$nonfinite render_ready=$ready native_lines=$native" | tee -a "$OUT/summary.txt"
done
echo "logs: $OUT"
+41
View File
@@ -0,0 +1,41 @@
# Setup equivalence checks
Prove that a faster setup path produces exactly the same bytes as the code it replaces. Both were
written to verify setup speed-ups (see `docs/hails-additions/08-setup-performance.md`).
| Script | What it proves |
|---|---|
| `compare_streams.py` | A changed district stream loader (`tools/vendor/university/.../skate3_streams.py`) returns exactly what a reference copy returns: every asset's record, source path, offset, stored size and decoded bytes, per district and stream (Pres, Sim, Tex). Also times both. |
| `compare_refpack.py` | The native RefPack DLL (`target/native/refpack.dll`, built by `scripts\Build.ps1`) decodes exactly like the pure-Python decoder: every entry of `createacharacter.big` and `marquee.big`, and with `--streams` every extracted district stream asset. |
## Inputs
- Your extracted disc: `--disc DIR` (the folder holding `data/`), default `$SKATE3_DISC` or `.local/skate3-disc`.
- Extracted districts under `--work` (default `.local/collision`), made by
`tools/collision-inspect/map_collision.py` or `scan_surfaceless.py`.
- For `compare_streams.py`, a reference copy of the loader:
`git show <commit>:tools/vendor/university/tools/vanilla_map_extraction/tools/skate3_streams.py > .local/ref.py`
## Usage
```
py -3.13 tools/setup-equivalence/compare_streams.py .local/ref.py MegaPark StartPark
py -3.13 tools/setup-equivalence/compare_refpack.py --streams
```
Both exit 1 on any difference.
## Example output
(made-up timings)
```
MegaPark Pres assets 812 old 1.90s new 0.85s IDENTICAL
MegaPark Sim assets 405 old 0.70s new 0.31s IDENTICAL
TOTAL old 2.6s new 1.2s
```
## Requirements
Python 3.13 (standard library); the repository's setup modules (`tools/asset_pipeline`,
`tools/owned_game`, the vendored map tools). `compare_refpack.py` needs the DLL from `scripts\Build.ps1`.
@@ -0,0 +1,65 @@
"""Prove the native RefPack DLL decodes exactly like the pure-Python decoder.
Usage (repo root, after scripts\\Build.ps1 built target/native/refpack.dll):
py -3.13 tools/setup-equivalence/compare_refpack.py [--streams] [--disc DIR] [--work DIR]
Decodes every entry of the character archives (createacharacter.big, marquee.big — what
the customiser reads) with the Python decoder (fast_refpack._library = None) and with the
DLL, and compares bytes. --streams also compares every district stream asset (map stage) of the
districts extracted under --work (default .local/collision, see tools/collision-inspect).
--disc is the extracted disc root (the folder holding data/; default $SKATE3_DISC or .local/skate3-disc).
Exit 1 on any difference.
"""
import argparse, os, sys, time
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO))
sys.path.insert(0, str(REPO / 'tools/vendor/university/tools/vanilla_map_extraction/tools'))
from tools.asset_pipeline import fast_refpack
from tools.owned_game.big import BigArchive
parser = argparse.ArgumentParser(description='Prove the native RefPack DLL decodes exactly like the Python decoder.')
parser.add_argument('--streams', action='store_true', help='also compare every extracted district stream asset')
parser.add_argument('--disc', type=Path, default=Path(os.environ.get('SKATE3_DISC', REPO / '.local/skate3-disc')),
help='extracted disc root (default: %(default)s)')
parser.add_argument('--work', type=Path, default=REPO / '.local/collision', help='extracted districts (default: %(default)s)')
args = parser.parse_args()
native = fast_refpack._library
if native is None:
sys.exit('target/native/refpack.dll not loaded: run scripts\\Build.ps1 first')
disc = args.disc / 'data/content'
def decode_all(read):
fast_refpack._library = None
start = time.perf_counter(); python = read(); t_python = time.perf_counter() - start
fast_refpack._library = native
start = time.perf_counter(); fast = read(); t_native = time.perf_counter() - start
return python, fast, t_python, t_native
failed = False
for name in ('createacharacter.big', 'marquee.big'):
archive = BigArchive(disc / name)
python, fast, t_python, t_native = decode_all(lambda: [archive.read(e) for e in archive.entries])
compressed = sum(1 for e in archive.entries if e.compression)
same = python == fast
failed |= not same
print(f'{name:22} entries {len(archive.entries):5} (compressed {compressed:5}) python {t_python:6.1f}s native {t_native:5.1f}s {"IDENTICAL" if same else "DIFFERENT"}', flush=True)
if args.streams:
import skate3_streams
for district in sorted(args.work.iterdir()):
stream = district / 'raw/data/content/world/stream' / f'DIST_{district.name}'
for kind in ('Pres', 'Sim', 'Tex'):
if not (stream / f'DIST_{district.name}_{kind}.xst').is_file():
continue
python, fast, t_python, t_native = decode_all(
lambda: [a.data for a in skate3_streams.load_district_stream(stream, kind, f'DIST_{district.name}')])
same = python == fast
failed |= not same
print(f'{district.name:20} {kind:4} assets {len(python):5} python {t_python:6.1f}s native {t_native:5.1f}s {"IDENTICAL" if same else "DIFFERENT"}', flush=True)
print('ALL IDENTICAL' if not failed else 'DIFFERENCES FOUND')
sys.exit(1 if failed else 0)
@@ -0,0 +1,47 @@
"""Prove a changed stream loader returns exactly what a reference copy returns.
Usage (repo root):
py -3.13 tools/setup-equivalence/compare_streams.py <reference skate3_streams.py> [District ...] [--work DIR]
Get a reference with: git show <commit>:tools/vendor/university/tools/vanilla_map_extraction/tools/skate3_streams.py > .local/ref.py
Needs <work>/<District>/raw (default work .local/collision) from tools/collision-inspect/map_collision.py
or scan_surfaceless.py.
For every district and stream (Pres, Sim, Tex): load with the reference module and the current one, and
compare every asset's record, source path, source offset, stored size and decoded bytes. Also times both.
Exit 1 on the first difference.
"""
import argparse, importlib.util, sys, time
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
TOOLS = REPO / 'tools/vendor/university/tools/vanilla_map_extraction/tools'
sys.path.insert(0, str(TOOLS))
import skate3_streams as new
parser = argparse.ArgumentParser(description='Prove a changed stream loader returns exactly what a reference copy returns.')
parser.add_argument('reference', type=Path, help='reference copy of skate3_streams.py')
parser.add_argument('districts', nargs='*', help='district names without DIST_ (default: all ten)')
parser.add_argument('--work', type=Path, default=REPO / '.local/collision', help='extracted districts (default: %(default)s)')
args = parser.parse_args()
spec = importlib.util.spec_from_file_location('skate3_streams_original', args.reference)
old = importlib.util.module_from_spec(spec); sys.modules[spec.name] = old; spec.loader.exec_module(old)
import dataclasses
names = args.districts or ['MegaPark', 'StartPark', 'BlackBoxPark', 'IndustrialSkatePark', 'DownTownSkatePark',
'MaloofMoneyCup', 'SkateSchool', 'Industrial', 'University', 'DownTown']
total_old = total_new = 0.0
for name in names:
stream = args.work / name / 'raw/data/content/world/stream' / f'DIST_{name}'
for kind in ('Pres', 'Sim', 'Tex'):
if not (stream / f'DIST_{name}_{kind}.xst').is_file():
continue
t = time.perf_counter(); a = old.load_district_stream(stream, kind, f'DIST_{name}'); t_old = time.perf_counter() - t
t = time.perf_counter(); b = new.load_district_stream(stream, kind, f'DIST_{name}'); t_new = time.perf_counter() - t
total_old += t_old; total_new += t_new
same = len(a) == len(b) and all(
(dataclasses.astuple(x.record) == dataclasses.astuple(y.record) and x.source_path == y.source_path and x.source_offset == y.source_offset
and x.stored_size == y.stored_size and x.data == y.data) for x, y in zip(a, b))
print(f'{name:20} {kind:4} assets {len(a):5} old {t_old:7.2f}s new {t_new:7.2f}s {"IDENTICAL" if same else "DIFFERENT"}', flush=True)
if not same:
sys.exit(1)
print(f'TOTAL old {total_old:.1f}s new {total_new:.1f}s')
+42
View File
@@ -0,0 +1,42 @@
# Vault inspection
Look up values in the game's attribute database ("vault"): the skater collections setup converts to
`assets/private/stock/skater-collections.json`, and the class layouts in the disc's schema.
Classes, collections and fields are 64-bit name hashes (`Hash_XXXXXXXXXXXXXXXX`).
| Script | What it does |
|---|---|
| `find_field.py` | Finds field hashes anywhere in the converted database: prints class, collection, field, type and the decoded value. |
| `vault_fields.py` | Prints one class's collections (or one collection), with parent, and every field (or the chosen ones), floats / ints decoded and arrays listed. |
| `vault_layout.py` | Prints a class's field layout from the schema: field hash, type, offset, count, maximum, flags, alignment. Fixed-layout fields (flags bit 2) first, by offset. |
## Inputs
- `--vault`: the converted database, default `assets/private/stock/skater-collections.json` (written by
`tools/setup.py`).
- `--schema` (for `vault_layout.py`): the schema path without extension; the folder must hold
`skaterschema.vlt` and `skaterschema.bin`. Extract them from your disc's `data/big/db.big`:
`py -3.13 tools/world-stream-inspect/big_list.py <disc>/data/big/db.big skaterschema --out .local/db`
## Usage
```
py -3.13 tools/vault-inspect/find_field.py 0123456789ABCDEF
py -3.13 tools/vault-inspect/vault_fields.py Hash_0123456789ABCDEF default
py -3.13 tools/vault-inspect/vault_fields.py 0123456789ABCDEF default FEDCBA9876543210
py -3.13 tools/vault-inspect/vault_layout.py 0123456789ABCDEF --schema .local/db/data/db/skaterschema
```
## Example output
(made-up hashes and values)
```
== Hash_0123456789ABCDEF default parent=
Hash_1111111111111111 ('EA::Reflection::Float', 0.5)
Hash_2222222222222222 ('EA::Reflection::Int32', 3)
```
## Requirements
Python 3.13 (standard library); `vault_layout.py` uses `tools/asset_pipeline/vlt.py`.
+35
View File
@@ -0,0 +1,35 @@
"""Find vault field hashes in the install's converted database (skater-collections.json): prints class,
collection, field, type and the decoded value (big-endian float or int for 4-byte values) of every hit.
usage: py -3.13 tools/vault-inspect/find_field.py HASH [HASH ...] [--vault JSON]
HASH: 16 hex digits, with or without Hash_.
--vault defaults to assets/private/stock/skater-collections.json (written by setup).
"""
import argparse
import json
import struct
from pathlib import Path
DEFAULT = Path(__file__).resolve().parents[2] / 'assets/private/stock/skater-collections.json'
def main():
parser = argparse.ArgumentParser(description='Find vault field hashes in skater-collections.json.')
parser.add_argument('hashes', nargs='+', help='16 hex digits, with or without Hash_')
parser.add_argument('--vault', type=Path, default=DEFAULT, help='converted database (default: %(default)s)')
args = parser.parse_args()
want = {('Hash_' + h.replace('Hash_', '').upper().zfill(16)) for h in args.hashes}
for c in json.loads(args.vault.read_text())['collections']:
for w in want & set(c['fields']):
v = c['fields'][w]
h = ''.join(v.get('data', '').split())
val = h
if 'Float' in v['type'] and len(h) == 8:
val = struct.unpack('>f', bytes.fromhex(h))[0]
elif len(h) == 8:
val = struct.unpack('>i', bytes.fromhex(h))[0]
print(c['class'], c['key'], w, v['type'], val)
if __name__ == '__main__':
main()
+65
View File
@@ -0,0 +1,65 @@
"""Print vault fields from the install's converted database (skater-collections.json) by class /
collection / field keys, with the collection's parent. Floats and ints are decoded; arrays list their items.
usage: py -3.13 tools/vault-inspect/vault_fields.py CLASS [COLLECTION] [FIELD ...] [--vault JSON]
CLASS / FIELD: 16 hex digits (with or without Hash_); COLLECTION: a name ('default') or hex.
--vault defaults to assets/private/stock/skater-collections.json (written by setup).
"""
import argparse
import json
import struct
from pathlib import Path
DEFAULT = Path(__file__).resolve().parents[2] / 'assets/private/stock/skater-collections.json'
def norm(k):
k = k.strip()
if k.lower() == 'default':
return 'default'
return k if k.startswith('Hash_') else 'Hash_' + k.upper().replace('0X', '').zfill(16)
def decode(v):
t = v.get('type', '')
def one(h):
b = bytes.fromhex(h)
if 'Float' in t and len(b) == 4:
return round(struct.unpack('>f', b)[0], 6)
if len(b) == 4:
return struct.unpack('>i', b)[0]
if len(b) == 1:
return b[0]
return h
if 'array' in v:
return t, [one(x) for x in v['array']['items']]
return t, one(v['data'])
def main():
parser = argparse.ArgumentParser(description='Print vault fields by class / collection / field.')
parser.add_argument('cls', metavar='CLASS')
parser.add_argument('collection', nargs='?', metavar='COLLECTION')
parser.add_argument('fields', nargs='*', metavar='FIELD')
parser.add_argument('--vault', type=Path, default=DEFAULT, help='converted database (default: %(default)s)')
args = parser.parse_args()
d = json.loads(args.vault.read_text())
items = d['collections'] if 'collections' in d else d
items = list(items.values()) if isinstance(items, dict) else items
cls = norm(args.cls)
col = norm(args.collection) if args.collection else None
fields = [norm(f) for f in args.fields]
by = {c['key']: c for c in items if c['class'] == cls}
for key, c in by.items():
if col and key != col:
continue
print(f'== {cls} {key} parent={c.get("parent")}')
for f, v in sorted(c['fields'].items()):
if fields and f not in fields:
continue
print(f' {f} {decode(v)}')
if __name__ == '__main__':
main()
+55
View File
@@ -0,0 +1,55 @@
"""Print a vault class's field layout (field hash, type, offset, count, flags) from the disc schema.
usage: py -3.13 tools/vault-inspect/vault_layout.py CLASS --schema STEM [--name NAME ...]
CLASS: 16 hex digits (with or without Hash_).
--schema: path of skaterschema without extension (the folder must hold skaterschema.vlt and .bin;
extract them from data/big/db.big with tools/world-stream-inspect/big_list.py db.big skaterschema --out DIR).
--name: type names to label (their 64-bit hash is shown by name when it matches); repeatable.
flags bit 2 = a fixed-layout field: the offset is where the record keeps it.
"""
import argparse
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from tools.asset_pipeline.vlt import vault, unpack, hash64 # noqa: E402
CLASS_EXPORT = 0x2A7895AC4A876152
# Generic reflection type names, so the common field types print by name.
TYPE_NAMES = ('EA::Reflection::Int32', 'EA::Reflection::Bool', 'EA::Reflection::Float', 'Attrib::RefSpec')
def layout(cls: int, stem: Path):
sv, sb, se = vault(stem)
for _, kind, size, at in se:
if kind != CLASS_EXPORT:
continue
key, reserve, count, defs, static_size, static, lay = unpack('QIIIIII', sv, at)
if key != cls:
continue
rows = []
for i in range(count):
fkey, typ, offset, n, maximum, flags, alignment = unpack('QQHHHBB', sb, defs + i * 24)
rows.append((fkey, typ, offset, n, maximum, flags, alignment))
return rows
return None
def main():
parser = argparse.ArgumentParser(description="Print a vault class's field layout from the disc schema.")
parser.add_argument('cls', metavar='CLASS', help='16 hex digits, with or without Hash_')
parser.add_argument('--schema', type=Path, required=True, help='skaterschema path without extension')
parser.add_argument('--name', action='append', default=[], help='extra type name to label; repeatable')
args = parser.parse_args()
cls = int(args.cls.replace('Hash_', ''), 16)
names = {hash64(n): n for n in (*TYPE_NAMES, *args.name)}
rows = layout(cls, args.schema)
if rows is None:
sys.exit('class not found')
for fkey, typ, offset, n, maximum, flags, alignment in sorted(rows, key=lambda r: (not r[5] & 2, r[2])):
print(f'Hash_{fkey:016X} {names.get(typ, hex(typ)):36} off={offset:4} n={n:3} max={maximum} flags={flags} align={alignment}')
if __name__ == '__main__':
main()
+42
View File
@@ -0,0 +1,42 @@
# World stream inspection
Read the world districts' simulation streams (`cSim_*.xsf`, RW4 arenas) straight from your own
disc's `.big` archives, without extracting anything to disk.
| Script | What it does |
|---|---|
| `big_list.py` | Lists the entries of any `.big` archive (path and unpacked size), filtered by a regex, or extracts the matching entries with `--out`. |
| `sim_types.py` | Per stream file of a district: the arena dictionary's entry types and counts. `--find HEX64` searches every arena for a 64-bit value (both word orders) and names the entry that holds it; `--dump TYPE` hex-dumps the entries of one type. |
| `volumes_dump.py` | Dumps every named volume (arena type `0x00EB0019`, trigger / zone / teleport boxes): name, instance id, link id, bounds, base corners, plus each arena's collision meshes and other entry types. Writes JSON too. |
| `arenas.py` | Shared helper: walks the arenas of a district (or any `.big` with `*_Sim.xst` + `cSim_*.xsf`, such as `missions.big`) and parses the arena dictionary. |
The volume layout is documented at the top of `volumes_dump.py`.
## Inputs
- Your extracted disc: `--disc DIR` (the folder holding `data/`), default `$SKATE3_DISC` or `.local/skate3-disc`.
- District ids with the prefix: `DIST_SkateSchool`, `DIST_MegaPark`, … (`volumes_dump.py` defaults to all ten).
## Usage
```
py -3.13 tools/world-stream-inspect/big_list.py .local/skate3-disc/data/big/db.big skaterschema
py -3.13 tools/world-stream-inspect/big_list.py .local/skate3-disc/data/audio/audiofiles.big "\.bnk$" --out .local/bnk
py -3.13 tools/world-stream-inspect/sim_types.py DIST_SkateSchool
py -3.13 tools/world-stream-inspect/sim_types.py DIST_SkateSchool --dump 00eb0019
py -3.13 tools/world-stream-inspect/volumes_dump.py DIST_SkateSchool > volumes.txt
```
## Example output
`sim_types.py` (made-up ids and counts):
```
cSim_0_0_high.xsf 0123456789abcdef 00080001:1 00080006:3 00eb000a:1
TOTAL 00080001:40 00080006:112 00eb000a:40 00eb0019:2
```
## Requirements
Python 3.13 (standard library); the repository's `tools/owned_game` and the vendored map extraction
tools (`tools/vendor/university/.../skate3_streams.py`, `retail_collision_mesh.py`).
+89
View File
@@ -0,0 +1,89 @@
"""Shared helpers: walk the RW4 arenas in a district's simulation streams (cSim_*.xsf) straight from a
world<District>.big (or any .big holding *_Sim.xst + cSim_*.xsf, such as missions.big), nothing extracted.
import arenas
for stream_file, asset_id, arena in arenas.district_arenas(disc, 'DIST_SkateSchool'):
for offset, _, size, _, _, type_id in arenas.entries(arena):
...
Uses the vendored stream reader (tools/vendor/university/.../skate3_streams.py) for the asset table and
the section decoder.
"""
import os
import struct
import sys
import tempfile
from collections import defaultdict
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO))
sys.path.insert(0, str(REPO / 'tools/vendor/university/tools/vanilla_map_extraction/tools'))
from tools.owned_game.big import BigArchive # noqa: E402
from skate3_streams import read_atoc, _decode_section # noqa: E402
ARENA_PROCESSOR = 0xAB329A6A
DISTRICTS = ['DIST_SkateSchool', 'DIST_MegaPark', 'DIST_IndustrialSkatePark', 'DIST_StartPark', 'DIST_BlackBoxPark',
'DIST_DownTownSkatePark', 'DIST_MaloofMoneyCup', 'DIST_University', 'DIST_Industrial', 'DIST_DownTown']
def default_disc() -> Path:
"""Extracted disc root (the folder holding data/): $SKATE3_DISC, else .local/skate3-disc."""
return Path(os.environ.get('SKATE3_DISC', REPO / '.local/skate3-disc'))
def entries(arena: bytes):
"""The arena's dictionary: one (offset, ?, size, align?, ?, type id) tuple of BE u32 per entry."""
n = struct.unpack_from('>I', arena, 0x20)[0]
at = struct.unpack_from('>I', arena, 0x30)[0]
return [struct.unpack_from('>6I', arena, at + 24 * i) for i in range(n)]
def big_arenas(bigpath, only_dirs=None):
"""Yield (dir name, stream file, asset id, arena bytes) for every arena asset of every cSim_*.xsf in a
.big, using the *_Sim.xst in the same directory."""
big = BigArchive(Path(bigpath))
by = defaultdict(list)
for e in big.entries:
by[Path(e.path).parent.as_posix()].append(e)
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir) / '_tmp.xst'
for dname, ents in sorted(by.items()):
if only_dirs and Path(dname).name not in only_dirs:
continue
xst = [e for e in ents if e.path.endswith('_Sim.xst')]
if not xst:
continue
tmp.write_bytes(big.read(xst[0]))
recs = {r.asset_id: r for r in read_atoc(tmp)}
seen = set()
for e in ents:
if not (Path(e.path).name.startswith('cSim_') and e.path.endswith('.xsf')):
continue
s = big.read(e)
cur = struct.unpack_from('>I', s, 16)[0]
while cur + 208 <= len(s):
aid = struct.unpack_from('>Q', s, cur)[0]
if aid == 0:
break
st, hs, stride = struct.unpack_from('>III', s, cur + 8)
r = recs.get(aid)
if r and r.processor_id == ARENA_PROCESSOR and aid not in seen:
seen.add(aid)
if st == r.total_size and hs >= 128:
data = s[cur + hs:cur + hs + st]
else:
v = struct.unpack_from('>11I', s, cur + 128)
c, q = _decode_section(s, cur + 208, v[0], v[1], v[2])
g, q = _decode_section(s, q, v[8], v[9], v[10])
data = c + g
yield Path(dname).name, Path(e.path).name, aid, data
if stride == 0:
break
cur += stride
def district_arenas(disc: Path, district: str):
"""Yield (stream file, asset id, arena bytes) for one world district."""
for _, fname, aid, data in big_arenas(Path(disc) / 'data/content' / f'world{district}.big'):
yield fname, aid, data
+37
View File
@@ -0,0 +1,37 @@
"""List the entries of a .big archive, or extract the ones matching a regex.
usage: py -3.13 tools/world-stream-inspect/big_list.py <big> [regex] [--out DIR]
Without --out prints `<path> <unpacked size>` per entry; with --out writes the matching entries
(decompressed) under DIR, keeping their archive paths.
"""
import argparse
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from tools.owned_game.big import BigArchive # noqa: E402
def main():
parser = argparse.ArgumentParser(description='List or extract entries of a .big archive.')
parser.add_argument('big', type=Path)
parser.add_argument('regex', nargs='?', help='only entries whose path matches (case-insensitive)')
parser.add_argument('--out', type=Path, help='extract the matching entries here')
args = parser.parse_args()
a = BigArchive(args.big)
pat = re.compile(args.regex, re.I) if args.regex else None
for e in a.entries:
if pat is not None and not pat.search(e.path):
continue
if args.out:
p = args.out / BigArchive.safe_relative(e.path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(a.read(e))
print('wrote', p)
else:
print(e.path, e.unpacked_size)
if __name__ == '__main__':
main()
+52
View File
@@ -0,0 +1,52 @@
"""Survey RW4 arena dictionary types in a district's simulation streams (cSim_*.xsf).
usage: py -3.13 tools/world-stream-inspect/sim_types.py DIST_SkateSchool [--find HEX64 ...] [--dump TYPEHEX] [--disc DIR]
Reads straight from <disc>/data/content/world<District>.big (nothing extracted to disk).
Dictionary entry = 6 BE u32: (offset, ?, size, align?, ?, type). Prints type counts per stream file;
--find searches every arena for a 64-bit value (both word orders) and names the entry holding it;
--dump hex-dumps the first 256 bytes of every entry of one type.
"""
import argparse
import sys
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import arenas # noqa: E402
def main():
parser = argparse.ArgumentParser(description='Survey RW4 arena dictionary types in a district.')
parser.add_argument('district', help='e.g. DIST_SkateSchool')
parser.add_argument('--find', action='append', default=[], metavar='HEX64', help='64-bit value to search for')
parser.add_argument('--dump', metavar='TYPEHEX', help='hex-dump the entries of this type id')
parser.add_argument('--disc', type=Path, default=arenas.default_disc(), help='extracted disc root (default: %(default)s)')
args = parser.parse_args()
finds = [int(v, 16) for v in args.find]
dump = int(args.dump, 16) if args.dump else None
total = Counter()
for fname, aid, d in arenas.district_arenas(args.disc, args.district):
ents = arenas.entries(d)
c = Counter(e[5] for e in ents)
total.update(c)
if not finds and dump is None:
print(fname, '%016x' % aid, ' '.join('%08x:%d' % kv for kv in sorted(c.items())))
for v in finds:
for pat, tag in ((v.to_bytes(8, 'big'), 'BE'), (((v & 0xFFFFFFFF) << 32 | v >> 32).to_bytes(8, 'big'), 'LOWFIRST')):
i = d.find(pat)
while i >= 0:
owner = [k for k, e in enumerate(ents) if e[0] <= i < e[0] + e[2]]
print('FIND %016x %s %s @%x in entry %s type %s' % (v, tag, fname, i, owner, [hex(ents[k][5]) for k in owner]))
i = d.find(pat, i + 1)
if dump is not None:
for k, e in enumerate(ents):
if e[5] == dump:
print(fname, k, 'off %x size %x' % (e[0], e[2]))
blob = d[e[0]:e[0] + min(e[2], 256)]
for j in range(0, len(blob), 32):
print(' ', blob[j:j + 32].hex(' ', 4))
print('TOTAL', ' '.join('%08x:%d' % kv for kv in sorted(total.items())))
if __name__ == '__main__':
main()
@@ -0,0 +1,86 @@
"""Dump every named volume (arena type 0x00EB0019, 'volume set') in the world districts: trigger boxes,
teleport and zone volumes, with their bounds, base corners and link ids.
usage: py -3.13 tools/world-stream-inspect/volumes_dump.py [DIST_x ...] [--disc DIR] [--json OUT] > volumes.txt
Default: all ten districts; JSON to .local/world-stream-inspect/volumes.json.
Volume-set layout (big-endian):
header: +0 magic, +4 count, +8 count, +12 items offset, +16 strings offset
item (240 bytes): +0 mat4, +64 aabb min (vec4), +80 aabb max, +96 up axis (vec4), +112 4 base corners (vec4 x4),
+176 u64 link GUID (an import of type 0x00EB006B), +184 u64 instance id, +192 16 bytes 0xFF, +220 import handle,
+224 name offset (from record start).
Each arena line also lists its clustered collision meshes (triangles, surfaceless or not) and other entry types.
"""
import argparse
import json
import struct
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import arenas # noqa: E402
from retail_collision_mesh import decode_clustered_mesh # noqa: E402 (vendored, on the path via arenas)
VOLUME_SET = 0x00EB0019
CLUSTERED_MESH = 0x00080006
def cstr(b, o):
e = b.index(b'\0', o)
return b[o:e].decode('latin1')
def items(b):
magic, n, n2, ioff, soff = struct.unpack_from('>5I', b, 0)
for i in range(n):
o = ioff + 240 * i
mn = struct.unpack_from('>3f', b, o + 64)
mx = struct.unpack_from('>3f', b, o + 80)
up = struct.unpack_from('>3f', b, o + 96)
corners = [struct.unpack_from('>3f', b, o + 112 + 16 * k) for k in range(4)]
guid, iid = struct.unpack_from('>QQ', b, o + 176)
imp, noff = struct.unpack_from('>II', b, o + 220)
mat = struct.unpack_from('>16f', b, o)
yield dict(name=cstr(b, noff), id='%016x' % iid, link='%016x' % guid, import_handle=imp,
min=[round(v, 3) for v in mn], max=[round(v, 3) for v in mx], up=[round(v, 3) for v in up],
corners=[[round(v, 3) for v in c] for c in corners],
translation=[round(v, 3) for v in mat[12:15]],
identity_rot=mat[0] == 1.0 and mat[5] == 1.0 and mat[10] == 1.0)
def main():
parser = argparse.ArgumentParser(description='Dump every named volume in the world districts.')
parser.add_argument('districts', nargs='*', help='e.g. DIST_SkateSchool (default: all ten)')
parser.add_argument('--disc', type=Path, default=arenas.default_disc(), help='extracted disc root (default: %(default)s)')
parser.add_argument('--json', type=Path, default=arenas.REPO / '.local/world-stream-inspect/volumes.json',
help='JSON output (default: %(default)s)')
args = parser.parse_args()
out = {}
for dist in args.districts or arenas.DISTRICTS:
res = []
for fname, aid, d in arenas.district_arenas(args.disc, dist):
ents = arenas.entries(d)
vs = [e for e in ents if e[5] == VOLUME_SET]
if not vs:
continue
meshes = []
for e in ents:
if e[5] == CLUSTERED_MESH:
m = decode_clustered_mesh(d[e[0]:e[0] + e[2]])
sl = all(not (t.unit_flags & 0x80) for t in m.triangles)
meshes.append(dict(tris=len(m.triangles), surfaceless=sl,
min=[round(v, 2) for v in m.bounds_min], max=[round(v, 2) for v in m.bounds_max]))
other = sorted({'%08x' % e[5] for e in ents} - {'00eb0019', '00080006', '00080001', '00eb000a', '00eb0008', '00eb000b'})
for e in vs:
for it in items(d[e[0]:e[0] + e[2]]):
it.update(stream=fname, arena='%016x' % aid)
res.append(it)
short = it['name'].split('_0x')[0]
print(dist, fname, it['id'], short, it['min'], it['max'], 'link', it['link'])
print(' arena %016x meshes %s other types %s' % (aid, [(m['tris'], m['surfaceless']) for m in meshes], other))
out[dist] = res
args.json.parent.mkdir(parents=True, exist_ok=True)
args.json.write_text(json.dumps(out, indent=1))
if __name__ == '__main__':
main()