mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Review fixes: - The audio host takes each physics step exactly once (one-step pulses latched, nothing while paused or silenced, at most 4 steps per frame), so pushes and other one-frame events are never dropped or doubled. e2e at 60 fps and above is byte-identical; 30 / 45 fps change only by the latch and the step pair. - Removed the emitter PROFILES fallback, the stray trace dump and the audition script; data-gated tests are #[ignore = "needs the private install data"] and fail loudly when run without it; per-post log lines behind SKATE_AUDIO_TRACE; checked bank parsing, finite output, camera-cut Doppler reset, fewer render allocations. - The 15 verdict-settled A/B switches are removed; their "on" behaviour is the only one (byte-identical). World / NPC audio, hookable by engine systems and mods: - Map-change fix: world and NPC hosts reset when the map's banks are unloaded, so surviving objects sound again. - Engine surface (skate-game::world_audio): TrafficAudio / PedAudio / NpcSkaterAudio components, horn / alarm / speech messages, read-back of who is audible; retail limits (4 cars within 40 m, 15 peds within 50 m with footsteps for the nearest 3, one NPC skater within 30 m), measured in the recompilation. - Mod surface (Lua API 2, `sdk.world_audio.*`); test mod `mods/world-audio-test/` (cars, peds, a ghost skater). - Per-skater audio state builder (local player output identical); remote players take the NPC skater slot; an opt-in non-retail "more audible" setting. - Docs: doc 15 "Hooking up world audio", doc 11 sections, PR plan.
4606 lines
356 KiB
Markdown
4606 lines
356 KiB
Markdown
# 11 — Game audio (ambience, rolling, trick cues, footsteps, water)
|
||
|
||
**Branch:** `gameplay/audio` (from `gameplay/water`; partly committed 2026-10-02; the world-audio work since then is uncommitted until the user's batch commit). Upstream form: `audio/retail-audio`, draft PR #32, marked Work in Progress. **Status:** implemented and tuned
|
||
by ear over ~15 play sessions with the user (2026-10-01). Most cues are confirmed in play; the rest are marked
|
||
**not yet confirmed** below. All sample choices are project choices, not retail event data.
|
||
|
||
## Problem
|
||
|
||
The engine made no sound of its own. Only Lua mods could play audio
|
||
(`crates/skate-game/src/modding/audio.rs`, PCM WAV through Bevy's `bevy_audio`).
|
||
Skate 3's sounds are on the owned disc but nothing extracted or decoded them.
|
||
|
||
## What is on the disc
|
||
|
||
Everything audible is under `data/audio/`. All archives except `music/overlays.big` are EB BIG v3
|
||
archives readable with `tools/owned_game/big.py`.
|
||
|
||
| Archive | Contents |
|
||
|---|---|
|
||
| `ambience.big` + `ambienceresident.big` | 24 per-area ambience beds (`.sns` bodies, `.snr` headers), e.g. `04_dt_main`, `09_univ_campus`; 5-channel 48 kHz, ~2–2.5 min loops |
|
||
| `audiofiles.big` | 376 ABKC `.abk` banks, 20 SPLC `.bnk` banks, 23 per-map `.ems` emitter files, 9 MOIR `.csi` AEMS event scripts |
|
||
| `grains.big` | 14 rolling "grains", 7 surfaces × soft/hard wheels (`asphalt_smooth`, `asphalt_rough`, `concrete_smooth`, `concrete_rough`, `concrete_aggregate`, `wood_ramp`, `metal_smooth` hard only) + `x_jet_rolling` |
|
||
| `wheels.big` | `Whls_spins_Jump_1`, `Whls_spins_Man_1` (+ `.sek` seek tables) |
|
||
| `post.big`, `nis.big`, `english/…`, `music/…` | stingers, speech, cutscenes, licensed music — not used |
|
||
|
||
### Formats (worked out from the archives; `tools/asset_pipeline/audio_formats.py`)
|
||
|
||
- **Codec:** every sample is an EA SNR (EAAC) stream with codec 3 = **EA-XMA** (Xbox 360 XMA2),
|
||
mostly mono 48 kHz (also 44.1, 36, 24 and 22.05 kHz).
|
||
- **SNR header:** `u32 version(4)|codec(4)|channels-1(6)|rate(18)`, `u32 type(2)|loop(1)|samples(29)`,
|
||
optional `u32 loop start`. RAM streams are followed by blocks of `u8 flag (0x80 = last)`, `u24 size`,
|
||
`u32 samples`.
|
||
- **SPLC `.bnk`:** stream count at 0x18, 36-byte parameter records, then that many SNR streams back to
|
||
back at **unaligned** offsets. The scanner finds exactly the declared count in all 20 banks (2,266 streams).
|
||
- **ABKC `.abk`:** embedded SNR streams; the scanner's count matches vgmstream's subsong count for all
|
||
376 banks (5,168 streams).
|
||
- **`.grain`:** `u32` offset of the SNR stream, `f32` duration (matches samples/rate exactly), a
|
||
`.sek`-style seek table, then one SNR stream. Each recording is a **slow-to-fast rolling sweep**: over
|
||
17–22 s it gets ~10 dB louder and brighter (e.g. concrete_smooth_hard −24 → −14 dBFS, centroid
|
||
0.9 → 2.0 kHz), so looping the whole file sounds wrong.
|
||
- **5-channel ambience** order is L, C, R, Ls, Rs (from inter-channel correlation).
|
||
- **`.csi` (MOIR)** event scripts name events, classes and variables (`play_grind_start`, `Class_Flips`,
|
||
`Ollie_Rattles`, `surface_type`, `playercharacter_footstep`, `body_imp_Torso`, …) in 16-byte records
|
||
(name offset, 16-bit id, default value). The banks hold no per-sample names; linking events to samples
|
||
would mean re-implementing EA's AEMS runtime, which this change does not do.
|
||
- Retail mixes at runtime: many raw samples peak at 0 dBFS with RMS up to −6 dBFS, so playback levels
|
||
must be low; footstep recordings are the opposite (peak ≈ −11 dBFS, RMS ≈ −28 dBFS).
|
||
|
||
## Change — current design
|
||
|
||
### Setup: new `audio` asset group
|
||
|
||
- `tools/asset_pipeline/audio_formats.py` — SNR scanner/validator (block-chain check), SPLC and grain
|
||
readers, standalone `.snr` cut-out, gain-free 5→2 downmix (weights per output sum to 1), loopable
|
||
band cutting for grains.
|
||
- `tools/asset_pipeline/audio_export.py` — decodes only what the engine plays to PCM16 WAV in
|
||
`assets/private/audio/` with `audio_manifest.json` (version 2): 22 ambience beds (stereo), 14 grains
|
||
cut into **6 loopable speed bands** each (84 files, 0.08 s loop cross-fade), 2 wheel spins and 23 sample
|
||
banks (2,718 samples). No normalisation or gain. ~40 s, ~615 MB.
|
||
- **Decoder: vgmstream r2117** (`vgmstream-win64.zip`, SHA-256 `6c4a8a38…dc6c`, matching GitHub's
|
||
published digest), downloaded and verified by the existing `install.dependency()` into
|
||
`data/tools/vgmstream-cli/`. It bundles FFmpeg's XMA2 decoder (LGPL DLLs). vgmstream reads SNR/SNS and
|
||
ABKC itself but not SPLC or grain files, so every stream is cut out to a standalone `.snr` first and
|
||
decoded in batches of 256 (one vgmstream run stops accepting input files between 600 and 1,100 arguments).
|
||
- Registered as group `audio` (`versions.py` `GROUPS`/`SOURCES`, `group_receipts.py` `ROOTS`,
|
||
`asset_exports.audio`, `install.py`). **Optional content:** a failed download or decode records
|
||
`assets/private/audio-availability.json` and setup still succeeds; the game then runs silent. Existing
|
||
installs refresh only this group.
|
||
- `.gitignore`: retail audio extensions and `*.wav` (except the Skyline Drive mod's own WAVs).
|
||
|
||
### Engine: `crates/skate-game/src/game_audio/`
|
||
|
||
| File | Role |
|
||
|---|---|
|
||
| `mod.rs` | `AudioSettings` (master **75 % by default** — 100 % is unity gain; 25 % (−12 dB) was too quiet to judge retail parity in play, user 2026-10-02 —, ambience 100 %, effects 100 %, 5 % steps) saved to `settings/audio.json`; menu rows in GRAPHICS; `--mute`; `GlobalVolume` follows the master so mod audio obeys it; the single `SpatialListener` (moved out of `modding/audio.rs`) follows the gameplay camera; preloads every cue sample, grain band, wheel spin and water piece at startup (~207 clips, ~35 ms) so riding never reads the disk. |
|
||
| `library.rs` | Loads the manifest; reads WAVs into `Assets<AudioSource>`, measuring each clip's peak; rejects paths outside the audio folder; warns once per missing file. |
|
||
| `voices.rs` | Voice pool and **loudness rules** (below). Everything pauses while the menu is open or a replay runs. |
|
||
| `cues.rs` | The sample table, per-cue minimum gaps, metal-surface test, surface → grain table. |
|
||
| `skate_events.rs` | `observe` (FixedUpdate, after each physics tick) turns state changes into cues; `play` (Update) plays them and drives the loops (rolling, grind, powerslide, foot drag, wheel spin). |
|
||
| `ambience.rs` | One bed per map, level 0.6, 2 s cross-fade on map change. |
|
||
| `emitters.rs` | Retail world emitters from the map's `.ems` file (shape, falloff, 5-emitter pool) with measured bank programs (water banks). |
|
||
|
||
Outside the module (observation only; physics never reads any of it):
|
||
|
||
- `physics/animation_input.rs` — `AudioEvents` latch. `push_contact` (flags bit 27), `brake_contact`
|
||
foot-down/up (bits 28 + 30 / 23) and `AudibleFootStepStrength` are cleared within the tick
|
||
(`finish_output_publication`, `ScalarAttributeInputs::reset`), so they are latched while the animation
|
||
attributes are processed and taken by `observe`.
|
||
- `physics.rs` `foot_clearance` — each animated foot's height above its ground line test (via
|
||
`biped_ground::services::feet_input`).
|
||
- `skate-core` `WheelLineState.audio_surfaces` — audio surface (`surface_tag & 0x7F`) per wheel, next to the
|
||
existing `physics_surfaces`. Additive output only.
|
||
|
||
### Loudness rules (`voices.rs`)
|
||
|
||
- A requested volume may raise a quiet clip only until the clip's own peak reaches full scale, and never
|
||
more than ×4; category and master volumes (both ≤ 1) then scale it down. No clip plays hotter than
|
||
full scale × master.
|
||
- One-shots start at their final level on the first sample; loops and sounds that ask for a fade start
|
||
silent and fade in (≥ 10 ms). (Until 2026-10-01 every sound faded in and its gain was raised once per
|
||
frame from 0, which ate the attack of 25–35 ms impacts and knocks — pops and landings lost their
|
||
layering.) Retail layer offsets are kept with `Record::delay` (frame-granular). At most 32 voices, 3 of one sound, same sound again only
|
||
after 40 ms; each cue also has a minimum gap (`cues::min_gap`: step 0.2 s, run step 0.15, push 0.3,
|
||
bail 0.5, splash 1.0, others 0.12) as a safety net against flags that stay set for many ticks.
|
||
- One-shots get a random ±4 % pitch. Spatial attenuation scale 0.1 (Bevy's attenuation never amplifies).
|
||
|
||
### Cues (as in `cues.rs` / `skate_events.rs` now)
|
||
|
||
"Level" is the cue level in `cues.rs`; the scale column multiplies it. Fraction = board speed / 10 m/s.
|
||
|
||
| Cue | Trigger (per physics tick) | Samples | Level × scale | In play |
|
||
|---|---|---|---|---|
|
||
| Pop | `ground_animation.launched` rising | retail recipe: knock 1112 + 878 + 891 at once, crack 1096 + one of 1097-1099 at +20 ms, body 1111 +30 ms, low thud 876 +40 ms (traced offsets) | retail median × 2 | Rebuilt from 57 traced pops + captured sound; not yet confirmed |
|
||
| Flip / grab whoosh + catch | new trick name while airborne (a held grab re-announced by scoring stays silent) | `Sk8_Air_Flip_Tricks` 1–3 (retail's Class_Flips) + container 1115 (feet catching the board) | 0.45 / 0.8 | Retail mapping new |
|
||
| Land | filtered category Air → Ground, air not begun on foot; impact = `riding.ground.maximum_closing_speed` | container 1095 (wheels down, ~1 s) + impact family 1055 / 1058 / 1052 by impact (< 3, < 6, ≥ 6 m/s) | (0.6; 0.5 / 0.64 / 0.72) × (0.3 + 0.7·impact/12) | Retail mapping new; the impact-family choice is ours |
|
||
| Board down | Air → Ground when the air phase began on foot (on foot within the last 0.5 s) | container 1126 (foot on deck) + 1119 (knock) + 1054 (wheel touch) | (0.6 / 0.54 / 0.28) × (0.3–1.0 over 0–5 m/s) | Retail mapping new |
|
||
| Board down heavy | same, impact > 2.5 m/s ("caveman" jump-on) | + landing set 1095 | 0.6 × (impact − 2.5)/4 | Retail mapping new |
|
||
| Grind | `grinds.grinding_316` or a grind state, and not `leaving_317` | start: container 954 (ledge etc.) or 1146 (metal rail object); then metal: `GRINDS` 54–56 loop; other surfaces: container 955 pieces (202–206) re-fired every 63 ms | start 0.5 / 0.8, loop 0.26, pieces 0.3; × (0.5 + 0.5·fraction) | Retail mapping new |
|
||
| Powerslide | state 101 SlideGround: random piece every ~0.08 s (±20 %), 10 ms fade-in | `WHEEL_SKID_BANK` 0–7 | 0.2 × fraction | Not yet confirmed |
|
||
| Foot drag | between `brake_contact` foot-down ticks (ends at foot-up or 0.1 s after the last foot-down), riding on the ground above 0.5 m/s; piece every ~0.11 s, 50 ms fade-in | `FOOT_DRAG` 36–39, 44–47, 60–64 | 0.25 × (0.3 + 0.7·fraction) | **Confirmed** |
|
||
| Push | `push_contact` rising edge | `fstep_skateshoe1_sm` 74–84 | 0.6 | Plays at a sane rate (no flood); sound not judged separately |
|
||
| Step / run step | foot strike (below) while on foot (states 500/501) and moving > 0.3 m/s; run set above 3 m/s | `fstep_skateshoe1_sm` 74–84 / 6–17 | 0.85 / 0.68 × (`AudibleFootStepStrength`/4, 0.3–1.0) | **Confirmed** ("almost perfect") |
|
||
| Bail | entering WipeoutGround (300), not in water and no splash in the last 0.5 s; tier by body speed (≥ 3.5 / ≥ 7 m/s) | body hits container 1074 (records 667–671) + `Bodyslide` 0–4 / 5–9 / 10–15 layer | 0.52 × (0.6 / 0.8 / 1.0) + 0.25 | Bodyslide tiers heard as right; body hits = retail mapping, new |
|
||
| Splash | body water contact (`collision_feedback.flags.material_12`) rising while falling > 1 m/s (1.5 s cooldown); or entering a wipeout in water with no splash in the last 1 s (speed ≥ 2) | `Skate_Collisions` 476–478 | 0.5 × (0.4 + 0.6·speed/8) | **Confirmed** (found by the user); the wading-wipeout path not yet confirmed |
|
||
| Rolling (loop) | wheels in contact, not grinding, > 0.3 m/s; grain by majority wheel audio surface; speed band | `grains/<surface>_hard/<band>` | 0.15 × min(speed/1.5, 1) | **Confirmed** |
|
||
| Wheel spin | take-off above 1 m/s, cut on landing (0.05 s) | `Whls_spins_Jump_1` | 0.15 × fraction | Not yet confirmed |
|
||
|
||
**Rolling bands:** board speed is low-passed (0.25 s) and mapped over 0–10 m/s to the 6 bands; the band
|
||
changes only when the speed is a full band away and after ≥ 1 s on the current one; changes cross-fade
|
||
over 0.15 s, and within a band the pitch follows speed by ±6 %. A new surface must stay under the wheels
|
||
0.25 s before the grain changes; after 0.15 s off the ground the loop stops and restarts directly on the
|
||
surface it lands on.
|
||
|
||
**Footsteps:** `FootStrike` (in `skate_events.rs`) learns each foot's resting clearance (follows the
|
||
lowest value, drifts back up over ~2 s) and fires when a foot that lifted > 5 cm above rest comes back
|
||
within 2 cm. `AudibleFootStepStrength` only scales the volume.
|
||
|
||
**Surface → grain (`cues::grain_for`), CORRECTED 2026-10-02 to retail's own surface map.**
|
||
|
||
The old table was our own guess by surface name. The grain player research
|
||
(`.claude/notes/grain-player-spec.md` §1.4) decoded retail's chain: a wheel's 7-bit audio surface tag →
|
||
material (tag − 1) → the vault's `Sk8::AudioSurfaceMap` → rolling surface 1–14 → grain. The function now
|
||
follows it exactly:
|
||
|
||
| Rolling surface | Grain | Tags |
|
||
|---|---|---|
|
||
| 1 | asphalt_rough | 2 |
|
||
| 2 | concrete_rough | 4, 66 |
|
||
| 3 | asphalt_smooth | 1, 55, 56, 61, 62, 71–83, 86–88, ≥ 95, 0 (no material): **the default** |
|
||
| 4 | concrete_smooth | 3, 51–54, 57, 60, 63–65, 92, 93 |
|
||
| 5 | wood_ramp | 6, 7, 41–50, 58, 59, 94 |
|
||
| 6 | concrete_aggregate | 5 |
|
||
| 9 | metal_smooth | 9, 11–36, 38–40, 84, 85, 89, 91 |
|
||
|
||
**What changed audibly:**
|
||
- **Asphalt is the default:** unlisted and high tags now play asphalt_smooth, not concrete_smooth.
|
||
- **Wood:** tags 47–50, 58, 59 and 94 now play wood.
|
||
- **Asphalt:** 55 and 56 now play asphalt, not aggregate.
|
||
- **Concrete:** 51–54, 57, 60, 63–65, 92 and 93 stay concrete_smooth (now explicit).
|
||
- **Metal:** 84 is now metal.
|
||
|
||
**Still interim, marked in the code:**
|
||
- **No grain in retail.** Rolling surfaces 7 (tags 10, 70), 8 (8), 10 (67), 12 (68, 69) and 13 (37)
|
||
play a per-surface `Class_rolling` patch (selectors 1, 2, 10, 11, 9), which needs the AEMS evaluator.
|
||
Until then they keep stand-in grains: aggregate for 8/10/70, metal for 37/67–69.
|
||
- **Uncertain:** tag 90 (rolling surface 0, no grain member) keeps wood_ramp.
|
||
- **Soft wheels:** only the hard members play. Retail picks the soft member when an audio-state flag is
|
||
set (Motion+200 < 0.5, meaning not decoded yet). Surface 9 has only a hard member.
|
||
- **The speed-band loops are still interim:** retail's grain player reads windows from the sweep (two
|
||
players per truck, a Bézier speed → position curve, equal-power crossfades, a grain every 0.3 s). See
|
||
the native-runtime research.
|
||
|
||
### Measured retail mapping (2026-10-01)
|
||
The pop/flip/landing/board-down/grind/bail cues above use the retail SPLC patches that a **local
|
||
recompiled build** of the game (github.com/mchughalex/skate3recomp, built and run locally only, no code
|
||
taken) was measured playing for the same moments. The recomp was instrumented to log every sample start
|
||
with its final gain (gain modules × send), every SPLC patch request with the gameplay function that asked,
|
||
and every audio-object post; the user played normally while it logged, and the events were grouped by
|
||
the game's own requesting function. Exact moments came from a scripted ollie/kickflip:
|
||
|
||
| time after the flick | retail plays |
|
||
|---|---|
|
||
| +0.24 s (pop) | containers 1096 + 1098/1099, flip object (Sk8_Air_Flip_Tricks 1–3) |
|
||
| +0.3–0.6 s (catch) | deck knocks 1112 / 1115 / 1118 (548–552 + 530–535) |
|
||
| +0.95 s (landing) | 1095 (396–404, ~1 s) + one impact family of 1052/1055/1058 + wheel skid |
|
||
| getting on the board | 1126 (422–426) + 1054 + 1095 + knock 1119 |
|
||
|
||
Measured levels (linear, retail mix): knocks 0.36–0.57, pop/landing sets 0.2–0.46, board-down 1126 up to
|
||
0.76, ledge grind pieces ≤ 0.35, rail start 0.49, bail hits ≤ 0.26, Bodyslide ≤ 0.19, GRINDS ≤ 0.13,
|
||
rolling grains p90 0.065. Our levels = retail × 2.0 (`cues::RETAIL_SCALE`), calibrated so retail's
|
||
rolling level lands on our play-tested `ROLL_LEVEL`. Not decoded yet: how retail picks among the impact
|
||
families 1050–1059 (surface/force); we choose by impact.
|
||
|
||
### World emitters from the retail `.ems` files (`emitters.rs`, 2026-10-02)
|
||
|
||
Replaces our own water placement (`water.rs`), which grouped water collision into bodies and started a
|
||
random 1–2.5 s piece every 0.7–1.3 s. The pieces piled up 2–3 deep; the user heard the University plaza
|
||
water "overlapping or playing too many times".
|
||
|
||
**Data (setup, `audio_formats.py` / `audio_export.py`, manifest version 4):**
|
||
- `.ems` layout: BE u32 count + 72-byte records: index, flags, position[3], extent[3], scalars[4],
|
||
u64 sound id, gains[4].
|
||
- The sound id is Bob Jenkins' lookup8 64-bit hash (seed `0xABCDEF0011223344`) of a name, written as named
|
||
or lower-cased.
|
||
- Each id keys a record of the emitter attribute class `0xF0CEF367088EFFF8` in the disc's
|
||
`skatercollections` database, resolved through its parents:
|
||
- volume, bank file, patch (the selector the bank's program plays);
|
||
- `eVolumeType` (1 = looping emitter, 5 = reverb zone);
|
||
- `eVolumeFalloffType`;
|
||
- a float that defaults to 10 (probably a one-shot duration; unverified).
|
||
- The exporter writes all 23 `.ems` files with resolved attributes. It also decodes every bank the
|
||
`sfx_*` / `skateschool` emitters use: 100 banks, 95 of them new. All 486 sound emitters resolve to a bank
|
||
on the disc.
|
||
|
||
**Game side (from TU3, reference only):**
|
||
- **Shape:** a record is a sphere (equal extents) or an ellipsoid. Its semi-axes are the extents along
|
||
forward = scalars[1..4], up and side (side = Y × forward).
|
||
- **Core:** scalars[0] is an inner core with full level. Outside it the distance is rescaled to the edge.
|
||
- **Level** = attribute volume × falloff: (1−d)², 1−d or flat.
|
||
- **Pool:** at most 5 emitters play (`CSTATEMGR_Emitter`'s pool), taken in the order they were reached. An
|
||
emitter the listener leaves is released at once (we use a 50 ms fade only against clicks).
|
||
- **Flags:** records with non-zero flags are gated by a context mask we haven't identified, so they are
|
||
skipped.
|
||
- **Gains:** the four gains are never read by the game.
|
||
|
||
**Bank programs (interim):**
|
||
- What a bank plays is its own AEMS patch program. Run through the PoC evaluator:
|
||
- `water_fountain`, `water_lapping`, `water_lapping_pond` and `ocean_wave_small` are two-voice relays of
|
||
one-shots from per-voice shuffle bags, the next piece starting about 0.17 s before the current one ends;
|
||
- `water_dam_*` and `fountains_waterlaps_*` are single loops;
|
||
- each has slow level and pitch modulation (e.g. fountain pitch 0.82–0.94).
|
||
- `emitters.rs` `PROFILES` reproduces those measured patterns for the water banks until the AEMS evaluator
|
||
is ported. Banks without a profile (trees, birds, HVAC, interiors, …) are not played yet.
|
||
|
||
University: the plaza channel is `fountains_waterlaps_left` (a 37 s loop; patch 347) in a 6 × 4 × 47 m
|
||
ellipsoid, next to `water_fountain` in a 13 × 7 × 61 m one.
|
||
|
||
### Random distant one-shots: sirens, horns, dogs, jets (`random_sets.rs`, 2026-10-02)
|
||
|
||
Retail has occasional police sirens and other distant city sounds. They come from a location-based
|
||
random one-shot scheduler, not from placed emitters.
|
||
|
||
**Location → set:**
|
||
- The game queries the district's world-painter region layer `audio_emitters` at the skater's x, z.
|
||
- The layer is 128 m tiles of quadtrees in the `cSim_*.xsf` streams of `worldDIST_<district>.big`.
|
||
- Each leaf holds the key of an `aud_wp_emitters` record (57 sets). Examples:
|
||
- `e_dwtn_office_buildings`: a sound every 2–5 s at level 0.2–1.0, 23 weighted sounds including city
|
||
sirens;
|
||
- `e_dwtn_spillway_brewery` (the Aletown spawn): every 4–8 s, 15 sounds including 5 sirens.
|
||
- Setup exports the sets (`audio_export.random_sets`) and the region tiles (`audio_export.regions`,
|
||
parser `audio_formats.region_layers`), and decodes their banks. The manifest now has 222 banks.
|
||
|
||
**Scheduler (from TU3):**
|
||
- At most 2 banks are loaded, each picked by weight.
|
||
- A timer fires the loaded sound that was loaded longest ago, every uniform min..max seconds.
|
||
- Level = sound volume × a 0.1-step random factor in the set's range.
|
||
- A post comes from a random fixed direction (not positional) and ends with its program or its timeout:
|
||
sirens 19.5 s, dogs 5 s.
|
||
- Each bank's program (layers, delays, pan sweep) is measured per bank in `random_programs.rs` until the
|
||
AEMS evaluator is ported.
|
||
- Four banks never answer their attribute's selector (`Siren_city_5/6`, `car_horn_city_2/3`), so they
|
||
stay silent here as in retail.
|
||
|
||
**Verified:**
|
||
- A recomp trace at the Aletown spawn shows set `e_dwtn_spillway_brewery` firing a sound every 4.5–7.8 s,
|
||
including `Siren_distant_2` and `Siren_city_8`.
|
||
- Our region lookup returns the same set there, and `e_univ_megapark` at the University start, as the
|
||
recomp does.
|
||
- A 15-location recomp run (5 teleports each in Downtown, Industrial and University, 45 s at each) gives
|
||
our lookup the same set as retail on **1,859 of 1,859** position samples, across 15 different sets.
|
||
Retail's fired sounds fit each place: sirens, horns and a car crash downtown; seagulls at the docks;
|
||
wolves and coyotes at the Spillway; hawks and rockfalls at the mega-park.
|
||
- Retail fired `Siren_city_5` at Hotel District and no siren sample played, so the banks we keep silent
|
||
are silent in retail too.
|
||
- **Long runs (5 minutes per location, 2026-10-02)** — *re-verify on clean data: the long_* sessions were deleted
|
||
(malformed, interleaved trace lines; user, 2026-10-02):*
|
||
- **All 36 Challenge Map locations** (Downtown 14, Industrial 12, University 10). Our location sets match
|
||
the game on **26,817 of 26,817** samples, across 28 sets.
|
||
- The game's ambience zone (trace hook AMBST) equals our `audio_ambience` lookup at every location in
|
||
all three districts.
|
||
- Every drawn interval lies within its set's range.
|
||
- About 60% of intervals fire a sound, matching the 2-loaded-banks rule.
|
||
- Rare sounds need long listening: `work_whistles` never fired in about 20 minutes of the factory set,
|
||
nor hawks and rattle_snake at the mega-park or cicadas at the Observatory in 5 minutes each.
|
||
- Tools: `.claude/skills/audio-tuning/tools/{check_sets_vs_trace,long_run_stats}.py`.
|
||
- Muted in-game run: our engine at Aletown selects the same set and fires sirens, horns, buses and bangs.
|
||
- The small parks have no `audio_emitters` layer, so no random one-shots play there.
|
||
- `SKATE_AUDIO_SET=<name>` forces a set for testing.
|
||
|
||
### Zone ambience beds (`ambience.rs`, 2026-10-02): replaces the per-map bed choice
|
||
|
||
**Data:**
|
||
- The zone is the district's region layer `audio_ambience` at the skater's x, z: an `aud_wp_ambiences`
|
||
record (23 zones) with volume, bed number, fade-out time (+8) and fade-in time (+12).
|
||
- Bed number `NN` is the `ambience.big` stream whose name starts with `NN_`. Index 19 exists twice; retail
|
||
takes the first in TOC order, `19_reclaimed_b_fix`, and so do we now.
|
||
- `aud_wp_ambience_crossfades` (23 pairs, e.g. `dt_03_main2dt_open`) map a zone pair to a group of the
|
||
district's `Main_Ambience_Crossfade_DT/Ind/Uni` bank, with a level.
|
||
- Setup exports zones and crossfades (`audio_export.ambience_zones`) and decodes the three crossfade banks.
|
||
|
||
**Behaviour (TU3 SFXObj_Ambience, reference only):**
|
||
- **One bed at a time.** On a zone change the old bed fades out linearly over the old zone's fade-out
|
||
time and stops, then the new bed fades in over the new zone's fade-in time. A return during the
|
||
fade-out fades the same bed back in; a change during a fade-in waits until it ends.
|
||
- **Crossfade layer.** For the whole transition the crossfade group of the pair (either order; group 1,
|
||
level 1.0 by default) plays: four looping 2 s voices at quad directions, rear voices at ≈0.70 (measured
|
||
per group, `crossfade_groups.rs`). It stops when the fade-in ends.
|
||
- **Districts.** The parks have no crossfade bank, so no crossfade layer there.
|
||
- **No panning.** Beds aren't positional.
|
||
- **Old installs.** Without zone data (manifest v3) the old per-map table below is the fallback.
|
||
|
||
**Verified (muted run, DownTown):**
|
||
- The spawn is `dt_less_busy`. Moving into `dt_main` played crossfade group 2 at level 1.75 (the data's
|
||
`dt_02_less_busy2dt`), then the `dt_main` bed.
|
||
- The teleport to Aletown played group 3 at 1.25 (`dt_03_main2dt_open`), and `06_dt_open` started after
|
||
`dt_main`'s 1.75 s fade-out.
|
||
- The teleport to Hotel District used the same pair in reverse.
|
||
|
||
**Bed level (verified 2026-10-02):** the Ambience MixMap's bed output carries a **−11 dB base**. Retail
|
||
captures at four zones were checked with `.claude/skills/audio-tuning/tools/check_bed_level.py`: the bed's
|
||
5-channel stream × zone volume, folded like the recomp's capture.
|
||
|
||
| Zone (volume) | Retail median | Predicted with −11 dB | Without |
|
||
|---|---|---|---|
|
||
| dt_open (0.75) | −42.1 dBFS | −42.1 | −31.1 |
|
||
| dt_main (0.6) | −39.2 | −38.7 | −27.7 |
|
||
| dt_rez (0.75) | −44.2 | −43.6 | −32.6 |
|
||
| indu_quarry (0.8) | −47.7 | −47.8 | −36.8 |
|
||
|
||
The bed gain is now zone volume × 10^(−11/20) × `cues::RETAIL_SCALE`. Our levels run at 2 × retail, so
|
||
this keeps retail's bed-to-player balance. Before this the beds were 11 dB too loud relative to retail.
|
||
|
||
**Not decoded yet:** the other MixMap outputs feeding the bed (pitch, low-pass), the crossfade level
|
||
(taken as full scale), and ducking. Trace hooks for them are listed in the notes.
|
||
|
||
### Ambience per map: fallback only (installs without zone data; project choice by bed name)
|
||
|
||
| Map | Bed |
|
||
|---|---|
|
||
| University | `09_univ_campus` |
|
||
| StartPark | `10_univ_housing` |
|
||
| DownTown | `04_dt_main` |
|
||
| DownTownSkatePark, MegaPark | `05_dt_parks` |
|
||
| Industrial | `11_indu_shipyard` |
|
||
| IndustrialSkatePark | `20_indu_old_factory` |
|
||
| SkateSchool | `18_skate_school` |
|
||
| MaloofMoneyCup | `21_interior_arena_amb` |
|
||
| BlackBoxPark | `22_interior_tunnel_amb` |
|
||
| Test world | none |
|
||
|
||
"Ambience good so far" (user, University); other maps not yet judged.
|
||
|
||
### Logging (always on)
|
||
|
||
- `AUDIO_CUE <cue> <bank>:<index> volume=…` for every one-shot (`(voice limit)` if refused).
|
||
- `AUDIO_LOOP start <file>` for rolling/grind/water loops, `AUDIO_LOOP start <name> (<bank>)` for shuffles.
|
||
- `AUDIO_EVENT brake down|up`, `push`, `grind start|stop surface= ledge= flag= leaving=`,
|
||
`rolling surface= grain= band=` (on change only).
|
||
- Startup: `Game audio: 22 ambience beds, 14 rolling grains, 23 sample banks`, `Game audio: preloaded N clips`,
|
||
`Ambience: <bed> for map …`, `Water audio: N bodies (…)`.
|
||
- `SKATE_AUDIO_TRACE=1` adds per-tick `AUDIO_TRACE` lines (footstep strength, push/brake flags, foot heights).
|
||
|
||
### Dev tools
|
||
|
||
- (Removed 2026-10-03: `tools/audio_audition.py`, the cue audition page; see "PR #32 review fixes, step 1".)
|
||
- `cargo run -p skate-data --release --example audio_surfaces -- <map.skate>` lists audio surface IDs per
|
||
map; with `AT=x,y,z[,r]` it lists the collision surfaces (audio ID, physics type, slope) within r m
|
||
(default 3) of a point, e.g. a HUD position the user reports.
|
||
|
||
### Retail patch records (SPLC) and prior work upstream
|
||
- **Prior work:** upstream PRs #4 ("Audio/retail exact player sound") and #1 (an earlier snapshot of it,
|
||
plus a procedural vehicle-engine synth) run retail's AEMS sound routing through a function-by-function
|
||
transcription of recompiled game code and a dumped TU3 memory image. That runtime conflicts with this
|
||
fork's rule (re-implement; disassembly is reference only; no game code/data), so it is not used. Their
|
||
format notes were used as *knowledge*, re-derived and verified here.
|
||
- **SPLC patch tree** (`audio_formats.splc_patches`): 60-byte header (+8 sample-table offset from byte 60,
|
||
+12 records, +16 containers, +20 extras = 0, +24 samples); 36-byte records (+4 id, +7 group count); 72-byte
|
||
containers (+4 record ids, +68 count, +69 mode); per record its groups (12-byte header: +8 member count,
|
||
+9 mode) of 72-byte members (+0 sample index, +8 gain, +44 pitch spread, +48 gain range, +64 probability;
|
||
+20/+52 delay, not used yet). Verified on all 20 SPLC banks: the walk ends exactly at the sample table,
|
||
every member names a valid sample (5,973 members → 2,266 samples), and sample-table offsets sit at a
|
||
constant distance from our SNR scan — **SPLC sample index n is our stream n**.
|
||
- **Semantics** (per PRs #1/#4, implemented independently in `skate_events.rs` `play_record`): a record's
|
||
groups are layers played together, one member each; group mode 0 random, 1 in sequence, 2 shuffled
|
||
without an immediate repeat; a member plays unless rand > probability; gain ± range; pitch random between
|
||
1/s and s for spread s > 1; ids ≥ the record count are containers that pick one record.
|
||
- **Validation of the ear-picked samples:** every group the user picked is exactly one retail record in
|
||
`Skate_Collisions`: pop 1074–1078 = record 818, landing 1088–1092 = records 824/825, heavy 1097–1100 =
|
||
records 832/833, board-down 0/1/2 = records 125/126/127, splash 476–478 = part of record 869.
|
||
- **Corrected 2026-10-02 from the retail Splice code** (`sub_82975CC8` / `sub_82976860`): member `+4` is the gain,
|
||
`+8` the pitch, `+44` the gain spread, `+48` the pitch randomisation, `+64` the probability (the field roles
|
||
above and PR #4's were swapped); the native Splice player uses these (see "Board contacts, skid and squeaks").
|
||
- **Splash now plays record 869** (three layers: 169 at 75 % probability + one of 476–478 + one of 171/172).
|
||
- Setup exports each SPLC bank's patch tree in `audio_manifest.json` (`patches`, manifest v3).
|
||
- PR #4's own pop/landing ids are different, layered patches (e.g. container 1097 → records 745–750, each
|
||
5 single-member layers from 260–267 / 635–657 / 1000–1015); they come from game tuning values we do not
|
||
read. Not adopted; the user's picks stay.
|
||
|
||
## How it was tuned (condensed session history)
|
||
|
||
Each step came from the user's report of an in-play session plus the `AUDIO_*` log lines of that session.
|
||
|
||
1. **First pass:** push, foot drag, footsteps silent — the animation events are cleared inside the tick
|
||
before any ECS system can see them → `AudioEvents` latch. Bails silent (`board_wiping_out` is reset by the
|
||
ground phase) → WipeoutGround state. Grinds silent (raw `category_12`) → `player_state.current()`.
|
||
2. **Flood:** the next log showed **push fired 1,723 times** and steps ~60/s: push contact and
|
||
`AudibleFootStepStrength` stay set for many ticks. That flood caused scratchiness, odd pick-up sounds and
|
||
likely the hitches (now fixed). → rising edges, per-cue minimum gaps, preloading.
|
||
3. **Footsteps:** a `SKATE_AUDIO_TRACE=1` run showed `AudibleFootStepStrength` is a **held level**
|
||
(2.5–4, higher when running), **not a step event**; a peak detector fired only when the level changed.
|
||
→ foot strikes from foot clearance (planted 0.02–0.04 m, swing up to 0.4–0.55 m; 56 steps over ~16 s at
|
||
~6 m/s). Footstep clips are ~10 dB quieter than impacts → the headroom rule replaced "volume ≤ 1".
|
||
4. **Foot drag:** `brake_contact` foot-down **repeats every tick** while braking (47 and 139 ticks seen),
|
||
then foot-up repeats while lifting (24–28 ticks); bit 28 is set by both, so the first latch also dragged
|
||
on foot-up. → drag only between foot-down ticks, above 0.5 m/s, overlapping 0.2 s scuffs.
|
||
5. **Rolling:** grain files are speed sweeps → 6 bands. Then 53 restarts in one session (band jumps right
|
||
after landings, concrete seams flickering) → smoothing, holds. On University's plywood ramp the sound
|
||
"played twice": `AT=` confirmed the ramp is audio 41 (Wood_1) throughout, so the mapping was right; 36 of
|
||
38 quick restarts were band changes → switch only a full band away, ≥ 1 s per band. Remaining doubling at
|
||
low speed: on returning to the ramp the loop **restarted on the debounced old surface** (concrete) and
|
||
switched to wood 0.25 s later → stop after 0.15 s off the ground and restart on the surface underneath.
|
||
User: "much better".
|
||
6. **Board down:** every touch-down after stepping on played `land`, because the physical state leaves
|
||
BipedGround a few ticks before the filtered category reaches Air → "began on foot" = on foot within 0.5 s;
|
||
level by impact (0.18–0.45 m/s seen for gentle set-downs).
|
||
7. **Deck family (found by the user):** the user picked out `Skate_Collisions` 1066–1092 as "noises heard a
|
||
lot while skating". Measured: 0.13–0.37 s knocks, most energy at 400–2500 Hz (the deck), unlike the low
|
||
thumps either side (1060–1065, 1093–1100; 70–90 % of energy under 400 Hz). Pop = sharpest
|
||
(1074–1078, 5–20 ms attack); land = 1088–1092; weight = 1097–1100. User: pop and landing "10000% better".
|
||
Earlier rejected landings: 35–40 (harsh), 18–20.
|
||
8. **Splash (found by the user):** a full decode of all 7,434 streams scored for splash shape found no
|
||
long splash; candidates (`water_misc`, `Skate_Collisions` 741–762) were rejected ("not water related at
|
||
all"). The user found **`Skate_Collisions` 476** ("literally a splashing noise") on a candidate page;
|
||
477–478 are its variations (same length 1.6–2.0 s and spectrum). 473–475 (shorter) were tried for small
|
||
falls and rejected → always 476–478. Each water entry also played a bail ~20 ms later → bail suppressed
|
||
in water. Wading in then falling gave no fresh fast contact → a wipeout in water splashes by itself.
|
||
9. **Grinds:** sound followed the named state, which lags the selector → `grinding_316` / `leaving_317`.
|
||
A University rail sounded like wood: `Skate_Metal` has no clip ≥ 1 s, so `GRINDS` (123 clips,
|
||
1.4–2.6 s) holds every material. **By tonality** (energy share of the strongest 1 % of spectral bins)
|
||
9 and 11 are among the least tonal (0.30–0.36), **54–56 the most tonal and brightest** (0.65–0.85,
|
||
4–5.6 kHz) → 54–56 on metal.
|
||
10. **Bail:** `Bodyslide` 0–15 is three blocks of five (one longer slide + four hits), heard as rising
|
||
impact → tiers. In play they sound like **additions to a fall, not the body impact** → kept as a quiet
|
||
sweetener layer (0.25) until a main impact is chosen (`.local/audio-audition/bail.html`; EA's scripts
|
||
name body impacts per body part: `body_imp_Torso`, `_leg`, `_Head`, `_foot`).
|
||
11. **Grab whoosh** repeated while holding a grab (3 in 0.8 s): scoring re-announces a held grab with the
|
||
same name → whoosh only for a new name in the air phase. Confirmed.
|
||
12. A game window that closed with no report was Windows Terminal crashing (the game was attached to its
|
||
console via `PLAY.bat`), not the game.
|
||
|
||
## Files
|
||
|
||
- New: `tools/asset_pipeline/audio_formats.py`, `audio_export.py`, `test_audio_formats.py`,
|
||
`tools/audio_audition.py` (removed 2026-10-03), `crates/skate-game/src/game_audio/{mod,library,voices,ambience,cues,skate_events}.rs`,
|
||
`crates/skate-data/examples/audio_surfaces.rs`.
|
||
- 2026-10-02 world audio:
|
||
- `game_audio/emitters.rs` replaces `water.rs`;
|
||
- new `random_sets.rs`, `random_programs.rs` (generated), `crossfade_groups.rs` (generated);
|
||
- `ambience.rs` rewritten for the zone beds; `library.rs` reads manifest v4;
|
||
- `audio_formats.py`: `.ems` reader, `name_id`, region layers; `audio_export.py`: emitters, location
|
||
sets, zones and crossfades, regions, emitter and crossfade banks.
|
||
- Changed: `tools/asset_pipeline/{asset_exports,install,versions,group_receipts}.py`,
|
||
`tools/asset_pipeline/test_versions.py` and `tools/test_setup_assets.py` (hand-written group lists),
|
||
`crates/skate-core/src/physics/board_ground.rs` (+ test), `crates/skate-game/src/{main,app,config,graphics_menu,physics}.rs`,
|
||
`physics/animation_input.rs` (audio event latch), `modding/audio.rs` (listener moved),
|
||
`retail_character.rs` and `tests/map_transition.rs` (`Config.mute`), `.gitignore`.
|
||
|
||
## Verification
|
||
|
||
- `test_audio_formats` (synthetic streams only); setup tests 42/42; all tracked tools tests identical to the
|
||
clean tree (the same 3 pre-existing import errors).
|
||
- Every SPLC bank: scanner count = declared count; every ABKC bank: scanner count = vgmstream subsongs.
|
||
- `cargo test`: `game_audio` (cue table sanity, bail tiers, band hysteresis, foot strikes, board-down levels,
|
||
water bodies and fade, voice limits, headroom), `graphics_menu`, `modding::audio`, `map_transition`,
|
||
`skate-core board_ground`.
|
||
- Export into the dev install: 22 beds, 14 grains (84 bands), 2 wheel spins, 23 banks; `--check-assets` →
|
||
`SKATE_ASSETS_READY`.
|
||
- Muted smoke runs (`--mute`): the startup lines above, no panics, errors or missing-sink warnings.
|
||
- ~15 in-play sessions by the user (University, DownTown); confirmed items are marked in the cue table.
|
||
- **Not yet done:** full `regression-check` pass and a setup refresh through the installer path (only the
|
||
`audio` group should rebuild).
|
||
|
||
### Native audio runtime: research (2026-10-02)
|
||
The measured per-bank tables (`emitters.rs` PROFILES, `random_programs.rs`, `crossfade_groups.rs`, the
|
||
`cues.rs` tables) are interim. The plan is to replace them with our own implementation of the retail
|
||
runtime:
|
||
- the AEMS patch-program evaluator;
|
||
- the voice graph: sample player, resampler, RBJ Q = 1 filters, gain with a 64-sample de-click,
|
||
6-channel pan, sends;
|
||
- the MixMap mixer and the granular rolling bed.
|
||
Specs written so far: evaluator (all 40 opcodes, checked against the recompiled game across all 376
|
||
banks), voice graph, and a prior-art survey. The MixMap and grain player specs are in progress.
|
||
|
||
**Design goal: modular and moddable (2026-10-02).**
|
||
- Each subsystem sits behind a clear boundary: formats, evaluator, voice graph, mixer and buses, MixMap,
|
||
grain player, world systems, output host.
|
||
- Every sound resolves by its retail identity (bank + patch, sample, set/zone key) through a content
|
||
layer: mod override first, then retail. Mods can replace samples, banks and programs, location sets,
|
||
emitters and ambience.
|
||
- The Lua SDK grows from today's play/update/stop of mod WAVs. Planned: posting retail sounds, setting
|
||
globals, reading MixMap values, hooking game audio events (to replace, suppress or layer), and mod
|
||
emitters on the same voice graph and buses. The existing commands keep working.
|
||
|
||
The evaluator research found that upstream #4's evaluator mishandles op 5 (CallFunction), so its
|
||
sample-selection functions never run, and lacks ops 19/20/38. Measurements taken through it are therefore
|
||
reliable for timing, layers and pans, but not for which sample a function-driven bank (sirens, dogs, birds)
|
||
picks.
|
||
|
||
### Native AEMS runtime: implementation (2026-10-02, `crates/skate-audio`)
|
||
**Problem.** Every world bank's behaviour was a measured table. Retail runs each bank's own patch
|
||
program through the AEMS evaluator and a fixed voice graph.
|
||
|
||
**Approach.** A new engine-independent crate, `crates/skate-audio` (`#![forbid(unsafe_code)]`, no Bevy, no
|
||
dependencies), written from our specs (`.claude/notes/aems-evaluator-spec.md`,
|
||
`aems-voice-graph-spec.md`):
|
||
- `formats/`: ABKC module banks (modules, programs, templates, interface exports, the `S10A` sample bank),
|
||
MOIR Csis projects, EA SNR sample headers. Bank load rejects opcodes ≥ 40 and programs whose block
|
||
walk does not end at the template size.
|
||
- `eval/`: the evaluator. Instance memory is a byte copy of the module's template, read and written as
|
||
big-endian words at retail's offsets, so programs run untranslated. All 40 opcodes, including 19/20
|
||
(n-ary min/max) and 38 (ControlClass, a child post); the shared 6-word RNG from zero; the 6-block tick
|
||
with tick scale f32 31.999998; Csis classes (constructor clients, newest bank first), functions
|
||
(synchronous delivery, seen at the subscriber's next op 37) and global variables (notify on change);
|
||
post / redeliver / release with refcounted nodes; Destroy releasing voices and child posts; the
|
||
Player op (open applies the full input set, changed inputs pushed, query → time left / time current,
|
||
restart only on a 0 → 1 edge, no restart of a voice that died while paused).
|
||
- `dsp/`: Resample (16.16 linear with retail's weight constant 0x377FFC9C, ≤ 4×), High/LowPassIir2
|
||
(RBJ Q = 1, bypass outside 24 Hz … 0.999·Nyquist), Gain (64-sample de-click), Send (64/65 ramp, 16-tap
|
||
release fold), Pan2D1 (6 outputs, pairwise constant power with centre extraction, distance spread,
|
||
multichannel layouts, 64-sample matrix ramp), the route tables and the output stage's stereo fold
|
||
(0.707·L + 0.5·C + 0.5·Ls, clamp ±1, no master gain).
|
||
- `mixer.rs`: voices built from those modules (first block silent = format change, 16-frame stop fade,
|
||
loops, source-time remaining/elapsed for the Player op), implementing the evaluator's `VoiceHost`;
|
||
routing codes (ids ≥ 9) are parsed and kept, every voice mixes into the default bus.
|
||
- `runtime.rs`: evaluator + mixer on one 256-frame block clock (render, then tick: a walk's voice
|
||
commands apply from the next block, as retail drains its command ring per block).
|
||
|
||
**Game side.** `game_audio/native.rs` hosts it: off by default; `SKATE_AEMS=1` or `"native": true` in
|
||
`settings/audio.json` turns it on. All Csis projects install at startup, `emitter_utility` is posted as at
|
||
boot, banks load on first use (their WAVs as PCM) and unload on map change; one device-paced rodio stream
|
||
pulls 48 kHz stereo; volume = master × ambience, paused while silenced. So far only the `.ems` world
|
||
emitters use it (`emitters.rs`): with it on, **every** emitter record whose bank is in the install plays
|
||
its retail program (DownTown: 176 of 176 records, against 30 with a measured profile), posting
|
||
`c_emitter` with w0 32767, w1 = dry level (our falloff level; MixMap not ported), w3 = azimuth
|
||
(0 = ahead, clockwise, 65536 = 360°), w4 4096, w5 25000, w8 = the attribute patch; redelivered every
|
||
frame, released when the listener leaves. Location sets, zone beds, crossfades and skate cues keep their
|
||
tables.
|
||
|
||
**Setup.** `audio_export.aems_files` copies every `.csi` (archive order) and the exported banks' `.abk`
|
||
files plus `emitter_utility.abk` byte for byte into `private/audio/aems/` (52 MB), listed under the
|
||
manifest's `aems` key; manifest version 5 (`library.rs` reads 3..5). All 376 banks keep their rebase and
|
||
interface lists after the sample data, so the whole file is needed. S10A slot i = WAV i on all 376 banks.
|
||
|
||
**Evidence.**
|
||
- Format census against the spec on every disc bank (`tests/disc_banks.rs`): 376 banks / 385 modules
|
||
parse; opcode counts equal the spec's census exactly; every op 0/1/2/37 block sits on the laid-out
|
||
subscription state; op 5/27/38 block sizes match their layouts; 1,059 exports resolve except exactly
|
||
the 4 the spec lists; 291 banks bind to `c_emitter`.
|
||
- **Evaluator vs the PoC oracle** (our probe `aems_golden` in the local PoC worktree, with its op 5
|
||
fixed and n-ary min/max added locally, never committed; identical mock voice device on both sides):
|
||
**234 of 235 scripts identical — 88,597 device calls (open / set / azimuth / release / end) and 599
|
||
voice opens, 0 differences.** Scripts: one per emitter and location-set bank in the manifest
|
||
(utility at boot, release at 20 s), both crossfade banks with group changes, sirens posted twice, and
|
||
28 player/world classes (grinds, footsteps, cloth, seams, rolling, traffic, crowds incl. op 20, car
|
||
alarms, moveables, flips…) with moving payloads. Every opcode used on the disc ran except 38. Before
|
||
the PoC's op 5 was fixed, 30 scripts differed, all at the first sample choice of a function-driven
|
||
bank: the native port delivers `*_msg` calls, as retail does (spec §11).
|
||
- The remaining script, Tazer (op 38), has no oracle: `tazer_control_class_owns_and_releases_its_child`
|
||
checks the child post's lifecycle (refcount 3 while the child lives, 1 after it ends itself; parent
|
||
release clears every instance, node and voice).
|
||
- **Voice graph vs the PoC's replay-verified kernels** (`tests/dsp_oracle.rs`, vectors from our local
|
||
probe `dsp_vectors`): Resample **16,384 / 16,384 samples bit-exact** (8 steps from 1/65536 to the 4×
|
||
clamp); LPF coefficients **40 / 40 words bit-exact** (image trig); biquad kernel **16,384 / 16,384
|
||
bit-exact** over 8 cutoffs (25 Hz … 23975 Hz) after fitting retail's per-position association of the
|
||
fused multiply-adds black-box against the oracle (`fit_biquad_association`, every position 100 %);
|
||
gain ramp 1,013 / 1,024 bit-exact, the rest 1 ulp (lane arithmetic for irregular steps not recovered).
|
||
- Pan gains match all 13 worked cases of the spec (≤ 1.5e-4).
|
||
- Headless renders (`examples/aems_render.rs`, real banks and WAVs through the whole graph and the
|
||
stereo fold): water_fountain relay −28 dBFS RMS, trees_rustle gusts −38…−44, Siren_city_4 two layers
|
||
then silence after its sample; about 2,000× real time.
|
||
|
||
**Files.** New: `crates/skate-audio/**` (lib, `tests/disc_banks.rs`, `tests/dsp_oracle.rs`, examples
|
||
`aems_replay`, `aems_list`, `aems_render`), `crates/skate-game/src/game_audio/native.rs`. Changed:
|
||
`Cargo.toml` (workspace member), `Cargo.lock`, `crates/skate-game/Cargo.toml`, `game_audio/{mod,library,emitters}.rs`,
|
||
`tools/asset_pipeline/audio_export.py` (+ test). Local tools: `.claude/skills/aems-port/tools/`.
|
||
|
||
**Verification run.** `cargo test -p skate-audio --release`: 45 unit + 3 disc + 3 oracle tests pass
|
||
(2 ignored fit tests). `cargo test -p skate-game --release --bin skate3rust`: `game_audio` 35/35, the
|
||
whole suite 354 passed with only the known `pipelines_accept_valid_group_outputs_when_fingerprint_changes`
|
||
failure. Pipeline tests (`test_audio_formats`, `test_versions`, `test_setup_assets`) 32/32. One muted
|
||
start with `SKATE_AEMS=1` on DownTown: runtime on, 176/176 records, `trees_rustle` started natively,
|
||
no panic; with it off, the old 30/176 path unchanged. **Needs a listening check in game by the user.**
|
||
|
||
**Not modelled yet / open** (marked UNCERTAIN in the specs, nothing invented): the environment send
|
||
(Send A) and effect returns (Send B, codes 4096/8192/16384), the material buses and 512/2048 buses (their
|
||
authored values are not recovered), so every voice mixes dry into the default bus; the sample-group
|
||
"level %" byte (not applied); pause/resume Send modes; loop time-left semantics (we report time left in
|
||
the current pass); SndPlayer pre-roll; the gain ramp's last ulp; who writes `c_emitter` w0 (we pass
|
||
32767) and the MixMap channels that should feed w1/w2/w3 (done since, next section). Next: retiring
|
||
`random_programs.rs`, `crossfade_groups.rs` and the cue tables one at a time.
|
||
|
||
### MixMap and the granular rolling bed: implementation (2026-10-02, `crates/skate-audio`)
|
||
**Problem.** The native runtime's `c_emitter` words were stubs (dry = our falloff level, no send, fixed
|
||
pitch/filter), and the rolling bed was our own approximation: each `.grain` recording cut into 6 looped
|
||
speed bands. Retail mixes every audio object through the MixMap (`MixMapSK8.mxb`: 247 controllers feeding
|
||
per-object volumes, pitches, filters and pans) and plays the bed with a granular player.
|
||
|
||
**Root cause / retail mechanism** (specs `.claude/notes/mixmap-spec.md`, `grain-player-spec.md`): the
|
||
MixMap is a fixed graph of input products (A), distance/azimuth lookups with Doppler (B), AHR/ADSR ducks
|
||
(F), clamped sums (C) and output sums (E) evaluated once per 60 Hz frame; the bed is two GrainPlayers per
|
||
truck reading windows of one slow-to-fast recording around a speed-driven read position, picked without
|
||
repeats and cross-faded with square-root fades, rescheduled every 256-frame block.
|
||
|
||
**Change.**
|
||
- `crates/skate-audio/src/mixmap/`: `format.rs` (file decoding), `tables.rs` (POW/LOG/CURVE/SEMI/FINE by
|
||
formula, the four FINE ulps and three image floats as stored: K = ±0x3FFF1FC4, 16.666, 16.66667), the
|
||
evaluator `mod.rs` (instances per slot, cross-slot reference expansion, input runs by curve kind, the
|
||
one-tick lags, envelopes, Doppler slew, the output conversions and their quirks: 0 mB → 32730, pitch
|
||
below −4800 → 0, a disabled block writes −10000 read back as 22768) and `keys.rs` (controller keys).
|
||
Owner readers `level` / `raw` / `pitch_4096` / `filter_hz`. Ported from our own Python reference
|
||
evaluator (`audio-tuning/tools/re/mxb_tool.py`), no PR #4 code.
|
||
- `crates/skate-audio/src/grain/`: `format.rs` (`.grain` header, stored duration, the run-length varint
|
||
seek table, EAAC header), `player.rs` (GrainPlayer: the pick with its 16-entry recent list and collapse
|
||
rule, the per-block scheduler with the drift cut and timer × pitch in sustain, voices SndPlayer1 →
|
||
Resample → square-root GainFader → Send into the player's mono bus), `board.rs` (per-surface tuning,
|
||
the Bézier speed → position curve in retail's factored single-precision form, the A/B records, push
|
||
envelope, slews, the rocket record), `bed.rs` (2 trucks × players A/B with their chains HighPass →
|
||
LowPass → Gain → Pan2D1 → default bus; the rocket straight into the default bus; one title-wide
|
||
generator seeded with the image constants). `runtime.rs` renders the bed on the block clock after the
|
||
AEMS voices.
|
||
- Game (`SKATE_AEMS=1` only; the default path is unchanged): `native.rs` loads the MixMap and runs
|
||
`mixmap_frame` before the cue systems (fixed 1/60 s steps): inputs Master.in1–4 / Music.in1, 2, 5 /
|
||
Reverb.in5 = 32767, Pause.in0 while the menu is open, the local player's PlayerPhysics speed words
|
||
(image scales, clamped), no-contact, wheel count, brake and id 9 = 0, its two 3DObjPos blocks (board seen
|
||
from the camera, 0.25 m pull-back), Contacts.in1 on landings, the board owner's 0/4/6 pulses. The 5
|
||
emitter states are the MixMap Emitter instances: `emitters.rs` writes each state's 3-D input and posts
|
||
`c_emitter` with w1 = out4 × level, w2 = out8 × level, w3 = out0, w4 = out5 (pitch reader), w5 = out6.
|
||
`grain_bed.rs` drives the bed when the install has the whole recordings: surface routing (one sounding
|
||
truck, `cues::grain_for`), records from SkateBoard level(1)/(2)/pitch(3), chains from level(11)/(12)/
|
||
raw(0) and the graph-1 level ramp, the rocket above 35 km/h; the interim speed-band loop then stays
|
||
silent. Volumes: stream = master, AEMS voices × ambience, bed × effects.
|
||
- Setup (`audio_export.py`): `mixmap_file` copies `MixMapSK8.mxb` to `aems/` (`manifest.aems.mixmap`);
|
||
`grain_whole` keeps each member's whole decoded recording (`grains/<stem>.wav`) and raw `.grain`;
|
||
`grain_tuning` exports the vault's grain class, owner class and `Sk8::AudioSurfaceMap` exactly
|
||
(`manifest.grain_player`). All keys are optional, so the manifest version stays 5. Dev installs:
|
||
`.claude/skills/aems-port/tools/stage_grain_mixmap.py` adds them without a full setup run.
|
||
|
||
**Evidence.**
|
||
- **MixMap vs the PoC's port** (PR #4's MixMap run locally as a black box, `mixmap_golden`) **and our
|
||
Python reference**: 3 golden scripts, 1,500 evaluations, **87,000 / 87,000 output cells identical**
|
||
(`aems-port/tools/mixmap_compare.py`). golden1/2: ambience fade/crossfade, emitter distance/angle,
|
||
player speed and Doppler, pause/menu/reverb/HOM ducks, dt changes; golden3 (new): 10 Collision
|
||
instances, a Traffic drive-by with Doppler and sign-flip resets, the ADSR envelopes (F133/F137/F138 with
|
||
retrigger), F24, Jitter-driven sums, a dt change.
|
||
- Disc census: 14 slots; 547 input entries, 853 A, 635 B, 350 F, 240 C, 1044 E; 154 output blocks;
|
||
**247 controllers** (= the retail capture). Generated tables equal the image's (all 512/602/512/12/100
|
||
entries and the four float constants).
|
||
- **Grain picks: all 16 golden sequences** (A and B params × positions 0 / 0.25 / 0.63 / 1 × generator
|
||
from zero and from the image seed, 24 picks each) **and the 3 candidate tilings identical** to the PoC's
|
||
vectors; **positions bit-exact on all 31 POS rows** (concrete_rough_hard and default, 0–80 km/h) after
|
||
using retail's factored Bézier rounding.
|
||
- The 14 disc members parse (`tests/disc_grains.rs`): single-entry seek tables, pre-roll 384, stored
|
||
durations; `cues::grain_for` agrees with the install's own AudioSurfaceMap for every grain surface
|
||
(tags 0–127).
|
||
- Board outputs with our inputs (`examples/board_mix_probe.rs`): grain pitch 0.790 at rest, 0.864 at
|
||
1 m/s, 0.996 from 5 km/h (retail medians 3209 / ~3500 / ~4080 of 4096); high-pass 77 Hz and low-pass
|
||
24971 Hz (retail modal 77 / 24971 — 1109 Hz with id 9 = 32767, which is how id 9 = 0 for the local
|
||
player was confirmed); gain A 0.23–0.26 from 5 km/h (retail p90 7859 = 0.24).
|
||
- Whole bed headless (`examples/grain_bed_render.rs`, concrete_rough_hard): 6.6 grain starts/s after the
|
||
bind (retail modal 6/s for one truck), at most 4 voices, stereo RMS −40.7 dBFS at 5 km/h rising to
|
||
−33.3 dBFS at 40 km/h (the recording's own rise plus the MixMap).
|
||
- Unchanged: the evaluator oracle still 234/235 scripts identical.
|
||
|
||
**Verification run.** `cargo test -p skate-audio --release`: 63 unit + 3 disc-bank + 1 disc-grain + 3 DSP
|
||
oracle tests pass (2 ignored fit tests). `cargo test -p skate-game --release --bin skate3rust`: `game_audio`
|
||
40/40; the whole suite 359 passed with only the known
|
||
`pipelines_accept_valid_group_outputs_when_fingerprint_changes` failure. Pipeline tests
|
||
(`test_audio_formats`, `test_versions`, `test_setup_assets`) 34/34. All headless; **the bed and the new
|
||
emitter words need a listening check in game by the user (`SKATE_AEMS=1`).**
|
||
|
||
**Files.** New: `crates/skate-audio/src/mixmap/{mod,format,tables,keys,tests}.rs`,
|
||
`crates/skate-audio/src/grain/{mod,format,player,board,bed}.rs`, `crates/skate-audio/tests/disc_grains.rs`,
|
||
examples `mixmap_golden`, `board_mix_probe`, `grain_bed_render`, `game_audio/grain_bed.rs`. Changed:
|
||
`skate-audio` `lib.rs`, `runtime.rs`; `game_audio/{mod,native,emitters,library,skate_events}.rs`;
|
||
`tools/asset_pipeline/audio_export.py` (+ tests). Local tools: `aems-port/tools/mixmap_compare.py`,
|
||
`stage_grain_mixmap.py`; golden3 in `.local/audio-re/mixmap/`.
|
||
|
||
**Not modelled / open** (nothing invented):
|
||
- MixMap inputs without a game source yet stay 0: Jitter (random every frame in retail), VU meter,
|
||
Menu/NIS/HOM/Challenge flags, Contacts 2/6, Rail, OffBoard, HandGrabs, PlayerPhysics 3/5/6/11/12/13, the
|
||
skater-frame azimuths (camera frame used for both), relative speeds (no Doppler), the second player.
|
||
Retail's medians of gain A (0.06 at 5 km/h, 0.08–0.17 above) are lower than ours (0.23–0.26, near
|
||
retail's p90); the missing inputs (Jitter scales A63, for one) are the first suspects.
|
||
- Bed: turn intensity (state +204 not mapped), slope, the seam-pattern envelope and the special latch are
|
||
not wired, so player B (the turning / downhill layer) is silent; FrequencyShiftSsb (allpass
|
||
coefficients not recovered), graph 3 (clip + shelf copy), the wobble gains and the environment send
|
||
are not modelled; the fade curve is our equal-power form of retail's square-root code (exact
|
||
rsqrte/Newton rounding not reproduced); the `Class_rolling` surfaces still use grain stand-ins; in the
|
||
air the last member keeps playing (retail UNCERTAIN); soft-wheel members unused.
|
||
- Emitter positions reach the MixMap one frame late (inputs written in the cue systems, ticked at the
|
||
next frame's start).
|
||
- `mixmap-spec.md` §6.2 said PlayerPhysics.in9 = 32767 for the local player; the PoC's input writer and
|
||
the board's 77 Hz high-pass say 0. With 0 the ambience bed's speed duck (A[Player.5], up to −3 dB) is
|
||
active — the ambience beds are not native yet, so nothing changes there today.
|
||
|
||
### Player inputs and the first native player components (2026-10-02, `skate_audio::player`)
|
||
**Problem.** The native runtime left most MixMap inputs at 0 (Jitter, Contacts 2/6, Rail, OffBoard,
|
||
PlayerPhysics 3/5/6/11/12/13, relative speeds), the rolling bed had no turn, slope, seam or special
|
||
input (player B silent), and every player sound still came from the measured cue tables.
|
||
|
||
**Retail mechanism** (read in the TU3 recompilation; word tables cross-checked with upstream PR #4's
|
||
driver notes): per 60 Hz frame the audio-state bridge `sub_824B0DA8` fills one record per player; the
|
||
state controller writes PlayerPhysics (`sub_824B19C8`); every component *processes* (owner inputs,
|
||
posts, releases), the MixMap evaluates once, then every component *updates* its held packets from the
|
||
owner's outputs and redelivers them.
|
||
|
||
**Change** (`crates/skate-audio/src/player/`, engine independent, unit-tested):
|
||
- `state.rs`: the audio-state record (`AudioState`, retail offsets in the docs). The game fills it in
|
||
`skate_events::observe` (`audio_state`): ground speed, COM velocity/position (SystemReckoning),
|
||
board position/velocity, per-wheel contact / material (tag − 1, 143 none) / **seam pattern** (new
|
||
in `skate-core` `WheelLineState.seam_patterns`, tag bits 12..15, the part `board_ground.rs` used to
|
||
drop), turn (`animation_input.fields.turn` = Processed+2676), slope `+712` (pumping absorption),
|
||
KnownAir (state 200..300 with no wheel down) and our own air timer, the State52/54/59/60 flags
|
||
(brake, manual brake, bail, balance), trick active / hippy jump (the score packet's scorable id with
|
||
flag bits 24/25), feet in the deck box (Skeleton 600/601), soft wheels (wheel hardness
|
||
Processed+2764 < 0.5), latched grind family / material.
|
||
- `inputs.rs`: PlayerPhysics 0–14 (3 = 0 and 13 = 0, see below; 11 = the bail-camera byte held 110
|
||
frames after a bail — PR #4's capture shape, writer unidentified), Contacts (1 landing pulse, 2 the
|
||
landing class from the per-wheel air buckets of `sub_824B2350`: ≥ 1 s air → 32767, ≥ 0.62 s →
|
||
16000; 6 the landing-flag material), Rail 0/1, OffBoard 0.
|
||
- `jitter.rs`: `SFXObj_Jitter`, 24 bounded random walks from the vault (6 write inputs 0–5), own
|
||
instance of the title generator.
|
||
- `objpos.rs`: the 3DObjPos writer as retail binds it — 60010010 follows the skater's COM (with its
|
||
velocity), 60010020 the board; camera distance from the camera itself, frame-A azimuth from the
|
||
0.25 m pulled-back origin, horizontal-plane angles, **signed relative speeds** (Doppler) and the
|
||
sign-flip bits 31/30.
|
||
- `components.rs`: `Class_grind` (poster `sub_824C28B0`, constructor `sub_824AF8C8`, updater
|
||
`sub_824C39E0`: layer by family, the family-0 layer-1 companion, V/F levels per grind surface, speed
|
||
word capped at 9000, the updater's w10 = 1 quirk for family 0), `SenseOfSpeed_rattle` /
|
||
`SenseOfSpeed_wind` (`sub_824E7980` / `sub_824E7CB0`: rattle ≥ 30 km/h ground speed, wind ≥ 15 km/h
|
||
COM speed or 1 km/h while bailing), `Class_foot_drag` (`sub_824BB540` / `sub_824AF498` /
|
||
`sub_824BEEE8`: brake or manual brake, Contacts outputs 4/5/16/17/18/22, foot-drag surface).
|
||
- `grain/board.rs`: turn intensity (`sub_824C8588`: min(clamp(|COM v|·0.24, 0, cap)·turn, cap),
|
||
forced 0 by a latch, signed slew with the rise step when the target is above), the manual / trick
|
||
latches, slope levels (owner inputs 2/3, A79 = +244 mB on gain A downhill) and the seam-pattern gain
|
||
envelope (`sub_824CA448` / `sub_824CA318`: **one** linear ramp from 1 to a random gain per pattern
|
||
change, then hold — the grain spec's "random walk" corrected; only spidercrack has a wobble ≠ {1, 1}).
|
||
- `mixer.rs`: per-bank volume groups (world / player, our user volumes) and a voice snapshot for
|
||
diagnostics.
|
||
|
||
**Game side.** `game_audio/player_audio.rs` hosts it inside `native::mixmap_frame` (inputs → process →
|
||
tick → update). `grain_bed.rs` now feeds turn, special, downhill and the seam envelope into the records
|
||
and picks the **soft** members when the wheels are soft. With `SKATE_AEMS=1` the components run
|
||
(`SKATE_AEMS_PLAYER=0` keeps them off); their banks (GRINDS, sense_of_speed, FOOT_DRAG) load at start
|
||
in the player group (effects volume) and stay across map changes; the interim metal GRINDS loop, the
|
||
sense_of_speed bed cue and the foot-drag pieces are then silent. The default path (native off) is
|
||
unchanged. Setup: `audio_export.player_tuning` exports the AudioSurfaceMap rows, Jitter channels, seam
|
||
wobbles, grind levels (grind surface keys from the image table `0x82249F90`, as constants), wheel
|
||
landing buckets and — only with a TU3 image, so not in setup — the landing-flag materials; dev
|
||
installs: `.claude/skills/aems-port/tools/stage_player_tuning.py`.
|
||
|
||
**Two values read as "0 for the local player".** PlayerPhysics.in13 is |state+96 − `[G+0x2F078]`+32|:
|
||
`G+0x2F078` is the audio-state record array (player 0 first), so for the local player both are its own
|
||
COM velocity (PR #4 read it as a listener distance). In3 is a facing factor of two PhysOut slot-0
|
||
vectors we have not identified; only A25 reads it, as a factor of the combo emphasis (Music.in3 = 0
|
||
outside combos).
|
||
|
||
**Evidence.**
|
||
- **Wind level vs retail**: SenseOfSpeed level(4) (= wind w7) at matched COM speeds against PR #4's
|
||
retail medians: 25 km/h 339 / 343 (−0.10 dB), 30 km/h 693 / 730 (−0.45 dB), 35 km/h 1239 / 1194
|
||
(+0.32 dB), 40 km/h 2074 / 2017 (+0.24 dB) — test `wind_level_matches_the_retail_medians`.
|
||
- **Components through the real banks, MixMap and voice graph** (test
|
||
`components_play_their_retail_banks`, 3 s each; gain = master × dry): Class_grind metal 6 m/s
|
||
median 0.018 / max 0.199, ledge (family 0, two layers) 0.026 / 0.237 — retail Class_grind 0.004–0.11
|
||
/ ≤ 0.28 (session 20261001_211347 — deleted, re-verify on clean data), GRINDS 0.004 / p90 0.027 / max 0.083 (all_20261002_163809);
|
||
wind + rattle 55 km/h 0.033 / 0.132, 40 km/h 0.004 / 0.015 — retail sense_of_speed 0.003 / p90 0.093
|
||
/ max 0.387; foot drag 4 m/s 0.023 / 0.057 — retail FOOT_DRAG 0.006 / p90 0.075 / max 0.281.
|
||
- Unit tests: Jitter walk bounds and a hand-computed step; 3DObjPos signs and flip bits; landing
|
||
buckets; grind posts (one layer, family 0 two layers, release, speed cap); rattle/wind thresholds and
|
||
words; foot drag words; turn intensity cap/slew/latch; latches; slope; seam envelope draw and ramp.
|
||
- Python: `PlayerTuning` export test (leaf channels, parent chain, zero-block patterns, grind defaults).
|
||
- Unchanged: evaluator oracle, MixMap oracle, DSP oracle tests.
|
||
|
||
**The bed's gain A, investigated** (test `bed_gain_a_with_the_full_inputs`, straight rolling, camera
|
||
3.5 m behind and 1.4 m above):
|
||
|
||
| km/h | level(1) before (no Jitter) | level(1) with Jitter p10 / p50 / p90 | gain A, turn 0 | gain A / B, turn 0.5 |
|
||
|---|---|---|---|---|
|
||
| 5 | 0.228 | 0.156 / 0.187 / 0.217 | 0.187 | 0.156 / 0.094 |
|
||
| 15 | 0.239 | 0.192 / 0.212 / 0.230 | 0.212 | 0.127 / 0.261 |
|
||
| 30 | 0.250 | 0.231 / 0.243 / 0.249 | 0.243 | 0.146 / 0.301 |
|
||
| 45 | 0.253 | 0.224 / 0.242 / 0.251 | 0.242 | 0.146 / 0.283 |
|
||
|
||
- Jitter (A63, A69: small ducks scaled by Jitter.in0/in2/in3) lowers gain A by 1.7 dB at 5 km/h and
|
||
0.4 dB from 30 km/h; the p90 under Jitter (0.217–0.251) sits on retail's level(1) p90 of 0.240.
|
||
So the MixMap side matches retail.
|
||
- Retail's lower medians (0.06 at 5 km/h, 0.07–0.17 above, PR #4's table) are of the *record* gain:
|
||
level(1) × (1 − max(I, Bk)) × special × seam, pooled over a session with turning, braking, manuals
|
||
and mixed surfaces; a turn of 0.5 alone takes gain A to 0.13–0.15. The 163809 session's per-start
|
||
grain levels (A and B pooled, idle and air time included) are lower still (asphalt_smooth_hard
|
||
median 0.000 / p90 0.032; concrete_rough_hard 0.009 / 0.071), as expected when every start at rest
|
||
or in the air counts. **Not settled:** a straight-roll retail measurement at turn 0 (GREC hook, grain
|
||
spec §5.3) would close it; we invented no factor.
|
||
|
||
**Not modelled / open:** `Class_wheels_skid` (needs slip `+232`: the vault pair 45 / −0.75 of holder
|
||
`0xBA9837A6CF4C26ED` does not fit PR #4's formula as written — check `sub_82772E18`), Class_Seams,
|
||
squeaks, rolling rattle, Class_rolling, cloth/body slide, footsteps, Class_Flips / Treatment, and the
|
||
SPLC Contacts (pops, landings, touchdowns: a Splice player plus the unrecovered pop bank pick
|
||
`sub_824B9AD8`) — their interim cues play. Contacts.in6 needs the TU3 material key table (dev only).
|
||
Grind SPLC starts/pieces (collision manager `sub_824D2318`) stay interim. The bool-class word of
|
||
Class_grind (a global setting) is 0. Owner input 5 (heading rate), skid input 1, PlayerPhysics 4/5 and
|
||
Rail.in1 reach no MixMap output. Retail's B layer is silent ≥ 40 km/h in PR #4's capture; ours would
|
||
sound with a turn input there (level(2) 0.72 at 40 km/h) — check in game.
|
||
|
||
**Later stages (data from the main session):** traffic (horn `sub_824D6BE8`, skids `sub_824D7440`,
|
||
engine `aud_traffic_engine`; vehicle positions in VEHSTATE lines for Doppler validation; no special
|
||
skitch sounds) and pedestrian speech (not AEMS: streamed from `livingworldspeech.big` with
|
||
`*_Events.evt` / `hdr.big` / `sth.big` event tables; reaction → clip map in
|
||
`.local/research/npc/speech_161849.txt`; summary `.claude/todo/audio-world-npc.md`,
|
||
`.claude/notes/npc-livingworld-re.md` §5b–§6e). AI skaters are full skaters with their own board
|
||
objects (voice budget question). Retail sessions with per-bank levels: `all_20261002_163809`, `all_20261002_164620` (clean trace; traffic horn / skid /
|
||
engine-class and ped-footstep levels for the world stages).
|
||
|
||
**In-game listening checklist** (`SKATE_AEMS=1`; `SKATE_AEMS_PLAYER=0` to hear the interim cues
|
||
instead):
|
||
1. Roll straight at 10–30 km/h on asphalt: the bed is 1–2 dB quieter at a crawl than before (Jitter)
|
||
and breathes slightly.
|
||
2. Carve left/right at ~15 km/h: a second, slightly different-sounding layer (player B) comes in with
|
||
the turn and the main layer dips; straight again → B fades out within ~10 frames.
|
||
3. Manual: the bed drops (special gain 0.65) and the turn layer stays off until four wheels are down.
|
||
4. Roll down a slope / pump a transition: when `+712` (our pumping absorption; what retail means by it
|
||
is UNCERTAIN) goes negative the bed gets a little louder (A79, up to +244 mB) and B joins in —
|
||
listen for a bed that swells on every pump (that would mean the mapping is wrong).
|
||
5. Ride over a spidercrack pavement: one dip of the bed (to 0.6–0.8) per pattern change.
|
||
6. Change the board's wheels to soft (hardness < 0.5): the soft grain members play.
|
||
7. Grind a metal rail and a concrete ledge: GRINDS loops (native) plus the interim start / scrape
|
||
pieces; the old interim metal loop is gone.
|
||
8. Ride fast (> 30 km/h): rattle comes in; the wind from 15 km/h of body speed, louder with speed;
|
||
bail at speed: wind keeps blowing while tumbling (1 → 10 km/h range).
|
||
9. Brake with the foot (and manual brake): native foot drag instead of the interim pieces (the interim
|
||
one was confirmed by ear — compare).
|
||
|
||
**Files.** New: `crates/skate-audio/src/player/{mod,state,tuning,inputs,jitter,objpos,components}.rs`,
|
||
`crates/skate-game/src/game_audio/player_audio.rs`, `.claude/skills/aems-port/tools/stage_player_tuning.py`.
|
||
Changed: `skate-core` `physics/board_ground.rs` (+ test), `skate-audio` `lib.rs`, `mixer.rs`,
|
||
`runtime.rs`, `mixmap/keys.rs`, `grain/board.rs`, `examples/grain_bed_render.rs`;
|
||
`game_audio/{mod,native,grain_bed,library,skate_events}.rs`; `tools/asset_pipeline/audio_export.py`
|
||
(+ test).
|
||
|
||
**Verification run.** `cargo test -p skate-audio --release`: 77 unit + 3 disc-bank + 1 disc-grain + 3 DSP
|
||
oracle tests pass (2 ignored fit tests). `cargo test -p skate-game --release --bin skate3rust --
|
||
game_audio::` 42/42. `skate-core` 621 pass; the 2 failures (`predictive_contacts_and_retention_match_full_scan_for_every_primitive`,
|
||
`a_moving_group_8_body_reaches_native_impact_feedback_for_a_stationary_actor`) fail identically with
|
||
our change reverted (pre-existing in release builds). The whole `skate-game` suite: 361 passed, only the
|
||
known `pipelines_accept_valid_group_outputs_when_fingerprint_changes` failure. Pipeline tests
|
||
(`test_audio_formats`, `test_versions`, `test_setup_assets`) 35/35. All headless;
|
||
**needs the listening check above.**
|
||
|
||
### Board contacts, skid and squeaks; the end-to-end PoC comparison (2026-10-02, `skate_audio::splice`, `player::contacts`)
|
||
**User listening check of the previous stage** (native build staged 16:59, `SKATE_AEMS=1`): "definitely closer,
|
||
but it still needs tuning"; rolling "wasn't quite right as far as sync and being too loud"; grinds "sounded ok but …
|
||
not at the right octave … just wasn't the same sound"; overall "pretty underwhelmed". Decision: "the PoC sounded
|
||
closer, use it as a reference" → the end-to-end PoC comparison harness below. A note on the reference: on one launch
|
||
of the PoC build the wheels-over-cracks (seam) sounds played louder/harsher, rattle-like; on the next launch at the
|
||
same spot they were the normal, quieter version; not reproduced since — a first-play effect of the PoC (it decodes XMA
|
||
into a PCM cache at run time). **Requirement recorded for our port:** the first trigger of every sample must sound like
|
||
the later ones (decoded before use, same gain/filter path): the native banks decode their WAVs when they load, and
|
||
`splice::tests::the_first_trigger_of_a_sample_renders_like_the_next` renders a sample twice, bit-identical.
|
||
|
||
**Problem.** Pops, landings and touchdowns, powerslides and squeaks still came from the measured cue tables; the
|
||
native build had no Splice (SPLC) player at all, so with the native components on the board's impacts were the
|
||
interim samples at interim levels, and the rolling bed dominated the mix.
|
||
|
||
**Retail mechanism** (read in the TU3 recompilation; addresses are facts, the code is ours):
|
||
- **Splice player** (`sub_82975700` start, `sub_829757D0` voices, `sub_82975A60` / `sub_82975CC8` draws,
|
||
`sub_82975B08` per frame, `sub_82976860` voice frame, `sub_82976360` start, `sub_82976CF0` / `sub_82976FF0` fades,
|
||
`sub_82976DD8` picks): an id below the bank's record count is a record, then containers (one record picked), past
|
||
both the last record. One voice per group; a member by pick mode (0 random, 2 shuffled halves from the disc's state
|
||
word, else sequential), skipped unless rand/32768 ≤ its probability (`+64`). Member fields (corrected): `+4` gain,
|
||
`+8` pitch, `+16` pan offset (× block[4]; −127 = no panner), `+20/+52` delay, `+24` start, `+28` length, `+32/+36`
|
||
fade-in end / fade-out start, `+40` curve (0: 1−cos, 1: (1−cos)², 2: linear, 3: sin, 4: sin² over π/2), `+44` gain
|
||
spread (gain × [s, 1/s], linear in 2U−1), `+48` pitch randomisation. Record `+8` gain and `+12 + U·+16` pitch
|
||
factor. A voice stops when its elapsed time (advancing by the previous frame's pitch × dt) passes
|
||
`+8 / pitch × +28 + 0.16` or the sample ends. The CRT `rand()` (MS LCG) drives every draw.
|
||
- **Contacts** (`SFXObj_Contacts`; vault class `0xC26949FCB638A2CA` `default`): frame order `sub_824B8218` →
|
||
manual landing `sub_824BB330` → touchdowns `sub_824B86E0` → process `sub_824B90D8` (pop `sub_824B9CC8` on entering
|
||
the air with an audio trick other than −1/31/32/35/36; landing `sub_824BA630`) → update `sub_824BE1B8` after the
|
||
tick. Pop: selector 2/1/0 by the jump velocity `+468` (|Air+112|/2.65 while Air440) over 0.42/0.25, ids
|
||
1097–1099 (1103–1105 on a hollow surface), gain trunc(level(2) × [0.5, 0.75, 1.0]) (hollow [0.58, 0.76, 0.99]);
|
||
roll 1111 above 4 m/s; ollie 1096 and landing impact 1095 for the local player (levels 12/13). Touchdowns: per wheel
|
||
the conditioner's landed latch (`sub_82772FD8`) and the bridge bucket `+448`; kind 0 four wheels, 1 a pair from
|
||
nothing, 2 the last pair, 3 one wheel, 4 manual landing; set = table[tier][3·kind + variant] (tier `sub_824BA310` =
|
||
2·hollow + soft wheels; the second voice of a bucket-2 landing reads tier 0), gain trunc(level(3) × K[tier][…]).
|
||
The block every sound gets: [level/32767, pitch(1)/4096, raw(0)·360/65535, dt, 1 (local 3-D), 1].
|
||
- **Class_wheels_skid** (`sub_824C7438` / `sub_824C72F0` / `sub_824AF678` / `sub_824C7A20`): held while the
|
||
predicate holds — any slip (with a wheel down; slip `+232` = clamp((|deck velocity · deck Ri| + 0.75)/45, 0, 1),
|
||
`sub_82772E18`, vault holder `0xBA9837A6CF4C26ED` 45 and −0.75: **settled**, so straight rolling keeps a skid at slip
|
||
word 1), not grinding (unless family 4), no audio trick but a grab; without slip the revert flag `+690` / counter.
|
||
w10 = clamp(counter + trunc(90 × slip), 0, 90).
|
||
- **Class_Squeaks** (`sub_824C7738` / `sub_824AFF48` / `sub_824C7DD0`): both feet in the deck box and > 1 wheel, posted
|
||
once |deck tilt `+264`| × 114.59 ≥ 15, a tilt sign change reposts; w9 = deck spin |`+488`| / 1.5 × 1000 (< 50 → 0).
|
||
|
||
**Change.**
|
||
- `crates/skate-audio/src/splice/` (`format.rs` patch tree, `mod.rs` player, tests): the Splice player; its voices
|
||
are mixer voices (`Mixer::open_direct` / `set_direct`: SndPlayer1 → Resample → Gain → Pan2D1 → default bus).
|
||
`Runtime::splice`, `Runtime::splice_host()`.
|
||
- `player/contacts.rs`: the Contacts component (pop, roll, ollie, landing impact, touchdowns with the second voice,
|
||
manual landing) behind the `SpliceHost` trait. `player/components.rs`: `Skid`, `Squeaks`.
|
||
- `player/state.rs`: `jump_velocity` (+468), `audio_trick` (+348, resolved by the host from the scorable through the
|
||
vault's eSk8AudioTricks), `scorable`, `slip` (+232), `revert` (+690), `deck_tilt` (+264), `deck_spin` (+488);
|
||
`state::slip`, `state::jump_velocity`. `tuning.rs`: `audio_tricks`, `name_hash` (lookup8).
|
||
- Game: `player_audio.rs` runs the contacts (when `Skate_Collisions`' patch tree is in the install) and the skid /
|
||
squeaks; `native.rs` loads the Splice banks with their WAVs at start; `skate_events.rs` fills the new state fields
|
||
(deck Ri / At from the part transform) and silences the interim pop layers, landing impacts and powerslide pieces
|
||
when the native ones run; `library.rs` reads `aems.splice` and `player_tuning.audio_tricks`.
|
||
- Setup: `audio_export.splice_trees` writes each SPLC bank's patch tree to `aems/<stem>.splc` (`manifest.aems.splice`;
|
||
optional key, version unchanged); `player_tuning.audio_tricks`. Dev install: `aems-port/tools/stage_splice.py`
|
||
(manifest backup `.local/audio-re/audio_manifest.before-splice-stage.json`) and `stage_player_tuning.py`.
|
||
- **Audio state log** (`game_audio/state_log.rs`): `SKATE_AUDIO_STATE_LOG=<path>` writes one TSV row per frame in the
|
||
e2e scenario columns plus elapsed ms and board / COM positions (buffered, flushed every 6 rows, no cost when unset,
|
||
with or without `SKATE_AEMS`). `scenarios.py --from-log LOG --cut A-B NAME` turns a window of it into a scenario both
|
||
stacks render.
|
||
|
||
**End-to-end comparison with the PoC** (`.claude/skills/aems-port/tools/e2e/`: `scenarios.py` writes per-frame
|
||
situations; our probe `game_audio::e2e::e2e_render` and the PoC's local probe render them headless to raw 6-channel
|
||
f32; `compare.py` folds both the same way and compares per segment: RMS, octave bands, centroid, pitch, onsets).
|
||
Ours vs PoC, RMS dB of the common fold:
|
||
|
||
| situation | before this stage | after |
|
||
|---|---|---|
|
||
| straight roll 10 / 20 / 30 / 45 km/h | +0.4 / +0.1 / 0.0 / 0.0 (bands ≤ ±1 dB) | same |
|
||
| speed sweep 0 → 40 → 0 km/h | +2.3 … −0.7 (+1.3…+2.3 below 8 km/h) | same |
|
||
| ollie at 20 km/h: air / after the landing | −24.4 / −9.2 (no pop, no landing) | −1.0 / 0.0 |
|
||
| powerslide 20 → 8 km/h | −9.6 / −8.9 / −4.6 (no skid) | +0.4 / 0.0 / −1.0 |
|
||
| foot brake | −1.0 … −1.3 | −0.7 … −1.1 |
|
||
| carve ±0.8 turn at 20 km/h | +3.4 | +3.4 |
|
||
| manual at 15 km/h | −4.3 | −4.3 |
|
||
| metal rail / concrete ledge grind | −3.1 / +1.7, different spectrum | +0.7 / +2.0, different spectrum |
|
||
|
||
Causes of the remaining differences, each checked against retail rather than the PoC:
|
||
- **Grinds:** the PoC plays every grind as surface 2 (its "user-accepted interim" policy, GRINDS samples 94/95);
|
||
retail's GRINDS voices in all_20261002_164620 / 20261001_211347 / long_dt_c (the last two deleted: re-verify on
|
||
clean data) are samples 13/14, 20–23, 55/56
|
||
(= our surfaces 0/1 and 4–9 through the program, `aems-port/tools/grind_census.py`), never 94/95, with pitch ratio
|
||
p50 0.98–1.00 and LPF 24971 / HPF 0 like ours (`tools/retail_voices.py`). So **pitch and sample choice are retail;
|
||
the PoC's grind is not**. Retail's grind sound is mostly the collision manager's Splice layers (ledge start 954 with
|
||
464 events, 968, 958, 969, 876–878 in 164620, levels 0.1–0.4) with GRINDS underneath (p50 0.007, p90 0.15): those
|
||
layers are the next port (below) and the likely reason ours "isn't the same sound". **Corrected in the next
|
||
section:** 954 / 955 / 956 are body-impact (skin / denim / arm) sounds, mostly outside grinds; the collision
|
||
manager's grind part is one start contact (board / truck against the grind material).
|
||
- **Carve:** our B layer follows the turn input (`sub_824C8588`, from the retail code); the PoC's B record stays 0 in
|
||
this harness even with its SkateBoard level(2) at 0.68–0.71. Retail's capture medians of gain B (0.01–0.10) are
|
||
pooled over mostly straight frames. Not changed; checking it needs the GREC hook below.
|
||
- **Manual:** ours applies the grain spec's special gain 0.65 to gain A in a manual (−3.7 dB); the PoC does not. Kept
|
||
(retail code reading); a GREC session settles it.
|
||
- **Rolling level ("too loud"):** the native bed equals the PoC's to ±0.4 dB at every speed, so the bed itself is not
|
||
louder than the PoC. What differs is the host: our stereo fold is the title's table (0.707·L + 0.5·C + 0.5·Ls, spec)
|
||
while the PoC trims its downmix (0.6 × (L + 0.707·C + 0.5·Ls), not retail) — ours is 1.4 dB (front) to 4.4 dB
|
||
(surround) hotter for the same mix — and until this stage the impacts that retail puts 17–23 dB over the bed were
|
||
missing, so the bed was all there was. Against the retail capture (`tools/e2e/retail_windows.py`) the bed cannot be
|
||
isolated: the capture's floor at rest is −32 dBFS (ambience, emitters) and only three clean straight-roll windows
|
||
exist; the per-voice levels agree (retail grain voices p50 0.15 / p90 0.27, ours gain A 0.21–0.24).
|
||
- **"Sync":** nothing headless shows a lag (our and the PoC's envelopes follow the same speed sweep); the in-game
|
||
path adds the device buffer and the bed is fed per rendered frame. The audio state log replays real play into both
|
||
stacks to look at this with the user's own runs.
|
||
|
||
**Contacts against retail** (all_20261002_164620 report, per-sample audible level medians): roll 1111 retail
|
||
0.13–0.18, ours 0.16–0.24; pop selector 1 (1098) retail 0.07–0.40, ours 0.15–0.35; touchdown kind 0 (1052) retail
|
||
0.42–0.64, ours 0.22–0.48. Ollie 1096 and landing impact 1095 are silent outside combos in both: their outputs 12/13
|
||
carry the envelope F[Global.135] triggered by Music.in3 (the combo emphasis), which no one writes yet — retail's
|
||
medians (0.01–0.12) are consistent with that.
|
||
|
||
**Not modelled / open:** the owner buses of the Splice voices (eEQChain presets, Contacts levels 14/15 as send levels)
|
||
— they mix dry; the landing's collision-manager pair contact; foot taps / scuffs on the deck (`sub_824B95A0`,
|
||
board knocks 1112/1115 — frequent and loud in retail, next with the collision manager); the grind start contact
|
||
`sub_824BB0E0`; body / deck impacts; Music.in3 (combo emphasis); `+308`, `+720`, `+814` (not published); the slip's
|
||
B40+16 ring. Still interim: seams (`Class_Seams`, `sub_824C14C8` / `sub_824C1698` / `sub_824C1CA0` grid / `sub_824C1F18`),
|
||
rolling rattle, `Class_rolling`, footsteps, cloth / body slide, flips / treatment, the collision manager
|
||
(`sub_824D2318`, `sub_82486EF0` posts from landing, grind start, body and deck impacts).
|
||
|
||
**Verification run.** `cargo test -p skate-audio --release`: 90 unit + 3 disc-bank + 1 disc-grain + 3 DSP oracle
|
||
(2 ignored fit tests). `cargo test -p skate-game --release --bin skate3rust`: 362 passed, only the known
|
||
`pipelines_accept_valid_group_outputs_when_fingerprint_changes` failure (game_audio 43/43). `cargo test -p skate-game
|
||
--release --no-run` builds every target. Pipeline tests (`test_audio_formats`, `test_versions`, `test_setup_assets`)
|
||
37/37. All headless. **Needs the user's listening check** (checklist below).
|
||
|
||
**In-game listening checklist** (`SKATE_AEMS=1`; `SKATE_AEMS_PLAYER=0` for the interim cues):
|
||
1. Ollie on concrete at ~20 km/h: a crack (pop) at take-off with a short roll; landing on four wheels: one impact (plus
|
||
a heavier second layer after ≥ 1 s of air). The interim pop/landing layers are gone.
|
||
2. Land on two wheels then the other two (nose/tail first): two separate touchdowns.
|
||
3. Manual, then put all four wheels down: one manual-landing touchdown.
|
||
4. Ollie on wood (a hollow surface): a different, hollower pop and touchdowns.
|
||
5. Powerslide: skid noise that follows the slide (louder the more sideways), with board squeaks when both feet are on
|
||
the board and the deck tilts past 15°; straight rolling has no audible skid.
|
||
6. Compare the overall balance: impacts should now sit well above the rolling bed.
|
||
7. Optional: play with `SKATE_AUDIO_STATE_LOG=<file>` set so we can replay your exact runs headless.
|
||
|
||
**Ready to retire after that check:** `cues::POP`, `cues::POP_TAIL`, `cues::LAND_NORMAL` / `LAND_HOLLOW` /
|
||
`LAND_KIND_CHANCE` / `land_tier` / `land_scale` and `cues::LAND` (the native contacts replace them),
|
||
`cues::POWERSLIDE` / `POWERSLIDE_SHUFFLE`; `cues::LAND_CLOTH` and the board-down / bail / step / splash cues stay.
|
||
|
||
**Next stage:** the collision manager (Splice layers of grinds, landings, impacts; then grind start, foot taps), seams,
|
||
rolling rattle, `Class_rolling`, footsteps, cloth / body slide, flips / treatment; Music.in3.
|
||
|
||
**Files.** New: `crates/skate-audio/src/splice/{mod,format,tests}.rs`, `crates/skate-audio/src/player/contacts.rs`,
|
||
`crates/skate-game/src/game_audio/{e2e,state_log}.rs`, `.claude/skills/aems-port/tools/{e2e/scenarios.py,
|
||
e2e/compare.py, e2e/retail_windows.py, retail_voices.py, grind_census.py, splc_fields.py, vault_fields.py, img.py,
|
||
stage_splice.py}`. Changed: `skate-audio` `lib.rs`, `mixer.rs`, `runtime.rs`, `player/{mod,state,tuning,inputs,
|
||
components}.rs`; `game_audio/{mod,library,native,player_audio,grain_bed,skate_events}.rs`;
|
||
`tools/asset_pipeline/audio_export.py` (+ tests). The PoC probe (`player_audio/e2e.rs` in the PoC worktree) is local.
|
||
|
||
### Collision manager, wheel spin, foot taps; the second listening test and the retail bed data (2026-10-02, `player::collision`, `player::wheels`)
|
||
**User's second listening test** (native build 18:02, `SKATE_AEMS=1`, relayed by the main session): (1) carving "too
|
||
easily triggered, just turning a little bit causes them to trigger"; (2) background ambience and noises like cars "a
|
||
little loud and don't sound like they're in the distance enough"; (3) "when jumping the rolling sound continues to
|
||
play"; (4) "manuals sound completely wrong"; (5) falling in water "almost right, maybe a tiny bit too loud"; (6)
|
||
grinding "still not in sync properly and doesn't play out the continuous grinding noise as in retail"; (7) "after
|
||
riding and then picking up the board, the riding noise continues too long"; (8) the put-down board sound "much better
|
||
but not quite right; it should sound like wheels hitting the ground and whatever effects the original engine uses
|
||
aren't being recreated".
|
||
|
||
**Validation data used.**
|
||
- Retail **GREC** session `all_20261002_180430` (11 min, the user playing the recomp; hook on `sub_824C6BD8` added by
|
||
the main session from our spec, tool `.claude/skills/aems-port/tools/grec_level.py`; credit: skate3recomp by
|
||
@mchughalex, run locally): per-frame grain records A/B of both trucks, turn intensity, brake slew, state words.
|
||
- Our engine's **real-play log** `.local/audio-state-logs/state_20261002_181631.tsv` (`SKATE_AUDIO_STATE_LOG`, the
|
||
user's own 4 min of play), cut with `scenarios.py --from-log` into `log_jump`, `log_ollie`, `log_manual`,
|
||
`log_grind`, `log_pickup`, `log_carve` (`.local/audio-re/e2e-log/`) and rendered headless.
|
||
- Clean retail level sessions only (`all_20261002_163809`, `all_20261002_164620`, `all_20261002_180430`); the
|
||
sessions with malformed trace lines were deleted by the user (see "re-verify" notes in this document).
|
||
|
||
**The bed's low-speed level ("~5–6 dB too loud") — not confirmed.** The pooled straight-roll medians (`grec_level.py`:
|
||
0.104 at 5–10 km/h, 0.114 at 10–15, 0.188 at 15–20, 0.233 at 30–35) mix in frames the bed is meant to be quiet
|
||
in: the `+336` word (brake, push) and the `+340` word (balance, **grinding**, trick) are not filtered, a paused game
|
||
(material 4 at 25–30 km/h: speed fixed at 7.9362 m/s for ~10 s, gain 0), the seam envelope (`+1456` 0.74–0.98 on
|
||
material 1), a second board owner (`40C34020`, material 65) and Doppler moments (pitch A 1.18–1.24). On **clean**
|
||
frames (`.claude/skills/aems-port/tools/grec_clean.py`: four wheels, |turn| ≤ 0.02, I/Bk ≈ 0, both words 0, 30 settled
|
||
rows, gain ÷ seam envelope) retail's level(1) is 0.219 (material 2, 5–10 km/h), 0.21 (material 40, 10–15), 0.229–0.244
|
||
(20–30), 0.245–0.25 (30–50) — our MixMap gives 0.231–0.256 at the test camera (3.5 m), i.e. within ~1 dB (Jitter −0.4
|
||
dB). On clean carves A/(1−I) = 0.20–0.27 (= level(1)) and B/I = 0.61–0.83 (ours level(2) 0.62–0.78): the bed formula
|
||
is retail's. Not changed. Open: the board's camera distance (B[Player.2] roll-off: 5 m −0.6 dB, 7 m −1.8, 10 m −3.9) is
|
||
not in GREC — adding the `0x60010020` block's input 1 to the hook would settle the residual. Analysis:
|
||
`.local/audio-re/grec_180430_clean.txt`.
|
||
|
||
**(1) Carving.** Our turn intensity follows `sub_824C8588` exactly: I vs |turn input| medians ours / retail 0.1 →
|
||
0.06 / 0.066, 0.3 → 0.18 / 0.185, 0.5 → 0.24 / 0.30, 1.0 → 0.50 / 0.60 (cap 0.8 vs retail p90 0.99 on some
|
||
surfaces). The difference is upstream: **our turn input (`Turn` animation attribute, ProcessedPhysIn+2676) is almost
|
||
binary** — in the user's log 29 % of riding frames sit at |turn| = 1.0 and 7 % in between, retail 16 % / 25 %; a stick
|
||
movement takes our value 0 → 0.5 → 0.75 → 1.0 in three frames. After the stick returns our I decays for ~13 frames
|
||
(retail's input decays through the middle values, so retail's I is 0 at turn 0: p90 0.0 vs ours 0.196). That is the
|
||
animation side (the `Turn` scalar our animation graph publishes), not audio; reported to the main session. No audio
|
||
change.
|
||
|
||
**(3) Rolling in the air / (4) manuals: `SFXObj_Wheels` was missing.** In the air our bed goes silent within ~5
|
||
frames (the MixMap's no-wheels duck F[Player.0] on level(1), confirmed headless on `log_jump`: gain A 0.25 → 0 in 3
|
||
frames). What kept playing was the interim `AIR_WHEELS` loop — `Whls_spins_Jump_1` from its beginning at a constant
|
||
level for the whole air time. Retail (`sub_824CD6F8` / `sub_824CDC70` / `sub_824CDD28`, our port `player/wheels.rs`):
|
||
a **spin-down recording started part-way in**, t0 = 14 s × (1 − clamp01(speed / 50 km/h)) (vault `0xC1831BDB6CB1B1EA`
|
||
`0x03B710C80E1AC13E`), slot 0 in the air, slot 1 **while balancing (manuals)**, the jump and manual recordings
|
||
alternating, gain Wheels level(1) (level(5) while balancing), pitch(2); stopped on landing / when the manual ends.
|
||
Retail levels (164620): `Whls_spins_Jump_1` 92 starts median 0.240, `Whls_spins_Man_1` 81 / 0.264. Ours on the user's
|
||
log: manual 0.20–0.21 (level(5)), air 0.21–0.24 after the pop. In manuals the bed itself matches retail (GREC manual rows gain A
|
||
0.141–0.157 at 10–40 km/h; ours 0.13–0.15); the missing spinning wheels were the wrong part. The +150 Hz frequency
|
||
shift of the special state is still not modelled.
|
||
|
||
**(6) Grinding: the collision manager** (`CSTATEMGR_Collision`, our port `player/collision.rs`; full mechanism in the
|
||
module docs): `sub_82486EF0` posts a 48-byte message (materials A/B, impact tiers, position, two contact levels) to an
|
||
LRU router over ten slots; each slot's `SFXObj_Collision` (`sub_824D1E00` / `sub_824D1F68` / `sub_824D2318`) starts one
|
||
Splice voice per material — the sample from the material's AudioSurface record (layout read from the disc schema,
|
||
`tools/vault_layout.py`) by its **kind** (the TU3 table at `0x8302D6E8`: 0 Skate_Collisions, 1 Skate_Metal, 2
|
||
HOM_Set_1), its tier and the other material's collision class — and updates it from the slot's Collision controller
|
||
outputs (category level 12..21, pitch 1/22, azimuth from the slot's 3-D block at the message position). Contact levels
|
||
`sub_82496C58` (the 13E20D39 window record), impact bands `sub_82497088` (the 7DAFF70B record). Ported posters:
|
||
- **grind start** `sub_824BB0E0`: truck 96 (board 95 for families 1, 2, 5) against the grind material, tier by the
|
||
last grind impact `+228` over Class_grind's 0.25 / 0.5, 0.5 s cooldown;
|
||
- **landing pair** `sub_824BA630`: on a landing-flag material only (66 materials: the metals and a few others), board
|
||
95 against the first landed wheel's material, tier by the latched air time / 0.4 over 0.1 / 0.3, levels × 0.65 / 1;
|
||
- **deck impact** `sub_824BD000`: board 95 (113 on foot or bailing) against the deck contact's material, each by its
|
||
impact band on `+668` = max of the last 4 clamp01(|deck acceleration · deck normal| × 0.00125); a band-2 hit posts
|
||
a second message at tier 1 × the window record's `+40`; 6-frame cooldown. Needs the deck contact's tag:
|
||
`skate-core` `BoardGroundState.part_audio_surfaces` (CollisionInfo+4/+8/+12, + test).
|
||
**Correction of the previous section:** Skate_Collisions sounds 954 / 955 / 956 / 947 / 948 are the **skin / denim /
|
||
arm / leg** body-impact sounds (materials 107, 108/109, 100, 99), posted by the body-impact posters (`sub_824BC188`,
|
||
ragdoll `sub_824E3BE0`); only 29 of 464 events of 954 in 164620 fall within 1.5 s of a GRINDS voice. The interim
|
||
grind start (954) and the re-fired "scrape pieces" (955 every 63 ms) were body sounds, which is part of why grinds were
|
||
"not the same sound"; with the native contacts they are now silent. Retail's grind = Class_grind's GRINDS loop
|
||
(native since the previous stage) plus this start contact. Retail pitch of the start samples (163809 MOD lines):
|
||
streams 690–692 p10 0.44 / p50 0.61–0.95 — the material's pitch word 2096 (0.51) shows in retail too. The body
|
||
posters are **not ported**: they need the skeleton's per-region contacts (Collision+80..195, `sub_82BD60C8`), which
|
||
the engine does not compute. "Sync" of the continuous grind layer: GRINDS starts on the grind's first frame in the
|
||
log replay (`log_grind`).
|
||
|
||
**Foot taps and scuffs** (`sub_824B95A0` → `sub_824B9508` / `sub_824B9268`, `sub_824B9948` / `sub_824B97A8`, updates
|
||
`sub_824BEBD8` / `sub_824BF4A8`): a foot coming back into the deck box (Skeleton 600/601) after more than 25 ms out
|
||
taps the deck — first foot 1112–1114, second 1115–1117, both 1118–1120 by toe speed (≥ 2 / ≥ 5 m/s, Skeleton+196/+212),
|
||
hippy-jump set 1176–1178 — at Contacts level(8); a foot in the box moving faster than 0.35 m/s across the deck (max |x|,
|
||
|z| of its local velocity) scuffs it (`sk8_foley` 95 / 94) at level(9). Retail (164620): board knocks 1112 ×152 / 1115
|
||
×132 (sample medians 0.11–0.41), sk8_foley 94/95 ×434/×488 (0.06–0.25); ours on the log 0.2–0.3. The engine already
|
||
publishes the toe velocities (`FootPhysicalOutput.local_velocity`). The soft variants (`+484` from the unidentified
|
||
`+769` flag) are unused.
|
||
|
||
**(8) Board put-down.** Natively the wheels' touchdown sets (Contacts) and the feet's deck taps now play from the
|
||
retail mechanism; the interim knock (1119) and wheel touch (1054) doubled them and are silent with the native
|
||
contacts. The interim foot-on-deck set (1126) stays: retail's step-on / step-off (`sub_824B85B0` → `sub_824B8310` 1124
|
||
in the air / 1125 on the ground, `sub_824B8448` 1126) needs Skeleton+602/+603 (feet planted on the deck), which the
|
||
engine does not publish. The owner buses (eEQChain presets) are still not modelled — that is the "effects" part.
|
||
|
||
**(2) Distance / (5) water: the interim layer's ×2.** Every interim cue plays at measured retail level × `RETAIL_SCALE`
|
||
(2.0), a factor anchored to the interim rolling loop (by ear). The native player sounds play at retail level, so with
|
||
them on, the interim layer (zone beds, location-set one-shots incl. cars, crossfades, splash, steps, bails) was 6 dB
|
||
hot relative to retail's balance. With the native player components running the interim voices now play at the
|
||
measured level (`Voices.scale` = 1/RETAIL_SCALE, `native::follow_volume`); the default path is unchanged. Measured
|
||
remainder (open, item 7 of the plan): our native host folds 6 channels to stereo with the title's table, so a mono voice
|
||
at per-voice gain g reaches each ear at 0.5 g (front) … 0.35 g (rear) (`examples/pan_fold_probe.rs`), while an
|
||
interim Bevy voice reaches 0.75 g (spatial, near) or g (beds) — the interim layer is still ~3–6 dB above the native
|
||
one. The distance cue retail adds through the emitters' positional send (MixMap out8 into the send buses) is not
|
||
modelled (buses not ported).
|
||
|
||
**(7) Riding noise after picking up the board — not reproduced.** In the `log_pickup` replay every voice is silent
|
||
within 0.1 s of state 500 (no wheels → the no-contact duck) and wind within 0.75 s. Retail's wind (SenseOfSpeed) also
|
||
keeps running on COM speed. Needs the user's timestamp (or a fresh state log) of the moment.
|
||
|
||
**Music.in3 ("combo emphasis") — a game-mode flag, not a gap.** `SFXObj_Music`'s process `sub_824D1208` writes in3 =
|
||
32767 when `sub_824898C8` holds: a bit of `*(0x83083C38)+0x2F0D0` (bit 13), or the manager's `+932` with `+476`, or the
|
||
manager's mode `+1060` = 8. None is part of free skating, so outputs 12/13 (ollie 1096, landing impact 1095) stay
|
||
gated — retail 164620: 1095 69 events, sample medians 0.002–0.115. Left at 0.
|
||
|
||
**First-trigger rule.** The grain recordings are now decoded when the bed is created (`grain_bed::Bed::new`), not at
|
||
first use; the wheel-spin streams and the Skate_Metal / sk8_foley Splice banks are decoded at start like
|
||
Skate_Collisions.
|
||
|
||
**End-to-end vs the PoC** (standard scenarios, RMS of the common fold, ours / PoC): rolling ±0.4 dB (unchanged),
|
||
ollie air −0.9, after landing 0.0; ledge grind +2.0 (start contact), metal +0.7; manual −4.0 (special gain; ours now
|
||
adds the wheel spin, +28 dB in the 125 Hz band); carve +3.4 (unchanged). Log replays (`.local/audio-re/e2e-log/`, ours
|
||
only): grind start voices Skate_Metal 194 + Skate_Collisions 619 on the first grind frame, GRINDS 0.66; knocks after
|
||
landings; wheel spin in the air and in the 6.5-s manual.
|
||
|
||
**Files.** New: `crates/skate-audio/src/player/{collision,wheels}.rs`, `crates/skate-audio/examples/pan_fold_probe.rs`,
|
||
`.claude/skills/aems-port/tools/{vault_layout,find_field,grec_clean}.py`, `.claude/skills/aems-port/tools/e2e/
|
||
voices_summary.py`. Changed: `skate-audio` `player/{mod,state,tuning,contacts}.rs`, `runtime.rs` (stream bank);
|
||
`skate-core` `physics/board_ground.rs` (+ test); `skate-game` `game_audio/{player_audio,native,e2e,library,
|
||
skate_events,grain_bed,state_log,voices}.rs`; `tools/asset_pipeline/audio_export.py` (`collision_tuning`, the image's
|
||
material kind / key table as constants) + test; `aems-port/tools/e2e/scenarios.py` (real-play extra columns).
|
||
Dev install: `stage_player_tuning.py` re-run (`player_tuning.collision`, optional key; manifest backup
|
||
`.local/audio-re/audio_manifest.before-collision-stage.json`).
|
||
|
||
**Verification run.** `cargo test -p skate-audio --release`: 102 unit tests + the disc / oracle tests pass (collision 5,
|
||
wheels 3, contacts 8). `cargo test -p skate-game --release --bin skate3rust`: 362 passed, only the known
|
||
`pipelines_accept_valid_group_outputs_when_fingerprint_changes` failure (game_audio 43/43); `--no-run` builds every
|
||
target. `skate-core` lib: 621 pass, the 2 known pre-existing failures. Pipeline tests (`test_audio_formats`,
|
||
`test_versions`, `tools/test_setup_assets`) 38/38. All headless; **needs the user's listening check**.
|
||
|
||
**In-game listening checklist** (`SKATE_AEMS=1`):
|
||
1. Ollie at ~20 km/h: in the air a wheel-spin that is already slowing down (fainter and lower the slower you go), gone
|
||
on landing — no rolling sound in the air.
|
||
2. Manual at ~15 km/h: the lifted wheels spin down audibly under a quieter bed; ends with the manual.
|
||
3. Grind a metal rail and a concrete ledge: a hit at the start (truck or board on the rail, metal ring on rails), then
|
||
the continuous GRINDS layer; no repeated skin/denim "pieces" any more.
|
||
4. Land a jump on a metal surface (e.g. a rail top or metal ramp): an extra board-on-metal contact over the
|
||
touchdowns.
|
||
5. Slam the deck onto a ledge / board slide hard: a deck hit (board 95 against the ledge).
|
||
6. Lift a foot and put it back on the deck (pushing, after an ollie): a board knock; dragging a foot on the deck: a shoe
|
||
scuff.
|
||
7. Put the board down from walking: the wheels' touchdown and a knock when the feet land on it.
|
||
8. World balance: ambience beds, cars, sirens and the splash are 6 dB quieter than in the 18:02 build.
|
||
9. Carving: unchanged (the turn input is the cause; see (1)).
|
||
|
||
**Ready to retire after that check:** `cues::GRIND_START`, `GRIND_PIECES`, `GRIND_PIECE_INTERVAL`,
|
||
`GRIND_METAL_START` (collision grind start), `cues::AIR_WHEELS` (SFXObj_Wheels), `cues::BOARD_DOWN_KNOCK`,
|
||
`BOARD_DOWN_TOUCH` (taps / touchdowns); plus the previous stage's list.
|
||
|
||
**Not modelled / open:** body impacts (needs skeleton region contacts), step-on / step-off (Skeleton+602/+603), the
|
||
owner buses / eEQChain presets and the emitters' send buses, the HOM override (global `+564`), the stamp counter of the
|
||
LRU (we use the post order), Class_Seams (next), rolling rattle, Class_rolling, footsteps, cloth / body slide,
|
||
flips / treatment, the +150 Hz special shift, the turn input (animation), camera distance in GREC.
|
||
|
||
### Listening tests 3–5, the native-audio stutter, the riderless board (2026-10-02, `SKATE_AEMS=1`)
|
||
|
||
**Stutter (19:01 build).** User: tricks with `SKATE_AEMS=1` were "super stuttery … kinda impossible to do anything".
|
||
No build was running at the time.
|
||
- *Measured first.* A headless replay of the user's state log (`state_20261002_190234.tsv` through the e2e
|
||
harness, `E2E_TIMING=1`) puts the native player audio on the game thread at p50 26 µs, p99 140 µs and max 1.5 ms per
|
||
frame. Render is p50 16 µs per 256-frame block. The audio work itself was not the cause.
|
||
- *Root cause.* `native.rs` spawned the native output stream with `PlaybackSettings::LOOP`. Bevy turns LOOP into rodio
|
||
0.20's `repeat_infinite()`, which wraps the source in `Buffered`. That has three effects:
|
||
- It pulls **32,768 samples (64 blocks, 341 ms) at a time inside the device callback**. The runtime's mutex is
|
||
taken 64 times back to back every 341 ms, so the game thread waits behind the burst.
|
||
- It keeps every chunk forever: about 23 MB of memory per minute.
|
||
- It adds up to 341 ms of variable latency between a game event and its sound.
|
||
- Burst cost (`examples/voice_load_probe.rs`): 24 voices ≈ 64 × 130 µs ≈ 8 ms per burst; 96 voices ≈ 33 ms. The
|
||
19:01 build added voices (collision manager, contacts, wheel streams), which lengthened the bursts.
|
||
- *Fix.* `PlaybackSettings::ONCE`. The stream never ends, so it plays forever without buffering. Same block
|
||
sequence, about 5 ms latency. Test `native::tests::the_stream_renders_one_block_per_pull_unless_looped`: a looped
|
||
stream renders 64 blocks on its first pull, ONCE renders 1.
|
||
- *Readout for real play.* `SKATE_AUDIO_TIMING=1` (`game_audio/timing.rs`) logs one `AUDIO_TIMING` line per second.
|
||
It gives max, average and count for each audio system, the game-thread and audio-thread lock waits, the render
|
||
per block and the slowest frame. It also reports `state_log_dropped=N` when rows were dropped.
|
||
|
||
**Third listening test (19:19 build, ONCE fix).** User:
|
||
- "much better for almost every sound";
|
||
- "only every now and again did a grind not quite sound right";
|
||
- "the ambience sounded great";
|
||
- "the rolling sound was almost perfect, there was one instance where it ran a tiny bit long after I jumped";
|
||
- "a few tiny hitches … should still be looked into at the end";
|
||
- "the landings and pops sounded almost perfect!".
|
||
|
||
User-confirmed, ready to retire in the "remove the interim cue tables" step (not deleted yet): `cues::POP`,
|
||
`POP_TAIL`, `LAND_NORMAL` / `LAND_HOLLOW` / `LAND_KIND_CHANCE` / `land_tier` / `land_scale`, `cues::LAND`,
|
||
`cues::POWERSLIDE*`.
|
||
|
||
**Hitches in `observe` (19:20 session, `AUDIO_TIMING`).** The per-second maximum of `observe` was 751 ms, 554 ms
|
||
and 143 ms on three frames. The median was 36 µs. `observe` wrote the `SKATE_AUDIO_STATE_LOG` rows on the game thread,
|
||
and a flush or OS stall blocked the frame.
|
||
- Fix: `state_log::write` now only formats the row and does a `try_send` into a bounded queue of 4,096 rows. A
|
||
thread, `audio-state-log`, creates the file, writes the rows in order and flushes every 6 rows. When the queue is
|
||
full the row is dropped and counted, and the count shows in AUDIO_TIMING.
|
||
- Two other hitches (197 ms and 144 ms frames) had every audio system under 0.2 ms. They are not audio, and are
|
||
noted for the frame-timing todo.
|
||
- The remaining small costs belong to the final optimisation pass (below): mixmap_frame p90 220 µs, grain_bed
|
||
58 µs, render_block 157 µs, game lock wait at most 131 µs.
|
||
|
||
**"Riding sound continues too long" (19:29 session) — the riderless board.**
|
||
- Replaying the windows headless (`.local/audio-re/e2e-1929`; new tool
|
||
`aems-port/tools/e2e/frame_levels.py`, which prints per-frame folded dBFS and per-voice-group gains next to the
|
||
state) shows the bed is not lingering after takeoff: gain A drops to 0 within 3 frames of air.
|
||
- The audible part came after the air phase. Our engine put the skater into Offboard (state 500) and Wipeout (300),
|
||
and in those states the **riderless board's** contacts (`physics.riding.ground`) kept flickering: 1/8/12/15 wheels
|
||
at 3–7 m/s.
|
||
- At 39.3 s that produced a bed + Class_wheels_skid burst at **−28.8 dBFS**: skid voice gain 0.245, grain 0.176,
|
||
against −53 dBFS in the air just before.
|
||
- Retail's audio record reports no wheels through such stretches. The clean GREC sessions show whole stretches moving
|
||
at speed with wheels 0, air 0 and material 143, with no flicker (e.g. 180430 at 246 s, 22 m/s for more than 1 s;
|
||
five stretches over 20 frames).
|
||
- Fix (`skate_events::board_unridden`, audio only): in states 300 and 500–502 the record gets wheel count 0, no wheel
|
||
or deck contacts, materials 143 and seam 0. Ground speed is kept, as retail does. The interim cues see
|
||
`rolling = false` there too.
|
||
- After the fix: **−79 dBFS**, then silence. In state 300 the bed stops at once.
|
||
- UNCERTAIN: the record writer itself (+152 bits 20–22) is not located; this rests on the measured retail behaviour.
|
||
- Physics note for the main session: the user called these "mid-air tricks", but our engine moved the skater into
|
||
Offboard or Wipeout.
|
||
|
||
**Walking with the board (19:32 test).** The user picked the board up and put it down repeatedly while walking:
|
||
"sounded right, nothing out of ordinary fired". The log still shows 23 skid posts and 48 wind post/release cycles in
|
||
state 500. Those voices are held at about 0 by the MixMap, the speed and the no-contact inputs. **Post counts are
|
||
not audible sound**: measure the rendered level before calling something a bug. The wasted posts belong to the
|
||
optimisation pass.
|
||
|
||
**Air tricks #1 and #3 (19:38 session, "played in the air and at the landing").** All 8 tricks rendered headless
|
||
(`.local/audio-re/e2e-1938`):
|
||
- In the air: the bed is at 0 within 4 frames, and every trick sits at −49…−56 dBFS.
|
||
- The pop's roll layer (id 1111 → record 742, samples 625/626/260) plays at **gain 0** throughout, as in retail:
|
||
163809 has 3 and 7 voices of streams 625/626, gain median 0.000. Its trigger is retail's (`sub_824B9CC8`: ground
|
||
speed above 12 / 8 / 4 m/s, vault `C26949FCB638A2CA`, or byte +344).
|
||
- Our "+344" stand-in is `audio_trick ≥ 0`. Retail's +344 is record+148 bit 23, meaning unrecovered. All three ids
|
||
are 1111, so this changes nothing.
|
||
- The audible in-air voice is SFXObj_Wheels' spin-down: 0.21–0.36, against retail's Whls_spins_Jump_1 median 0.240.
|
||
It is a recording of wheels spinning down, entered 5.6 s in at 30 km/h, and can sound like rolling. It is retail's
|
||
mechanism, the same in all 8 tricks.
|
||
- At landing: #3 lands on a new surface (tag 3 → 4), so the grain bed rebinds, as retail's does.
|
||
- The only state difference between #1/#3 and the rest is one frame of state 103 with no wheels and not airborne
|
||
before the air phase. It does not change the native render.
|
||
- Open: the interim layer and frame pacing are not in e2e. A short retail ollie at about 30 km/h in the recomp would
|
||
settle the spin-down.
|
||
|
||
**Grinds (19:20 session, "now and again a grind didn't sound right").** Six grinds: families 0/2/3/4 on tags 3/4,
|
||
including a family change 0 → 3 mid-grind (states 401 → 403 at 28.25 s). The GRINDS voices follow the family (slots
|
||
13/14 → 6/7/8). The routing records show the GRINDS voices go to eEQChain bus 5 (jittered EQ, below) and to the
|
||
FlangeSub return. That return is not modelled yet and is the remaining audible difference on grinds.
|
||
|
||
**Interim voices through the native fold** (native mode only, `voices::native_fold_gain`):
|
||
- With the native host running, every interim (Bevy) voice gets the gain that makes it reach the ears with the power
|
||
a native voice of the same per-voice gain has after Pan2D1 and the stereo fold.
|
||
- Non-positional: mono 0.5, stereo 0.707.
|
||
- Positional: by azimuth, compared with rodio's ear factors. Ahead −3.5 dB, behind −6.6 dB.
|
||
- **rodio 0.20's spatial source is mirrored.** It gives the ear *farther* from the source the larger factor:
|
||
((d_this − d_other)/gap + 1)/4 + 0.5. With the native host on, the listener's ears are swapped so interim sounds
|
||
come from the right side. The default path (no native host) is unchanged; a decision for the main session.
|
||
|
||
### The buses: environment (reverb) network, eEQChain buses, owner one-shot buses; Class_Seams (2026-10-02, `skate_audio::bus`, `player::seams`)
|
||
|
||
**Why.**
|
||
- User: ambience and cars "don't sound like they're in the distance".
|
||
- User: the board put-down's "effects the original engine uses aren't recreated".
|
||
- Every voice used to mix dry into one bus.
|
||
|
||
The specs come from our reading of the TU3 code with two research agents: `.claude/notes/aems-env-bus-spec.md` and
|
||
`.claude/notes/aems-eqchain-buses-spec.md`. They supersede §6 of the voice-graph spec.
|
||
|
||
**Environment network** (`bus/env.rs`; boot `sub_826DD3A8` → `sub_8248EEE8`):
|
||
- *Input.* `[[0x830CFDEC]+52]` is never 0 in retail, so **every standard voice has Send A**: pre-gain, N → 1 mono,
|
||
level = VOL × FXWET0 (property 5).
|
||
- *Graph.* EnvSendSub (199) splits into two sides A/B. Per side:
|
||
- EnvSub (200): Gai0 → PeakingIir2, with the EQ frequency swept by the LFO task;
|
||
- RvrbSub (201): pre-delay Del0 → **ReverbModel1** (six Moorer combs with prime delays from the space size, then
|
||
one all-pass; T60 / size / brightness) → Gai0 → two rvrbfiltsub (HPF → LPF → Gai0 → Pan2D1 at 270° / 90°, LFE
|
||
0.5);
|
||
- two rvrbdelaysub echo taps (Del0 with feedback → HPF → LPF → Gai0 → Pan2D1). Their delay and pan are swept per
|
||
block by the "LFO Task" (`sub_82490B60`).
|
||
- Everything goes into SFX Master.
|
||
- *Presets.* 24 vault presets (`204CAC1FD77088B8`, `aud_reverb/reverbNN`, 44 values by record offset) applied as
|
||
`sub_8248DD18` posts them.
|
||
- *Preset choice.* The district's `audio_reverb` region layer at the skater (default reverb01). A change loads the
|
||
other side with weight 0 and crossfades the two weights linearly over 1 s of game time (`sub_824DE548`).
|
||
- *Emitters.* FXWET0 is the MixMap send word: out8 = −2600 mB + camera-distance roll-off 4 → 70 m. A near emitter
|
||
gets a wet tail and echoes; a far one loses them. Its dry level has no distance roll-off. That is retail's
|
||
distance cue. The zone ambience bed has no Send A.
|
||
- New DSP modules: `dsp/delay.rs` (whole-sample delay, ±0.99 feedback, 128-sample tap crossfade),
|
||
`dsp/reverb.rs` (ReverbModel1) and `dsp/peaking.rs` (PeakingIir2, plus the DCl0 hard clip).
|
||
|
||
**eEQChain buses** (`bus/eqchain.rs`; `sub_82490CA0`, resolver `sub_82491108`, re-roll `sub_824916E8`, clear
|
||
`sub_82491180`):
|
||
- *Graph.* Eight buses (order 253): DCl0 → PI20 → PI20 → SFX Master. Index 8 = SFX Master itself.
|
||
- *Buses 0–4* (vault `AA801D9FC0ADBBBF`, records from the image table 0x8224DC78) re-roll all six EQ values on the
|
||
first create-flagged use after each clear. Each value is b + (k·(a − b))·0.1, with k = r mod 11 from our instance
|
||
of the title generator (no draw when a == b). Validated by the agent against retail: every non-default PEAK line of
|
||
buses 0–2 sits on that grid (all_163809: 228/228, 207/207, 2013/2016).
|
||
- *Buses 5–7* take the shared jitter walk's values: keys `E17029CE…` and five more. These are channels of our
|
||
existing `SFXObj_Jitter` table, now exported with their keys.
|
||
- *Clear.* Every second game frame, or every frame when frames are longer than 20 ms.
|
||
- *Who uses which bus:*
|
||
- routing records 0–7 / 10–17 (+10 = may re-roll) on AEMS voices. GRINDS → bus 5, skid → 5, foot drag / wind /
|
||
rattle → 7, as the programs' records say;
|
||
- pop → owner bus at Contacts level(14), then bus 0;
|
||
- touchdowns and the manual landing → owner bus at level(15), then bus 0;
|
||
- landing impact → bus 1;
|
||
- foot taps and scuffs → bus 1;
|
||
- collision manager → SFX Master (its Collision SubMix bus, `sub_824D25E0`, is UNCERTAIN).
|
||
|
||
**Owner one-shot buses** (`sub_82488DD0`): Sub0 → Sen0 (env, at the owner's level when built) → Sen0 (eEQChain bus).
|
||
Modelled per voice: the panned output summed 6 → 1 × the level goes into the env input, and the voice's output goes
|
||
to the bus.
|
||
|
||
**SFX Master / output.** The trace has SFX Master's gain at 1 and LPF/HPF open, so it is an identity here; its DCl0
|
||
level is not traced. Retail's output-channel byte 0x8306705D is **always 6** (Dac setup `sub_82B20EF8`): retail
|
||
hands 6 channels to the console, and the stereo downmix is the platform's. The recomp host's is 0.4·(FL + SL +
|
||
0.5·C). We keep the title's own N = 2 table (0.707 / 0.5 / 0.5) as our stereo fold. That is the game's matrix,
|
||
though retail does not run it. No change.
|
||
|
||
**Class_Seams** (`player/seams.rs`; ctor `sub_824C1198`, create `sub_824C13D0`, process `sub_824C14C8`, trigger
|
||
`sub_824C1698`, crossing `sub_824C1CA0` / `sub_824C1BA8`, hit `sub_824C1DF8`, update `sub_824C1F18`):
|
||
- *Packets.* Four 20-word per-wheel packets, held for the component's life.
|
||
- *Material change.* A hit with the transition surface.
|
||
- *Grid mode.* The wheels' world positions are rotated by the pattern's angle and divided by its grid. One hit per
|
||
axle per line crossing, after the minimum frames; the rear axle is skipped in manuals.
|
||
- *Distance mode* (slats): front then rear hits every `spacing` of ground speed × 100 × dt.
|
||
- *Update.* MixMap Cracks outputs.
|
||
- *Kept retail quirks:*
|
||
- the cells start at 0x7FFFFFFF and the frame counter runs per axis, so the first frame at speed fires one hit per
|
||
axle;
|
||
- the turn word slews ±100 per packet write.
|
||
- *Engine inputs.* AudioState gets `wheel_position` (the wheel bodies' world positions). The engine already
|
||
published the seam patterns (`WheelLineState.seam_patterns`). The state log gains `seam0`, `seam3`, `wheel_x`,
|
||
`wheel_z` and `heading` for replays.
|
||
- *Tuning.* The pattern fields are exported with the seam wobbles. Seams_Bank joins the player banks and is decoded at
|
||
start (first-trigger rule).
|
||
|
||
**Evidence and numbers.**
|
||
- *Dry identity.* With `E2E_BUSES=0` the 12 standard e2e renders are bit-identical to the previous stage's on 7.
|
||
The other 5 differ by at most 1.2e-7 (relative 1.4e-7), only from the changed summation order through the bus
|
||
inputs.
|
||
- *Wet.* Env + EQ on: the wet part is −6.7 / −6.9 dB under the dry on grinds (EQ bus 5 included), −9.7 / −9.9 dB on
|
||
brake and slide, −22.6 dB on the ollie, and below −40 dB on plain rolling (the bed has no Send A; its graph-2 env
|
||
send is not modelled yet).
|
||
- *Send A against retail* (`.local/audio-re/envbus/senda_bank.py` on 163809, per-voice send medians):
|
||
|
||
| bank | ours (mean) | retail (median) |
|
||
|---|---|---|
|
||
| GRINDS | 0.0142, send/gain 0.16 | 0.0062 / 0.19 |
|
||
| WHEEL_SKID | 0.0256 / 0.28 | 0.0160 / 0.51 |
|
||
| FOOT_DRAG | 0.0037 / 0.13 | 0.0112 / 0.17 |
|
||
| owner buses | 0.079 | 2590/32767 = 0.079 in the capture |
|
||
| sense_of_speed | **0** | 0.0150 |
|
||
| Skate_Collisions Splice voices | none | 0.0146, send/gain 0.35 |
|
||
|
||
sense_of_speed: the programs post property 5 = 0 in our evaluator; the source of retail's value is not found
|
||
(open). Skate_Collisions: retail's Splice voice graph has a Send A, its level rule is not recovered (open).
|
||
- *Seams through the real bank.* Sidewalk pattern at 10 / 20 / 30 km/h: voice gain median 0.045–0.049, p90
|
||
0.054–0.063. square_8 at 20 km/h: 0.027 / 0.050. Retail Seams_Bank per voice (163809): non-zero p10 0.010 /
|
||
median 0.074. First hit = later hits.
|
||
- *Cost.* Env network about 21 µs per block. All buses add about 105 µs per block at p50 (render p50 19 → 124 µs,
|
||
budget 5,333). The eEQChain buses filter every block and are the main share: an optimisation-pass item.
|
||
|
||
**Dev install.** `aems-port/tools/stage_bus_tuning.py` adds `bus_tuning` and refreshes `player_tuning` (jitter keys,
|
||
seam pattern fields). Manifest backup: `.local/audio-re/audio_manifest.before-bus-stage.json`. Both keys are optional
|
||
and older builds ignore them. Setup writes the same keys (`audio_export.bus_tuning`).
|
||
|
||
**Not modelled / open.**
|
||
- The FlangeSub returns (routing 4096 / 8192 / 16384: GRINDS uses one).
|
||
- Reverb-zone emitters (type 5: zone fades and the pan rotation).
|
||
- The grain chain's graph-2 env send (level(13)).
|
||
- The Splice voices' own Send A and Collision SubMix.
|
||
- sense_of_speed's FXWET0.
|
||
- The reverb module's timer delay before a new preset is heard (applied at the next block here).
|
||
- PI20 not bit-exact (no replay vectors).
|
||
- The +150 Hz manual frequency shift.
|
||
- Rolling rattle, Class_rolling, footsteps, cloth, flips/treatment.
|
||
- Step-on (Skeleton+602/+603).
|
||
|
||
**Final stage of the plan: an optimisation pass with adversarial reviews** (skill `.claude/skills/optimisation`,
|
||
foundation). Measure first (AUDIO_TIMING in real play, e2e timing headless). Change one thing at a time and keep
|
||
behaviour identical: e2e renders bit-identical, or a documented tolerance. An independent reviewer tries to break
|
||
each change, looking for hidden output changes and fake speed-ups. Then re-measure (worst case and 1 % lows).
|
||
First round done 2026-10-03: see "Optimisation pass over the native runtime" below. Candidates: eEQChain buses that filter silence, per-block `Vec` allocations in the mixer, the wind/rattle and skid
|
||
post/release thrash, the remaining small game-thread hitches.
|
||
|
||
### Listening test 6 (20:06 build): seams on brick and in the University park (2026-10-02)
|
||
|
||
User: DownTown (Aletown) brick "played too often or out of sync"; in the University's big park "an odd noise while
|
||
riding … when going over seams … too much reverb added possibly". Logs `state_20261002_200832.tsv` /
|
||
`_201324.tsv` (the clock restarts at each map load: cut by rows, `scenarios.py --cut rA-B`), rendered headless
|
||
(`.local/audio-re/e2e-2008*`, `e2e-rev-*`).
|
||
|
||
**Surfaces.** The brick is tag 66 with seam pattern 13 (`mini_tile`: 0.08 m grid, angle 30°) and tag 5 / pattern 7;
|
||
the park is tag 41 (Wood_1) / pattern 9 (`irregular_large`, 0.7 m, gain 0.41).
|
||
|
||
**Cause 1 — materials gated by contact (fixed).** The audio record's wheel materials (`+620..+632`) and seam patterns
|
||
(`+636..+648`) come from the wheel lines (82C079E0: each wheel's 0.2 m ray), not from contact. Our `audio_state`
|
||
gated them by `in_contact`, so every contact flicker was a material change, and Class_Seams fires a *transition* hit
|
||
(AudioSurfaceMap word 9 + 5, a different sound) on every change.
|
||
- Retail (GREC, `aems-port/tools/grec_material.py`, 180430, 683 s rolling): wheel 0 keeps its material on 100 % of
|
||
3-wheel frames and 96 % of 2-wheel frames; it changes 0.9 times a second.
|
||
- Ours (contact-gated): wheel 0 changed 5.0 / 8.9 / 6.6 times a second in the 20:08 / 20:13 / 19:29 logs, 19–27 changes
|
||
a second over all wheels (the park's wheel mask changes 16–19 times a second).
|
||
- Fix: `skate_events::audio_state` takes both from the lines (still 143 / 0 for the riderless board). The state log has
|
||
a new `lines` column; `e2e.rs` replays it (older logs: every line hits while a wheel is down; `E2E_CONTACT_MATERIALS=1`
|
||
replays the old gating).
|
||
- Park windows: transition hits 243 → 80 (of 635 → 531 hits) in `mp_seams2`, 118 → 40 in `mp_seams`; brick 58 → 36.
|
||
Scripted e2e: 11 of 12 renders bit-identical, `manual15` differs (the lifted truck keeps its material) within 0.1 dB.
|
||
|
||
**Cause 2 — interim seams and knocks still playing beside the native ones (fixed).** In native mode the interim
|
||
riding bed (`cues::BED_CUES`) still fired random `Seams_Bank` samples (about 0.45 a second, at random times: "out of
|
||
sync"), and `BED_RECORDS` fired the deck knocks 1112 / 1115 / 1119 and the scuffs `sk8_foley` 94 / 95 that the native
|
||
foot taps / scuffs play; the interim `CATCH` knock (1115, 0.22 s after a flip) doubled the native taps too. All
|
||
silent now when the native player / contacts run (`cues::bed_record_native`).
|
||
|
||
**Hit rate on brick: not above retail.** Ours on tag 66 / pattern 13 at ~8 m/s: 100–220 Class_Seams hits a second
|
||
(the 0.08 m grid; 116 a second over the brick window, 224 in the 30 km/h sweep), heard as about 11 seam voices a second — the program sees the
|
||
one-frame w7 pulse only at its 32 ms walks, as in retail. Retail on the same material (wheel-0 material 65 = tag 66,
|
||
180430 seconds 443 / 451 / 452): 27–40 Seams_Bank voices a second, per-voice gain median 0.088 (ours 0.07–0.09).
|
||
|
||
**Sample variety (resolved in Listening test 8: the missing `Start_up_Play_ctl` boot utility, no trace needed).** A sweep of the packet words through the real program
|
||
(`player_audio::tests::seam_samples_by_words`, `SEAM_SWEEP=word:value`) shows the program picks one sample per
|
||
(surface w10, speed w8, soft w11): surface 1 → sample 80 (72 at speed word 0, 88 at 10000), soft → 170. So at a steady
|
||
speed ours repeats one or two samples (80 / 32 on the brick). Retail on tag 66 plays 45, 83, 170, 80, 176, 34 … in
|
||
the same seconds — the soft-wheel set (170 / 176) and other surface blocks appear. Which words differ in retail is not
|
||
known: a recomp hook on the Class_Seams packet (`sub_824C1DF8` / `sub_824C1F18`, words 7–18 per wheel) would settle it.
|
||
|
||
**Reverb in the park: retail's preset, strong echoes.** The University's `audio_reverb` region gives preset
|
||
`407AFA1D6C7CEAD8` everywhere the user rode (DownTown: `68A9E6020DF2076D`). Retail used it too: in 163809 the env
|
||
EQ's sweep runs from 928.8 Hz = 1161 × (1 − 0.2) with gain 0.1 and Q 20, that preset's values. It has two echo taps
|
||
at 0.17 / 0.18 s, gain 1.0, feedback 0.2, with a ±20 % delay wobble, which makes short seam hits audibly slap back.
|
||
Rendered bus part on the park window: −16.1 dB re dry with it, −22.6 dB with reverb01, −21.1 dB with DownTown's.
|
||
The seams' Send A matches retail (send / gain 0.22, retail median 0.225). Our env network's wet level has not been
|
||
checked against retail *output* (no trace of the network's return); a capture of a seam hit in the park would.
|
||
|
||
### Listening test 7: rolling "busy", "continues into jumps", "rougher than it is" (2026-10-02)
|
||
|
||
User: rolling "can sound faster or like more is happening than actually is"; "sometimes while jumping the rolling on
|
||
ground sound continues"; "the materials sound rougher than they are for some things". Logs
|
||
`state_20261002_203746.tsv` / `_204047.tsv` (University, 0 malformed lines), plus a recomp trace-all session at the
|
||
same spot (`all_20261002_204336`, clean).
|
||
|
||
**Cause 1: interim rolling one-shots still playing beside the native layers (fixed, not yet in a test build).**
|
||
The interim bed table (`cues::BED_CUES`) still fired random `Rolling_Rattles` (0.31/s × bed rate, level 0.44) and
|
||
`PatchBank_Rolling_Surfaces` (0.14/s) samples under `SKATE_AEMS=1`. In 21 s of riding that is about 7.7 rattles and
|
||
3.5 surface one-shots. Retail posts the rattle only on a push and plays the surface layers as continuous textures
|
||
gated by the MixMap. These samples last 0.3–1.9 s, so one started just before takeoff rang on into the jump: about a
|
||
1 in 3 chance per jump for a rattle at this session's speeds. `skate_events.rs` now skips both entries whenever the
|
||
native rattle / rolling layers run (`PlayerAudio::rattle_on` / `rolling_on`). The native layers themselves go quiet
|
||
within 10 frames of takeoff: SkateBoard MixMap levels 1, 6, 7, 9 and 10 are 0 with no wheels down
|
||
(`skate-audio/tests/rolling_banks.rs::layers_go_quiet_after_takeoff`).
|
||
|
||
**Surface choice matches retail.** Retail's wheel-0 material (GREC `+620`) is 3 = tag 4 (concrete_rough) for all
|
||
32.5 s of four-wheel rolling, the same tag our engine reports (17.2 s of tag 4, 0.7 s of tag 64). Speeds match too
|
||
(retail median 11.0 m/s, p90 12.0; ours 9.4 / 12.1). So "rougher than it is" is not a wrong surface. The rendering
|
||
of `concrete_rough_hard` and the layering on top of it are being compared with the retail capture.
|
||
|
||
**Retail facts for the other layers.** The SenseOfSpeed rattle's gain is not gated on wheel contact (level 832 at 31 km/h
|
||
both on the ground and in the air), so it keeps playing in jumps and posts again as the speed crosses 30 km/h. That is
|
||
parity with retail. Retail keeps the riding speed during jumps (±5 km/h).
|
||
|
||
### Listening test 8: concrete "too rough", brick "firing too often, sounds faster" (2026-10-02, build 21:04)
|
||
|
||
User (build with the grain chain, board layers, seams taken from the wheel lines, interim cues off): rolling was
|
||
"much better", but "the same concrete still sounds too rough"; brick "sounds better but is still firing too much or
|
||
too often and sounds like you're going faster than you are". Logs `state_20261002_210721.tsv` (University, 22.8 s on
|
||
tag 4 at a mean 31 km/h) and `_210845.tsv` (University, then DownTown; 21.6 s on tag 66 / seam pattern 13 at a mean
|
||
19 km/h). Both logs are clean. Rendered headless (`.local/audio-re/seams8/`): brick 15 / 21 / 32 km/h, concrete
|
||
on patterns 2 / 8 / 11.
|
||
|
||
**Cause: a missing boot utility froze the Seams program's sample choice (fixed).** We traced the Class_Seams program
|
||
op by op (new example `skate-audio/examples/program_trace.rs`). Every player's sample index is built as
|
||
table(surface w10) + table(speed w8) + soft wheels + one of three globals, `send_random_0_to_9a` / `9b` / `9c`. The
|
||
program calls the function `rnd_call` on a hit. Only `Common.abk` implements that function, through its class
|
||
`Start_up_Play_ctl`. Retail posts that class once at audio boot, after `c_emitter_utility` and before
|
||
`c_foley_utility` (retail's three boot POSTs; upstream PR #4's player does the same). On each call it runs three
|
||
RandomShuffle draws over 0–9 and writes them into the globals. We neither loaded nor posted it, so the globals stayed
|
||
at their defaults (7 / 0 / 0) and each (surface, speed, soft) word set always played sample 0 of its block of eight:
|
||
- brick played 80 and 32 every time; retail 180430 plays all of 32–39 and 80–87;
|
||
- concrete played 32, 104 and 112; retail 204336 plays all of 32–39, 104–111 and 112–119.
|
||
|
||
At 20–30 hits a second, the same transient repeated as a regular buzz, which reads as "too often", "faster" and
|
||
"rough".
|
||
|
||
**What matched already (no change):**
|
||
- *Speed word.* w8 = clamp01((state+208 − 0.5) × 0.08) × 10000, with +208 in m/s. Constants `0x8209975C` 0.5 and
|
||
`0x8208EDA4` 0.08 come from `sub_824C1F18`; ours is identical. The speed table moves the block 72 → 80 → 88 with
|
||
speed; retail plays 80–87 at 30 km/h, as ours does.
|
||
- *Seam hit gating.* We re-read `sub_824C1698` (per axle: one wheel, minimum frames since the partner wheel's hit,
|
||
frame counter per axis), `sub_824C1CA0` (grid cell, surface-3 scale only for class 10) and `sub_824C1BA8`
|
||
(rotation). They match the port.
|
||
- *Rate.* We compared at matched speed and seam pattern, looking up the pattern at retail's WPPOS position in our
|
||
logs:
|
||
|
||
| surface | retail | ours, after the fix |
|
||
|---|---|---|
|
||
| concrete, pattern 8, 30–40 km/h | 21 voices/s, 16 walks/s | 28 voices/s, 18 frames/s (36 km/h) |
|
||
| concrete, pattern 8, 40–50 km/h | 28 voices/s | — |
|
||
| brick (material 65), 30 km/h | 27–40 voices/s | 29–31 voices/s |
|
||
- *Per-voice level.* Ours is 0.045–0.053 on concrete against retail's 0.052–0.074 (by block), and 0.08–0.09 on brick.
|
||
Ours is not louder than retail.
|
||
- *Concrete's other layers.* The spidercrack layer 5 only plays on seam pattern 1; these windows are on patterns
|
||
2 / 8 / 11. Graph 3 (the high-speed copy) only starts above 46 km/h; these windows top out at 44.7 km/h.
|
||
|
||
**Fix:**
|
||
- `audio_export.AEMS_EXTRA_BANKS` now includes `Common.abk` (it is programs only, with no samples).
|
||
- `native.rs` loads it and posts `Start_up_Play_ctl` after `c_emitter_utility`, and keeps it across map unloads.
|
||
- The e2e test and the player tests do the same (`seams::UTILITY_BANK` / `UTILITY`).
|
||
- For an existing dev install: `stage_extra_banks.py --tag seams-utility Common`, which now also stages program-only
|
||
utility banks.
|
||
|
||
**Verification:**
|
||
- Hits are unchanged (722 in brick21).
|
||
- Each eight-sample block is now used evenly. In brick21, 32–39 and 80–87 each get 2–10 starts.
|
||
- Whole-mix level: −0.6 to +0.05 dB.
|
||
- `E2E_SEAM_UTILITY=0` renders are byte-identical to the 21:04 renders.
|
||
- New test `player_audio::seam_hits_shuffle_their_samples`: 146 voices in 5 s on brick at 30 km/h, all 16 samples.
|
||
|
||
**Open:**
|
||
- On cells our logs mark as pattern 11 (sidewalk, 3 m grid), retail 204336 played 2 seam voices in 1.5 s of moving
|
||
at 40 km/h; ours plays about 12 frames a second at 33 km/h. The sample is too small to call: most of retail's
|
||
"pattern 11" seconds come from a stopped stretch with the speed still held. A recomp trace of `+636` (wheel 0's seam
|
||
pattern) next to WPPOS would settle whether retail sees pattern 11 there.
|
||
- Our boot order is foley utility, then `c_emitter_utility`, then `Start_up_Play_ctl`. Retail's is emitter,
|
||
Start_up, foley. The three share one RNG, so draw order differs from retail; behaviour does not.
|
||
|
||
### The board's other layers, the grain chain, tricks / treatment, the effect returns (2026-10-02, `player::{rolling, tricks, treatment}`, `grain::chain`, `bus::{flange, zones}`)
|
||
|
||
Specs (our reading of the TU3 code with research agents): `.claude/notes/aems-board-layers-spec.md`,
|
||
`aems-grain-chain-spec.md`, `aems-tricks-treatment-spec.md`, `aems-bus-leftovers-spec.md`.
|
||
|
||
**SFXObj_SkateBoard's rolling layers** (`player/rolling.rs`):
|
||
- *Surface routing* (`sub_824C5CA8`, surface of a truck `sub_824C82A8`, member `sub_824C8370`): two trucks, the
|
||
primary one reading wheel 0's material, the other wheel 3's; one sounding truck per distinct surface. A truck
|
||
that leaves its surface hands the sound to the silent truck instead of stopping it (retail never stops the
|
||
local player's last sounding truck; the MixMap's level(1) mutes it in the air). GREC 180430 agrees: neither
|
||
truck running on 621 of 310k frames. The bed now binds and stops from these events (`GrainEvent`) and the
|
||
routing writes SkateBoard inputs 0 / 6; `grain_bed.rs` keeps its old one-truck routing only when the native
|
||
rolling layers are off.
|
||
- *Class_rolling* (constructor `sub_824C4C18`): the non-grain rolling surfaces 7, 8, 10, 11, 12, 13 post a
|
||
per-surface patch (selectors 1, 2, 10, 12, 11, 9) instead of a grain — this replaces the interim stand-ins in
|
||
`cues::grain_for` (tags 8 / 10 / 70 → aggregate, 37 / 67–69 → metal) on the native path. Rolling surface 0
|
||
(tag 90) binds asphalt_rough_hard with the `default` collection's tuning (open question closed).
|
||
- *Held layers 0 and 3* (posted once at board create, `sub_824C9830` / `sub_824C9948`) and *layer 5* while wheel 0
|
||
is on the spidercrack pattern (`sub_824C9F68` / `sub_824CA038`).
|
||
- *Rolling_Rattle_Class* (`sub_824C6198`, `sub_824B0248`, `sub_824C80C0`): re-posted on every push plant while the
|
||
primary truck is on a grain surface (sample = 3 × surface code + speed band).
|
||
- *c_board_slide* (`sub_824CB3C8` / `sub_824B0670` / `sub_824CB4C0`): the loose board on its back / side while the
|
||
rider bails or walks. The `up_dot` vectors are UNCERTAIN (deck up · world up stands in).
|
||
- Every Class_rolling post reaches all four banks bound to the class, so setup now exports
|
||
`PatchBank_SpiderCracks`, `PatchBank_Objects` and `PatchBank_RocksBounce` (and `Treatments`).
|
||
- Levels through the real banks (per-start peaks, agent's `tests/rolling_banks.rs`): Rolling_Rattles 0.34–0.37
|
||
(retail p90 0.30–0.36, max 0.37); SpiderCracks max 0.355 (retail max 0.29–0.35); held layer slot 15 median 0.030,
|
||
p90 0.046 (retail 0.007–0.010 / 0.044–0.121); Objects up to 0.20 (retail p90 0.03–0.22); board_scrapes max
|
||
0.06–0.10 (retail p90 0.10–0.12). First rattle = later rattles. In the air every layer is silent within 10 frames
|
||
(SkateBoard levels 1, 6, 7, 9, 10 are 0 without wheels).
|
||
|
||
**The grain bus chain** (`sub_824C8878`; `dsp/fss.rs`, `dsp/shelf.rs`, `grain/chain.rs`):
|
||
- *FrequencyShiftSsb* (`sub_82B22898`): two cascades of two allpass sections (fixed image constants at
|
||
`0x82FCE2B0`) form an I/Q pair on the shared biquad kernel; out = I·cos φ − Q·sin φ with retail's polynomial
|
||
sin / cos; at 0 Hz it is the I path (an allpass), never a bypass. Phase error of the Hilbert pair ±0.6° at
|
||
100 Hz–1 kHz.
|
||
- *Values per frame* (`sub_824C9058`): A = (special ? +150 Hz : 0) + the push shift envelope + D × A's shift per slope;
|
||
B = B's base shift (−10 Hz; wood −25, metal −20) + push shift + D × B's per slope. **This is the +150 Hz manual
|
||
shift.**
|
||
- *Graph 3* (the high-speed copy): send 0 below 46 km/h → 3.0 at 70 km/h, DCl0 ±0.09, the wobble gain, HS20 5 kHz ×
|
||
0.65. *Wobbles* (`sub_824CB078` / `sub_824CB180`) and the graph-1 level ramp, with the quirk that truck 1 never
|
||
receives a wobble. *Graph 2*: the env send level(13)/32767 (pre-pan mono) into the reverb network, the
|
||
FlangeSub send level(21)/(22) carried but not rendered (no bed → return route yet).
|
||
- Retail (`dsp_20261002_185826`, `.local/audio-re/grain-chain/chain_check.py`): the graph-3 send matches exactly (189
|
||
posts), graph-1 / graph-3 gain = the level ramp within 1e-4, wobble depth 0.290 / 0.240 against the caps 0.30 /
|
||
0.25, only truck 0's graph-3 gains change.
|
||
- e2e: **manual15 went from −4.0 dB to +0.1 dB against the PoC** (A × 0.65 and +150 Hz; centroid 652 → 763 Hz, PoC
|
||
77x). Rolling scenarios unchanged (≤ 0.1 dB). Graph 3 adds about +5.7 dB at 60–85 km/h (not compared with a
|
||
retail capture).
|
||
|
||
**Tricks and Class_Treatment** (`player/tricks.rs`, `player/treatment.rs`):
|
||
- *Class_Flips* (`sub_824CBFB8` → `sub_824AFAD8`) posts on the first air frame of a trick (not at the pop: retail
|
||
median one frame after the jump-velocity write), held while airborne with the same audio trick, released at the
|
||
landing; ids −1 / 35 / 36 post nothing. *cloth_trick* A as soon as the trick registers (~215 ms before the air),
|
||
B with the scorable's second audio trick (`+352`, vault field `A2C5C22C5BE725F8`, now exported as
|
||
`player_tuning.audio_tricks_2`) when the trick ends.
|
||
- *Class_Treatment* (`sub_824DD408` / `sub_824B0080` / `sub_824DD6F0`): posted once, streams the predicted time to
|
||
landing (Air+184) and jump height; its landing streams 13 / 14 start ~0.37 s before touchdown.
|
||
- Levels through the real banks vs retail: flip whooshes 0.370 (retail 0.347–0.365), flips slot 5 0.133 (0.134),
|
||
cloth_trick 0.18–0.24 (0.21–0.25), Treatments 13 / 14 0.0736 / 0.0184 (0.0746 / 0.0186). First flip = second.
|
||
- Treatments slots 16 / 17 (the long-air layer) play in the recomp too, at our level (section "Treatments slots
|
||
16 / 17" below). The game-mode globals (G+96, X+1060, G+4) are free-skate defaults.
|
||
- The interim `cues::FLIP` is silent with the native Tricks.
|
||
|
||
**Effect returns and the other bus leftovers** (`bus/flange.rs`, `bus/zones.rs`, mixer Send B):
|
||
- *FlangeSub returns* (`sub_82490270`, presets `sub_824DDF58`, levels `sub_824DF220`): Del0 (≤ 15 ms) → PI20 → env
|
||
send → Gai0 → Pn21 → SFX Master, the delay swept 0.2–3.8 ms at 0.5 Hz (A) / 0.2–0.8 ms at 0.25 Hz (B). Routing
|
||
4096 → A: GRINDS; 8192 → A: WHEEL_SKID_BANK; 16384 → B: Flips. Send B = the post-gain mono at property 11; retail
|
||
GRINDS 0.0499 / skid 0.0561 (= Rail out6 / SkateBoard out20, which our components post). On a grind the return
|
||
is a centred copy 26 dB under the dry voice. Presets exported as `bus_tuning.flange`.
|
||
- *Send A corrections:* retail sense_of_speed Send A **is 0** (its updater writes w6 = property 5 = 0 every frame) and
|
||
Splice voices have **no Send A** (graph `PITCH GAIN SEND6`). The 0.0150 / 0.0146 in "The buses" came from a trace
|
||
script that attributed every line to an owner's last PLAY of the session (`.local/audio-re/busleft/senda_bank_fixed.py`
|
||
is the corrected one). Nothing to port.
|
||
- *Collision SubMix* (`sub_824D25E0`): collision voices play mono into their submix with an env send at the
|
||
category's Collision output (≈ 0.100 near the camera, as the trace) and the AudioSurfaceMap words 12–16 eEQChain bus
|
||
(all 8 = SFX Master in the vault). On by default (`SKATE_AEMS_SUBMIX=0` = the old direct route).
|
||
- *SFXObj_Reverb's selector* (`sub_824DE548`, zone blend `sub_824DEEF0`) runs in retail's order (`env.update`): the
|
||
first region preset fades in over 1 s from reverb01. The reverb-zone emitters (`.ems` attribute type 5) are not
|
||
wired: one record on the disc (`1F94F2F815C00368` → preset `BEEFC8E3DE04FBAE`). *Superseded: 1,040 records, now wired
|
||
— see "FootStep SubMix, the skeleton inputs, …".*
|
||
- Not applied: the global env-send scale (manager +104 = Reverb out4): it depends on Reverb.in5, whose writer is
|
||
unknown (the trace says in5 = 0; our hosts write 32767, which would drop every env send 4 dB). *Superseded: the writer
|
||
is `sub_824DF468` (by the preset number); applied — see "FootStep SubMix, the skeleton inputs, …".*
|
||
|
||
**Footsteps, clothing, hands on the deck** (`player/footsteps.rs`, `player/clothing.rs`, `player/step_on.rs`; spec
|
||
`.claude/notes/aems-offboard-clothing-spec.md`):
|
||
- *SFXObj_OffBoard* (`sub_824E9270`, poster `sub_824E9FD8`, updater `sub_824EAEA8`): two held
|
||
`playercharacter_footstep` packets (25 words) plus the foot-down Splice sounds (surface layers `sub_82493E60`,
|
||
step layers `sub_82493690`), the walking voices (`sk8_foley` 62 / 63 / 64; Skate_Collisions 1121–1123 with the
|
||
board in hand) and the jump take-off / apex voices. On the board the push plants and the brake count as feet
|
||
down, so this also plays the push steps.
|
||
- *Clothing* (`sub_824DBB68`): `c_cloth_falls` at a bail, the push foley `sk8_foley` 73 / 74 (eEQChain bus 0),
|
||
`c_body_slide`.
|
||
- *Hands on the deck*: Skeleton+602/+603 are the IK's **hand** targets (limbs 2 / 3) inside the padded deck box, not
|
||
the feet. 1124 / 1125 (hand on, air / ground) and 1126 (hand off) play when the board is picked up or put down
|
||
by hand (Contacts `sub_824B85B0`). The interim "1126 = foot on deck" was a wrong mapping and is silent natively.
|
||
- Caller 824D8164 (the 1017 `sk8_foley` 62/63/64 events in 164620) is `SFXObj_PedestrianSFX` — pedestrian footsteps,
|
||
not the player.
|
||
- Levels vs retail per-voice medians: walking foley 62 / 63 0.387 / 0.244 (retail 0.387 / 0.244); step layer 82 equal
|
||
per sample; board in hand 0.155–0.28 (0.17–0.34); metal surface 0.082 (0.082); push foley 0.257 (0.18–0.26);
|
||
hand on 0.125–0.153 (0.13–0.16), hand off 0.062–0.070 (0.071–0.081); Foley_Cloth +7 dB (guessed inputs below).
|
||
- Engine inputs: feet down (OffBoard 306 / 307, Air 449 / 450, push flags, brake), the feet's surfaces
|
||
(`processed.right/left_surface`), toe world speeds, footstep strength, the hand targets — published. APPROXIMATE:
|
||
the feet's vertical speeds (toe world velocity y), the body speed (COM speed). Not published: the step code
|
||
(Skeleton+144/+160), the limb speeds and the ragdoll body-part slide records (body slide stays silent). The
|
||
FootStep SubMix chain (HPF → LPF → PI20 → env → pan per sound) is passed through but not rendered by the mixer.
|
||
- Setup exports per material `B1CF62EA632CF13F` (has footsteps), `37320DF00471F91A` / `9D6068AB16703650` (gains).
|
||
`SKATE_AEMS_FOOTSTEPS=0` keeps the interim steps. The interim `STEP` / `RUN_STEP` / `PUSH` / `BOARD_DOWN` cues are
|
||
silent with the native footsteps / contacts. The state log gains `foot_down`, `foot_tag_a/b`, `hands`, `strength`,
|
||
`foot_vy_a/b` for replays.
|
||
|
||
**Retired interim cues (native path).** The native path no longer reaches `POP`, `POP_TAIL`, `LAND*`, `land_tier`,
|
||
`land_scale`, `LAND_CLOTH` (= the native scuffs `sk8_foley` 95), `POWERSLIDE*` (user-confirmed), nor `CATCH`, `FLIP`,
|
||
the interim bed's Rolling_Rattles / PatchBank_Rolling_Surfaces / Seams_Bank / sense_of_speed cues and its knocks /
|
||
scuffs, `STEP` / `RUN_STEP` / `PUSH` / `BOARD_DOWN` (native replacements, not user-confirmed). The tables stay for the default path (native off).
|
||
|
||
**Cost.** Render p50 87–117 µs → 114–178 µs per block over the e2e scenarios (the grain chain ≈ +27 µs per bound
|
||
truck, mostly `mul_add` library calls); game thread p50 32–38 µs → 35–38 µs per frame. `voice_load_probe 96`: p50
|
||
512–520 µs with or without the returns (budget 5,333).
|
||
|
||
**e2e against the PoC (all on, `.local/audio-re/e2e-on2`).** Unchanged within 0.3 dB everywhere except manual
|
||
(−4.0 → +0.1 dB, the chain) and the slide (st101 segments +3.0 → +2.2, +0.8 → +0.8, −0.2 → +0.6 dB against the PoC: the skid's flange
|
||
return and the rolling layers). With every new part off (`E2E_ROLLING=0 … E2E_CHAIN=0 E2E_FLANGE=0`) 11 of 12 renders are
|
||
bit-identical to the previous stage; roll45 differs by the graph-1 wobble (−36 dB under the signal), which the
|
||
host now posts.
|
||
|
||
|
||
### Listening after the components stage (2026-10-02, builds 21:04 and 21:12)
|
||
|
||
User verdicts, verbatim:
|
||
- Rolling "is much better, it just has issues with certain surfaces":
|
||
- University concrete (session `state_20261002_210721`, tag 4, about 31 km/h): "the same concrete still sounds too
|
||
rough on our engine".
|
||
- DownTown brick (`state_20261002_210845`, tag 66 / seam pattern 13, about 19 km/h): "the brick surface sounds better
|
||
but is still firing too much or too often and sounds like your going faster than you are".
|
||
- Footsteps, clothing and hands on the deck (`state_20261002_212254`): "the footsteps sound great, cant tell on the
|
||
clothing but nothing intrusive sounding. pickup / put down sounds were great."
|
||
|
||
Consequences:
|
||
- Footsteps and the hand pick-up / put-down are user-confirmed. The interim STEP / RUN_STEP / PUSH / BOARD_DOWN* cues
|
||
join the confirmed retirements.
|
||
- Both surface reports are being measured against retail (recomp sessions `all_20261002_204336` at the same University
|
||
spot and `all_20261002_180430` on brick). The lead is the Class_Seams packet words, especially whether our speed word
|
||
has the scale retail's program expects: a wrong scale would pick the "fast" samples at low speed.
|
||
### FootStep SubMix, the skeleton inputs, the Reverb inputs, the reverb zones (2026-10-02, `bus::submix`, `skate_events`, `bus::env`, `emitters::reverb_zones`)
|
||
|
||
**The FootStep SubMix** (`sub_82494188`, spec `.claude/notes/aems-offboard-clothing-spec.md` §2.5; `bus/submix.rs`):
|
||
- One mono graph per foot sound slot of `SFXObj_OffBoard` (two feet × four slots), built once:
|
||
`Sub0 → HI20 → LI20 → PI20 → Sen0 (env bus) → Pn21 (1 → 6) → Sen0 (SFX Master)`.
|
||
- The slot's Splice voices play into Sub0. Their 6-channel final Send sums L, C, R, Ls, Rs into the
|
||
mono input; LFE is dropped.
|
||
- At each start of the slot's sound, `sub_82494550` posts the slot's EQ record (HPF, LPF, PI20
|
||
centre / gain / Q), the holder's env level and its azimuth.
|
||
- The graph keeps those values, its filter history, its send ramps and its panner matrix until the
|
||
next start. `SpliceHost::set_submix` now reaches the mixer (`Output::Submix`, `Route::mono`).
|
||
- Off: `SKATE_AEMS_FOOTSTEP_SUBMIX=0` or `E2E_FOOTSTEP_SUBMIX=0` (the old direct route).
|
||
- UNCERTAIN: Sen0 #2's level (never posted, 1.0) and Pn21's law (class defaults); the release
|
||
de-click of a voice goes to SFX Master as for the other buses.
|
||
- Effect: the step layers keep the slot's open EQ, so they come out as before. A replay of the
|
||
user's walking (state log 21:22, rows 462–948) differs by 5e-8. The EQ records apply to the
|
||
surface layers (metal surface 5, special surface 7, and materials with their own footstep record).
|
||
|
||
**The engine inputs the components were missing** (no `skate-core` change; read from what the engine
|
||
already keeps; `skate_events::audio_state`). Retail's Skeleton::FillPhysOut (`sub_82BE1AE8`) and the
|
||
record writer (`sub_827A1B78`) read back from the TU3 code:
|
||
|
||
| audio state | retail | ours |
|
||
|---|---|---|
|
||
| `+740` step code | Skeleton `+144` / `+160` = the physical pose translations of toe parts 15 / 19 (Skeleton 9024 / 9280) at the OffBoard 306 / 307 plants | `StepCode::update(flags_306_307, left / right surface, record.pose[15/19][3].y)` |
|
||
| `+296` / `+292` foot "vertical" speeds | \|y\| of Skeleton `+304` / `+320` = the **angular** velocity (body `+48`; `+32` is linear) of the assembly bodies of parts 20 / 16 (assembly `+76 + 96·part`) | \|angular_velocity.y\| of `skeleton.bodies()[20 / 16]` (was the toes' linear speed, a proxy) |
|
||
| `+328` body speed | \|Skeleton `+288`\| = the angular speed of part 23's body | \|`bodies()[23]` angular velocity\| (was \|COM v\|, a proxy) |
|
||
| `+672` limb speed | 0.25 · (572 + 568 + 564 + 560): \|record velocity − Skeleton16176 (physical COM velocity)\| of parts 8, 4, 21, 17 | the same from `skeleton.record.velocities` and `reckoning.vector_16` (was 0) |
|
||
| `+528..+548` slide speeds | the ragdoll regions' tangential speed (SkeletonCollision `+944 + 4i`, published by `sub_82BD60C8` into Collision `+80..`; 0 without a contact part) | `collision_feedback.regions[i].tangent_speed` (was 0: body slide never played) |
|
||
| `+560..+580` slide tags | the regions' surface tags (`+976 + 4i`; the per-frame PhysOut reset leaves 0 without a part) | `regions[i].material_flags` |
|
||
| `+593` | SkeletonCollision byte 4009 = the face point's contact this frame (specific point 1) | `collision_feedback.specific[1].current` |
|
||
| loose board `up_dot` | SkateboardReckoning `+80` (the effective deck basis' Y = the physical deck up) · Ground `+80` (the retained wheel-contact normal); deck contact Collision `+3475`, material Collision `+12` − 1 | `skate_events::deck_up`; contact and material read raw (the riderless-board gate of the record's wheel fields does not cover them) |
|
||
|
||
- Lengths use `f32::sqrt`. Retail uses x · the twice-refined reciprocal-sqrt estimate; the two
|
||
differ by about 1 ulp.
|
||
- The state log appends `step`, `body`, `limb`, `slide`, `deck_up` and `deck_contact`.
|
||
`scenarios.py --from-log` copies them and the e2e replay reads them when present. Older logs keep
|
||
the old defaults.
|
||
|
||
**Reverb.in0..6 and the global env scale** (`sub_824DF468`, `sub_824DF390`; `bus::env::reverb_inputs`):
|
||
- *Who writes them.* `SFXObj_Reverb`'s update starts with `sub_824DF468`. It writes Reverb.in0..in6
|
||
= 0, then 32767 into one input.
|
||
- *Which input.* It comes from the number of the preset on the side being faded to (record `+48` =
|
||
the NN of reverbNN, jump table `0x824DF3D0`):
|
||
|
||
| presets | input |
|
||
|---|---|
|
||
| 1–5 | in4 |
|
||
| 6–8 | in0 |
|
||
| 9, 13, 14, 17, 18 | in1 |
|
||
| 10 | in2 |
|
||
| 21 | in3 |
|
||
| 11, 12, 15, 16, 22 | in5 |
|
||
| 19, 20, 23, 24 | in6 |
|
||
|
||
- *What it changes.* In5 drives F213 (Reverb out4 −400 mB) and F141 (music). In1 drives the
|
||
emitter / ambience ducks F21 / F40 / F41 and others.
|
||
- So our fixed in5 = 32767 was wrong. At reverb01 the MixMap now gives out4 = 32730 (scale 0.99887,
|
||
as the retail trace showed), and inside a reverb11 zone 20603 (−4 dB).
|
||
- The scale is applied each frame after the tick (`EnvNetwork::scale_frame`).
|
||
- Off: `SKATE_AEMS_REVERB_INPUTS=0` or `E2E_REVERB_INPUTS=0` (fixed in5 = 32767, no scale).
|
||
|
||
**The reverb-zone emitters** (`emitters::reverb_zones` → `native::reverb_frame` → `EnvNetwork::update`):
|
||
- *The earlier count was wrong.* The previous stage counted "one record". The disc has **1,040**
|
||
eVolumeType-5 records in 24 attributes, each naming a reverb preset (`99FD793BC30CF0FA`; reverb02…24):
|
||
- `reverb_downtown`: 616 records;
|
||
- `reverb_industrial`: 240;
|
||
- `reverb_university`: 133;
|
||
- the parks' `sfx_` files: 51.
|
||
- *Which files load.* The map database (`F4917ACACAFAF913` field `65FA976EF23A314E`) lists the files
|
||
a map loads. The districts load `music_`, `sfx_`, `reverb_`, `speakers_` and `crowds_`; the parks
|
||
load one file.
|
||
- *Per frame.* The zones holding the camera (sphere / ellipsoid with inner core, as for the sound
|
||
emitters) are listed in the order they were reached. The retail selector blends and rotates the
|
||
reverb toward them.
|
||
- *Setup.* The emitter export adds `reverb` (16 hex digits) to kind-5 records (`_value` now reads
|
||
an untyped `Attrib::RefSpec` as class + key). Dev install: `aems-port/tools/stage_reverb_zones.py`,
|
||
backup `.local/audio-re/audio_manifest.before-reverb-zone-stage.json`.
|
||
- Off: `SKATE_AEMS_REVERB_ZONES=0`.
|
||
- Test `emitters::a_downtown_reverb_zone_selects_its_preset_and_raises_reverb_in5`: in the core of a
|
||
`1F94F2F815C00368` zone, reverb11 fades in and commits, in5 = 32767, and out4 32730 → 20603.
|
||
|
||
**Boot order.** Retail posts `c_emitter_utility` → `Start_up_Play_ctl` → `c_foley_utility`. They share
|
||
one random generator, so the native host now posts in that order (the foley utility used to come
|
||
first, from the optional banks).
|
||
|
||
**Treatments slots 16 / 17** (settled 2026-10-02: the recomp plays them too):
|
||
- *Problem.* Ours plays Treatments 16 / 17 quietly in every ollie. Our recomp sessions seemed to show
|
||
they never play there, so `+240` (w8, the predicted time to landing) looked wrong around takeoff.
|
||
- *Evidence.* The user's TREAT session (`all_20261002_223306`, University: plain ollies, one ~2 s air
|
||
off a ramp; hook on `sub_824DD6F0`, 57,323 lines, 0 malformed) logs Class_Treatment's inputs per
|
||
rendered frame:
|
||
|
||
| input | ground | first air tick | in the air | landing tick | after |
|
||
|---|---|---|---|---|---|
|
||
| `+240` predicted time to landing | 0 | the whole prediction (0.70–0.77 s ollies, 2.01 s ramp) | −1/60 per tick | still counting (0.019, can go below 0) | 0 |
|
||
| `+236` air time | 0 | 1/60 | +1/60 per tick | still counting (0.767) | 0 |
|
||
| `+260` jump height | 0 | 0.22 m | rises to 1.19–1.21 m (ollies) | held | 0 |
|
||
| `+224` (G+4 byte) | 0 | 0 | 0 | 0 | 0 |
|
||
|
||
Values change per 60 Hz physics tick; the recomp updates the packet about 345 times a second. Our
|
||
audio state publishes the same shape: `+240` is KnownAir's Air+184 from the physics port.
|
||
- *Root cause.* A measurement error. Treatments 16 / 17 are byte-identical to sense_of_speed 3 / 4.
|
||
`retail_voices.py` matches PLAY payloads against the disc and took the first archive hit, so the
|
||
recomp's 16 / 17 voices were counted as sense_of_speed. Their sample addresses sit in the
|
||
Treatments bank's memory (0x4B22…/0x4B23…, next to 13 / 14 at 0x4B21…). The real sense_of_speed
|
||
3 / 4 voices are at 0x4AFF…/0x4B00…. Only 10 samples on the disc are shared across banks.
|
||
- *The recomp vs ours.* We replay the recomp's own per-frame inputs through our Class_Treatment, bank
|
||
and evaluator (`tests/player_tricks.rs` `treatment_replay_of_the_recomp_capture`).
|
||
- Voice counts:
|
||
|
||
| slot | 13 | 14 | 15 | 16 | 17 |
|
||
|---|---|---|---|---|---|
|
||
| the recomp | 18 | 18 | 1 | 18 | 18 |
|
||
| ours (replay) | 18 | 18 | 1 | 20 | 20 |
|
||
|
||
- In the 15 airs of 0.6–1.0 s, both play 16 and 17 once per air. The recomp starts them a median
|
||
87 ms after takeoff, ours a median 88 ms after (42–380 ms).
|
||
- Peak gains: the recomp 0.0042–0.0287 (median 0.0149), ours 0.0054–0.0320 (median 0.0155, +0.3 dB).
|
||
- *Change.*
|
||
- No change to 16 / 17 or `+240`.
|
||
- `retail_voices.py` now resolves duplicate samples by sample address (`play_resolve_all.json`;
|
||
`--no-disambiguate` gives the old attribution).
|
||
- `+236` now counts through the landing tick, as the recomp's does. It is Air+176, the time in the
|
||
air *state*, and the state is still 200..300 on that tick (`skate_events::air_time_236`). Before,
|
||
it dropped to 0 a tick early. Switches: `SKATE_AEMS_AIR_TIME_STATE=0` / `E2E_AIR_TIME_STATE=0`.
|
||
- The e2e scenarios now pass `to_land` / `jump_height` as `+240` / `+260`. Before they were 0, so
|
||
the e2e renders had no Treatments voices at all. Switch: `E2E_AIR_WORDS=0`.
|
||
- *Files.*
|
||
- `crates/skate-game/src/game_audio/skate_events.rs` (`air_time_236`, test
|
||
`air_time_holds_through_the_landing_tick`)
|
||
- `crates/skate-game/src/game_audio/e2e.rs`
|
||
- `crates/skate-audio/tests/player_tricks.rs` (the TREAT replay, a real test, and the diagnostic
|
||
`treatment_replays_the_recomp_capture`; the `_by_late_prediction` diagnostic is removed)
|
||
- *Verification.*
|
||
- skate-audio: all tests pass. game_audio: 48 pass.
|
||
- e2e on 13 scenarios, including a real-play cut with three ollies (`ollies_log`):
|
||
- with both switches off, every render is bit-identical to the previous renders;
|
||
- the `+236` hold alone is bit-identical too, even with Treatments playing (it also gives identical
|
||
voices in the replay);
|
||
- with the air words on, `ollie20` / `ollies_log` gain Treatments 13 / 14 at 0.0746 / 0.0186 (the
|
||
recomp: 0.0746 / 0.0186) and 16 / 17 at 0.013–0.030 (+0.01 dB overall).
|
||
|
||
**Evidence.**
|
||
- skate-audio: 180 lib tests plus the data tests pass (new: submix ×2, Reverb inputs).
|
||
- game_audio: 47 tests pass (new: the zone test).
|
||
- Python: 27 tests pass (new: the reverb RefSpec).
|
||
- e2e with every new part off is bit-identical to the previous stage (`.local/audio-re/e2e-on2`) on
|
||
11 of 12 scenarios. ollie20 differs in its random flip / cloth sample picks; that comes from the
|
||
other agents' work since 20:53, not from these parts, which consume no random numbers.
|
||
- All on against all off: ≤ 3.8e-5 peak difference and −0.00 dB on every scenario. That is the env
|
||
scale's −0.01 dB on the wet path, plus the submix.
|
||
- Tools: `aems-port/tools/e2e/render_diff.py DIR_A DIR_B`, renders in `.local/audio-re/skel/`.
|
||
|
||
### Leftovers: Treatments 16 / 17 timing, sense_of_speed figures, FootStep SubMix build values, the zone check, the other `.ems` files, fallbacks (2026-10-02, overnight)
|
||
|
||
**Treatments 16 / 17: the two remaining differences are the recomp, not the mechanism.**
|
||
- *Problem.* On the ~2 s ramp air (session `all_20261002_223306`, 102.0 s) the recomp started 16 / 17 538 / 577 ms
|
||
after takeoff at gains 0.051 / 0.026. Ours started them at once, at 0.10. In 2 of 7 airs under 0.5 s ours also
|
||
played a 0.004–0.005 voice where the recomp played none.
|
||
- *Root cause, the late start.* The recomp's audio thread stalled. The trace has **no audio lines at all
|
||
(PLAY / GAIN / SEND / MOD / XMA) from 101499 to 102500 ms**, while the game thread logged 1,724 GREC / TREAT lines.
|
||
At 102500 about 40 voices start in the same millisecond. The second late start in the long session
|
||
(`all_20261002_223613`, 443.4 s: 16 / 17 at 1113 ms, gain 0.005) follows a 1055 ms stall in the same way. That
|
||
session has 11 stalls > 250 ms, most of them about 1.0 s.
|
||
- Replaying the TREAT rows through our Class_Treatment with no audio rendered during that interval gives 17 / 16 at
|
||
650–680 / 780–810 ms after the blip, gains 0.062 / 0.046. Without the stall: 70–130 / 160–260 ms, 0.075–0.080.
|
||
- The 30 ms air blip before the takeoff plays no part: without the stall both renders start 16 / 17 in the
|
||
second air.
|
||
- *The short airs.* Whether a short air opens 16 / 17 depends on where the program's 32 ms walks fall against the
|
||
packet changes and on the shared random generator. Row-by-row replays at the recomp's cadence play none on the two
|
||
airs in question. Over both sessions the rate is the same: airs under 0.5 s with a 16 / 17 voice, ours (60 Hz
|
||
replay) 11 of 22, the recomp 10 of 22. In the long session's airs of 0.85–3.3 s both start 16 / 17 a median
|
||
50–90 ms after takeoff at matching levels (3 s airs: 0.15–0.19 in both).
|
||
- *Change.* None to the component.
|
||
- *Tools.*
|
||
- `.local/audio-re/treat/audio_gaps.py SESSION` lists audio stalls.
|
||
- `recomp_airs.py SESSION CSV` lists the recomp's Treatments voices per air, from the `retail_voices.py --csv` dump.
|
||
- Ignored tests in `tests/player_tricks.rs`: `treatment_airs_by_walk_phase` (row cadence, every walk phase, the ramp
|
||
air with and without the stall) and `treatment_replays_the_recomp_capture` (`TREAT_SESSION=` for the long session).
|
||
- **Check a session for audio stalls before reading timing out of it.**
|
||
|
||
**sense_of_speed recomp figures, redone with the shared-sample fix.** Script `.local/audio-re/sos/sos_figures.py`
|
||
(attributes each PLAY window's bank by sample address, as `retail_voices.py` now does; `--old` = first-match).
|
||
|
||
| session | sense_of_speed voices (old → fixed) | streams 3 / 4 (old → fixed) | Treatments 16 / 17 (hidden before) | Send A lines (non-zero) |
|
||
|---|---|---|---|---|
|
||
| 163809 | 645 → 624 | 125 / 124 → 114 / 114 | 11 / 10 | 282 (11, values 0.08–0.55) |
|
||
| 164620 | 1941 → 1827 | 299 / 303 → 244 / 244 | 55 / 59 | 801 (40) |
|
||
| 223306 | 269 → 233 | 43 / 43 → 25 / 25 | 18 / 18 | 72 (2) |
|
||
| 223613 | — → 1059 | — → 128 / 128 | 24 / 26 | 440 (9) |
|
||
|
||
- Send A stays 0 (FXWET0 = 0, as the updater writes). The few non-zero lines are module-address reuse by other
|
||
graphs; with the fix there are fewer of them, not more. The old "retail median 0.015" was such an artefact.
|
||
- *Ours against the recomp.* Ignored test `sense_of_speed_replay_of_the_recomp_speeds`: the local board's GREC
|
||
ground speed of 223613 per 60 Hz frame through SenseOfSpeed, the real bank, MixMap and evaluator. COM speed =
|
||
ground speed, so the wind is approximate in the air.
|
||
- Voice counts: 1068 for ours, 1059 for the recomp.
|
||
- Streams 0 / 1 / 3 / 4: 124 / 215 / 130 / 130 for ours, 111 / 219 / 128 / 128 for the recomp.
|
||
- 5–9: 24 in both. 10–14: 72 for ours, 61 for the recomp. 15 + 16: 343 for ours, 347 for the recomp.
|
||
- Streams 3 / 4 peak gain p90: 0.054 / 0.050 for ours, 0.058 / 0.056 for the recomp.
|
||
- **No difference to fix.** Before the fix the recomp seemed to play 3 / 4 about 20 % more often than our replay
|
||
would have.
|
||
|
||
**FootStep SubMix: the two "class default" values, read** (`sub_82494188`, plug-in descriptors in the image):
|
||
- *Pn21.* The build copies Pn21's four constructor arguments from its descriptor (`0x82FD0F98`, 40-byte records):
|
||
front 30 (1..90), side 110 (90..180), **normalisation 2** (0..2) and rear 150. It then overwrites argument 2 with
|
||
0.0, the same as the voice open (`sub_824A3140` passes 30, 110, 0). So the law is 0, a gain of 1.0, as `Pan2D`
|
||
already applies. Its parameters keep their defaults except the azimuth.
|
||
- *Sen0 #2.* It is only linked (0x7FF7FFF4 → SFX Master), never posted. Sen0's only parameter is `ATTRIBUTE_SETGAIN`
|
||
"Gain", default 1.0.
|
||
- The recomp's SEND lines confirm this: the eight FootStep SubMix graphs (2 feet × 4 slots; pattern `SEND1 SEND6`,
|
||
`.local/audio-re/busleft/submix_sends.py` on 223306) log Sen0 #2 = 1.0 from the build on and never change it.
|
||
- The same lines show Sen0 #1 at 1.0 from the build until the holder's level is posted (e.g. 0.059).
|
||
- *Change.* `bus/submix.rs` now starts Sen0 #1 at its class default 1.0, so the first posted level ramps from there
|
||
(`bus::env::Level::running`). Sen0 #2 and the law were already 1.0.
|
||
- *Effect.* e2e renders (13 scenarios incl. the walking replay `walk8`, `.local/audio-re/ovn/`) are bit-identical
|
||
with the change and with it reverted, submix on and off: the ramp runs in the block before the step's voice
|
||
starts.
|
||
|
||
**Reverb zones: the attribute check** (vfunc92 of the emitter manager, vtable `0x822FBDC8` +92 = `sub_824A2438`):
|
||
- It reads the attribute's reverb RefSpec (`99FD793BC30CF0FA`) and passes when its key is one of the **24 reverb
|
||
preset keys in the image table `0x8302E298`**. That is exactly the set of the 24 exported `aud_reverb` presets.
|
||
- A missing field reads the default RefSpec (key 0), which fails.
|
||
- *Change.* `emitters::zone_records` keeps type-5 records without a known preset as disabled zones instead of
|
||
dropping them: retail's query stops at the first type-5 node that fails. All 1,040 zone records on the disc
|
||
pass, so nothing changes in play.
|
||
|
||
**The music, speakers and crowds `.ems` files.**
|
||
- The emitter system loads all of a map's files and dispatches by eVolumeType. Every caller of the type getter in
|
||
TU3 checks only 1 (the looping-emitter pool, `sub_828E9ED8` / `sub_828EAD90` / `sub_828EB028` / `sub_828EB0F0`),
|
||
4 (`sub_828EB410`) or 5 (`sub_82488278`).
|
||
- *Type 4 (`music_*.ems`; 3 records in `reverb_downtown` / `reverb_industrial`) = music zones.*
|
||
- Their attributes reference class `E93ABF17955A5264` (RefSpec `442A158E27F2B5AD`): playlists with
|
||
`ePlayListMode`, `eChannelMapPreset` and `IPodSongInfo` / `DJSongInfo` song lists.
|
||
- `sub_824A2330` gates a node on the playlist record's `+4` = audio `+1608`. `sub_828EB410` keeps one winner
|
||
(smallest distance) and `sub_824A2198` stores its playlist key, volume, the float `463B21BB46415616` and its
|
||
distance / position in the emitter system.
|
||
- This feeds the licensed-music player, which we do not have. **Not ported.**
|
||
- *Types 6 / 7 (`speakers_*`, `crowds_*`, and the Maloof / MegaPark `sfx_` files).*
|
||
- They have no bank, and every record carries flags. The emitter system's flags mask is audio `+1032`, or 4 when
|
||
that is 0 or 0x80000 (`sub_8248B258`).
|
||
- Their nodes are never dispatched by type: speakers belong to the music / event PA, crowds to event crowds
|
||
(`0B5950C0184014C2` = crowd type 3 / 4). This needs event gameplay we lack. **Not ported.**
|
||
- *Change.* `emitters::update` now reads the sound emitters (type 1, flags 0) from all of the map's files, as
|
||
retail. Only the `sfx_` / `skateschool` files hold type 1, so the emitter list is unchanged.
|
||
|
||
**Native as the default with missing install data** (new data-gated test
|
||
`native::tests::missing_install_parts_fall_back_without_breaking`: copies of the dev manifest with parts removed,
|
||
data folders linked):
|
||
|
||
| case | result |
|
||
|---|---|
|
||
| no AEMS projects / no `emitter_utility` bank | runtime off, warning, measured tables |
|
||
| no MixMap | native on, no player components or bed (interim player cues and rolling loop) |
|
||
| no GRINDS (a core player bank) | native on, components off (interim cues) |
|
||
| no Treatments, or its file missing | native on, treatment off, the rest on |
|
||
| no `Common` (seam utility) / no bus tuning | native on, warning |
|
||
| no Splice trees | native on, contacts and footsteps off (interim cues) |
|
||
|
||
The per-frame host code (`native`, `player_audio`, `grain_bed`, `emitters`, `skate_events`) has no `unwrap` /
|
||
`expect` outside tests. Each interim cue is gated on its own part's flag.
|
||
|
||
**Files.**
|
||
- `crates/skate-audio/src/bus/submix.rs`, `bus/env.rs` (`Level::running`)
|
||
- `crates/skate-game/src/game_audio/emitters.rs` (zone check, all files for type 1)
|
||
- `crates/skate-game/src/game_audio/native.rs` (fallback test)
|
||
- `crates/skate-audio/tests/player_tricks.rs` (`render_frames`, `replay_treat_rows`, `treatment_airs_by_walk_phase`,
|
||
`sense_of_speed_replay_of_the_recomp_speeds`)
|
||
- Tools in `.local/audio-re/{treat,sos}/`
|
||
|
||
**Verification.**
|
||
- skate-audio: all tests pass (183 lib).
|
||
- game_audio: 50 pass.
|
||
- skate-game `--no-run` builds.
|
||
- Python: 27 tests pass.
|
||
- e2e: identical with every new part off, and with it on (above).
|
||
|
||
### Listening test 9: University water "overplaying itself, doubling or restarting" (2026-10-02, build 21:57)
|
||
|
||
**Report.** At (344.2, 68.03, −322.5), beside the dark water channel by the plaza trees: "It sounds like it is
|
||
overplaying itself and doubling or restarting? its hard to tell." Session `state_20261002_215843` (0 malformed),
|
||
`SKATE_AEMS=1`.
|
||
|
||
**What played.** The state log puts the user off board and still at (344.5, −322.5) from 29 s to 119 s. In that window
|
||
the only water is `.ems` record #27, `water_fountain` (13 × 7 × 61 m ellipsoid, patch 81), posted once and never
|
||
re-posted (03:59:08 → 04:01:00). Record #50, `fountains_waterlaps_left` (6 × 4 × 47 m, patch 347), was reached only
|
||
while walking along the channel (20.7–26.7 s, 46.1–47.2 s, 122.7–131.5 s). Its 4 m vertical semi-axis around y = 67.7
|
||
puts the listener (the camera) near its edge.
|
||
|
||
**Double-play candidates, all ruled out headless.**
|
||
- Interim water: with the native runtime the `PROFILES` relay is off for every bank in the install (log tag
|
||
`(native)`). `water.rs` is gone. Splashes fire only on water entry, and there were none. No location set played
|
||
water (only birds and jets).
|
||
- Two records: University's other fountain record (#46) is 109 m away. #27 and #50 overlap, and retail plays both
|
||
together too (sessions `all_20261002_180430` and `214002` show both banks in the same seconds).
|
||
- Cross-talk between banks: every `c_emitter` post reaches every loaded `c_emitter` bank. With `water_fountain`,
|
||
`fountains_waterlaps_left` and `trees_rustle` loaded and posts 81 and 347 live, only the matching bank plays
|
||
(`emitter_scene_probe`).
|
||
- Re-posting: release ends the post's voices at once. A re-post starts one fresh relay, never a second one. Two
|
||
voices at most per fountain post over 15 minutes.
|
||
|
||
**What the programs do** (`crates/skate-audio/examples/emitter_scene_probe.rs`, real banks and WAVs, redelivered every
|
||
60 Hz frame as `emitters.rs` does):
|
||
- `water_fountain` is a two-player relay of ten 1.6–2.5 s one-shots at pitch 0.82–0.94. Each player has its own op-8
|
||
shuffle bag (two op 8 blocks, 316 and 540).
|
||
- The next piece opens when the current one has about 0.17 s of source time left: 0.19–0.26 s before it ends in wall
|
||
time.
|
||
- Over 15 minutes that gives 14.4 starts per 30 s. The same piece plays twice in a row 11 % of the time (48 / 432),
|
||
because the two bags are independent. Each bag avoids its own repeats (0 repeats one start apart).
|
||
- `fountains_waterlaps_left` is one 37.46 s loop (loop start frame 189, clean wrap). It restarts from the top on
|
||
every post.
|
||
|
||
**Retail, earlier sessions.** `.claude/skills/aems-port/tools/retail_relay.py` lists a bank's PLAYs with their gaps.
|
||
- `water_fountain`: retail starts every 1.73–2.53 s, about one piece length apart (gap / length 0.99–1.07 at
|
||
the traced rate). That matches the relay above. No back-to-back repeat in the 5 retail transitions, which is too
|
||
few to tell from 11 %.
|
||
- `fountains_waterlaps_left`: retail re-posts it on fresh players 2–14 s apart as the listener crosses its edge, the
|
||
same restart-on-entry as ours.
|
||
|
||
**Retail at the spot (WATERRELAY, `all_20261002_222155`, clean).** The user walked to the channel and stood at
|
||
(346.0, −325.7) from about 61 s to 104 s. The fountain was posted once (c_emitter POST at 47.76 s, first piece 34 ms
|
||
later) and played 24 pieces. Run `retail_relay.py SESSION water_fountain [--from MS]`.
|
||
- **Steady stretch while standing (66.8–102.2 s), 18 starts:**
|
||
- Each relay turn draws from its own bag: A = 6 7 1 4 5 3 0 8 2 9 | 0 7, B = 6 7 1 8 2 9 0 5 3 4 | 6 8 (the first
|
||
three of each come from the approach stretch). Each is a complete pass of all ten pieces with no repeat. The
|
||
first pick of the next pass is never the last pick of the pass before. This is op 8 exactly as ported (avoid
|
||
flag at the wrap), with one bag per player, as in the bank (`emitter_scene_probe` prints both bags: range 10,
|
||
identity set).
|
||
- Back-to-back same piece: 1 of 17 (6 %; the 0 → 0 where bag A wraps). Ours is 11 % (48 / 432). With 17
|
||
transitions, retail's count is consistent with 11 % (P(≤ 1) ≈ 0.43).
|
||
- Cadence: gap / piece length p10 / p50 / p90 0.99 / 1.07 / 1.09, against ours 0.99 / 1.04 / 1.08. That is
|
||
15.3 starts per 30 s against our 14.4. The two retail outliers (1.19 and 1.58) sit on a recomp stall: no PLAY
|
||
and a 411 ms GREC gap at 76.4–77.6 s.
|
||
- **Approach stretch (47.8–64.2 s, listener walking in):** the first six pieces came in identical pairs (6 6 7 7
|
||
1 1): both bags made the same first three picks. They were also slow (gap / length 1.10–1.88, no overlap). Ours
|
||
doesn't do this. A fresh post draws both bags in its first walk and B's draw is never played (`PROBE_SHUFFLES=1`
|
||
prints every draw). Two explanations failed: a shared bag (ruled out by the steady stretch), and a slower relay
|
||
from the pitch word alone (w4 2600 slows ours but keeps the bags apart). The session has no MOD (pitch) lines, so
|
||
this is parked. It doubles **more** than ours, so it isn't what the user heard while standing.
|
||
- `fountains_waterlaps_left`: two brief activations while the listener settled (PLAY 57.78 s and 62.60 s, each
|
||
voice's player reused for other sounds within 1.1 s / 1.6 s), then silent while standing. That is the same
|
||
restart-on-entry and edge behaviour as ours. One difference: the second start came 0.57 s after its post (62.03 s)
|
||
instead of ~20 ms, probably retail reloading the bank (it unloads banks no node uses; we keep them until the map
|
||
changes).
|
||
|
||
**Verdict.** No doubling bug, and no code change. In the standing case the user reported, our relay matches retail
|
||
in mechanism (per-player op-8 bags, wrap rule), repeat rate and cadence. The "restarting" is authored:
|
||
- each piece's attack lands over the previous piece's tail;
|
||
- now and then the same splash plays again about 0.2 s before it ends;
|
||
- while walking, the channel loop starts over at each re-entry.
|
||
|
||
Open (parked):
|
||
- The approach-stretch pairing (both bags making the same picks), which needs an op-8 draw hook or MOD lines to
|
||
settle.
|
||
- The re-post bank-reload delay.
|
||
|
||
Side finding: the user's session logs
|
||
`OFFBOARD_GROUND_RESULT_NONFINITE` every frame while the user stood there (physics, off-board ground job). It did not
|
||
interrupt the emitter and is not audio, but it needs its own look.
|
||
|
||
**Files.** New: `crates/skate-audio/examples/emitter_scene_probe.rs` (scene probe; prints the op-8 bags and, with
|
||
`PROBE_SHUFFLES=1`, every draw); local tool `.claude/skills/aems-port/tools/retail_relay.py` (`--from/--to`, bags
|
||
by relay turn).
|
||
|
||
### Listening test 9 (seams): brick and University concrete; the console's 30 fps seam cadence (2026-10-02, build 21:34)
|
||
|
||
(Numbered with the water report above: both come from the same round of listening.)
|
||
|
||
**Problem.** The 21:34 build loads `Start_up_Play_ctl`. With it, the user said of both surfaces: "both sound better but
|
||
still need work". Earlier reports, verbatim:
|
||
- University concrete: "the same concrete still sounds too rough on our engine".
|
||
- DownTown brick: "still firing too much or too often and sounds like your going faster than you are".
|
||
|
||
Sessions (all clean, 0 malformed lines):
|
||
- Aletown brick: ours `state_20261002_213757`, the recomp `all_20261002_214002`.
|
||
- University at the PCU Library: ours `state_20261002_214224`, the recomp `all_20261002_214346`.
|
||
- A later recomp session with new seam hooks: `all_20261002_223306`.
|
||
- SEAMPAT logs `+636`/`+648`/`+620`/`+208`, the frame time and the four wheel positions on every Class_Seams process
|
||
call.
|
||
- SEAMHIT logs every hit.
|
||
|
||
Tools are in `.local/audio-re/seams9/`:
|
||
- `pairs.py`, `tagrate.py`, `agg.py`, `blocks.py`, `window_compare.py`, `hit_prominence.py`;
|
||
- `seampat.py` and `seamhit_match.py` for the hook session;
|
||
- `recomp_fps.py`.
|
||
|
||
Program probe: `skate-audio/examples/seam_pulse_probe.rs`. It drives the real Seams program and samples with per-block
|
||
w7 sequences or with a host model.
|
||
|
||
**Mechanism (asm).** `sub_82485190`, the audio manager, runs once per rendered frame: process (vtable +16), then the
|
||
MixMap tick, then update (+20).
|
||
- On every call, Class_Seams' process (`sub_824C14C8`) clears w7, then fires a hit when a wheel's grid cell changed.
|
||
- The program walks every 32 ms and sees only the latest w7.
|
||
- So a hit's pulse lasts one rendered frame, and that length decides how many hits the program sees and which of its two
|
||
players start.
|
||
|
||
Every hit starts a "base" block (samples 32–47, the same on every surface) and a surface block (80–87 on brick, 104–119
|
||
on concrete). The share of base-block voices works as a fingerprint of the pulse length:
|
||
|
||
| pulse length | base-block share |
|
||
|---|---|
|
||
| one 60 Hz tick | 2–32 % |
|
||
| 3–5 ms | 45–57 % |
|
||
| 33 ms | every hit is seen |
|
||
|
||
**The recomp is not the console.** The recomp renders uncapped. In 223306 it calls the process every 2.9 ms (frame-time
|
||
field median 2.5 ms). The speed changes on 18 % of calls (the 60 Hz physics step); the wheel positions change on 37 %
|
||
(the rendered pose, between the two rates). So the recomp's pulses last about 3 ms. On the shipped game (about 30 fps)
|
||
they last one 33.3 ms frame.
|
||
|
||
Measured in the recomp:
|
||
- Base-block share: 41–50 % in all sessions.
|
||
- Rates: 25–36 voices/s on brick (tag 66), 21–30 on concrete (tag 4, pattern 8).
|
||
- Pattern 11 (sidewalk), **settled.** The recomp reads `+636` = 11 there (SEAMPAT). It fires 15–21 hits a second at
|
||
20–40 km/h, but plays only about 0.5 voices per hit: 7.5 voices/s in 223306, about 3 in 214346. The sparse sidewalk
|
||
comes from its short pulse, not from the pattern.
|
||
- Our cell computation matches retail's. From the logged positions, 400 of 520 grid hits coincide with a cell change of
|
||
that wheel on the same call.
|
||
|
||
**Change: the console cadence at any frame rate.** User decision, verbatim: "match the shipped game, use the 30 fps
|
||
cadence however lets scale up and account for the higher framerates that the rust engine is going to be running at".
|
||
- **Virtual calls.** `player::seams::Seams::frame` runs on every rendered frame (`PlayerAudio::seam_frame`, from
|
||
`native::mixmap_frame`). It makes virtual process calls on a fixed 30 Hz grid of real time.
|
||
- **Wheel positions.** Each virtual call reads the wheel positions at its own time, interpolated along the rendered
|
||
trajectory: the physics samples interpolated by `Time<Fixed>::overstep_fraction`, as the presentation does. So it sees
|
||
the same crossings the console samples.
|
||
- **The pulse.** A hit's w7 is redelivered at once and cleared on the audio clock after 6 or 7 blocks (6.25 on average,
|
||
one console frame), via `SeamCommand::RedeliverAt` and the new `Runtime::redeliver_at`.
|
||
- **The update half** (levels, the turn word's per-write slew) also runs once per console frame.
|
||
- **The 60 Hz tick** only creates the packets and writes Cracks.in0 (`process_tick`), latched from any hit since the
|
||
last tick.
|
||
- **Below 30 fps,** several virtual calls run in one frame.
|
||
- **Default and off switch.** On by default. `SKATE_AEMS_SEAM_PULSE=0` restores the old path: the whole process per
|
||
60 Hz tick, at the physics positions.
|
||
|
||
**Proof of frame-rate independence.** E2E `E2E_FPS=f` runs the game's host at f fps. Over all straight rolling above
|
||
10 km/h in both logs, voices per second and base-block share per tag:
|
||
|
||
| tag | 30 fps family (30 / 29.7 / 30.3 / 20) | 59–370 fps (59, 60, 61, 140, 144, 150, 240, 360, 365, 370) |
|
||
|---|---|---|
|
||
| 3 | 16.9–20.6, 50–59 % | 17.5–21.0, 47–60 % |
|
||
| 5 | 32.9–37.6, 10–30 % | 28.0–41.0, 7–32 % |
|
||
| 66 brick | 18.4–20.7, 10–15 % | 16.6–21.0, 3–22 % |
|
||
| 4 concrete | 17.4–20.1, 20–26 % | 17.7–22.7, 19–38 % |
|
||
|
||
- Grid hits are the same at every frame rate: 2121–2141 (brick log) and 1189–1207 (University log).
|
||
- Per-voice gain is the same at every frame rate.
|
||
- The 30 fps spread is phase noise in the program's shuffle, and every higher frame rate falls inside it.
|
||
- Unit test `seams::tests::console_cadence_is_frame_rate_independent`:
|
||
- hits within 2 % from 30 to 365 fps (5 % at 20 fps);
|
||
- every pulse lasts 6 or 7 blocks, with a mean of 6.25.
|
||
- For comparison, our old 60 Hz host (the 21:34 build) gave tag 66 at 25.8 voices/s with a 25 % base share, and tag 4
|
||
at 26.0 voices/s with 20 %.
|
||
|
||
**Against the recomp (a high-fps reference, not the target).**
|
||
|
||
| | ours, console cadence | the recomp |
|
||
|---|---|---|
|
||
| brick | ~19 voices/s, base ~14 % | 25–36 voices/s, 46–50 % |
|
||
| concrete | ~19 voices/s, base ~23 % | 21–30 voices/s, 41–49 % |
|
||
| sidewalk | 14–32 voices/s (every hit is seen) | 3–7.5 voices/s (short pulses) |
|
||
|
||
A per-call model run at the recomp's own rate matched the recomp within about 20 % on rate and share, so the mechanism
|
||
reproduces both machines. No console capture exists to check the 30 fps numbers directly.
|
||
|
||
**Other comparisons (no change).**
|
||
- Grain bed (the sounding truck's player A), the recomp vs ours:
|
||
|
||
| window | gain | position |
|
||
|---|---|---|
|
||
| concrete, 43 km/h | 0.237 vs 0.245 | 0.78 vs 0.78 |
|
||
| brick | 0.208 vs 0.196 | 0.494 vs 0.494 |
|
||
- Rattles: about one per push in both.
|
||
- Spidercracks and Class_rolling: none in these windows.
|
||
- Octave spectrum above 125 Hz: within about 2 dB. The recomp's capture also holds ambience bass.
|
||
|
||
**Open.**
|
||
- **Grain pitch.** The recomp's GREC pitch is about 0.97 (0.966–0.991 by band). Ours is 0.995 in e2e, where the
|
||
listener is synthetic and gives no Doppler. SkateBoard pitch(3) carries the MixMap's Doppler term, so in game the
|
||
camera decides it. Not yet measured in our game.
|
||
- **Hit prominence.** A hit's high-frequency rise over the rolling median is 2.8–6.0 dB in the recomp's capture and
|
||
5.5–11 dB in ours. Voice gain matches, so the cause is downstream: pitch, bus or masking. *Re-measured in "The MixMap
|
||
on the console cadence; seam-hit prominence re-measured": the recomp figures were taken ~110 ms before the hits.*
|
||
- **Other per-frame rules.** On the console the whole audio manager is per rendered frame at 30 fps. That covers the
|
||
MixMap tick (dt 1/30), the eEQChain jitter every second frame (15 Hz on the console, 30 Hz in ours), the Doppler slew
|
||
per evaluation, and other components' one-frame pulses. Only Class_Seams follows the console cadence so far. These are
|
||
listed for the host owner. *Done for the MixMap, the Jitter and the eEQChain clear (next section); the eEQChain clear
|
||
is 30 Hz on the console, not 15 Hz.*
|
||
- **Landed latch.** Wheel 0's landed latch stays per 60 Hz tick. *Correct: the conditioners run per physics step (next
|
||
section).*
|
||
|
||
**Validation.**
|
||
- skate-audio: 181 lib tests and the data tests pass.
|
||
- `game_audio::`: 47 tests pass.
|
||
- The `--no-run` build and `cargo check` pass.
|
||
- Default e2e renders (one call per row, the old path) are byte-identical to the 21:34-era renders of both logs.
|
||
|
||
**Files.**
|
||
- Changed:
|
||
- `skate-audio/src/player/seams.rs` (`frame`, `process_tick`, `expire`, `RedeliverAt`, the Cracks latch);
|
||
- `skate-audio/src/runtime.rs` (`redeliver_at`);
|
||
- `skate-game/src/game_audio/{native,player_audio,e2e}.rs`.
|
||
- New: `skate-audio/examples/seam_pulse_probe.rs`.
|
||
- E2E switches: `E2E_FPS`, `E2E_CALLS`, and `E2E_SUBSTEPS` (diagnostic: the whole host N times per row).
|
||
|
||
### The MixMap on the console cadence; seam-hit prominence re-measured (2026-10-03, overnight)
|
||
|
||
**Problem.** Listening test 9 left two items open. First, the per-frame host rules other than Class_Seams (the MixMap
|
||
tick, the eEQChain jitter, the Doppler slew, wheel 0's landed latch) still ran on our 60 Hz step instead of the shipped
|
||
console's cadence. The user's decision for that: "match the shipped game, use the 30 fps cadence however lets scale up
|
||
and account for the higher framerates". Second, the seam hits measured more prominent above the rolling bed in ours
|
||
than in the recomp's capture (5.5–11 dB vs 2.8–6 dB high-frequency rise), and ours showed more high-frequency onsets.
|
||
|
||
**Mechanism (asm, `sub_82485190`, the audio manager; reference reading only).** The manager runs once per rendered
|
||
frame with that frame's dt and splits its work into two halves.
|
||
- Half 1: `sub_82491180` (the eEQChain clear and jitter post), the listener (`sub_8248CC08`), then every state
|
||
manager's process (vtable +16). That reaches each SFX object's process slot +36, which includes `SFXObj_Jitter`'s
|
||
walk step (`sub_824EF378`, vtable `0x822FC248` +36).
|
||
- Half 2: the MixMap tick (`sub_8294BAE8`, the halves' summed dt), then every update (vtable +20).
|
||
- When dt is above 0.02 s (`0x822F8DE8`), both halves run on every call. At or below it they alternate, each with the
|
||
sum of the last two dts.
|
||
- So the console at ~30 fps evaluates the MixMap, steps the Jitter and clears the eEQChain buses **once per 1/30 s**,
|
||
with dt ≈ 1/30. It would be the same at 60 fps, with the halves alternating at 30 Hz each.
|
||
- The eEQChain clear is in half 1, so it runs on **every** console frame (30 Hz). The "15 Hz" in the previous section was
|
||
wrong. Our old host's rule, every second 60 Hz frame, was already 30 Hz.
|
||
- Envelopes use dt. The Doppler slew `D −= trunc((target − D)·(−0.2))` and the jitter walk are per evaluation, so at our
|
||
old 60 Hz both ran twice as fast as on the console.
|
||
- The recomp renders uncapped (~345 fps), so its halves alternate at ~170 Hz: not the console's rate.
|
||
|
||
**Change.**
|
||
- `skate_audio::mixmap::cadence::Cadence` picks the 60 Hz host steps that carry a console evaluation: every second step
|
||
on the fixed grid of real time the 60 Hz steps already use. `native::mixmap_frame` keeps the inputs, the components'
|
||
process and update per 60 Hz step. On the steps that carry an evaluation it also:
|
||
- clears the eEQChain buses, before the inputs, as half 1 does;
|
||
- steps the Jitter walk once (`PlayerAudio::jitter_steps`);
|
||
- ticks the MixMap with dt = `CONSOLE_DT` (1/30).
|
||
- Below 30 fps one real frame can carry two evaluations; both run.
|
||
- **Held inputs.** Retail's writers run once per evaluation, so a one-frame flag lasts a whole console frame and the tick
|
||
always sees it. Ours write per 60 Hz step, so a flag set on the step between two evaluations would be cleared before
|
||
the tick. The new `MixMap::hold_input` makes the next tick see the largest value written since the last one; the
|
||
stored input keeps the last write. It is used for the six flag inputs (`native::hold_flag_inputs`):
|
||
- Contacts 1 / 6 (landing swell, landing material);
|
||
- Rail 1 (grind ended);
|
||
- SkateBoard 0 / 4 (surface change, push plant);
|
||
- Cracks 0 (seam hit).
|
||
|
||
Every other input the host writes is a level.
|
||
- On by default. `SKATE_AEMS_MIX_CONSOLE=0` restores the old host: an evaluation per 60 Hz step with dt 1/60, the Jitter
|
||
per step and the eEQChain clear every second step. In e2e the cadence is on with `E2E_FPS` (the game's host) and off in
|
||
the per-row renders; `E2E_MIX_CONSOLE=0/1` forces it.
|
||
- e2e fix: below 60 fps one call spans several rows, but the listener's velocity (Doppler) and the bed's frame took one
|
||
row's dt. They now take the call's dt, as the game passes the real frame time. This affects only `E2E_FPS` < 60
|
||
renders; the 30 fps renders in "Listening test 9 (seams)" had this error.
|
||
|
||
**Wheel 0's landed latch: no change.** The latch is the conditioner `sub_82772FD8` (called from `sub_82772748`). That is a
|
||
physics-side PhysOut conditioner (vtable `0x82310A78`, listed among physics jobs at `0x82348E98`), not an SFX object.
|
||
The recomp shows the conditioners' outputs change at the physics rate, not per rendered frame. In session 223306 the air
|
||
time `+236` (TREAT) changes on 860 of 5,094 in-air calls (17 %), always by 1/60 s (0.0167 / 0.0166). Its "more than 5
|
||
frames in the air" are physics steps on the console too, so our per-60 Hz-step latch (`Seams::process_tick`,
|
||
`contacts`, `rolling`) already matches.
|
||
|
||
**Proof (e2e, both user logs: full1 = brick 213757, full3 = University 214224; `.local/audio-re/cadence/`,
|
||
`fps_band.py`).**
|
||
- Identity with the new parts off: the default per-row renders, and `E2E_FPS=60 E2E_MIX_CONSOLE=0`, are byte-identical
|
||
(f32 and voices) to a pre-change build of the same tree (`.local/cb`, my files reverted).
|
||
- Frame rate, with the console cadence on:
|
||
|
||
| fps | full1 grain A gain p50 / p90 | full1 A pitch p50 | full3 A pitch p50 |
|
||
|---|---|---|---|
|
||
| 29.7 / 30 / 30.3 | 0.2204 / 0.2205 / 0.2019, p90 0.2491 / 0.2491 / 0.2488 | 0.9907 / 0.9907 / 0.9895 | 1.1069 |
|
||
| 20 | 0.2192 / 0.2491 | 0.9907 | 1.1069 |
|
||
| 60 / 144 / 240 / 365 | 0.2012 / 0.2488 (all four identical) | 0.9912 | 1.1074 |
|
||
|
||
- The MixMap-driven quantities are identical from 60 to 365 fps.
|
||
- Between 60 and 365 fps only the seams' shuffle phase moves: 18.3–20.0 seam starts/s, against 18.0–19.0 at 29.7–30.3.
|
||
- The 60+ fps values sit in the 30 fps band or within 0.0005 of it.
|
||
- The remaining 30-vs-60 fps difference in per-second levels was already there with the old cadence: median
|
||
|Δ| 0.61 / 0.24 dB old vs 0.42 / 0.18 dB new. It comes from the components' process running once per real frame below
|
||
60 fps (one state sample per two physics steps), not from the MixMap.
|
||
- New cadence vs old at 60 fps: per-second level median |Δ| 0.27 / 0.08 dB, p90 0.96 / 0.41 dB. The visible change is
|
||
the Doppler: grain A pitch p10 0.839 → 0.820, because the slew now runs at the console's 30 Hz.
|
||
- Unit tests: `mixmap::cadence::tests::evaluations_follow_the_steps_not_the_frames`,
|
||
`mixmap::tests::a_held_flag_written_between_ticks_reaches_the_next_tick_once`.
|
||
|
||
**Seam-hit prominence, re-measured (no change made).** Tools in `.local/audio-re/seams10/`: `windows.py`, `perhit.py`,
|
||
`lag.py` (event-triggered average), `jitter.py`.
|
||
- **The old comparison was misaligned.** `hit_prominence.py` took the recomp's high-frequency peak 0–40 ms after the
|
||
logged PLAY time.
|
||
- An event-triggered average of the capture's >2 kHz envelope around ~500 hits per session puts the hits **~110 ms
|
||
after** the logged time in every session (214002, 214346, 223306, 223613).
|
||
- Isolated strong hits spread from 75 to 160 ms (interquartile), so the capture's alignment jitters by about ±45 ms.
|
||
- Ours peak at +40 ms (the voice log is per 60 Hz frame).
|
||
- So the recomp's "2.8–6 dB" was mostly the bed before the hit.
|
||
- **Aligned comparison.** Event-triggered peak over the local median, by band (four recomp sessions vs our two renders):
|
||
|
||
| band | the recomp | ours |
|
||
|---|---|---|
|
||
| 0.5–1 kHz | 2.3–3.7 dB | 5.5–5.8 dB |
|
||
| 1–2 kHz | 3.4–5.0 dB | 7.0–7.5 dB |
|
||
| 2–4 kHz | 5.0–6.4 dB | 8.3–8.6 dB |
|
||
| 4–8 kHz | 6.4–7.7 dB | 8.8–10.0 dB |
|
||
| 8–16 kHz | 7.3–8.3 dB | 11.5–13.2 dB |
|
||
|
||
The recomp's figures are blurred by its alignment jitter. Adding Gaussian jitter of 30–60 ms to our hit times moves
|
||
ours by at most about 1 dB (the hits are dense), so a gap of about 2–3 dB below 2 kHz and 3–5 dB above 8 kHz remains.
|
||
- **Mechanism checks, all equal or not the cause:**
|
||
- **Pitch.** Seam voice pitch p10/50/90: the recomp 0.98 / 1.10 / 1.18 (163809 MOD); ours 0.99 / 1.08 / 1.16.
|
||
- **Record levels.** Seam voice gain over the bed's gain A while rolling at 20–50 km/h: the recomp −9.0 to −13.1 dB,
|
||
ours −11.1 to −13.4 dB. Ours is not louder at the record level.
|
||
- **Bus.** The seams post to eEQChain 8 = the default bus input (no EQ), like the bed's graph 2.
|
||
- **Voices per hit.** The same distribution (about 60 % single, 25 % pairs; identical samples per hit alike).
|
||
- **Fold.** 99 % of our render's energy is in C (the board ahead of the camera), so the capture's fold weights don't
|
||
change the comparison.
|
||
- **Bed spectrum.** Away from hits, relative to its 0.5–1 kHz band, the bed is within about 1.5 dB per octave up to
|
||
16 kHz.
|
||
- **Low-pass.** The recomp's seam voices often play low-passed: 54 % of their starts set the LPF below 24 kHz (down
|
||
to 8,990 Hz). The Seams program maps w15 = |turn·1000| (state +204, slewed ±100 per write) to the voice LPF:
|
||
w15 ≤ 200 → 24,971 Hz, 300 → 20,177, 600 → 15,382, 1000 → 8,990 (`program_trace`). Ours does the same. Our rolling
|
||
|turn| is distributed like the recomp's (both p75 0, >0.25 on 4–18 % vs 13–31 % of frames), so on straight rolling
|
||
the filter is open in both.
|
||
- **Grain start fades.** The fade request's curve is 1.0 (`0x8231A844` in `sub_828EC2F8`) → GaF0's square-root
|
||
curve, as ported. The window picks are bit-exact with the PoC vectors.
|
||
- **Onsets.** Our high-frequency onsets coincide with grain starts no more often than chance: 45 % and 41 % within
|
||
±25 ms, with ~9 grain starts/s across both trucks' players, where chance is ~45 %.
|
||
- The remaining 2–5 dB has no mechanism found. Candidates: what else the capture holds around the board (ambience at
|
||
6–17 dB below the bed in high frequencies, other skaters, the recomp's 25–36 seam voices/s raising the local median)
|
||
and the bed's post-record chain. No level change: retail parity needs a mechanism, not a fitted weight.
|
||
|
||
**Files.**
|
||
- New: `skate-audio/src/mixmap/cadence.rs`.
|
||
- Changed:
|
||
- `skate-audio/src/mixmap/{mod.rs, tests.rs}` (`hold_input`, tick split into hold + `evaluate`);
|
||
- `skate-game/src/game_audio/native.rs` (`mix_console_requested`, `hold_flag_inputs`, the cadence in
|
||
`mixmap_frame`);
|
||
- `skate-game/src/game_audio/player_audio.rs` (`jitter_steps`);
|
||
- `skate-game/src/game_audio/e2e.rs` (`E2E_MIX_CONSOLE`, the call dt fix).
|
||
|
||
**Verification.** skate-audio: 183 lib tests and the data tests pass; `game_audio::` 50 pass; the `--no-run` build
|
||
passes; the e2e identity and frame-rate band are above.
|
||
|
||
**Open.**
|
||
- The components' own per-call rules still run per 60 Hz step. Retail runs every process / update on the 30 Hz cadence
|
||
too, but its conditioners hold pulses over several physics steps for that. Examples: Seams' turn-word slew
|
||
(already 30 Hz), the bed's per-frame steps (`grain_bed::update` runs per rendered frame, dt-scaled), and frame
|
||
counters in the components. Moving those is a separate, wider change; it touches components other agents own.
|
||
- The remaining seam-prominence gap (above). A console capture, or a recomp capture with a time-exact voice log, would
|
||
settle it.
|
||
|
||
### World sound sources: traffic, pedestrians, speech (2026-10-03, `skate_audio::world`, `game_audio/world_sources.rs`)
|
||
|
||
**Problem.** The traces had already found retail's ped voices, ped footsteps, traffic engines, horns, alarms and skids
|
||
(todo `audio-world-npc.md`). The engine has no living world (no peds, no traffic), so none of them could play. The
|
||
user asked for the foundation: the retail sound objects ported and ready, so that a future ped or vehicle system
|
||
only has to publish state.
|
||
|
||
**Root cause.** These sounds are not emitters. Each comes from a per-object SFX owner on its own MixMap slot:
|
||
- Traffic slot 4: 4 instances. TrafficEngine, TrafficSkids, TrafficHorn.
|
||
- Pedestrian slot 5: 15 instances. PedestrianSpeech, PedestrianSFX, PedBodyFall, Tazer.
|
||
|
||
Each owner is driven by its game object's state, so the owners need that state before they can sound.
|
||
|
||
**Evidence** (TU3 recompilation, reference only; spec notes `.claude/notes/world-traffic-audio.md`, `world-ped-audio.md`,
|
||
`world-speech.md`):
|
||
- **TrafficEngine** (`sub_824D6110` / `sub_824D6478`): an RPM model.
|
||
- In each "gear" (4 × 7 m/s) the target rises linearly from 0 to the record's max at the gear's top.
|
||
- A ±8 RPM wobble is added and the result is clamped to [idle, max].
|
||
- The RPM follows at 2000 RPM/s, rises at 1000 and falls at 4000. A fall holds the shift word w17 = 1 for 3 updates.
|
||
- The levels are split front / rear by the car's heading against the camera.
|
||
- The `TRAFFIC_CAR` packet's w14 is the engine patch. The record's patch 1 becomes 7 or 8 for a third each, and patch 3
|
||
becomes 6 for half.
|
||
- Retail loads all eight engine banks at once (recomp READs at 8.8 s). **Each bank's program destroys its
|
||
instance unless w14 is its own number**, so every post sounds in exactly one bank. The recomp agrees: the starts of
|
||
session `all_20261002_164620` are single-bank.
|
||
- **TrafficHorn / car alarm / TrafficSkids:**
|
||
- Horn: posts `TRAFFIC_HORN` with a random variant 0..8. The kind is the vehicle's horn state 1–5, and a record with
|
||
patch 0 never honks.
|
||
- Car alarm: horn state 6 posts `c_car_alarm` (variant 0..3).
|
||
- Skids: post `TRAFFIC_SKID`, which plays on the vehicle's skid flag.
|
||
- **PedestrianSFX:**
|
||
- Two held `livingword_footstep` packets (one per foot), with the program stepping on the foot-down word.
|
||
- A `sk8_foley` Splice step per plant: walk / jog / run 62 / 63 / 64 by the ped's speed, above 2.5 and 7.5 m/s.
|
||
- The packet's shoe class (w14) picks the sample set, and **class 1 is silent**.
|
||
- **Speech:**
|
||
- The state graphs send a speech value on state entry (`SendSpeechEvent`). PedestrianSpeech hands it to the speech
|
||
manager on change (values 7 / 8 remapped).
|
||
- The clips stream from `livingworldspeech.big`: 3,011 clips × 1–48 takes, mono XMA2 at 36 kHz. Each take's offset and
|
||
SNR header are in the nested `livingworldsth.big`.
|
||
- Which reaction plays which event was measured in the recomp: warn → 501, slam nearby → 104, collision nearby → 205 /
|
||
204 / 202, trick nearby → 101, flee → 108, greet → 1901 / 806 / 807.
|
||
|
||
**Change.**
|
||
- `crates/skate-audio/src/world/` (new, engine independent), with the player components' process / update split:
|
||
- `traffic.rs`: `Engine`, `Horn`, `Skids`, `Vehicle`, `EngineRecord`;
|
||
- `peds.rs`: `PedSfx`, `PedSpeech`, `PedFootstepTuning`;
|
||
- `speech.rs`: clip index, reaction cues, line choice, mixer slots;
|
||
- `owners.rs`: instance pools and 3DObjPos blocks, through the existing `player::objpos`;
|
||
- `keys.rs`: the slot 4 / 5 controller keys.
|
||
- `game_audio/world_sources.rs` (new): the `WorldOwners` resource a ped / vehicle system fills each frame, and a system
|
||
after `native::mixmap_frame`. It assigns instances, updates and processes the owners, applies the posts, and loads the
|
||
world banks on the first published frame.
|
||
- **Inert by default.** With no owners it returns at once, so play is unchanged. `SKATE_AEMS_WORLD=0` turns it off.
|
||
- Shared files touched: one module line and one plugin line in `game_audio/mod.rs`, and one optional manifest field
|
||
(`world_tuning`) with its accessor in `library.rs`.
|
||
- Setup: `tools/asset_pipeline/world_audio.py`.
|
||
- The 13 world banks join the decoded set.
|
||
- The manifest gets `world_tuning` (the `aud_traffic_engine` records and the ped footstep fields).
|
||
- `speech/livingworld.json` holds the clip index (2.3 MB).
|
||
- Decoding the speech is opt-in with `SKATE_SETUP_SPEECH=1`: about 2.4 GB of PCM for the free-roam events.
|
||
- Dev install tools: `stage_extra_banks.py --tag world …` and `.claude/skills/aems-port/tools/stage_world_audio.py`.
|
||
|
||
**Verification.**
|
||
- `crates/skate-audio/tests/world_sources.rs`, headless through the real MixMap and banks:
|
||
- A car of patch 1 at 12 m/s passing 8 m away:
|
||
- only `C01_family01` sounds;
|
||
- the engine pitch shows the Doppler shift: 4182 approaching, 4007 receding (4096 = 1.0);
|
||
- the front layer peaks at the closest approach and is gone beyond ~45 m;
|
||
- the RPM settles at 12·4000/14.
|
||
- Patches 0–9 through all eight banks: patch n sounds only in `C0n`; 2 and 9 are silent.
|
||
- A ped walks past and plants its feet: an AEMS step and a `sk8_foley` step per plant.
|
||
- The sample groups our evaluation produces for shoe classes 2–5 cover **4,798 of retail's 4,949**
|
||
`fstep_livingworld` starts (session 164620).
|
||
- A bumped business man (voice 59) resolves to a `501` warn line of his own voice; the take renders for its 5.49 s.
|
||
- Unit tests:
|
||
- skate-audio `world::` (14): the RPM model, the patch override, the front / rear split, horn / alarm / skid
|
||
words, footstep packets, speech value remap, clip names, line choice;
|
||
- game_audio:: `world_sources` (2);
|
||
- Python `test_world_audio` (3).
|
||
|
||
**Open questions.**
|
||
- **Speech:**
|
||
- ~~the speech manager's `.evt` rules~~ and ~~the `_n` / `_f` lines~~: ported, see "World speech: the speech
|
||
manager's rules" below;
|
||
- which PedestrianSpeech output sets the speech level.
|
||
- **Assignment and positions:**
|
||
- retail's instance assignment (ours: nearest N);
|
||
- which points the traffic position blocks 4.1–4.3 follow (ours: all three at the body).
|
||
- **Fields not traced:** vehicle `+112` / `+144`, `TrafficCarPhysics.in0`, the ped model → shoe class / voice.
|
||
- **Not ported:**
|
||
- PedBodyFall;
|
||
- Tazer, which needs AEMS op 38;
|
||
- ~~NPC skaters' boards~~: foundation ported, see "NPC skaters' board sounds" below;
|
||
- event PA / crowds / music zones, which need events and a music player.
|
||
|
||
#### World speech: the speech manager's rules (2026-10-03, overnight; `world::speech_manager`, `world::speech_rules`)
|
||
|
||
**Problem.** The line and take choice was a uniform placeholder, and the `_n` / `_f` lines and the vault tuning were open.
|
||
|
||
**Root cause.** Retail's choice runs in two layers.
|
||
- The game's speech manager maps a speech value to an event and gates it with the vault tuning.
|
||
- A generic speech library then picks the line and its takes from the `.evt` table and the clips' `.hdr` headers.
|
||
- Neither layer was read.
|
||
|
||
**Evidence** (TU3 recompilation, reference only; `.claude/notes/world-speech.md`):
|
||
- **Value → event** (`sub_824AB6C8`): a switch over the speech value.
|
||
- A `rand()` coin picks between 203 / 205, 603 / 607 and 108 / 109.
|
||
- Values 1 / 3 play radio lines for security guards. For bums, `rand() % 3` picks 206, 207 or 1901.
|
||
- **Gate** (`sub_824ABA18`, `sub_824A8C78`, `sub_824A75F0`): the vault class `Hash_9C1F48F5D637E275`, with
|
||
`SPCHType_1_EventID` naming the living-world event. Its `Sk8::Audio::tSpeechTuning` holds:
|
||
- per-speaker timers: the same event after `+24` s, any line after `+8` s;
|
||
- a not-follow list (no line within t s after event e);
|
||
- a `rand() % 1000 × 0.1` probability against `+20`;
|
||
- the player's speed window in km/h (`+32` / `+36`);
|
||
- excluded challenge types;
|
||
- zombie mode (`+60`). The timers start at 0 and restart when a line starts (`sub_824A90B8`).
|
||
- **Request words** (`sub_824D9908`, `sub_824ABD90`): ped type bit, voice variant bit, the near / far flag, the
|
||
conversation partner's type, zombie 1 / 2. Which words an event reads is per event.
|
||
- **Line** (`sub_82973CB8`):
|
||
- the event's probability;
|
||
- the records in a weighted random order: weight = 4^(b >> 5) × (b & 31) of the record's first byte;
|
||
- the first record that passes its own probability and whose field masks share a bit with the request words (0 = any).
|
||
- **Takes** (`sub_82972660`, `sub_82974220`, `sub_82973408`):
|
||
- a record's clips play in sequence;
|
||
- per clip, the takes not in its history ring (`.hdr` +8, usually the take count) are the candidates; when every
|
||
take is in the ring, the one played longest ago is the only candidate;
|
||
- a random candidate index, redrawn while it is among the last min(n/2, 10) picks of that clip in a 32-entry ring;
|
||
- the chosen take goes into the clip's history when the line starts.
|
||
- **Near / far.** The flag is 1 when the ped's `+148` exceeds `+156`. The `.evt` itself shows that 1 means far:
|
||
`101_51_GenPos_Grn1_far`, `104_51_grn1_Slam_far`, and all 366 `_f` / `_n` records of 101 / 104 / 202 / 203 / 501
|
||
split that way.
|
||
- **The recomp agrees** (`.claude/skills/recomp-research/tools/speech_takes.py`, 10 sessions, reads in audio stalls
|
||
left out):
|
||
- The take of each speech READ follows from its offset. All 57 clips played twice or more follow the history rule,
|
||
including one full cycle (`806_82_torm1_Int_c14_tour`: 0, 1, 0). A uniform pick would have repeated a take in
|
||
about 13.6 of them.
|
||
- All 15 multi-clip lines are one record's clip list in order (radio chirp / line / chirp; phone greet / silence /
|
||
call).
|
||
|
||
**Change.**
|
||
- `crates/skate-audio/src/world/speech_rules.rs` (new):
|
||
- the `.evt` parser (`EventTable::parse`, the same layout for all four speech banks);
|
||
- `Library::start`: the record order, the field match, the clip sequence, the history and the recent ring;
|
||
- the generator: the add-with-carry with the image seed, which retail shares with the grain player.
|
||
- `crates/skate-audio/src/world/speech_manager.rs` (new):
|
||
- `event_for_value`, `request_block` / `request_words`;
|
||
- `EventTuning` and `SpeechManager::gate` / `request`;
|
||
- `speaker_bits`: voice id → type and variant bits from the records.
|
||
- `speech.rs`:
|
||
- `SpeechIndex::set_ids` / `clip_by_id` / `picks_to_lines`;
|
||
- `choose` is now documented as a fallback for hosts without the rules.
|
||
- `world/mod.rs`: two module lines.
|
||
- Setup (`world_audio.py`), with no change to `audio_export.py`:
|
||
- `speech_index` now also exports each clip's `.hdr` id and history and the parsed `.evt` (`rules`);
|
||
- `world_tuning` gains `speech_tuning` (per bank and event).
|
||
- The game host is unchanged and still inert.
|
||
|
||
**Not ported** (fails closed, or unused by living-world data):
|
||
- the per-channel request queue (16 slots, 8 streams, timeout = event +2). The recomp plays several living-world lines
|
||
at once;
|
||
- the interrupt rules (`sub_824A73F0`);
|
||
- external conditions and clip parameters;
|
||
- the main-cast path for peds without a living-world speaker (`+116 == 0`).
|
||
|
||
**Verification.**
|
||
- skate-audio unit tests `world::speech_rules` (6) and `world::speech_manager` (4).
|
||
- `tests/world_speech.rs` (4) on the dev install's export:
|
||
- near / far: 366 records agree, 0 disagree;
|
||
- voice ids map to their type and variant bits;
|
||
- a bumped business man gets `501_59_busm1_Warn_f` far and `_n` near, and is refused inside the 15 s repeat. His 3
|
||
takes play once each, then repeat in the same order;
|
||
- a guard's radio line is chirp / line / chirp with two different chirp takes.
|
||
- Python `test_world_audio` (6).
|
||
|
||
**Open.**
|
||
- Which ped values `+148` / `+156` are.
|
||
- The speech level and pan (which speech-manager output drives the stream voice).
|
||
- The time unit of the queue timeout.
|
||
|
||
#### NPC skaters' board sounds (2026-10-03, overnight; `world::skaters`, `game_audio/npc_skaters.rs`)
|
||
|
||
**Problem.** The AI skaters (4–14 around the player in the user's sessions) had no board sounds, and which MixMap slot
|
||
retail uses for them was open: the Player slot has only 2 instances in free skate.
|
||
|
||
**Root cause.** Retail does not give every AI skater sounds. `CSTATEMGR_Player` owns exactly two records, one per
|
||
Player-slot instance. The local player holds the first. The second goes to **one** NPC skater: the first in the skater
|
||
list that comes within 30 m of the camera while the record is free. It runs every player component for that skater.
|
||
|
||
**Evidence** (TU3 recompilation, reference only; spec `.claude/notes/world-npc-skater-audio.md`):
|
||
- **The records.**
|
||
- The manager's init `sub_824F1F40` creates two `CSTATE_Player` records (creator `sub_824F8D60`; +68 skater index,
|
||
+72 local byte).
|
||
- Its update `sub_824F1FB0` walks the skater list (544-byte entries at `*(0x83083C38)+0x2F070`). It keeps a skater's
|
||
record (matched by id) or asks for a new one.
|
||
- NPC skaters are considered only while `sub_824F8EF8` holds: distance to `*(0x830CFDD4)` (the camera) < 30.0
|
||
(`0x820D4924`).
|
||
- The create `sub_824F2238` hands out the first inactive record. Only the local skater may evict one.
|
||
- The record's update `sub_824F8E18` drops an NPC at 30 m or more, or when it leaves the list.
|
||
- **Every component runs for that skater.** The audio-state bridge `sub_824B0DA8` reads the skater entry at the record's
|
||
index. The components' local tests (`[record+72]`) pick the non-local branches the port already has: no wind /
|
||
rattle, one routing pass, no held layers, the collision local byte clear. Class_Seams gates only on the record's
|
||
active byte (`+52`), so it runs for the NPC too.
|
||
- **The soft-wheel word** (`sub_824B23C8`). For a non-local skater it returns 1 when the local player's record `+84` is 0.
|
||
The bridge writes `+84` with the local player's own `+684`. So **an NPC board plays the soft-wheel samples exactly
|
||
when the local player's wheels are hard**.
|
||
- **PlayerPhysics in13** (`sub_824B19C8`) for a non-local skater: |its COM velocity − the local player's|, capped at 35,
|
||
slewed by 100 /s, × 32767 / 35. The vault class is `0xC1831BDB6CB1B1EA`.
|
||
- **The recomp:**
|
||
- The CONTACT hook's second Contacts object (`0x40C710A0`) fires in bursts that match a nearby AI board's speed and
|
||
landing. Session `all_20261002_164620`: 326–329 s = board `468B18F0`, 25 m away, 10–11 m/s; the local player is still.
|
||
The earlier note that both contact objects were the player's was wrong.
|
||
- The user's board is hard. Soft grain members play 0.6–4.9 voices/s around those bursts, against 0.3–0.9/s over
|
||
the session. Their GAIN × SEND p50 / p90: `concrete_smooth_soft` 0.026 / 0.172, `concrete_aggregate_soft` 0.012 /
|
||
0.090, `asphalt_rough_soft` 0.002 / 0.106. The local's `concrete_smooth_hard` reads 0.238 / 0.737.
|
||
- Tool: `.local/research/npc-skater-audio/instance1_voices.py SESSION`.
|
||
- **The non-local collision helper `sub_824BF5F8`** is speech, not board audio. On a body impact during a bail it finds
|
||
the skater's `CSTATE_SkaterSpeech` record and posts message 8206 / 115 (a bail grunt).
|
||
|
||
**Change.**
|
||
- `crates/skate-audio/src/world/skaters.rs` (new):
|
||
- `Slots`: the record assignment above;
|
||
- `component_state`: an NPC's `AudioState`, with `local` = false and `soft_wheels` = the inverse of the local
|
||
player's;
|
||
- `NpcSkater`: the local player's component objects for instance g. It writes PlayerPhysics, the two 3DObjPos blocks,
|
||
Contacts, Rail and OffBoard inputs at g. It runs the rolling routing, rattle, seams, grind with its on / off
|
||
sounds, skid, squeaks, foot drag, sense of speed and the Splice contacts. It returns the same `Command`s and hands
|
||
its collision messages to the shared collision manager. No component code is forked.
|
||
- Shared code, each change identical for the local player (`AudioState::local` is always true there):
|
||
- `player/inputs.rs`: `Physics` and `Contacts` get an `instance`; `Physics::write_against` gives the non-local in13;
|
||
`write_rail_at` and `write_off_board_at` are added.
|
||
- `player/seams.rs`: an `instance` for the Cracks key. The `!local` early returns are gone; retail has none.
|
||
- `inputs.rs` in12, `seams.rs` w11, `components.rs` skid w8 and `contacts.rs` tier: post `s.soft_wheels`
|
||
(`sub_824B23C8`'s value) instead of `local && soft_wheels`.
|
||
- `game_audio/npc_skaters.rs` (new): the `NpcSkaters` resource an AI-skater system fills each frame, in list order, and
|
||
a system after `native::mixmap_frame`, like `world_sources.rs`. It uses the local player's tuning and loaded banks.
|
||
- **Inert by default:** with nothing published it returns at once.
|
||
- `SKATE_AEMS_NPC_SKATERS=0` turns it off.
|
||
- `PlayerAudio::post_collisions` takes the NPC's collision messages.
|
||
- Shared lines: one module line and one plugin line in `game_audio/mod.rs`.
|
||
|
||
**Verification.**
|
||
- skate-audio `world::skaters` unit tests (3): the assignment (30 m in / out, no eviction, list order, NaN, leaving the
|
||
list), the soft inverse, and in13's slew and cap with the instance's blocks.
|
||
- `game_audio::npc_skaters::tests::npc_skater_rolls_and_grinds_past_the_listener` (data-gated). An NPC rolls past 8 m in
|
||
front of the camera at 7 m/s, ollies onto a metal rail, grinds and lands, through the real banks, MixMap, Splice and
|
||
buses at the 30 Hz evaluation cadence:
|
||
- claimed at 30.0 m, released at 30.06 m; nothing opens a second after the release;
|
||
- Seams_Bank voices peak at 0.046–0.065 near the camera and 0.017 at 22–30 m;
|
||
- GRINDS 0.087–0.094 at 8 m, Skate_Metal on / off 0.10–0.12, the landing (Skate_Collisions) 0.205;
|
||
- the seam packets carry the soft word 1 with hard local wheels and 0 with soft ones.
|
||
- Identity: `e2e_bench.sh` before / after the change (13 scenarios and 4 real sessions, row and fps300) → every output
|
||
hash identical (`compare_runs.sh npc-pre npc-post`). skate-audio tests and `game_audio::` (60) pass.
|
||
|
||
**Open.**
|
||
- The NPC's granular rolling bed. The runtime has one `GrainBed`, driven from `skateboard(0)`; the routing's grain binds
|
||
are collected (`NpcSkater::routed`) and dropped by the host. Most surfaces' rolling needs that second bed.
|
||
- Wheels, tricks, treatment, footsteps and clothing for the NPC instance (per instance in retail).
|
||
- What an inactive instance's inputs hold. We deactivate the 3-D blocks and release the packets.
|
||
- The manager's gate (a game-state word == 5 and `sys+560` == 1) and the AI manager's list order.
|
||
- The NPC's speech (`sub_824BF5F8`, the SkaterSpeech manager `sub_824F7D10`) belongs to the speech stage.
|
||
|
||
### Optimisation pass over the native runtime (2026-10-03, overnight; skill `optimisation`)
|
||
|
||
**Problem.** The final stage of the plan: make the native audio cheaper without changing a single output bit (user:
|
||
"optimise only with proof of identical behaviour"). The audio thread renders each 256-frame block while it holds the
|
||
runtime lock. The game thread waits for that lock several times per frame (`game_lock_wait`), so render time turns
|
||
straight into frame-time jitter at 300+ fps.
|
||
|
||
**Measured first.**
|
||
- *Real play* (`SKATE_AUDIO_TIMING=1`, sessions 21:37, 21:58, 22:47):
|
||
- `render_block` averages 263–292 µs, with a per-second maximum of ~470 µs median and 680–830 µs p90.
|
||
- The game thread takes the lock 490–1,170 times a second (~3.5 per frame). In most seconds one of those waits
|
||
is about one render long: `game_lock_wait` per-second maximum median 276–332 µs.
|
||
- `mixmap_frame` averages 21–37 µs per frame (lock waits included); the MixMap tick itself is ~45 µs at 30 Hz.
|
||
- `emitters` has 3–36 ms spikes, a few per session (below).
|
||
- *Headless.* The new bench (`.claude/skills/optimisation/tools/e2e_bench.sh`) runs the 13 e2e scenarios and four
|
||
whole user sessions (state logs 21:37:57, 21:42:24, 21:58:43, 22:47:47; 24,264 rows), in `row` and `E2E_FPS=300`
|
||
modes, and hashes every `.f32` render and voice log. Render per block on the real sessions: p50 210 µs, p99
|
||
340–377 µs.
|
||
- *Where it goes* (temporary per-stage timers, real play, µs per block): eEQChain buses 95–104, grain bed 70–77 (its
|
||
FrequencyShiftSsb 59), env network 22–25, voices 8–20, evaluator 5–7.
|
||
- *Root cause.* Every `f32::mul_add` without the `fma` target feature is a call into the CRT's `fmaf`. A
|
||
256-sample biquad channel costs 1.4 µs that way and 0.43 µs with hardware FMA; an FSS `sin` costs 19 ns against
|
||
~1 ns. The eEQChain filters 8 buses × 6 channels × 2 peaking EQs every block, mostly on silence. The FSS evaluates
|
||
512 polynomial `sin` / `cos` per chain per block.
|
||
|
||
**Changes (each bit-identical by construction and by test).**
|
||
1. `dsp::biquad::kernel_block` (used by `Iir2`, `PeakingIir2`, `HighShelfIir2` and the FSS allpasses). The kernel is a
|
||
pure function of (coefficients, history, input), and its arithmetic depends on the position only through `n % 8`.
|
||
- *Settled filter:* on an 8-periodic block (silent or constant), run one group of 8. If the history comes back
|
||
bit-identical, the remaining groups repeat it exactly. Otherwise the rest runs from the state after the group,
|
||
so nothing is computed twice.
|
||
- *Silent tail:* on silent input, after the history's inputs reach +0, each feed-forward form equals the 1e-18
|
||
bias exactly. This is checked at run time with the kernel's own expressions, so only the two feedback FMAs run.
|
||
2. `PeakingIir2::process`: a silent channel whose history is bit-identical to an earlier silent channel's copies that
|
||
channel's output and history (jittered buses 5–7 idle with six equal histories).
|
||
3. `FrequencyShift::process`: a lane whose value is bit-identical to its previous group's (a 0 Hz shift, 40–45 % of
|
||
chain blocks) reuses its `sin` / `cos`.
|
||
4. `dsp::wrap` instead of `%` in the reverb combs and the delay lines. It equals `%` for every input, overflowed
|
||
ones included.
|
||
5. No allocations on the audio thread in steady state (was ~104 per block). The voice / bus / submix plane lists are
|
||
stack arrays (`each_mut` / `each_ref`), the pan's speaker weights are an array, and the grain pick's candidate
|
||
list and recent-window list keep their buffers. RNG draw order is unchanged: an explicit loop in
|
||
`eqchain::resolve`.
|
||
|
||
**Proof.**
|
||
- *e2e byte identity:* `compare_runs.sh base final` → IDENTICAL for scen-row, scen-fps300, real-row and
|
||
real-fps300 (every `.f32` and every voice log). The same against the original sources rebuilt for `E2E_FPS=30`
|
||
and `E2E_FPS=1000` (frame-rate extremes).
|
||
- *Test-only references, bit for bit:*
|
||
- `dsp::biquad::kernel_block_matches_the_plain_kernel`: every coefficient set, including NaN / ∞ / zero ones and
|
||
the FSS allpasses; lengths 0–264; silent, −0.0, constant, 8-periodic and noise blocks; fresh, noisy, settled and
|
||
non-finite histories; 3,000-block decays. It also asserts that both shortcuts ran.
|
||
- `dsp::peaking::silent_shortcuts_match_the_plain_kernel`: the old process as reference, with bus-like 6-channel
|
||
traffic, jitter, re-rolls, bypass and a NaN frequency.
|
||
- `dsp::fss::repeated_lanes_reuse_the_same_trig`: the old process as reference, with 0 / ±150 Hz, tiny, huge and
|
||
NaN shifts.
|
||
- `dsp::tests::wrap_is_the_remainder`.
|
||
- *No allocations:* `tests/render_alloc.rs` (a counting allocator around 300 steady-state blocks of a full scene)
|
||
asserts 0.
|
||
- *Suites:* skate-audio all pass; `game_audio::` 52 / 52; `--no-run` builds.
|
||
|
||
**Speed (two runs each, same machine, µs per render block).**
|
||
|
||
| Set | before p50 / p90 / p99 | after p50 / p90 / p99 |
|
||
|---|---|---|
|
||
| Real sessions, row | 210 / 250–260 / 340–377 | 131 / 167 / 233 |
|
||
| Real sessions, 300 fps | 211–214 / 250–293 / 347–417 | 128 / 166 / 231 |
|
||
| Scenarios, row | 120–122 / 210 / 281–288 | 84 / 140 / 193–198 |
|
||
|
||
Total render time on the real sessions is down 39 % (17.1 s → 10.4 s per 75,826 blocks). The spread between runs
|
||
is a few µs at p50 / p99. The maxima (10–38 ms) move between runs and blocks in both builds: OS preemption on a
|
||
shared machine, not code. The game-thread side is unchanged (p50 30–35 µs per tick call). The in-game lock wait
|
||
should shrink with the render; check `game_lock_wait` / `render_block` in the next `SKATE_AUDIO_TIMING` session.
|
||
|
||
**Rejected or left for the user.**
|
||
- *Hardware FMA* (`-C target-feature=+fma`, measured in a separate build, `.local/opt-target-fma`): render p50
|
||
130 → 52 µs, p99 233 → 130 µs. Every e2e output is byte-identical: the fused result is unique, and 100 M random
|
||
`fmaf` cases agree too. It would raise the CPU baseline to FMA3 (Intel Haswell 2013 / AMD Piledriver 2012) for
|
||
the whole game. **Done as runtime dispatch** (user decision "option 1"): see "Hardware FMA dispatch" below.
|
||
- *A safe software fma* (f64 product and sum, f32 midpoint check, library fallback): exact, but no faster (the
|
||
kernel is latency-bound). Dropped.
|
||
- *`a * b + c` instead of `mul_add`:* not bit-identical. Never.
|
||
- *Skipping silent grain chains / FSS trig on idle chains:* their bias-level output (~1e-18) reaches the master
|
||
bit-for-bit, so it is not skippable. Dropped.
|
||
- *Merging the game thread's lock sections:* it changes which intermediate states a render can see. Not provably
|
||
identical. Dropped.
|
||
- *Emitter bank loads* (the 3–36 ms `emitters` spikes): `Native::ensure_bank` read and decoded a world emitter
|
||
bank's WAVs on the game thread when its first emitter started. That cost 2.6–5.4 ms warm per bank (3.8–8.7 MB of
|
||
f32), more from a cold disk. **Done in a follow-up:** a distance prefetch on a worker thread, see "Emitter bank
|
||
prefetch" below.
|
||
|
||
**Files.**
|
||
- `crates/skate-audio/src/dsp/{biquad,peaking,fss,shelf,reverb,delay,pan,mod}.rs`, `bus/{eqchain,submix}.rs`,
|
||
`grain/player.rs`, `mixer.rs`.
|
||
- New test `crates/skate-audio/tests/render_alloc.rs`.
|
||
- `crates/skate-game/src/game_audio/e2e.rs`: with `E2E_TIMING=1` it also writes `<name>.ours.timing.tsv`.
|
||
- Bench tools: `.claude/skills/optimisation/tools/` (local).
|
||
|
||
**Open.** ~~The FMA decision~~ (done: "Hardware FMA dispatch" below). MixMap tick (~45 µs at 30 Hz, game thread) not examined in depth. The emitter-bank
|
||
prefetch is done (below).
|
||
|
||
#### Emitter bank prefetch (2026-10-03, follow-up)
|
||
|
||
**Problem.** The `emitters` system took 3–36 ms on a few frames per session (`AUDIO_TIMING`, 22:47 session:
|
||
`emitters=4129/23us`). When a world emitter of a bank started for the first time, `Native::ensure_bank` read the
|
||
bank's `.abk` and decoded every WAV to planar f32 on the game thread. That costs 2.6–5.4 ms per bank with a warm disk
|
||
cache and more from a cold disk.
|
||
|
||
**Retail.** No question about retail timing arises: the change never delays or defers a start. Every start happens on
|
||
the same frame as before, and `Runtime::load_bank` runs at the same point in `ensure_bank`. Only reading, parsing and
|
||
decoding move to another thread, and their result is the same data.
|
||
|
||
**Memory decides the shape.** Decoded, a map's emitter banks (`.claude/skills/optimisation/tools/
|
||
emitter_bank_memory.py`) take:
|
||
- University: 41 banks, 161 MiB;
|
||
- DownTown: 44 banks, 210 MiB;
|
||
- Industrial: 51 banks, 249 MiB;
|
||
- each park: 0–7 MiB.
|
||
|
||
Prefetching a whole district would cost too much. Instead the prefetch follows the listener:
|
||
- A bank is queued when the listener comes within `AHEAD` = 60 m of the bounding sphere (largest extent) of one of its
|
||
emitters, nearest first.
|
||
- An unused prefetched bank is dropped when the listener is more than `EVICT` = 90 m from all of its emitters.
|
||
- Loaded banks stay until the map changes, as before.
|
||
|
||
The simulation over the users' sessions (`prefetch_memory_sim.py`, board position as the listener) gives this extra
|
||
resident memory (prefetched, not yet loaded):
|
||
|
||
| Session | Map | Loaded banks (as before) | Extra, peak |
|
||
|---|---|---|---|
|
||
| state 21:37:57 | DownTown | 4 banks, 26.8 MiB | 29.7 MiB (the spawn ring) |
|
||
| state 21:58:43 | University | 3 banks, 17.8 MiB | 20.1 MiB |
|
||
| state 22:47:47 | University | 6 banks, 28.6 MiB | 19.3 MiB |
|
||
|
||
The other settings tried: 40 / 70 m gave a peak of 11–30 MiB, and 100 / 140 m gave 27–43 MiB.
|
||
|
||
**Change.**
|
||
- `library.rs`: a new `BankSource` (`Library::bank_source`) holds the root, `.abk` path and WAV paths, detached from the
|
||
library. `BankSource::load` reads, parses (`Bank::parse`) and decodes (`decode_wavs`, which `Library::bank_pcm` uses
|
||
too) with the same error strings `ensure_bank` had.
|
||
- New `game_audio/native/prefetch.rs`: one worker thread (`audio-bank-prefetch`, spawned on the first request) runs
|
||
`BankSource::load` for the queued banks. `Prefetch::take` at the start returns:
|
||
- done: the data;
|
||
- being decoded: waits for it, which is never longer than decoding it on the game thread;
|
||
- queued, failed, panicked or unknown: None. The worker then skips a queued bank, and `ensure_bank` loads on the
|
||
game thread as before, with the same errors and warnings.
|
||
- Dropped or cleared banks' data is handed to the worker to free. Freeing it on the game thread cost up to 0.5 ms
|
||
per `prefetch_near` call in the test below.
|
||
- `native.rs`: `ensure_bank` takes from the prefetch or else calls `library.bank_source(stem)?.load()?`, then runs
|
||
`load_bank` as before. `unload_map_banks` also clears the prefetch.
|
||
- `emitters.rs`: on map load, each native emitter gets the index of its bank and a status per bank
|
||
(idle / requested / loaded). Each frame, `prefetch_near` takes the distance to each bank's nearest emitter, queues
|
||
and drops banks, and touches no runtime, random or node state.
|
||
|
||
**Proof.**
|
||
- *e2e byte identity:* `compare_runs.sh pf_base pf_new` → IDENTICAL for scen-row, scen-fps300, real-row and
|
||
real-fps300. Both builds come from one snapshot of the tree (`.local/pf-base-src`), differing only in the four files
|
||
above. The e2e harness doesn't run world emitters, but it loads every player bank through the refactored
|
||
`bank_pcm`. The later move of frees to the worker only touches `prefetch.rs`, which the harness does not run. The
|
||
unit tests below were rerun on the final code.
|
||
- *Identical data:* `native::prefetch::tests::prefetched_banks_are_identical_and_not_decoded_at_the_start` covers the
|
||
66 emitter banks of DownTown and University. For each, the worker's bank equals `BankSource::load` on the game thread
|
||
(Debug-equal), and its PCM equals both that load and `Library::bank_pcm` bit for bit (`to_bits`).
|
||
- *No decode on the game thread:* a thread-local WAV-decode counter (test builds only) stays at 0 across
|
||
`ensure_bank` for every prefetched bank. The same banks loaded without the prefetch decode all their WAVs there.
|
||
- *Runtime untouched:* `emitters::tests::the_prefetch_follows_the_listener_and_touches_no_runtime_state` moves the
|
||
listener past all of DownTown's emitters (3,520 frames). It checks four things:
|
||
- requested = within `AHEAD`, minus the loaded banks;
|
||
- nothing is kept beyond `EVICT`;
|
||
- the starts take prefetched banks with 0 decodes;
|
||
- the evaluator's random state and block count are unchanged by the prefetch.
|
||
- *Map change:* `unload_map_banks` empties the prefetch and unloads the map's banks. The next start loads again (same
|
||
test).
|
||
- *Fallbacks:* `unknown_failed_and_dropped_banks_fall_back_to_the_game_thread`. A missing `.abk` gives the old error
|
||
text, there is one slot per bank, and drop and clear work.
|
||
|
||
**Speed (headless, 66 banks, warm cache, 5 runs).**
|
||
- `ensure_bank` on the game thread (the old path): median 2.2–2.4 ms, max 5.7–16 ms.
|
||
- Prefetched: median 0.001 ms, max 0.009–0.018 ms.
|
||
- `prefetch_near` over DownTown's 176 emitters / 44 banks (3,520 calls):
|
||
- mean 0.44 µs;
|
||
- the first call 52–61 µs (it starts the worker thread);
|
||
- the rest at most 14–48 µs (queuing requests).
|
||
- The e2e render and frame timings of both builds are the same within run-to-run noise. The e2e harness doesn't run
|
||
this code.
|
||
|
||
**Adversarial review (skill checklist, what was tried).**
|
||
- *Purity / hidden state:* `BankSource::load` reads only the files, and `Bank::parse` and `wav_pcm` are pure
|
||
functions of the bytes. The library's clip caches (`loaded` / `failed`) are not involved; `bank_pcm` never used
|
||
them. Nothing on the worker touches the runtime, the MixMap, the emitters' xorshift or the evaluator RNG. The test
|
||
checks the evaluator RNG and block count before and after 3,520 `prefetch_near` calls.
|
||
- *Order:* the runtime sees the same sequence of `load_bank` / `post` / `redeliver` / `release` calls. Bank ids are
|
||
handed out at `load_bank`, which has not moved, so they are unchanged. The order in which banks are requested
|
||
doesn't matter: the data waits outside the runtime.
|
||
- *Races:* `take` against a worker that is about to start on the same bank. The slot's mutex decides which comes
|
||
first: either the worker marks it Running first and `take` waits, or `take` marks it Skip first and the worker skips
|
||
it. A bank dropped while Running finishes on the worker and is freed there with the slot. No deadlock: `take` never
|
||
holds the runtime lock, and the worker never takes it.
|
||
- *Failures:* missing `.abk`, missing WAVs (None slots, same as before), a panicking load (`catch_unwind` → the game
|
||
thread loads it and panics as it always did), no worker thread (spawn failure or the channel closed) → game-thread
|
||
loads.
|
||
- *Map change / reload:* `unload_map_banks` clears the prefetch. The emitter state rebuilds its bank table on every
|
||
map identity change (same map, new generation included).
|
||
- *Teleport / spawn inside a reach:* the start finds its bank queued and loads it on the game thread, exactly as
|
||
before. This is not a regression, only no gain.
|
||
- *Other `ensure_bank` callers* (boot banks, `world_sources`): they take a prefetched bank when one is there (same
|
||
data), otherwise they load as before.
|
||
- *Is the speed-up real?* The cost did not move to the game thread: `prefetch_near` runs at mean 0.44 µs. The first
|
||
version freed evicted banks on the game thread (max 0.5 ms per call), which the review caught; frees now run on the
|
||
worker.
|
||
|
||
**Residual spikes.**
|
||
- An emitter that is already in reach on the first frame of a map, or right after a teleport, still loads on the game
|
||
thread (its bank is still queued). That frame is part of the map load.
|
||
- The world-source banks (`world_sources.rs`, traffic / peds) loaded on the game thread when the first vehicle or ped
|
||
was published. They now use the same prefetch once the living world sets `WorldOwners::expected` (see
|
||
"Session-review leftovers").
|
||
- Map change still frees the loaded banks on the game thread (`unload_bank`, as before).
|
||
- A file changed on disk between the prefetch and the start would differ. That only happens if setup runs while the
|
||
game is running.
|
||
|
||
**Real play still to check.** Look at the `emitters` per-second maximum in the next `SKATE_AUDIO_TIMING=1` session.
|
||
|
||
#### Hardware FMA dispatch (2026-10-03, follow-up; user decision "option 1")
|
||
|
||
**Problem.** Without the `fma` target feature every `f32::mul_add` is a call into the C runtime's `fmaf`. That was
|
||
the main render cost left after the optimisation pass (above). `-C target-feature=+fma` halved the render with
|
||
byte-identical output, but it would raise the CPU baseline for the whole game. The user's decision: one exe that picks
|
||
hardware FMA or the plain code at start-up, with `skate-audio` staying `#![forbid(unsafe_code)]`.
|
||
|
||
**Which loops.** Only the loops that call `mul_add` per sample: the Direct Form I biquad kernel and its silent-tail
|
||
feedback loop (`dsp::biquad`; used by the high/low-pass, PeakingIir2, the shelf and the FSS allpasses), the
|
||
FrequencyShiftSsb oscillator with its polynomial `sin` / `cos` (`dsp::fss`), and the linear resampler
|
||
(`dsp::resample`, every voice). Reverb, delay, pan, gain, send and the grain player have no per-sample `mul_add`. Their
|
||
`mul_add`s are once per block or per parameter change: coefficients, envelopes, phase wrap. Evidence: the dispatched
|
||
build reaches the whole-crate `+fma` build's render time (p50 ~54 µs vs 52 µs), so nothing else is left to gain.
|
||
|
||
**Change.**
|
||
- New crate `crates/skate-audio-fma` (no dependencies):
|
||
- `body.rs` holds each loop once (`#[inline(always)]`, moved verbatim from skate-audio).
|
||
- The `kernels!` macro wraps every body twice: `plain::name` with the normal features, and `fma::name` with
|
||
`#[target_feature(enable = "fma")]`, which implies AVX and SSE4.1. Same source.
|
||
- `Path` picks one. A `Path` naming the FMA copy only comes from `Path::fma()`, after
|
||
`is_x86_feature_detected!` confirmed fma, avx, sse4.2, sse4.1, ssse3 and sse3. That check is the whole safety
|
||
argument for the single `unsafe` call (in the macro, with a `// SAFETY:` comment; clippy
|
||
`undocumented_unsafe_blocks` is on).
|
||
- The process-wide choice is cached in an `AtomicU8` (`active()`; `init()` chooses, `force()` is for tests).
|
||
- `SKATE_AUDIO_FMA=0` forces the plain copy. `1` (or unset) takes FMA only when the CPU has it.
|
||
- `skate-audio`: `biquad::kernel` / `kernel_silent_tail`, `fss::{sin, cos}`, `FrequencyShift::process` and
|
||
`Resampler::render` call the crate. `Coefficients`, `BIAS`, `INV_TWO_PI` and `WEIGHT` come from it (same paths,
|
||
same values). `dsp::{init_fma, force_fma, fma_path, FmaPath, FmaChoice}` are re-exported. Still
|
||
`#![forbid(unsafe_code)]`.
|
||
- Game: `game_audio/native.rs` logs `AUDIO_DSP path=fma|plain cpu_fma=… SKATE_AUDIO_FMA=…` once, at native audio start
|
||
and before the first render.
|
||
|
||
**Proof.**
|
||
- *e2e byte identity:* 13 scenarios + 4 whole user sessions, row and `E2E_FPS=300`, `compare_runs.sh`. Three trees:
|
||
(a) the tree before the change, from a snapshot in `.local/fma-base-src`; (b) the new tree with `SKATE_AUDIO_FMA=0`;
|
||
(c) the new tree with FMA. All IDENTICAL: every `.f32` render and every voice log, a-b, a-c and b-c, in 9 runs
|
||
(`fma_base{,2,3}`, `fma_plain{,2,3}`, `fma_on{,2,3}`).
|
||
- *Both copies called directly* (`skate-audio-fma/src/tests.rs`):
|
||
- biquad and feedback: 4,000 random rounds with wild coefficients / histories / inputs (NaN, ±∞, denormals, ±0,
|
||
random bits) at lengths 0–1000; plus 60 stable filters × 400 blocks of finite input, compared exactly.
|
||
- `sin` / `cos`: 4 M points of the oscillator range, 4 M random bit patterns and the edge values.
|
||
- `fss_mix`: 3,000 rounds. `resample`: 3,000 rounds; step 0, 1×, 4× and random.
|
||
- *Whole runtime on both paths* (`skate-audio/tests/fma_paths.rs`, own binary): the `render_alloc` scene (24 voices
|
||
on all buses, eEQChain, flange, env reverb, grain truck) for 1,500 blocks. Finite: every bit identical. With a
|
||
NaN / −NaN-payload / ∞ / denormal in a voice's PCM, or a NaN eEQChain gain (NaN biquad coefficients): NaNs in the
|
||
same places, every non-NaN bit identical, the same voice counts.
|
||
- *Suites:* skate-audio 245 + skate-audio-fma 6 pass, with `SKATE_AUDIO_FMA=0` and with auto. `game_audio::` 60
|
||
pass. `--no-run` builds. Both link configs build: dev `cargo build -p skate-game --bin skate3rust --release
|
||
--locked`, and release-style `+crt-static --target x86_64-pc-windows-msvc --no-default-features` (in
|
||
`.local/fma-release-target`).
|
||
|
||
**The one difference: a NaN's sign / payload.** When several operands of one operation are NaN, x86 returns the
|
||
first source operand's NaN, and which operand is first is the compiler's choice per instruction. The CRT's
|
||
`fmaf(a, b, c)` keeps its order; an inlined `vfmadd` may be commuted. In the direct tests this happened 544 times in
|
||
the wild-input biquad rounds (NaN coefficients plus NaN histories), and never with one NaN source. ∞ − ∞ gives x86's
|
||
default −NaN on both copies. (LLVM's constant folder makes +NaN instead: the tests use `black_box`.) Why it can't
|
||
change an observable output:
|
||
- Every bit-level comparison of DSP state is a memo of a pure function on the same path. Examples: the settled-filter
|
||
repeat (history bits before / after a group), the twin-channel copy (equal history bits), the silent-tail check,
|
||
`periodic8` / `silent` on the input, and the FSS trig reuse (lane bits). When a memo fires, its result is what the
|
||
plain kernel gives on that path (tests `kernel_block_matches_the_plain_kernel` with NaN histories and
|
||
coefficients, `silent_shortcuts_match_the_plain_kernel`). A different payload can only change *whether* the memo
|
||
fires, never a value.
|
||
- NaN is absorbing in these loops: in the IIR feedback, the sums and the gains. No sample value feeds control flow:
|
||
voice lifetimes, the evaluator, grain picks and bus routing depend on parameters, positions and the RNG. So the
|
||
voice logs cannot see it. Casts (`as i32`), comparisons, `max` / `clamp` and `is_nan` ignore payloads.
|
||
- The output goes to rodio as f32. A NaN there is already a broken sample either way.
|
||
- In the whole-runtime NaN scenes, 0 of 4.4 M NaN output samples differed in bits: the bus sums re-pick the NaN.
|
||
- In practice NaN does not occur: 0 NaN / ∞ in the 287 M samples of the kept `base` renders (13 scenarios + 4
|
||
sessions, both modes).
|
||
|
||
**Adversarial review** (skill `optimisation` checklist):
|
||
- *Purity:* the bodies are the old code, moved. The only new state is the cached path, which only selects a copy.
|
||
- *Signed zero / NaN / denormals:* tested directly. Only NaN payloads differ (above). MXCSR is the same for both copies.
|
||
`round_ties_even` becomes `vroundss` in the FMA copy and is exact in both.
|
||
- *Position dependence:* `kernel_block` still passes `samples[8..]` from a group boundary. The `n % 8` association
|
||
is inside the moved loop.
|
||
- *Block sizes:* 0, 1, 2, 7, 8, 9, 15, 16, 24, 255, 256, 264, 1000.
|
||
- *State across blocks:* 400-block decays, and 1,500 runtime blocks with moving cutoffs and azimuths.
|
||
- *Frame-rate extremes:* e2e row and 300 fps. Host code is unchanged apart from the start-up log line.
|
||
- *Integer identities:* the resampler's 16.16 stepping is moved verbatim.
|
||
- *Allocation / RNG order:* no new draws. `init()` reads the environment once, at audio start; a lazy first use
|
||
outside the game only happens in tests and the e2e harness (`render_alloc` still asserts 0 after its warm-up).
|
||
- *Soundness:* the FMA copy can only run through a `Path` that `Path::fma()` returned after the CPU check. A race
|
||
between two first calls stores the same answer. Without OS AVX state support, `is_x86_feature_detected!("fma")`
|
||
is false.
|
||
- *Real speed-up:* 3 base runs and 2 clean plain / FMA runs. p99 improves with p50. The game thread is unchanged.
|
||
|
||
**Speed** (µs per 256-frame render block, real-session and scenario replays, headless; clean runs only: run 2 of plain / FMA was disturbed by other load on the machine, and run 1 of plain's scenario sets overlapped the release-style build):
|
||
|
||
| Set | before (base, 3 runs) p50 / p90 / p99 | plain (clean runs) p50 / p99 | FMA (runs 1, 3) p50 / p90 / p99 |
|
||
|---|---|---|---|
|
||
| Real sessions, row | 129 / 174–177 / 286–316 | 128–137 / 327–339 | 54–58 / 87–109 / 158–209 |
|
||
| Real sessions, 300 fps | 130–140 / 178–215 / 312–392 | 128–130 / 280–355 | 55–59 / 88–102 / 153–178 |
|
||
| Scenarios, row | 86–91 / 142–166 / 228–278 | 84–85 / 228–253 | 38–40 / 67–73 / 117–141 |
|
||
|
||
The total render time on the real sessions (row) drops from 10.4 s to 4.5–5.2 s per 75,826 blocks (−50 to −56 %). The plain
|
||
path matches the old code. The game-thread side is unchanged.
|
||
|
||
**Open.**
|
||
- In-game check: an `AUDIO_DSP path=fma` line, and `render_block` / `game_lock_wait` in a `SKATE_AUDIO_TIMING=1`
|
||
session.
|
||
- A CPU without FMA runs the plain copy, the old code. The tests skip the comparison there, so it is untested on
|
||
such hardware here.
|
||
- To rerun the proof:
|
||
- `cd .local/fma-base-src && CARGO_TARGET_DIR=../fma-base-target bash .claude/skills/optimisation/tools/e2e_bench.sh fma_base row fps300`
|
||
(the snapshot of the old tree);
|
||
- `CARGO_TARGET_DIR=.local/fma-target SKATE_AUDIO_FMA=0 bash .claude/skills/optimisation/tools/e2e_bench.sh fma_plain row fps300`,
|
||
then the same without the variable as `fma_on`;
|
||
- `compare_runs.sh fma_base fma_plain` / `fma_on`;
|
||
- `cargo test -p skate-audio-fma -p skate-audio --release --locked` (with and without `SKATE_AUDIO_FMA=0`).
|
||
- Build folders to delete once accepted: `.local/fma-target`, `fma-target2`, `fma-base-target`,
|
||
`fma-release-target`, and `fma-base-src` (rmdir its junctions `assets`, `maps`, `.local/audio-re` first).
|
||
|
||
**Files.** New: `crates/skate-audio-fma/{Cargo.toml, src/lib.rs, src/body.rs, src/tests.rs}`,
|
||
`crates/skate-audio/tests/fma_paths.rs`. Changed: `Cargo.toml` (workspace member), `Cargo.lock` (the path crate, no
|
||
external dependencies), `crates/skate-audio/Cargo.toml`, `crates/skate-audio/src/dsp/{mod,biquad,fss,resample}.rs`,
|
||
`crates/skate-game/src/game_audio/native.rs`.
|
||
|
||
### Session-review fixes: push plant timing, the push foot's plant / lift, the body poster, grind on / off sounds (2026-10-03, overnight)
|
||
|
||
Four ranked items of the session review (`.claude/notes/session-review-2026-10-03.md` #1, #2, #4, #5) and the e2e harness
|
||
gaps (#10). "The recomp" = skate3recomp run locally: its program logic is evidence, its frame rate and stalls are not
|
||
retail. Recomp figures below are from the nine clean sessions 163809 … 223613, stall windows excluded
|
||
(`.local/audio-re/review/rlib.py`).
|
||
|
||
**Problem.**
|
||
- Every push sound but the stroke foley played ~550 ms early, at the stroke instead of the foot plant, and the rattle
|
||
ran at a third of the recomp's rate.
|
||
- The Contacts' push-foot plant / lift sounds (sk8_foley 84 / 85 …) never played.
|
||
- Bails had a few body impacts where the recomp has a dense layer of skin / denim / arm / leg / head / torso hits, plus
|
||
the interim `bail_hit` / `bail_soft..hard` cues on top in native mode.
|
||
- Grinds had no on / off contact sounds (Skate_Collisions 1135–1175, Skate_Metal 525–564).
|
||
|
||
**Root causes.**
|
||
- *Push plant (#1).* The audio-state bridge `sub_824B0DA8` sets `+335` on State55's rise (the foot plant), and `+333` /
|
||
`+334` while it holds. The host fed the animation's push contact (`flags2468` bit 27), which fires near the stroke.
|
||
`grain_bed`'s push count used the same edge.
|
||
- *Plant / lift (#2).* `sub_824B8218` calls `sub_824BBB28` after the process. It was not ported.
|
||
- *Body poster (#4).* `sub_824BC188` was not ported. It needs the regions' impacts (audio state `+496..+516`), which the
|
||
host did not compute. A setup export bug also lost the head and torso materials (below).
|
||
- *Grind on / off (#5).* The Rail poster `sub_824C28B0` calls `sub_824C3FC8` after each GRINDS post and
|
||
`sub_824C4138` at the release. `components::Grind` skipped both.
|
||
|
||
**Evidence and changes.**
|
||
|
||
*#1 Push plant.*
|
||
- `skate_events::push_plant`: `push_planted` = State55, `push_trigger` = its rise (`+333 || +334` / `+335`).
|
||
`cues.riding.pushes` counts the same edge.
|
||
- The retail split (`+333` = State55 && !`+338` && State56, `+334` = State55 && `+338`) waits for `+338` to be mapped.
|
||
The recomp's GREC logs `+338` (the third byte of its `+336` word) if someone wants to settle it.
|
||
- State log: new `plant` column (State55). The `push` column keeps its old meaning (the animation's push edge).
|
||
`e2e.rs` prefers `plant` when present.
|
||
- Older logs have no State55. `E2E_PLANT_FROM_FEET=1` rebuilds it from their `foot_down` bits on ground states without
|
||
the brake (State55 && (State57 || State56)) — a proof aid only.
|
||
- Off: `SKATE_AEMS_PUSH_PLANT=0` / `E2E_PUSH_PLANT=0`.
|
||
|
||
| per push (the recomp: 137 pushes / 917 riding s; ours: 4 user logs, 32 pushes / 236 riding s) | the recomp | ours before | ours after (feet proxy) |
|
||
|---|---|---|---|
|
||
| plant foley sk8_foley 74, lag after the stroke | +548 ms | +0 ms | +550 ms (p10/p90 450/600) |
|
||
| rattle, lag after the stroke | +558–660 ms | +33–50 ms | +600 ms (p10/p90 433/650) |
|
||
| rattle per riding minute (`rattle_attr.py`, level > 0.05) | 31.3 (24.3 "other", 6.4 push) | 9.4 (0 other, 9.1 push) | 37.1 (26.7 other, 9.4 push) |
|
||
| rattle level p50 | 0.326 / 0.355 | 0.325 | 0.326 |
|
||
|
||
The plant now lands with the footstep. Most rattles come from State55 rises with no stroke nearby, in the recomp and
|
||
in ours. The remaining rate gap is +19 % (the foot-down proxy, not the game's own State55).
|
||
|
||
*#2 The push foot's plant / lift* (`player::contacts::plant_lift`, update `sub_824BF268`).
|
||
- Trigger: `+333 || +334` rising while `+123` is clear → plant (`sub_824BB8D8`); falling → lift (`sub_824BBA00`).
|
||
- Sound: `sk8_foley` by wheel 0's material kind. The kind is AudioSurfaceMap word 5 (`sub_82494EB8`; 0 for no
|
||
material).
|
||
- Plant ids [84, 88, 92, 80, 76], lift ids [85, 89, 93, 81, 77] (Contacts `default` fields in the code docs).
|
||
- Kinds above 4 post id 0.
|
||
- Routing: eEQChain bus 1 (class `42AFE160E647167C` field `748CBC9727A5347F`).
|
||
- Block: [0, 1, 0, dt, 1, 1] at the start, then [level(6), pitch(1), raw(0), dt, 0, 1].
|
||
- Off: `SKATE_AEMS_PLANT_LIFT=0` / `E2E_PLANT_LIFT=0`.
|
||
|
||
| | the recomp (`plantlift.py`) | ours after (feet proxy) |
|
||
|---|---|---|
|
||
| plant on pushes | 130 / 137 (95 %), +548 ms (498 / 682), level p50 / p90 0.220 / 0.463 | 29 / 32 (91 %), +550 ms (450 / 617), 0.222 / 0.490 |
|
||
| lift on pushes | 130 / 137, +664 ms (580 / 748), level 0.155 / 0.423 | 28 / 32, +683 ms (583 / 717), 0.213 / 0.493 |
|
||
| ids | 84 > 88 > 92 (materials 1–5) | 84 / 88 / 92 by kind |
|
||
|
||
*#4 Body poster* (`player::contacts::body`, messages into the collision manager).
|
||
- Gate: runs unless the rider is bailing with `+677` set.
|
||
- Per region 0..5 with an impact and no cooldown (15 frames, vault `6DD85F43C1B6E6AA`):
|
||
- the region material (head 97, torso 98, arms 100, legs 99) against the touched surface (`+560` tag − 1), each by
|
||
its impact band;
|
||
- the band-2 second message;
|
||
- the cloth contact (denim 109 / skin 107 / denim 108) at tier 0;
|
||
- the pad contact (110 head / torso, the face 112, 111 limbs) by `sub_824BCCF8`'s thresholds (class
|
||
`6EBA5BCD3E38A98A`: 0.75 / 0.7 / 0.65, face 0.05 / 0.3; highs 1.25 / 0.55).
|
||
- Region impacts (`skate_events::region_impacts`, from `sub_82BD60C8`): clamp01(max(0.001, Δv(part) · region normal ×
|
||
part mass × 10)).
|
||
- Δv = SkeletonState4048 `velocity_changes`.
|
||
- Mass = SkeletonState4560 = the bone-box volumes `animation_masses.part_weights`.
|
||
- 10 = physics_collision `default` +164, field `1430BD50F0A33475`.
|
||
- The conditioner `sub_82773298` keeps the max of the last 4 frames.
|
||
- **Export fix:** five AudioSurface records are keyed by name in the converted collections: head, torso, water,
|
||
drum_pylon, crumpled_paper. The band holder's `default` is keyed by name too.
|
||
- Materials 77 / 87 / 92 / 97 / 98 exported as missing, and every RefSpec to the `default` bands resolved nothing.
|
||
- Before the fix, an impact on those materials read tier 2 with an empty window.
|
||
- `audio_export.collision_tuning` now aliases name keys by hash64. Head = ids 1032 / 952 / 953; torso = 951 / 949 /
|
||
950 — the ids the recomp plays in bails (below).
|
||
- Not ported, and why:
|
||
- The first-hit torso message `sub_824BCEB0`: gated by an unidentified global at `*(0x82083C38) + 0x2FCB4`.
|
||
- The Hall of Meat layer: global byte `+859`.
|
||
- The non-local `sub_824BF5F8`.
|
||
- `sub_824BDA90`: game events, not sounds.
|
||
- `sub_824E3BE0` is not the player's ragdoll. It is a physics-prop SFX object: its own record from `[data+128]`,
|
||
materials `+360` / `+160`, caller `sub_824E2790`. It belongs to the world-sources stage.
|
||
- The interim bail cues are silent while the body poster runs (`PlayerAudio::body_impacts_on`).
|
||
- Off: `SKATE_AEMS_BODY_IMPACTS=0` / `E2E_BODY_IMPACTS=0`.
|
||
- **The recomp, per bail** (38 bails, `fixes/bail_splc.py`):
|
||
- 137 collision-manager voices.
|
||
- Ids 955 25.4, 954 20.7, 956 19.2, 947 13.0, 958 8.7 (concrete), 948 6.0, 949–953 (torso / head) 14, 1031 / 1030 /
|
||
1032 (tier 2) 5.
|
||
- **Ours.** The user's logs carry no region records yet, so no real bail can be replayed. A synthetic bail
|
||
(`fixes/e2e_bail_on`, region impacts set by hand) plays 31 collision voices instead of 3: torso, legs, denim and
|
||
skin, per-voice peaks 0.10–0.25. The recomp's mean is ≈ 0.15 (Σg² 4.3 over 185 voices). This shows the chain, not
|
||
the retail density. That needs a state log with the new `rimp*` / `rtag*` columns.
|
||
|
||
*#5 Grind on / off* (`components::Grind::sounds` / `update_sounds`, `sub_824C42A8`).
|
||
- At each Class_grind post: slot 0, slot 1 for family 0, and the updater's family change, which uses the new family's
|
||
layer.
|
||
- Sound: the grind surface's on id (`sub_824C35D0`): Skate_Metal on a metal surface (bool `02BAC36BCC8A30DE`), else
|
||
Skate_Collisions, one id per layer. At the release, the off id (`sub_824C37D8`).
|
||
- Routing: through eEQChain bus 0 (field `D1A87641CCB98787`), mono, env send = Rail level(5).
|
||
- Level = ((10000 − speed word) × 0.0001 × (B − A) + A) × layer factor × Rail level(1). A / B = 0.1 / 1.25 on, 0.1 /
|
||
1.0 off.
|
||
- Pitch = the same lerp (0.8 / 1.0) × pitch(2).
|
||
- Data: exported per grind surface (`player_tuning.grind[*].on / off / metal`, `grind_contact_eq`).
|
||
- Off: `SKATE_AEMS_GRIND_ONOFF=0` / `E2E_GRIND_ONOFF=0`.
|
||
|
||
| per grind | the recomp (17 grinds, `fixes/evpost.py`) | ours before | ours after (7 grinds) |
|
||
|---|---|---|---|
|
||
| on sound | 17 / 17, lag 0, level p50 / p90 0.290 / 0.454 | 0 / 7 | 7 / 7, lag 0, 0.259 / 0.383 |
|
||
| off sound | 17 / 17, lag 0, 0.204 / 0.285 | 0 / 7 | 7 / 7, first audible +50 ms, 0.245 / 0.313 |
|
||
|
||
*#10 Harness.*
|
||
- New state-log columns: `plant`, `stroke`, `deck_spin` / `spin_x` / `spin_y`, `bail`, `bail_end`, `held`,
|
||
`offboard_air`, `footplant`, `revert`, `soft`, `face`, and `rimp0..5` / `rslide0..5` / `rtag0..5`.
|
||
- `e2e.rs` reads them when a log has `plant`, and then also sets `on_foot` = state 500. Older logs render as before.
|
||
- `audio_trick` was already resolved from the scorable inside `PlayerAudio` (e2e included); only the Script's literal
|
||
read −1.
|
||
- `scenarios.py --from-log` copies the new columns and the skeleton columns it used to drop (step, body, limb, slide,
|
||
deck_up, deck_contact).
|
||
|
||
**Files.**
|
||
- `crates/skate-game/src/game_audio/`: `skate_events.rs`, `state_log.rs`, `e2e.rs`, `player_audio.rs`, `library.rs`.
|
||
- `crates/skate-audio/src/player/`: `state.rs` (`body_impact`), `contacts.rs`, `components.rs`, `tuning.rs`
|
||
(`GrindContact`, `grind_contact_eq`).
|
||
- `tools/asset_pipeline/audio_export.py`, `test_audio_formats.py`.
|
||
- Dev install: `stage_player_tuning.py` re-run. Backup: `.local/audio-re/audio_manifest.before-review-fixes-stage.json`.
|
||
|
||
**Verification.**
|
||
- skate-audio tests: 204 + integration, all pass.
|
||
- `cargo test -p skate-game --release --bin skate3rust --locked -- game_audio::`: 57 pass.
|
||
- Python `test_audio_formats` / `test_world_audio`: 31 pass.
|
||
- e2e of the four user logs (213757, 214224, 215843, 224747; `E2E_FPS=60`) with every new part off: **byte-identical**
|
||
(f32 and voices) to a build of the tree without this pass (`.local/cbf`, old manifest). The same holds with the
|
||
restaged manifest, so the export fix changes nothing in these logs.
|
||
|
||
**Open.**
|
||
- A user state log with the new columns (1 min of pushing; one or two bails). It checks the game's own State55 rate
|
||
against the recomp's 27 plants / riding minute (the feet proxy gives 29.5) and the bail density.
|
||
- ~~The lift's level is +2.7 dB over the recomp's (0.213 vs 0.155).~~ A measurement artefact; per id ours matches
|
||
(see "Session-review leftovers").
|
||
- `+338`'s mapping.
|
||
- The first-hit torso message's global: the gate is read; its writer is still not found (see "Session-review
|
||
leftovers").
|
||
- Needs an in-game listening check.
|
||
|
||
**Addendum: the user's session 084712 (2026-10-03, build 04:20, all fixes on).** University,
|
||
`.local/audio-state-logs/state_20261003_084712.tsv` (0 malformed, new columns). The user: "sounded SO MUCH BETTER, I WOULD
|
||
CALL IT ALMOST OR EXACTLY RETAIL"; the footsteps while running / walking "sounded amazing too". Tools:
|
||
`.local/audio-re/check0847/` (`plants.py`, `regions.py`, `bails_cmp.py`).
|
||
- *State55 plants: one per stroke, as in the recomp.* The lower rate is riding style (slow riding, walking between
|
||
bails; confirmed by the user), not a missing plant.
|
||
|
||
| | ours (084712) | the recomp (9 sessions, stalls excluded) |
|
||
|---|---|---|
|
||
| strokes (State56 rise) / riding min | 6.3 (4) | 8.8 (135) |
|
||
| plants (State55 rise) / riding min | 14.1 (9) | 27.0 (412 plant posts) |
|
||
| plants per stroke | 2.25 | 3.05 |
|
||
| strokes followed by a plant within 1.2 s | 4 / 4, lag p50 500 ms (400–517) | 116 / 135, lag +548 ms |
|
||
| plants with no stroke in the 1.2 s before | 5 (≈ 1 s apart, a foot rhythm) | 296 |
|
||
|
||
Riding = states 100–299 with wheels, plus air: 38 s here. The brief's 28 s / 19 per minute used a narrower riding set.
|
||
- *Bails (2): the state-log side.*
|
||
- Bail 1 at 18.25 s comes from off-board air (501 → 300, 6.5 m/s). Bail 2 at 36.23 s comes from 500 → 300 at
|
||
7.6 m/s, after an air.
|
||
- Region impacts are low. Bail 1: max 0.086 (region 1), the others ≤ 0.02. Bail 2: regions 0 / 4 / 5 reach 0.145 /
|
||
0.202 / 0.206, regions 2 / 3 0.03 / 0.06.
|
||
- Most contact frames sit near the 0.001 floor. All the touched surfaces are tag 4.
|
||
- Contact frames > 0.001 per bail: 84 and 104.
|
||
- *The recomp, per bail, same window (−0.5…+2.5 s, voices > 0.002; 38 bails):*
|
||
- Skate_Collisions: 207.7 voices, Σg² 4.68, peak p50 0.61.
|
||
- Skate_Metal: on 16 / 38 bails, 6.5 voices, Σg² 0.40.
|
||
- 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:* 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
|
||
skate-game --release --bin skate3rust --locked -- --ignored --exact game_audio::e2e::e2e_render --nocapture`.
|
||
- Compare: `py -3.13 .local/audio-re/check0847/bails_cmp.py - .local/audio-re/check0847/e2e:real_084712`. It reports
|
||
voices per bank, Σg², records → ids and regions per bail.
|
||
- Watch-point: with impacts ≤ 0.2 the bands may never reach tier 2, which the recomp plays at about 5 per bail
|
||
(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.~~ (Wrong: 15 of 18 sessions had the second owner, an NPC skater; the hooks now gate on local72 — see "The
|
||
per-player recomp hooks logged an NPC skater too" below.) 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,
|
||
but its frame rate and stalls are not retail. Recomp figures come from the nine clean sessions 163809 … 223613, with
|
||
stall windows excluded. Tools: `.local/audio-re/left/`.
|
||
|
||
**1. The push foot's lift, "+2.7 dB" (0.213 vs 0.155): no mechanism difference, so no code change.**
|
||
- *The level path is the same.* `sub_824BB8D8` (plant) and `sub_824BBA00` (lift) are the same function apart from
|
||
their slots (`+84` / `+80` and `+92` / `+88`). Both:
|
||
- post `sk8_foley` with the id of wheel 0's material kind;
|
||
- start with the block [0, 1, 0, dt, 1, 1];
|
||
- get their level from the one updater `sub_824BF268`: level(6) × 1/32767, pitch(1), raw(0), dt, 0, 1.
|
||
- Contacts level(6) is MixMap E27 (`-600 + B[Player.2] + B[Player.0] + C[Player.8]`). It has no plant- or lift-specific
|
||
duck: F9, the duck on Contacts.in6, feeds out3, not out6. So a lift plays at the plant's level(6). The only per-sound
|
||
differences are the splice records: lift id 85 is record 31 (gains up to 2.83); 89 is record 35 (members 0.35–0.40).
|
||
- *The gap came from the measurement.* The review's figure took the first `sk8_foley` voice within 60 ms of each push
|
||
edge, from any record (the clothing foley plays there too). It also pooled the sessions' different material mixes.
|
||
`left/plantlift_ids.py` matches every post to the voices of its own record's samples instead:
|
||
|
||
| id (kind) | the recomp: posts, level p50 / p90 | ours (`fixes/e2e_feet`): posts, level p50 / p90 |
|
||
|---|---|---|
|
||
| plant 84 (0) | 472, 0.176 / 0.491 | 147, 0.196 / 0.479 |
|
||
| plant 88 (1) | 123, 0.247 / 0.303 | 18, 0.235 / 0.283 |
|
||
| plant 92 (2) | 44, 0.221 / 0.342 | 0 |
|
||
| lift 85 (0) | 473, **0.201** / 0.491 | 111, **0.204** / 0.494 |
|
||
| lift 89 (1) | 121, 0.068 / 0.082 | 12, 0.080 / 0.083 |
|
||
| lift 93 (2) | 46, 0.160 / 0.205 | 0 |
|
||
|
||
- Per id, ours and the recomp agree within about 1 dB (−0.1 dB for lift 85). Pooled, the recomp's lift p50 is 0.169
|
||
against our 0.193, because its sessions have more kind-1 surfaces: 19 % of lifts against our 10 %, and kind 1's lift
|
||
record is quiet. The 0.155 also included other voices.
|
||
- `+338` doesn't enter the level. It only splits `+333` / `+334`, and the trigger reads their OR.
|
||
|
||
**2. Grind off "first audible +50 ms": an authored delay plus a measurement artefact, so no code change.**
|
||
- *Post timing is the same.* Ours posts the off sound (`sub_824C4138`) at the release, on the frame the grind ends.
|
||
The recomp posts it 0 ms after the grind end too (`fixes/evpost.py`).
|
||
- *The updater is the same.* `sub_824C42A8`, read again: per slot, the on and off sound get gain = their level ×
|
||
level(1)/32767, pitch = their pitch × pitch(2)/4096, raw(0), dt, 0, 1, and the env send level(5); a sound that
|
||
ended is released. `Grind::update_sounds` matches this word for word. It runs in the same frame's update, so the
|
||
console cadence plays no part.
|
||
- *The delay is in the data.* The off records' members carry authored delays. For example, Skate_Collisions record 835
|
||
has group 2 at 11 ms (+ up to 25 ms random) and group 1 at 50 ms (+ up to 50 ms). The splice player honours them.
|
||
- The recomp's PLAY lines after each off post (`left/grind_off_delay.py`, sample-matched) put the first audible member
|
||
at p10 / p50 / p90 = 4 / 14 / 47 ms. Its delayed groups play late as authored: d57 → 64–72 ms, d64 → 80–101 ms,
|
||
d162 → 176–190 ms.
|
||
- Ours: the first member comes 0–1 frames (0–17 ms) after the post in all 7 grinds, and the 50 ms groups come 3–6
|
||
frames later.
|
||
- `fixes/grind_cmp.py` took the first run in its list, which is in completion order, not the earliest. Fixed to take
|
||
the earliest: the off lag p50 is 0 ms (7 / 7).
|
||
|
||
**3. World-source banks through the prefetch** (`game_audio/world_sources.rs`, `native/prefetch.rs`).
|
||
- *Problem.* The traffic and ped banks (`TRAFFIC_BANKS`, `PED_BANKS`; 10 of 13 in this install) were read and decoded
|
||
on the game thread in `ensure_bank` when the first vehicle or ped was published. That is one frame with 10 bank
|
||
decodes.
|
||
- *Change.*
|
||
- `WorldOwners::expected` (new): the living-world system sets it as soon as it knows it will publish owners on this
|
||
map.
|
||
- While it is set, `prefetch_world_banks` queues every world bank that is neither loaded nor requested on the
|
||
prefetch worker. When it clears, the requested banks that no owner loaded are dropped.
|
||
- `Prefetch` counts its clears (`clears()`). A map change (`unload_map_banks`) clears the prefetch and unloads the
|
||
world banks, so the host asks again. `contains` is no longer test-only.
|
||
- *Identity.* The rules are the same as for the emitter prefetch:
|
||
- `ensure_bank` still runs `load_bank` at the first owner, with the same calls in the same order;
|
||
- only reading and decoding move;
|
||
- a queued, failed or missing bank loads on the game thread as before, with the same errors.
|
||
- *In play the host stays inert.* No system sets `expected` or publishes owners, so `frame` returns at its first line,
|
||
as before. The e2e harness doesn't run world sources.
|
||
- Off: `SKATE_AEMS_WORLD_PREFETCH=0` (game-thread loads at the first owner, as before). `SKATE_AEMS_WORLD=0` turns off
|
||
both.
|
||
- *Test* (`world_sources::tests::world_banks_are_prefetched_when_expected_and_load_without_decoding`, data-gated):
|
||
- nothing is requested until the world is expected;
|
||
- then every installed world bank is requested once (missing ones are listed as unavailable);
|
||
- the worker's bank and PCM equal `BankSource::load` bit for bit;
|
||
- the requests leave the evaluator RNG and the block count unchanged;
|
||
- `ensure_bank` for all of them decodes 0 WAVs on the calling thread;
|
||
- loaded banks are not requested again;
|
||
- `unload_map_banks` makes the host request them again;
|
||
- "not expected" drops them;
|
||
- without a prefetch the bank decodes on the game thread as before.
|
||
- The prefetch fallback test also checks the clear counter.
|
||
|
||
**4. The first-hit torso message's gate (`sub_824BCEB0`): read further, but the writer is still not found.**
|
||
- `sub_824BC188` sets Contacts input 7 to 0 on every frame. With the byte B+16 set, it also sets Contacts input 8 to
|
||
clamp(B+24 × 32767 (`0x821747FC`), 0, 32767), else 0. Here B = `*(*(0x83083C38) + 0x2FCB4)` (`lis -31992` =
|
||
0x8308; an earlier version of this note wrote 0x8208).
|
||
- The object's `+420` latches B+16's rising edge (`+421` = last frame's value). `sub_824BCEB0` then:
|
||
- pulses Contacts input 7 = 32767;
|
||
- for a region (loop index `r17`) other than 1 and a region tier (`r20`) ≥ 1, posts the torso material 98
|
||
against no material (143) through `sub_82496C58` (level) and `sub_82486EF0`;
|
||
- clears `+420`.
|
||
- Contacts.in7 triggers F7 in Player (+400 mB, hold 14, release 29). That feeds the **Collision** slot's C0: the bail's
|
||
collision layer is 4 dB louder for about half a second after the first hit. Contacts.in8 has no reader in the MixMap.
|
||
- B+16 is the same flag that PlayerPhysics.in11 (`sub_824B19C8`) and the `hall_of_meat_slo_mo` companion's w11
|
||
(`sub_824DD6F0`, B+24 × 1000) read. `sub_824C0AB8` and `sub_824BFA48` (Contacts state) read it too.
|
||
- B+16 is a large sub-object (≥ 12.5 KB), reset by `sub_827AB6E0` from `sub_8279E800`. The `sub_827AE9C8` /
|
||
`sub_827AEF08` / `sub_827AF4E0` / `sub_827AF748` / `sub_827F1DD0` family copies it into locals.
|
||
- No direct store to B+16 byte 0 or B+24 was found in the 15 functions that load B, or in the same class's methods
|
||
(`sub_827A*` / `sub_827B*` `stb …,0(r3)` / `stfs …,8(r3)`).
|
||
- Next: a recomp hook that logs B+16 (byte) and B+24 (float) per frame during bails and Hall of Meat runs. A
|
||
write-watch on that address would name the writer.
|
||
- *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 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`.
|
||
- Tools: `.local/audio-re/left/{splc_records,plantlift_ids,grind_off_delay}.py`; `fixes/grind_cmp.py` (earliest
|
||
voice).
|
||
|
||
**Verification.**
|
||
- skate-audio tests;
|
||
- `cargo test -p skate-game --release --bin skate3rust --locked -- game_audio::`;
|
||
- `--no-run`.
|
||
|
||
Results: skate-audio 228 pass; game_audio 58 pass (new world test included, run on the install's data); `--no-run`
|
||
builds. e2e doesn't run the changed code: `world_sources` is not in the harness, and
|
||
`prefetch.rs` only gained a counter and a public `contains`.
|
||
|
||
## Credits
|
||
|
||
The retail measurements in this document were taken with **[skate3recomp](https://github.com/mchughalex/skate3recomp)**
|
||
by @mchughalex, a native static recompilation of Skate 3. It is built on the
|
||
[rexglue SDK](https://github.com/rexglue/rexglue-sdk) and credits [Xenia](https://github.com/xenia-project/xenia)'s
|
||
research. We ran it locally with trace hooks and scripted teleports; it has no licence, so nothing from it is
|
||
included here. Upstream PRs #4 / #1 by @andrewnakas (research notes) were also a key reference.
|
||
Setup decodes the disc's EA-XMA audio with [vgmstream](https://github.com/vgmstream/vgmstream) (pinned
|
||
r2117, hash-checked).
|
||
|
||
Research for the native audio runtime (specs in progress, 2026-10-02) also draws on:
|
||
- [dbalatoni13/nfsmw](https://github.com/dbalatoni13/nfsmw) (CC0): a matching decompilation of Need for
|
||
Speed: Most Wanted's EA audio library. Official AEMS opcode and bank-header names and state layouts.
|
||
- [BurnoutDecomp/BP-Decomp_Workflow](https://github.com/BurnoutDecomp/BP-Decomp_Workflow) and
|
||
[BurnoutDecomp/b5-decomp](https://github.com/BurnoutDecomp/b5-decomp): RenderWare Audio plug-in
|
||
identities, the SndPlayer1 and filter/resampler details, opcode names. b5-decomp has no licence, so it
|
||
is reference only.
|
||
- XNA Math / [DirectXMath](https://github.com/microsoft/DirectXMath) (Microsoft, MIT License): the
|
||
`XMVectorSin` / `XMVectorCos` polynomials the FrequencyShiftSsb oscillator uses (`skate-audio-fma`); the
|
||
coefficients are read from the shipped image.
|
||
- [mitsevox/tw2004](https://github.com/mitsevox/tw2004) (CC0): Tiger Woods 07 RenderWare Audio function
|
||
listings.
|
||
No code from these is included.
|
||
|
||
The native runtime (`crates/skate-audio`) is our own code written from our specs. Its oracle was the PoC
|
||
worktree of upstream PR #4 by @andrewnakas (no licence; run locally as a black box: our probes drive its
|
||
evaluator and replay-verified DSP kernels; two local fixes to it, never committed). Its facts (offsets,
|
||
opcode numbers, constants) come from our reading of skate3recomp (no licence, reference only) and the disc
|
||
banks; official names from dbalatoni13/nfsmw (CC0).
|
||
|
||
The MixMap port and the granular rolling bed (2026-10-02) rest on upstream PR #4 / #1 by @andrewnakas
|
||
(no licence, reference only): they found that the bed is a granular player and recovered the MixMap's
|
||
function map, stage order and input writers, the GrainPlayer object, pick and scheduler, the board
|
||
records, the bus chains, the vault field roles and the instance counts. Their MixMap port and grain
|
||
functions, run locally in the PoC worktree, are our golden oracle (MixMap 87,000 cells, pick / position
|
||
vectors); the order of retail's single-precision operations in the Bézier, the speed scales and the
|
||
`id 9 = 0 for the local player` rule were read from their documentation. EA's record names (MixCtl,
|
||
3DMixCtl, EvtMixCtl, SubMixCh, MasterMixCh, Preset) come from
|
||
[BurnoutDecomp/b5-decomp](https://github.com/BurnoutDecomp/b5-decomp)'s NFSMix headers (no licence,
|
||
reference only). Spot checks and addresses: skate3recomp (no licence, reference only). Data: the user's
|
||
own Skate 3 disc and TU3 image (`MixMapSK8.mxb`, `grains.big`, `skatercollections.vlt`; never
|
||
committed), decoded with [vgmstream](https://github.com/vgmstream/vgmstream). No code from any of these
|
||
is included.
|
||
|
||
The player inputs and components (2026-10-02) are our own code from our reading of the retail functions
|
||
in the skate3recomp generated code (no licence, reference only; addresses in the source docs) and the
|
||
user's vault. Upstream PR #4's `player-audio-retail-drivers.md` and `mixmap/inputs.rs` notes by
|
||
@andrewnakas (no licence, reference only) gave the function map, the packet word tables, the Jitter /
|
||
Contacts / 3DObjPos writer descriptions and the retail wind and grain capture tables we validate
|
||
against. The retail level tables come from our local recomp trace sessions
|
||
(`.local/recomp/sessions/20261001_211347` — deleted 2026-10-02, re-verify on clean data — and
|
||
`all_20261002_163809`). No code from any of these is included.
|
||
|
||
The Splice player, the board contacts, skid and squeaks (2026-10-02) are our own code from our reading of the retail
|
||
functions in the skate3recomp generated code (no licence, reference only; addresses in the docs) and the user's vault
|
||
(`skatercollections.vlt`). Upstream PR #4's driver notes by @andrewnakas (no licence, reference only) gave the
|
||
Contacts / skid / squeak function map and word tables we checked against the code; their PoC, run locally as a black
|
||
box through our own headless probe, is the end-to-end oracle. No code from either is included.
|
||
|
||
The collision manager, the wheel spin, the foot taps / scuffs (2026-10-02) are our own code from our reading of the
|
||
retail functions in the skate3recomp generated code (no licence, reference only; addresses in the module docs) and
|
||
the user's vault (field layouts from the disc's own schema, `skaterschema.vlt`). The bed validation data (GREC hook)
|
||
was recorded with skate3recomp by @mchughalex, run locally by the user. Upstream PR #4's driver notes and
|
||
`collision_states.rs` docs by @andrewnakas (no licence, reference only) gave the manager's map (router, ten slots,
|
||
message layout, the material table at `0x8302D6E8`) that we re-read and corrected in the code (the update reads the
|
||
category output's level; materials ≥ 143 take pitch output 1; the impact bands come from the `+24` RefSpec). No code from
|
||
either is included.
|
||
|
||
The environment network, the eEQChain buses, the owner one-shot buses and Class_Seams (2026-10-02) are our own code
|
||
from our reading of the retail functions in the skate3recomp generated code (no licence, reference only; addresses in
|
||
the specs `.claude/notes/aems-env-bus-spec.md`, `.claude/notes/aems-eqchain-buses-spec.md` and the module docs), the
|
||
TU3 image and the user's vault; validated against the user's own recomp traces (skate3recomp by @mchughalex, run
|
||
locally). BurnoutDecomp/b5-decomp's reconstructed ReverbModel1 and Delay (no licence) were read only to orient; the
|
||
DSP follows the TU3 asm. Upstream PR #4's notes by @andrewnakas (no licence, reference only) named the eEQChain
|
||
holder fields, the owner bus builder and the Class_Seams function map. No code from any of these is included.
|
||
|
||
## Open questions
|
||
|
||
- World sound sources (2026-10-03): the speech manager's `.evt` rules, take choice and level; retail's SFX instance
|
||
assignment; the traffic 3DObjPos binding; ped model → shoe class / voice; PedBodyFall, Tazer (op 38), NPC skaters' grain bed / wheels / foley (foundation in "NPC skaters' board sounds"),
|
||
event PA / crowds / music zones ("World sound sources").
|
||
- Main bail body-impact sound (Bodyslide is only a layer); landing weight layer level; metal grind pick
|
||
(54–56) by ear; powerslide, wheel spin, push sound, fountain bank, caveman jump-on — not yet confirmed.
|
||
- Surface → grain now follows retail (2026-10-02); still open: the `Class_rolling` surfaces (interim stand-ins), tag 90, and what selects the soft-wheel grains.
|
||
- Retail plays rolling granularly; the default path plays banded loops, `SKATE_AEMS=1` plays the native
|
||
granular bed (player B silent until turn/slope inputs exist; FSS, graph 3, wobble not modelled).
|
||
- MixMap inputs: VU (output meter) and the menu/NIS/HOM/Challenge flags stay 0; the bed's gain A matches
|
||
retail's level(1) p90 with Jitter wired, the lower retail medians need a straight-roll trace at turn 0
|
||
(see "Player inputs and the first native player components").
|
||
- Native player components still to port: seams, rattle, Class_rolling, cloth, body slide, footsteps,
|
||
flips/treatment, the body-impact and step-on posters (need skeleton region contacts / Skeleton+602/+603 from the
|
||
engine). Native since 2026-10-02: skid, squeaks, the Contacts pops / landings / touchdowns, the collision manager
|
||
(grind start, landing pair, deck impacts), foot taps / scuffs, the wheel spin. Music.in3 is a game-mode flag
|
||
(0 in free skate), so the ollie voice and the landing impact stay gated as in retail.
|
||
- The turn input (`Turn` animation attribute) is almost binary in our engine (29 % of riding frames at 1.0 vs
|
||
retail 16 %; 7 % in between vs 25 %): carving layers trigger too easily (user, 18:02 build). Animation side.
|
||
- The interim layer still bypasses the native host's 6→2 fold (~3–6 dB above native voices of the same per-voice
|
||
level); the send buses (distance cue) are not modelled.
|
||
- User listening check 2026-10-02 (native build 16:59): rolling "too loud" and "not in sync", grinds "not the
|
||
same sound" — see "Board contacts, skid and squeaks; the end-to-end PoC comparison" for what the measurements
|
||
show; the PoC reference had a one-off louder/harsher seam sound on one launch (first-play decode).
|
||
- Zone ambience from `.ems` emitters (beds per map are a name-based guess); manual wheel spin
|
||
(`Whls_spins_Man_1`); music.
|
||
- ~615 MB of PCM (mostly ambience): a compressed format would need an encoder at setup.
|
||
- Retail's own sample choices could be measured from a recompiled build that logs which bank data the
|
||
game decodes (local research only, not part of this change).
|
||
- `AUDIO_*` logging is verbose for a release build.
|
||
- Native runtime: the unmodelled buses and sends (the MixMap's emitter send word w2 now has a value but no
|
||
bus to go to), loop time-left, `c_emitter` w0, and the gain ramp's last ulp (see "Native AEMS runtime:
|
||
implementation" and "MixMap and the granular rolling bed").
|
||
- Buses (2026-10-02): the FlangeSub effect returns (GRINDS routes to one), reverb-zone emitters (type 5), the grain
|
||
chain's graph-2 env send, the Splice voices' own Send A (retail Skate_Collisions median 0.0146) and Collision SubMix,
|
||
sense_of_speed's FXWET0 (retail median 0.015, ours 0), the reverb module's timer delay; SFX Master's DCl0 level.
|
||
- The riderless-board gate (states 300 / 500–502 report no wheels) rests on retail's measured record; the writer of
|
||
record +152 bits 20–22 is not located.
|
||
- rodio 0.20's spatial panning is mirrored (the default path still uses it; native mode swaps the ears).
|
||
- 19:38 air tricks #1 / #3: the audible in-air voice is retail's wheel spin-down; a retail ollie at ~30 km/h would
|
||
settle whether ours sounds like it.
|
||
- Port stage 2026-10-02 (rolling layers, chain, tricks, footsteps, returns), still open: the FlangeSub send of the grain chain
|
||
(no bed → return route); the bool-class word `0x11A631878B239355`; the seam pattern retail sees on University's sidewalk
|
||
cells (pattern 11, "Listening test 8"). The seam sample variety is fixed: it was the missing `Start_up_Play_ctl` boot
|
||
utility ("Listening test 8"). Closed 2026-10-02 (stage "FootStep SubMix, the skeleton inputs, …"): the FootStep SubMix, the
|
||
step code, limb / body speeds, ragdoll slide records, the loose-board vectors, Reverb.in0..6 + the env scale, the reverb
|
||
zones (1,040 records, not one), the boot order.
|
||
- Treatments 16 / 17: settled, including the ramp air's late start (a 1 s audio stall in the recomp) and the short
|
||
airs (same rate as the recomp). sense_of_speed figures redone with the shared-sample fix: no difference
|
||
("Leftovers: Treatments 16 / 17 timing, …").
|
||
- FootStep SubMix: Sen0 #2 = 1.0 and the Pn21 law 0 are read, not defaults. Left: a released voice's de-click tail
|
||
skips the submix's filters (as for every bus).
|
||
- Reverb zones: vfunc92 is ported (preset ∈ the 24 presets). Not ported: the music zones (type 4, playlists for the
|
||
licensed-music player we lack) and the speakers / crowds records (types 6 / 7, event PA and crowds; flags mask
|
||
audio +1032, 4 by default). The value of audio +1032 in free skate is not measured.
|
||
- The Send A table in "The buses" is superseded for sense_of_speed (0, as retail) and the Splice voices (none):
|
||
`.local/audio-re/busleft/senda_bank_fixed.py`.
|
||
|
||
### 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
|
||
honouring it would keep those players on the tables.
|
||
- The tables stay in the code for now.
|
||
- Verification: `game_audio::` tests, including the new
|
||
`native_is_the_default_even_with_an_old_saved_native_false`; muted smoke runs (`--mute`) on StartPark and University
|
||
with the staged build: native runtime and every component on, render ready, no panics, errors or non-finite physics.
|
||
|
||
### Seam-hit prominence: closed (2026-10-03)
|
||
|
||
The remaining 2–5 dB gap between our seam hits and the recomp's capture (after the 110 ms alignment fix) has no
|
||
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).
|
||
|
||
**Listening (user, 2026-10-03, bin\ 10:06, session `state_20261003_101640`, 0 malformed):** "The sounds are SO GOOD.
|
||
I think that is basically retail, if there is a difference i couldn't tell. Splashes sounded correct. Riding sounded
|
||
right, jumping sounded right, falling sounded right. Cracks on bricks sounded correct at both higher speed and lower
|
||
speed. Manual sounded amazing."
|
||
|
||
**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).
|
||
|
||
### The ragdoll part masses against the recomp; the missing |Δv·n| (2026-10-03, analysis only; no code change)
|
||
|
||
Open item 1 of "Bail density" listed the recomp's SkeletonState `+4560` masses rounded to two digits and asked for a
|
||
check against ours. Ours = `SkeletonAnimationMasses::part_weights` = the raw bone-box product x·y·z of each
|
||
`PhysicsParamBoneData` size (`assets/private/stock/physics-skeletons.json`, f32 as the code multiplies). The recomp =
|
||
the `mass` field of every BAILREG line of the fixed-hook runs t2 / ok / ok2 / ok3 (12,054 lines, 0 malformed), printed
|
||
to six decimals:
|
||
|
||
| part | ours | the recomp | | part | ours | the recomp |
|
||
|---|---|---|---|---|---|---|
|
||
| 1 neck1 (head) | 0.005248 | 0.005248 | | 13 spine1 | 0.005832 | 0.005832 |
|
||
| 2 neck | 0.000784 | 0.000784 | | 15 / 19 toes | 0.000307 / 0.000307 | 0.000307 / 0.000307 |
|
||
| 3 / 7 hands | 0.000336 / 0.000336 | 0.000336 / 0.000336 | | 16 / 20 feet | 0.001015 / 0.001016 | 0.001015 / 0.001016 |
|
||
| 4 / 8 forearms | 0.001666 / 0.001667 | 0.001666 / 0.001667 | | 17 / 21 legs | 0.005109 / 0.005110 | 0.005109 / 0.005110 |
|
||
| 5 / 9 arms | 0.003134 / 0.003050 | 0.003134 / 0.003050 | | 18 / 22 uplegs | 0.006708 / 0.006708 | 0.006708 / 0.006708 |
|
||
| 6 / 10 shoulders | 0.001483 / 0.001483 | 0.001483 / 0.001483 | | 23 hips | 0.009753 | 0.009753 |
|
||
|
||
**The masses are identical** for all 20 parts the recomp's bails touched (the left / right arm asymmetry 0.003134 /
|
||
0.003050 included). The 2-digit list in "Bail density" was rounding, and the earlier "≈ 0.003 arm … 0.015 torso"
|
||
estimate was a guess. So the masses are not part of the bail-density gap. (Parts 11 / 12 / 14, the upper spine, never
|
||
touched in those runs: ours 0.007751 / 0.007001 / 0.005935.)
|
||
|
||
**Found on the way: retail takes the absolute value of Δv·n; ours doesn't.** `sub_82BD60C8`'s region loop computes
|
||
`vmsum3fp128 v44 = Δv · n`, then `vandc128 v42 = v44 & ~(v63 << 31)` (the sign mask): |Δv·n|. The recomp lines agree:
|
||
with Δv·n = v_new·n − v_old·n from each line, clamp01(max(0.001, |Δv·n| × mass × 10)) matches the written impact in
|
||
12,054 / 12,054 lines (within 0.002), the signed form in 11,776. `skate_events::region_impacts` uses the signed
|
||
`along`, so a step with Δv·n < 0 is floored to 0.001 there.
|
||
- How often: 5,355 of the recomp's 12,054 region steps have Δv·n < 0. Of the 1,729 above the floor, 447 (26 %) are
|
||
negative ones; above 0.1 it is 8 of 166, above 0.25 4 of 46.
|
||
- What it does to ours: about a quarter of the above-floor region contacts read 0.001, under every body floor (0.002
|
||
head / torso / legs, 0.005 arms) and the concrete floor (0.12). Those contacts post nothing. It barely touches tier 1
|
||
/ 2 (5 % of the impacts above 0.1). So it costs mostly tier-0 posts: a small part of the 4–5× gap, which stays the
|
||
ragdoll's small per-step Δv (open items 1–2).
|
||
- Not changed (this task was report-only). The fix is audio-side, not physics: `along.abs()` in `region_impacts`
|
||
(the doc's "matches term by term" missed the `vandc`). It changes the bail renders, so it needs the user's go.
|
||
*Applied 2026-10-03 with the user's approval: see "Region impacts take |Δv·n|" below.*
|
||
|
||
### The per-player recomp hooks logged an NPC skater too; measurements re-checked (2026-10-03, recomp hooks + analysis; no engine change)
|
||
|
||
**Problem.** GREC / GRECX / FIRSTHIT / SKID / TREAT / SEAMPAT / SEAMHIT were meant to log the local rider only. Once an
|
||
NPC skater spawned (20–80 s into a session), they also logged its board, treatment and seams objects, interleaved per
|
||
frame. The earlier note "earlier sessions had one owner" was wrong: 15 of the 18 sessions with these hooks had the
|
||
second owner (`.local/research/localtest/owner_scan.py`); only `all_20261002_204336`, `all_20261002_232153` and
|
||
`bailrun_verify_20261003_095507` are clean. In `all_20261002_180430` (the bed-level session) the NPC owner is 227,295 of
|
||
460,476 GREC lines.
|
||
|
||
**Root cause.** The hooks tested the 32-bit WORD at `[[object+16]+72]`. The game's local flag is the BYTE at +72 (the
|
||
skid updater `sub_824C7A20` reads `lbz 72` of `[owner+16]`; the eqchain / grain / tricks code reads `[[object+28]+72]`).
|
||
The NPC's component has byte 72 = 0 but a non-zero byte after it, so the word test passed. The TREAT / SEAMPAT mask bit 2
|
||
(state = GREC's last accepted state) alternated with the two owners, so it selected neither reliably.
|
||
|
||
**Change (recomp research hooks only, `src/research/hooks_audio.cpp`, uncommitted).** All seven kinds now gate on the
|
||
byte `[[object+28]+72]` (local72, as COLLPOST). SKID keeps its last holder per owner; FIRSTHIT keeps its per-state slots.
|
||
TREAT / SEAMPAT / SEAMHIT's last field: bit 4 = local72 (the gate), bit 1 = byte `[[object+16]+72]`, bit 2 = state equals
|
||
GREC's local state. New kind `LOCALTEST` (category `audio`, ≤ 256 lines, 6 fields; `trace.py` FIELD_COUNTS): per hook and
|
||
object, both bytes, the old word test and the decision, whenever the pair (old, new) changes.
|
||
|
||
**Proof (our own runs, `ride_spawn.txt` at the Mega-Park spawn, background, `SKATE3_TRACE=audio,audiox`, 0 malformed).**
|
||
`localtest2_20261003_102933`: at 21.57 s the NPC's board `40C34020`, treatment `40C58200` and seams `40C60560` start
|
||
passing the old test (bytes 0 / 0, word ≠ 0) and are rejected (`LOCALTEST … 0 0 1 0`); the local `40C33020` /
|
||
`40C581A0` / `40C60320` pass with bytes 1 / 1. GREC / GRECX / SKID / TREAT / SEAMPAT / SEAMHIT each logged one object
|
||
(33,048 / 33,048 / 28,533 / 33,048 / 33,047 / 929 lines) while the NPC was posting deck and body impacts (COLLPOST
|
||
local72 0, `40C710A0`) and visible in the screenshots. Run `localtest_20261003_102703` agrees (NPC seams object
|
||
rejected at 20.4 s).
|
||
|
||
**Measurements re-checked** (copies with only the local rows: `.local/research/localtest/filter_local.py [--mask2]`;
|
||
scripts in the same folder; the local object is the first one each trace logs, confirmed by LOCALTEST):
|
||
|
||
| measurement (session) | as published | local rider only | conclusion |
|
||
|---|---|---|---|
|
||
| Retail turn input: share of riding frames at full / in between (180430) | 16 % / 25 % | **17 % (0.95–1.0, median 0.992) / 8 %**; release ≥ 0.9 → 0 in a median 34 ms | **Changes.** The 25 % was the NPC's steering (81 % of its frames in between). Retail's local input is as binary as ours (29 % / 8 %, release 50 ms in `state_20261002_181631`). |
|
||
| I vs \|turn\| medians 0.1 / 0.3 / 0.5 / 1.0 (180430) | 0.066 / 0.185 / 0.30 / 0.60 | 0.062 / 0.180 / 0.301 / 0.600 | Holds. Retail's I is 0 by 50 ms after the input reads 0 (p90 0.000, 366 releases). |
|
||
| Wheel-0 material changes while rolling (180430, `grec_material.py`) | 0.9 /s | **0.45 /s** (keep rates 100 % / 96 % unchanged) | Value changes; the fix it supported (materials from the wheel lines; ours was 5–9 /s) holds and is stronger. The comment in `skate_events.rs` (≈ line 543) still says 0.9. |
|
||
| Trucks: neither running / (1,0)↔(0,1) swaps (180430, `grec_running.py`) | 621 of 310 k / 39 k swaps | **9 of 233 k / 86 direct swaps** (+ ~310 through both-running) | "Never stops the last sounding truck" holds. The 39 k swaps were the two owners alternating; the hand-over is rare. |
|
||
| Clean straight-roll level(1) per material (180430) | 0.219 (mat 2, 5–10), 0.21 (40, 10–15), 0.229–0.244, 0.245–0.25 | 0.219, 0.215, 0.228–0.246, 0.243–0.250 | Holds (ours within ~1 dB). Material 65's low 5–15 km/h values (0.079 / 0.024) were the NPC; local 0.208. |
|
||
| Manual rows gain A, 10–50 km/h (180430) | 0.141–0.157 | 0.139–0.158 | Holds. |
|
||
| Class_Seams cadence (223306, `seampat.py`) | calls every 2.9 ms, frame field 2.5 ms; speed changes 18 %, positions 37 % | 3.0 ms, 3.0 ms; 19 %, 41 % | Holds (~3 ms pulses). The old `mask & 2` filter kept only 14,861 of 33,785 local calls. |
|
||
| Pattern 11 hits (223306) | 15–21 /s at 20–40 km/h | 15–29 /s | Holds (sparse sidewalk = short pulse). |
|
||
| Grid hits on a cell change of that wheel (223306, `seamhit_match.py`) | 400 of 520 | 2,677 of 3,156 (85 %); pattern 11: 470 of 619 | Holds. |
|
||
| TREAT replay, air-time +236 changes (223306), sense_of_speed replay (223613) | 860 of 5,094 | same | Already filtered by object (`40C581A0`, `40C33020`). The "57,323 lines" count includes the NPC (local 33,786, ~305 /s). |
|
||
| Clean carves A/(1−I), B/I; bed per material (`review/bed.py`) | — | — | Already filtered by owner (`rlib` / local owner). |
|
||
|
||
**Not separable:** voice-based figures (PLAY, SPLC: the riding Skate_Collisions / sk8_foley rates, seam voice rates
|
||
and levels) include an NPC's sounds whenever one was near, and these sessions have no COLLPOST. That was already noted
|
||
for the 1.7 /s body posts; a user session with `PLAY_TRACE_AUDIOX.bat` (COLLPOST) settles it.
|
||
|
||
**For the main session (no engine change made):**
|
||
- "(1) Carving": the turn input is not the difference. If ours really keeps I up for ~13 frames after the stick
|
||
returns (retail: 0 within 50 ms), the cause is on our I side, not the animation's `Turn` scalar; worth a look.
|
||
- Hook consumers written before this fix need an object filter (or `filter_local.py`); new traces need none.
|
||
|
||
### The body poster on the console cadence (2026-10-03, `player::contacts`, open item 3 of "Bail density")
|
||
|
||
**Problem.** `Contacts::body` (retail `sub_824BC188`, called from the Contacts frame `sub_824B8218`) ran once per
|
||
`PlayerAudio::process`, and its 15-frame region cooldown counted those calls. The host runs the components once per
|
||
pass with at least one 60 Hz step (`native::mixmap_frame`), so the cooldown was 0.25 s at 60 fps and above but 0.5 s
|
||
at 30 fps. Retail runs the Contacts frame once per rendered frame: on the 30 fps console the cooldown is 0.5 s. The
|
||
standing rule applies: per-frame retail processes target the 360's ~30 fps cadence, at any engine fps.
|
||
|
||
**The 4-frame region max is not part of it.** The ring is the conditioner `sub_82773298`, called from
|
||
`sub_82772748` next to the landed latch `sub_82772FD8` (same caller, same pass). Those PhysOut conditioners run per
|
||
physics step (gotchas "MixMap console cadence": the recomp's `+236` air time steps by 1/60 on 17 % of its ~345 fps
|
||
calls), and the bail hook measured the ragdoll pass at 60 Hz in the recomp too. So "4 frames" = 4 physics steps =
|
||
67 ms on the console as well, which is what `skate_events::observe` (per physics tick) already does. Unchanged.
|
||
|
||
**Change.**
|
||
- `Contacts::body_calls: Option<usize>`: `Some(n)` runs the poster n times in this process (n console frames end
|
||
here), `None` once (the old cadence; tests).
|
||
- `PlayerAudio::body_console` (`SKATE_AEMS_BODY_CONSOLE=0` off; e2e `E2E_BODY_CONSOLE=0`) sets `body_calls` to the
|
||
MixMap cadence's evaluations of the pass (`jitter_steps`: `mixmap::cadence`, every second 60 Hz step on a fixed
|
||
grid). Without the MixMap console cadence (`SKATE_AEMS_MIX_CONSOLE=0`) the old cadence stays.
|
||
- The NPC skaters' host does the same for its instance (`NpcSkater::set_body_calls`, the evaluations since its last
|
||
call).
|
||
- Diagnostics: `Contacts::body_digest` (FNV-1a over every posted message) and, in the e2e harness,
|
||
`<name>.ours.body.tsv` (the row where the count changed, the count, the digest).
|
||
- Below 30 fps a pass spans more than one console frame and the host has only the newest step's state, so the poster
|
||
then runs twice on it (as the MixMap evaluations do).
|
||
|
||
**Proof of frame-rate independence.**
|
||
- Unit test `the_body_poster_keeps_the_console_cadence_at_any_frame_rate`: a 20 s 60 Hz stream of rising and falling
|
||
impacts on both arm regions, run through a host at 30 / 60 / 144 / 240 / 365 fps (process on passes that complete
|
||
steps, newest state, `Cadence`): the same messages on the same steps (counts and digests). The old cadence differs
|
||
between 30 and 60 fps. A held impact posts on steps 2, 32, 62, 92: every 30 steps = 0.5 s.
|
||
- e2e, the two bail sessions (`state_20261003_084712`, `state_20261003_101640`; 0 malformed; cut with
|
||
`scenarios.py --cut r0-999999`, 3,324 / 14,922 rows), `E2E_FPS` = 30 / 60 / 144 / 365: the body traces are
|
||
byte-identical at all four rates. With the old cadence 30 fps differs from 60 (144 / 365 equal 60).
|
||
- Identity of everything else: `E2E_BODY_CONSOLE=0` renders are byte-identical (f32 and voices) to the pre-change
|
||
build at all four rates. The optimisation bench (13 scenarios + 4 sessions, row and `E2E_FPS=300`,
|
||
`opt/runs/cad_t1` against `cad_base`): IDENTICAL. Those logs carry no region impacts (row mode also keeps the old
|
||
cadence).
|
||
|
||
**The e2e effect on bails (`E2E_FPS=60`, `bails_cmp.py`, window −0.5…+2.5 s; tools `.local/audio-re/bodycad/`).**
|
||
|
||
| | 084712 before | 084712 after | 101640 before | 101640 after |
|
||
|---|---|---|---|---|
|
||
| body-poster messages (whole session) | 35 | 30 (−14 %) | 102 | 91 (−11 %) |
|
||
| bails | 2 | 2 | 10 | 10 |
|
||
| Skate_Collisions voices / bail | 45.5 (46, 45) | 42.0 (43, 41) | 26.9 | 26.6 |
|
||
| three banks (Collisions + Metal + Bodyslide) voices / bail | 52.0 | 48.5 | 29.0 | 28.8 |
|
||
| three banks Σg² / bail | 1.895 (1.95, 1.84) | 2.114 (1.74, 2.49) | 0.954 | 0.997 |
|
||
|
||
The longer cooldown removes 11–14 % of the body messages, far less than the "up to 2×" bound: in our bails few
|
||
regions are hit again within 0.25–0.5 s. The voice counts drop slightly. Σg² moves both ways, because the posts that
|
||
remain fall on different steps of the impact curve (bail 2 of 084712 now posts on a stronger step: peak 0.715 →
|
||
0.976). The bail-density gap against the recomp (≈ 208 Skate_Collisions voices, Σg² 4.7 per bail) stays. Its cause is
|
||
the ragdoll's per-step Δv (open items 1–2) and, for a quarter of the floor crossings, the missing |Δv·n| (section
|
||
above). The recomp's post counts are inflated by its ~345 fps cadence (cooldown ≈ 43 ms there), so it is not the
|
||
reference for this rate either; the console's 0.5 s is.
|
||
|
||
**Files.** `crates/skate-audio/src/player/contacts.rs`, `crates/skate-audio/src/world/skaters.rs`,
|
||
`crates/skate-game/src/game_audio/{player_audio,e2e,npc_skaters}.rs`.
|
||
|
||
**Open.** The deck-impact poster `sub_824BD000` has the same shape (a 6-frame cooldown counted per process), and the
|
||
other Contacts / component processes still run per 60 Hz step. Not changed here (not asked; extending the rule is the
|
||
user's call).
|
||
|
||
### NPC skaters' second grain bed (2026-10-03, `grain::GrainBed`, `Runtime::npc_grains`, `game_audio/{grain_bed,npc_skaters}.rs`)
|
||
|
||
**Problem.** The NPC skater holding the Player slot's instance 1 (`world::skaters`, inert until an AI-skater system
|
||
publishes) ran its routing, but its grain binds were dropped: the runtime had one bed. Most surfaces (asphalt,
|
||
concrete, wood, aggregate, metal) are grain surfaces, so the NPC's rolling was silent there.
|
||
|
||
**Retail's mechanism (TU3 recompilation, reference only).** Every `SFXObj_SkateBoard` owns 2 trucks × 2 GrainPlayers
|
||
(owner `+1176 + 8t` / `+1180 + 8t`) and their chains. Its process `sub_824C6A78` (vtable `0x822FC780` +20) runs for an
|
||
active record (`[+28]+52`), and its update `sub_824C6BD8` (+24) has no local test, so instance 1's board runs its own
|
||
bed. The local tests (`[owner+16]+72`) in the bed's helpers, read one by one:
|
||
|
||
| part | function | for the NPC instance |
|
||
|---|---|---|
|
||
| slope inputs 2 / 3 (D, U) | `sub_824CA738` | runs (no local test) |
|
||
| routing, soft member | `sub_824C5CA8`, `sub_824C8370` | one pass, no hand-over (already ported in `player::rolling`); soft via `sub_824B23C8` = the local player's inverse |
|
||
| push envelopes, input 4, turn intensity | `sub_824C6198` → `sub_824C8588` | run |
|
||
| chain values (HPF / LPF / pan / env send / FSS) | `sub_824C9058` | run; the graph-2 FlangeSub sends level(21) / (22) are local only |
|
||
| seam-pattern gain envelope | `sub_824CA448` | returns at once (local only) |
|
||
| graph-1 → graph-3 send, level ramp | `sub_824CAEC0` | returns at once |
|
||
| gain wobbles | `sub_824CB180` (→ `sub_824CB078`) | returns at once: the graph-1 / graph-3 Gains stay at the module default 1 |
|
||
| graph 3 | `sub_824C8878` (chain build) | built only for the local player (`lbz 72` before the graph-3 build); the eEQChain create flag is the local byte too |
|
||
| records (gain / pitch / position), brake slew, latches | `sub_824C6BD8` | run, from the instance's SkateBoard outputs level(1) / (2), pitch(3) |
|
||
| rocket `x_jet_rolling` | SenseOfSpeed `sub_824E7980` | returns for non-local (already noted) |
|
||
| pick generator | `sub_82A8AF10`, `0x82FD7D74` | one title-wide generator for every GrainPlayer |
|
||
|
||
**Change.**
|
||
- `GrainBed::local` (true by default): false skips graph 3 (`process_full(…, graph3)`). `GrainBed::share_rng` runs a
|
||
closure with another generator swapped in.
|
||
- `Runtime::npc_grains: Option<Box<GrainBed>>`: rendered after the local bed on the local bed's generator (retail's
|
||
one generator), at the local bed's user gain and `chain_extras`. The env sends of both beds are summed when both
|
||
send; its dry mix is added after the local one. `None` = the previous render path exactly.
|
||
- `grain_bed::Bed` gains its instance. `Bed::for_instance(g)`: the same tunings and decoded recordings, no rocket.
|
||
`write_inputs` writes `SkateBoard(g)` inputs 2 / 3 / 4. `step_with` (the old `step` body, with the runtime handed
|
||
in) gates the local-only parts above and targets `npc_grains` (created at the NPC bed's first step; binds draw
|
||
through the shared generator). `step` keeps its signature and order for the local player.
|
||
- `npc_skaters::frame`: at the claim (native rolling layers on) the held skater gets `Bed::for_instance(1)`. Per
|
||
console evaluation: update → the bed step on this evaluation's outputs with the routing's binds of the last
|
||
process → `write_inputs` (2 / 3 / 4) → the instance's inputs → process. Push plants = `+335` rises
|
||
(`push_trigger`); brake = `+336` (`AudioState::brake`). At the release the bed is dropped and the trucks stop.
|
||
Logged as `AUDIO_NPC grain bind instance 1 truck …`.
|
||
|
||
**Verification.**
|
||
- `grain::bed::tests::a_non_local_bed_has_no_graph3_and_shares_the_generator`: without graph 3 the send level is
|
||
inert; with it, it adds the copy. At send 0 a built graph 3 adds only its filters' anti-denormal bias (< 1e-12;
|
||
retail builds none for the NPC). A bed on a shared generator renders exactly like one owning that state, and its own
|
||
generator never moves.
|
||
- `npc_skaters::tests::npc_skater_rolls_on_its_own_grain_bed` (real install; the synthetic pass of the existing NPC
|
||
test): the second bed binds `concrete_smooth_soft` (local wheels hard → soft member), 2–4 voices while held, record
|
||
A gain 0.004 at 29 m → 0.096 at 10 m → 0.009 at 29 m (the instance's MixMap distance roll-off), nothing after the
|
||
release, the local bed never bound. In the air before the rail, the fixture's "no material" wheels rebind
|
||
`asphalt_smooth_soft` (surface 3): the routing's known UNCERTAIN air behaviour (grain spec §2.10).
|
||
- The local player's renders: see "Verification of the 2026-10-03 cadence / NPC-bed stage" below.
|
||
|
||
**Open.**
|
||
- What the released instance's players do in retail (here they stop, as the packets are released): not read.
|
||
- Retail's order of the two owners' picks on the shared generator (ours: the local bed first per block). The picks
|
||
are random either way; it only matters while an NPC is held.
|
||
- The brake slew's direction test (`+336` against the direction of travel) is unported for both beds.
|
||
|
||
### Verification of the 2026-10-03 cadence / NPC-bed stage (the two sections above)
|
||
|
||
- `cargo test -p skate-audio --locked`: all pass (lib 220 + the integration tests; new:
|
||
`the_body_poster_keeps_the_console_cadence_at_any_frame_rate`, `a_non_local_bed_has_no_graph3_and_shares_the_generator`).
|
||
- `cargo test -p skate-game --release --bin skate3rust --locked -- game_audio::`: 55 pass, 4 ignored (new:
|
||
`npc_skater_rolls_on_its_own_grain_bed`).
|
||
- `cargo build --locked` (dev): OK, only the 3 known warnings (`library.rs` ×2, `prefetch.rs`).
|
||
- e2e identity (target `.local/fma-target`; tools `.claude/skills/optimisation/tools/e2e_bench.sh`,
|
||
`.local/audio-re/bodycad/render.sh`):
|
||
- the optimisation bench, 13 scenarios + 4 sessions, row and `E2E_FPS=300`: `cad_base` (before) = `cad_t1` (body
|
||
cadence) = `cad_t3` (+ NPC bed), byte-identical (f32 and voices), and equal to `retire1`;
|
||
- the bail sessions 084712 / 101640: `E2E_BODY_CONSOLE=0` = before at 30 / 60 / 144 / 365 fps; after Task 3 = after
|
||
Task 1 at 60 and 30 fps (f32, voices and body traces). So the NPC bed leaves the local player's renders untouched.
|
||
- Headless only: no game launch, no smoke test, bin\ / data\ untouched. For the user in game: bails (the body poster
|
||
now has the console's 0.5 s cooldown at 60+ fps). The NPC bed stays silent until an AI-skater system publishes.
|
||
|
||
### Region impacts take |Δv·n|; the deck poster on the console cadence (2026-10-03, user-approved follow-up)
|
||
|
||
The user approved both follow-ups of the sections above: retail's |Δv·n| and the console cadence for the deck poster.
|
||
|
||
**1. |Δv·n| in `skate_events::region_impacts`.**
|
||
- Change: `along = (Δv · n).abs()`, matching the `vandc128` sign mask in `sub_82BD60C8`. A part pulled away from the
|
||
surface now counts like one stopped by it, no longer flooring to 0.001.
|
||
- Tests:
|
||
- `region_impacts_scale_the_velocity_change_by_the_part_mass` pins it: −2 m/s gives 0.2; a tiny step still reads the
|
||
0.001 floor.
|
||
- `region_impacts_match_the_recomps_bail_lines` (data-gated: the fixed-hook runs t2 / ok / ok2 / ok3) rebuilds every
|
||
BAILREG line's Δv along its normal with its mass. Ours equals the written impact within 0.002 on **12,054 / 12,054
|
||
lines**, 5,355 of them with Δv·n < 0.
|
||
- **The e2e can't show it.** The harness replays the impacts the game logged (`rimp0..5`), and the two bail sessions
|
||
were logged with the signed formula. Re-rendered 084712 / 101640 at `E2E_FPS=60` with the new build (and the deck
|
||
cadence off): byte-identical to the current state. So the per-bail change (Skate_Collisions voices, Σg², tiers) needs
|
||
a new user session with this build.
|
||
- What it acts on: in the logs, contact region-frames at the 0.001 floor are 1,035 of 1,237 (084712) and 649 of
|
||
1,042 (101640), after the 4-step max. Some of these are negative steps that will now read their |Δv·n|.
|
||
- In the recomp, 26 % of the above-floor steps are negative, mostly small: 8 of 166 above 0.1.
|
||
- So expect more tier-0 body / cloth posts and few new tier-1 / 2 ones.
|
||
|
||
**2. The deck-impact poster `sub_824BD000` on the console cadence.**
|
||
- It had the body poster's shape: a 6-frame cooldown (vault `27D3C5DC3282B59D`) counted per `process`, so 0.1 s at
|
||
60+ fps and 0.2 s at 30 fps. The 4-step deck-impact max (`+668`) is the per-physics-step conditioner: unchanged.
|
||
- Change:
|
||
- `Contacts::deck_calls` (as `body_calls`); `PlayerAudio::deck_console` from the MixMap cadence's evaluations.
|
||
- The NPC instance follows: `NpcSkater::set_deck_calls`.
|
||
- Off switches: `SKATE_AEMS_DECK_CONSOLE=0` / `E2E_DECK_CONSOLE=0`.
|
||
- Diagnostics: `deck_posts` / `deck_digest`, and e2e writes `<name>.ours.deck.tsv`.
|
||
- Proof:
|
||
- Unit test `the_deck_poster_keeps_the_console_cadence_at_any_frame_rate`: the same messages on the same steps at
|
||
30 / 60 / 144 / 240 / 365 fps; the old cadence differs at 30. A held impact posts on steps 2, 14, 26, 38, 50, i.e.
|
||
every 12 steps = 0.2 s.
|
||
- The bail sessions at `E2E_FPS` 30 / 60 / 144 / 365: deck traces byte-identical across the four rates; the old
|
||
cadence's 30 fps differed.
|
||
- Off = before: `E2E_DECK_CONSOLE=0` renders are byte-identical to the current state (at all four rates for the bail
|
||
sessions; the bench `cad_dkoff` against `cad_t3`, row and fps300).
|
||
- Effect on the bench (`cad_dk` against `cad_t3`):
|
||
- Row mode: identical (no console cadence there).
|
||
- `E2E_FPS=300`: 5 outputs change, the scenario `ollies_log` and the sessions 213757, 214224, 215843, 224747. The
|
||
other 12 scenarios are identical.
|
||
- In each changed output the body trace is unchanged and the first differing voice row comes exactly one row after the
|
||
first differing deck post (`.local/audio-re/bodycad/deck_attr.py ON OFF`).
|
||
- Later rows keep differing: from that post on, the shared Splice picks shift.
|
||
|
||
| deck messages (`E2E_FPS`=300 bench / 60 bails) | old | console |
|
||
|---|---|---|
|
||
| ollies_log | 3 | 3 (timing moved) |
|
||
| 213757 / 214224 / 215843 / 224747 | 11 / 2 / 18 / 19 | 9 / 2 / 15 / 19 |
|
||
| 084712 / 101640 | 6 / 55 | 6 / 44 |
|
||
|
||
- Bails (`E2E_FPS=60`, against the current state, `bails_cmp.py`):
|
||
|
||
| | 084712 now | 084712 deck console | 101640 now | 101640 deck console |
|
||
|---|---|---|---|---|
|
||
| Skate_Collisions voices / bail | 42.0 | 41.0 | 26.6 | 25.7 |
|
||
| Skate_Collisions Σg² / bail | 1.926 | 1.600 | 0.982 | 0.970 |
|
||
| three banks voices / bail; Σg² / bail | 48.5; 2.114 | 47.5; 1.788 | 28.8; 0.997 | 27.6; 0.982 |
|
||
| body / cloth voices, tier 0 / 1 / 2 | 47 / 5 / 0 | 47 / 5 / 0 | 110 / 26 / 2 | 107 / 27 / 4 |
|
||
|
||
- 084712's bail 2 Σg² drop (2.485 → 1.834) has the same count of deck posts; the post timings moved and so did the
|
||
Splice picks after them.
|
||
- The tier counts come from `bails_cmp.py`'s top-24 ids per bail; the body traces themselves are unchanged.
|
||
|
||
**Verification.**
|
||
- `cargo test -p skate-audio --locked`: all pass (lib 221).
|
||
- `game_audio::`: 56 pass, 4 ignored.
|
||
- `cargo build --locked`: OK, with the 3 known warnings.
|
||
|
||
**Files.** `game_audio/{skate_events,player_audio,e2e,npc_skaters}.rs`, `skate-audio/src/player/contacts.rs`,
|
||
`skate-audio/src/world/skaters.rs`.
|
||
|
||
### Bail density: the missing speed curve, not the ragdoll (2026-10-03, research only; no engine change)
|
||
|
||
Spec: `.claude/notes/ragdoll-contact-spec.md` (retail mechanism, ours, decision points). Summary:
|
||
|
||
- **The retail Δv figures above (p99 1.8–13 m/s, max 7.6–20) are not comparable with our bails.** The scripted runs at
|
||
the Mega-Park spawn bailed into the gap or slid down the MegaRamp roll-in, so the body hit at 11–23 m/s (screenshots;
|
||
e.g. foot v·n −23 → −12 → −1 m/s). In the ordinary-height tumbles of run `bailx3_20261003_110208` (first 2 s of each
|
||
bail) retail's |Δv·n| is p90 2.3, max 3.7 m/s, approach speeds ≥ −4.3 m/s: the size of ours. The ragdoll solve
|
||
(Horus solver, 50 iterations, restitution 0, ragdoll friction 0.9, angular drag 0.5 × 60, ragdoll masses) is ported in
|
||
ours from the same code and data; no difference was found in the contact response.
|
||
- **What is missing: the audio bridge's speed curve.** `sub_824B0DA8`, after copying the conditioner's 8 region impacts
|
||
into the audio state (`+496..`), multiplies each by an 8-point graph (`sub_82481E10`) of the previous frame's |COM v|
|
||
(`+216` ← `+212`): x 0 / 0.366 / 0.448 / 0.521 / 0.593 / 0.741 / 0.863 / 0.946, y 1.0 / 1.2 / 1.486 / 1.914 / 2.429 /
|
||
3.6 / 4.571 / 5.0 (read from the running game with a watch on `[[[0x830CFDA4]+36]+4]`). New recomp hook `BANDQ` on
|
||
`sub_82497088` (impact, tier, band edges per query) shows the body poster's impact = 5.0 × the clamped region impact
|
||
(ratio p50 5.0 over 218 posts) and band edges equal to our exported table. Ours skips the curve, so our body impacts
|
||
are 1/5 of retail's at the poster: no tier 1 / 2, and concrete 1047 / 991 (> 1.0 / 1.85) unreachable — this also
|
||
answers the open question "which impact posts concrete tier 1 / 2" above.
|
||
- **Not changed** (needs the user's go): port the curve in `skate_events` (audio only; skate-core untouched), together
|
||
with the |Δv·n| abs and the body poster's 30 fps cadence; then re-measure bail density against the recomp.
|
||
*Ported 2026-10-03 (user: "Port it"): see "The bridge's speed curve on the body impacts" below.*
|
||
- Script: `bail_ledge.txt` now uses `lt rt l3 r3` (6 / 6 bails; user tip), with an X jump + mid-air `l3 r3 lt rt` fallback.
|
||
|
||
### The bridge's speed curve on the body impacts (2026-10-03, user decision "Port it"; `player::contacts`, `skate_events`)
|
||
|
||
The user decided (2026-10-03): port retail's speed curve on the body impacts ("Port it"); the ragdoll physics item is
|
||
closed with no physics change.
|
||
|
||
**Listening before the curve** (user, session `state_20261003_110725`, build with |Δv·n| and the body / deck posters on
|
||
the console cadence, no curve): "bails sounded good, a bit soft like you said. Deck hits sound really good and seem to
|
||
be either equal to retail or almost perfect to retail." The deck hits are approved, so the curve must leave the deck
|
||
posts alone (shown below).
|
||
|
||
**Retail's mechanism (TU3 recompilation, reference only).**
|
||
- The audio-state bridge `sub_824B0DA8` copies the skater entry's 116-byte block `+320` into the audio state `+496`.
|
||
This holds the conditioner's 8 region impacts, already clamped to [0, 1] by `sub_82BD60C8`.
|
||
- Then, for i = 0..7: `+496 + 4i` = `fmuls`(graph(`+216`), `+496 + 4i`). The graph is `sub_82481E10(8, rec + 16, rec + 48, v)`
|
||
with rec = `[[0x830CFDA4] + 36] + 4`.
|
||
- **No clamp after the multiply.** The poster sees up to 5.0. The recomp's BANDQ lines agree: the body poster's queries
|
||
(callers `824BC4C8` / `824BC530`) reach 4.11 (bandq run) and 4.88 (bailx3 run), concrete (material 2) up to 2.72 at
|
||
tier 2. The deck poster's queries (`824BD090` / `824BD0C4`) stay ≤ 1.0.
|
||
- The deck impact `+668` lies outside the multiplied range (`+496..+524`).
|
||
- **Which frame.** `+212` is written earlier in the same call from the skater entry's `+108`. `+216` is read by the
|
||
multiply, then set from `+212` after the loop. So the graph sees the *previous* bridge call's `+212`.
|
||
- **Units.** The entry's `+108` is written by `sub_827A1B78`: the length (`vmsum3fp128` + `vrsqrtefp128` with two
|
||
Newton steps) of the vector at `[r30 + 36] + 16`. That is SystemReckoning `+16`, the COM velocity, in m/s. It is the
|
||
same quantity as our `AudioState::com_speed()` (the footsteps' walk curve reads it too).
|
||
- The BANDQ floor queries agree with m/s. A lying body posts 0.00101–0.00116, i.e. ×1.0–1.16, and the graph gives that
|
||
below 0.37 m/s.
|
||
- **Interpolation** (`sub_82481E10`, already ported for the footsteps' 16-point curves):
|
||
- below x0 → y0; from x7 on, or for an unordered input → y7;
|
||
- in between, the first i with v < x[i]: slope first, then `fmadds`;
|
||
- y[i] where two x coincide.
|
||
- **Data: the install's own vault.** The words read from the running game equal the stock vault's class
|
||
`6EBA5BCD3E38A98A` `default` field `8B164823E008749C` (a `Sk8::PointNegGraphData8`, the same collection as the body
|
||
cooldown and the deck cooldown). Header 0 / 1 / 1 / 5, x at +16, y at +48:
|
||
- x 0, 0.3664494, 0.4478826, 0.5211726, 0.592834, 0.7410426, 0.863192, 0.946254;
|
||
- y 1.0, 1.2, 1.485714, 1.914286, 2.428571, 3.6, 4.571427, 5.0.
|
||
|
||
**Change.**
|
||
- `skate_audio::player::contacts::SpeedGraph8`: the 8-point graph and its evaluator. `ContactsTuning::body_speed_curve`
|
||
defaults to the vault words (`SpeedGraph8::BODY_SPEED`, bit patterns).
|
||
- Setup exports the graph: `audio_export.collision_tuning` posters `body_speed_x` / `body_speed_y`. `library.rs`
|
||
prefers them when present. Installs set up before today use the identical default.
|
||
- `AudioState::com_speed_216`: retail's `+216`. `skate_events::observe` sets it per physics tick to last tick's
|
||
`com_speed()` (`Seen::com_speed_212`).
|
||
- `AudioState::body_impact` stays the conditioner's (pre-graph) value. `Contacts::body` multiplies each region's
|
||
impact by `body_speed_curve.eval(com_speed_216)` (f32, no clamp). The cloth and pad contacts read the same product,
|
||
as retail's poster reads `+496`.
|
||
- Switch `Contacts::body_speed_on`: `SKATE_AEMS_BODY_CURVE=0` / `E2E_BODY_CURVE=0` turn it off, giving the impacts as
|
||
logged. The NPC instance's Contacts leaves it off. Its state has no `+216` (0 → ×1.0) and no ragdoll impacts yet.
|
||
- The deck poster, the 4-step region max and `region_impacts` are unchanged.
|
||
- **Replays.** The state log gains a `com_speed` column (`+212`, appended), and `scenarios.py` (both copies) carries
|
||
`com_speed` and `com_x/y/z` into cut scenarios. The e2e harness computes each row's `+216` from the previous row:
|
||
- from `com_speed` when the log has it;
|
||
- else (logs before today) |Δ COM position| × 60 from `com_x/y/z`, the reckoning's followed point at 4 decimals
|
||
(±0.006 m/s). The graph saturates from 0.95 m/s, so this only matters for slow bodies. A respawn jump reads as fast
|
||
(×5, as any speed > 0.95);
|
||
- else 0 (×1).
|
||
|
||
So the old bail sessions render with the curve.
|
||
- Diagnostics: `E2E_BODY_LOG=1` writes `<name>.ours.bodymsg.tsv` (every body message with its row, region, the impact
|
||
the poster read, `+216`, materials, tiers, levels). `Contacts::body_log` has no effect on the output.
|
||
|
||
**Tests.**
|
||
- `the_bridge_speed_graph_is_the_vault_curve_piecewise_linear_and_clamped` checks the knots, both clamps, NaN, the
|
||
midpoint, and is bit-exact against the slope-first `mul_add`.
|
||
- `the_speed_graph_scales_the_body_impacts_not_the_deck`: at rest (×1) a 0.3 arm hit on concrete is arm tier 1 /
|
||
concrete tier 0. Moving (×5) it is 1.5, i.e. arm tier 2 / concrete tier 1 (1047). 0.4 → 2.0 gives concrete tier 2
|
||
(991). Off equals at rest. 0.0011 × 5 crosses the arm floor. The deck messages are identical with the graph on and off.
|
||
- `the_body_speed_graph_is_the_stock_vault_record` (`library.rs`, data-gated on the converted `skater-collections.json`):
|
||
exported posters win; the default equals the vault record word for word.
|
||
|
||
**Frame-rate independence and identity** (`.local/audio-re/bodycurve/`: `render.sh`, `bails.py`, `body_attr.py`;
|
||
inputs re-cut with `scenarios.py --cut r0-999999`, 0 malformed).
|
||
- Body traces with the curve are byte-identical at `E2E_FPS` 30 / 60 / 144 / 365 for all three sessions.
|
||
- `E2E_BODY_CURVE=0` renders (f32 and voices) are byte-identical to the previous state's (`bodycad/dk`) at all four rates
|
||
for 084712 / 101640. The new COM columns are inert when the curve is off.
|
||
- **Deck posts unchanged:** the deck traces (count + FNV digest of every message) are identical with the curve on and
|
||
off in all three sessions, at every rate.
|
||
- In each session the first differing voice row is the row of the first differing body post (084712 row 1101, 101640
|
||
row 2427, 110725 row 1963, all in a bail, state 300). Before it, the voices are identical.
|
||
- After that the voices keep differing, outside the bails too: the shared Splice picks shift, as with the deck cadence
|
||
change. The deck hits' messages (timing, materials, tiers, levels) stay the same. Only the random variant a hit draws
|
||
may differ after the first bail.
|
||
- **Bench** (13 scenarios + 4 sessions, row and `E2E_FPS=300`, `opt/runs/curve` against `cad_dk`): IDENTICAL. Those
|
||
inputs carry no region impacts and no COM columns, so they cannot change. The graph acts only on body posts, and it
|
||
scales them only while the body moves (> 0 m/s): ×1.2 at 0.37 m/s, ×5 from 0.95 m/s.
|
||
|
||
**Per bail** (`E2E_FPS=60`; posts = message sides with a tier, each plays one collision id; window = bail rise − 0.5 s …
|
||
bail clear, as the recomp's 094336 summary; voices / Σg² = `bails_cmp.py`, −0.5…+2.5 s):
|
||
|
||
| per bail | the recomp (094336, 10 bails) | 084712 off → on (2) | 101640 off → on (10) | 110725 off → on (7) |
|
||
|---|---|---|---|---|
|
||
| body posts | 71 | 15.0 → 24.5 | 7.8 → 10.9 | 15.1 → 20.0 |
|
||
| tier 0 / 1 / 2 | ≈ 49 / 13 / 9 | 14.0 / 1.0 / 0 → 19.5 / 3.5 / 1.5 | 6.5 / 1.1 / 0.2 → 6.7 / 2.9 / 1.3 | 12.7 / 1.7 / 0.7 → 12.3 / 5.4 / 2.3 |
|
||
| tier-2 1030 / 1031 / 1032 | 1.8 / 3.3 / 2.1 | 0 / 0 / 0 → 0 / 0.5 / 0.5 | 0 / 0 / 0 → 0.1 / 0.6 / 0.3 | 0 / 0.1 / 0 → 0.3 / 0.9 / 0.3 |
|
||
| torso 951 | 1.0 | 0 → 0.5 | 0.2 → 0.2 | 0.3 → 0.4 |
|
||
| concrete 958 / 1047 / 991 | 3.4 / 2.3 / 0.9 | 1.0 / 0 / 0 → 2.0 / 0.5 / 0 | 0.1 / 0 / 0 → 0.5 / 0 / 0 | 0.6 / 0 / 0 → 1.9 / 0.4 / 0 |
|
||
| Skate_Collisions voices (recomp: 38 bails, nine sessions) | 207.7 | 41.0 → 59.5 | 25.7 → 30.6 | 35.6 → 43.7 |
|
||
| three banks Σg² | ≈ 5.1 | 1.79 → 2.52 | 0.98 → 1.03 | 1.58 → 1.78 |
|
||
| peak impact the poster read (max over the bails) | 4.9 (bailx3) | 0.21 → 1.03 | 0.32 → 1.59 | 1.00 → 5.00 |
|
||
|
||
- What changed: the tier-1 / 2 hits and concrete 1047 now occur. The posts per bail rise 30–60 %: floor contacts
|
||
0.001 × 5 = 0.005 pass the arm floor, and more regions clear their bands.
|
||
- Concrete 991 (> 1.85, i.e. a region impact > 0.37 while moving) still doesn't occur in our bails.
|
||
- The gap to the recomp's 71 posts stays, but the recomp is not the reference for counts. Its ~345 fps cadence makes
|
||
the 15-frame cooldown ≈ 43 ms (0.5 s on the console). With 6 regions and a 0.5 s cooldown, our poster can't post
|
||
more than ~12 groups a second.
|
||
- Σg² rises less than the counts. A tier-2 hit's level comes from its own window, and many of the new posts are
|
||
quiet tier-0 floor hits.
|
||
- 110725's last bail (155.9 s, 4.5 s long) has no region impacts at all (`rimp*` 0 throughout). Not investigated.
|
||
|
||
**|Δv·n| against the older sessions** (110725 was recorded with |Δv·n|, 084712 / 101640 without; from the logs):
|
||
|
||
| | 084712 | 101640 | 110725 |
|
||
|---|---|---|---|
|
||
| contact region-frames at the 0.001 floor | 84 % (1,035 / 1,237) | 62 % (649 / 1,042) | 76 % (1,950 / 2,554) |
|
||
| above-floor region-frames / bail | 101 | 39 | 86 |
|
||
| region-frames > 0.1 / > 0.25 | 12 / 0 | 68 / 16 | 71 / 29 |
|
||
| body posts / bail, no curve | 15.0 | 7.8 | 15.1 |
|
||
|
||
- No session-level signature of the abs. The bails differ more between sessions than the expected effect: 26 % of
|
||
above-floor steps in the recomp, mostly small, 8 of 166 above 0.1.
|
||
- The abs can't be isolated without the same bail logged both ways (the replay reads the logged impacts). 110725's
|
||
many hits above 0.25 are its larger falls (peak 1.0).
|
||
|
||
**Files.** `crates/skate-audio/src/player/{contacts,state}.rs`,
|
||
`crates/skate-game/src/game_audio/{skate_events,state_log,e2e,player_audio,library}.rs`,
|
||
`tools/asset_pipeline/audio_export.py`, `tools/audio-e2e/scenarios.py`.
|
||
|
||
**Open.**
|
||
- The NPC instance gets no `+216` (its state comes from `world::skaters`, and no ragdoll impacts are published).
|
||
- Old logs use the COM-position fallback. Sessions from this build log `com_speed`.
|
||
- For the user in game: bails should now have more tier-1 / 2 body hits and concrete tier 1 (louder, punchier). Deck
|
||
hits are unchanged.
|
||
|
||
### The turn intensity after release; trace / comment leftovers (2026-10-03)
|
||
|
||
**Turn intensity "linger": no change. The recomp's 50 ms is its frame rate.**
|
||
- Retail's `sub_824C8588` moves the signed I toward the raw target by the grain collection's step per call: `+1160` ±
|
||
the primary truck's rise / fall step (the vault field lookup `sub_82B72420`; 0.06, 0.1 on one surface). There is no dt.
|
||
- The local GREC rows of `all_20261002_180430` (filtered by `filter_local.py`; 1,874 calls where the input reads 0 and
|
||
I > 0.07) step by exactly 0.06 per call (1,832; 0.1 on 36), at 2.8 ms per call (the recomp's ~345 fps).
|
||
- So the recomp's I is back to 0 in ~10 calls ≈ 30 ms. That is the "0 by 50 ms" figure, a recomp artefact. On the
|
||
30 fps console the same 10 steps take ~333 ms.
|
||
- Ours: `TurnIntensity::step` with `frames = dt × 60` takes 0.06 per 1/60 s.
|
||
- In 110725 / 101640 our input (the animation's Turn) falls from ≥ 0.9 to 0 in 3 frames (p50; p90 4–5), like
|
||
retail's 34–50 ms.
|
||
- Our I then reaches 0 in 8 frames (p50; p90 9–10, simulated from the log at cap 0.6): the "~13 frames" from the
|
||
release start.
|
||
- That is already half the console's time, not a linger.
|
||
- The retail mechanism on the console-cadence rule would be 0.06 per console frame (`frames = dt × 30`; the brake slew
|
||
`BRAKE_STEP` has the same shape). That doubles our fall time to ≈ 330 ms and changes the rolling bed in every riding
|
||
scenario. Not changed: it is the opposite of the reported symptom and needs the user's call.
|
||
**Decided 2026-10-03 ("lets go for like retail"): see "The turn and brake slews on the console cadence" below.**
|
||
|
||
**Leftovers.**
|
||
- `skate_events.rs`: the wheel-material comment now says 0.45 changes / s (local rider only).
|
||
- The published `tools/recomp-trace/trace.py` FIELD_COUNTS gains BAILLOCAL, BAILSTEP, BAILREG, BAILCAND, COLLPOST,
|
||
BANDQ and LOCALTEST (scrubbed comments).
|
||
|
||
**Verification (both sections).**
|
||
- `cargo test -p skate-audio --locked`: all pass (lib 223).
|
||
- `cargo test -p skate-game --release --bin skate3rust --locked -- game_audio::`: 57 pass, 4 ignored.
|
||
- `cargo build --locked`: OK, with the 3 known warnings.
|
||
- Headless only (target `.local/fma-target`); bin\ and data\ untouched.
|
||
|
||
**Listening (user, 2026-10-03, bin\ 11:34 with the bail speed curve; session `state_20261003_113953`, 0 malformed):** "Sounds so much better!"
|
||
|
||
### The turn and brake slews on the console cadence (2026-10-03, user decision "on the decision lets go for like retail"; `grain::board::OwnerSlews`, `game_audio/grain_bed.rs`)
|
||
|
||
The user decided on the open item of "The turn intensity after release" above: "on the decision lets go for like
|
||
retail".
|
||
|
||
**Retail's mechanism (TU3 recompilation, reference only).**
|
||
- The SkateBoard process `sub_824C6A78` (vtable `0x822FC780`, called with the frame's dt in f1) runs, in order:
|
||
slope `sub_824CA738`, routing `sub_824C5CA8`, the owner modulators `sub_824C6198` (dt passed on), then
|
||
`sub_824C9058`, `sub_824C7438`, `sub_824C7738`, `sub_824C9F68` and the seam envelope `sub_824CA448` (dt).
|
||
- In `sub_824C6198`, everything that uses time takes dt: the push speed-scale and shift envelopes (`+912` / `+1036`)
|
||
advance through `sub_8248D510` with f1 = dt.
|
||
- Two values move by a fixed step per call with **no dt**:
|
||
- **Turn intensity** `sub_824C8588` (the call at `0x824C68F4`; the result is stored as `+1164`): the signed `+1160`
|
||
moves toward the raw target by the primary truck's rise or fall step from the vault (`sub_82B72420`; default
|
||
word at `0x830D0850`), with `fadds` / `fsubs`, then `stfs +1160`.
|
||
- **Brake** `+1168`, inline after it. While braking (state `+336`), the cosine of the two velocity vectors
|
||
(state `+128`, `+96`) is checked: below 0, slew toward 1 (f31); otherwise toward 0 (f27). Without braking, slew
|
||
toward 0. The step is the `lfs` at `0x82165A00` (0.05, grain-player-spec §2.7). The value snaps to the target
|
||
once within one step.
|
||
- The function has no other per-call slews. The manual latch (`+1504`, inline in `sub_824C8588`) and the trick latch
|
||
(`sub_824CA6E0`) are set / clear latches, not steps. They stay per rendered frame here: they are idempotent on a
|
||
repeated state, and sampling them at 30 Hz would drop one-step pulses (the reason the MixMap flags needed
|
||
`hold_input`).
|
||
- **Call site cadence.** The process runs once per call of the audio manager's half (`sub_82485190`, "The MixMap on
|
||
the console cadence"). On the ~30 fps console both halves run on every frame, so: one step per console frame. A
|
||
full release from the cap 0.6 to 0 takes 10 calls (f32 leaves 7.45e-9, which the 11th call removes) = 333 ms. A
|
||
full brake release 1 → 0 takes 20 calls = 667 ms.
|
||
- Ours stepped by 0.06 / 0.05 × (dt × 60), i.e. per 1/60 s: twice as fast (167 ms / 333 ms). The NPC bed ran at
|
||
`dt = CONSOLE_DT × evaluations`, so its step was also 0.12 per evaluation.
|
||
|
||
**Change.**
|
||
- `skate_audio::grain::board::OwnerSlews { turn, brake }` and `board::BRAKE_STEP` (moved from `grain_bed.rs`).
|
||
`OwnerSlews::step(tuning, com_speed, turn, special, braking, calls, frames)`:
|
||
- `calls = Some(n)`: n retail calls (each step × 1);
|
||
- `None`: once, with the steps × `frames`, the old code path.
|
||
- `grain_bed::Bed`:
|
||
- `slews` replaces `turn` / `brake`.
|
||
- `slew_calls` is set by the host before each step and taken by it: the console evaluations of this pass from
|
||
`mixmap::cadence`.
|
||
- `slew_console` (`SKATE_AEMS_SLEW_CONSOLE=0` off) is copied to the NPC beds.
|
||
- Hosts:
|
||
- `native::mixmap_frame` sets `slew_calls = Some(0)` on every frame (also frames without a 60 Hz tick) and
|
||
`Some(calls)` on frames that tick. With `SKATE_AEMS_MIX_CONSOLE=0` it sets `None`.
|
||
- The NPC host sets `Some(evaluations)`.
|
||
- e2e: `E2E_SLEW_CONSOLE=0` off. The cadence follows `E2E_MIX_CONSOLE` (on with `E2E_FPS`, off in the per-row
|
||
renders).
|
||
- Between console frames the bed keeps feeding the records the held |I| and Bk.
|
||
- Diagnostics: `E2E_SLEW_LOG=1` writes `<name>.ours.slew.tsv` per call (turn input, |COM v|, I, signed I, braking,
|
||
Bk). It has no effect on the output.
|
||
|
||
**Proofs** (tools `.local/audio-re/slewcad/`: `render.sh`, `compare.py`, `localise.py`, `release.py`).
|
||
- Inputs: the sessions 084712, 101640, 110725, **113953** (cut with `scenarios.py --cut r0-999999`; 5,484 rows =
|
||
lines − 1, so 0 malformed) and the bench's 213757, 214224, 215843, 224747.
|
||
- **Off = byte-identical.** `E2E_SLEW_CONSOLE=0` renders (f32, voices, body, deck) equal the pre-change tree's at
|
||
`E2E_FPS` 30 / 60 / 144 / 365 for all eight sessions. `E2E_SLEW_LOG=1` was on in those renders.
|
||
- **Frame-rate independence.**
|
||
- Unit test `owner_slews_are_the_same_at_any_frame_rate`: a host that runs once per rendered frame with the newest
|
||
60 Hz state and the cadence's calls gives bit-identical I / Bk on every console frame at 30 / 60 / 144 / 240 / 365
|
||
fps. The old per-frame host differs between 30 and 60.
|
||
- e2e: the slew logs' values on every evaluation are identical at 30 / 60 / 144 / 365 for all eight sessions.
|
||
- **Step count.** Unit test `owner_slews_take_one_step_per_console_frame`:
|
||
- one 0.06 / 0.05 step on each console frame, none on the steps between;
|
||
- turn 0.6 → < 1e-6 after 10 console frames (20 steps), exactly 0 after 11;
|
||
- brake 1 → 0 after 20 (40 steps);
|
||
- the old host takes 11 steps;
|
||
- without a primary truck the brake still slews and the turn holds (reads 0).
|
||
- **What changes (on vs off, `E2E_FPS=60`).**
|
||
- Only grain-bed records differ: grain0/1 A/B gain, pitch and position. No other bank's voices differ in any
|
||
session.
|
||
- Every differing voice frame lies within 0.5 s of a call where I or Bk differ.
|
||
- The first differing voice frame is at or after the first slew difference.
|
||
- Per session:
|
||
|
||
| session | calls where I / Bk differ | voice frames that differ |
|
||
|---|---|---|
|
||
| 084712 | 615 / 0 of 3,384 | 17.9 % |
|
||
| 101640 | 2,905 / 104 of 14,982 | 19.6 % |
|
||
| 110725 | 1,788 / 0 of 9,804 | 17.5 % |
|
||
| 113953 | 646 / 0 of 5,544 | 11.6 % |
|
||
| 213757 | 565 / 0 of 2,838 | 19.7 % |
|
||
| 214224 | 137 / 0 of 1,746 | 7.6 % |
|
||
| 215843 | 844 / 0 of 11,712 | 6.9 % |
|
||
| 224747 | 1,893 / 1 of 8,208 | 22.6 % |
|
||
- **Bench** (13 scenarios + 4 sessions; `opt/runs/slew_on` against `slew_base`):
|
||
- row mode: IDENTICAL (the per-row renders keep the old cadence);
|
||
- `E2E_SLEW_CONSOLE=0` (`slew_off`): IDENTICAL in both modes;
|
||
- fps300: brake20, carve20 and ollies_log change (the scenarios with braking or turning) and the 4 sessions. The
|
||
other 10 scenarios are identical (roll10/20/30/45, grind_ledge / metal, manual15, ollie20, slide20, sweep).
|
||
|
||
**Before / after release times** (`release.py`; a turn release = a continuous fall of I from a peak to 0, from the last
|
||
row at the peak; the eight sessions pooled; `E2E_FPS=60`, the same at 30):
|
||
|
||
| | before (off) | after (on) |
|
||
|---|---|---|
|
||
| turn, peak ≥ 0.5 | n 193, p50 217 ms, p90 400 ms | n 61, p50 367 ms, p90 800 ms |
|
||
| turn, peak ≥ 0.3 | n 320, p50 167 ms, p90 300 ms | n 168, p50 267 ms, p90 500 ms |
|
||
| brake (sessions, n 2–3) | 333 ms | 517–533 ms |
|
||
| brake20 scenario | 1 → 0 in 333 ms, 0 → 1 in 333 ms | 667 ms each (the scenario ends at 0.25, 30 rows after the release) |
|
||
| turn from the cap 0.6, input to 0 (theory) | 11 steps = 183 ms | 10 console frames = 333 ms (+ 1 for the f32 remainder) |
|
||
|
||
- Fewer releases reach 0 after: with the slower fall the rider often turns again before I is back at 0.
|
||
- The long tails (≈ 1–5 s) are slow input ramps, not the slew.
|
||
|
||
**Files.** `crates/skate-audio/src/grain/board.rs`,
|
||
`crates/skate-game/src/game_audio/{grain_bed,native,npc_skaters,e2e}.rs`.
|
||
|
||
**Verification.**
|
||
- `cargo test -p skate-audio --locked`: all pass (lib 225, 2 new).
|
||
- `cargo test -p skate-game --release --bin skate3rust --locked -- game_audio::`: 57 pass, 4 ignored.
|
||
- `cargo build --locked`: OK, with the 3 known warnings.
|
||
- Headless only (target `.local/fma-target`); bin\ and data\ untouched.
|
||
|
||
**For the user in game.** After a carve the turning layer (grain player B, the turn gain) fades out over about a third
|
||
of a second instead of a sixth. The brake layer fades in and out over 2/3 s instead of 1/3 s. Straight rolling, tricks,
|
||
bails and grinds are unchanged.
|
||
|
||
### Concrete ledges grinded as metal: the grind material is the tag − 1 (2026-10-03, `skate_events`, `e2e`)
|
||
|
||
**Report (user, 2026-10-03, session `state_20261003_115334`, 0 malformed):** "the last area I was in, the concrete
|
||
sounded like metal where I was grinding". The spot is on University, around board (258, 64, −257) / (198, 64, −207) /
|
||
(145, 64, −168).
|
||
|
||
**Cause.** Our grind material skipped the tag − 1 step.
|
||
- The engine's grind audio surface is the 7-bit collision tag (Grinds+216 = probe surface & 0x7F), the same kind of
|
||
value as the wheel tags.
|
||
- Retail's packer `sub_827A1B78` writes every material into the record as tag − 1 (0 or out of 0..143 → 143): the
|
||
wheels at `+464..+476`, and the grind at `+512`. The bridge `sub_824B0DA8` copies `+512` to the audio state's `+692`.
|
||
The grind start `sub_824BB0E0` reads `+692` as material B (143 → 10), and Class_grind / the on / off sounds look it
|
||
up in the AudioSurfaceMap.
|
||
- Ours used the tag directly (`skate_events::audio_state`), so every grind read the next entry of the surface map:
|
||
- tag 66 (concrete: material 65, rolling `concrete_rough`, grind surface 1) read entry 66, which is grind surface 4
|
||
(metal: Skate_Metal on / off, the metal GRINDS layer);
|
||
- tag 5 (material 4, grind surface 0) read entry 5 (surface 8, metal);
|
||
- tag 3 read surface 0 instead of 1. Tag 16 (a rail) gave surface 6 either way.
|
||
- The map's tag is right. Rolling over the same ledges reads tag 66 too, and the recomp's material at the same
|
||
positions is 65.
|
||
|
||
**Evidence (the recomp, `audiox_grind_20261003_120315`, 0 malformed, local rider `local72` = 1).** The user rode the
|
||
same route from the PCU library to the spot. Grind start COLLPOST `824BB0E0` material B (= `+692`) and position, against
|
||
our grind tags at the same places:
|
||
|
||
| retail material B | positions (x, z) | our tag there | GRINDS samples, retail | Skate_Metal voices, retail |
|
||
|---|---|---|---|---|
|
||
| 65 | (255, −257), (198, −206), (150, −213), (259, −239…−189) | 66 | 13–23 | 0 in 17 grinds |
|
||
| 2 | (149, −390), (266, −246), (230, −232) | 3 | 13–23 | 0 |
|
||
| 4 | (175, −116) | 5 | 4–8, 14 | 0 |
|
||
| 15 | (237, −413), (84, −403) | 16 | 55–65 | 5–17 per grind |
|
||
|
||
Every retail value is our tag − 1. Retail also never reads 66 or 3 here.
|
||
|
||
Our replay of the session's tag-66 grinds (e2e, `E2E_FPS=60`):
|
||
- before: GRINDS 55–65 (the metal layer) plus 3–11 Skate_Metal voices per grind;
|
||
- after: GRINDS 13–23, no Skate_Metal, as retail;
|
||
- tag 16 rails: unchanged (GRINDS 55–65 plus Skate_Metal).
|
||
|
||
**Change.**
|
||
- `skate_events::audio_state`: `grind_material = material_of_tag(Grinds+216)`, as the wheels, the deck and the feet
|
||
already do.
|
||
- The state log keeps the engine's tag in `grind_tag`, so the logs don't change. `e2e` converts it the same way.
|
||
- `AudioState::grind_material` doc updated.
|
||
- No off switch: this is the retail mechanism, and the old path read the wrong table entry.
|
||
|
||
**Bench** (`.local/audio-re/grindmat/bench.sh`: the 13 scenarios plus 5 sessions incl. 115334, before / after):
|
||
- Changed: grind_ledge, grind_metal, real_115334, real_215843, real_224747. These are exactly the inputs with grinds.
|
||
- All other outputs are byte-identical.
|
||
- In the changed ones, nothing differs before the first grind. Within 1 s after a grind, the differences are the
|
||
grind's own voices.
|
||
- Later differences are Splice voices only (Skate_Collisions / sk8_foley member picks). The shared CRT rand stream
|
||
moves when a grind's on / off record has a different member count. No AEMS or grain voice differs after a grind.
|
||
- The bench's `grind_metal` scenario (tag 9) used to render a concrete grind (entry 9 = surface 1). It now plays
|
||
metal (entry 8 = surface 4), as `player_audio`'s tests (which already used `material_of_tag(9)`) intended.
|
||
|
||
**Verification.**
|
||
- `cargo test -p skate-audio --locked`: all pass (lib 225).
|
||
- game_audio tests: 57 pass, 4 ignored.
|
||
- `cargo build --locked`: OK, with the 3 known warnings.
|
||
- Headless only (`.local/fma-target`); bin\ and data\ untouched.
|
||
- Tools: `.local/audio-re/grindmat/{bench.sh,ours.py,retail.py}`.
|
||
|
||
**For the user in game.** Concrete ledges and curbs (e.g. that University spot) grind with the concrete GRINDS layer
|
||
and the Skate_Collisions on / off sounds, no metal ring. Metal rails still ring. Some other concrete / stone grinds
|
||
change surface too (tag 3: surface 0 → 1; tag 5 was metal, now concrete).
|
||
|
||
**Listening (user, 2026-10-03, couch launcher, bin\ with the turn / brake console cadence and the grind tag - 1 fix; session `state_20261003_135706`, 0 malformed; asked about the University concrete ledge, a metal rail, carving, braking, enclosed spots (reverb) and fast riding above 46 km/h):** "all of it sounded really good"
|
||
|
||
### Riding body-collision posts settled (2026-10-03, recomp session `audiox_ride_20261003_141046`, 0 malformed)
|
||
|
||
User ride (~4.5 min, 2 bails). Outside the bails (+3 s): body-poster posts from NPC skaters 163 (0.70/s, local72 0), from the local rider 49 (0.21/s), and the local ones are only three bursts, no steady riding posts: 45.9 s (4), 172.7 s (19; screenshot: landing a 7.5 ft stair gap) and 202.6 s (26; pushing into a parked car, vehicle material 36). So steady riding gives 0 local body posts in retail, as in ours; the earlier ~1.7/s was NPC skaters plus such events. Follow-up: check that ours posts body hits on a big-drop landing and on bumping a car (a state-log session with both).
|
||
|
||
### PR #32 review fixes, step 1: the host clock is the physics steps; guards, tests, cleanups (2026-10-03, headless)
|
||
|
||
**Problem (code review of PR #32).** `skate_events::observe` runs once per physics step (FixedUpdate, Virtual
|
||
time, 16.6666 ms) and overwrote `Cues.riding` each step. `native::mixmap_frame` (Update) ran its own `Time<Real>`
|
||
accumulator (`mix_clock += delta`, ticks = mix_clock / (1/60), cap 4) and ran inputs / process / update on whatever
|
||
sample was in `Cues`. So the two grids drifted:
|
||
- a frame with 2 physics steps and 1 mix tick lost the first step's one-step pulse (`+335`, the push plant's rise);
|
||
- a frame with 1 mix tick and 0 steps re-processed a stale sample;
|
||
- while the menu paused `Time<Virtual>`, `mixmap_frame` and the grain bed kept ticking on the stale sample;
|
||
- below 60 fps the components saw every second step (still true, see below: that is retail's behaviour too).
|
||
|
||
**Root cause.** Two clocks for one stream of samples. Retail's audio manager (`sub_82485190`) reads the record the
|
||
game steps wrote; it never runs on a sample twice and never on a frame the game didn't step.
|
||
|
||
**Change.**
|
||
- `skate_events::Cues` counts the steps published since the host last took them (`Cues::publish` /
|
||
`take_steps`). While steps are pending, the one-step pulses of the new step are OR-ed with the pending one
|
||
(`latch_pulses`: `push_trigger` = `+335`). Every other field is a level (the latest step's value is what retail's
|
||
per-frame manager reads too), a latch (`+228`, `+468`, `+192` / `+692`), a 4-frame max (`+668`, `+496..`) or a
|
||
counter (`Riding::pushes`); the components find their own edges (landing, bail, grind) against the last sample
|
||
they processed, so those survive a 2-step frame without latching.
|
||
- `native::HostClock::pass` (used by `mixmap_frame` and the e2e harness): takes the frame's steps. No step, or the
|
||
game silenced (menu, replay, the multiplayer menu that doesn't pause): no pass (no inputs, process, tick or
|
||
update; a silenced frame drops its steps and pulses, as the stream is paused). Otherwise ticks = steps, at most
|
||
`MAX_STEPS_PER_FRAME` = 4; the console cadence advances by those ticks.
|
||
- **The cap (4 steps = 2 console evaluations).** Retail runs its audio manager once per rendered frame and never
|
||
catches up: a long frame is one call with a long dt. Our host counts steps to stay on the console's 30 Hz grid at any
|
||
frame rate, so it bounds the catch-up instead. 4 keeps frame-rate independence down to 15 fps (the old accumulator's
|
||
cap) while a hitch (Bevy runs up to 15 physics steps after a 250 ms frame) can't release a burst of evaluations,
|
||
Jitter steps or poster calls. The pulses of the dropped steps stay latched.
|
||
- `Native::frame_ticks`: the grain bed (`grain_bed::update`) runs only on frames with a pass, dt = ticks × 1/60 (as
|
||
the e2e harness always did), not every rendered frame on `Time<Real>`. Pause no longer advances its envelopes.
|
||
- Class_Seams' console cadence (`seam_frame`) still runs on every rendered frame (its own 30 Hz grid), now on game
|
||
time (`Time<Virtual>`, still while paused) and not while silenced. The rendered board's interpolation pair is the
|
||
last two physics steps (`Riding::wheels_before`, `PlayerAudio::step_wheels`), so a frame that takes two steps
|
||
interpolates between the right two.
|
||
- Pause.in0 is no longer written as 32767: nothing ticks while silenced, so no evaluation would read it.
|
||
- **Camera cuts.** `Presentation::cuts` counts the engine's snaps (teleport flag, camera discontinuity, cadence
|
||
change). On a change, or a map change (`CurrentMap::generation`), `mixmap_frame` resets the listener's last camera
|
||
(`PlayerAudio::reset_listener`: no Doppler velocity from the jump) and the seam pair (no interpolation from the old
|
||
place), and counts `Native::cuts`; the world / NPC hosts drop their camera velocity on a change of it.
|
||
|
||
**Evidence / proof.**
|
||
- e2e bench (`optimisation/tools/e2e_bench.sh`, 13 scenarios + 4 whole sessions; baseline built from `git archive
|
||
HEAD` in `.local/clk-base-src`): `row`, `fps60`, `fps144`, `fps300` byte-identical to the baseline (audio and voice
|
||
logs). `fps30` / `fps45` differ, as intended. Attribution: with the latch and the step pair both disabled in a
|
||
throw-away build, fps30 / fps45 are byte-identical to the baseline too, so these two are the only differences.
|
||
- The latch: in the 4 real sessions, 16 of 34 logged push edges fell on a row without a pass at 30 fps and were
|
||
dropped before; now each reaches one pass (also `ollies_log`'s one push).
|
||
- The step pair: at < 60 fps the old change-tracking pair spanned two steps.
|
||
- The e2e harness already modelled the ideal host at ≥ 60 fps (one pass per row); it now uses `HostClock` and
|
||
`Cues` itself, so the harness and the game share the scheduling code.
|
||
- Unit test `native::tests::the_host_clock_takes_every_physics_step_once`: a Bevy app with `TimePlugin`, the physics
|
||
period and `TimeUpdateStrategy::ManualDuration` frame times (alternating 1 / 2 steps, frames without a step, 30 fps,
|
||
a 300 ms hitch = 15 steps, a paused second, a silenced stretch that still steps). Every step is taken by exactly one
|
||
frame; a pass sees a pulse iff one of its steps had one (the hitch frame merges three); ticks = steps capped at 4;
|
||
no pass while paused / silenced or without a step; console evaluations = ticks / 2.
|
||
|
||
**Other review items in this step.**
|
||
- Removed `trace-downtown-lag.json` (now ignored as `/trace-*.json`, `TRACE_PLAY.bat` writes it) and
|
||
`tools/audio_audition.py` (the audition pages are gone; the "Dev tools" and "Files" entries above are updated).
|
||
- **No fallback, for real.** `emitters.rs` no longer plays the measured `PROFILES` table through Bevy voices when the
|
||
native runtime is absent: without it the emitters are silent (the runtime's start logs why). Removed with it: the
|
||
relay / loop patterns, the shuffle bag, `Library::sample_seconds` and the manifest's `seconds` field read.
|
||
`native::follow_volume`: Bevy voices always play at 1 / `RETAIL_SCALE` with the native fold and swapped ears (before,
|
||
an install without the runtime kept ×2 and Bevy's mirrored panning). Identical with the runtime.
|
||
- Data-gated tests: 68 tests in `skate-audio` and `game_audio` (96 skip sites, incl. the world / NPC ones) are
|
||
`#[ignore = "needs the private install data"]`, and a missing piece now panics ("missing private data: …") instead
|
||
of passing silently. Run them with `-- --ignored` (or `--include-ignored`). On this machine every ignored test passes
|
||
(skate-audio 51, game_audio 21, older diagnostics included). The wall-clock assertion in the prefetch test is gone (timings are printed).
|
||
- `AUDIO_NATIVE post` / `release` lines (one per component post, under the runtime lock) only with
|
||
`SKATE_AUDIO_TRACE=1`. No tool reads them.
|
||
- Guards: `formats::abk` template end is a checked add; `Runtime::fill_stereo` turns non-finite samples into 0 for
|
||
the device (host-side safety, not a parity change: finite samples pass untouched; e2e reads the bus before it).
|
||
- Allocations: the evaluator reuses unloaded bank slots (map changes no longer grow the list); `Evaluator::walk`
|
||
reuses its order snapshot; `redeliver` / `release` read their client lists in place; `destroy` reads the module's
|
||
object lists through the bank's `Arc` (it copied them). `grain_bed::update` no longer clones the seam wobbles and
|
||
builds a `PlayerTuning` every frame. `tests/render_alloc.rs` gains a hand-built AEMS bank (`eval::synthetic`, the
|
||
evaluator tests' builder made public and hidden from docs) with a playing program, a redeliver every console frame
|
||
and a release: 0 allocations in 480 warm blocks; the allocation counter is per thread now. All e2e outputs of
|
||
these changes are byte-identical (bench `clk_final` vs `clk_new`, every mode).
|
||
- clippy `approx_constant` errors in the `dsp::pan` tests (`FRAC_1_SQRT_2`); 0 clippy errors in `skate-audio`.
|
||
- `.gitignore`: `*.wav` is scoped to the folders retail audio is written to (`/assets/private`, `/data`,
|
||
`/maps/private`, `/.local`); no untracked WAV became visible. Mods and the SDK may ship WAVs.
|
||
- Credit: the FSS `sin` / `cos` polynomials are XNA Math's `XMVectorSin` / `XMVectorCos` (successor DirectXMath,
|
||
Microsoft, MIT); see Credits.
|
||
|
||
**Files.** `game_audio/{skate_events,native,grain_bed,player_audio,e2e,emitters,library,world_sources,npc_skaters}.rs`,
|
||
`game_audio/native/prefetch.rs`, `presentation.rs` (the cut counter), `skate-audio/src/{eval/mod.rs,eval/synthetic.rs,
|
||
eval/tests.rs,formats/abk.rs,runtime.rs,dsp/pan.rs}`, `skate-audio/tests/render_alloc.rs`, every data-gated test file,
|
||
`skate-audio-fma/src/lib.rs`, `.gitignore`.
|
||
|
||
**Open questions.**
|
||
- Slow motion: the physics timer period changes (`physics/clock.rs`), so the host now ticks slower in real time with
|
||
it (it followed real time before). What retail's audio manager does in slow motion is not traced (`+220` time
|
||
scale is written 1.0).
|
||
- The world / NPC hosts keep NodeIds across `unload_map_banks` (not fixed here; the world build-out step).
|
||
- Needs the user's listening check at a low or uneven frame rate (pushes) and after a menu pause.
|
||
|
||
### World audio hook-in, phases P0–P2: the map-change fix, the engine / mod surface, the dev test mod (2026-10-03, headless)
|
||
|
||
Full write-up, field tables and examples: [doc 15](15-world-audio.md). Design: `.claude/notes/world-audio-hookin-spec.md`.
|
||
|
||
- **P0, map change.** `Native::unload_map_banks` destroyed every instance of the 13 world banks, but `WorldHost`
|
||
kept the dead nodes and "already posted" objects, so an owner that survived the change was silent for the rest of
|
||
its life. Now `Native::map_epoch` is bumped by the unload. On a change, `WorldHost` / `NpcHost` release every node,
|
||
clear the pools / records, deactivate the 3DObjPos blocks, stop the ped Splice steps and the NPC bed, and drop
|
||
their objects; the owners still published are claimed and posted afresh. The evaluation count is a
|
||
`saturating_sub`. The world hosts' `dt` is now 1/30 per console evaluation and 1/60 per tick with
|
||
`SKATE_AEMS_MIX_CONSOLE=0` (it was `CONSOLE_DT` either way, double the real time in the non-console mode; that mode
|
||
went with the A/B switches, below, so it is 1/30 now).
|
||
Regression test `world_owners_post_again_after_a_map_change`, which fails without the reset.
|
||
- **Retail limits from the gap runs** (spec §7.3): traffic list cut at 40 m horizontal (4 nearest); ped list cut at
|
||
50 m (15 nearest); ped footsteps for the 3 nearest (`S+68`); `S+148` / `S+156` = the distance / 20 m. Applied by
|
||
the hosts (radii) and the bridge (footsteps-on, the pair).
|
||
- **P1, the engine surface** `crate::world_audio`: `TrafficAudio`, `PedAudio`, `NpcSkaterAudio`, `AudioVelocity`,
|
||
the read-back `WorldAudioInstance`, `LivingWorldAudio`, `WorldAudioStats`, the messages `PedSpeechEvent` /
|
||
`VehicleHorn` / `VehicleAlarm` (8 s). Bridge: `game_audio/world_bridge.rs`.
|
||
- The per-skater builder `skate_events::skater_audio_state` + `SkaterAudioMemory` is a pure move of `observe`'s
|
||
code. 1,500 production physics steps (`audio_state_capture`) gave a published state identical line for line
|
||
to the pre-refactor capture, and the builder equals `observe` on every step.
|
||
- `AudioState::rolling(&LiteSkater)` is the documented minimal fill for skaters not simulated with the player's
|
||
physics.
|
||
- **User decisions included** (2026-10-03):
|
||
- mod cars may opt in to retail traffic engine sounds (`body=`);
|
||
- remote multiplayer players take the NPC skater instance, first in the list (non-retail; a lite state with the
|
||
ground's material);
|
||
- opt-in non-retail "more audible" (`settings/audio.json` `"more_audible_world": true`: 8 / 24 / 3 instances;
|
||
the MixMap is built with them; instance 0 unchanged);
|
||
- no traffic-light sounds.
|
||
- **Mods:** `sdk.world_audio.spawn / update / event / remove / read / info` (capability `world_audio` = 1). 16
|
||
objects per mod, 64 in all; an object is parked after 0.5 s without updates; everything is cleaned up on disable,
|
||
reload or failure. The existing `sdk.audio.*` is unchanged.
|
||
- **P2, the dev test publisher** `mods/world-audio-test/`:
|
||
- 16 cars (c00–c08 records, honks, skids, F8 alarm);
|
||
- 20 peds (walk / jog / run, warn / cheer / slam);
|
||
- a ghost NPC skater replaying a window of one of the user's state logs (`logs/<name>.tsv` or
|
||
`SKATE_AUDIO_STATE_LOGS`);
|
||
- debug boxes by audibility.
|
||
|
||
The e2e state-log replay moved unchanged from `e2e.rs` to `game_audio/state_replay.rs` for the ghost.
|
||
- **Proof for the local player:** the e2e bench is byte-identical before / after (13 scenarios + 4 sessions, row and
|
||
fps300). Tests: the bridge unit tests, the ghost through the real host (claim, release at range, a map change), and
|
||
the skate-mods validation plus a 400-frame run of the test mod.
|
||
- **Learned for the next pass (P3), from G3:** for an NPC, Wheels runs (layers 0 / 1) and Clothing runs; Tricks and
|
||
Treatment are local only; OffBoard creates packets but plays no steps. Instance 1 changes hands often (12× in
|
||
74 s).
|
||
|
||
### A/B switches removed (2026-10-03, user decision; separate change after the world audio P0–P2)
|
||
|
||
**Decision (the user):** remove the 15 verdict-settled A/B env switches. Their ON behaviour becomes the only
|
||
behaviour.
|
||
|
||
**Removed:**
|
||
- `SKATE_AEMS_SEAM_PULSE`, `_MIX_CONSOLE`, `_PUSH_PLANT`, `_PLANT_LIFT`, `_BODY_IMPACTS`, `_GRIND_ONOFF`,
|
||
`_AIR_TIME_STATE`, `_SUBMIX`, `_FOOTSTEP_SUBMIX`, `_REVERB_INPUTS`, `_REVERB_ZONES`, `_BODY_CONSOLE`,
|
||
`_DECK_CONSOLE`, `_BODY_CURVE`, `_SLEW_CONSOLE`;
|
||
- their e2e equivalents: `E2E_MIX_CONSOLE`, `_BODY_CONSOLE`, `_DECK_CONSOLE`, `_BODY_CURVE`, `_PLANT_LIFT`,
|
||
`_BODY_IMPACTS`, `_GRIND_ONOFF`, `_SLEW_CONSOLE`, `_FOOTSTEP_SUBMIX`, `_REVERB_INPUTS`, `_PUSH_PLANT`,
|
||
`_AIR_TIME_STATE`;
|
||
- the dead comment-only names `SKATE_AEMS_PLAYER` / `SKATE_AEMS_FOOTSTEPS`.
|
||
|
||
**Kept:** `SKATE_AEMS_WORLD`, `_WORLD_PREFETCH`, `_NPC_SKATERS`, `SKATE_AUDIO_FMA`, the tools (`SKATE_AUDIO_TRACE` /
|
||
`TIMING` / `STATE_LOG` / `SET`, `SKATE_AEMS_BANKS`), and the e2e harness's part switches (`E2E_ROLLING`,
|
||
`E2E_CONTACTS`, `E2E_BUSES`, …).
|
||
|
||
**Dead code removed with them (game crate):**
|
||
- the old 60 Hz MixMap evaluation (`HostClock::pass` has no `console` parameter; every pass is on the console
|
||
grid, the eEQChain clear included);
|
||
- the per-tick Class_Seams process (`PlayerAudio::seam_console`; the seams run in `seam_frame`);
|
||
- the fixed Reverb.in5 path;
|
||
- the zone-less reverb;
|
||
- the per-call body / deck posters (`PlayerAudio::body_console` / `deck_console`);
|
||
- the dt-scaled bed slews (`Bed::slew_console`);
|
||
- the air-flag `+236` count and the animation-contact push plant (`air_time_236` / `push_plant` lost their switch
|
||
parameters);
|
||
- `set_review_ports` / `set_body_curve`;
|
||
- the e2e harness's non-console row mode.
|
||
|
||
**Kept on purpose:** the `skate-audio` components' own on / off fields (`Contacts::plant_lift_on` / `body_on` /
|
||
`body_speed_on`, `Grind::onoff`, `CollisionManager::submix`, `Option` body / deck / slew calls). The game always
|
||
turns them on. Their unit tests use "off" to isolate one part, so these are component configuration, not switches.
|
||
Their docs no longer name env vars.
|
||
|
||
**The e2e `row` mode is now the game's host at 60 fps.** Before, it ran the removed old 60 Hz evaluation and the
|
||
per-tick seams.
|
||
|
||
**Proof (byte-identical):**
|
||
- e2e bench before (`wa_new` row / fps300, `sw_base` fps30 / fps60 / fps144) vs after (`sw_new`, all five
|
||
modes): fps30, fps60, fps144 and fps300 IDENTICAL for the 13 scenarios and the 4 whole sessions (26 + 8 outputs
|
||
each);
|
||
- the new `row` renders are identical to the old `fps60` renders, which is what the game ran at 60 fps;
|
||
- `audio_state_capture` (1,500 production physics steps through `observe`) is identical to its capture from before
|
||
both changes;
|
||
- every unit and data test passes.
|
||
- two data tests (`seams_play_their_retail_bank`, `seam_hits_shuffle_their_samples`) drove the removed per-tick seams.
|
||
They now call `seam_frame` before each pass, as `mixmap_frame` does, and pass with the same picture (pattern 11
|
||
at 10 / 20 / 30 km/h: 39 / 67 / 93 voices; brick: all 16 samples).
|