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