Audio: world / NPC sources complete, ped speech, specs published

- Ped speech plays: speech manager -> library -> the living world's two streams, with queue / interrupts and the
  near / far lines by distance; levels and filters from the retail mechanism (filters equal to the recomp's, gain
  median within 0.5 %). Silent and logged when the opt-in speech decode is absent.
- NPC skater: wheels and clothing on its own instance and a bail grunt; tricks, treatment and footsteps stay off for
  NPCs as in retail.
- Per-model ped data (shoe class, voice, far threshold) and traffic engine records per model exported by setup;
  traffic relative-speed input and front / rear sound points; world hosts process before the mixer tick.
- Test mod: opt-in (off by default), higher world-object limits (48 per mod, 128 total), a per-second summary log.
- Specs published in docs/hails-additions/audio-specs/; code comments point at them; test data paths behind env vars
  (no .local defaults).
This commit is contained in:
Hails committed 2026-10-03 18:34:48 -06:00
1 parent a4ec831a02
commit 7f120de0b8
122 files changed
+10368 -485

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ Szafranski, Deerman of Dark Woods) with errors like:
Windows' 260-character `MAX_PATH` limit. The roster work paths above are 260
and 261 characters long when the repository sits in a moderately deep folder
(`F:\VS-Code\Github Repos\skate-3-rust-engine`). Long paths were disabled on the
(the test checkout's root path was 44 characters). Long paths were disabled on the
machine (`HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled = 0`).
`python.exe` and PyInstaller-built executables are long-path aware, so enabling
the OS setting is sufficient; no code paths need changing.
+217 -135
View File
@@ -138,7 +138,7 @@ 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 →
(`audio-specs/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:
@@ -282,7 +282,7 @@ random one-shot scheduler, not from placed emitters.
- 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`.
- Tools: `check_sets_vs_trace.py`, `long_run_stats.py` (local tools).
- 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.
@@ -317,7 +317,7 @@ random one-shot scheduler, not from placed emitters.
- 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
captures at four zones were checked with a local tool (`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 |
@@ -441,7 +441,7 @@ Each step came from the user's report of an in-play session plus the `AUDIO_*` l
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
sweetener layer (0.25) until a main impact is chosen (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.
@@ -511,7 +511,7 @@ picks.
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`,
dependencies), written from our specs (`audio-specs/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
@@ -582,7 +582,7 @@ interface lists after the sample data, so the whole file is needed. S10A slot i
**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/`.
`tools/asset_pipeline/audio_export.py` (+ test). Plus local tools (not published).
**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
@@ -605,7 +605,7 @@ pitch/filter), and the rolling bed was our own approximation: each `.grain` reco
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
**Root cause / retail mechanism** (specs `audio-specs/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
@@ -618,7 +618,7 @@ repeats and cross-faded with square-root fades, rescheduled every 256-frame bloc
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.
evaluator (local tool `mxb_tool.py`, not published: it carries a name table taken from the game), 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 →
@@ -643,12 +643,12 @@ repeats and cross-faded with square-root fades, rescheduled every 256-frame bloc
`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.
The local tool `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,
(local tool `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.
@@ -682,8 +682,8 @@ emitter words need a listening check in game by the user (`SKATE_AEMS=1`).**
`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/`.
`tools/asset_pipeline/audio_export.py` (+ tests). Local tools: `mixmap_compare.py`,
`stage_grain_mixmap.py`; golden3 kept locally.
**Not modelled / open** (nothing invented):
- MixMap inputs without a game source yet stay 0: Jitter (random every frame in retail), VU meter,
@@ -757,7 +757,7 @@ sense_of_speed bed cue and the foot-drag pieces are then silent. The default pat
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`.
installs: the local tool `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
@@ -816,8 +816,8 @@ sound with a turn input there (level(2) 0.72 at 40 km/h) — check in game.
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
`speech_161849.txt`; summary in [doc 15](15-world-audio.md) and
`audio-specs/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).
@@ -841,7 +841,7 @@ instead):
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`.
`crates/skate-game/src/game_audio/player_audio.rs`, the local tool `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`
@@ -914,14 +914,14 @@ interim samples at interim levels, and the rolling bed dominated the mix.
(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`.
optional key, version unchanged); `player_tuning.audio_tricks`. Dev install: the local tool `stage_splice.py`
(manifest backup `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
**End-to-end comparison with the PoC** (`tools/audio-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:
@@ -941,7 +941,7 @@ Causes of the remaining differences, each checked against retail rather than the
- **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
(= our surfaces 0/1 and 4–9 through the program, the local tool `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
@@ -1003,9 +1003,9 @@ rolling rattle, `Class_rolling`, footsteps, cloth / body slide, flips / treatmen
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,
`crates/skate-game/src/game_audio/{e2e,state_log}.rs`, `tools/audio-e2e/{scenarios,compare}.py`,
`tools/recomp-trace/{retail_windows,retail_voices}.py`, `tools/audio-file-inspect/splc_fields.py`,
`tools/vault-inspect/vault_fields.py`; local tools `grind_census.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.
@@ -1021,11 +1021,11 @@ 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
the main session from our spec, tool `tools/recomp-trace/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
- Our engine's **real-play log** `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.
`log_grind`, `log_pickup`, `log_carve` 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).
@@ -1034,13 +1034,13 @@ aren't being recreated".
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
frames (`tools/recomp-trace/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`.
`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
@@ -1131,18 +1131,18 @@ 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
adds the wheel spin, +28 dB in the 125 Hz band); carve +3.4 (unchanged). Log replays (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);
`tools/vault-inspect/{vault_layout,find_field}.py`, `tools/recomp-trace/grec_clean.py`,
`tools/audio-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).
material kind / key table as constants) + test; `tools/audio-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`).
`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
@@ -1220,8 +1220,8 @@ and a flush or OS stall blocked the frame.
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
- Replaying the windows headless (new tool
`tools/audio-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
@@ -1245,8 +1245,7 @@ state 500. Those voices are held at about 0 by the MixMap, the speed and the no-
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`):
**Air tricks #1 and #3 (19:38 session, "played in the air and at the landing").** All 8 tricks rendered headless:
- 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
@@ -1283,8 +1282,8 @@ FlangeSub return. That return is not modelled yet and is the remaining audible d
- 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.
The specs come from our reading of the TU3 code in two research passes: `audio-specs/aems-env-bus-spec.md` and
`audio-specs/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,
@@ -1312,7 +1311,7 @@ The specs come from our reading of the TU3 code with two research agents: `.clau
- *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
of the title generator (no draw when a == b). Validated 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.
@@ -1361,7 +1360,7 @@ though retail does not run it. No change.
- *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):
- *Send A against retail* (local script `senda_bank.py` on 163809, per-voice send medians):
| bank | ours (mean) | retail (median) |
|---|---|---|
@@ -1380,8 +1379,8 @@ though retail does not run it. No change.
- *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
**Dev install.** The local tool `stage_bus_tuning.py` adds `bus_tuning` and refreshes `player_tuning` (jitter keys,
seam pattern fields). Manifest backup: `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.**
@@ -1396,8 +1395,8 @@ and older builds ignore them. Setup writes the same keys (`audio_export.bus_tuni
- 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
**Final stage of the plan: an optimisation pass with adversarial reviews** (optimisation
workflow). 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
@@ -1407,8 +1406,7 @@ post/release thrash, the remaining small game-thread hitches.
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-*`).
`_201324.tsv` (the clock restarts at each map load: cut by rows, `scenarios.py --cut rA-B`), rendered headless.
**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).
@@ -1417,7 +1415,7 @@ the park is tag 41 (Wood_1) / pattern 9 (`irregular_large`, 0.7 m, gain 0.41).
(`+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
- Retail (GREC, `tools/recomp-trace/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).
@@ -1485,7 +1483,7 @@ User (build with the grain chain, board layers, seams taken from the wheel lines
"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
19 km/h). Both logs are clean. Rendered headless: 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
@@ -1546,7 +1544,7 @@ At 20–30 hits a second, the same transient repeated as a regular buzz, which r
### 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`,
Specs (our reading of the TU3 code in research passes): `audio-specs/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`):
@@ -1569,7 +1567,7 @@ Specs (our reading of the TU3 code with research agents): `.claude/notes/aems-bo
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
- Levels through the real banks (per-start peaks, `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
@@ -1587,7 +1585,7 @@ Specs (our reading of the TU3 code with research agents): `.claude/notes/aems-bo
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
- Retail (`dsp_20261002_185826`, local script `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
@@ -1616,7 +1614,7 @@ Specs (our reading of the TU3 code with research agents): `.claude/notes/aems-bo
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`
script that attributed every line to an owner's last PLAY of the session (local script `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
@@ -1630,7 +1628,7 @@ Specs (our reading of the TU3 code with research agents): `.claude/notes/aems-bo
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`):
`audio-specs/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
@@ -1665,7 +1663,7 @@ scuffs, `STEP` / `RUN_STEP` / `PUSH` / `BOARD_DOWN` (native replacements, not us
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
**e2e against the PoC (all on).** 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
@@ -1691,7 +1689,7 @@ Consequences:
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`):
**The FootStep SubMix** (`sub_82494188`, spec `audio-specs/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
@@ -1765,8 +1763,8 @@ record writer (`sub_827A1B78`) read back from the TU3 code:
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`.
an untyped `Attrib::RefSpec` as class + key). Dev install: the local tool `stage_reverb_zones.py`,
backup `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.
@@ -1836,12 +1834,12 @@ first, from the optional banks).
- 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
- e2e with every new part off is bit-identical to the previous stage 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.
other changes made 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/`.
- Tools: `tools/audio-e2e/render_diff.py DIR_A DIR_B`, renders kept locally.
### 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)
@@ -1865,13 +1863,13 @@ first, from the optional banks).
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.
- The local script `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`
**sense_of_speed recomp figures, redone with the shared-sample fix.** Local script `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) |
@@ -1901,11 +1899,11 @@ first, from the optional banks).
- *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.
local script `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
- *Effect.* e2e renders (13 scenarios incl. the walking replay `walk8`) 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.
@@ -1958,7 +1956,7 @@ The per-frame host code (`native`, `player_audio`, `grain_bed`, `emitters`, `ska
- `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}/`
- Local tools (`treat`, `sos` folders)
**Verification.**
- skate-audio: all tests pass (183 lib).
@@ -2002,7 +2000,7 @@ puts the listener (the camera) near its edge.
- `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.
**Retail, earlier sessions.** `tools/recomp-trace/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 %.
@@ -2051,7 +2049,7 @@ Side finding: the user's session logs
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
`PROBE_SHUFFLES=1`, every draw); local tool `tools/recomp-trace/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)
@@ -2071,7 +2069,7 @@ Sessions (all clean, 0 malformed lines):
call.
- SEAMHIT logs every hit.
Tools are in `.local/audio-re/seams9/`:
Local tools:
- `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`.
@@ -2251,10 +2249,10 @@ time `+236` (TREAT) changes on 860 of 5,094 in-air calls (17 %), always by 1/60
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/`,
**Proof (e2e, both user logs: full1 = brick 213757, full3 = University 214224; local script
`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).
(f32 and voices) to a pre-change build of the same tree (a snapshot with this change reverted).
- Frame rate, with the console cadence on:
| fps | full1 grain A gain p50 / p90 | full1 A pitch p50 | full3 A pitch p50 |
@@ -2274,7 +2272,7 @@ frames in the air" are physics steps on the console too, so our per-60 Hz-step l
- 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`,
**Seam-hit prominence, re-measured (no change made).** Local tools: `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.
@@ -2334,7 +2332,7 @@ passes; the e2e identity and frame-rate band are above.
- 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.
counters in the components. Moving those is a separate, wider change; it touches components other work streams own.
- The remaining seam-prominence gap (above). A console capture, or a recomp capture with a time-exact voice log, would
settle it.
@@ -2351,7 +2349,7 @@ only has to publish state.
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`,
**Evidence** (TU3 recompilation, reference only; spec notes `audio-specs/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.
@@ -2398,7 +2396,7 @@ Each owner is driven by its game object's state, so the owners need that state b
- 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`.
- Dev install tools: `stage_extra_banks.py --tag world …` and the local tool `stage_world_audio.py`.
**Verification.**
- `crates/skate-audio/tests/world_sources.rs`, headless through the real MixMap and banks:
@@ -2442,7 +2440,7 @@ Each owner is driven by its game object's state, so the owners need that state b
- 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`):
**Evidence** (TU3 recompilation, reference only; `audio-specs/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.
@@ -2469,7 +2467,7 @@ Each owner is driven by its game object's state, so the owners need that state b
- **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
- **The recomp agrees** (local tool `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
@@ -2526,7 +2524,7 @@ retail uses for them was open: the Player slot has only 2 instances in free skat
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`):
**Evidence** (TU3 recompilation, reference only; spec `audio-specs/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).
@@ -2552,7 +2550,7 @@ list that comes within 30 m of the camera while the record is free. It runs ever
- 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`.
- Tool: the local script `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).
@@ -2599,7 +2597,7 @@ list that comes within 30 m of the camera while the record is free. It runs ever
- 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`)
### Optimisation pass over the native runtime (2026-10-03, overnight)
**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
@@ -2613,7 +2611,7 @@ straight into frame-time jitter at 300+ fps.
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
- *Headless.* The new bench (`tools/audio-bench/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.
@@ -2674,7 +2672,7 @@ shared machine, not code. The game-thread side is unchanged (p50 30–35 µs per
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
- *Hardware FMA* (`-C target-feature=+fma`, measured in a separate build): 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.
@@ -2695,7 +2693,7 @@ should shrink with the render; check `game_lock_wait` / `render_block` in the ne
`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).
- Bench tools: `tools/audio-bench/`.
**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).
@@ -2711,7 +2709,7 @@ cache and more from a cold disk.
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/
**Memory decides the shape.** Decoded, a map's emitter banks (`tools/audio-bench/
emitter_bank_memory.py`) take:
- University: 41 banks, 161 MiB;
- DownTown: 44 banks, 210 MiB;
@@ -2755,7 +2753,7 @@ The other settings tried: 40 / 70 m gave a peak of 11–30 MiB, and 100 / 140 m
**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
real-fps300. Both builds come from one snapshot of the tree, 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.
@@ -2785,7 +2783,7 @@ The other settings tried: 40 / 70 m gave a peak of 11–30 MiB, and 100 / 140 m
- 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).**
**Adversarial review (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
@@ -2856,7 +2854,7 @@ build reaches the whole-crate `+fma` build's render time (p50 ~54 µs vs 52 µs)
**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`;
(a) the tree before the change, from a snapshot; (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`):
@@ -2871,7 +2869,7 @@ build reaches the whole-crate `+fma` build's render time (p50 ~54 µs vs 52 µs)
- *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`).
its own target folder).
**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
@@ -2893,7 +2891,7 @@ change an observable output:
- 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):
**Adversarial review** (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.
@@ -2927,14 +2925,12 @@ path matches the old code. The game-thread side is unchanged.
- 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`,
- in a snapshot of the old tree (own `CARGO_TARGET_DIR`): `bash tools/audio-bench/e2e_bench.sh fma_base row fps300`;
- `SKATE_AUDIO_FMA=0 bash tools/audio-bench/e2e_bench.sh fma_plain row fps300` (own `CARGO_TARGET_DIR`),
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).
- The extra build folders and the snapshot are local and can be deleted once accepted.
**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
@@ -2943,10 +2939,10 @@ external dependencies), `crates/skate-audio/Cargo.toml`, `crates/skate-audio/src
### 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
Four ranked items of the review of all clean sessions (2026-10-03, analysis only; items #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`).
(local script `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
@@ -3075,14 +3071,14 @@ in ours. The remaining rate gap is +19 % (the foot-down proxy, not the game's ow
- `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`.
- Dev install: `stage_player_tuning.py` re-run. Backup: `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
(f32 and voices) to a build of the tree without this pass (a snapshot, old manifest). The same holds with the
restaged manifest, so the export fix changes nothing in these logs.
**Open.**
@@ -3096,9 +3092,9 @@ in ours. The remaining rate gap is +19 % (the foot-down proxy, not the game's ow
- 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`).
real-play state log `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". Local tools:
`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.
@@ -3125,12 +3121,12 @@ CALL IT ALMOST OR EXACTLY RETAIL"; the footsteps while running / walking "sounde
- 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
the headless e2e build (own target folder) was stopped mid-compile for a PC restart. To finish it:
- The scenario is already cut: `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
- Render: `E2E_DIR=<absolute scenario folder> 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
- Compare: the local script `bails_cmp.py - <scenario folder>: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
@@ -3138,7 +3134,7 @@ CALL IT ALMOST OR EXACTLY RETAIL"; the footsteps while running / walking "sounde
#### 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
Session 084712 rendered headless through the e2e path (own target folder, `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.
@@ -3209,7 +3205,7 @@ Caveats:
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>`.
assumes 1/60 s. Summary: `tools/recomp-trace/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
@@ -3219,7 +3215,7 @@ Caveats:
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`):
*What the session shows without the BAIL\* lines* (local script `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
@@ -3256,7 +3252,7 @@ Caveats:
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
`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
@@ -3293,7 +3289,7 @@ Caveats:
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/`.
stall windows excluded. Tools: local scripts.
**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
@@ -3407,7 +3403,7 @@ stall windows excluded. Tools: `.local/audio-re/left/`.
**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
- Tools: local scripts `splc_records.py`, `plantlift_ids.py`, `grind_off_delay.py`; `fixes/grind_cmp.py` (earliest
voice).
**Verification.**
@@ -3469,7 +3465,7 @@ user's vault. Upstream PR #4's `player-audio-retail-drivers.md` and `mixmap/inpu
@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
(recomp session `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
@@ -3489,7 +3485,7 @@ 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
the specs `audio-specs/aems-env-bus-spec.md`, `audio-specs/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
@@ -3552,7 +3548,7 @@ holder fields, the owner bus builder and the Class_Seams function map. No code f
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`.
local script `senda_bank_fixed.py`.
### Native runtime is the default (2026-10-02)
@@ -3626,7 +3622,7 @@ speed. Manual sounded amazing."
**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`):
(local scripts `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) |
|---|---|---|---|---|
@@ -3651,7 +3647,7 @@ poster (the recomp, riding):
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
0 malformed lines; summary `tools/recomp-trace/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
@@ -3719,8 +3715,8 @@ The zone beds, location sets and crossfades still play their measured layers thr
- 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).
Rerun: `bed_vs_recomp.py <recomp sessions> <DIR:NAME,…>`,
`riding_posters.py <recomp sessions>` (local scripts; PYTHONIOENCODING=utf-8).
### The ragdoll part masses against the recomp; the missing |Δv·n| (2026-10-03, analysis only; no code change)
@@ -3764,7 +3760,7 @@ with Δv·n = v_new·n − v_old·n from each line, clamp01(max(0.001, |Δv·n|
**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
second owner (local script `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.
@@ -3787,7 +3783,7 @@ passing the old test (bytes 0 / 0, word ≠ 0) and are rejected (`LOCALTEST …
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]`;
**Measurements re-checked** (copies with only the local rows: local script `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 |
@@ -3853,7 +3849,7 @@ calls), and the bail hook measured the ragdoll pass at 60 Hz in the recomp too.
`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/`).**
**The e2e effect on bails (`E2E_FPS=60`, `bails_cmp.py`, window −0.5…+2.5 s; local tools).**
| | 084712 before | 084712 after | 101640 before | 101640 after |
|---|---|---|---|---|
@@ -3944,8 +3940,8 @@ bed. The local tests (`[owner+16]+72`) in the bed's helpers, read one by one:
- `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`):
- e2e identity (own target folder; tools `tools/audio-bench/e2e_bench.sh`,
local `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
@@ -3996,7 +3992,7 @@ The user approved both follow-ups of the sections above: retail's |Δv·n| and t
- `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`).
first differing deck post (local script `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 |
@@ -4028,7 +4024,7 @@ The user approved both follow-ups of the sections above: retail's |Δv·n| and t
### 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:
Spec: `audio-specs/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;
@@ -4119,7 +4115,7 @@ posts alone (shown below).
- `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`;
**Frame-rate independence and identity** (local scripts `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
@@ -4212,7 +4208,7 @@ bail clear, as the recomp's 094336 summary; voices / Σg² = `bails_cmp.py`, −
- `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.
- Headless only (own target folder); 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!"
@@ -4266,7 +4262,7 @@ retail".
- 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`).
**Proofs** (local tools `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
@@ -4326,7 +4322,7 @@ row at the peak; the eight sessions pooled; `E2E_FPS=60`, the same at 30):
- `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.
- Headless only (own target folder); 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,
@@ -4378,7 +4374,7 @@ Our replay of the session's tag-66 grinds (e2e, `E2E_FPS=60`):
- `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):
**Bench** (local script `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
@@ -4392,8 +4388,8 @@ Our replay of the session's tag-66 grinds (e2e, `E2E_FPS=60`):
- `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}`.
- Headless only (own target folder); bin\ and data\ untouched.
- Tools: local scripts `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
@@ -4448,8 +4444,8 @@ game steps wrote; it never runs on a sample twice and never on a frame the game
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
- e2e bench (`tools/audio-bench/e2e_bench.sh`, 13 scenarios + 4 whole sessions; baseline built from `git archive
HEAD` in a separate folder): `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
@@ -4506,7 +4502,7 @@ eval/tests.rs,formats/abk.rs,runtime.rs,dsp/pan.rs}`, `skate-audio/tests/render_
### 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`.
Full write-up, field tables and examples: [doc 15](15-world-audio.md). Design: `audio-specs/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
@@ -4603,3 +4599,89 @@ per-tick seams.
- 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).
### World audio P3–P5: ped speech plays, the NPC instance's wheels / clothing / grunt, traffic, retail's order (2026-10-03, headless)
Full write-up: [15](15-world-audio.md), section "P3–P5". In short:
- **Ped speech plays.** It goes through the speech manager, the library and the living world's two streams
(interrupt by tuning `+13` / `+14`, a 16-request queue, the 200 / 60-frame cut).
- The stream values come from the speaker's PedestrianSpeech outputs, read from the code (`sub_824D9370`):
main level out2, or out3 for `_f` clips; reverb send out15; high pass out13; low pass out14; pan out0;
pitch out1.
- Recomp, 39 lines rebuilt at their geometry: the filters match exactly, and gain recomp / ours has median
1.005.
- The full free-roam decode is in the dev install. Without it, speech stays silent and logged.
- **The NPC skater's instance** now runs Wheels (layers 0 / 1) and Clothing, and raises the bail grunt (event
8206 for its voice, once per bail). Tricks, Treatment and footsteps stay local only, as recomp gap run G3
showed.
- **Per-model ped data** (`world_tuning.ped_models` from `aud_characteristics`): 3146 of 3146 recomp PEDAUD
lines agree.
- **Traffic.**
- Model → engine record export; `TrafficCarPhysics.in0`; front / rear 3DObjPos points.
- Against gap run G1: RPM within 60 in 97.3 % of sample pairs, `+176` within 0.5 m/s in 99.2 %.
- **Retail's order.** The world / NPC hosts now process before the MixMap ticks and update after them
(`native::mixmap_frame` / `mixmap_tick`). Before, both ran after the ticks. A test proves this is exactly a
one-evaluation shift (89 of 89).
- **The local player's output is byte-identical:** e2e 13 scenarios + 4 sessions, `row` and `fps300`, against a
worktree of `a4ec831`.
- **Gaps:**
- `SFXObj_Tazer`: the recordings support it, but the object is not decoded;
- PedBodyFall: no recorded post;
- speech: the PEAK filter, the SEND for most lines, the `Obj:Speech` inputs, which stream a request takes;
- the traffic 3DObjPos binding's writer;
- the NPC's board slide and on-foot voices.
- **The dev test mod is opt-in** (`"enabled_by_default": false`, `SKATE3_MODS_ENABLE`). The mod limits are now
48 / 128, and a `WORLD_AUDIO` summary is logged once a second.
### PR #32 review fixes, step 2 (code half): spec references and private-data paths (2026-10-03, headless)
**Problem.** Code comments in `crates/`, `tools/asset_pipeline/` and the dev test mod cited the working notes and
local tools by their private paths (the gitignored working-notes folder, the local skill tools), which a reader of
the PR cannot open. Data-gated tests found their research data through hard-coded paths into the gitignored work
folder of this checkout.
**Change (comments only, except the test paths below).**
- Spec references → the published specs: `audio-specs/<name>.md` everywhere (= `docs/hails-additions/audio-specs/`;
`skate-audio/src/lib.rs` says so once), the `§n` anchors kept and checked against the published copies. 53 full
paths (46 in `crates/`, 6 in `tools/asset_pipeline/`, 1 in `mods/world-audio-test`) and 21 bare names (`notes
ems-emitters-re.md` → `audio-specs/ems-emitters-re.md`, …). The one anchor that was not a heading
(`world-speech.md` "Speech manager gate") now names its section ("Mechanism", "The request").
- Tool references → the published tool (`tools/audio-file-inspect/{splc_fields,bank_layout_check,
decode_bank_samples}.py`, `tools/recomp-trace/{retail_voices,grec_material}.py`, `tools/audio-bench/`), setup's
`audio` group (`SKATE_SETUP_SPEECH=1` for the speech export), or "a local tool, not published" (the PoC probes,
`mxb_tool.py` with the game's name table, `golden_compare.py`, `mixmap_compare.py`, `speech_levels.py`,
`sos_figures.py`, `instance1_voices.py`): 15 places. Doc 15's file list: the same.
- Local data in comments → described ("the PoC's golden vectors, kept locally", "local data", the env var).
**Testing notes: where the data-gated tests find the private data.** No default paths any more: every kind has an
environment variable, and with nothing set the (ignored) test fails with "missing private data: …", as before.
`skate-audio`'s integration tests share the lookup in `tests/private_data/mod.rs`.
| Variable | Points at | Used by |
|---|---|---|
| `SKATE_AUDIO_RE_DIR` | the extracted research data root (`aems-banks/`, `splc-banks/`, `bank-wavs/`, `tricks/`, `golden/dsp/vectors.txt`); fills in the next four when they are unset | `skate-audio` tests |
| `SKATE_AEMS_BANKS` | the disc's `.abk` / `.csi` (`bank_layout_check.py --extract`) | `disc_banks`, `rolling_banks`, `offboard_levels`, `world_sources` |
| `SKATE_SPLC_BANKS` | the disc's SPLC `.bnk` files | `splice::tests`, `offboard_levels`, `world_sources` |
| `SKATE_DSP_VECTORS` | the PoC's DSP oracle vectors file | `dsp_oracle` |
| `SKATE_TU3_IMAGE` | the dumped TU3 image `default_82000000_011B0000.bin` | `mixmap::tables` |
| `SKATE3_DISC` | the extracted disc (as the published tools); the MixMap when the install has none | `mixmap::tests` |
| `SKATE_RECOMP_SESSIONS` | the recomp session folders (`<session>/trace.tsv`); `TREAT_SESSION` / `GREC_SESSION` still pick one session | `player_tricks`, `skate_events`, `game_audio::world_sources` |
| `SKATE_AUDIO_STATE_LOGS` | the audio state logs (as the dev test mod) | `npc_skaters` |
| `SKATE_SPEECH_LEVELS` | the speech level export (`levels_<session>.json`) | `world_speech` |
The tools' own default work folders (doc 14) are unchanged.
**Verification.** `cargo build --locked`. `cargo test -p skate-audio -p skate-audio-fma --locked`: all pass; with the
variables set to this machine's data, `-- --ignored`: 51 of 51 pass; with none set, every data test fails loudly
("missing private data"). `cargo test -p skate-game --release --bin skate3rust --locked -- game_audio::`: 43 pass,
28 ignored; `-- --ignored`: 28 of 28 pass. `cargo test -p skate-mods --locked`: pass except the known
`skyline_physics` (GLB not in this checkout). e2e bench (13 scenarios + 8 sessions, `row` and `fps300`):
byte-identical to the run before the change.
**Files.** Comments: `skate-audio/{src,examples,tests}` (bus, dsp, eval, formats, grain, mixer, mixmap, player,
splice, world, lib.rs), `skate-game/src/{game_audio/*,world_audio.rs}`, `tools/asset_pipeline/{audio_export,
world_audio}.py`, `mods/world-audio-test/main.lua`, doc 15. Test paths: `skate-audio/src/{mixmap/tables.rs,
mixmap/tests.rs,splice/tests.rs}`, `skate-audio/tests/{private_data/mod.rs (new),disc_banks,dsp_oracle,
offboard_levels,player_tricks,rolling_banks,world_sources}.rs`, `skate-game/src/game_audio/{skate_events,
world_sources,world_speech}.rs`, `game_audio/npc_skaters/tests.rs`.
@@ -64,7 +64,7 @@ Xbox 360 research.
## Playing it from the couch, and closing it
- Our couch launcher (local, `.local\steam\`) starts the traced recomp from Steam / Steam Link with one shortcut per
- Our couch launcher (a local script, not published) starts the traced recomp from Steam / Steam Link with one shortcut per
trace mode, checks each trace for malformed lines afterwards, and keeps one game running at a time.
- The recomp's settings overlay opens with **Escape** or **F1** and has **"Quit to the desktop"**; it is
controller-navigable once open. On the `research-hooks` branch, holding **LB + RB + Back** for ~1 s opens it from the
+222 -19
View File
@@ -38,7 +38,7 @@ remove the component. Owner ids are `Entity::to_bits()`, so a reused index count
| field | retail | meaning | who fills it | default |
|---|---|---|---|---|
| `engine` | vehicle record `+168` | the `aud_traffic_engine` record: sedans / hatchback `c01_family01`, sports / muscle `c03_sports01`, taxi / patrol `c04_taxi01`, SUV / pickup / minivan `c05_truck01` (entity → vehicle spec → record, G1). The engine's patch override picks c06 / c07 / c08 itself. | the vehicle system, from the model | — (an unknown name is logged once and stays silent) |
| `engine` | vehicle record `+168` | the `aud_traffic_engine` record by name, **or the living-world model** (`taxi01`, `sedan02`, `suv`, `minivan01`, …), which setup maps through retail's attribute chain (entity → vehicle spec → record, G1; export `world_tuning.traffic_models`): sedans / hatchback → `c01_family01`, sports / muscle → `c03_sports01`, taxi / patrol → `c04_taxi01`, SUV / pickup / minivan → `c05_truck01`. The engine's patch override picks c06 / c07 / c08 itself. | the vehicle system, from the model | — (an unknown name is logged once and stays silent) |
| `speed` | `+148` | m/s | the vehicle system | the length of the velocity |
| `load` | `+144` | the driver's signed acceleration in m/s². G1 measured −15.6 in a hard stop, up to +3 pulling away, and 0 while cruising. It goes into the engine and skid words × 3000. | the driving AI | derived from the speed change |
| `horn` | `+156` | `None`, `Honk(1..=5)` or `Alarm` | the AI (honk decisions) | `None` |
@@ -48,14 +48,14 @@ remove the component. Owner ids are `Entity::to_bits()`, so a reused index count
| field | retail | meaning | default |
|---|---|---|---|
| `voice` | `S+84` | the model = the speech voice id 41–96 | none (no speech) |
| `shoe_class` | `S+132` | 1–5 per model (`aud_characteristics`). Most models are 2; no model uses 1, which is silent. | 2 |
| `voice` | `S+84` | the model = the speech voice id 41–96. Its `aud_characteristics` record (setup export `world_tuning.ped_models`) gives the shoe class, the security kind, the speech type / variant / gender words and the far threshold (G2) | none (no speech, the defaults below) |
| `shoe_class` | `S+132` | 1–5 (`None` = the model's). Most models are 2; no model uses 1, which is silent. | the model's, else 2 |
| `weight` | `[obj+28]+144` | 1–5. It changes per ped in retail (1 beyond about 12 m, 2–5 nearer) and its meaning is open. | 1 |
| `close_range` | `S+96 == 64` | the security guards' close-range footstep levels | false |
| `close_range` | `S+96 == 64` | the security guards' close-range footstep levels and their speech levels (`None` = the model's type bit) | the model's, else false |
| `feet_down` | `S+74` / `S+73` | the walk animation's foot plants (A, B) | up |
| `foot_materials` | `S+140` / `S+144` | the audio surface materials under the feet | 0 (retail's pavements read 0 in every line) |
| `footsteps_on` | `S+68` | footsteps on | retail's rule: the 3 nearest peds in the list |
| `speech_distance` | `S+148` / `S+156` | distance to the listener / the model's far threshold. Beyond the threshold the far `_f` lines are used. | the 3-D distance / 20 m (retail for regular peds) |
| `speech_distance` | `S+148` / `S+156` | distance to the listener / the model's far threshold. Beyond the threshold the far `_f` lines are used. | the 3-D distance / the model's threshold (20 m for regular peds, 30 for pros) |
| `speech_value` | `S+136` | the state graph's speech value | 0. Send `PedSpeechEvent` rather than writing it. |
### `NpcSkaterAudio` (the MixMap Player slot's second instance)
@@ -65,13 +65,17 @@ remove the component. Owner ids are `Entity::to_bits()`, so a reused index count
| `list_order` | the skater list position (retail walks its list in order) | spawn order |
| `state` | this frame's `AudioState`. A skater simulated with the player's physics uses `game_audio::skate_events::skater_audio_state(physics, skater, &mut memory, dt)`, the same builder the local player uses (proved identical, below). Anything else uses `AudioState::rolling(&LiteSkater { .. })`: speed, wheels, materials, grind / air flags. With that fill, rolling, surfaces, seams, grinds and landings sound; tricks and foot / body foley stay silent. | none (not published) |
| `remote` | a remote multiplayer player (see below) | false |
| `voice` | the skater's speech voice (the AI skaters' models 89–96): its bail grunt says a line of it | none |
### Messages, resources and the read-back
- `PedSpeechEvent { ped, value: SpeechValue }`: sets the ped's speech value, so PedestrianSpeech sees
the change and requests a line. `SpeechValue::from_name` accepts the state-graph names (`DoWarning`,
`LongCheer`, …), the short names `warn` / `cheer` / `slam` / `flee` / `knockdown` / `nearby`, and
numbers.
the change and requests a line. A repeat of the current value goes through 0 for one frame, so it
speaks again. `SpeechValue::from_name` accepts the state-graph names (`DoWarning`, `LongCheer`, …),
the short names `warn` / `cheer` / `slam` / `flee` / `knockdown` / `nearby`, and numbers.
**`warn` is 53** (`SpeechValue::WARN`): the code's warn, which the manager maps to `501_warn`. The
state graph's `DoWarning` (11) entry is commented out in retail ("moved to the code"), and 11 is
also `LostInterestEndChase`, which maps to `605_chase_terminate`.
- `VehicleHorn { vehicle, kind, seconds }`: holds horn kind `kind` for the caller's time. The length
is the AI's choice, not retail data.
- `VehicleAlarm { vehicle }`: retail's alarm, horn state 6 for **8 s**.
@@ -128,7 +132,7 @@ honks.write(VehicleHorn { vehicle, kind: 2, seconds: 0.8 });
alarms.write(VehicleAlarm { vehicle });
// A ped system: walk animation foot plants, reactions from its state graph:
commands.spawn((transform, PedAudio { voice: Some(59), shoe_class: 5, ..Default::default() }));
commands.spawn((transform, PedAudio { voice: Some(59), ..Default::default() })); // shoe class, kind, words: the model's
speech.write(PedSpeechEvent { ped, value: SpeechValue::WARN });
// An AI skater simulated with the player's physics, near the camera:
@@ -138,7 +142,9 @@ npc.state = Some(skate_events::skater_audio_state(&physics, &skater, &mut memory
## Mod surface (`sdk.world_audio`, API 2, capability `world_audio` = 1)
Mods publish through the same components: each key becomes an entity. The rules follow the existing
mod rules. Keys belong to the calling mod. The limits are **16 objects per mod and 64 in all**.
mod rules. Keys belong to the calling mod. The limits are **48 objects per mod and 128 in all** (raised from 16 / 64 the same day: the
test mod publishes 37 objects; retail's pools pick the audible few anyway, so the limits only
bound the per-frame work, a distance per object and one sort).
Options are validated before anything is allocated, and a field of another kind is an error. An
object that is not updated for **0.5 s is parked** (speed 0, feet up, horn off). Disabling,
reloading or a failing mod removes everything it published. The existing `sdk.audio.*` (the mod's
@@ -176,15 +182,22 @@ the spot where the mod starts:
skids) or soft (−4). One car honks every 10–15 s with kind 1–5. One taxi is parked, and **F8**
fires its alarm.
- **20 peds** on back-and-forth lines: 16 walk (1.3 m/s), 3 jog (4) and 1 runs (8), so the three
`sk8_foley` step ids play. A gait clock alternates the feet; it is dev-only, not retail. Shoe
classes are 2–5, and the voices cycle through 41–96. Reactions:
- warn (11) when you pass within 1.5 m at more than 3 m/s;
- cheer (23) on a landed trick within 10 m;
- slam (25) on a bail within 10 m.
Speech requests are logged (`AUDIO_WORLD speech`). Playback comes in the next pass.
- **A ghost NPC skater** replays the 20 s window of a recorded state log, 5 m beside the start.
`sk8_foley` step ids play. A gait clock alternates the feet; it is dev-only, not retail. Each ped is
one of retail's 35 living-world models (its voice), so its shoe class, kind and speech words are the
model's. Speech (it plays when the speech decode is installed):
- warn (53, `501_warn`) when you pass within 1.5 m at more than 3 m/s;
- cheer (23, `101_pos`) on a landed trick within 10 m;
- slam (25, `104_spec_slam`) on a bail within 10 m;
- every 6–10 s the nearest ped shouts (2, `1901_shout`; dev-only chatter, not retail). The
manager's own tuning still gates every line (the shout: 50 % and 30 s per voice), and nothing
speaks in the first 30 s after the game starts (retail's timers start at 0).
- **A ghost NPC skater** replays the 20 s window of a recorded state log, 5 m beside the start, with
voice 91 (an AI skater's): its Wheels streams, Clothing foley and bail grunt play as an NPC's.
Settings: `ghost_log`, `ghost_from`.
- **Off by default** (`"enabled_by_default": false`, a new optional `mod.json` field): enable it in the
mod menu, or run with `SKATE3_MODS_ENABLE=dev-world-audio-test`. While it publishes, the game logs one
`WORLD_AUDIO cars N/audible M, peds N/M (footsteps K), skaters N/M, posts +P (npc +Q), speech lines +S`
line per second.
- Debug **boxes**: green = holds an instance (audible), grey = not. An on-screen line shows the
audible counts, the limits and the ghost's status. **F9** re-centres everything on the skater.
@@ -243,7 +256,197 @@ the spot where the mod starts:
- `sdk/skate.lua`;
- `.gitignore` (`mods/world-audio-test/logs/`).
## Open questions and the next pass (P3–P5)
## P3–P5: speech, the NPC instance, traffic, per-model data, retail's order (2026-10-03, headless)
Everything below was built and checked headless against the existing recomp recordings. Neither the game
nor the recomp was launched, and no new recording was made. "The recomp" numbers come from the recordings,
not from a console.
### Ped speech plays (`skate_audio::world::speech_player`, `game_audio/world_speech.rs`)
**How it works.** A ped's speech value change becomes a PedestrianSpeech request (ported before). The speech
manager maps it to an event and gates it (also ported before). The library then picks the record and its takes.
New in this pass:
- **The line plays on one of the living world's two streams.** The interrupt `sub_824A73F0` indexes the
channel's stream records as `channel × 2 + k`, and the recomp's living-world lines play on two stream
players.
- **When both streams are busy, the event's tuning decides.** With `+13` it stops a playing line of lower
priority; with `+14` it does the same when the channel is full. Otherwise the request waits in the
16-request queue until the event's queue timeout runs out.
- **The stream follows its speaker every console frame.** This was read from the code, not fitted: the
PedestrianSpeech update `sub_824D9370` copies its outputs into the block that the line's stream reads.
- Main level: out2. A clip whose name ends in `_f` uses out3 instead (`sub_824A89E8` compares the name with
`"_f"`).
- Reverb send: out15.
- Azimuth: raw out0. Pitch: out1.
- High pass: out13. Low pass: out14.
- Security guards, the guard radio and the conversation states use other outputs (spec note
`world-speech.md`).
- **The cut.** A speaker whose level stays at or below 200 for more than 60 frames has its line stopped
(vault `6995C510258C9AF6` / `3D8CD05C962FF399`). A speaker that loses its MixMap instance stops at once.
- **The NPC bail grunt.** It goes through the same manager and streams. Its level comes from the skater's
PlayerSpeech instance (`sub_824DA300`: out2 / out3, send out10, filters out8 / out9).
- **The manager's clock is console time since the start.** Retail's per-speaker timers start at 0, so nothing
speaks in the first 30 s: the warn's not-follow list holds it, and the shout has its own 30 s.
**`SpeechValue::WARN` is now 53**, the code's warn, which the manager maps to `501_warn`. The state graph's
`DoWarning` (11) entry is commented out in retail. 11 is also `LostInterestEndChase` → `605_chase_terminate`.
**Data and failure modes.**
- The index and rules are part of the normal setup.
- The takes need the opt-in decode (`SKATE_SETUP_SPEECH=1`, about 2.4 GB). The dev install now has all 40
free-roam events: 13,322 takes, 2.3 GB.
- A take is read from disk when its line starts, as retail streams it. The last 24 stay loaded.
- Without the index, speech is off and the log says so once.
- Without the decode, lines are still chosen and logged but stay silent, and the log says so once.
**Checked against the recomp.**
- *End to end* (`a_ped_warns_through_a_stream_at_its_owner_levels`, data-gated): a business man (voice 59)
warns 3.6 m from the camera and gets `501_59_busm1_Warn_n`; its stream plays at out2 / 32767. At 30 m the
next warn is `Warn_f` at out3.
- *Levels* (`speech_levels_follow_the_recomp`, from `recomp-research/tools/speech_levels.py` on 163809 /
164620 / 180430): 39 lines with a joined speaker. Each one was rebuilt at its recorded geometry (ped, camera,
player).
- The recomp's stream filters sit at exactly our out14 / out13: 24956 / 77 Hz near a speaker, 3489 / 379 Hz
far away.
- First LPF / HPF within 10 % in 4 of the 5 lines that log both.
- First GAIN: recomp / ours median 1.005 (p10 0.55, p90 1.43, n 34).
- `_f` lines at 30 / 40 m: 0.092 / 0.044 against our 0.085 / 0.056. out2 alone would be 0.030 / 0.001.
- SEND within 0.01 in only 10 of 37 lines (open).
- *Overlap*: the recomp shows two persistent stream players (163809: 40 + 6 lines; 164620: 95 + 48; 180430:
28 + 17).
### The NPC skater's instance: Wheels, Clothing, the bail grunt
Recomp gap run G3 showed which components run for the NPC instance. Now ported:
- **Wheels:** the spin-down streams on layers 0 / 1. Layer 2 is local only.
- **Clothing:** push / plant foley, body slide, cloth falls, with its non-local start block.
- **The bail grunt:** the body poster's first message of a bail (`+422`, once per bail) → event 8206 for the
skater's voice (`NpcSkaterAudio::voice`, the AI skaters' 89–96).
Tricks, Treatment and the OffBoard steps stay off for NPCs, as in retail. Board slide was reached by neither
instance in the runs and is not run.
Data test `the_npc_instance_runs_wheels_clothing_and_the_bail_grunt`: a 476 s user log replayed as an NPC.
- Wheel streams: 0.21 starts/s (recomp NPC 0.24 per held second, other motion).
- Clothing: 0.39 Splice starts/s (recomp 0.27).
- 8 grunts for 8 bails.
- The local player's components posted nothing.
### Pedestrians: per-model data
- **Setup export `world_tuning.ped_models`** (`world_audio.ped_models`, from `aud_characteristics`, keyed by the
voice): variant, kind, gender, shoe class, far threshold, and a per-voice float. 82 models.
- **The bridge fills these from `PedAudio::voice`:** the shoe class (which sets the footstep samples), the
security kind (footstep and speech levels), the speech words and the far threshold.
- **Checked** (`recomp-research/tools/ped_models_check.py`) against every PEDAUD line of the gap runs: 20 models,
3146 of 3146 lines agree on all five fields.
- **Footsteps** still play only for list index < 3 (already in the bridge).
### Traffic
- **Model → record:** setup export `world_tuning.traffic_models` (27 entities). `TrafficAudio::engine` accepts
`taxi01`, `sedan02` and the like.
- **`TrafficCarPhysics.in0`:** the record writer's relative speed, min(|heading × speed − v_listener|, 35),
slewed by 100 /s, × 32767 / 35. It opens A11.
- **3DObjPos blocks 1 / 2 / 3** follow the body and the front / rear points at ±1 m along the heading. The
binding is inferred: block 2 feeds the engine layer, block 3 the exhaust.
- **Checked** (`traffic_rpm_and_relative_speed_follow_the_recomp`, gapg1 VEHAUD):
- the RPM model lands within 60 RPM in 97.3 % of 451 live sample pairs (p50 0.7 RPM);
- `+176` lands within 0.5 m/s in 99.2 % while the listener stands. While it moves, 29 of 59 land within 2 m/s;
the listener's own velocity is not logged.
- 696 pairs were frozen "stale" records of cars that had left the 40 m list. They were left out.
### Retail's order: process before the tick, update after
- **The world and NPC hosts now run inside the pass:** `native::mixmap_frame` (inputs, the local player's
process) → `world_sources::frame` (pre) → `npc_skaters::frame_pre` → `native::mixmap_tick` (ticks, the
local update) → … → the hosts' update → `world_speech::frame`.
- **Before, both ran after the ticks**, so the inputs a tick saw were one console frame old.
- **The local player's sequence is unchanged** (inputs → process → ticks → update), and the hosts touch nothing
without owners.
- **Proof (`process_before_the_tick_shifts_the_outputs_by_one_evaluation`):** a car driving past the camera through the real MixMap, rendered in the old order (tick, update,
process) and the new (process, tick, update), with the same start-up history (one empty first tick). The
TrafficEngine / TrafficSkids / TrafficHorn outputs (level, raw, pitch and filter of 11 outputs each) satisfy
new[k] == old[k + 1] on 89 of 89 evaluations. So the move is a pure one-evaluation shift: the tick now sees
this frame's position.
- **The NPC bed now always steps after the local bed's step.** It draws from the local bed's generator, and
before this the order of those two systems was not fixed.
### Instance managers (checked, unchanged)
The hosts match the measured rules:
- Traffic: the 4 nearest within 40 m, horizontal.
- Peds: the 15 nearest within 50 m (3-D, nearest-first), footsteps for the 3 nearest.
- NPC skater: the first in list order within 30 m, held until 30 m.
### PedBodyFall and Tazer: gaps
- **Tazer.** The recordings support it: in 164620, each of the 3 tazer zaps (`SECTAZE want`) is followed 0.03 s
later by a burst of 10–20 Tazer-bank starts, 0.1–0.2 s apart, for 1.3–2.1 s (49 starts). No POST is attached,
which fits op 38 child posts; op 38 is ported. But `SFXObj_Tazer` itself (its trigger on the ped state, its
words) is not decoded. Not ported.
- **PedBodyFall.** No recording shows a post from it (no event group with a caller in its code in the
163809 / 164620 reports). Not decoded.
- Neither message is offered in the API.
### Mod surface
**Done in this pass (cheap):**
- `voice` for skaters (the bail grunt);
- traffic model names in `engine`;
- the model-driven ped defaults;
- `info().speech_lines`;
- the mod limits raised to 48 per mod / 128 in all;
- `"enabled_by_default"` in `mod.json` and `SKATE3_MODS_ENABLE`.
**Left for the final moddability pass:** spec §8.2–§8.4 (the content overlay for banks, samples, programs and
speech lines; mod emitters on the native voice graph; tuning read / write; `sdk.audio.post`; audio event hooks)
and §8.6.
### Proofs and tests (this pass)
- **Local player byte-identical:** the e2e bench (13 scenarios + 4 whole sessions, `row` and `fps300`) from a worktree of
`a4ec831` (`p3_base`, equal to the earlier `sw_new`) against this tree (`p3_new`): all four sets IDENTICAL (26 +
26 + 8 + 8 outputs). The e2e harness drives the local player's pass itself, so the native split was proved by the
shift test and by its unchanged local sequence.
- **New data tests** (all pass, `--ignored`):
- the speech end-to-end;
- the speech levels against the recomp;
- the NPC components;
- traffic RPM / `+176` against the recomp;
- the one-evaluation shift.
- **New unit tests:**
- `speech_player` (level ids, far clip, streams, interrupt, queue timeout, cut);
- the traffic relative speed and points;
- the opt-in mod;
- the test mod's objects within the limit.
### Files (this pass)
- **New:**
- `crates/skate-audio/src/world/speech_player.rs`;
- `crates/skate-game/src/game_audio/world_speech.rs`;
- the local tools `speech_levels.py` and `ped_models_check.py` (not published).
- **Changed:**
- `skate-audio`: `mixer.rs` (`set_direct_dsp`, `set_bank_sample`), `player/contacts.rs` (the grunt flag),
`world/{keys,mod,peds,skaters,speech_manager,traffic}.rs`;
- `skate-game`: `game_audio/{library,mod,native,npc_skaters,player_audio,world_bridge,world_sources}.rs`,
`modding/world_audio.rs`, `world_audio.rs`;
- `skate-mods`: `schema.rs`, `lib.rs`, `world_audio.rs`, `vm.rs`, `api.lua`;
- `sdk/skate.lua`, `sdk/AGENTS.md`, `mods/world-audio-test/*`, `tools/asset_pipeline/world_audio.py`.
### Open (after P3)
- **Speech:**
- the stream's PEAK filter;
- the reverb SEND for most lines;
- the `Obj:Speech` inputs a playing line sets (F45 / F19 / F42), probably the ~+100 mB the recomp's near
lines carry over ours;
- which of the two streams a request targets;
- the queue timeout's unit;
- values 49 (a Splice ring, `sub_824D9C70`) and 29 (the photographer's timer);
- the main-cast path for pros.
- **Tazer** (`SFXObj_Tazer`) and **PedBodyFall**: the objects are not decoded.
- **Traffic:** the 3DObjPos binding's writer; the horn kinds per model (the AI's); the frozen record of a held
car (not ported).
- **NPC:** board slide; the walking / jump voices of an NPC on foot; the NPC grain bed against 180430's NPC GREC
rows (not compared in this pass).
## Open questions before P3 (kept for the record)
- **Speech playback** in the host: the manager, the library and the streams. The level and pan
mapping is still open.
+4 -3
View File
@@ -16,16 +16,17 @@ verification, open questions).
| F | Tooling | Streaming map validator (`--validate-maps`) wired into setup | [06](06-map-validator.md) | `18f41a3` | E (speed only) | Draft upstream [#28](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/28) |
| H | Gameplay + rendering | Water: detection and bail, shallow water solid / deep water floats, board floats, water camera + vignette, entry splash, water look (family 33 cube blur + tint, anti-tiling, body-size calming, slower waves), animated water (frame-state buffer fix, ocean PCA from default.xex, XEX2 unpacker), ripple scale | [09](09-water.md) | `gameplay/water` (`af1b350`…`55776dc`), upstream branch `water/retail-water` (`8a6e328`, one commit on `60efdef`) | — (carries #28's `install.py` `spawn()` split verbatim) | Draft upstream [#30](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/30) |
| I | Test fix | `sky_shader_validates`: add the `VERTEX_POSITIONS` shader def Bevy sets for real meshes | [10](10-sky-shader-test.md) | `b7865d0` | — | Not opened; tiny, can go upstream alone |
| J | Feature (audio) | Game audio: setup group `audio` extracts the disc's audio (vgmstream r2117) and the native data (AEMS banks, MixMap, grains, Splice banks, vault tuning); a native port of the retail audio runtime (`crates/skate-audio`, `crates/skate-audio-fma`) plays all world and player audio; volume settings and `--mute` | [11](11-audio.md), [15](15-world-audio.md) | `gameplay/audio` (latest pushed `870940e`); upstream PR branch `audio/retail-audio` mirrors it exactly (`843cca9`). History: batch commit `f438017` "New audio who dis" (merged as `ca7b4f3`, 2026-10-02), then follow-up commits | H (branched from `gameplay/water`, #30, uses its water detection) and **#35** (50 solver iterations; needed for retail-like pops — #32 carries the same commit until #35 merges). Overlaps upstream #4/#1 (not adopted; doc 11) | Draft upstream [#32](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/32), **Work in Progress**. State 2026-10-03: native runtime is the only path; every retail player component ported; user: "basically retail". Road to ready (`.claude/todo/pr32-ready.md`): code-review fixes (host clock = physics steps, stray files, data tests, fallback) → world / NPC audio built out in #32 with an engine- and mod-facing hook-in surface (P0–P2 done 2026-10-03, uncommitted: map-change fix, `world_audio` components + `sdk.world_audio`, dev mod `mods/world-audio-test`; doc 15; P3–P5 next) → publish the specs, repoint `.claude/` references → moddability pass → second optimisation pass → move the non-audio tools to L → full regression check. #32's description is kept current (standing rule). Commits only when the user asks. |
| J | Feature (audio) | Game audio: setup group `audio` extracts the disc's audio (vgmstream r2117) and the native data (AEMS banks, MixMap, grains, Splice banks, vault tuning); a native port of the retail audio runtime (`crates/skate-audio`, `crates/skate-audio-fma`) plays all world and player audio; volume settings and `--mute` | [11](11-audio.md), [15](15-world-audio.md) | `gameplay/audio` (latest pushed `870940e`); upstream PR branch `audio/retail-audio` mirrors it exactly (`843cca9`). History: batch commit `f438017` "New audio who dis" (merged as `ca7b4f3`, 2026-10-02), then follow-up commits | H (branched from `gameplay/water`, #30, uses its water detection) and **#35** (50 solver iterations; needed for retail-like pops — #32 carries the same commit until #35 merges). Overlaps upstream #4/#1 (not adopted; doc 11) | Draft upstream [#32](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/32), **Work in Progress**. State 2026-10-03: native runtime is the only path; every retail player component ported; user: "basically retail". Road to ready (local plan): code-review fixes (host clock = physics steps, stray files, data tests, fallback) → world / NPC audio built out in #32 with an engine- and mod-facing hook-in surface (P0–P2 done 2026-10-03, uncommitted: map-change fix, `world_audio` components + `sdk.world_audio`, dev mod `mods/world-audio-test`; doc 15; P3–P5 next) → publish the specs (docs done 2026-10-03: `audio-specs/`; code comments still to repoint), repoint local references → moddability pass → second optimisation pass → move the non-audio tools to L → full regression check. #32's description is kept current (standing rule). Commits only when the user asks. |
| G | Performance (setup) | Skip decoding duplicate stream copies; run the customiser beside the maps; parallel clothing library and pro roster, plus a native RefPack DLL in `Build.ps1` | [08](08-setup-performance.md) | `8327e94` (+ overlap hunks of `install.py` in `18f41a3`) | — | Draft upstream [#29](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/29) |
| K | Gameplay fix (physics) | Board/skater solver: 50 constraint iterations (retail live value) instead of `RWMaxIterations` = 25 | [12](12-solver-iterations.md) | `physics/solver-iterations` (`3f6e989`, cherry-picked from `gameplay/audio`'s `7a9e6dc` onto `main` `7ae67f2`) | — (one-file change in `physics/settings.rs`) | Open upstream: [#35](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/35) (2026-10-03). #32 depends on it for retail-like pops and carries the same commit until #35 merges. Open question in the PR: the asset-gated `customiser_equipment_reaches_ground_force_and_torque` test (doc 12). Tests: 309 pass; the 2 failures (`pipelines_accept_…`, `sky_shader_validates`) also fail on `main`. |
| L | Tooling | Development and research tools in `tools/<name>/` (regression checks, setup equivalence, collision / world-stream / audio-file / vault readers, audio e2e and bench; recomp trace and code search, reference only) | [14](14-published-tools.md) | uncommitted (on `gameplay/audio` working tree) | `audio-e2e` / `audio-bench` / `audio-file-inspect` need J's native audio and setup modules; `setup-equivalence/compare_refpack.py` needs G's RefPack DLL; `regression-checks/check_maps.py` uses F's `--validate-maps` (falls back to `--check-assets` without it) | Not opened; the generic folders could go first, the audio ones with or after J |
| M | Feature (audio modding) | Content overlay (mods replace / add retail audio by identity), map audio as data, retail posts / globals / MixMap view for mods, observe-only audio events, docs and example mods | [16](16-audio-modding.md) | `audio/moddability` (from `gameplay/audio`; design commit `8bf4653`) | J (#32) | Draft upstream [#36](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/36), design only (2026-10-03); ~9 working days, the smaller cut chosen by the user. |
Opened 2026-10-01 as drafts from per-PR branches cut from upstream `60efdef`
(code only; `docs/hails-additions` stays in the fork). #29 (G) is stacked on
#28 (F); in the upstream branches the customiser-overlap hunks of `install.py`
live in G, and each branch has its own equivalence pairs (F: maps main→F;
G: character/environment/maps from main and from F). Bodies: `.claude/pr-drafts/`.
G: character/environment/maps from main and from F). Bodies: kept as local drafts.
Notes for whoever opens them:
@@ -63,4 +64,4 @@ Notes for whoever opens them:
(`retail_render.rs` `write_frame_state`) also unfreezes family-14 scrolling and
the shadow floor, so it could go upstream on its own first.
- Fork-only commits not meant for upstream: `167605a` (ignore the local
`.claude/` workspace) and `c2be42b` (docs index; the docs travel with each PR).
workspace folder) and `c2be42b` (docs index; the docs travel with each PR).
+1
View File
@@ -21,6 +21,7 @@ Each document stands alone. File paths are relative to the repository root.
| 13 | [Research hooks for the recompilation](13-recomp-research-hooks.md) | Research tooling (external: skate3recomp `research-hooks` branch) | Published; reference and information gathering only (the recompilation is not a perfect copy of the console game) |
| 14 | [Published development and research tools](14-published-tools.md) | Tooling (`tools/<name>/`, Python and shell) | Done (uncommitted); scripts compile and print their usage; the two `recomp-*` folders are for the recomp's research hooks, reference only |
| 15 | [Hooking up world audio](15-world-audio.md) | Game audio (Rust), mod API (Lua) | Part of #32 (uncommitted). Engine-facing components (`TrafficAudio`, `PedAudio`, `NpcSkaterAudio`) and `sdk.world_audio` for mods feed the ported retail traffic / ped / NPC-skater audio with retail's limits; the map-change bug fixed; a dev test mod (cars, peds, a ghost skater) makes it audible now |
| — | [Audio specs](audio-specs/README.md) | Reference for 11 and 15 (Markdown) | Published 2026-10-03: the behavioural specs (AEMS evaluator, voice graph, buses, MixMap, grain player, player components, world / NPC audio, prior work) that docs 11 / 15 and the code comments cite by section |
## Environment used for verification
@@ -0,0 +1,58 @@
# Audio specs
Behavioural specifications behind the native audio port ([doc 11](../11-audio.md)) and the world audio
hook-in ([doc 15](../15-world-audio.md)). Code comments in `crates/skate-audio` and
`crates/skate-game/src/game_audio` cite them as `audio-specs/<name>.md §n`; the section numbers are kept
stable for that reason.
## What these are
- Written in our own words from reading the Skate 3 TU 3.0.3.0 recompilation, the disc data and the
attribute database, then checked against traces of the running recompilation. Function addresses
(`sub_8xxxxxxx`), object offsets and image constants are given as references so a reader can find the same
code; no game code or game data is copied here.
- Measured values (levels, rates, timings, counts) come from the recompilation unless a line says otherwise.
The recompilation is not the console: its frame rate, timing and threading differ, so its program logic is
the strong evidence and its timings are weaker.
- Upstream PR #4 and PR #1 (@andrewnakas) and the other prior work were read for behaviour only; see
[`rwaudio-prior-work.md`](rwaudio-prior-work.md) and the credit tables in the specs.
- These are working notes. Tags such as **[R]** (our reading of the code), **[IMG]** (image constant),
**[TR]** / **[T]** (trace-checked), **[I]** (inference) and **UNCERTAIN** say how firm each statement is.
Later findings are added as dated sections; superseded text is marked rather than removed.
- Session names (`all_20261002_164620`, `state_20261003_084712.tsv`, …) are labels of recordings made
during the work; the recordings themselves are local and not published. Scripts named as "local" are not
published either; the published ones are under [`tools/`](../../../tools/README.md) (doc 14).
## Index
| Spec | Covers |
|---|---|
| [`aems-reference.md`](aems-reference.md) | Overview: ABKC bank / program layout, evaluator tick, opcode list, sources, corrections; index of the specs below |
| [`aems-evaluator-spec.md`](aems-evaluator-spec.md) | AEMS patch-program evaluator: bank and MOIR layouts, runtime, all 40 opcodes, timing, RNG, test plan |
| [`aems-voice-graph-spec.md`](aems-voice-graph-spec.md) | Voice graph and output mix: SndPlayer1, resample, filters, gain, pan, sends, buses, output stage |
| [`aems-env-bus-spec.md`](aems-env-bus-spec.md) | Environment (reverb) bus: send routing, reverb / delay sub-mixes, presets, zones |
| [`aems-eqchain-buses-spec.md`](aems-eqchain-buses-spec.md) | eEQChain material buses, EQ jitter, LFO task, owner send buses |
| [`aems-bus-leftovers-spec.md`](aems-bus-leftovers-spec.md) | FlangeSub returns, sense_of_speed FXWET0, Splice Send A, Collision SubMix, reverb zones |
| [`mixmap-spec.md`](mixmap-spec.md) | MixMap (`MixMapSK8.mxb`): format, evaluation, ambience / emitter outputs, ducking, validation |
| [`grain-player-spec.md`](grain-player-spec.md) | Granular rolling bed: `grains.big`, surface → grain map, GrainPlayer pick / scheduler / fades, chains |
| [`aems-grain-chain-spec.md`](aems-grain-chain-spec.md) | Board grain bus chain (`sub_824C8878`): FSS, shelf, clip, sends; listening-report evidence |
| [`aems-board-layers-spec.md`](aems-board-layers-spec.md) | SFXObj_SkateBoard rolling layers: Class_rolling, rattles, board slide |
| [`aems-tricks-treatment-spec.md`](aems-tricks-treatment-spec.md) | Tricks component (Class_Flips, cloth_trick) and Class_Treatment |
| [`aems-offboard-clothing-spec.md`](aems-offboard-clothing-spec.md) | Off-board footsteps, clothing, hands-on-deck |
| [`ragdoll-contact-spec.md`](ragdoll-contact-spec.md) | Ragdoll contact response and the bail-sound gap (research) |
| [`ems-emitters-re.md`](ems-emitters-re.md) | World emitters (`.ems`), random one-shot sets, zone ambience beds |
| [`npc-livingworld-re.md`](npc-livingworld-re.md) | The living world: census, traffic, pedestrians, security, AI skaters, NPC speech (background for the world audio) |
| [`world-audio-hookin-spec.md`](world-audio-hookin-spec.md) | Design of the engine-facing world audio API and mod surface (doc 15) |
| [`world-traffic-audio.md`](world-traffic-audio.md) | Traffic audio: retail mechanism, port state, hook points |
| [`world-ped-audio.md`](world-ped-audio.md) | Pedestrian audio: retail mechanism, port state, hook points |
| [`world-speech.md`](world-speech.md) | Pedestrian speech: data, mechanism, port state |
| [`world-npc-skater-audio.md`](world-npc-skater-audio.md) | NPC (AI) skaters' board sounds |
| [`rwaudio-prior-work.md`](rwaudio-prior-work.md) | Prior work on RenderWare Audio and Snd9 AEMS, licences, applicability |
## Credits
[skate3recomp](https://github.com/mchughalex/skate3recomp) by @mchughalex, built on the
[rexglue SDK](https://github.com/rexglue/rexglue-sdk) and [Xenia](https://github.com/xenia-project/xenia)'s
Xbox 360 research; upstream PR #4 and PR #1 by @andrewnakas; sk8Audio (andrewnakas/skate3-audio);
BurnoutDecomp's b5-decomp and BP-Decomp_Workflow; dbalatoni13/nfsmw; mitsevox/tw2004;
[vgmstream](https://github.com/vgmstream/vgmstream). Details and licences per source are in each spec.
@@ -0,0 +1,205 @@
# SFXObj_SkateBoard rolling layers: Class_rolling, Rolling_Rattle_Class, c_board_slide (spec, 2026-10-02)
Native port: `crates/skate-audio/src/player/rolling.rs`, tests
`tests/rolling_banks.rs` (data-gated). Read from the TU3 recompilation (reference only; every function below was dumped
to a local text file per address). Companion specs: `grain-player-spec.md` §1.4/§2.7/§3.3 (routing, push
envelope), `aems-evaluator-spec.md` §1.10 (a post reaches every bank bound to the class).
Owner offsets are `SFXObj_SkateBoard` (`r31`), state offsets the audio state (`[owner+36]`).
## 0. Order inside the owner
Process `sub_824C6A78` (before the MixMap tick): slope `sub_824CA738` → **routing `sub_824C5CA8`** → push / **rattle**
/ brake / turn `sub_824C6198` → chain values `sub_824C9058` → skid `7438` → squeaks `7738` → **layer 5 `sub_824C9F68`**
→ seam envelope `CA448` → `CAEC0` / `CB180` (send / wobble) → **board slide `sub_824CB3C8`** → `CB828`, `CBAC0` (not
examined: no class posts).
Update `sub_824C6BD8` (after the tick): per-truck loop (grain records, or **the Class_rolling patch**), then skid
`7A20`, squeaks `7DD0`, **rattle `80C0`**, **held layers `9948`**, **layer 5 `CA038`**, seam draw, push envelopes,
**board slide `CB4C0`**.
Create (`sub_824C59C8`, owner reset): truck surfaces `+768/+772` = 14, kinds `+1320/+1324` = 1 (grain), key `+760` =
`default`; for the local player `sub_824C9830` posts the **held layers 0 and 3**.
## 1. Surface routing `sub_824C5CA8` (V: read in the asm)
Per frame: SkateBoard input 0 := 0. Pass 0 = primary truck `p` (`+1500`), pass 1 (local player only) = `1 − p`
(recomputed after pass 0). For truck `t`, other `o = 1 − t`:
1. `new` = `sub_824C82A8(t)`: local player and manual latch (`sub_824CA688`: `+1504` set while balancing `+340`, cleared
once wheel count `+200` is 0 or 4) and the truck's wheel not landed (`+464` wheel 0 for the primary truck, `+467`
wheel 3 for the other) → 14; grinding (`+341`) → 14; material `+620` (primary) / `+632` (other) ≥ 143 → 3; else
AudioSurfaceMap word 1 of `min(material, 94)` (`sub_82494CD8`).
2. `new == stored[t]` → next pass.
3. `stored[t] != 14`: input 0 := 32767 (this frame). If **local and the other truck has no live sound** (`+1496 + o`
== 0): **hand over** — `p := 1 − p`, swap `t` ↔ `o` (no stop). Else stop `t`: kind 0 → release its patch
(`+1312 + 4t`); kind 1 with grains running (`+1328 + t`) → stop both players, reset both chains; live := 0.
4. `stored[t] := new`; 14 → next pass (kind keeps its value).
5. `+760` := member key of `new` (`sub_824C8370`, soft by `sub_824B23C8` = state `+684` for the local player); the grain
collection `+184 + 16t` := lookup(grain class `7AB23C11B6ADA2DE`, key).
6. kind = 0 (Class_rolling) for surfaces 7, 8, 10, 11, 12, 13, else 1 (the `bdzf` switch at `0x824C5EB8`; surface 11
has no tag in the vault map).
7. `new == stored[o]` → no start. Else kind 0: selector `+1488 + 4t` = {7: 1, 8: 2, 10: 10, 11: 12, 12: 11, 13: 9},
post `Class_rolling(speed 0, selector, new)` → `+1312 + 4t`; kind 1: bind both grain players (`sub_824C8878`,
members by the key's slots), `+1328 + t` := 1. Either way live `+1496 + t` := 1.
8. kind → `+1320 + 4t`.
After the passes: input 6 := 32767 iff `sub_824C82A8(0) == 9` (truck **index 0**, whichever wheel it reads).
Consequences (new, not in the grain spec):
- The primary truck always reads wheel 0, the other wheel 3; a hand-over moves the "primary" label, not the sound.
- **The local player's last sounding truck is never stopped**: when both trucks go to 14 together (grind, manual with
both latches clear) pass 0 hands the sound to the silent truck (which stores 14) and pass 1 hands it back. A manual
(front wheel up) hands the label to truck 1 and the sounding truck keeps playing on wheel 3. The MixMap mutes it
(SkateBoard level(1) = 0 without wheel contact, see §5). Retail evidence: GREC (session 180430, 310 k frames) has
neither truck running on only 621 frames (menus/start), 39 k (1,0)↔(0,1) label swaps
(`grec_running.py` (local script)). Grain spec §2.10 "Grind: both players stop" is wrong for the local
player; it holds for other players (no hand-over).
- Surface 0 (tag 90): kind 1 with the `default` key; `sub_824C8370` leaves the caller's slot words at 0/1, so the bind
plays **asphalt_rough_hard** with the `default` collection's tuning (resolves grain spec §1.4 "0 UNCERTAIN").
## 2. Class_rolling (12 words; constructor `sub_824C4C18`)
Post: w0 0, w1 0, w2 4096, w3 clamp(speed, 0, 10000), w4 clamp(selector, 0, 15), w5 0, w6 clamp(surface, 0, 13), w7 0,
w8 0, w9 25000, w10 0, w11 32767.
| use | post | update |
|---|---|---|
| per-truck patch `+1312 + 4t` (routing) | speed 0, selector by surface, w6 surface | in `sub_824C6BD8` while live and kind 0: w0 32767, w11 level(1), w1 raw(0) ≤ 65536, w2 pitch(3), w3 = S(selector, push-scaled), w5 0, w8 = selector 1 ? 0 : level(13), w9 filter(11), w10 filter(12), w7 = `+340` ∥ `+339` |
| layer 0 `+1304` / layer 3 `+1308` (create, local) | speed L(0)/L(3), selector 0 / 3, surface 3 | `sub_824C9948`: w0 32767, w11 level(7) / level(9), w1 raw(0) ≤ 65536, w2 pitch(8), w3 L(layer), w5 0, w6 = surface rule, w7 manual flag, w8 level(19), w9 filter(17), w10 filter(18) |
| layer 5 `+1332` (spidercrack) | `sub_824C9F68`: pattern = `+636` (wheel 0), or `+648` (wheel 3) while the manual latch holds and `+464` = 0; none held and pattern 1 → post S(5, push-scaled), selector 5, surface 3; held and pattern ≠ 1 → release | `sub_824CA038`: as the held layers with w11 level(10), w3 S(5, push-scaled) |
- S(n, scaled) = `sub_824C6B30`: trunc(clamp01((v·k / kmh[n])·3.6)·10000), v = state `+208` (signed), k = push
speed-scale envelope value `+1028` while running (`+1032` = 0), kmh[n] = vault array (§6).
- L(n) = the same without k (`sub_824C9830` / `sub_824C9948`).
- Surface rule (w6): primary truck's `sub_824C82A8`; if 14 the other's; if both 14: 13 while grinding, else unchanged.
- clamp01 = retail's `fsel` pair (NaN → 1).
- Held layers and patches are released only by the routing / layer-5 rule (and owner destruction).
Retail POST callers (sessions 163809 / 164620 / 180430): held layers `824C98C4` / `824C991C` once per session at board
create; patches `824C5F88` 2 / 14 / 14; layer 5 `824C9FE8` 1 / 0 / 108.
### Which bank answers (census `class_rolling_selector_census`, evaluator only)
A post reaches PatchBank_Rolling_Surfaces, _SpiderCracks, _Objects and _RocksBounce. Slots opened (8 s):
- always: Rolling_Surfaces 11, 12, SpiderCracks 0 / 11 (selectors 0–5) or 17 (6–15), Objects 9 / 12 / 15 (by speed) —
silent at the gains below (none shows in the rendered table);
- surface word 3: Rolling_Surfaces 15 (the held layers' texture — retail's most played stream, 50 of 133);
- selector 1: Rolling_Surfaces 0, 2 (4 at low speed); selectors 2–15 (except 9): 1, 3 (5 at low speed);
- selector 9: Rolling_Surfaces 13, 14; selector 5: SpiderCracks 0–16; selectors 10, 11: Objects 0–2, 12–14 (5–7, 15–17
fast; 8–11 slow);
- RocksBounce never opens for these packets (other owners post it).
## 3. Rolling_Rattle_Class (12 words; holder `+1300`)
In `sub_824C6198` inside the push-plant block (`+335`), after the push envelopes: release + free the held rattle; then
if `+1320 + 4·p` == 1 (primary truck kind grain) post `sub_824B0248(speed, code, eq)`:
- speed = trunc(clamp01(((v − 1)·3.6) / F)·10000), v = `+208`, F = field `12275AA8AC4A63FB` of the primary truck's
grain collection (30 in `default`; no member overrides it);
- code from `+760`: asphalt_smooth_hard 1, concrete_rough_hard 2, concrete_smooth_hard 3, wood_ramp_hard 4,
concrete_aggregate_hard 5, else 0 (soft members, asphalt_rough, metal, `default`);
- words: w2 4096, w3 clamp(speed, 0, 10000), w4 clamp(code, 0, 8), w6 1, w8 25000, w10 32767, w11 clamp(eq, 0, 32767),
rest 0; eq = eEQChain class `42AFE160E647167C` `default` field `C04832978CDED925` = 8.
Update `sub_824C80C0` (while held): w0 32767, w10 level(6), w1 raw(0) ≤ 65535, w2 pitch(3) ≤ 8192, w7 level(16), w8
filter(14), w9 filter(15). Never released except by the next push.
Bank: sample = 3·code + (0, 1, 2) by speed word (thresholds between 4000–7000 and at 10000); code 5 reuses 6..8.
## 4. c_board_slide (12 words; holder `+1884`, bank board_scrapes)
`sub_824CB3C8`: none held and state `+780` ≠ 0 → post `sub_824B0670(eq, mode = (+780 == 2))`: w2 4096, w4 25000, w8
clamp(eq), w10 clamp(mode, 0, 3), rest 0; eq field `F2B44F93BD91662E` = 7. Held and `+780` = 0 → release.
`sub_824CB4C0`: w0 32767, w1 raw(0) ≤ 65535, w2 pitch(23), w3 = trunc(clamp01(((v − 0.5) / 15)·3.6)·10000) ≤ 10000
(0.5 = image `0x8209975C`, 15 = `C1831BDB6CB1B1EA`/`621090620F4F936A` field `9635B780C7472A6E`), w4 filter(25), w5
filter(26), w6 level(27), w7 level(24), w11 = mode 2 ? `AB87C3D1EDDDDCBC` : `662CEE73D2E3FE2F` (both 15000).
`+780` ("loose board"), bridge `sub_824B0DA8` from audio record `+156` bits 5–6, written by the conditioner
`sub_827A1B78` (`0x827A2A60..2B14`, cleared each frame): set when (record `+148` bit 5 = State`+59` (bail) **or**
`+152` bit 30 = state 500 (on foot)) and `+148` bit 8 (= `[rec+24]+3475`, a deck-contact byte) and `+476` (=
`[rec+24]+12` − 1, a material) < 94: dot(`[rec+0]+80`, `[rec+32]+80`) < −0.9 → 1, −0.1 < dot < 0.1 → 2.
Retail plays it: caller `824CB470` 22 events (163809, peak 0.256), 28 (164620, peak 0.346). Port:
`rolling::loose_board(bail ∥ on_foot, deck_contact, deck_material, up_dot)`. UNCERTAIN: which two vectors make the dot
(taken as deck up · contact/world up) and what `[rec+24]+3475` is exactly.
## 5. MixMap SkateBoard outputs read (headless, real MxB, straight roll, camera 3.5 m behind)
| km/h | l1 | l6 | l7 | l9 | l10 | l13 | l16 | l19 | f11/f12, f14/f15, f17/f18 | l24 | l27 | f25/f26 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 2614 | 0 | 0 | 24971 / 77 | 7093 | 3849 | 19456 / 77 |
| 8 | 7575 | 11013 | 12791 | 4834 | 7488 | 2614 | 1658 | 0 | 24971 / 77 | 7093 | 3849 | 19456 / 77 |
| 40 | 8277 | 12034 | 12791 | 4834 | 10231 | 2614 | 1812 | 0 | 24971 / 77 | 7093 | 3849 | 19456 / 77 |
In the air l1/l6/l7/l9/l10/l13/l16 are 0. (`skateboard_outputs_by_speed`.)
## 6. Vault values (setup must export: `audio_export.player_tuning` → `rolling`)
| value | class / collection / field | user's vault |
|---|---|---|
| Class_rolling km/h by selector 0..15 | `C1831BDB6CB1B1EA` / `7B0ED922C779B74C` / `880C82E8EF647EC4` (16 floats, holder `[0x830CFDA4]+56`) | 70, 65, 65, 70, 100, 45, 45, 45, 45, 35, 45, 45, 45, 45, 45, 45 |
| rattle km/h | grain `7AB23C11B6ADA2DE` / `default` / `12275AA8AC4A63FB` (per member, inherited) | 30 |
| rattle eEQChain | `42AFE160E647167C` / `default` / `C04832978CDED925` | 8 |
| slide eEQChain | `42AFE160E647167C` / `default` / `F2B44F93BD91662E` | 7 |
| slide km/h divisor | `C1831BDB6CB1B1EA` / `621090620F4F936A` / `9635B780C7472A6E` | 15 |
| slide level mode 1 / 2 | same / `662CEE73D2E3FE2F` / `AB87C3D1EDDDDCBC` | 15000 / 15000 |
Fallback when a field is missing: retail reads `0x830D0850` (zero block) → divide by 0; the port keeps the vault values
as `RollingTuning::default()`.
## 7. Validation (rendered through the real banks + MixMap, `rolling_layers_play_their_retail_banks`)
Per-start level = peak voice gain (master × dry) in the first 0.4 s (`analyse_session.py` GAIN_WINDOW); retail =
GAIN × SEND per start (report "Levels per bank").
| bank | ours (8 / 20 / 35 km/h) | retail 163809 med / p90 / max | 164620 |
|---|---|---|---|
| Rolling_Rattles (push on asphalt) | 0.336 / 0.354 / 0.366 | 0.007 / 0.301 / 0.366 | 0.032 / 0.362 / 0.370 |
| PatchBank_SpiderCracks (layer 5) | p90 0.226–0.341, max 0.226 / 0.277 / 0.355 | 0.000 / 0.223 / 0.293 | 0.001 / 0.228 / 0.353 |
| PatchBank_Rolling_Surfaces held layers (surface 3, slot 15) | 0.030 / 0.046 / 0.102 | 0.007 / 0.044 / 0.074 | 0.010 / 0.121 / 0.388 |
| Rolling_Surfaces patch surface 8 (slots 1, 3) | 0.024 / 0.120 / 0.115 (max 0.169) | — | slot 3 median 0.126, max 0.358 |
| Rolling_Surfaces patch surface 13 (slots 11–14) | 0.106 / 0.111 / 0.115 | — | slot 14 max 0.174, 13 max 0.284 |
| PatchBank_Objects (surfaces 10, 12) | 0.101 / 0.166 / 0.203 | 0.001 / 0.030 / 0.030 | 0.003 / 0.219 / 0.250 |
| board_scrapes (loose 1/2, 6–15 km/h, per frame) | 0.057–0.058 median, max 0.060–0.099 | 0.002 / 0.096 / 0.256 | 0.004 / 0.121 / 0.346 |
Maxima and p90 agree; retail's low medians come from the session mix (rattles at a standstill: level(6) = 0 at 0
km/h; quiet samples of other surface codes; landing swells raise level(1) for the higher retail maxima). First
trigger: the first rattle's peak equals the later ones (asserted).
## 8. What this replaces in the game (interim)
- `cues::grain_for` tags 8 / 10 / 70 → concrete_aggregate_hard and 37 / 67–69 → metal_smooth_hard (INTERIM): with the
native routing these surfaces bind **no grain**; their sound is the Class_rolling patch (selectors 2, 1, 1, 9, 10,
11). Tag 90 binds asphalt_rough_hard with the `default` tuning (not wood_ramp_hard).
- `cues::BED_CUES` `Rolling_Rattles` and `PatchBank_Rolling_Surfaces` entries: replaced by the rattle (per push) and
the held layers / patches; the interim bed must not play them when the native layers run.
- The grain bed's own routing (`grain_bed.rs`: majority surface, one truck, stop on grind) should be driven by
`Rolling::process`'s `GrainEvent`s (two trucks, hand-over, never stopping the local player's last sounding truck;
the MixMap mutes it on grinds / in the air), and its SkateBoard input 0 / 6 writes taken from `Routed`.
## 9. UNCERTAIN / not modelled
- `sub_824B23C8` for non-local players (a lookup, not `+684`).
- The loose-board vectors and `[rec+24]+3475` (§4); our engine publishes none of it yet.
- `CB828`, `CBAC0` in the owner's process (no class posts; not examined).
- The bus send each layer's voices take (eEQChain tweak words are posted, routing per bus spec).
## 10. Listening report 2026-10-02 ("busy / faster than the speed", "rolling continues while jumping")
Session `state_20261002_203746` (+ stderr log), headless analysis:
- **My layers' interim stand-ins are responsible for part of it.** With `SKATE_AEMS=1` the interim bed still fires
`BED_CUES` `Rolling_Rattles` (random 0.31/s × `bed_rate`, level 0.44, samples 0.9–1.9 s) and
`PatchBank_Rolling_Surfaces` (0.14/s, samples 0.3–1.6 s): ~7.7 random rattles + 3.5 surface one-shots in 21 s of
riding with 4 pushes. Retail posts a rattle only per push (and at a standstill level(6) = 0 → silent) and its
Class_rolling layers are continuous MixMap-gated textures. The bed gate (`!airborne`) stops new interim fires in the
air, but a one-shot started up to ~1.2 s before takeoff rings on: P ≈ 0.33–0.43 per jump for a rattle, 0.14–0.19
for a surface one-shot at the session's takeoff speeds. Fix: skip those two `BED_CUES` when the native player runs
(and once `rolling.rs` is integrated, they are replaced).
- **The native layers go quiet in the air** (`layers_go_quiet_after_takeoff`: push at 30 km/h, takeoff 10 frames later:
rattle peak 0.217 / patch 0.173 on the ground, no voice above 0 from 10 frames after takeoff) — SkateBoard
level(1)/(6)/(7)/(9)/(10) are 0 without wheels.
- Retail facts checked for the other owners: retail's state `+208` keeps the riding speed in the air and fluctuates
±5 km/h (GREC 180430, owner 40C33020, 96 takeoffs), so SenseOfSpeed's rattle (level(1) not wheel-gated: 832 at
31 km/h both on the ground and in the air) toggling around 30 km/h in jumps (11 posts in this 38 s session) is retail
behaviour, not a bug. Retail's wheel material is 143 on 96 % of air frames (37938 / 39685), so routing surface 3
(asphalt_smooth) in the air and the grain bed's "bind asphalt_smooth_hard (surface tag 0)" at landings is retail-like;
the bed is muted there by level(1) = 0 (the grain bed's part).
@@ -0,0 +1,341 @@
# Bus leftovers: FlangeSub returns, sense_of_speed FXWET0, Splice Send A / Collision SubMix, reverb zones
Written 2026-10-02 (headless reading of the code). Companion to `aems-env-bus-spec.md` (§7 FlangeSub, §6.2 zones) and
`aems-eqchain-buses-spec.md`, which it completes. Behaviour in our own words from the TU3 lifted code (reference
only); addresses are TU3 3.0.3.0. Tags: [R] our reading of the code, [IMG] image constant, [VLT] vault value,
[TR] checked against a clean recomp trace, UNCERTAIN = not settled. Local analysis scripts
(`sendb_bank.py`, `senda_bank_fixed.py`, `senda_windows.py`, `voice_windows.py`, `submix_sends.py`). Crate
probes: `examples/effect_routes.rs` (routing-record census), `examples/post_probe.rs` (what a class's words do to
its voices), `aems_render` with `AEMS_FLANGE=…`, `voice_load_probe … fx`.
## 0. Answers
1. **FlangeSub returns** are two mono flangers: `Sub0 → Del0 → PI20 → Sen0 (env) → Gai0 → Pn21 → Sen0 (SFX
Master)`. Routing 4096 / 8192 → return A, 16384 → B. Users on the disc: GRINDS (4096), WHEEL_SKID_BANK (8192),
Sk8_Air_Flip_Tricks and hom_slo_mo (16384). The Send B level comes from the component's words (grind w15 =
Rail out6 = −26 dB, skid w16 = SkateBoard out20 = −25 dB) and already matched retail; only the return was
missing. Implemented (`bus/flange.rs`, mixer Send B).
2. **sense_of_speed FXWET0 = 0 is retail.** The updater `sub_824E7CB0` writes w6 = 0 every frame and w6 is
property 5. The "retail median 0.0150" came from `envbus/senda_bank.py`, which attributes every trace line
to the owner's *last* PLAY of the session. With time-correct attribution, 282 of 293 sense_of_speed Send A
lines are 0; the 11 others are other graphs reusing the owner address (send/gain 754). No port bug.
3. **Splice voices have no Send A** (retail graph `Rsp0 → Gai0 → … → Sen0`, trace `PITCH GAIN SEND6`). The
"0.0146" was the same attribution artefact. The collision voices' wet path is the per-voice **Collision
SubMix** (`sub_824D25E0`): mono, env send = Collision output (category) / 32767 posted once at build (≈ 0.1
near the camera, retail trace cluster 0.100), then into SFX Master's centre. Implemented (`Route::mono`,
`CollisionManager::submix`).
4. **Reverb zones** (`.ems` attribute type 5): the full `SFXObj_Reverb` state machine with zone fade,
zone-to-zone and the rotation of the reverb outputs toward the zone. Implemented as
`EnvNetwork::update` (`bus/zones.rs`); the host has to supply the zone nodes.
5. Extra: **manager +104** (global env-send scale) is the Reverb owner's out4 every frame. The EnvSub sends are
re-posted per frame as *last applied preset* × scale (`sub_8248DB78`) [TR: 0.2497 / 0.4994 / 0.9989 =
preset × 0.99887]. Implemented as `EnvNetwork::scale_frame`. The hosts write Reverb.in5 = 32767 (from PR #4's
capture stats). That holds F213 at −400 mB, so out4 = 20603 and the scale would be 0.63. The retail trace
rules this out: in those sessions in5 = 0.
## 1. FlangeSub effect returns
### 1.1 Graph `sub_82490270` [R]
- Two graphs, both order 150, 7 modules each. Module list (params, FourCC, channels):
- Sub0 (name 0x8224DC5C), 1 ch;
- Del0, 1 ch, ctor max delay [0x820D0190] = 0.015 s;
- PI20, 1 ch;
- Sen0, 1 ch;
- Gai0, 1 ch;
- Pn21, 6 ch;
- Sen0, 6 ch.
- Return A: graph at mgr+108, modules mgr+116. Return B: mgr+112 / mgr+120.
- Sen0 #3 links (0x7FF7FFF4) to `[[mgr+52]]`, the env input. Sen0 #6 links to `[[0x830CFDBC]+44][0]`, SFX
Master.
- Module pointers kept for the level posts:
- A: mgr+148 = Pn21, +152 = Sen0 env, +156 = Gai0;
- B: +184 / +188 / +192.
- Boot `sub_826DD3A8` zeroes these six pointers before building.
### 1.2 Voice side `sub_824A3140` [R]
- A routing record (id ≥ 9) with value 4096 / 8192 / 16384, followed by an enable record = 1, sets the effect:
- voice +72 = code, +76 = 1;
- +80 = next-next value / 32767;
- +88 = `[[mgr + 4·(29 + (code == 16384))]]` (Sub0 of A or B).
- The voice graph gets a Sen0 **after Gain and before Pn21**, at the voice's channel count. N → 1 into the
mono Sub0 uses the image route tables: L, C, R, Ls, Rs at unity, LFE dropped.
- Level posted 0.0 at open. Property 11 then sets Send B = value / 32767 (`sub_824A29A8`, only when +88 ≠ 0).
It is not multiplied by VOL: the signal is already post-Gain.
- The +80 record level is stored but never posted.
### 1.3 Census `examples/effect_routes.rs` (disc banks) [R]
The template words are 0 and programs fill them; `post_probe` shows the source.
| bank / class | record 9 | record 10 (enable) ← | record 11 (level) ← | record 12 |
|---|---|---|---|---|
| GRINDS / Class_grind | 4096 → A | w14 (local) | w15 = Rail out6 | w16 = eEQChain 5 |
| WHEEL_SKID_BANK / Class_wheels_skid | 8192 → A | w15 (local) | w16 = SkateBoard out20 | — |
| Sk8_Air_Flip_Tricks / Class_Flips | 16384 → B | (not ported) | | |
| hom_slo_mo / hall_of_meat_slo_mo | 16384 → B | (not ported) | | |
Totals over all routing records: 0: 211, 256: 3, 512: 160, 2048: 347, 4096: 6, 8192: 4, 16384: 19.
### 1.4 Presets `sub_824DDF58` → `sub_8248FE68` [R][VLT]
- Vault class `4CDD7CDC1A955D5C`, two collections (first → type 12288 → A, second → 16384 → B):
- A = `C6290FFCB9E84CD9`, which drives the graph that sweeps 1200–1800 Hz in the trace;
- B = `DFDFFAD67CCBA322` (constant 250 Hz).
- Nine floats by record offset:
| off | field | A | B | used as |
|---|---|---|---|---|
| 0 | DE9BF5C10AB2C866 | 20.0 | 1.7 | stored at mgr+216+96i; no reader found (PI20 Q?) |
| 4 | 113F78E17495296F | 0.3 | 0.3 | PI20 LFO rate: period = 1 / v (fdivs, no lower bound) |
| 8 | 3C0376079AD9A56C | 0.2 | 0.0 | PI20 LFO amount |
| 12 | 7D3294EC1C443CBD | 0.1 | 1.0 | stored only (PI20 gain?) |
| 16 | CD44B25FAD9E48D9 | 1500 | 250 | PI20 LFO centre = depth (Hz) |
| 20 | EDEE41D561FA8CE5 | 0.002 | 0.0005 | Del0 LFO centre = depth (s) |
| 24 | 9C890EC62D3BC5C6 | 0.5 | 0.25 | Del0 LFO rate (period 1 / v) |
| 28 | D10D7F661896AC30 | 0.9 | 0.6 | Del0 LFO amount |
| 32 | 9024FC56E2314F9F | 0.03 | 0.11 | stored only (feedback?) |
- LFO records 11 / 13 (Del0 p0) and 12 / 14 (PI20 p0): {period, phase 0, amount, centre, depth = centre}.
- The "LFO Task" (`aems-env-bus-spec.md` §5) posts them every block: d(t) = d₀ (1 + amount · sin 2πt / period).
- A: delay 0.2…3.8 ms at 0.5 Hz (10…182 samples). B: 0.2…0.8 ms at 0.25 Hz.
- **Nothing posts PI20's gain or Q, nor Del0's feedback.** The trace's PEAK lines stay at gain 1.0 / Q 3.0
(class defaults, PI20 bypassed). Del0's descriptor default feedback is 0.0 (attribute table 0x82FCE068:
delay default 0 / max 20; feedback default 0, range ±0.99).
### 1.5 Levels per frame `sub_824DF220` (SFXObj_Reverb vtable 0x822FCDE0 +48, after the MixMap tick) [R][TR]
- vfunc60 of the Reverb owner (`0x40050000`), each / 32767:
| target | output | free skate | retail |
|---|---|---|---|
| A Gai0 | out0 | 32692 (E46, 0 mB + Global ducks) | 0.9977 [TR] |
| A Sen0 env | out1 | 2313 (E48, −2300 mB) | 0.0706 [TR] |
| B Gai0 | out2 | as out0 | |
| B Sen0 env | out3 | as out1 | |
| mgr+104 | out4 | 32730 (E47) | |
- The values go through `sub_8248FFF8`.
- mgr+124 / +160 (the Pn21 value) and +136 … +144 / +172 … +180 (parameter ids) are never written.
- Zeroed allocation assumed: Pn21 p0 (azimuth) = 0, D = 1 (class default). The return plays **in the
centre**. UNCERTAIN (allocator flags 257 | 0x1000000, not traced).
- Retail processes return A only in bursts (its PEAK lines appear while grinds / skids play). We render a return
only in blocks where some voice sends into it; its LFO advances every block.
### 1.6 Port
- `bus/flange.rs` (`FlangeReturns`, `FlangePreset`, `FlangeReturn::render`). Disabled until `set_presets`.
- Mixer Send B (`Voice::render`): mono sum of the post-gain channels × property 11 (our user group volume too),
Sen0 64 / 65 ramp. Allocation-free per block.
- **Identity:** returns disabled → GRINDS and WHEEL_SKID `aems_render` WAVs byte-identical to the baseline; the
mixer test renders an effect-routed voice identically to a plain one.
- **Effect:** GRINDS render with the returns: the difference signal is −26.1 dB re the dry (a −26 dB centre
copy, 0.2–3.8 ms, sweeping). Comb ripple ≈ ±0.4 dB.
- **Cost:** `voice_load_probe 96 3000` p50 per block, three runs each:
- baseline 516 / 515 / 516 µs;
- ours, off: 521 / 522 / 521 (+5 µs, the larger Voice and the extra branch);
- all 96 voices routed into the returns: 528 / 530 / 529 (+8 µs).
## 2. sense_of_speed FXWET0 [R][TR]
- `post_probe sense_of_speed SenseOfSpeed_rattle`: property 5 ← w6, 7 ← w5, 8 ← w7, 2 ← w0 × curve.
- The retail updater `sub_824E7CB0` writes w0, w1 = raw(0), w2 = pitch(2), **w6 = 0** (stw r27), w7 =
level(1) and w3. The wind is the same with outputs 3 / 4.
- So retail Send A = VOL × 0 = 0, as ours.
- Trace (all_163809, `senda_windows.py`): every sense_of_speed voice window starts `SEND1 0.0000 / GAIN … /
SEND6 1.0` and its Send A never changes.
**`envbus/senda_bank.py` bug:**
- `cur[o]` is read after the file loop, so every SEND line is attributed to the owner's last PLAY in the
session.
- `senda_bank_fixed.py` attributes by voice window and identifies Send A as the SEND below the GAIN address
(all_163809):
| bank | Send A lines (non-zero) | non-zero median | Send A / gain | Send B median |
|---|---|---|---|---|
| GRINDS | 15 (15) | 0.0102 | 0.165 | 0.0499 |
| WHEEL_SKID_BANK | 107 (88) | 0.0146 | 0.635 | 0.0561 |
| fstep_skateshoe1_sm | 3318 (2527) | 0.0147 | 0.255 | — |
| Seams_Bank | 730 (623) | 0.0127 | 0.205 | — |
| Rolling_Rattles | 326 (326) | 0.0241 | 0.190 | — |
| sense_of_speed | 293 (11) | artefacts | 754 | — |
| Skate_Collisions | 153 of 6754 voices | 0.0703 (= Collision SubMix env sends on reused owners) | 1.44 | — |
- The doc-11 table's GRINDS / WHEEL_SKID / FOOT_DRAG Send A figures should be re-read with the fixed
script.
- Hook caveat: the SEND / GAIN hooks key their change filter by module address across voices. A window sees a
module's first post and changes > 0.01 relative to the last logged value at that address.
## 3. Splice Send A and the Collision SubMix
### 3.1 No Splice Send A [TR]
- Skate_Collisions voice windows: 271 × `PITCH GAIN`, 111 × `PITCH GAIN SEND6`, 13 × `PITCH`, 2 × with stray
SEND1s (reused owners). There is no Send A and no filters.
### 3.2 `sub_824D25E0(collision, i)`, called by `sub_824D1F68` per material voice before it starts [R]
- Graph order 4, 3 modules, all **1 channel**: Sub0 → Sen0 → Sen0. Stored at this + 32i + 52 / 56; module
indices 1 / 2 at +64 / +68.
- Sen0 #1 → env input; level = vfunc60(`sub_824D21D0`(material i)) / 32767, posted once at build.
- Category → output: 0 → 3, 1 → 4, 2 → 5, 3 → 6, 4 → 7, 5 → 8, 6 → 2, 7 → 9, 9 → 11, else (8, material
≥ 143) → 10. Jump table 0x824D2214.
- MixMap E2: outputs 2–10 = −2000 / −2000 / −1622 / −2000 / −2000 / −1755 / −2000 / −2000 / −2000 mB +
B[Collision.1] (4…50 m) + C0 + A0, and out11 = −1600.
- Sen0 #2 → `sub_82491108(mgr, sub_82497BF8(mat i, tier i, mat 1−i), create = message byte +41)`, level never
posted (1.0).
- The Splice voice is started with this Sub0 as its output (`sub_82975700` r6).
- Its 6-ch final Send sums 6 → 1 (L … Rs, LFE dropped).
- The submix's Sen0 into the 6-ch bus is 1 → 6 = **centre**.
- So retail collisions are mono in the centre, and a voice panned between two speakers sums its pair (up to
+3 dB at the sides).
- `sub_82497BF8`: AudioSurfaceMap entry (0..93 by material, else entry 94).
- +48 (word 12) for tier 0, +64 (16) for tier 2.
- Tier 1: +52 / +56 / +60 by `sub_82497910`(other) = class 0 / 1 / 2 (+52 when other = 143).
- **Every shipped entry holds 8** (= SFX Master), so the EQ leg is SFX Master.
- [TR] SEND lines on non-voice owners within 1.5 ms of a collision PLAY: SEND1 values cluster at 0.100 (290
lines, = −2000 mB near the camera) and 1.0 (281 = Sen0 #2 default); `submix_sends.py`: 315 owners
`SEND1 SEND1`.
### 3.3 Port
- `Route::mono` (mixer `SIX_TO_MONO_CENTRE`: the final Send adds L…Rs into the bus centre; the existing
owner-env tap gives the env send).
- `CollisionTuning::{surface_eq, eq_bus, env_output}`.
- `CollisionManager::submix` (default **off** = SFX Master as before). With it on, `start` sets each voice's
route from the outputs after this frame's tick.
- Test: with the MixMap warmed up, board (category 6 → out2) env ≈ 0.1. On the very first tick of a fresh
MixMap every output is 0.
## 4. Reverb-zone emitters (type 5) [R]
### 4.1 SFXObj_Reverb update `sub_824DE548` (per frame)
State:
- +400 current key, +408 target key;
- +416 current side, +420 target side;
- +424 zone side;
- +428 / +432 zone nodes;
- per side (+28 + 184·side): +48 weight, +128 mode, +204 timer, +208 length 1.0.
`sub_82488278(mgr, skip)` returns the first type-5 node of the emitter manager's active list (audio+488, list
+128) that is not `skip`, provided vfunc92(attribute) passes (else 0; it does not look further).
- **No transition** (+400 = +408):
- no zone → clear the nodes; if the current key ≠ the region key (`*(0x83083C38)+0x2F0B0+16`, 0 → reverb01)
→ start(region, 0);
- zone z1 and none held → hold z1; start(z1 preset, 1); zone side = target side;
- z1 held and a second zone z2 = query(z1):
- same preset and current = k1 → target side = current side;
- k1 ≠ k2 and current = k1 → start(k2, 2), node 2 = z2;
- otherwise → start(k1, 2), node 2 = z1;
- z1 held, no z2: if held node d > 0 and zone side = current side → start(region, 1), zone side = current
side (leaving a committed zone).
- **Transition** → the target side's mode:
- 0 = timed fade `sub_824DE850` (timer += dt; weights x / 1 − x; commit at 1; pans 270 / 90);
- 1 = zone fade `sub_824DE970`;
- 2 = zone-to-zone `sub_824DEAE8`;
- ≥ 3: target side = current side.
- start `sub_824DE468`: the target key; target side = 1 − current; the preset on that side at weight 0;
mode; timer 0; length 1.0.
### 4.2 Zone fade `sub_824DE970`
- Re-query z1 / z2. If z1 is missing, or the held node is neither z1 (or the same attribute) nor z2 → snap
`sub_824DEDD8`:
- if zone side = current → weights current 0 / target 1, commit;
- else current 1 / target 0, cancel;
- nodes cleared, pans 270 / 90.
- Timer[zone side] = held node d (node +12, 0 inside the inner core). d ≠ 0 → blend; d = 0 → commit (zone side
= target) or cancel, weights current 1 / other 0, pans 270 / 90.
### 4.3 Blend `sub_824DEEF0(side, zone)`
- Inputs: camera = `*(0x83083C38)+0x2F078` +48 position / +64 forward (x, z); zone position = node record +8 /
+16. No camera or no node record → vfunc 28 (UNCERTAIN).
- Unit vectors f̂ (forward) and d̂ (zone − camera).
- c = clamp(d̂·f̂, −1, 1); cross = d̂ₓ f̂_z − d̂_z f̂ₓ.
- θ = (1/π)·(acos(c)·180) ∈ [0, 180] (vector acos `sub_82453298`).
- left = θ − 270, or θ + 90 when cross ≤ 0 and θ < 90; right = 90 − θ.
- L = 270 + d·left (fused); R = −(d·right − 90) (fused). Both wrapped into [0, 360).
- Weights: side 1 − d, other side d. Post side with (L, R), other side with (270, 90).
### 4.4 Zone-to-zone `sub_824DEAE8`
- No z1 → vfunc 28 (UNCERTAIN; we snap).
- z1 only:
- held node 1 ≠ z1 → node 1 = node 2;
- current key = node 1's preset → current 1 / target 0, else target 1 / current 0;
- node 2 cleared; target side = current side; zone side = current side.
- Retail quirk, kept: the 1-weighted side need not become current.
- Both zones: the held nodes must both be z1 or z2 (else vfunc 28).
- Same attribute → current 1 / target 0, cancel.
- Else d of node 2 = 0 → current 0 / target 1, commit, node 1 = node 2.
- Else timer[target] = d, blend(target, node 2).
### 4.5 Port and host contract
- `bus/zones.rs`:
- `EnvNetwork::update(dt, region_key, &[Zone], Option<&Camera>)` replaces `request` + `frame`.
- With no zones it is the same timed fade in retail's order: the starting frame does not advance it, and the
network starts with reverb01 on both sides (`sub_824DDE50`), so the first region preset fades in over
1 s.
- `Side::apply` now resets the reverb outputs' pans to 270 / 90 (sub_8248DD18). `scale_frame` follows the
selector's sides.
- (2026-10-02 correction) The disc has 1,040 type-5 records in 24 attributes, not one: 989 in the district
`reverb_*.ems` files (loaded with music_ / sfx_ / speakers_ / crowds_, map database field `65FA976EF23A314E`), 51 in the
parks' `sfx_` files. Wired: `emitters::reverb_zones`.
- `Zone`, per frame, the active `.ems` nodes of attribute type 5 in the emitter list order:
- `id`: node identity;
- `attribute`: the record's sound id (node +24);
- `preset`: the attribute's RefSpec `99FD793BC30CF0FA` collection key (class `204CAC1FD77088B8`; the only
type-5 record on the disc, `1F94F2F815C00368`, 37 nodes in the sfx files, points at
`BEEFC8E3DE04FBAE`; its parent `ECADC64E974CF9A1` holds reverb01);
- `d`: normalised distance after the inner core (`ems-emitters-re.md`: sphere / ellipsoid, active while
d < 1);
- `position` [x, z];
- `enabled` = vfunc92 `sub_824A2438`: the attribute's `99FD793BC30CF0FA` key is in the image table `0x8302E298` (the 24
reverb preset keys = the exported `aud_reverb` set); missing field → key 0 → fails. All 1,040 disc zones pass.
- Tests: the timed fade from reverb01; zone entry → blend 1 − d / d with the pans turned toward the zone (θ = 0:
L 337.5, R 22.5 at d = 0.75) → commit at d = 0 → leaving (region preset on the other side) → snap outside;
the rotation unwraps on both sides (θ = 45 / 90 → both pans on θ at d = 1).
## 5. Integration (game host, read-only for this pass)
1. **Flange data** (setup `audio_export.bus_tuning`): add `"flange": [[A 9 floats], [B 9 floats]]` from vault
class `4CDD7CDC1A955D5C`, collections `C6290FFCB9E84CD9` (A) and `DFDFFAD67CCBA322` (B), fields by offset
(table §1.4).
- Host: `rt.mixer.buses.flange.set_presets(FlangePreset(a), FlangePreset(b))` wherever `env.presets` is set
(native.rs and e2e.rs).
2. **Per MixMap frame after the tick** (`native::mixmap_frame`, e2e loop):
`let r = Owner { mixmap: m, key: keys::REVERB }; buses.flange.frame([r.level(0), r.level(1), r.level(2), r.level(3)]);`
- Optional: `buses.env.scale_frame(r.level(4))`. Only with Reverb.in5 = 0: the hosts write 32767 today,
which gives out4 = 20603, i.e. −4 dB on every env send. The retail trace shows the scale at 0.9989. Who
writes Reverb.in5 is not known: decide before wiring (`flange::tests::the_reverb_owner_…`).
3. **Collision SubMix:**
- `library.rs` `CollisionJson::tuning` adds
`surface_eq: surface_table.iter().map(|r| std::array::from_fn(|k| r.get(12 + k).copied().unwrap_or(8))).collect()`
(the words are already exported).
- `PlayerAudio.collision.submix = true`.
4. **Zones:** `native::reverb_frame` calls `env.update(dt, region_key, &zones, Some(&camera))` instead of
`request` + `frame`.
- Zones come from `emitters.rs`: the type-5 nodes the per-frame query finds, with their d and position.
They are currently skipped there.
- Camera = the listener transform: position and forward (Bevy forward = −Z).
- New `AudioState` fields: none.
5. **No new Slots, banks or MixMap owner keys** beyond `keys::REVERB` (exists).
## 6. e2e expectations (integrator; `tools/audio-e2e/`, game test `e2e_render`)
- **Defaults** (returns disabled, `submix = false`, `request` / `frame` kept): every scenario bit-identical to
the baseline.
- **Flange on:**
- rail / ledge grind scenarios gain a centre-channel −26 dB (re the grind voices' dry level) flanged copy,
plus its env send (0.0706 × that);
- power-slide / skid scenarios −25 dB;
- rolling, ollie, manual, bail: unchanged (no effect records). Folded level change < 0.1 dB RMS, comb ripple
±0.4 dB.
- **Submix on:**
- every collision one-shot (grind-start rail hits, deck impacts, landings on landing-flag surfaces) moves
to the centre;
- level +0 … +3 dB depending on its azimuth (the e2e camera sits behind the skater, so most are near the
centre: small);
- a new reverb tail at ≈ −20 dB (Collision outputs 2–11 / 32767) into the env network.
- **scale_frame with in5 = 0:** wet path −0.01 dB everywhere. With in5 = 32767: −4 dB (wrong, see §5.2).
- **update instead of request / frame:** with no map (reverb01) identical. With a region preset ≠ reverb01 the
start fades in over 1 s instead of applying at once, and fades start one frame later.
## 7. UNCERTAIN
1. FlangeSub Pn21 azimuth (mgr+124 / +160 never written; zeroed allocation → 0 = centre).
2. The three unread FlangeSub record fields (+0, +12, +32: Q / gain / feedback by their values; no code posts
them).
3. When retail processes a return. Evidence: A's modules log only in bursts. Ours: blocks with a sending voice;
retail may keep a tail.
4. (Closed 2026-10-02) Reverb.in0..6: `sub_824DF468` (first step of `sub_824DE548`) writes all seven 0, then 32767 into
the input of the target side's preset number (`sub_824DF390`, table 0x824DF3D0: 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). Applied with `scale_frame`.
5. The Collision SubMix create byte (message +41; irrelevant while every AudioSurfaceMap word 12–16 is 8).
6. vfunc 28 of SFXObj_Reverb (no zone in mode 2 / no camera). (vfunc92 of the zone query: read 2026-10-02, §4.5.)
7. Reverb-zone node record layout (+8 / +16 = x / z assumed from the `.ems` record layout).
8. Flips / hom_slo_mo Send B levels (their components are not ported; retail flips showed level 0 in
all_163809).
@@ -0,0 +1,385 @@
# Skate 3 environment (reverb) bus: behavioural spec for the native port
Written 2026-10-02 (headless RE of TU3 + clean recomp traces). Companion to `aems-voice-graph-spec.md`
(§3 Send A, §4.8 Send, §6 buses), `mixmap-spec.md` §6.3 (emitter send word out8), `ems-emitters-re.md` (reverb
zones, region layers). Behaviour, formulas and constants in our own words. The recomp's generated code, PR #4 / the
PoC and b5-decomp were read for behaviour only. Addresses are TU3 3.0.3.0. Tags as in the voice-graph spec:
**[R]** our reading of the recomp asm, **[IMG]** image constant, **[TR]** confirmed in a clean recomp trace
(`dsp_20261002_185826`, `all_20261002_163809`), **[I]** inference, **UNCERTAIN** = do not treat as settled.
Throwaway local scripts (`owners.py` groups trace modules per graph, `senda_bank.py` joins voice
Send A levels to banks, `presets.py` dumps the 24 reverb presets, `strs.py` reads image strings). Asm dumps kept locally too.
---------------------------------------------------------------------------------------------------------
## 0. Answer in one paragraph
`[[0x830CFDEC]+52]` is the input Sub0 of a **mono "EnvSendSub" bus** built by the bus manager constructor
`sub_8248EEE8`. It is never zero after audio init, so **every standard voice has Send A** (confirmed in traces:
108–1920 voice graphs per session carry `… LPF SEND1 GAIN …`). The env bus feeds a fixed 15-graph network: two
parallel "sides" (A/B, for crossfading presets), each = EQ stage → one **ReverbModel1** (Schroeder: 6 combs + allpass)
with a pre-delay, panned to the left and right surrounds-ish (270° / 90°), plus two **feedback echo taps**
(0.04–0.3 s, LFO-wobbled delay and pan) — all summed into the 6-ch "SFX Master" bus. Parameters come from 24 vault
presets (`aud_reverb/reverbNN`, class `204CAC1FD77088B8`), chosen by the world-painter `audio_reverb` region layer
(default `reverb01`) or a type-5 reverb-zone emitter, crossfaded by moving the env send between side A and B. A
voice's env send level = VOL × FXWET0 (property 5). For emitters FXWET0 is the MixMap send word (out8 = −2600 mB +
distance roll-off B0), so a nearby emitter carries a wet tail/echo and a far one loses it. The user-facing symptom
("ambience/cars don't sound distant") is consistent with the port having no env bus at all.
---------------------------------------------------------------------------------------------------------
## 1. Construction (who builds what) [R]
Audio boot `sub_826DD3A8` allocates the bus manager (1156 bytes) into global **0x830CFDEC**, sets
manager+104 = **1.0** (global env-send scale), +388/+392 = 0.0, and calls in order:
1. `sub_8248EEE8` — the **environment network** (this spec);
2. `sub_82490270` — the two **"FlangeSub" effect returns** (§7);
3. `sub_824907D8` — fills 14 **LFO records** and registers the per-block **"LFO Task"** `sub_82490B60` (§5);
4. `sub_82490CA0` — the material buses (not part of this spec).
Graph creation is `sub_82B48C48(system, order, module_count, module_list)`; a module-list entry is
{params, descriptor, channel_count}. Module descriptors are looked up by FourCC: Sub0, Sen0, Gai0, PI20 (PeakingIir2),
Del0 (Delay), RM10 (ReverbModel1), HI20/LI20 (HighPass/LowPassIir2), Pn21 (Pan2D1). Bus names are image strings at
0x8224DC24.. (`EnvSendSub`, `EnvSub`, `RvrbSub`, `rvrbfiltsub`, `rvrbdelaysub`, `FlangeSub`).
| graph (name) | order | ch | modules (index: module) | manager slot (graph / module array) | count |
|---|---|---|---|---|---|
| **EnvSendSub** | 199 | 1 | 0 Sub0 · 1 Sen0 · 2 Sen0 | +0 / **+52** | 1 |
| **EnvSub** (side A, B) | 200 | 1 | 0 Sub0 · 1 Gai0 · 2 PI20 · 3 Sen0 · 4 Sen0 · 5 Sen0 | +4/+56 (A), +8/+60 (B) | 2 |
| **RvrbSub** (A, B) | 201 | 1 | 0 Sub0 · 1 Del0 (max 0.16 s) · 2 RM10 · 3 Gai0 · 4 Sen0 · 5 Sen0 | +12/+64, +16/+68 | 2 |
| **rvrbfiltsub** ×4 | 202 | 1 → 6 | 0 Sub0 · 1 HI20 · 2 LI20 · 3 Gai0 · 4 Pn21 (6 out) · 5 Sen0 (6 ch) | +20/+72, +24/+76 (A), +28/+80, +32/+84 (B) | 4 |
| **rvrbdelaysub** ×4 (echo taps) | 201 | 1 → 6 | 0 Sub0 · 1 Del0 (max 0.46 s) · 2 HI20 · 3 LI20 · 4 Gai0 · 5 Pn21 (6 out) · 6 Sen0 (6 ch) | +36/+88, +40/+92 (A), +44/+96, +48/+100 (B) | 4 |
Del0 max-delay constructor values: 0.16 s at 0x82098E40, 0.46 s at 0x8208ECE0 [IMG]. RM10 gets no constructor
parameter, so its max space size is the descriptor default 80 m [IMG 0x82FD2060 = 80.0 double].
**Links** (Sen0 configure id 0 = "send to bus"):
```
EnvSendSub.1 → EnvSub A EnvSendSub.2 → EnvSub B
EnvSub A.3 → RvrbSub A EnvSub A.4 → rvrbdelaysub A1 (+88) EnvSub A.5 → rvrbdelaysub A2 (+92)
EnvSub B.3 → RvrbSub B EnvSub B.4 → rvrbdelaysub B1 (+96) EnvSub B.5 → rvrbdelaysub B2 (+100)
RvrbSub A.4 → rvrbfiltsub A1 (+72) RvrbSub A.5 → rvrbfiltsub A2 (+76)
RvrbSub B.4 → rvrbfiltsub B1 (+80) RvrbSub B.5 → rvrbfiltsub B2 (+84)
every rvrbfiltsub.5 and rvrbdelaysub.6 (6 ch) → default bus [[0x830CFDBC]+44] = "SFX Master"
```
Pass order: 199 < 200 < 201 < 202 < 254 (SFX Master), so the whole chain resolves inside one 256-frame block
(voices order 0 → … → SFX Master 254 → mastering 255). No extra block latency per hop [I, voice-graph spec §1].
**Other contributors to the env bus** [R] (all send their Sen0 to `[[manager+52]]`): every standard voice (Send A,
§4); both FlangeSub returns (§7); the board grain chain graph 2 (`sub_824C8878`, level vfunc60(13)/32767);
"Splicer Wet SubM" (`sub_82488DD0`, order 4, 6 ch: Sub0 → Sen0 env → Sen0); "FootStep SubMix" (`sub_82494188`);
"Collision SubMix" (`sub_824D25E0`); `sub_82498248` (order 1, 2 ch, env level initially 0.0); `sub_824CE108`.
Their runtime send levels are **UNCERTAIN** (not traced per graph here; the generic SEND hook sees them).
**Trace confirmation** [TR, `dsp_20261002_185826`]: graph 0x40C28020 = EnvSendSub (`SEND1 SEND1`, levels sweeping
0..1); 0x40C18420 / 0x40C18820 = EnvSub A/B (`GAIN PEAK SEND1 SEND1 SEND1`); 0x40C30020 / 0x40C30820 = RvrbSub
(`GAIN SEND1 SEND1`, gain 1.0–2.0); 0x40C31020 / 0x40C31820 / 0x40C38020 / 0x40C386E0 … = rvrbfiltsub /
rvrbdelaysub (`HPF LPF GAIN SEND6`); 0x40C40020 / 0x40C40770 = FlangeSub (`PEAK SEND1 GAIN SEND6`). Del0, RM10 and
Pn21 are not hooked.
---------------------------------------------------------------------------------------------------------
## 2. Signal flow of one side (what to implement)
```
voice Send A (N ch → 1 ch: 1→1 copy, 2→1 L+R, 4→1 sum, 6→1 L+C+R+Ls+Rs; all unity, LFE dropped)
+ other env contributors
│ EnvSendSub (mono)
├─ Sen0 level = wA (side A blend weight) ─┐
└─ Sen0 level = wB ───────────────────────┤ (the other side is identical)
▼
EnvSub: Gai0 g_in → PI20 (f_eq, gain_eq, Q_eq; f_eq LFO-swept)
├─ Sen0 × (s_rev · 1.0) → RvrbSub: Del0 (predelay, fb) → RM10 (T60, size, brightness) → Gai0 g_rev
│ ├─ Sen0 1.0 → rvrbfiltsub L: HPF → LPF → Gai0 → Pn21 az 270°, D 1, LFE 0.5 → SFX Master
│ └─ Sen0 1.0 → rvrbfiltsub R: HPF → LPF → Gai0 → Pn21 az 90°, D 1, LFE 0.5 → SFX Master
├─ Sen0 × (s_tap1 · 1.0) → rvrbdelaysub 1: Del0 (d1, fb1; d1 LFO) → HPF → LPF → Gai0 → Pn21 (az LFO about 270°) → SFX Master
└─ Sen0 × (s_tap2 · 1.0) → rvrbdelaysub 2: Del0 (d2, fb2; d2 LFO) → HPF → LPF → Gai0 → Pn21 (az LFO about 90°) → SFX Master
```
- "· 1.0" = manager+104, a global scale written 1.0 by the constructor; no other writer found among the manager's
users (UNCERTAIN whether an option changes it).
- Route rows for the mono send are from the image route tables 0x820ED700 (ranges) / 0x820ED780 (route bytes) [IMG]:
2→1 = routes {0→0, 1→0} gain 1.0; 6→1 = sources 0..4 → 0 at 1.0 (LFE not routed).
- The Pn21 instances are 1-in/6-out bus panners: angle in degrees, positive = right (voice-graph spec §4.9), so
**270° = −90° = hard left side (between L and Ls), 90° = right side**. Pan2D1 parameter 1 (distance D) = 1.0 (on the
speaker circle), parameter 6 (LFE send) = **0.5** (written raw: the reverb/echo outputs feed the LFE at 0.5).
- Sen0 levels on RvrbSub (→ filt) and the final Sen0s are never posted → constructor default 1.0 [TR: SEND 1.0].
- PI20 on EnvSub: bypassed whenever its gain is exactly 1.0 (voice-graph spec §4.10), which is the case for many
presets (e.g. reverb01 gain_eq = 1.0).
---------------------------------------------------------------------------------------------------------
## 3. Parameter application — `sub_8248DD18(manager, block)` [R]
`block` is a 212-byte record (copied to manager+868, flag manager+1080 = 1). Offsets (bytes), the module/parameter
each goes to (side s = block+0: 0 = A, 1 = B), and the preset-record field it comes from (`sub_824DE140`, record =
the vault preset, §6). Parameter ids are the module's attribute index.
| block | target | from preset record (offset, hash) |
|---|---|---|
| +0 | side s (int) | — |
| +4 | EnvSendSub Sen0 (1 + s) level = side blend weight | 0 at preset load, then the crossfade (§6.3) |
| +8 | EnvSub Gai0 p0 (g_in) | +84 `C76E3B36EC05F9E0` |
| +12 | EnvSub PI20 p0 (f_eq, Hz) — then LFO-driven (§5) | +80 `2DA3FDBA90F64924` |
| +16 | PI20 p1 gain_eq: posted only if 0.1 ≤ v ≤ 20 [IMG 0x820641A8, 0x820996EC], else **0.1** | +76 `DECBDDB58AC61519` |
| +20 | PI20 p2 Q_eq: posted if 0.2 ≤ v ≤ 20 [IMG 0x82099280], else **0.2** | +64 `2EBE3CBF5E339625` |
| +24 | EQ LFO depth (fraction) | +72 `8249454FFE32EA2B` |
| +28 | EQ LFO rate Hz: period = 1/rate if rate ≥ 0.1, else period **10 s** [IMG 0x821963E4] | +68 `533ED00D3783EF02` |
| +32 | EnvSub Sen0 3 (→ reverb) level × manager+104 | +52 `578AB8D6BF494236` |
| +36 | EnvSub Sen0 4 (→ tap 1) level × manager+104 | +60 `B3BB9F5129BBCFB5` |
| +40 | EnvSub Sen0 5 (→ tap 2) level × manager+104 | +56 `C8A4239F7CBBF072` |
| +44 | RvrbSub Del0 p0 pre-delay s | +12 `66588E403F254F64` |
| +48 | RvrbSub Del0 p1 feedback | +16 `8968075E7CBACC4E` |
| +52 | RM10 p0 reverb time s | +20 `FA81EA055905EB17` |
| +56 | RM10 p1 space size m | +24 `2490987C531FB4E9` |
| +60 | RM10 p2 brightness | +32 `1812369BC788F71F` |
| +64 | RvrbSub Gai0 p0 (g_rev) | +28 `5075CFC3C70ACB30` |
| +68 / +72 / +76 | filt L: LI20 Hz / HI20 Hz / Gai0 | +36 `D548E9…` / +40 `4C50D4…` / +44 `4ABF29…` |
| +80 / +84 / +88 | filt L Pn21 p0 / p1 / p6 | constants **270.0** [IMG 0x82063B20] / 1.0 / 0.5 |
| +92 / +96 / +100 | filt R: LI20 / HI20 / Gai0 | +0 `48376C…` / +4 `4E7CDD…` / +8 `22FB42…` |
| +104 / +108 / +112 | filt R Pn21 p0 / p1 / p6 | **90.0** [IMG 0x82256FD8] / 1.0 / 0.5 |
| +116 / +120 | tap 1 Del0 p0 delay s / p1 feedback | +168 `B388DA…` / +172 `EECADF…` |
| +124 / +128 | tap 1 delay-LFO depth / rate Hz (rate < 0.01 [IMG 0x820D71E8] → period 10 s) | +156 `7B2416…` / +152 `843273…` |
| +132 / +136 / +140 | tap 1 LI20 / HI20 / Gai0 | +148 `7492F9…` / +160 `D6812F…` / +164 `796D83…` |
| +144 / +148 / +152 | tap 1 Pn21 p0 / p1 / p6 | 270 / 1.0 / 0.5 |
| +156 / +160 | tap 1 pan-LFO depth / rate | +92 `30DCBF…` / +88 `27D289…` |
| +164 / +168 | tap 2 Del0 delay / feedback | +128 `433B43…` / +132 `FC5073…` |
| +172 / +176 | tap 2 delay-LFO depth / rate | +116 `024737…` / +112 `EDA354…` |
| +180 / +184 / +188 | tap 2 LI20 / HI20 / Gai0 | +108 `3E80A0…` / +120 `E9E26E…` / +124 `81504B…` |
| +192 / +196 / +200 | tap 2 Pn21 p0 / p1 / p6 | 90 / 1.0 / 0.5 |
| +204 / +208 | tap 2 pan-LFO depth / rate | +92 / +88 (same fields as tap 1) |
Preset fields never read by this path: +48 (int index = the NN of reverbNN), +96, +100, +104 (270.0), +136, +140,
+144 (90.0) — the authored pan constants are ignored in favour of the code constants (same values).
A lighter per-frame re-post `sub_8248D770` (called through `sub_824DE400`) sends only: the side weight, f_eq,
filt L/R pans (+ rotated, §6.3) and gains, tap 1/2 delays and the fixed tap pans 270/90.
---------------------------------------------------------------------------------------------------------
## 4. Voice side (FXWET0 → Send A) [R][TR]
- Voice open (`sub_824A3140`): if `[[manager+52]] ≠ 0` (always, after init) a Send A module is inserted before Gain
and linked to the EnvSendSub Sub0; level posted 0 at open; then level = VOL/32767 × FXWET0/32767 on every change of
property 2 or 5 (voice-graph spec §3.2).
- **Retail evidence** (`all_20261002_163809`, `senda_bank.py`): non-zero Send A on player sounds, footsteps,
collisions, foley, rolling, wheels, grinds and on world banks (fountains_waterlaps_left, helicopter_1, Jet_Passby_1,
bus_by_3, truck_horn_city_2). Typical levels: median 0.011–0.027, max ≈ 0.07 (−39 … −23 dBFS re 1.0), i.e. the
wet path sits ≈ 10–20 dB under the dry (send/gain ratio median 0.17–0.5).
- **Emitters**: `c_emitter` payload w2 → property 5 (PoC probe, `ems-emitters-re.md`); the game writes w2 = MixMap
out8 × emitter level (positional: −2600 mB + C1 + B0 dB, B0 = camera distance 4 → 70 m, 1−sin) or out7 (−2100 +
C1, non-positional). So near an emitter the env send is ≈ −26 dB × shape level, falling to silence by 70 m;
the dry word (out4, −600 mB) has no distance roll-off.
- **Ambience bed** (`SnP1 → Rch0 → Rsp0 → Gai0 → LI20 → Sen0`): no Send A → **the zone bed is never reverberated**.
`c_main_ambience_crossfade` voices are ordinary AEMS voices (Send A exists); their w2 is not written by
`sub_824D0F38` (w1, w4, w5, w9, w0/w8 only) → send 0 unless the bank program sets it (UNCERTAIN, check the bank).
- Player components' "first send" levels: grain chain graph 2's env Sen0 (vfunc60(13)) and the owner one-shot bus
feed this same env bus (§1 contributors). The 4096/8192/16384 routing records feed the FlangeSub returns (§7),
whose output also re-enters the env bus.
---------------------------------------------------------------------------------------------------------
## 5. The LFO task — `sub_82490B60` [R][TR]
Registered by `sub_824907D8` (`sub_82481BE0`, task list 0, name "LFO Task"); called **once per mixer block with
dt = 256/48000 s** [TR: the EnvSub PI20 frequency moves 0.777 Hz per block near the sweep centre for reverb02
(1161 Hz, depth 0.2, rate 0.1 Hz) = 232 Hz × 2π × 0.1 × 5.333 ms]. It walks **14 records** of 32 bytes at
manager+396 (+32 each):
| +0 | +4 | +8 | +12 | +16 | +20 | +24 | +28 |
|---|---|---|---|---|---|---|---|
| period s | phase s | amount | centre | depth | last output | module | parameter id |
Per block: `phase = fmod(phase + dt, period)` (truncating), `out = centre + depth × amount × sin(2π · phase / period)`
(2π = 6.2831855 [IMG 0x820B411C]); posted to the module parameter every block, even when unchanged.
| rec | module (param) | set by preset (§3) | default (`sub_824907D8`) |
|---|---|---|---|
| 1 / 6 | EnvSub A / B PI20 (p0 f_eq) | period 1/rate_eq, amount depth_eq, centre = depth = f_eq, phase 0 | period 1, amount 0.5, centre = depth = 1.0 |
| 2 / 7 | tap 1 Del0 A / B (p0 delay) | period 1/rate_d1, amount depth_d1, centre = depth = d1 | centre = depth = 0.3 |
| 3 / 8 | tap 2 Del0 (p0) | period 1/rate_d2, amount depth_d2, centre = depth = d2 | 0.3 |
| 4 / 9 | tap 1 Pn21 (p0 azimuth) | period 1/rate_pan, amount depth_pan, centre 270, depth 90, phase 0 | 0.3 / 0.3 (pre-preset) |
| 5 / 10 | tap 2 Pn21 (p0) | period 1/rate_pan, **phase = period/2** (opposite sweep), amount depth_pan, centre 90, depth 90 | 0.3 / 0.3 |
| 11, 13 | FlangeSub A / B Del0 (p0) | flange preset (§7) | centre = depth = 0.006 s |
| 12, 14 | FlangeSub A / B PI20 (p0) | flange preset | centre = depth = 1.0 |
So, effectively:
- f_eq(t) = f_eq · (1 + depth_eq · sin(2π·rate_eq·t));
- d1(t) = d1 · (1 + depth_d1 · sin(2π·rate_d1·t)) — the echo taps' delay times wobble in whole-sample steps, each
step a 128-sample tap crossfade (§8.3), e.g. reverb01 tap 1: 0.28 s ± 50 % at 0.51 Hz;
- tap 1 azimuth = 270 + 90·depth_pan·sin(2π·rate_pan·t), tap 2 = 90 + 90·depth_pan·sin(… + π): the two echoes
swing around the left/right sides in opposite directions.
- Record values are rewritten whenever a preset is applied to that side (phase reset to 0 except recs 5/10).
- Because the task posts every block, any direct post of those parameters (e.g. +144/+192 pans) is overwritten
within one block.
---------------------------------------------------------------------------------------------------------
## 6. Presets and zone selection
### 6.1 Presets (vault class `204CAC1FD77088B8`, 24 collections) [R][IMG]
Collection key = lookup8 hash of `reverbNN`; field +48 holds NN (1..24). Lookup `sub_824DDC10`. Full table:
`presets.py`, a local script (data stays local). Key columns (reverb01 = **default**, key
`A2782D75A971CC8C`):
| preset | T60 s | size m | bright | pre-dly s | g_in | s_rev | s_tap1/2 | taps d1/d2 s (fb) | tap gain | filt L/R LPF–HPF | EQ f/gain/Q |
|---|---|---|---|---|---|---|---|---|---|---|---|
| reverb01 | 1.5 | 70 | 0.7 | 0.08 | 0.5 | 0.25 | 1 / 1 | 0.28 (0.25) / 0.30 (0.30) | 0.75 / 0.75 | 4000–500 / 4000–600 | 1161 / 1.0 / 3 |
| reverb02 | 1.04 | 8.3 | 1 | 0.08 | 0.75 | 1 | 1 / 1 | 0.17 / 0.18 (0.2) | 1 / 1 | 10000–0 | 1161 / 0.1 / 20 |
| reverb03 | 2 | 23.9 | 0.5 | 0.1 | 0.5 | 1 | 0.5 / 0.5 | 0.23 / 0.22 | 1.5 | 6000–774 / 6000–700 | 2000 / 0.1 / 3 |
| reverb04 | 1 | 45 | 0.5 | 0.15 | 0.5 | 0.5 | 1 / 1 | 0.15 (0.2) / 0.151 (0.22) | 1.5 | 8000–400 | 1935 / 0.1 / 10 |
(others in the dump; region coverage: reverb01 Downtown 23 / Industrial 15 / University 128 tiles, reverb03
Industrial 165, reverb04 Downtown 74, reverb16 Downtown 69, …; parks have their own.)
### 6.2 Which preset (SFXObj_Reverb update `sub_824DE548`, per game frame, dt in f1) [R]
State: +400 current key, +408 target key, +416 current side, +420 target side, +424 zone side, +428/+432 zone
emitter nodes, side records at +28 + 184·side (record pointer +32, blend weight +48, mode +128, timer +204, fade
length +208).
1. If a transition is in progress (+400 ≠ +408), run its mode handler: 0 → timed fade `sub_824DE850`;
1 → zone fade `sub_824DE970`; 2 → zone-to-zone `sub_824DEAE8`; ≥ 3 → finish.
2. Otherwise query the reverb-zone emitters (`sub_82488278`: first active `.ems` node whose attribute type = 5):
- **none**: key = region record `*(0x83083C38)+0x2F0B0+16` (world-painter `audio_reverb` layer at the focused
skater's x,z, `ems-emitters-re.md`), or `A2782D75A971CC8C` (reverb01) when that is 0; if it differs from the
current key, start a **timed fade (mode 0)**.
- **a zone** (first time): its attribute record's preset key → start a **zone fade (mode 1)**.
- a second overlapping zone with a different preset → mode 2.
3. Starting a transition (`sub_824DE468`): target side = 1 − current side; load the preset onto that side with
weight 0 (`sub_824DE140` → full `sub_8248DD18` post); timer = 0, fade length = **1.0 s** [IMG 0x8231A844].
### 6.3 Crossfades [R][TR]
- **Timed (region change)**: timer += frame dt; x = min(timer / 1.0, 1); new side weight = x, old side = 1 − x
(linear), re-posted every frame through `sub_8248D770`. At x = 1 the new side becomes current. [TR: EnvSendSub
levels step ≈ 0.0167 per 16.7 ms frame, 0.0055 → 0.28 in 268 ms.]
- **Zone (mode 1)**: w = the zone emitter's normalised distance d (node +12, 0 at the inner core, 1 at the edge,
`ems-emitters-re.md` "Inner core s0"); zone side weight = 1 − w, other side weight = w (linear in d, not the
emitter's level curve). While blending, the reverb outputs' pans are rotated toward the zone (`sub_824DEEF0`):
with θ = horizontal angle from the camera forward to the zone centre (degrees, 0..360; camera from
`*(0x83083C38)+0x2F078` +48 pos / +64 forward; exact angle function `sub_82453298` UNCERTAIN), the zone side's
filt L azimuth = 270 + w·(θ' − 270) and filt R = 90 − w·(90 − θ''), wrapped into 0..360 (θ', θ'' = θ unwrapped
towards each side). At the zone centre (w = 0) the reverb is the normal left/right pair; toward the edge both
outputs collapse onto the zone's direction. The other side keeps 270/90. When d is exactly 0 the zone side becomes
current (weights 1/0, pans 270/90); when the zone is no longer found, `sub_824DEDD8` snaps the weights to one
side (1/0) and clears the zone nodes.
- Mode 2 (two overlapping zones): same blend between the two zone presets using the second zone's d. UNCERTAIN in
detail (read `sub_824DEAE8` again before porting).
- The first preset is applied ≈ 5.5 s after the env network is built [TR 6119 ms]; before that both sides hold
constructor defaults with EnvSendSub sends at 1.0 (but no voices play yet).
---------------------------------------------------------------------------------------------------------
## 7. FlangeSub effect returns (`sub_82490270`) [R][TR]
Two graphs, order 150, 1 ch: `Sub0 → Del0 (max 0.015 s) → PI20 → Sen0 (→ env bus) → Gai0 → Pn21 (6 out) → Sen0
(→ SFX Master)`; manager +108/+116 (A) and +112/+120 (B). These are the targets of the voice routing records 4096 /
8192 (A, `[[manager+116]]`) and 16384 (B, `[[manager+120]]`) (voice-graph spec §3.3). The Del0 delay is LFO-swept
around 6 ms and the PI20 frequency is swept (LFO recs 11–14) → a **flanger**, not a slap-back. Its first Sen0 sends
the flanged signal into the env bus. Preset: `sub_824DDF58` reads two vault records (class lookup on key
`4CDD7CDC1A955D5C`; type 12288 → A, 16384 → B) through `sub_8248FE68` (LFO templates + levels at manager+124..192),
posted by `sub_8248FFF8` (Pn21/Sen0/Gai0 of each return). [TR: 0x40C40770 PEAK sweeping 1.03 … 1800 Hz, gain 1.0
(→ bypassed), Q 3; env send 0.07–1.0.] Field meanings beyond this are **UNCERTAIN**.
---------------------------------------------------------------------------------------------------------
## 8. DSP modules new to the port
### 8.1 Delay (Del0) — attributes from the image help strings [IMG 0x820F6348..]
- Constructor CONSTRUCTORPARAM_MAXDELAY (s): buffer size; larger requests reallocate.
- p0 ATTRIBUTE_SETDELAYTIME (s), "a delay of 0 turns the plug-in off".
- p1 ATTRIBUTE_SETFEEDBACK, range −0.99 … 0.99.
- "Dynamically changing the delay time and feedback amount by smoothly cross fading from one set of parameters to
another … over a 128 sample period."
- DSP details: see §8.3 (filled from the asm).
### 8.2 ReverbModel1 (RM10) — attributes [IMG 0x82116294..]
- Constructor CONSTRUCTORPARAM_MAXSPACESIZE (default 80 m).
- p0 ATTRIBUTE_SETREVERBTIME (s): T60; **0 = off and the module outputs nothing**; non-zero values below 0.366 s are
clamped to 0.366.
- p1 ATTRIBUTE_SETSPACESIZE (m): average distance between opposite reflecting surfaces.
- p2 ATTRIBUTE_SETBRIGHTNESS: scales the cut-off of the low-pass in each comb feedback loop; 1 = the model default.
- Structure (help text): mono in, **6 parallel comb filters followed by series all-pass filter(s)**; per output
channel slightly different all-pass parameters for spatialisation; the LFE is not fed. Object size 1104 bytes
(`sub_82B2EC00`), constructor `sub_82B2EC08`.
### 8.3 DSP details from the asm [R] (2026-10-02; local asm dumps `rm/`, `dl/`, `del/`;
model script `rm_model.py`)
**Delay (Del0)** — GetSize 0x82B21F80 (192 B), CreateInstance `sub_82B22008`, **Process `sub_82B222D8`**, timer
`sub_82B22228` (same task scheduler as the LFO task), kernels plain `sub_82B3CF58` / crossfade `sub_82B3D0A8`.
- +52 p0 delay s, +60 p1 feedback, +72 max delay (ctor), line at +100 sized round(max·48000); the timer grows it if
p0 exceeds max.
- D = round-half-away(p0 · 48000) — **whole samples, no fractional read**. Feedback: |p1| > 0.99 → ±0.99
(0x820ED5E8 / 0x822F8E60); NaN passes.
- State +64: 0 = **bypass** (module skipped, signal passes undelayed); on D > 0: reset filter + positions, set fb,
→ state 1. State 1: D ≤ 0 → state 0; buffer filled < D → state 2 (keep old D until filled); D changed → crossfade.
- Plain kernel (wet only): w[n] = x[n] + fb·w[n−D]; y[n] = w[n−D].
- **Crossfade on a D change**: 128 samples, r = 127/128 … 0 (step 1/128, 0x820300D4), finished within the block it
starts in: y = (1−r)·t_new + r·t_old; w = x + (1−r)·fb_new·t_new + r·fb_old·t_old. A feedback change outside a
crossfade applies instantly.
- Under the LFO (§5) every block whose rounded D changes starts one 128-sample tap crossfade → stepped integer taps
(slight phasing/zipper), **not a Doppler pitch glide**.
**ReverbModel1 (RM10)** — GetSize 0x82B2EC00 (1104 B), CreateInstance `sub_82B2EC08`, ctor `sub_82B2E788`,
**Process `sub_82B2F8C0`**, configure `sub_82B2F590`, distances `sub_82B2FE00`, delays `sub_82B2FEA8`, G1
`sub_82B2F2C8`, allpass setup `sub_82B2FF88`, apply `sub_82B2F150`, timer `sub_82B2F038`; comb kernel
`sub_82B399D0` (the "one-pole stage" of the voice-graph spec), allpass kernel `sub_82B389A0`.
- **Distances** (on size change): S = clamp(p1, 2.0, 83.3) (written back); near = 0.8·S, far = 1.5·near; if far >
100 → far 100, near 66.666664; six distances = near, near + k·0.2·(far − near) for k = 1..4, far.
- **Delays**: x = 48000·d·0.0029002321 (1/344.8 m/s, 0x822F87C8); each delay = the first prime in the table
0x821147D0 (1652 floats, 2…13999) strictly greater than x, the search continuing from the previous comb (six
distinct primes). reverb01 (70 m) → 7817, 8581, 9371, 10139, 10937, 11699; 8.3 m → 929 … 1399.
- **g1 (damping)** at 48 kHz: per comb, linear interpolation over distance knots 0, 12.5, …, 100 (0x821161B0) of
0.08·row25k + 0.92·row50k (rows 0x8211621C {0,.12,.23,.30,.35,.40,.43,.46,.50} and 0x82116264
{0,.31,.45,.53,.57,.61,.64,.67,.70}). **Brightness quirk**: only when p2 differs from the last applied
brightness (constructor 1.0): b = max(p2, g1[5] + 0.001); every g1 ×= 1/b; comb states zeroed. With unchanged
brightness g1 is not divided.
- **g2 (decay)**: T = max(p0, 0.366); g2ᵢ = (1 − g1ᵢ)·(1 − 0.366/T). Loop DC gain = g2/(1 − g1) = 1 − 0.366/T.
- **Comb i** (Moorer, lowpass in the loop): sᵢ[n] = x[n] + g2ᵢ·sᵢ[n−Dᵢ] + g1ᵢ·sᵢ[n−1];
outᵢ[n] = (sᵢ[n−Dᵢ] − g1ᵢ·sᵢ[n−Dᵢ−1]) / 6. Sum of the six.
- **Allpass** (mode 1 = mono, our case): one section g = 0.7, D = round(48000·0.006) = 288 (0x821161AC,
0x82116288): a[n] = c[n] − 0.7·a[n−288]; **y[n] = 2.0·(0.7·a[n] + a[n−288])** (mix gain 2/ch = 2.0).
Modes 2/4 use 0.63/320 + 0.7778/259 and copy across channels (not used here).
- **Output** y = 2·AP(Σ combs(x)) — wet only; RvrbSub's Gai0 follows.
- **p0 ≤ 0** → output zeroed (state 0). A parameter change sets state 1: the current block still runs the old network
(or silence if coming from state 0), configure runs at the end of Process, and the new gains/line sizes are applied
at the next **timer** tick (state 2 → 3 → 4). No crossfade on a reverb change.
- Comb lines start at size 0 (no max-space ctor parameter) and are resized at the first apply to D + 3.
**DSP UNCERTAIN**: comb tap alignment (sᵢ[n−D] vs [n−D−1]); the module timer period (list init arg 74), i.e. how
many blocks until a new preset is audible; whether resizes/resets clear line contents; Del0 first-enable output
before D samples are buffered; bit-exact f32 order of the vectorised comb/allpass kernels.
---------------------------------------------------------------------------------------------------------
## 9. Master path and output [R][TR]
- Voices' final Send (6 ch) and every env/echo output go to **"SFX Master"** = `[[0x830CFDBC]+44]`, built by
`sub_828DDF78`: order 254, 6 ch, `Sub0 → DCl0 → Gai0 → LI20 → HI20 → Sen0`. [TR graph 0x40C18020: Gain 1.0,
LPF 25000 (open), HPF 0 (off), unchanged in a 58 s session.] DCl0 level not traced (UNCERTAIN; bypass when ≥ 100).
- SFX Master's Sen0 → the **mastering graph** `[[system+8]+52]`, built by `sub_828DD9F0`: order 255, **8 ch**,
`Sub0 → Scp0 → Dac0` (Dac0 parameter 2 = 13521.86 [IMG 0x822787CC], a load/budget figure, not a gain). "Music
Master" and "Speech Master" (`sub_828DDC58` / `sub_828DDDE8`, order 200) are separate 3-module graphs.
This settles voice-graph spec §6.2's "8-plane master" inference.
- **0x8306705D** (output channel count) is written only by the Dac set-up `sub_82B20EF8`: **always 6** once the
output device is opened. The same function posts Dac parameter 0 = 3.0 / 5.0 / 1.0 depending on the console
speaker-config flags (bit 16 / bit 0 / neither) — a speaker-mode code, not a gain (UNCERTAIN meaning).
- No master gain was found on this path besides SFX Master's Gai0 (1.0).
---------------------------------------------------------------------------------------------------------
## 10. Implementation plan (native)
1. Build the 15 graphs of §1 with their orders, the Sub0 accumulators and the Sen0 links; the default bus becomes
SFX Master (6 ch, gain 1, LPF/HPF open) feeding an 8-ch mastering Sub0 → Dac (6 out).
2. Voices: always insert Send A (route N → 1 per §2), level = VOL·FXWET0 with the usual 64/65 ramp.
3. Modules: Delay (§8.1/8.3), ReverbModel1 (§8.2/8.3), PeakingIir2 (exists in spec), Pan2D1 as 1-in/6-out bus panner
with LFE parameter 0.5.
4. Preset engine: load the 24 presets from the vault at setup (asset pipeline exports the table; no game data in
git), region `audio_reverb` lookup (already have region tiles) with default reverb01, the 1 s linear side
crossfade, the zone blend from type-5 `.ems` nodes, and the 14-record per-block LFO.
5. Apply posts at the same cadence: presets on key change, weights per game frame, LFO per block.
6. Validation: per-graph parameter timelines against the trace (EnvSendSub weights, EnvSub GAIN/PEAK, RvrbSub GAIN,
filt/delay HPF/LPF/GAIN — §1 owners), then an impulse through the whole network at reverb01 compared with a
recomp capture of a single isolated click (short scripted run, if ever needed).
---------------------------------------------------------------------------------------------------------
## 11. UNCERTAIN / open
1. Runtime send levels of the non-voice env contributors (Splicer Wet, FootStep/Collision SubMix, `sub_82498248`,
grain graph 2) — hook their Sen0 posts.
2. Whether anything other than the constructor writes manager+104 (global env scale).
3. Mode-2 (zone-to-zone) blend details and the exact θ function of the zone pan rotation (`sub_82453298`).
4. FlangeSub preset fields and which banks route 4096/8192/16384.
5. SFX Master DCl0 level; Dac parameter 0 meaning (3/5/1 by speaker config).
6. `c_main_ambience_crossfade` w2 (send) — set by the bank program or 0.
7. See §8.3 for the DSP-level uncertainties.
@@ -0,0 +1,232 @@
# eEQChain (material) buses, EQ jitter, LFO task and owner send buses: behavioural spec
Written 2026-10-02 from the TU3 recomp's generated code (reference only, read for behaviour), the image dump and the
user's vault. Companion to `aems-voice-graph-spec.md` (§3.3 routing records, §4.10 bus modules, §6.2 buses), which
this supersedes for the material buses. Tags as there: [R] our reading of the lifted code, [IMG] image constant,
[VLT] vault value, [TR] matched against a retail trace, UNCERTAIN = not proven. Throwaway local scripts
(`validate.py`, `walk.py`, `walk_sim.py`, `hashes_before.py`, `callers.sh`, asm dumps).
Bus manager = `[0x830CFDEC]` (`mgr`). Mixer/runtime = `[0x830CFDBC]`; default bus input = `[[0x830CFDBC]+44][0]`.
Init order (`sub_826DD3A8`): `sub_8248EEE8` (env/reverb graphs, mgr+52..+100) → `sub_82490270` (effect returns,
mgr+108..+120) → `sub_824907D8` (LFO task) → `sub_82490CA0` (8 material buses).
## 1. The 8 material ("eEQChain") buses
### 1.1 Graph [R] `sub_82490CA0`
Per bus i = 0..7: graph order **253**, 5 modules, all 6 channels: `Sub0 → DCl0 → PI20 (#1) → PI20 (#2) → Sen0`.
Sen0 is linked with configure id 0x7FF7FFF4 ("send to bus") to the default bus input `[[0x830CFDBC]+44][0]`.
Storage: `mgr+1116+4i` = graph, `mgr+1084+4i` = module array (graph+80; [0]=Sub0, [4]=DCl0, [8]=PI20#1,
[12]=PI20#2, [16]=Sen0), byte `mgr+1148+i` = "created" flag (0 at build). Module class defaults until first post:
PI20 = 96000 Hz / gain 1.0 / Q 3.0 (= bypass, gain exactly 1) [TR], DCl0 bypass (level ≥ 100).
Retail graph addresses [TR]: bus i = `0x40C18C20 + 0x400·i` (proven for 0–2 by value grids, 5–7 by jitter values).
### 1.2 Resolver `sub_82491108(mgr, idx, create)` [R]
- idx 8 → the default bus input (no EQ).
- else, if `create` (u8) ≠ 0 → `sub_824916E8(mgr, idx)` (below), then return `[[mgr + 4·(idx+271)]]` = bus i's Sub0.
- Voice routing records (`sub_824A3140`): value 0–7 → create = 0; 10–17 → idx = value−10, create = 1.
- Player components call it with create = **owner byte `[[component+28]+72]`** (pops `sub_824B9CC8`, touchdowns
`sub_824B8D48`, manual landing `sub_824BB330`, landing `sub_824BA630` (inlined), `sub_824EC670/958`, …). Meaning of
owner+72 UNCERTAIN (probably "local/human player"). Wheels `sub_824CE108` pass literal 0; `sub_824F0AB8` literal 1.
### 1.3 Preset data [VLT, IMG]
Vault class `AA801D9FC0ADBBBF`. Collection key per bus from the image table **0x8224DC78** (8 × u64):
| bus | collection | enable `C6DA68C12A3822D2` | PI20#1 freq / gain / Q | PI20#2 freq / gain / Q | DCl0 clip `2086A0CE99C39A86` |
|---|---|---|---|---|---|
| 0 | 42DDFA3F5011BAB4 | 1 | 150..450 / 0.75..1.5 / 0.25..3.0 | 1500..5000 / 0.9..1.25 / 0.25..2.0 | 100 (default) → bypass |
| 1 | DA24A51DDC51EDF3 | 1 | 150..500 / 0.75..1.25 / 0.25..1.5 | 1000..7500 / 0.25..2.5 / 0.25..1.5 | 100 → bypass |
| 2 | 201C88714085E3C3 | 1 | 150..350 / 0.25..1.5 / 0.2..1.5 | 1500..6000 / 0.1..2.0 / 0.2..1.5 | 100 → bypass |
| 3 | 8AF23914D89E4AF0 | 1 | 100..2000 / **1.0..1.0** / 1.0..3.0 | 1500..8000 / **1.0..1.0** / 1.5..5.0 | **0.1** |
| 4 | F7CFA0A8D2F98BDE | 1 | 100..700 / 0.75..1.2 / 2.0..5.0 | 2000..5000 / 0.5..1.2 / 1.0..3.0 | **0.07** |
| 5 | 8AA93567C056736B | 0 (parent 5D76D8135CFA617B) | jitter (§2) | jitter (§2) | 100 (clamped 0..1000) |
| 6 | 87D5F21D9A71736A | 0 | jitter | jitter | 100 |
| 7 | BFA2D9D8C138F925 | 0 | jitter | jitter | 100 |
Field pairs (first-read "a", second-read "b"; value range written b..a above):
PI20#1 freq a `5B48CBEB3EB440D2` b `2B77D4F58AF71CCF`; gain a `7317CC02098BDFF6` b `90A94DDDA5F804CB`;
Q a `F30B7500D67E206B` b `B4190B5C42432FA2`. PI20#2 freq a `3264F689300A522A` b `1DA611DA4DE5A67A`;
gain a `54868EDE952EF581` b `25853859640A3796`; Q a `594D91B30B95483A` b `CDF599CDEC9C7C64`.
Records 1–4 inherit from `55578C11340FFDF1` (enable 1), 5–7 from `5D76D8135CFA617B` (enable 0); `default` record
values fill the gaps (clip 100, freq 10000/5000, …). Bus 3 has gain fixed at 1.0 → its two PI20 stay bypassed; it is
a hard clipper at ±0.1. The per-record RefSpec fields (`23E3…`, `3875…`, `53AC…`, `7570…`, `B713…`, `FBE8…`) are not
referenced by any code we found (the jitter keys are hard-coded, §2) — UNCERTAIN whether used at all.
### 1.4 Re-roll on creation — `sub_824916E8(mgr, i)` [R, TR]
Skip if i = 8 or created[i] ≠ 0. Set created[i] = 1. Look up record (class `AA801D9FC0ADBBBF`, key table[i]);
if its enable byte is 0 → done (no posts). Otherwise, under the mixer lock, post in this order:
1. PI20#1 param 0 = pick(freq), param 1 = pick(gain), param 2 = pick(Q);
2. PI20#2 param 0/1/2 = pick(freq/gain/Q);
3. DCl0 param 0 = clip field (no clamp).
`pick(a, b)` = `sub_82491DF0`: if a == b → b (no RNG draw). Else `k = r mod 11` (r = u32 from the title-wide
add-with-carry generator `sub_82A8AF10`, state 0x82FD7D74; same generator as the grain picker), and
**value = b + (k·(a − b))·0.1** in f32, product k·(a−b) rounded, then one fused multiply-add with 0.1f
(0x820641A8 = 0x3DCCCCCD). So every value sits on an 11-point grid between the two fields. Up to 6 draws per
creation, in the order above.
### 1.5 Clearing and the jitter post — `sub_82491180(mgr)` [R]
Called from the audio manager update `sub_82485190` (vtable 0x822FBEA0 +36) on every second game frame (frame
counter at +500 alternates two halves; when frame dt > 0.02 s (double 0x822F8DE8) both halves run every frame).
For each bus i = 0..7:
- if the record's enable byte is **0** (buses 5–7, or a missing record): post jitter values (§2) to both PI20
with clamps, and DCl0 = clamp(clip field, 0, 1000) (0x82165A10 = 0.0, 0x82256FE8 = 1000.0);
- freq = clamp(v, 0, 96000) (0x822F8920); gain = clamp(v, 0.1, 20) (0x820641A8, 0x820996EC);
Q = clamp(v, 0.2, 20) (0x82099280). The clamps are `fsel` max/min (NaN → upper/lower bound behaviour as fsel).
- **always**: created[i] = 0.
Net behaviour: buses 0–4 get a **fresh random preset at the first use after each clear**, i.e. at most once per
2 game frames per bus, and only when a voice/component resolves the bus with create ≠ 0. Buses 5–7 all receive the
**same** jitter values every second frame, whether or not anything is routed there.
## 2. EQ jitter table (class `0AB9F005A2C8FBC7`, "Sk8::Audio::eJitterParams")
### 2.1 Table [R] (`sub_824EEFB0` ctor, vtable 0x822FC248; load `sub_824EF0B8` = slot +28; update `sub_824EF378` = slot +36)
The loader stores itself at `[0x830CFDC4]+724`, loads every record of class `0AB9F005A2C8FBC7` (max 30, count at
+1232). Entry E = this + 32 + 40·n: +0 u64 key, +8 value, +12 velocity (0), +16 a2, +20 a3, +24 a0, +28 a1,
+32 u8 post flag (`8F956FBAD301AE26`), +36 i32 post id (`E7D491E2EB228F54`), from params `B66AAD957873A8B3` =
{a0, a1, a2, a3}. Initial value = a0. Lookup `sub_824EF458(table, key)` returns the value (0.0 if absent).
### 2.2 Step `sub_824EF4C8` [R]
```
n = (r mod 2001) − 1000 # r from sub_82A8AF10 (title generator), u32
s = (a2 − a3) · (n · 0.001) # 0.001 = 0x82063A48
acc = s ≥ 0 ? a3 + s : s − a3 # |acc| ∈ [a3, a2], sign of n (n = 0 → +a3)
v = clamp(v + acc, −a2, a2)
x = value + v ; hi = a0 + a1 ; lo = a0 − a1
if x > hi: v = v · −1 (0x8216DEE0 = −1.0); x = hi − (x − hi)
elif x < lo: v = −v; x = lo + (lo − x)
value = clamp(x, lo, hi)
```
A bounded random walk with random acceleration, reflecting at the range ends. The update (slot +36) steps every
entry, then for entries with post flag set posts trunc(clamp(value, 0, 32767)) with the entry's id to the object at
table+12 (vfunc 8) — the six flag-1 records (`FB7567C0…`, `5FBCB3B8…`, `02D9546B…`, `C20C6630…`, `6AEEEEB9…`,
`B5E641CC…`, ids 0–5) feed some other consumer, UNCERTAIN which. Caller/cadence of slot +36 not found (virtual):
UNCERTAIN; the retail trace fits "one step per game frame" (§4.3).
### 2.3 Keys used by the material buses (hard-coded in `sub_82491180`) [R, VLT]
| target | key | a0, a1, a2, a3 | range lo..hi | max step |
|---|---|---|---|---|
| PI20#1 freq | E17029CE4388E1E8 | 400, 300, 299, 199 | 100..700 Hz | 299 |
| PI20#1 gain | C27373E7FD2DCE47 | 1.25, 0.75, 0.75, 0.25 | 0.5..2.0 | 0.75 |
| PI20#1 Q | EA2ED27D25247927 | 3.0, 2.5, 2.4, 1.0 | 0.5..5.5 | 2.4 |
| PI20#2 freq | 816A58ECCCD4112D | 2000, 1500, 500, 175 | 500..3500 Hz | 500 |
| PI20#2 gain | 8802BC5475904597 | 1.75, 1.25, 1.0, 0.25 | 0.5..3.0 | 1.0 |
| PI20#2 Q | D4ED2F0BA77ACAB5 | 3.0, 2.75, 2.5, 0.5 | 0.25..5.75 | 2.5 |
## 3. eEQChain enum → bus, and who uses which [VLT, R]
Enum value = bus index 0–7; 8 = default bus (no EQ). Routing records add 10 to request creation (10–17).
Holder class `42AFE160E647167C` (default record), with the component that reads the field where confirmed:
| bus | fields (value) | confirmed users |
|---|---|---|
| 0 | E34B48082B5BF185 (pops, `sub_824B9CC8`), 9326AEAC6E053997 (touchdowns `sub_824B8D48`, manual landing `sub_824BB330`, via `sub_824B7AF0`), 4B6E2D79A8452D9B (cloth), 22D0D4A5A14FFF7D, 985A1A2890A77F85 (`sub_824EFA60`), AEA5BA7E64515945 (`sub_824DBBB8` ×2), B2C4F86A53963BA2, D1A87641CCB98787 (`sub_824C3FC8`, `sub_824C4138`) | retail bus 0 re-rolls 228× in all_163809 |
| 1 | 8B0E030799CBDD00 (landing `sub_824BA630`), 0D73DB849EC8E2B2 (shoe scuffs `sub_824B97A8`, `sub_824C0DA8`), F61F2797B24868D9 (foot taps `sub_824B9268`, `sub_824C07D8`), 2F880F059AB9B151 (`sub_824F0718`), 428FDFC2E99FFA65 (`sub_824F05D0`), 55DA01533B378EFA (`sub_824C01E8`), 660885AD6F8F9AF4 (`sub_824EF8C8`), 748CBC9727A5347F (`sub_824BB8D8`, `sub_824BBA00`) | 207 re-rolls |
| 2 | C014A21D0FF6EDBA (footsteps, routing word +10 → created), ED52262DABB5DE4C (`sub_824B8310`, `sub_824B8448`), A9023782094771B5, CCDFABBAEEA33EAE | 2013 re-rolls |
| 3 | — (no field has 3) | never posted in either session |
| 4 | — | never posted |
| 5 | D489344CEDEE5036 (grind), F52450E504250254 (skid), E633C8F009CAEFFC | jitter |
| 6 | 55E6488906A2E330 (wheels; `sub_824CE108` create = 0), D9BE1F2F1A72FEE8 (flips) | jitter |
| 7 | 4D30A6CA8C9E1ABC (rattle), C1DC8556BA66CDCD (wind), 2DBD9ED0AD824844, 4A022FEF9905D8F4, C58CE169C13320CA, F2B44F93BD91662E | jitter |
| 8 | 46321AD5724BCC48, 5A837C613E3F41DC, A5D3ADA63608617F (board grain chain `sub_824C8878`), C04832978CDED925 | default bus |
Other `sub_82491108` callers take the enum from elsewhere: `sub_824D25E0`, `sub_824EC670`, `sub_824EC958` from the
material-pair lookup `sub_82497BF8` (collision voices; UNCERTAIN mapping); `sub_824EC488`/`sub_824EC670`/`sub_824EC958`
via `sub_824B7A98`; `sub_824F0AB8` via `sub_824B7B48` (fields 552899F3BF9927CC, A3E8A9381F4222E1,
C0FFD1E535F218E2 of another holder; UNCERTAIN class).
## 4. Validation against retail [TR] (`validate.py`, `walk.py`, `walk_sim.py`)
### 4.1 Random buses 0–2 (every non-default PEAK line on the 11-point grid of its fields)
| session | bus 0 P1 / P2 | bus 1 P1 / P2 | bus 2 P1 / P2 |
|---|---|---|---|
| dsp_20261002_185826 | 21/21, 21/21 | 31/31, 31/31 | 15/15, 15/15 |
| all_20261002_163809 | 228/228, 228/228 | 207/207, 207/207 | 2013/2013, 2016/2016 |
e.g. bus 0 PI20#1 freq ∈ {150, 180, …, 450} (step 30), gain ∈ {0.75, 0.825, …, 1.5}, Q ∈ {0.25, 0.525, …, 3.0};
PI20#2 freq ∈ {1500, 1850, …, 5000}. All 11 grid points occur in all_163809. Both PI20 change on the same line
timestamp (one creation = one re-roll of all six).
### 4.2 Jitter buses 5–7
- Inside the §2.3 bounds: dsp 904/904, 553/553, 980/980 lines; all 4050/4050, 8637/8637, 12666/12666.
Observed extremes reach the bounds (e.g. 100.0009..699.9996 Hz, 0.5..2.0, Q 0.25..5.75).
- Buses 6 and 7 carry **identical** values at the same timestamps (checked 20.5 s window) — one shared source.
- Before the first jitter step bus 6 showed exactly the centres 400 / 1.25 / 3.0 and 2000 / 1.75 / 3.0 (6159 ms, dsp).
### 4.3 Update cadence
- Bus 5–7 changes: median 9.5 ms apart in the recomp (its game frame rate, not 60 Hz; UNCERTAIN host pacing).
- |Δvalue|/a2 per logged change, all_163809 bus 7 PI20#1 freq: quantiles (10/25/50/75/90 %) 0.107 / 0.292 / 0.670 /
1.011 / 1.330. Simulated walk: 1 step per post 0.06 / 0.17 / 0.48 / 0.92 / 1.00; 2 steps 0.15 / 0.37 / 0.72 / 1.11 /
1.39; mixture 20–30 % one-step ≈ 0.12 / 0.31 / 0.68 / 1.02 / 1.30 — matches. Consistent with "jitter steps every
frame, bus post every second frame, both every frame on long (> 20 ms) frames". UNCERTAIN until the slot +36
caller is found.
## 5. Owner send bus `sub_82488DD0(graph_out, modules_out, target, f1 level)` [R]
Graph order **4**, 3 modules, all 6 ch: `Sub0 → Sen0 (env) → Sen0 (eEQChain bus)`.
- Sen0 #1: linked (0x7FF7FFF4) to `[[mgr+52]]` (env/reverb bus input); param 0 = `level` posted once at build.
- Sen0 #2: linked to `target` = `sub_82491108(mgr, eEQChain, owner+72)`; its level is never posted (Sen0 default,
UNCERTAIN = 1.0, constructor current gain 1.0).
- The voices of that owner send into this Sub0 instead of a material bus directly; the owner bus is a mixing point
that taps the sum to reverb and forwards it to the EQ bus.
Builders (level = `vfunc60(id) × 1/32767`, 0x822F8898):
| caller | owner | level id | eEQChain field | storage |
|---|---|---|---|---|
| `sub_824B9CC8` | pop (enter air with a trick) | Contacts 14 | E34B48082B5BF185 (0) | +72 / +76 |
| `sub_824B8D48` | touchdown voice slots (4 × 24 bytes at +140) | Contacts 15 | 9326AEAC6E053997 (0) | slot +156 / +160 |
| `sub_824B8D48` | extra (second-variant) touchdown voice | Contacts 15 | 9326AEAC6E053997 (0) | +252 / +256 |
| `sub_824BB330` | manual landing (kind 4) | Contacts 15 | 9326AEAC6E053997 (0) | +44 / +48 |
No other caller of `sub_82488DD0` exists. Whether the level is refreshed after build: UNCERTAIN (not seen here).
## 6. 0x40C18420 / 0x40C18820 and the LFO task
### 6.1 The two graphs = env pre-stages mgr+56 / mgr+60 [R, TR]
`sub_8248EEE8` builds: mgr+52 env bus input (order 199, `Sub0 → Sen0 → Sen0`, mono), then **two** order-200 mono
graphs `Sub0 → Gai0 → PI20 → Sen0 → Sen0 → Sen0` at mgr+56 / mgr+60, then ReverbModel1 (`RM10`) graphs mgr+64/+68
(order 201), four order-202 graphs mgr+72..+84 (HI20/LI20/Pn21 present), four order-201 7-module graphs mgr+88..+100.
The trace's 18420/18820 have exactly GAIN, PEAK, 3 SENDs in that module order → mgr+56 = 0x40C18420,
mgr+60 = 0x40C18820 (the LFO centres below confirm). The effect returns (mgr+108/+112, `Sub0 → Del0 → PI20 → Sen0 →
Gai0 → Pn21 → Sen0`) are the trace's 0x40C40770 / 0x40C40020 (PEAK, 1-ch SEND, GAIN, 6-ch SEND).
### 6.2 LFO task — `sub_824907D8` builds, `sub_82490B60` runs [R, IMG]
14 entries of 32 bytes at mgr+396+32n: +0 period (s), +4 phase, +8 sine scale, +12 offset, +16 depth, +20 output,
+24 module, +28 param index. Registered with `sub_82481BE0` as task "LFO Task" (string 0x8224DC68), callback
`sub_82490B60`, context mgr+388. Each run (dt in f1): phase = (phase + dt) mod period;
**out = offset + depth · scale · sin(2π · phase / period)** (2π = 0x820B411C, sine `sub_82F4DED0`), posted to the
module's param.
Template: period 1.0 (0x8231A844), phase 0, scale 0.5 (0x8209975C), param 0.
| n | module | offset = depth | output range |
|---|---|---|---|
| 1 | [[mgr+56]+8] PI20 freq (0x40C18420) | 1.0 | 0.5..1.5 |
| 2–5 | [[mgr+88]+4], [[mgr+92]+4], [[mgr+88]+20], [[mgr+92]+20] | 0.3 (0x820D06C0) | 0.15..0.45 |
| 6 | [[mgr+60]+8] PI20 freq (0x40C18820) | 0.3 | 0.15..0.45 |
| 7–10 | [[mgr+96]+4], [[mgr+100]+4], [[mgr+96]+20], [[mgr+100]+20] | 0.3 | 0.15..0.45 |
| 11 | [[mgr+116]+4] effect return A Del0 delay | 0.006 (0x82116288) | 3..9 ms |
| 12 | [[mgr+116]+8] return A PI20 freq | 1.0 | 0.5..1.5 |
| 13 | [[mgr+120]+4] return B Del0 delay | 0.006 | 3..9 ms |
| 14 | [[mgr+120]+8] return B PI20 freq | 1.0 | 0.5..1.5 |
[TR]: at boot 18420 sweeps 0.954..1.241 starting at 1.03, 18820 0.286..0.372 starting at 0.31, both with gain 1 /
Q 3 (bypassed); slope ≈ 1 s period at ~5.3 ms per step. Later an environment preset rewrites them (e.g. 6128 ms both
→ 1936.30 Hz / gain 0.1 / Q 10, then a shared ramp of +1.297 per step; 18820 → 1162 Hz gain 1 at 6168 ms). That
writer (probably the reverb-zone settings reprogramming the LFO entries and the PI20 gain/Q) is **not identified**.
Modules of the mgr+88..+100 graphs at module index 1 and 5 are not resolved (UNCERTAIN; probably modulated delays
of the reverb network).
## 7. Port notes
- Material buses: implement §1.4/§1.5 exactly — per-bus created flag, cleared every second frame; re-roll on first
create-flagged resolve; draws from the title generator in the fixed order; skip the draw when a == b.
- Jitter: one shared 30-entry table; buses 5–7 share it. Retail is unreproducible sample-for-sample (shared RNG), so
tests should check grids/bounds/step statistics as in §4, not sequences.
- Do not apply the material-bus EQ to bus 8 (default).
## 8. UNCERTAIN
1. Meaning of owner byte +72 (create flag for player components).
2. Caller and cadence of the jitter update (slot +36 of 0x822FC248); the trace fits per-frame stepping.
3. Real 60 Hz cadence: the recomp's game frame period (~5 ms?) differs; the "every 2nd frame" rule is from code.
4. Consumer of the six post-flagged jitter entries (ids 0–5).
5. Owner send Sen0 #2 level (never posted; assumed class default) and whether the env level is refreshed.
6. The environment-preset writer that changes the env pre-stage / return PI20 gain/Q and LFO entries.
7. Module types at index 1/5 of mgr+88..+100 (LFO entries 2–5, 7–10).
8. Exact rounding of `b + (k·(a−b))·0.1` (fmadds single rounding); all trace values match to 4 decimals.
9. Whether the per-record RefSpec fields of `AA801D9FC0ADBBBF` are read anywhere.
10. Enum sources for `sub_82497BF8` (material pair) and the `sub_824B7B48` holder.
@@ -0,0 +1,896 @@
# AEMS patch-program evaluator — behavioural specification (Skate 3 TU3)
Basis for a native Rust re-implementation. Written 2026-10-02 in our own words. Facts (offsets, opcode
numbers, constants, addresses) come from:
- the TU3 recomp (`skate3recomp/generated`, read with `tools/recomp-code-search/fn.sh`);
- the disc banks (our survey script `tools/audio-file-inspect/aems_survey.py`, all 376 `.abk` and 9 `.csi`);
- the PoC probe (`emitter_probe` in our local PoC worktree, which runs upstream PR #4);
- PR #4 and sk8Audio (reference only, no licence);
- nfsmw Snd9 (dbalatoni13/nfsmw, CC0; see `rwaudio-prior-work.md`). It is the source of the official names, and it is
2005-era, so differences are marked.
Licence rule: nothing here is code from PR #4, the recomp or b5-decomp. Describe, don't transliterate.
Status tags: **[V]** verified against retail code or data by us; **[P]** taken from PR #4's reading of
retail code and spot-checked; **[N]** from nfsmw 2005, consistent with Skate but not re-read in TU3;
**[?]** open.
Related notes: `aems-reference.md` (older summary; corrected by §11 here), `ems-emitters-re.md` (game side
of emitters), `rwaudio-prior-work.md` (prior art, voice graph).
---
## 0. Vocabulary (official AEMS/Csis names → what we called them before)
| Official (nfsmw/Csis) | Earlier notes / PR #4 | What it is |
|---|---|---|
| ModuleBank | `.abk` / ABKC bank | a compiled bank: modules + sample bank |
| Module | "input record" | one patch: program, instance template, capacity, bound to one Csis class |
| ModuleInstance | instance | a live copy of the module's template; owns voices |
| Csis project | `.csi` / MOIR | the authoring project's symbol table |
| Csis **Class** (csi table 1) | "object" (`c_emitter`, `Class_grind`) | what game code posts to |
| Csis **Function** (csi table 0) | "message/broadcast" (`*_msg`) | a fire-and-forget call with parameters |
| Csis **GlobalVariable** (csi table 2) | "variable/global" (`*_snd`, `*_vol`, `*_gbl`) | an int with subscribers |
| class instance / "pClass" | post node | the 16-byte object a post creates; refcounted |
| member data | payload words | the int parameters of a class instance |
| ClassDestructor client | release callback | told when the poster releases |
| timer client list | evaluator node list (`0x83036F4C`) | the instances the tick runs |
| Player | voice object (op 27) | plays one sample from a sample group |
| SampleGroup / sample entry | descriptor table / descriptor | the per-player list of selectable samples |
| TABLE | curve | sampled lookup table used by op 15 / op 9 |
| tick scale ("gVariableTimerPeriod") | `0x830775D8` | the tick length in ms (≈32) |
---
## 1. Containers
All multi-byte fields are big-endian. Offsets are from the start of the file (on disk). After load, every
offset listed in the rebase list becomes an absolute pointer. For a native port, keep offsets and resolve
on access.
### 1.1 ModuleBank (`.abk`, magic `ABKC`) — header, 0x5C bytes [V]
| Off | Name | Disc value / meaning |
|---|---|---|
| 0x00 | id | `ABKC` |
| 0x04 | ver, aimex major/minor/patch | bytes 01 01 02 02 on all 376 |
| 0x08 | platform | 5 = Xenon (all) |
| 0x09 | (pad) | 3 on all banks (meaning unknown) |
| 0x0A | u16 nummodules | 1 (369 banks), 2 (5), 3 (2): 385 modules total |
| 0x0C / 0x10 | debugcrc / uniqueid | 0 |
| 0x14 | totalsize | = file length |
| 0x18 | residentsize | = sample-bank offset (modules occupy 0x5C..this) |
| 0x1C | moduleoffset | 0x5C, the first Module |
| 0x20 / 0x24 | sfx bank offset / padded size | the `S10A` sample bank |
| 0x28 / 0x2C | midi bank | 0 |
| 0x30 | funcfixupoffset | list {count, offsets}; count 0 on every bank (native-code banks only) |
| 0x34 | staticdatafixupoffset | rebase list {u32 count, u32 offset[count]}: each offset names a word in the bank that holds a bank-relative offset (4,656 words over all banks) |
| 0x38 | interfaceOffset | interface (export) list, see 1.2 |
| 0x3C..0x5B | run time | bank id (+0x3C), sample-bank pointer (+0x40, `FFFFFFFF` on disk), midi (+0x44), stream file path (+0x48), stream file offset (+0x4C), bank list node (+0x50/+0x54), tweak header (+0x58) |
**Sample bank `S10A`** [V]: `'S10A'`, u32 0, u32 capacity, then u32 offsets[capacity] relative to the
tag. Used slots form a prefix, and `FFFFFFFF` marks an unused slot. Each used slot is an EA Audio Core
stream header plus data: mostly XMA, mono, 48 kHz. A loop flag and loop start are in the header (see
`gotchas`/PR #4 docs: loop headers are 12+ bytes).
### 1.2 Interface references (exports) [V]
`u32 count`, then count × 12 B: {u32 handle offset (where the resolved handle goes), u32 ID-record offset,
u32 kind}. The ID record is {u16 project id, u16 name id, NUL-terminated name, padded to 4}.
**kind top byte = InterfaceType:**
- **0 GlobalVariable** → csi table 2 (362 exports: `*_snd`, `*_gbl`, `*_vol`, …);
- **1 Class** → csi table 1 (386: `c_*`, `Class_*`);
- **2 Function** → csi table 0 (311: `*_msg`, …).
The low 24 bits take 5 values whose meaning is unknown and unused at run time. A resolved handle is
8 bytes: {symbol record pointer, u32 (name_id << 16 | generation)} (§1.4).
### 1.3 Module (the old "input record"), 60 bytes + 4 per extra [V]
| Off | Field | Meaning |
|---|---|---|
| +0x00 | id | unused (0) |
| +0x04 | classHandle (8 B) | the Csis class this module serves (its export target) |
| +0x0C | constructorClient (16 B) | {next, prev, fn, ctx} list node hung on the class's constructor list at install |
| +0x1C | s16 curinstances | live count (0 on disk) |
| +0x1E | s16 maxinstances | **capacity**: 10 (300 modules), 1 (30), 16 (28), 3 (13), 5 (5), 4 (3), 2, 6, 8, 20, 24 |
| +0x20 | u16 numGlobals | GlobalVariable subscriptions in the template |
| +0x22 | u16 numFunctions | Function subscriptions in the template |
| +0x24 | u8 numPlayers | Player (op 27) states |
| +0x25 | u8 classDestructorPresent | template has a ClassDestructor state |
| +0x26 | u8 classDataPresent | template has a ClassData (payload) state |
| +0x27 | u8 numClassControllers | ControlClass (op 38) states |
| +0x28 | pcode | program offset |
| +0x2C | pdata | instance template offset |
| +0x30 | datasize | template/instance size |
| +0x34 | destroydataoffset | offset of the Destroy (op 4) state in the instance; = datasize − 16 on all 385 |
| +0x38 | moduleInstance | live-instance list head (run time) |
| +0x3C | u32[numPlayers + numClassControllers] | template offsets of each Player state, then each ControlClass state |
The next module follows immediately. Install writes the bank pointer into +0 of every Player state (the
first numPlayers offsets).
### 1.4 Instance template (pdata) layout [V on all 385]
| Instance offset | Content |
|---|---|
| 0..7 | live-list links {next, prev} |
| 8..23 | timer client {next, prev, program, data pointer = instance+24} |
| 24.. | ClassDestructorState, 20 B, if present: client(16) + **triggered** (+16) |
| then | numGlobals × GlobalVariableState, 28 B: handle(8) + client(16) + **value** (+24) |
| then | ClassDataState, if present: client(16) + u8 numOutputs (+16) + **values[numOutputs]** (+20). Size (n+5)·4 |
| then | numFunctions × FunctionState: handle(8) + client(16) + u8 numParameters (+24) + u8 **triggered** (+25) + **values[n]** (+28). Size (n+7)·4 |
| then … | the op data blocks, in program order |
| datasize−16 | Destroy state {module, instance, class instance, triggered} = the last op's block |
**Block walk invariant [V]:**
- the program's data pointer starts at instance+24;
- each op record advances it by its own amount, always ≥ 0 on disc;
- it ends exactly at datasize on all 385.
Ops 0/2/1/37 read the subscription states above, so their blocks *are* those states.
**Payload word counts (numOutputs) on disc:** 9 for 296 modules (all `c_emitter`); also 0, 7, 8, 10–13,
15, 17, 18, 20–22, 25, 27, 28, 34, 50, 54.
### 1.5 Program encoding [V]
A sequence of records, ended by opcode 255:
`u8 opcode, u8 npairs, u16 unused (0 on all 31,016), npairs × {s32 src, s32 dst}, s32 advance`.
All opcodes on disc are < 40. Census (ops per opcode, all banks):
0:385 · 1:385 · 2:252 · 3:385 · 4:385 · 5:182 · 6:50 · 7:630 · 8:599 · 9:3 · 10:2545 · 11:1186 · 12:1902 ·
13:837 · 14:12 · 15:3126 · 16:190 · 17:2653 · 18:171 · **19:0** · **20:42** (arena_cheers, arena_ohhs,
arena_negative_reax, car_alarms) · 21:366 · 22:51 · 23:969 · 24:863 · 25:709 · 26:267 · 27:1527 · 28:372 ·
29:800 · 30:1958 · 31:1450 · 32:49 · 33:310 · 34:1145 · 35:2093 · 36:1927 · 37:129 · **38:1** (Tazer.abk,
c_tazer) · 39:110.
Typical frame:
- programs start with 0, then 1/2, then 3 (ClassDestructor, ClassData/GlobalVariable reads, Create);
- they end with `…, 4` (Destroy), most often preceded by 27 (217), 5 (77), 10, 36 or 12.
### 1.6 Player state (op 27 block) [V layout; P semantics]
| Off | Field |
|---|---|
| +0 | bank pointer (written at install) |
| +4 | sample group pointer (rebased) |
| +8 | voice handle (0 = none) |
| +12 | s8 prevplaycontrol[0] (last applied control) |
| +13 | s8 prevplaycontrol[1] (the one before) |
| +14 | u8 numinputs (7 typical; up to 11+) |
| +15 | u8 updateoutputs (1 on 819 of 1527 players): write timeleft/timecurr |
| +16 | s8 sampletype: `FF` on disc; unused by Skate's player code [?] |
| +17 | u8 extra outputs: 8 more output words after the time pair (0 on all disc players) |
| +20 | **sampleselect** (input) |
| +24 | **playcontrol** (input): 0 stop, 1 play, 2 pause |
| +28 | numinputs × 12 B input states {u8 type, pad, s32 applied (FFFFFFFF on disc), s32 value} |
| after inputs | if updateoutputs: **timeleft** (ms), **timecurr** (ms); then 8 words if +17 |
**Sample group** (Skate's newer format, differs from 2005's typed SAMPLEENTRY) [V]: u32 count, then
count × 12 B:
- s16 sample index into S10A (`FFFF` = silent entry);
- u8 level % (×0.01 → the voice's player level; 40–100 seen);
- 6 bytes of per-channel azimuths, each <<8 into 65536 = 360° units. Mono uses the first. The later bytes
look like `{…, slot+1, FF}` on many banks, meaning unknown [?];
- s32 stream offset at +8. `FFFFFFFF` = in-memory sample; it is added to the bank's stream file offset.
Counts run from 1 up to 10001 (Sk8_Air_Flip_Tricks, equal to its S10A capacity).
**Input type ids (PLAYER_INPUT_*) and use on disc:**
| id | Name | Effect | Count |
|---|---|---|---|
| 0 | PITCHMULT | 1525 | resample ratio = value/4096 (clamp 0..65535) |
| 1 | TIMEMULT | — | ignored |
| 2 | VOL | 1527 | master level value/32767 (clamp 0..32767); multiplies dry and send |
| 3 | AZIMUTH | 1516 | pan angle = value·360/65536 degrees (alternate setter) |
| 4 | ELEVATION | 2 | ignored |
| 5 | FXWET0 | 1515 | send level/32767 × master (clamp 0..32767) |
| 6 | LOWPASS | 1527 | low-pass cutoff, raw Hz (clamp 0..65535; 25000 = open) |
| 7 | HIGHPASS | 1521 | high-pass cutoff, raw Hz (clamp 0..65535; 0 = off) |
| 8 | DRYLEVEL | 1513 | dry gain/32767 × master (clamp 0..32767) |
| 9..136 | user / routing | 156 (9), 29 (10), 536 (11), 29 (12) | read at open as routing codes (§5.4); id 11 also = effect-send level/32767 when an effect bus is routed; others ignored afterwards |
### 1.7 TABLE (op 15 curve; op 9 weights) [V]
| Off | Field |
|---|---|
| +0 | u8 entry size: 1 = s8, 2 = s16, anything else s32 |
| +2 | u16 count |
| +4 | s32 min input |
| +8 | s32 max input |
| +12 | f32 resolution: entries per input unit; exactly 1.0 = nearest |
| +16 | entries |
On disc:
- s8 1917 (311 nearest), s16 1127 (609 nearest), s32 82 (all nearest);
- the op-15 "previous input" starts at `7FFFFFF1` (forces a first evaluation).
### 1.8 Csis project (`.csi`, magic `MOIR`) [V]
| Off | Field |
|---|---|
| 0x00 | `MOIR` |
| 0x04 | u16 3, u16 0x0100 |
| 0x08 | u8 5, u8 0 |
| 0x0A | u16 #functions (table 0) |
| 0x0C | u16 #classes (table 1) |
| 0x0E | u16 #globals (table 2) |
| 0x10 | u16 project id |
| 0x14 / 0x18 / 0x1C | run-time table pointers |
| 0x20 | project list node |
| 0x28 | tables 0, 1, 2 back to back, then the string pool |
**Records:**
- Function / Class, 12 B: {+0 client-list head (0 on disc), +4 name offset, +8 u16 name id,
+10 u16 generation (0 on disc)}.
- GlobalVariable, 16 B: {+0 client-list head, **+4 s32 value = default**, +8 name, +12 u16 name id,
+14 u16 generation}.
Name ids ascend within a table. The 9 projects:
| File | Project | functions / classes / globals |
|---|---|---|
| SK8_AEMS_Foley | 0x5C48 | 27 / 10 / 161 |
| Sk8_Emitters_Project | 0x2E5A | 136 / 11 / 144 |
| SK8_AEMS_skateboard | 0x64BD | 3 / 54 / 24 |
| SK8_AEMS_rolling | 0x4EA6 | 5 / 1 / 12 |
| SK8_AEMS_Crowds | 0x167A | 3 / 3 / 9 |
| Sk8_AEMS_MoveableObjects | 0x69E3 | 1 / 7 / 34 |
| AEMS_TRAFFIC | 0x2DED | 4 / 3 / 0 |
| Sk8_moments | 0x796A | 0 / 8 / 1 |
| AEMS_Calibrate | 0x0F52 | 0 / 1 / 0 |
**Install** [P]:
1. Convert name offsets to pointers.
2. Give every record a fresh generation from one global u16 counter. It pre-increments per record and
restarts at 1 when it would turn negative as an s16.
3. Link the project into the project list (newest first).
**Lookup** (`sub_828E3148/3250/3358` for tables 0/1/2) [P]:
1. Pass 1 scans only projects whose id matches the query's project id. Pass 2 scans every project.
2. A hit needs an equal name id **and** an equal name string. It writes {record, record's id|gen word}
into the handle.
3. A miss returns −5.
Pass 2 is why banks built against unshipped projects (0x63D9, 0x4228, …) still bind. 4 of 1,059 exports
find nothing: `semi_horns_msg`, `pa_announce_a_glb` ×2, `pa_announce_b_glb`.
**Handle check (every use)** [V]:
- handle word negative → return it as the error;
- record pointer null → −6;
- word ≠ the record's current id|gen → clear the handle (record 0, word −3) and return −3.
### 1.9 SPLC `.bnk`
Not part of AEMS. These are Splice sample collections, played by game code directly (SFXObj_Contacts,
the collision manager). They are not bound to any csi and have no program. Field correction (member +4 =
gain, +8 = pitch): `aems-reference.md`, CORRECTION section.
### 1.10 How a bank binds to `Sk8_Emitters_Project.csi:c_emitter` (worked example) [V]
1. Load all csi first, then each bank. Banks are installed only after their csi projects.
2. Resolve each export into its handle slot inside the bank:
- the module's classHandle (kind 1 → class `c_emitter`);
- GlobalVariable handles in the template (kind 0, e.g. `siren_city_snd_1`);
- Function handles (kind 2, e.g. `siren_city_msg_1` in an op-5 block).
3. If the module's classHandle is valid, push its constructorClient (fn = "create instance",
ctx = module) onto the class record's client list. Newest bank first.
4. 291 banks bind to `c_emitter`. A post to `c_emitter` reaches **every loaded** bank bound to it, and
each spawns an instance (§2.3).
5. The payload's selector word (w8 = the attribute patch index) decides inside each program whether that
bank plays; non-matching instances stay silent until released (`ems-emitters-re.md`).
---
## 2. Runtime model
### 2.1 Objects and state
- **Global evaluator state:**
- instance list (a stack: new instances are pushed at the head);
- tick-period cache {cached delta f32, period count u32, countdown u32};
- tick scale f32;
- the RNG (§2.8);
- the global u16 generation counter;
- the project list.
- **Class instance** (post node), 16 B: {class record, refcount, ClassData client list, ClassDestructor
client list}.
- **Module instance:** a byte copy of the template (§1.4) with live lists linked. All op state lives in
it. No other per-instance state exists, except voice handles and the post-node pointer.
### 2.2 Posting (CreateInstance; `sub_828E2B48`) [V]
Inputs: a class handle, a pointer to the payload ints, and an out-slot for the post node.
1. Validate the handle (§1.8). On failure return the code: −3/−5/−6, or −1 when out of memory.
2. Allocate the post node: refcount 1 (the poster's reference), empty lists.
3. **Call every constructor client** of the class, newest bank first. Each runs "create module instance"
with (post node, payload, module).
4. Then **call every ClassData client** now on the node; each copies payload words into its instance
(§2.4).
5. Store the node in the out-slot and return 0.
**The post still succeeds** when no instance was created (capacity full, no bank loaded).
### 2.3 Create module instance (`sub_82B1DAD0` → `sub_82B1D880`) [V/P]
- If curinstances ≥ maxinstances, do nothing (silently). Otherwise:
1. Allocate datasize bytes and copy the template.
2. Fill the Destroy state's {module, instance, post node}.
3. Link into the module's live list (head) and set the timer client {program, data = instance+24}.
4. ClassDestructor present → register its client on the node's destructor list; node refcount +1.
5. For each GlobalVariable state: subscribe its client to the global; **copy the global's current
value into +24 immediately**.
6. ClassData present → register on the node's ClassData list; refcount +1. Values are filled by step 4
of §2.2, or by a later redelivery.
7. For each Function state: if its handle is valid, subscribe to the function (stale → handle cleared).
8. curinstances +1.
9. **Push the timer client at the head of the evaluator list.** A new instance runs first in the next
walk.
- Nothing runs at creation; the first program run is at the next walk (§2.6).
### 2.4 Payload, held messages and redelivery [V]
- ClassData copy: numOutputs words (bank-defined) from the payload into values[]. **The count comes from
the bank, not the poster**: the poster must supply at least that many words.
- **Redelivery (`sub_828E2D18`, "SetMemberData"):** the game rewrites its payload and calls this with
(node, payload). It reruns only the node's ClassData clients. Constructors are not rerun, so no new
instances.
- Game components keep a node ("held message") and redeliver every 60 Hz game frame. The evaluator only
sees the last value before each walk (31.25 Hz).
- A "one-shot" use = post, then release soon after. Whether the sound survives depends on the program
(§2.5).
- c_emitter programs end at once on release, so emitters must be held for as long as they sound.
- Example senders: `Class_grind` (sub_824AF8C8 post, sub_824C39E0 per-frame redelivery);
`SFXObj_Emitter` (sub_824DCE60 create, sub_824DCF08 per frame; see `ems-emitters-re.md`).
### 2.5 Release, destroy, lifetime [V/P]
**Release (`sub_828E2C78`):** under the audio lock:
1. call every destructor client on the node; each sets its instance's ClassDestructor.triggered = 1;
2. drop the poster's reference, and free the node at 0.
**Program side:**
- Op 0 reads and clears `triggered`. Programs route it, directly or through logic, into the Destroy
state's triggered word (+12).
- Op 4 (Destroy), the last op, removes the instance in the **same walk** when that word ≠ 0:
1. unlink it from the module live list and from the evaluator list;
2. unregister its destructor client and its ClassData client, each dropping a node reference (free at 0);
3. unsubscribe its GlobalVariable and Function clients;
4. for each Player with a voice, **release the voice** (immediate stop at the AEMS level; any de-click
is the voice graph's business);
5. for each ControlClass with a node, **release that child post** (cascades to the child's instances);
6. curinstances −1, free.
- The walk is safe: the interpreter fetched the next instance before running this one.
**Lifetimes:**
- c_emitter banks: Destroy.triggered = the ClassDestructor pulse. The instance lives exactly until
release; finished voices do not end it.
- Some programs compute their own end (e.g. after their voices finish). Then the instance ends while the
game still holds the node. That is harmless: later redeliveries find no clients, and the release just
frees the node.
### 2.6 Timing: the tick [V]
The tick function (`sub_82B1E290`) is called once per **audio block of 256 frames at 48 kHz**, with the
block delta δ as an **f32** (scheduler +64).
- **Period (re)computation** whenever δ differs bit-for-bit from the cached f32:
1. limit = f32(1.0 / D). D = 30.0 in play: game init `sub_826D4C30` copies it from 0x820D4924 over the
boot value 41.6.
2. Accumulate f32 sums: count = the number of whole δ steps taken until one more step would reach the
limit. That is count = ceil(limit/δ) − 1 for non-exact cases, at least 1.
3. Store count as both the period and the countdown.
4. tick scale = f32( f32(f32(count) · δ) · 1000 ).
- **Every call:** countdown −= 1; at 0, reload it with the period and **walk**.
- With δ = f32(256/48000) = 0.0053333333:
- count = **6** → a walk every 6 blocks = 31.25 Hz;
- tick scale = **31.999998 ms** (not exactly 32: use the f32 value).
- On the first call after a recount the countdown becomes count−1, so the first walk is on the 6th
call.
- **Gotcha [V]:** feeding an f64 δ that is not f32-exact makes every call a "changed δ" → the countdown
never reaches 0 → nothing runs. The native port should simply keep a block counter and walk every 6th
block, with tick scale 31.999998.
- **Walk:** visit each instance from the head of the list (newest first) and run its program over its
data (§3). A post or redelivery made between walks takes effect at the next walk. The phase between a
post and the first walk is 0–5 blocks (0–27 ms).
### 2.7 Functions and globals (the `*_msg` → `*_snd` machinery) [V]
**CallFunction (op 5)** delivers its parameters **synchronously** to every subscriber of the function.
- Each subscriber's FunctionState copies numParameters words (its own count) and sets triggered = 1.
- The subscriber sees it when its own program next runs op 37:
- later in the **same walk** if it is older (further down the list);
- otherwise in the next walk.
- Game code can call functions too (same entry point `sub_828E29C0`).
- Result codes: −4 (no subscriber), −3, −6. All are ignored by op 5.
**SetGlobalVariable (op 39)** clamps and stores into the global record's value. The value is clamped to
the block's [min, max]; the raw value is kept as "prev".
- **Only if the stored value changes**, every subscriber is notified, and each copies it into its +24
synchronously.
- New subscribers copy the current value at creation (§2.3).
- Globals start at the csi default (+4).
- Game code can set globals too [?: the "G" writer `*(0x83083C38)+0x2FCB4` in PR #1 notes].
**Emitter project round trip:**
- `c_emitter_utility` is posted once at boot and held. Capacity 1; 97 Function subscriptions; 100 op 39.
- For each `*_msg` function:
1. op 37 (did someone call it?) gates an op 8 shuffle (triggered input);
2. the shuffle draws the next sample number;
3. op 39 publishes it into the paired `*_snd` global.
- Sound banks read `*_snd` through a GlobalVariable state (op 2) into a Player's sampleselect.
- Their Create pulse (op 3) triggers op 5 on `*_msg` to request the next number.
- Because the requester is newer than the utility (it runs first in the walk):
- **a post's first player opens with the number published by the *previous* request;**
- the new number arrives in the same walk, after the requester ran;
- a second layer opened ticks later (e.g. Siren_city_1 player 2) reads the *new* value [? confirm
with golden/retail].
### 2.8 Random numbers [V]
One global generator, 6 u32 words at 0x830775F0 (call them w0..w5). It is shared by every instance and
used only by ops 7, 8 and 9 (no other caller in the recomp).
**One draw:**
1. Ripple-add upward with carry:
- w4 += w5;
- w3 += w4' + carry; w2 += w3' + c; w1 += w2' + c; w0 += w1' + c.
- Each step uses the freshly updated lower word. Its carry is "result low word < original low word".
The first step also checks against w5.
2. Then w5 += 1. If w5 wrapped to 0, add 1 to w4, w3, w2, w1, w0 in turn, stopping at the first word
that does not wrap to 0.
3. The value used by every op is the **low 32 bits of the new w0** (ops take `draw mod n` on that
unsigned word). After a full ripple it is w0+1.
**Seed:**
- The boot image holds **all zeros** at 0x830775F0, and no TU3 code writes these words except the draw
itself.
- The fixed constants F22D0E56 883126E9 C624DD2F 0702C49C 9E353F7D 6FDF3B64 sit unreferenced at
0x82FD36A0, right after the opcode table.
- Game init `sub_826D4C30` seeds a **different** 6-word generator (0x82FD7D74, used by `sub_82A8AF10`)
with (time base + those constants).
- So AEMS draws are deterministic from zero but depend on every draw since boot. That is why the results
are not reproducible across runs.
- [?] Confirm with a passive recomp read of the 6 words at the first post.
- **Port:** start at zero. Expose seeding for tests; golden runs must log the state.
---
## 3. The interpreter (per instance, per walk) [V]
Data pointer `d` = instance+24; record pointer = program start.
While opcode ≠ 255:
1. r = op(opcode)(block at `d`). Each op returns a 32-bit value; only the low word is ever stored.
2. For each pair, in order (npairs is re-read every iteration, but no op writes programs, so it is
constant):
- src = −1 → store r at d+dst;
- else copy the word at d+src to d+dst.
- Offsets are bytes, relative to the **current** block, and may be negative (into earlier blocks).
3. d += advance; move to the next record.
**Data flow:** an op's inputs are words in its own block that earlier ops' pairs wrote. Values persist
across walks, so an op's input keeps the last written value until rewritten. Result fan-out happens only
through pairs.
**Opcodes ≥ 40 do not occur.** The port should treat them as a hard error at bank load.
**Integer semantics:** treat every word as i32 with wrapping arithmetic. Results are stored as the low
32 bits. Retail keeps 64-bit intermediates in some ops, but every store and every comparison it makes
uses the low word, so 32-bit wrapping is exact. Exceptions (op 9 unsigned compare) are noted per op.
**Float semantics:** all float math is **f32** with round-to-nearest after each operation:
- int → float conversion = exact to f64, then round to f32;
- "fused" means one rounding of a·b+c (PPC fmadds) — use an f64 FMA then round to f32; double rounding is
theoretically possible but immaterial at these magnitudes [?];
- **round(x)** = (x < 0 ? x − 0.5 : x + 0.5) in f32, then truncate toward zero to i32. Saturate like PPC
fctiwz: >2³¹−1 → 0x7FFFFFFF; < −2³¹ or NaN → 0x80000000. Rust `as i32` maps NaN to 0, so special-case
it.
---
## 4. Opcodes
Block offsets are from the op's block. "trig" = a trigger input word, where non-zero means true. All ops
return i32.
| # | Name | Block layout | Behaviour | PR #4 |
|---|---|---|---|---|
| 0 | ClassDestructor | the ClassDestructorState: +16 triggered | return triggered, then clear it (one-shot "released" pulse) | ported |
| 1 | ClassData | ClassDataState: +16 u8 n, +20 values | return values[0]; programs fan the other payload words out with copy pairs | ported |
| 2 | GlobalVariable | GlobalVariableState: +24 value | return value (no clear) | ported |
| 3 | Create | +0 trig (1 in every template) | return trig, then clear it → 1 on the first walk only | ported |
| 4 | Destroy | +0 module, +4 instance, +8 post node, +12 trig | trig ≠ 0 → destroy the instance (§2.5); return 0 | host op |
| 5 | CallFunction | +0 function handle (8 B), +8 u8 clamp flag, +9 u8 n, +12 [n × {min,max} if flag], then inputs {trig, params[n]} | if flag: clamp each param in place to its range (always, triggered or not); if trig ≠ 0: call the function with params (§2.7). Trig is not cleared. Return 0 | **host op, wrong handle (bug, §11)** |
| 6 | Counter | +0 min, +4 max, +8 value, +12 s8 step, +16 trig, +20 override | min ≤ override ≤ max → return override, no state change. Else if trig > 0 (signed): value += step; if value > max → min; else if < min → max. Return value | ported |
| 7 | Random | +0 min, +4 range, +8 current, +12 trig | trig ≠ 0 → current = min + (draw mod range), unsigned; range 0 → min + draw. Return current | ported |
| 8 | RandomShuffle | +0 u16 trig offset (self-relative), +2 u8 entry size (1 = u8, else u16), +3 s8 avoidrepeat, +4 min, +8 u16 index, +10 u16 range, +12 current, +16 number set | trig == 0 → return current. Else span = range − avoidrepeat − index; k = index + (draw mod span); output = set[k]; swap set[k] ↔ set[index]; current = output + min; index += 1; if index ≥ range → index = 0, avoidrepeat = 1; else avoidrepeat = 0. Return current. The last pick of a pass is excluded from the next pass's first pick. Span 0 is undefined (never on disc) | ported |
| 9 | RandomWeighted | +0 TABLE ptr (weights = its s8 entries), +4 min, +8 count, +12 current, +16 trig | trig ≠ 0 → r = draw mod 100; sum the signed weights in order and take the first i where (sum as u32) > r: current = min + i. If none, unchanged. Return current. A negative sum compares as huge and fires at once | ported |
| 10 | RangeTrigger | +0/+4 trip [lo,hi], +8/+12 reset [lo,hi], +16 s8 tripped, +17 s8 output, +20 input | input in trip range and not tripped → tripped = 1, output = 1, return 1. Otherwise: if outside the trip range and inside the reset range → tripped = 0; output = 0; return 0 (signed, inclusive) | ported |
| 11 | DelayTrigger | +0 f32 time (−1 = idle; all templates start idle), +4 s8 output, +8 trig (restart), +12 delay ms | trig ≠ 0 → time = 0. Then: time < 0 → return 0. time ≥ f32(delay) → output 1, time = −1, return 1. Else time += tick scale, output 0, return 0. A held restart re-zeroes every walk; it fires `delay` after the last walk with restart set, rounded up to a tick | ported |
| 12 | StateGenerator | +0 u16 offset of trig[] (self-relative), +2 u8 n, +4 current, +8 values[n] | the first i with trig[i] ≠ 0 → current = values[i]. Return current | ported |
| 13 | Merge | +0 u8 n, +4 trig[n] | 1 if any trig ≠ 0, else 0 | ported |
| 14 | Envelope | +0 u16 offset of control word (self-relative), +2 s8 prevcontrol, +3 u8 segment, +4 f32 remaining, +8 f32 delta, +12 f32 output, +16 u8 nseg, +18 s16 release segment, +20 f32 initial, +24 {f32 duration ms, f32 target}[nseg] | see §4.1 | ported |
| 15 | Table | +0 TABLE ptr, +4 prev input, +8 output, +12 input | see §4.2 | ported |
| 16 | DelayLine | +0 u16 offset of {value, delay ms} (self-relative), +2 u16 slots, +4 u16 in, +6 u16 out, +8 prev delay, +12 ring[slots] | see §4.3 | ported |
| 17 | Mux | +0 u8 n, +4 control, +8 inputs[n] | 1 ≤ control ≤ n → inputs[control−1]; else 0 | ported (called "stack top") |
| 18 | Demux | +0 u8 n, +2 s16 prev, +4 control, +8 value, +12 outputs[n] | outputs[prev−1] = 0 first (prev starts at 1 on all disc blocks); then if 1 ≤ control ≤ n: outputs[control−1] = value, prev = control. Return outputs[0] | ported (called "stack push") |
| 19 | Minimum (n-ary) | +0 u8 n, +4 inputs | min of inputs[0..max(n,1)] (signed) | **not ported** (unused) |
| 20 | Maximum (n-ary) | same | max (signed). Retail `sub_82B1CEF8` confirmed | **not ported** → those banks fail |
| 21 | Scale | +0 u8 n, +4 f32 scale, +8 inputs | acc = f32(in[0]); for i in 1..n: acc = f32(acc·f32(in[i])); acc = f32(scale·acc); return round(acc). in[0] is used even when n = 0 | ported |
| 22 | Add | +0 u8 n, +4 inputs | wrapping sum of inputs[0..max(n,1)] | ported |
| 23 | Subtract | +0 a, +4 b | a − b | ported |
| 24 | Multiply | +0 a, +4 b | a·b (low 32) | ported |
| 25 | Divide | +0 a, +4 b | b = 0 → 0; i32::MIN/−1 → 0; else a/b truncated | ported |
| 26 | Modulo | +0 a, +4 b | b = 0 → 0; else a − (a/b)·b with the same quotient rule (sign of a) | ported |
| 27 | Player | §1.6 | see §5 | host op |
| 28 | Oscillator | +0 u8 waveform, +4 f32 phase, +8 period ms, +12 amplitude | see §4.4 | ported |
| 29 | Ramp | +0 f32 current, +4 f32 delta, +8 prev target, +12 prev duration, +16 duration ms, +20 scale (4096 = 1×), +24 target | see §4.5 | ported |
| 30 | AddMaximum | +0 u8 n, +4 max, +8 inputs | min(wrapping sum, max), signed; in[0] used even when n = 0 | ported |
| 31 | SubtractMinimum | +0 min, +4 a, +8 b | max(a − b, min) | ported |
| 32 | MultiplyMaximum | +0 max, +4 a, +8 b | min(a·b, max) (low 32, signed) | ported |
| 33 | Minimum2 | +0 a, +4 b | a < b ? a : b | ported |
| 34 | Maximum2 | +0 a, +4 b | a > b ? a : b | ported |
| 35 | Scale2 | +0 f32 s, +4 a, +8 b | round(f32(f32(f32(b)·f32(a))·s)) | ported |
| 36 | Add2 | +0 a, +4 b | a + b | ported |
| 37 | Function | the FunctionState: +25 u8 trig, +28 values | return trig, then clear it; programs copy the values out with pairs | ported |
| 38 | ControlClass | +0 class handle (8 B), +8 post node, +12 u8 clamp flag, +13 u8 n, +16 [n × {min,max}], then inputs {construct, destruct, params[n]} | see §4.6 | **not ported** → Tazer fails |
| 39 | SetGlobalVariable | +0 global handle (8 B), +8 min, +12 max, +16 prev, +20 value | value ≠ prev → prev = value; set the global to clamp(value, min, max) (§2.7). Return 0. On disc prev = value = 7FFFFFFE initially, so nothing is published until the first real value | host op |
### 4.1 Envelope (op 14) [P, matches N]
Control word: 1 play, 2 hold, 3 release, anything else stop. Programming a segment means:
remaining = duration; delta = (target − value_from) / duration × tick scale. In f32, value_from is the
current output, or for a successor segment the target just reached.
Evaluate in this order:
- **Start:** control = 1 and prevcontrol = 0 →
- output = initial, segment = 0, program segment 0.
- **Release jump:** else, control = 3 and prevcontrol ≠ 3 and segment < release segment (signed 16-bit
compare) →
- segment = low byte of the release segment; program it from the current output.
- **Run:** else, if control is 1 or 3:
- if segment < nseg: remaining −= tick scale.
- remaining > 0 → output += delta.
- else → output = target[segment], segment += 1. If segment < nseg, program the next segment from
that target; else output = 0.
- if segment ≥ nseg: output = 0.
- **Hold:** control 2 → output unchanged.
- **Stop:** any other control → output = 0.
Finally prevcontrol = low byte of the control word, re-read after the stores. Return round(output).
**The output drops to 0 after the last segment**, so authors end on a hold or repeat.
### 4.2 Table (op 15) [V]
1. input == prev → return the cached output (no work).
2. prev = input; idx = clamp(input, min, max) − min.
3. **Resolution exactly 1.0 (nearest):** output = entry[idx], sign-extended. No bounds check: idx can run
past count if max − min ≥ count, which does not happen on disc.
4. **Else (linear):**
- x = f32(idx)·res;
- i0 = round(x − 0.5). That is floor(x) for x > 0, **but −1 for x = 0**, which reads the bytes just
before the entries (the resolution field). For s8/s16 the result is still exactly entry[0]; s32
tables are all nearest, so this never matters on disc.
- frac = x − i0;
- i1 = min(i0 + 1, count − 1);
- output = round(fused(entry[i1] − entry[i0], frac, entry[i0])) in f32.
5. Store and return the output.
nfsmw truncates i0 instead; Skate rounds. Keep Skate's rule.
### 4.3 DelayLine (op 16) [P, matches N]
1. If the input's delay ≠ prev delay:
- prev = delay;
- if delay < 0, write 0 **back into the input word**;
- offset = trunc(f32(f32(delay)/tick scale) + 0.5);
- if offset ≥ slots (signed), offset = slots − 1;
- in = out + offset (u16).
2. Wrap: if in ≥ slots, in −= slots; if out ≥ slots, out = 0.
3. ring[in] = value; result = ring[out]; in += 1, out += 1 (u16). Return the result.
4. The write comes before the read, so offset 0 passes the value straight through.
### 4.4 Oscillator (op 28) [V table, P code]
1. period ≤ 0 → return 0, no state change.
2. inc = f32(tick scale / f32(period)).
3. While phase ≥ 1.0: phase −= 1.0. A NaN or infinite phase hangs retail; guard it in the port.
4. Sample by waveform:
- **0 sine:** u = round(phase·1024); quadrant q = (u >> 8) & 3; i = u & 255. s = T[i], T[256−i],
−T[i], −T[256−i] for q = 0..3. sample = f32(f32(f32(s)·amp)·(1/65536)).
- **1 square:** phase < 0.5 → 0, else amp.
- **2 saw:** phase·amp.
- **3+ triangle:** phase < 0.5 → phase·amp·2; else (1 − phase)·amp·2 (f32, left to right).
- On disc: sine 169, triangle 154, square 48, saw 1.
5. phase += inc (every path). Return round(sample).
**Quarter-sine table T (257 u16, image 0x82FD36B8) [V]:** T[i] = min(65535, floor(65536·sin(i·π/512)))
for i = 0..256. Exact on all 257 entries (checked with f64; `aems_image_consts.py` (local tool)). Generate
it; don't copy it.
### 4.5 Ramp (op 29) [P, matches N]
1. f32(target) == current → return target.
2. If target ≠ prev target or duration ≠ prev duration:
- store both;
- duration ≤ 0 → current = f32(target), return target;
- delta = f32(f32(f32(f32(target) − current) / f32(duration)) · tick scale) · (1/4096).
3. current = fused(f32(scale), delta, current).
4. Overshoot: if delta < 0 and current < target, or delta ≥ 0 and current > target → current = target.
5. Return round(current).
With scale 4096 (714 of 800 on disc) the ramp spans `duration` ms. Scale 0 (79) freezes it until the
target or duration changes and a re-derive with duration ≤ 0 snaps it.
### 4.6 ControlClass (op 38) [V, retail sub_82B1C2B8]
It owns a child post: a nested class instance, e.g. c_tazer → `c_tazer_grn_play`.
- **destruct ≠ 0:** if the node exists, release it (§2.5) and clear it.
- **else construct ≠ 0:** if no node yet, clamp the params (if flag), post to the class with the params,
and store the node (0 on failure). Construct does not post again while a node exists.
- **else** if a node exists: clamp the params (if flag) and redeliver them.
- **Return:** the node's refcount if a node exists, else 0. The refcount is 1 for the owner, plus 1 for
each destructor or ClassData client of the child's live instances. So > 1 means "the child instance is
alive".
- The module's numClassControllers list lets Destroy release these nodes.
---
## 5. Players (op 27) and voices
### 5.1 Op 27 per walk [P, matches N; retail sub_82B1D240]
1. c = clamp(playcontrol, 0, 2).
2. **If c ≠ prevplaycontrol[0]:**
- c = 0: if a voice exists → release it, then clear the handle and the outputs (timeleft = timecurr
= 0 when updateoutputs).
- c = 1:
- a voice exists → resume it;
- otherwise, unless (prev[0] = 2 and prev[1] = 1) — a voice that died while paused is not
restarted —
- k = sampleselect clamped to [0, count−1];
- entry k's sample index `FFFF` → no voice (outputs cleared);
- otherwise **open** a voice (§5.2).
- c = 2: if a voice exists → pause it.
- Then prev[1] = old prev[0]; prev[0] = c.
3. **If c = 1 and a voice exists:**
- Push every input whose value ≠ applied (in record order; applied = value afterwards, unclamped).
- Query the voice. If it has ended: release the handle, clear the outputs, and the op returns 0.
- Else, if updateoutputs: timeleft = remaining ms, timecurr = elapsed ms (and 8 extra words if +17).
4. Return 1 (playing), 2 (paused with a voice) or 0 (no voice).
**Consequences:**
- **Restarting needs a 0 → 1 edge:** a voice that ends naturally leaves prevplaycontrol at 1. To play
again the program must drop playcontrol to 0 for at least one walk. This is how relays and re-triggers
are built (water_fountain alternates two players).
- On open, all inputs are pushed (open applies the full set). Query and outputs happen **in the same
walk**, so timeleft is valid immediately after the open.
### 5.2 Voice open (device `sub_824A3140`) — what AEMS hands over [P]
- the sample: S10A slot = entry's s16 index, data = sfx bank + offset[index];
- the entry's level % → per-voice player gain ×0.01;
- the azimuth bytes (per channel; mono uses the first) → initial pan in 65536 = 360° units;
- the bank's stream path and (stream offset + entry offset): RAM samples only on disc;
- the input list: routing codes are read from records with id ≥ 9 (§5.4); every input is then set once.
The voice graph is out of scope here; see `rwaudio-prior-work.md` and `aems-reference.md`:
SndPlayer1 → Rechannel → Resample → HighPassIir2 → LowPassIir2 → [Send] → Gain → [Send] → Pan2D1 → Send.
### 5.3 Remaining/elapsed ("timeleft/timecurr")
Retail query `sub_824A2DD8` [V]:
- alive = the player's state byte ≠ 2;
- duration = sample frames / sample rate, from the stream header;
- elapsed = the SndPlayer status time, **only once the player has reached this voice's play request**
(else 0);
- remaining = trunc((duration − elapsed)·1000), elapsed = trunc(elapsed·1000), in ms.
**Pitch:** duration is in source frames. The 2005 code computes time from the source frame position.
So **remaining is source time, not wall time**: at pitch p the real time left is remaining/p. This is
[V] for the duration and [N] for elapsed; Skate's status time field is not re-traced [?].
- Consequence: water_fountain (pitch 0.82–0.94) starts its relay later in wall time than a pitch-1 mock
predicts.
- 2005 reports 0 time-left for sustained loops [N]. Skate's loop behaviour is unknown [?]: the PoC mock
reports 1e6 ms for loops.
### 5.4 Routing codes at open (ids ≥ 9) [P]
For each input record with id ≥ 9, in order, the value v selects:
- **v ∈ {4096, 8192, 16384}:** the effect bus, 16384 = the second one. It **consumes the next two
records:** the first must be 1 (enable), the second is the level /32767.
- **v ∈ {512, 2048}:** fixed routing buses.
- **v ∈ {256, 1024}:** nothing.
- **v ∈ 0..7:** output bus = material bus v.
- **v ∈ 10..17:** output bus = bus v−10 of the second set.
- anything else: no effect.
Effect-send level = id 11 later. Exact bus meanings belong to the mixer port.
### 5.5 Setter clamps and units (summary)
See §1.6. id 2 is the master level, and dry (8) and send (5) are each multiplied by it.
---
## 6. Per-bank behaviours to reproduce (measured on the PoC)
All with the PoC mock device:
- a voice is alive while now < open time + sample duration;
- remaining/elapsed come from wall time at pitch 1;
- c_emitter payload `w0..w8 = 32767,32767,0,0,4096,25000,0,0,<selector>` unless stated.
| Bank / class | Measured (PoC) | Notes |
|---|---|---|
| water_fountain (sel 81) | **17 starts in 30 s** (re-run 2026-10-02): 0.059, 1.787, 4.091, 5.531, 7.163 … 28.923 s. Each new voice opens 192 ms (6 walks) before the previous ends | `ems-emitters-re.md` says "about 24 in 30 s": **not reproduced**, treat 17 as the PoC truth. Retail spacing will be longer (remaining is source time, pitch < 1) |
| water_lapping (sel 82) | 23 starts in 30 s | same relay |
| Siren_city_1 (sel 225, w0 = 20000) | 2 layers: 0.027 s and 0.219 s = 6 walks apart | retail Siren_city_4: +16 / +216 ms (recomp trace) ✓ |
| c_main_ambience_crossfade (w9 = group) | 4 voices open in the same walk (one group of 4) | 7 groups × 4 voices in DT/Ind/Uni |
| c_emitter_utility | 2.5 s timers re-drawing random_pitch/vol globals | function round trip broken in PR #4 (§11) |
---
## 7. Implementation outline for the native port (behaviour, not code)
- **Load:** parse the csi files into symbol tables. Parse each bank, apply rebases as offsets, resolve
interface references into handles, and register modules on class records. Reject opcodes ≥ 40 and
programs whose block walk ≠ datasize.
- **Instance memory:** a byte buffer per instance (template copy). Ops read and write big-endian words in
it at block offsets, exactly as on disc, so programs need no translation.
- Pointers inside templates (TABLE, sample group, bank) stay bank-relative and resolve through the
bank.
- Handles become {table, index, generation} triples in a side table keyed by their offset.
- **Clients:** destructor, ClassData, GlobalVariable and Function subscriptions hold (instance id, state
offset). Calls are synchronous, as in retail.
- **Tick:** counter-based (every 6th block), tick scale 31.999998 f32. The walk runs over a Vec of
instance ids in newest-first order; removals take effect immediately but must not skip the next one.
- **Voices:** a trait with open/release/pause/resume/set/query.
- The game backend maps it onto our mixer.
- The test backend is the PoC-equivalent mock (§8.2).
- Query must report **source-time** remaining for retail parity.
- **RNG:** a struct {w0..w5}, zero at engine start, one per engine; deterministic.
- **Posting API for game code:** post(class, payload) → node id; redeliver(node, payload);
release(node); call_function; set_global.
- Posts and redeliveries from the game thread are queued and applied at block boundaries, before that
block's tick.
---
## 8. Test plan
### 8.1 Unit tests per op (no banks)
- One test per opcode from this spec: edge cases (empty counts, out-of-range controls, the wrap paths,
Envelope start/release/end, Table x = 0, DelayLine delay change and offset 0, Ramp overshoot and
scale 0, Oscillator quadrants 0–3 and u = 1024).
- RNG: the first draws from zero are 0, 1, 7; state after 3 draws = [7, 6, 5, 4, 3, 3]. Test a full
ripple (w5 = FFFFFFFF, others 0).
- Shuffle: span arithmetic and "no repeat across the wrap" over 10,000 draws.
- Period: δ = f32(256/48000), D = 30 → count 6, tick scale 31.999998; the first walk on the 6th call.
### 8.2 Golden vectors from the PoC evaluator
Use the PoC's `emitter_probe` (PR #4 engine in a local worktree; local only, never
committed; build incrementally in its own target dir).
**Fix the PoC first (local edits only):**
1. **op 5:** pass the block's +0 handle, not the inputs word (§11). Without this no Function is ever
delivered.
2. Implement **ops 19, 20 and 38** per §4.
3. Add a trace hook to the interpreter. Per walk, log: walk number, block index, time; then per instance
(in walk order): module name, and for each op: opcode, block offset, result. Add a hex dump of the
instance buffer every N walks.
4. Log the 6 RNG words before every draw.
5. Keep the mock device:
- alive = now < start + duration;
- remaining = trunc((end − now)·1000), elapsed = trunc((now − start)·1000);
- loops alive forever with remaining 1,000,000;
- now = block index × f64(f32 δ).
- Also log OPEN (sample index, level %, azimuth bytes, stream offset, record count), SET (id, value),
QUERY results, RELEASE, PAUSE and RESUME.
**Capture set (one post each; 30 s unless noted; seed RNG = 0; δ f32):**
| Bank(s) | Post | Exercises |
|---|---|---|
| water_fountain, water_lapping, water_lapping_pond | c_emitter sel 81/82/83 | relay, shuffle (8), table on timeleft (15), oscillators (28), Scale2 (35), Player restart edges |
| trees_rustle, pub_amb | c_emitter sel 76/33 | loop player, AddMaximum gusts, single start |
| Siren_city_1 + emitter_utility | utility at boot (`PROBE_UTILITY=1`), then c_emitter sel 225; post twice 25 s apart | Function call (5/37), shuffle → SetGlobalVariable (39) → GlobalVariable (2) → sampleselect, the "previous draw" rule (§2.7) |
| DogBarks_medium, misc_bangs_distant, Bird_calls_song | with utility | more function/global pairs, weighted/random picks |
| Main_Ambience_Crossfade_DT | c_main_ambience_crossfade, w9 = 1..7, plus a w9 change mid-run | Mux/Demux (17/18), quad pans |
| GRINDS | Class_grind post + 6-walk redeliveries (PR #4 grind_instance payloads) | redelivery, ramps, StateGenerator |
| fstep_skateshoe1_sm | playercharacter_footstep with the msgs1 payloads | many players in one walk |
| Foley_Cloth | cloth_trick | op 39 with player banks, op 5 |
| Seams_Bank, Bodyslide, Security_Alarm_1 | their classes | Envelope (14) |
| PatchBank_RocksBounce, Traffic_Skid | Class_rolling / traffic | RandomWeighted (9) |
| car_alarms, arena_cheers | their classes | Maximum n-ary (20) |
| Tazer | c_tazer, release after 3 s | ControlClass (38) create/update/release, child lifetime |
| any c_emitter bank | post, release at 5 s | Destroy on release, voices released |
Store the goldens locally (one text file per bank), never committed. The native harness replays
the same posts, redeliveries and releases at the same block indices, uses an identical mock device, and
must match **every op result and every device call bit for bit**. Diff the first divergence and stop.
### 8.3 Retail checks (later, passive only; no repeat sessions)
Recomp trace hooks:
- the RNG words at the first AEMS draw (seed question);
- the δ passed to `sub_82B1E290`;
- `sub_824A2DD8` outputs for a pitched voice (source vs wall time, loops);
- op-5 calls (`sub_828E29C0` handle + params);
- op-39 publishes.
Compare to `Siren_city_4` layer times (+16/+216 ms) and the location-set firings already recorded.
---
## 9. Constants (facts)
| Address | Value | Use |
|---|---|---|
| 0x82FD3600 | 40 × u32 | opcode table (slot → function: see PR #4 eval/mod.rs table) |
| 0x82FD35F4 | f32 41.6 boot → 30.0 after `sub_826D4C30` | period denominator D |
| 0x8231A844 | 1.0 | period numerator, phase wrap, "nearest" test |
| 0x82256FE8 | 1000.0 | tick-scale unit (ms) |
| 0x830775D8 / DC / E0 / E4 | tick scale / cached δ / period / countdown | evaluator timing |
| 0x830775F0 | 6 × u32, 0 at boot | AEMS RNG |
| 0x83036F4C | list head | instances (timer clients) |
| 0x8216DEE0 | −1.0 | DelayTrigger idle |
| 0x822F890C | 1/4096 | Ramp unit |
| 0x822F8EA4 | 1024.0 | sine phase scale |
| 0x82098D0C | 1/65536 | sine normalisation |
| 0x82060C50 | 2.0 | triangle gain |
| 0x8209975C | 0.5 | rounding |
| 0x82FD36B8 | 257 × u16 | quarter sine (formula §4.4) |
| 0x822F8890 | f64 1000.0 | query ms unit |
| 0x8302D4A4 / 0x8302EE28 | 72 game classes / handle slots | game-side object table |
Functions: tick+interpreter `sub_82B1E290`; bank install `sub_82B1DF50`; post `sub_828E2B48`; redeliver
`sub_828E2D18`; release `sub_828E2C78`; call function `sub_828E29C0`; set global `sub_828E2F38`; create
instance `sub_82B1DAD0`/`sub_82B1D880`; destroy `sub_82B1C150`/`sub_82B1BF98`; RNG draw `sub_82B1F360`;
player `sub_82B1D240`; voice open `sub_824A3140`; voice query `sub_824A2DD8`; csi install `sub_828E2818`.
---
## 10. Open questions
1. RNG initial state in a live retail session (expected all zeros).
2. Remaining-time semantics for pitched and looping voices (status time = source position?).
3. Sample-group azimuth bytes 4–8: per-channel angles, or `{…, slot+1, FF}` metadata?
4. Player +16 (sampletype `FF`) and +17 (extra outputs): any Skate reader?
5. Who in game code sets globals or calls functions directly (e.g. the "G" writer)?
6. Siren second layer: does it read the freshly published `*_snd` value (§2.7)? Golden plus retail.
7. Header byte 9 = 3, and the export kind's low 24 bits.
## 11. Corrections to earlier notes and to PR #4
- **PR #4 op 5 bug [V]:** retail `sub_82B1C210` passes the **block's own +0 handle** to the
function-call helper, with params at inputs+4. PR #4 passes the inputs word (the trigger flag) as if it
were a handle.
- So every CallFunction is either skipped (trigger 0) or treated as an "unmapped symbol 1".
- The "literal 1 placeholder in Rolling_Rattles" in PR #4 docs is this bug.
- Result: no `*_msg` is ever delivered in PR #4 or the PoC, so `*_snd` sample counters never advance.
PoC data for function-driven banks (sirens, dogs, birds, rolling broadcasts) is suspect wherever
sample choice matters.
- **Ops 17/18** are Mux/Demux, not a stack. **19/20** are n-ary min/max (20 used by 4 banks, not by the
Rolling banks as `aems-reference.md` said). **38** is ControlClass, used once (Tazer).
- **Header +0x08** is platform byte 5 + byte 3 + u16 module count, not a "variant word"
(0x05030001/2/3 = 1/2/3 modules).
- **"Pure ops then host ops":** wrong. Ops run strictly in program order.
- **Csi tables:** 0 = Functions (`*_msg`), 1 = Classes, 2 = GlobalVariables (with default value).
- **Tick scale** is 31.999998, not 32.0. Remaining time is in ms of **source** time.
- **water_fountain** "24 starts / 30 s" did not reproduce; the PoC gives 17.
## Helper scripts
Local scripts (the survey is published as `tools/audio-file-inspect/aems_survey.py`, `fn.sh` as
`tools/recomp-code-search/fn.sh`):
- `aems_survey.py`: header/module/program/template/curve census and invariants; `--dump X.abk`.
- `aems_image_consts.py`: image constants, RNG words and sine-table formula check.
- `fn.sh`: dump a recompiled function.
@@ -0,0 +1,212 @@
# Board grain bus chain (`sub_824C8878`): spec for the native port
Scope: the four per-player bus chains of `SFXObj_SkateBoard` (2 trucks × players A/B) and everything the owner posts
to them per frame — FrequencyShiftSsb, graph 3 (clip / wobble / shelf), the graph-1 level ramp, the gain wobbles, the
graph-2 sends. Extends `grain-player-spec.md` §3.1–3.2 (read that first). Written 2026-10-02 from the TU3 lifted asm
(`tools/recomp-code-search/fn.sh`), image constants (`img.py` (local tool)) and vault fields (`find_field.py`).
Port: `crates/skate-audio/src/dsp/fss.rs`, `dsp/shelf.rs`, `grain/chain.rs`, `grain/bed.rs` (work copy until merged).
Tags: [R] read in the asm, [IMG] image constant, [V] vault value, [TR] matched in a retail trace, [U] UNCERTAIN.
## 1. Chain topology (`sub_824C8878`) [R]
Plug-in ids looked up by FourCC: `Sub0`, `HI20`, `LI20`, `FSS0`, `Pn21`, `Sen0`, `Gai0`, `HS20`, `DCl0`. Each module
entry is {config ptr, plug-in, channel byte}.
| graph | order | modules (index) | notes |
|---|---|---|---|
| 1 | 2 | 0 Sub0 · 1 HI20 · 2 LI20 · 3 FSS0 · 4 Sen0 (→ graph 3) · 5 Gai0 · 6 Sen0 (→ graph 2) | mono; FSS config {0x7FF7FFF1, **0.0**} |
| 2 | 5 | 0 Sub0 · 1 Sen0 (→ `[[mgr+116]]`) · 2 Sen0 (→ `[[mgr+52]]` env) · 3 Pn21 (6 out) · 4 Sen0 (→ bus) | 1, 1, 1, 6, 6 ch |
| 3 | 3 | 0 Sub0 · 1 DCl0 · 2 Gai0 · 3 HS20 · 4 Sen0 (→ graph 2) | local player only (`[owner+16]+72`) |
- Owner slots per chain (stride 24 from `+1192`): +0 graph-1 modules, +4 graph 1, +8 graph-2 modules, +12 graph 2,
+16 graph-3 modules, +20 graph 3. Player B of a truck = +24, truck 1 = +48.
- `mgr` = `[0x830CFDEC]` (bus manager). `+52` = EnvSendSub input (env/reverb network, `aems-env-bus-spec.md`);
`+116` = **FlangeSub return A** input (`aems-env-bus-spec.md` §7) — not ported (no FlangeSub bus in the crate).
- Graph 2's last Sen0 target = `sub_82491108(mgr, enum of field A5D3ADA63608617F, [owner+28]+72)` = eEQChain 8 =
the default bus (`aems-eqchain-buses-spec.md` §1).
- Posted at build: graph-2 Sen0 #1 level **0.0**; graph-1 Sen0 #4 (→ graph 3) level = `+1556` (current send);
DCl0 p0 = `+1528` (0.09), HS20 p0 = `+1548` (5000), p1 = `+1552` (0.65). Never posted (Sen0 / Gai0 defaults 1.0):
graph-1 Sen0 #6, graph-3 Sen0, graph-2 Sen0 #4, and both Gai0 until the wobble posts (§4).
- Built on every bind (two call sites); a rebuilt chain starts from module defaults: FSS phase/histories 0, Gai0 1.0.
- Pass order 2 < 3 < 5 within one block: graph 1 adds into graph 2's Sub0 first, then graph 3. The env send is
mono and **before** the panner (graph 2 is mono until Pn21).
## 2. FrequencyShiftSsb (`FSS0`) [R][IMG]
Functions: process `sub_82B22898`, constructor `sub_82B22770`, size `sub_82B22738`, descriptor `0x82FCE2A4`
("FrequencyShiftSsb"); param 0 = shift Hz (`+52`, range ±96000 = doubles at `0x82FCE268`).
Per 256-frame block (mono, channel 0 only):
1. **Hilbert pair**: four biquads through the shared kernel `sub_82B43AF8` (the HPF/LPF/HS20 kernel; coefficient
layout {a1, a2, b0, b1, b2}, history {x1, x2, y1, y2} at `+56/+72/+88/+104`):
I = AP(`0x82FCE2B0`) → AP(`0x82FCE2C4`); Q = AP(`0x82FCE2D8`) → AP(`0x82FCE2EC`), both from the input.
Fixed image constants (allpass: b0 = a2, b1 = a1, b2 = 1):
| section | a1 | a2 |
|---|---|---|
| I1 | −1.9228750 (0xBFF620C5) | 0.92365128 (0x3F6C7469) |
| I2 | −0.40179494 (0xBECDB811) | −0.21314885 (0xBE5A43B1) |
| Q1 | −1.9677674 (0xBFFBDFCD) | 0.96786267 (0x3F77C5D9) |
| Q2 | −1.2627572 (0xBFA1A207) | 0.34741214 (0x3EB1E001) |
Design (f64 response): I leads Q by 90° ± 0.6° from 100 Hz to 1 kHz, 6.1° at 8 kHz, 10° at 15 kHz, falls apart
above 18 kHz / below 50 Hz; image rejection −47 / −74 / −25 dB at 100 Hz / 1 kHz / 8 kHz.
2. **Oscillator**: Δ = f32(f32(shift / rate) × 2π) (`fdivs`, `fmuls`, 2π = `0x820B411C`); lanes {φ, φ+Δ (fadds),
fma(Δ,2,φ), fma(Δ,3,φ)}, each group of 4 adds 4Δ (`vaddfp`). φ = `+260`.
3. **out = I·cos φ − Q·sin φ** (products rounded, then `vsubfp`). Sign checked: a tone at f moves to f + shift.
4. Trig: `sub_824531C8` = XMVectorSin, `sub_82473930` = XMVectorCos: V = fma(−2π, round_even(x·1/2π), x)
(`vrfin`, constants `0x822F9850`: π, 2π, 1/π, 1/2π), then 11 Taylor terms (sin V³…V²³ `0x822F97C4..EC`, cos
V²…V²² `0x822F97F4..981C`) with `vmaddfp`; power pairing as in `fss.rs` docs. Max error vs libm 6e-7.
5. Block end: e = fma(Δ, 256, φ); φ' = fma(−trunc(e·1/2π), 2π, e) (`fctiwz`, `fnmsubs`; sign kept, |φ| < 2π).
- No smoothing (the shift applies from the next block), **no bypass**: at 0 Hz out = I exactly (allpass copy).
- Construction param 1.0 (the default) adds a 64-tap band-limit FIR before the pair (`sub_82B42510` designs a
windowed band-pass [max(0, −ωs), min(π, π − ωs)], `sub_82B41D58` runs it; size 296 + ch·256): **off** in this chain
(config 0.0) → not ported.
## 3. Graph 3 modules
- **DCl0** (`sub_82B22678`): clamp ±0.09; bypass ≥ 100 / NaN (already `dsp::peaking::clip`).
- **HS20** (`sub_82B26740`, coefficients `sub_82B43D78`) [R]: ω = f32(fc/rate)·2π; bypass if ω ≥ CEIL (3.1384511)
or gain == 1.0 (histories cleared once, the pair (ω, gain) cached); else ω raised to FLOOR, coefficients rebuilt
when (ω, gain) ≠ cache. A = √gain, α = sin ω × 0.70710653 (`0x822F8E50`), RBJ high shelf S = 1 in this order:
a0 = fma(√A·α, 2, (A+1) − (A−1)cos); b0 = A·((2√Aα + (A−1)cos) + (A+1))/a0; b1 = −2·A·((A+1)cos + (A−1))/a0
(−2.0 = `0x82094178`); b2 = A·(((A−1)cos + (A+1)) − 2√Aα)/a0; a1 = 2·((A−1) − (A+1)cos)/a0;
a2 = (((A+1) − (A−1)cos) − 2√Aα)/a0 (fused steps as written in `shelf.rs`). Plateau above 5 kHz = gain 0.65.
- **Gai0** (graph 3) = the wobble without the level ramp (§4).
## 4. Owner side, per frame (`sub_824C6A78` process, before the MixMap evaluation; dt = frame time, s) [R]
Order: `sub_824CA738` slope → `sub_824C6198` push envelopes (trigger, then advance both by dt) → **`sub_824C9058`**
→ … → `sub_824CA448` seam envelope → **`sub_824CAEC0`** → **`sub_824CB180`** (→ `sub_824CB078`).
### 4.1 `sub_824C9058` (per truck with +1328+t running and +1320+4t == 1)
- HI20 = vfunc64(12), LI20 = vfunc64(11) (both players' graph 1).
- Manual latch updated inline (+1504; set by state +340, cleared at wheel count 0 or 4); **the trick latch
(`sub_824CA6E0`) is only evaluated when the manual latch is clear**.
- FSS A = (special ? `special_shift` [truck's collection, F62BC5EBD8E5DDE8] : 0) + (push-shift env not done ?
`+1152` : 0), then if D (`+1508`) > 0: fma(primary-collection +80 = A shift/slope, D, ·).
- FSS B = truck collection +88 (B base shift) + (push shift if running), then if D > 0: fma(D, primary B shift/slope
[7FFF3A8AD44809EF], ·).
- Pn21 (both) = raw(0) × 0.0054932479 (`0x822F8C64` = 360/65535). Env Sen0 (both) = level(13) × 1/32767.
- Local player: graph-2 Sen0 #1 = level(21)/32767 (A), level(22)/32767 (B).
### 4.2 Push shift envelope (`+1036`, value `+1152`, done `+1156`)
Retail's segment envelope (`sub_8248D368` reset → value 0, done; `sub_8248D3C0` add(start, end, ms): duration =
ms × 0.001 (≤ 0 → 0.01), first segment sets value = start; `sub_8248D498` append(end, ms): start = previous end;
`sub_8248D510` advance(dt): t += dt; if t > dur: value = end, next segment with t −= dur (or done); else linear
value = fma(end − start, t/dur, start); kinds 1/2/5 = quadratic / ease-out / table, unused here). Push: 0 → peak
(lerp(low, high, t) of the primary collection) over attack, hold, → 0 over return.
### 4.3 `sub_824CAEC0` (local player): send and level
kmh = state +208 × 3.6 (`0x822F8628`).
- send = kmh ≥ 70 ? 3.0 : kmh ≥ 46 ? ((kmh − 46)/(70 − 46)) × 3.0 : 0 → `+1556`; posted to graph-1 Sen0 #4 of all
four chains only when it changes (and at every build). [TR exact]
- level `+1560` = kmh ≥ 74 ? 0.45 : kmh ≥ 52 ? fma(−(kmh − 52)/22, 1 − 0.45, 1) : unchanged (holds below 52).
### 4.4 `sub_824CB078` / `sub_824CB180`: the wobbles (local player)
Two records (A's chains `+1572`, B's `+1728`, stride 156): +0 ms_low, +4 ms_high (int), +8 low, +12 high, +16
previous target e, +20 graph-1 gain, +24 last posted, +28 graph-3 gain, +32 envelope (value +148, done +152).
Init (`sub_824C59C8`): e 0, gains 1, envelope reset (done → the first frame draws).
- Per record: if done → ms = ms_low + r % (ms_high − ms_low); range = trunc((high − low) × 100); target =
fma(r % range, 0.01, low) (two title-generator draws, `sub_82A8AF10` = `0x82FD7D74`), × −1 if the previous e ≥ 0;
reset; add(e, target, ms); e = target (no advance that frame). Else advance(dt).
- ramp = kmh ≥ 70 ? 1 : kmh ≥ 42 ? (kmh − 42)/28 : 0. g3 = fma(value, ramp, 1); g1 = g3 × level.
- Post loop: trucks 0, 1 × players A, B: if g3 ≠ last posted → last = g3, then (if the chain exists) graph-1 Gai0
= g1, graph-3 Gai0 = g3. The compare value is per player, so **truck 1's chains never get a wobble** [TR], and a
rebuilt chain keeps Gai0 1.0 until the next change (below 42 km/h: never — the held level is lost on a rebind).
- Non-local owners: `+1592`, `+1748`, `+1560` = 1.0, nothing posted.
### 4.5 Vault (class `6E878344774A7999`, `default`, read by `sub_824CA938`) [V]
| owner | hash | value | role |
|---|---|---|---|
| +1520 | 0D665393E2EDC605 | 46.0 | send start km/h |
| +1524 | 28E708782445747F | 70.0 | send end km/h |
| +1528 | E64C04ED542DABC8 | 0.09 | DCl0 level |
| +1532 | D900C07BE7C5450F | 3.0 | send max |
| +1536 | 88AA96B08FD16914 | 52.0 | level ramp start (exported `g1_level_start_kmh`) |
| +1540 | 3FFB5107C82BA3E0 | 74.0 | level ramp end (`g1_level_end_kmh`) |
| +1544 | D3E8894CA25A4F71 | 0.45 | level floor (`g1_level_floor`) |
| +1548 | 55BEB30353F244A9 | 5000.0 | HS20 corner |
| +1552 | 45516395725ED16B | 0.65 | HS20 gain |
| +1564 | 281A501B22B6CCDF | 42.0 | wobble ramp start |
| +1568 | 54CDE019E31FC04E | 70.0 | wobble ramp end |
| +1572 / +1576 | 36AE41817640FE04 / 71EE27313BD30F21 | 15 / 30 (int) | A segment ms |
| +1580 / +1584 | 437D128B53669C34 / 02885338DD5D7DCA | 0.0 / 0.30 | A |e| range |
| +1728 / +1732 | 2055BBF39C152FA9 / F5240AFADA3B3FFC | 5 / 15 (int) | B segment ms |
| +1736 / +1740 | F916E153393C5F24 / 0A36F90732016D85 | 0.10 / 0.25 | B |e| range |
## 5. Port (crate `skate-audio`)
- `dsp::fss::FrequencyShift` (+ `sin`/`cos` polynomials, `SECTIONS`), `dsp::shelf::HighShelfIir2`.
- `grain::bed`: `ChainValues` gains `fss_hz`, `graph3_send`, `graph3_gain`, `env_send`, `flange_send`;
`GrainBed::chain_extras` (default true; false = the older chain, bit-identical), `chain_tuning`;
`render_block` / `add_to` / `env_send` (runtime: bed first, its env mono added to `buses.env_in` after the voices,
`Mixer::render_with_env`). A truck's full chain runs from its first bind (retail builds it there).
- `grain::chain`: `Envelope`, `PushShift`, `fss_shifts`, `ChainTuning` (vault defaults), `ChainState`
(`frame`, `rebuilt`, `values`, `gains`), `ChainFrame`, `DEGREES_PER_RAW`, `PER_LEVEL`.
## 6. Validation
- Unit tests: allpass magnitudes / quadrature / tone shift (−60 dB image at 1 kHz) / 0 Hz = I path / phase wrap;
trig vs libm; HS20 vs f64 RBJ and plateau; envelope segments; send/level/hold; wobble bounds, alternation,
truck-1 quirk, rebuild; bed: +150 Hz shift of a tone, graph 3 silent at send 0, env tap = pre-pan mono × level,
extras off ignores the new values.
- Identity: `examples/grain_chain_probe.rs --mode old` = the pre-change copy (built with `--cfg baseline`),
byte-identical 12 s stereo (hash 7F07793579AFCFD5). Cost per block p50: 10.5 µs (old) → 37 µs (full chain, one
bound truck; f32 `mul_add` is a libm call without `+fma`).
- Retail (`chain_check.py` (local script), session `dsp_20261002_185826`, owner 40C33020):
graph-3 send = model exactly (median error 0.0000 over 189 posts, 4 chains); graph-1 Gai0 / graph-3 Gai0 = 1
below 52 km/h and = the level ramp above 53 km/h (|Δ| ≤ 1e-4); max |g3 − 1| / ramp = 0.290 (A, cap 0.30) and
0.240 (B, cap 0.25) — graph 3 `0x40C118A0` ↔ graph 1 `0x40C79BF0` is A, `0x40C11C20` ↔ `0x40C7A180` is B;
only truck 0's two graph-3 Gai0 ever change (the other graph 3s log only their default).
- Expected manual / trick effect: A × 0.65 (−3.7 dB, records) and A +150 Hz (FSS), B silent (I = 0): probe
30 km/h manual −38.21 dBFS (= old chain), centroid 1375 → 1502 Hz. High speed (send 3, level 0.45..0.87):
+5.7 dB from graph 3's clipped copy in the probe (60–85 km/h, concrete_rough_hard).
## 7. UNCERTAIN / not modelled
- FlangeSub return A (`[[mgr+116]]`, graph-2 Sen0 #1): not rendered; the level is carried in `flange_send`.
- Env send level vs retail: graph 2's mono Sen0s never appear in the SEND hook (`sub_82B31838`) in the dsp session,
so level(13) at the chain is untraced; our MixMap gives 0.079 at 30 km/h.
- Two SEND lines at 1.0 (t 27720 / 28690) where the model says 2.0 / 0: probably a rebuilt chain's first block at
the Sen0 default before its build-time post (game thread vs audio thread race); not modelled.
- XMVectorSin/Cos to the ulp: VMX flushes denormals and the recomp's `vmaddfp` fusing is assumed; no FSS oracle
vectors (PoC `dsp_vectors` has none).
- Release fold of a torn-down chain's env send (Sub0 deltas into the env bus) is not modelled; the dry fold is.
- The high-speed level jump (+5.7 dB) is the mechanism's result; not compared with a retail capture (the capture
also holds wind, rattle and rocket at those speeds).
- Trick latch only evaluated while the manual latch is clear (§4.1) — the existing `board::Latches` updates both.
## 8. Sources & credits
Retail behaviour read from the skate3recomp TU3 lifted code (reference only), image constants from the PoC's dumped
image, vault values from the converted collections; trace hooks in skate3recomp `src/research/hooks_audio.cpp`
(GREC / GAIN / SEND / MOD). Prior notes: upstream PR #4 (chain shape), `grain-player-spec.md`.
## 9. Listening reports 2026-10-02 (rolling busy / rough, rolling in the air) — evidence
Local tools: `air_check.py`, `surface_vs_retail.py`, `spectrum_compare.py`,
`transients.py`, `seams_rate.py`, `trucks_sounding.py`, `capture_windows.py`, `landing_ratio.py`; renders of the user
logs 200832 / 201324 / 203746 / 204047 in `e2e/` (main-repo `e2e_render`, old chain).
- **Air**: in all 48 air windows (> 0.2 s) of the four logs the bed's records are 0 from 100 ms after takeoff (MixMap
F0 on PlayerPhysics.in2 → C[Player] −10000 mB); retail (dsp session) A 0.06 → 0 in ≈ 80 ms. What keeps sounding in
the air is the rocket `x_jet_rolling` (≈ 0.06–0.10) and the wheel spin: retail does the same — jet grains keep
starting inside air windows with send 0.10–0.14 (0.09–0.12 before takeoff), SenseOfSpeed level(5) is not
wheel-gated (E74 has no F0 term). No change.
- **Surface (a)**: our tag = retail material + 1 at the same world position (WPPOS, same frame): 13 of 14 shared 10 m
cells of 204047 agree (tag 4 = concrete_rough; one boundary cell tag 64), mt_high 3/41 = retail 2/40.
- **Rendering (b)**: at matched speed on concrete_rough (retail all_20261002_204336 vs ours 204047): gain A
0.22–0.26 vs 0.22–0.24, position 0.70–0.72 at 40 km/h = the Bézier, pitch 0.99 vs 0.995, grain starts 6.7 vs 6/s,
one sounding truck (retail both trucks running 0.7–1.1 % of frames, ours 0 %), seam clicks 7.7 vs 9.9 /s at
30–40 km/h and 15.1 vs 15.9 /s at 40–50 km/h at ~0.05 per voice, rattle per push retail 0.32 vs ours 0.12, rocket
send 0.08–0.12 vs 0.05–0.09; octave spectrum of clean rolling within ≈ 2 dB per band (retail capture also holds
music/ambience). Retail-only parts left: this chain (A allpass, B −10 Hz FSS, env send level(13) ≈ 0.079,
graph 3 above 46 km/h). Capture-level ratios (landing over bed) are not usable (capture/trace alignment).
## 10. Listening test 8 (seams "too rough / too often / faster") — evidence
Local tools (`seam_stats.py`, `block_gain.py`, `retail_seams.py`, `retail_by_pattern.py`,
`retail_timeline.py`, `crossings.py`, `dataflow.py`, renders `e2e-before/-after/-off`).
- Cause outside the chain: Class_Seams' sample index = table(w10) + table(w8) + soft + `send_random_0_to_9a/b/c`. Those
globals are written only by `Common.abk`'s `Start_up_Play_ctl` (a boot utility, on `rnd_call`), which was not loaded.
Fixed (doc 11 "Listening test 8").
- Retail vs ours at matched pattern / speed: concrete pattern 8, 30–40 km/h: 21 voices/s (retail) vs 28 (ours, 36 km/h).
Brick, 30 km/h: 27–40 vs 29–31. Per-voice gain on concrete 0.052–0.074 vs 0.045–0.053. The speed word w8 is the
same as the asm.
- Graph 3 and the spidercrack layer 5 play no part on these concrete windows (≤ 44.7 km/h, patterns 2 / 8 / 11).
@@ -0,0 +1,267 @@
# Off-board footsteps, clothing and hands-on-deck — retail spec (2026-10-02)
Retail's `SFXObj_OffBoard` (controller `0x40010090`), `SFXObj_Clothing` (`0x40010060`) and the
Contacts owner's hand-on-deck sounds, read from the TU3 recompilation (`tools/recomp-code-search/fn.sh`;
local asm dumps, local tools `keys.py` (resolve the
lis/ori vault keys of a dump against a collection) and `surfaces.py` (per-material footstep fields)).
Port: `crates/skate-audio/src/player/{footsteps,clothing,step_on}.rs` (work copy until integrated).
Reference only: upstream PR #4 / #1, the PoC, skate3recomp code. Constants/offsets/addresses are facts.
Vault holders used below (audio table `*(0x830CFDA4)`, built by `sub_8289D5C8`):
`+92` = OffBoard tuning (class `C1831BDB6CB1B1EA` collection `1ABD2984D7248589`), `+136` = Clothing
(`A867FBE3454326FF` `default`), `+140` = eEQChain holder (`42AFE160E647167C` `default`), `+112` =
Contacts foley (`923CCB46EF5BF5BA` / `E0C3B44AB44F7B90`), `+36` = class `6EBA5BCD3E38A98A` `default`,
`+64` = AudioSurfaceMap (`C1831BDB6CB1B1EA` / `C489459A0C07D154`, field `4CA607558B1CF440`),
`+236` = `physics_skeleton` (`9F862C2EAA016CE5` `default`). Key `D7EDBD362D7D2152` = the `default`
collection.
## 1. Owner identification
- OffBoard vtable `0x822FCF98`: create `sub_824E8F98`, process `sub_824E9270` (+36), update
`sub_824E9628` (+40); factory `sub_824E8F08` names it from `0x8224A700` "SFXObj_OffBoard".
- Clothing vtable `0x822FCD10`: process `sub_824DBB68`, update `sub_824DCB98`.
- **Caller `824D8164` is not the player**: it is inside `sub_824D8078`, the process of vtable
`0x822FCCC8`, factory `sub_824D7E00` → "SFXObj_PedestrianSFX" (`0x8224A414`). Retail's
`sk8_foley` containers 62/63/64 by that caller (1017 events) are the pedestrians' footsteps. The
player's containers 62/63/64 come from caller `824E95BC` (= `sub_824E9D10`, the walking voices).
- Clothing's own Splice sounds are `sk8_foley` 73/74 (caller `824DBB94` = `sub_824DBBB8`).
## 2. OffBoard process `sub_824E9270` (per frame, before the MixMap tick)
Gate: `[owner+28]` record present and its `+52` active. Then:
1. OffBoard.in0 = 32767 if state `+716` (on foot) else 0 (vfunc 8, id 0).
2. Curve words (`sub_82481E10(16, x = rec+16, y = rec+80, v)`: v < x0 → y0; v ≥ x15 → y15; else
the first i with v < x[i]: dx = x[i] − x[i−1] > 0 ? (y[i] − y[i−1]) / dx × (v − x[i−1]) + y[i−1]
(fmadds) : y[i]; stored with `fctiwz`):
`+408` = walk(`+212` |COM v|) — curve `C3CD069BB1B16B58`; `+416` = xz(`+284`), `+412` = xz(`+288`) —
`CF844597AB96EAF8`; `+424` = vertical(`+292`), `+420` = vertical(`+296`) — `236311604A3C1FB5`.
(Points in `FootstepTuning::default`: walk 0 → 0, 2.0 → 205, 5.13 → 355, 6.03 → 409, ≥ 7.35 → ~512;
xz ≈ 512..529 above 0.3 m/s; vertical 2 below 0.5 m/s, 0 from 1 to 9.3, 3.5..8.3 above 9.45.)
3. Copies: `+52` = state `+724` (foot A down), `+53` = 0, `+236` = `+725` (foot B), `+237` = 0.
4. Footplant end (`+768` fell: `+460` was set, now clear): if `+54` (A was down) → `+464` = 10,
else if `+238` → `+468` = 10. Both counters then decrement (when > 0).
5. Poster `sub_824E9FD8`; if on foot: walking voices `sub_824E9D10`; jump voices `sub_824E9678(on
foot, footplant ended)`; `sub_824EBA08` (remote players only: sends message 0x2056/294 on leaving
foot — not modelled); stores `+404` on foot, `+460` = `+768`, `+54/+55/+238/+239` = this frame's
copies; splash `sub_824EBB58` (§2.5, not ported).
### 2.1 Poster `sub_824E9FD8`
- Materials: `+240` = state `+728` (foot B), `+56` = `+732` (foot A), 143 → 3.
- Packets: if holder `+220` (B) or `+36` (A) is empty, post (B first) `playercharacter_footstep`
(bank `fstep_skateshoe1_sm`, constructor `sub_824B73E0`, 25 words, all clamped):
`[0, 0, 4096, 0, 25000, 0, 0, 32767, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, eq+10]`,
eq = eEQChain `C014A21D0FF6EDBA` = 2 (→ 12: bus 2, created). Held for the owner's life.
- run = `+408` > `729D6290FB6A8E3B` (400); run mode = `+408` ≥ `E82237E2D18B5C5F` (400) (→ the step
selector's mode 2, else 0). Running starts near 5.9 m/s.
- Per foot with a rising edge (A: `+52 && !+54`, B: `+236 && !+238`) **and the local player**
(`[owner+28]+72`): create the holder's four FootStep SubMix graphs if missing (`sub_82494A68`),
stop its four slot sounds (`sub_82494B80` → `sub_824836B8` = destroy), then:
1. surface layer 0: (bank, id) = `sub_82493E60(run, bucket +300, 0, material)`; if id ≠ −1: R =
the sound's EQ record (`sub_82493448`), slot floats HPF = R+20, LPF = R+16, PI20 freq/gain/Q =
R+12/R+8/R+4, slot gain = `sub_824940F8` override or R+0; apply the submix (`sub_82494550`);
start the Splice sound into the slot's submix (`sub_82975700` + `sub_82975A60`, block
[0, 1, 0, dt, 1, 1]); then surface layer 1 the same way (gain R+0, no override). Layer 0 = −1
skips layer 1.
2. step layers 0 and 1: (bank, id) = `sub_82493690(mode, material, step code +740, layer)`; the
slot floats are not touched (the constructor's open EQ, gain 1 — `sub_824D7CE8`); apply; start.
- Holder layout (`sub_824D7CE8`): +0 packet, +8 env level (f32), +12 azimuth (int), slots at
+24 + 40k: +0 sound, +4 HPF 0, +8 LPF 96000, +12 PI20 96000, +16 gain 1.0, +20 Q 3.0, +24 slot
gain 1.0, +28 channels 6, +32 graph, +36 node list.
### 2.2 Selectors
- `sub_82494F58(m)` = AudioSurfaceMap word 6 (`+24`), the **footstep surface** (entry 94 for m ≥ 94).
- `sub_82493E60(run, bucket, layer, m)`: s = surface(m).
- s = 7 → bank 0 (Skate_Collisions), s = 5 → bank 1 (Skate_Metal); ids [layer 0, layer 1]:
bucket > 1: 519/518 (s7), 323/324 (s5); walk: 959/961, 455/458; run: 960/962, 459/460.
- else layer ≠ 0 or m ≥ 143 → −1; else `sub_824975D8(m, run, bucket)`: the material's
AudioSurface record (class `D40CB4C0FFE45676`, key = image table `0x8302D6E8` +8, kind = +0):
`+125` (`B1CF62EA632CF13F`) false → −1; bank = kind; kind 0: bucket > 1 → `+104`, walk `+64`,
run `+60`; kind 1: `+84` / `+80` / `+76`; kind 2: walk `sub_824825D0` (UNCERTAIN), run
`5432B35224E4B1C1` (no bucket test); kind ≥ 3 (none) → id 0. These are the collision export's
`ids[5]`, `ids[1]`, `ids[4]` (same record offsets).
- `sub_824940F8(m, bucket)` (the slot-gain override): false for surfaces 5/7, m ≥ 143 or no record
id; else `sub_824977C8` = `+48` (`37320DF00471F91A`) / 32767, bucket > 1: `9D6068AB16703650` / 32767.
- `sub_82493690(mode, m, code, layer)`: uneven = code ∉ {1, 3, 5}; s = surface(m).
Layer 0, bank 7 (`sk8_foley`), by s = 1..7 (else −1):
| mode | code | s1 | s2 | s3 | s4 | s5 | s6 | s7 |
|---|---|---|---|---|---|---|---|---|
| walk | 1/3/5 | 86 | 82 | 86 | 91 | 86 | 78 | 75 |
| walk | 2/4 | 87 | 83 | 87 | 91 | 107 | 79 | 110 |
| run | 1/3/5 | 107 | 102 | 105 | 105 | 105 | 99 | 101 |
| run | 2/4 | 104 | 103 | 104 | 106 | 104 | 100 | 100 |
Layer 1, bank 0, only s3/s4: 963/964, except run with code 2/4: 522/523.
(All keys in the OffBoard collection; resolved with `keys.py`.)
- `sub_82493448(id, bank)`: bank 0 searches `60B043CCA6F211C3` (tCollisionSpliceAttribute: 962,
960, 961, 959, 518, 519), others `CA945189654051D3` (459, 460, 455..458, 323, 324); the entry's
record of class `370AF2704BFA6866` (fields +0 `411D1D4CE3AFA346` gain, +4 `DE9BF5C10AB2C866` Q, +8
`7D3294EC1C443CBD` PI20 gain, +12 `805ABC23217FAC46` PI20 freq, +16 `79D8AB26EA217567` LPF, +20
`4E0F00838F472B4A` HPF); no entry → the `default` record (0.09, 3, 1, 96000, 96000, 0). Values in
`FootstepTuning::default` (e.g. 959: gain 1, HPF 200, LPF 7000, PI20 5200 Hz × 0.1 Q 0.5; 962:
gain 0).
### 2.3 Walking and jump voices
- `sub_824E9D10` (on foot), per foot rising edge: stop the foot's voice, start `sk8_foley`
(v = |COM v| > 7.5 → 64, > 2.5 → 63, else 62; Clothing `E12AF885D3C3A168` = [7.5, 2.5], ids
`9D6D2863CFE908C4`/`EC3399A49055DD8D`/`6B61C043E53C44CB`) into SFX Master (`[[0x830CFDBC]+44]`),
`+432` (A) / `+428` (B); with local && on foot && state `+308` also `Skate_Collisions`
1123/1122/1121 (`67717E2388A836ED`/`E420F7DD48E01E0E`/`F0292A62D280EB40`) → `+440` / `+436`.
Start block [0, 1, 0, dt, 1, 1].
- `+308` = OffBoard 311 = the board held in hand (packed record `+164` bit 1, `sub_827A1B78`). Retail
sessions: ~half the walking steps carry the Skate_Collisions layer.
- `sub_824E9678(on foot, footplant ended)`: off-feet = on foot && `+718` (OffboardAir) rising
(`+405` last); falling = COM vy (`+100`) ≤ 0 → `+456` = 1 (else 0; 2 at start), began = falling
&& old `+456` == 0; apex = `+444` held && began; hippy = `+372` rising (`+452`).
- off-feet || hippy || footplant ended: stop `+444`, `+472` = `+304` (jump bucket), start
`sk8_foley` off-feet ? 65 : bucket 1 → 69, 2 → 71, else 67 → `+444`; `+476` = off-feet.
- apex: stop `+444` and `+448`, start `+476` ? 66 : bucket 1 → 70, 2 → 72, else 68 → `+448`.
- block [0, 1, 0, dt, local ? 1 : 0, 1]; SFX Master. Retail: 65/66 ×10–15 per session.
### 2.4 Update `sub_824E9628`
1. `sub_824EAEA8` (both packets present): per packet (A = `+36`, B = `+220`) w0 32767, w1 raw(0)
0..65535, w2 pitch(1) 0..8192, w4/w5 vf64(4)/(5) 0..25001, w6 level(6), w7 level(2), w8 foot down
0/1, w9 vertical word (A `+420`, B `+424`) 0..1000, w10 xz word (A `+412`, B `+416`), w11 =
(countdown > 0 || `+444` held), w12 `+300` 1..4, w13 trunc(`+796`) 1..99, w14 `+408` 0..1000, w15 1,
w16 surface(material) 1..7, w17 `+740` 1..5, w18..w23 `636464FBAD0D71A3` = [32767, 10000, 15000,
25000, 32767, 28000]; redeliver A then B. Then (local): level = level(7) / 32767, or level(11)
when `sub_824940F8(foot A's material, bucket)` holds (shared by both feet); holders' env =
level(8) / 32767, azimuth = raw(0); `sub_82494C08` per holder, slots 0, 2, 1, 3: dead → destroy;
else block [slot gain × level, pitch(1) / 4096, 0, dt, 1, 1].
2. `sub_824EA9C8`: walking foley `+432`, `+428`: [level(3) / 32767, pitch(1) / 4096, raw(0) ×
360/65535, dt, local, 1]; dead → freed. `sub_824EAC38`: `+440`, `+436` at level(12).
3. `sub_824E9AB8`: take-off `+444` at level(9) (dead: **kept**, not freed — it gates w11 and the
apex), apex `+448` at level(10) (dead → freed).
4. `sub_824EBE78`: the splash sounds (levels 13/14/15) — not ported.
### 2.5 FootStep SubMix (`sub_82494188`, per foot slot)
Mono graph: `Sub0 → HI20 → LI20 → PI20 → Sen0 (→ env bus input `[[0x830CFDEC]+52]`) → Pn21 (1 → 6) →
Sen0 (6 ch → SFX Master [[0x830CFDBC]+44])`. `sub_82494550` (at each start of the slot) posts HI20 =
slot+4, LI20 = slot+8, PI20 freq/gain/Q = slot+12/+16/+20, Sen0 #1 level = holder+8, Pn21 azimuth =
holder+12 × 360/65536 (`0x822F88E8`). So env level and pan are those of the last update, latched per
start. Port: `SpliceHost::set_submix(Some(Submix))` before each foot start (host default ignores it);
the route carries `owner_env` = the env level. Read 2026-10-02: Sen0 #2 is never posted = class default 1.0
(`ATTRIBUTE_SETGAIN`; recomp SEND lines: 1.0 on all 8 graphs); Sen0 #1 also starts at 1.0 until the first post;
Pn21 constructor args = descriptor defaults (front 30, side 110, rear 150) with arg 2 (normalisation/law) overwritten
by 0.0 → law gain 1.0 (as the voice open).
### 2.6 Splash `sub_824EBB58` (not ported)
State `+811` in water (`+477` latch, `+480` time in water += dt), `+812` entered (once per `+478`,
only with state `+224` clear): stop `+484`, Skate_Collisions id `35D3B06292CDA10B`, or
`C17485220849574D` when `+480` ≥ `9CD13431903E3719`; `+813` (once per `+492`): id `BA81E93AE985D1C7`
→ `+488` (class `923CCB46EF5BF5BA`, collection `B2BD1F28DDE601B0` = material 92). Played through
`sub_82497F48` (collision splice object) into SFX Master. Retail ids 1197/1198 ("splash").
## 3. Audio-state inputs (bridge `sub_824B0DA8` from the packed record `sub_827A1B78`)
| state | retail source | engine (read-only survey 2026-10-02) |
|---|---|---|
| `+724` foot A down | rec+152 bit 0x20000000 = OffBoard 307 ‖ Air 450; ‖ `+334` ‖ `+336` | `p.off_board.flags_306_307[1]`, `p.air.footplant_right_450`, push foot (State55 && State57), brake flag(52) |
| `+725` foot B down | 0x10000000 = OffBoard 306 ‖ Air 449; ‖ `+333` | `flags_306_307[0]`, `footplant_left_449`, State55 && !State57 && State56 |
| `+732` / `+728` | OffBoard `+56` / `+52` u16 tags (Air `+224` while Air 448) & 0x7F − 1 | `skater.offboard.feet.hands[1/0].word_80` (`flag_84` valid) or `processed.right/left_surface_2600/2596` |
| `+288` / `+284` | max(\|x\|, \|z\|) Skeleton `+240` / `+224` | `foot_physical.output.world_velocity[1]` / `[0]` ✓ |
| `+296` / `+292` | \|Skeleton `+308`\| / \|`+324`\| = \|y\| of the **angular** velocity (body `+48`) of assembly parts 20 / 16 | ✓ (2026-10-02) \|`skeleton.bodies()[20/16]` angular_velocity.y\| |
| `+740` step code | `sub_827729B8` (§3.1); Skeleton `+144` / `+160` = physical pose translations of toe parts 15 / 19 (Skeleton 9024 / 9280) | ✓ `StepCode` over `skeleton.record.pose[15/19][3].y` |
| `+796` | Interaction+0 AudibleFootStepStrength | `animation_input.extra.footstep_strength` (engine comment: held 2.5–4; retail walking is < 2 → w13 = 1 — check a real-play log) |
| `+300` landing bucket | §3.2 | `LandingBucket` helper over `p.reckoning.vector_16[1]`, `p.filtered_state_0 == 7` |
| `+304` jump bucket | `sub_82772D30`: jump strength vs `468752B0BEE65CDB` = [0.45, 0.75] | `animation_input.extra.jump_strength` (host writes 0 today) |
| `+768` | rec+156 bit 7 = Air 448 (FootPlant) | Air 448 (FootPlantManager; check `p.air`) |
| `+718` | rec+152 bit 24 = filtered state 7 | `p.filtered_state_0 == 7` ✓ |
| `+716` | rec+152 bit 30 = category 500 | state 500 ✓ |
| `+308` | OffBoard 311 (board held) | `p.off_board.flag_311` ✓ |
| `+337` push stroke | State56 (rec+148 0x20000000) | `state_flags[56 − 52]` ✓ |
| `+335` push plant edge | State55 rising vs old `+333/+334` | `push_trigger` (host) ✓ |
| `+328` | \|Skeleton `+288`\| = the angular speed of part 23's body | ✓ |
| `+672` | 0.25 × (572 + 568 + 564 + 560): \|record velocity − Skeleton16176\| of parts 8, 4, 21, 17 | ✓ |
| `+528..+548`, `+560..+580`, `+593` | Collision `+80..` block (`sub_82BD60C8`, via the conditioner `sub_82773298`, which maxes only the first 8 floats over 4 frames): +32.. = SkeletonCollision region tangent speeds (+944 + 4i), +64.. = their tags (+976 + 4i), +97 = byte 4009 (face point contact) | ✓ `collision_feedback.regions[i]` (0 without a part) / `specific[1].current` |
| `+688` / `+689` | Skeleton `+602` / `+603` (§4) | inputs exist: `foot_ik.state.frames[2/3].board[3]`, deck settings, `p.off_board.flag_311` |
| `+676` / `+677` | bail / Skeleton 599 | ✓ (existing fields) |
| `+372` | hippy jump | host: scorable 234 (engine also `p.ground.hippy_jumping_322`) |
### 3.1 Step code `sub_827729B8` (audio conditioner, writes B40 211..215)
Tags `c+738` ← OffBoard+56, `c+736` ← OffBoard+52 when non-zero; B40+211 = ((`c+736` >> 7) & 31) == 8
(physics class 8). `c+740/741` = OffBoard 306 (now/last), `c+742/743` = 307. On 307 rising `c+720` =
Skeleton+160 (vec), on 306 rising `c+704` = Skeleton+144. d = y(+720) − y(+704): |d| ≤ 0.07
(`0x821BCD64`) → 212 = 213 = 0; else if 307: d > 0 → 212 = 1, else 213 = 1; else if 306: d ≤ 0 →
212 = 1, else 213 = 1 (bytes kept otherwise). Code = 212 ? (211 ? 2 : 4) : 213 ? (211 ? 3 : 5) : 1.
### 3.2 Landing bucket (`sub_82772B88` + bridge)
Ring of 4 COM vy (Reckoning+20); a = |min(0, r0..r3)| (fsel chain); a > 3.45 → 4, > 2.6 → 3, > 1.5
→ 2, else 1 (`7385078DD3C063BA` = [1.5, 2.6, 3.45]). Bridge: 1 if trick active with audio trick ≠
31, or `+720` == 0 (`+720` = 20 while `+718`, else −1 per frame; checked before its update).
## 4. Hands on the deck (Contacts `sub_824B85B0`)
- Skeleton `+602` / `+603` (`sub_82BF20C8`, before `sub_82BF22A0` in Skeleton::FillPhysOut
`sub_82BE1AE8`): SkeletonIK `+1840` / `+1904` = the translation of `+1792` / `+1856` = the engine's
`FootIkState.frames[2].board` / `frames[3].board` (hand limbs, parts 3 / 7: the animated target
relative to the animated board; updated only while the limb is on the deck or blending) strictly
inside (DeckWidth/2 + p.x, 0 + p.y, DeckFrontEndSize + DeckMidLength/2 + p.z) with p =
`physics_skeleton` `A28E50D30B0506A4` = (0.03, 0.03, 0) — the same test as 600/601 (toes, pad
`FootOnDeckPadding`).
- Packed record bits 0x2 / 0x4 of +148 → bridge `+688` / `+689`; both cleared when on foot (category
500) without the board in hand (OffBoard 311).
- Rising `+689` then `+688` → `sub_824B8310(hand, +332)`: stop `+372+8·hand`, start Skate_Collisions
1124 (in the air) / 1125 (ground) (`A7B32EC5FF4997F5` / `F17EEB71F18CEAF3` in `923CCB46EF5BF5BA` /
`E0C3B44AB44F7B90`); falling → `sub_824B8448`: stop `+388+8·hand`, start 1126 (`A7B7AE2A6C25670F`).
Route eEQChain `ED52262DABB5DE4C` = 2 (create = local), block [0, 1, 0, dt, 1, 1].
- Update `sub_824BF728` (Contacts update chain after `sub_824BF268`): on sounds [trunc(level(10) ×
0.5) / 32767, pitch(11) / 4096, raw(0) × 360/65535, dt, 0, 1], off sounds × 0.3
(`25DCB888413D570D` = [0.5, 0.3]); dead → freed.
- Retail: 1125 ×43–53, 1126 ×47–53, 1124 ×3–5 per session (board pick-up / put-down by hand). The
interim layer's "1126 = foot on deck" is a different, wrong mapping.
## 5. Clothing (`sub_824DBB68`: cloth falls, push foley, body slide)
- Cloth falls `sub_824DBF10`: k = 1 / 5.0 (`0A9A9BD1150FE838`); a = trunc(k × `+328` × 1000), b =
trunc(`+672` × k × 1000); `+44` = max(a, b). Held: release on `+677` or `!+676`. Not held: post on
`+676` rising `c_cloth_falls` (Foley_Cloth, `sub_824B72D8`, 10 words): [0, 0, 0, a 0..1000, 25000,
0, 0, 0, 0, eq `E633C8F009CAEFFC` = 5]. Update `sub_824DCA48`: w1 raw(0), w2 pitch(1) (Clothing out1
is a volume output read as pitch → 8192: retail's Foley_Cloth pitch p50 1.81–1.83), w3 `+44`, w0
32767, w7 level(2).
- Push foley `sub_824DBBB8`: stroke = `+337` rising: if no `+48`, start `sk8_foley` 73
(`6FD273157742AC4B`); held `+48` stops on `+335` or bail; on `+335`: restart `+52` with 74
(`616CE02AA10D596F`); `+52` stops on bail. Route eEQChain `AEA5BA7E64515945` = 0 (create = local),
block [0, 1, 0, dt, local, 1]. Update `sub_824DC7D8`: [level(4) / 32767, pitch(3) / 4096, raw(0) ×
360/65535, dt, local, 1]; dead → freed.
- Body slide `sub_824DC0E8` / helper `sub_824DC2B0`: sliding = any |`+528 + 4i`| > 0 (i 0..5); flag =
sliding && `+593`; tag = part 0's, overridden by part 1's, else the first non-zero of parts 2..5;
speed = trunc(`+212` / 4.5 × 1000) (`787171ECD02DBBC3`); material = tag − 1 (0..143 else 143); type
= 143 → 2, flag → 4, else AudioSurfaceMap word 10 (`+40`). Held: release unless speed > 150
(`746EA8EF187E1571`) && sliding; else post when sliding && speed > 350 (`B021DB338B89D0F2`)
`c_body_slide` (Bodyslide, `sub_824B7070`, 12 words): [0, 0, 0, speed, 25000, 0, 0, 0, type 0..4,
bail 0..1, 0, eq `4A022FEF9905D8F4` = 7]. Update `sub_824DC578`: w0 32767, w1 raw(0), w2 pitch(6),
w3 speed, w7 level(5), w8 type, w10 trunc(`+328` / 8 (`5D2F244E82CF7255`) × 1000), w9 bail.
## 6. MixMap outputs read
OffBoard (`0x40010090`): raw 0, pitch 1, filters 4/5, levels 2, 3, 6, 7, 8, 9, 10, 11, 12 (13–15 the
splash); free skate on foot: 2 = 9202, 3 = 8972, 6 = 1789, 7 = 2593, 8 = 1933, 9 = 3857, 10 = 9202,
11 = 14602, 12 = 11896 (in0 does not change them). Clothing (`0x40010060`): raw 0, pitch 1 (a volume
output), 3, 6, levels 2, 4, 5. Contacts (`0x40010010`): level 10, pitch 11, raw 0.
## 7. Retail comparison (per-voice GAIN p50, `tools/retail_voices.py --csv`, sessions 163809 / 164620)
The report.md per-container "audible" medians mix simultaneous starts (e.g. step layer 82 shows
0.21–0.26 there); the per-voice joins are the reliable measure. Ours = `tests/offboard_levels.rs`.
| sound | samples | retail p50 | ours |
|---|---|---|---|
| walking foley 62 | 94..97 | 0.387 | 0.387 |
| walking foley 63 | 103..107 | 0.244 | 0.244 |
| step layer 82 | 69..75 | 0.011 0.015 0.024 0.022 0.056 0.076 0.110 | 0.007–0.011, 0.013–0.018, 0.021, 0.020–0.024, 0.020–0.054, 0.073–0.077, 0.103–0.112 |
| board in hand 1121–1123 | 30..34 / 94..100 | 0.22–0.34 / 0.169–0.190 | 0.19–0.28 / 0.155–0.182 |
| metal surface 416 (time-matched to its 59 footstep starts) | Skate_Metal | 0.082 | 0.082 |
| push foley 73 / 74 | 0..7 | 0.176–0.260 | 0.257 |
| hand on 1125 / off 1126 | 427..432 / 422..426 | 0.133–0.160 / 0.071–0.081 | 0.125–0.153 / 0.062–0.070 (−1 dB) |
| footstep patch, w13 = 1 sets | 30..34, 45..49, 119..123, 158..162 | 0.070–0.083, 0.070–0.077, 0.026–0.040, 0.054–0.072 | 0.058–0.080, 0.052–0.077, 0.060–0.073 (+5 dB), 0.048–0.056 |
| Bodyslide | 0..4 | 0.000–0.035 | 0.023–0.027 (guessed slide inputs) |
| Foley_Cloth | 0..7 | 0.017–0.052 | 0.062–0.075 (guessed +328/+672) |
Footstep patch sample sets by w13 (probe `footstep_patch_sample_groups`): 1 → 30..59, 119..126,
141..145, 154..167; 2 → 6..9, 168..171 (retail: these open at gain 0.000); 3 → 10..17, 172..179; ≥ 5
→ none. Surface w16: 3 → 134..140, 150..153, 180..183; 4 → 18..21, 134..153; 6/7 → 0..2, 127..133,
146..149.
## 8. Open / UNCERTAIN
- (Closed 2026-10-02) Skeleton `+144` / `+160` = toe parts 15 / 19 pose translations; `+304` / `+320` = angular
velocities of parts 20 / 16 (see §3).
- Kind-2 walk id (`sub_824825D0`); never reached on the disc (+125 clear on materials 102..106).
- FootStep SubMix: rendered since 2026-10-02 (`bus/submix.rs`); Sen0 #2 = 1.0 and Pn21 law 0 read from the build (§2.5).
- AudibleFootStepStrength scale in our engine vs retail (selects the patch's sample sets).
- Splash (`sub_824EBB58` / `sub_824EBE78`) and remote players (`sub_824EBA08`) not ported.
- (Closed 2026-10-02) Ragdoll slide records and limb speed are published (§3).
@@ -0,0 +1,199 @@
# EA AEMS (Skate 3 audio runtime) — reference notes for the port
## Native-port spec index (2026-10-02)
| Spec | Status |
|---|---|
| `rwaudio-prior-work.md` | done: prior art (nfsmw CC0, BurnoutDecomp b5-decomp/BP-Decomp, tw2004 CC0), licences |
| `aems-evaluator-spec.md` | done: ABKC/MOIR layouts, runtime, 40 opcodes, timing, RNG, test plan; **PoC op 5 bug** |
| `aems-voice-graph-spec.md` | done: SndPlayer1 … Send DSP, buses, output stage, validation |
| `mixmap-spec.md` | done: MXB format (247 controllers), evaluation math, Ambience/Emitter outputs, ducking; decoder + reference evaluator match PR #4 on 73,800 cells |
| `grain-player-spec.md` | done: grains.big format, surface→grain map, GrainPlayer pick/scheduler/fades, chains, validation hooks |
Plan and order: the native-port plan (doc 11, "Native audio port"), incl. the "Modular and moddable" design goals.
**Superseded in detail by `aems-evaluator-spec.md` (2026-10-02): official op names, full op semantics, and
corrections (§11 there: PR #4 op-5 handle bug, 17/18 = Mux/Demux, 19/20 n-ary min/max, 38 ControlClass,
header +8 = platform + module count, tick scale 31.999998).**
Knowledge only, from others' hard-won work; never copy their code (it transcribes game code). Sources:
- Upstream PR #4 (SK8-ENGINE/skate-3-rust-engine, commit 368a433): `crates/skate-audio-core`, `crates/skate-audio-formats`,
`docs/audio-player-sounds-handoff.md`, `docs/player-audio-retail-drivers.md`, `docs/audio-object-registry.md`,
`docs/implementation-plan.md`, `crates/skate-game/src/player_audio/`. Read from a local clone.
- Upstream PR #1 (`mx/audio-engine-vehicles`): read from a local clone (report below).
- sk8Audio (andrewnakas/skate3-audio, NO license → reference only), branch `phase0-measurements`:
`docs/audio-banks.md` (canonical bank write-up), `execution-trace.md`, `rw-audio-core.md`, `rw_audio_structs.h`.
Read from a local clone.
- Our own measurements in the recompilation (summarised in doc 11).
PR #4 docs ≠ code in places (slots 19/20 claimed ported but missing; setters only in authored.rs) — trust code.
## .abk (ABKC) — big-endian, offsets from bank start
Header: +0 "ABKC"; +0x08 variant (0x05030001 ×369, 0x05030002 ×7); +0x0A u16 input-record count; +0x14 size;
+0x18/+0x20 S10A sample-bank offset (twice); +0x1C first record (0x5C); +0x24 S10A length; +0x30 code fixups
(empty on all banks); +0x34 rebase list (add bank base; 4,656 words); +0x38 exports (count, 12-byte entries:
target slot offset, record offset, kind word; record = u16 project id, u16 name id, NUL name; kind top byte
selects .csi table: 0→2, 1→1, else 0). Run time: +60 bank id, +64 S10A ptr, +72/+76 voice-open args, +80/+84 list.
S10A: magic, 0, count, offsets rel. to tag; 0xFFFFFFFF = unused (used slots are a prefix).
Input record (60 B + 4/entry): +4/+8 bound symbol + {name_id,generation}; +12..+27 listener node (fn 0x82B1DAD0,
ctx = record); +28/+30 u16 live/capacity; +32 u16 #28-byte variable subscriptions (table-2); +34 u16 #broadcast
subs; +36 u8 #voice objects; +37 has release cb; +38 has payload copy; +39 #held-message objects; +40 program
offset; +44 template offset; +48 template size; +52 offset of back-pointer triple; +56 live list head;
+60.. (+36 + +39) u32 offsets into the template (first +36 get the bank pointer at their +0).
Instance = copy of template: +0/+4 live links; +8 evaluator node (program at +16, operand block at +20 =
instance+24); from +24: optional 20-B release entry; N×28-B subscriptions (value at +24); payload entry (node,
fn, ctx, u8 word count at +16, words from +20; size (count+5)*4); broadcast subs (binding, node, u8 count +24,
arrival flag +25, words from +28; (count+7)*4). Triple: record, instance, post node; +12 = "end me".
Payload word i (no release/subs) at block+20+4i.
## Program grammar (all 385 programs verified)
Records until opcode 255: u8 opcode, u8 pair count, 2 unused, pairs × 8 B (source, destination), s32 block
advance. After the op: per pair, source −1 → store op result low word at block+dest, else copy block+src →
block+dest; then block += advance (64-bit arithmetic, pair count re-read each iteration). Opcodes < 40.
## Evaluator tick
Per audio block δ = 256/48000. Frame count = steps of δ until 1.0/denominator (denominator 30.0 after game
init) → 6 blocks (~31.25 Hz); tick scale = count × δ × 1000 = 32.0 ms. Countdown at 0x830775E4; on zero walk
the evaluator list 0x83036F4C: per record, pure ops then host ops (4/5/27/39). Random: global six-word counter
0x830775F0 (not reproducible run to run).
## Opcodes (table 0x82FD3600; PR #4 file refs in eval/)
0 return +16 then zero · 1 return +20 · 2 return +24 · 3 return +0 then zero · 4 end instance (if triple +12) ·
5 clamp (+8 flag, +9 count, [min,max] from +12) and broadcast to subscribers · 6 stepping cursor (low, high,
current, s8 step, enabled, probe; wraps) · 7 random base + draw % range (cached while disabled) · 8 shuffle bag
(byte/halfword, wrap) · 9 weighted pick (s8 weights summed past draw % 100) · 10 hysteresis latch (+17 edge
flag) · 11 one-shot float timer (steps by tick scale; fires → −1.0, returns 1) · 12 first non-zero flag → value ·
13 any of n non-zero · 14 multi-segment envelope (modes 1 start/run, 2 hold, 3 jump/run; {duration, target}) ·
15 sampled curve (s8/s16/s32; nearest at scale 1.0 else linear) · 16 word delay ring (length = delay/tick) ·
17 stack top (1-based) · 18 stack push/clear · 19 counted minimum? (NOT ported) · 20 counted maximum? (NOT ported;
used by Rolling_Surfaces, RocksBounce) · 21 round(scale × product of n) half away · 22 sum n · 23 sub · 24 mul
(64-bit) · 25 div (0 on /0) · 26 rem · 27 VOICE OP · 28 oscillator (quarter-sine table 0x82FD36B8, pulse, ramp,
triangle) · 29 ramp to int target over duration (tick scale, 1/4096 unit) · 30 capped sum · 31 max(b−c, floor) ·
32 capped multiply · 33 min · 34 max · 35 round(f32 × int × int) · 36 add · 37 return +25 flag and clear
(broadcast arrival) · 38 unknown (not ported) · 39 latch setpoint, notify table-2 listeners with clamped value.
## .csi (MOIR) projects
Counts u16 at +0x0A/+0x0C/+0x0E, project id +0x10, tables from 0x28. Tables 0/1: 12-B (listener head, name, u16
name id, u16 generation); table 2: 16-B (listener head, VALUE, name, id, generation). Install: rebase names,
fresh generation (global u16 0x83083CD0), table ptrs at +20/24/28, link project list 0x830BBE50. Lookup: pass 1
matching project id, pass 2 all; match name id + string; hit writes {record ptr, id|generation}; miss −5.
Table 1 = objects gameplay posts to.
## Post → instance
Install all .csi, then each .abk (rebase, resolve exports, hang each input record's listener on its symbol,
write bank ptr into template voice objects). Game object table 0x8302D4A4 (72 entries); handle slot of object i
= 0x8302EE28 + 8i. Post: validate slot (neg id → id; null → −6; stale generation → clear, −3); alloc 16-B post
node (symbol, refcount, payload-callback list +8, release list +12); call every listener, then payload
callbacks. Spawn (only while live < capacity): copy template, triple, live list, node program/block, release cb,
subscriptions (copy current value), payload copy, broadcast subs, push node on evaluator list; then payload
words copied in. Held messages: gameplay rewrites payload and re-runs payload callbacks each 60 Hz frame (seams
twice per frame). End: op 4 when triple +12 set, or game release → release cbs set instance +16 → program fires
op 4. Unmapped broadcast handle (e.g. literal 1 in Rolling_Rattles template) = "no holder yet".
## Voice op (27) and graph
Voice object: +0 bank; +4 descriptor table (count, 12-B descriptors); +8 open voice; +12/+13 latched/previous
state; +14 #param records; +15/+17 copy-back flags; +20 descriptor index; +24 requested state (0 off, 1 on,
2 paused); +28.. 12-B param records {u8 id, applied, wanted}. Op: clamp request; on change: 0 release, 1 resume
or open (index clamped; descriptor first u16 0xFFFF = no sound), 2 suspend; while on push changed records,
query voice (dead → deactivate; copy remaining/elapsed time back); poke.
Open: sample = S10A entry (index+3) + base; descriptor byte 2 = gain % (×0.01 → player float); bytes 3–8 <<8 =
panner angles; bank +72, bank +76 + descriptor +8.
Property ids: 0 pitch = value/4096 (resample ratio); 2 master level /32767; 5 first send /32767 (× master);
8 dry level /32767 (Gain = master × this); 6 low-pass raw (25000 = open); 7 high-pass raw; 3 pan value ×
360/65536°; 11 effect send /32767 (if effect bus); 1, 4 ignored; clamps: 0,6,7 → 0..65535; 2,5,8 → 0..32767.
Routing records (id ≥ 9) at open: 0–7 material bus n; 10–17 bus n−10 (on demand); 512/2048 fixed buses;
4096/8192/16384 effect bus (next record 1 = enable, next = level /32767; 16384 = second effect bus).
Graph (fixed): SndPlayer1 → Rechannel → Resample → HighPassIir2 → LowPassIir2 → [Send if intermediate target]
→ Gain → [Send if effect bus] → Pan2D1 (6 ch) → Send (6 ch, output bus). Mono: pan property 0 (mode 1).
## Player objects (slot = handle index; payload words per docs/player-audio-retail-drivers.md §4–5)
Conventions: vf52(0) azimuth 0..65535; vf56(n) pitch cents → ×4096 (0..8192); vf60/64(n) 15-bit MixMap levels;
25000 = open filter. Inputs from the audio-state bridge (drivers §1, audio_state.rs).
- 0 Class_foot_drag FOOT_DRAG (15 w) · 1 Class_wheels_skid WHEEL_SKID_BANK (18 w) · 6 Class_Seams Seams_Bank
(20 w; four held packets, one per wheel: w7 trigger pulse, w8 speed, w10 surface, w13 pattern class, w14 wheel)
· 7 Class_Squeaks Brd_Squeaks (11 w) · 9 Class_Treatment Treatments (22 w, posted once & held: w7/w8 air times,
w9 jump height, w10 time scale 500) · 12 SenseOfSpeed_wind (13 w) · 13 SenseOfSpeed_rattle (11 w, w3 intensity)
· 15 c_board_slide board_scrapes (12 w) · 21 c_body_slide Bodyslide (12 w) · 22 cloth_trick Foley_Cloth (11 w)
· 23 c_cloth_falls (10 w) · 24 playercharacter_footstep fstep_skateshoe1_sm (25 w) · Class_Flips (28 w) ·
Class_grind GRINDS (17 w) · boot utilities 16/20/38 (1-word posts).
- Class_rolling (PatchBank_Rolling_Surfaces/_Objects/_SpiderCracks/_RocksBounce): post w2 4096, w3 speed
(0..10000), w4 selector (0..15), w6 surface (0..13), w9 25000, w11 32767; held updater w0 32767, w1 azimuth,
w2 pitch(8), w3 speed, w6 surface (13 when both trucks lifted while grinding), w7 manual/brake, w8 level(19),
w9/w10 filters 17/18, w11 level(7)/(9). Surfaces 7, 8, 10–13 post selectors 1, 2, 10, 12, 11, 9.
**Retail's continuous rolling = the grain player; Class_rolling is sparse one-shot texture.**
- Rolling_Rattle_Class: w3 speed, w4 surface code 1..5, w6 1, w8 25000, w10 32767, w11 8; updater w2 pitch(3),
w7 level(16), w8/w9 filters 14/15, w10 level(6); posted on push trigger state byte 335.
- Contacts (SFXObj_Contacts): Splice (SPLC .bnk) one-shots — formats splc.rs, selection/gain/pitch
authored/oneshot.rs, game side player_audio/contact_voices.rs. Our decoded rule: doc 11 (recomp measurements).
- Collision (SFXObj_Collision / CSTATEMGR_Collision): 10 LRU slots, two voices per message (one per material),
material → Skate_Collisions.bnk / Skate_Metal.bnk (collision_states.rs).
- Missing in PR #4: landing dynamics (Contacts MixMap inputs not written); global random not reproducible; "G"
global writer; wheels.big and jet grain partial; Class_rolling broadcaster for placeholder symbol 1.
## Validation tools in PR #4 (ideas to mirror, not code to copy)
grind_instance example (post payload, tick 256/48000, redeliver traced updates, GRAPH/DISASM/WATCH); wheel_repro,
render_player_audio; retail capture tags ST/PO/UP/RL/BD/GP/GL/VF/MC + audio_capture_extract/verify/compare
tools; compare medians in narrow speed bins, never session averages.
## PR #1 (mx/audio-engine-vehicles) — what it adds (report 2026-10-01)
PR #1's skate-audio-core = PR #4's (PR #4 is a later superset: adds measured transfer functions §11, defect 13
"Collision switch = input 15 bit 0 on controller 60030000"). PR #1 adds the game side:
`skate-game/src/player_audio.rs` + `player_audio/**`, `physics/audio_observation.rs`, `skate-data/src/audio/*`,
`skate-audio-formats/*`, docs player-audio-retail-drivers.md (DENSE: constants, field layouts, packet tables),
audio-object-registry.md, audio-player-sounds-handoff.md, player-audio-implementation-plan.md,
wheel-audio-handoff-2026-09-17.md, engine-defects.md; tools audio_capture_extract/verify.py, audio/landing_levels.py,
audio/skid_scrub_levels.py.
Architecture: (1) every gain/pitch/pan word is an output of EA PathFinder 5.03 **MixMap** (data
`data\audio\MixMapSK8.mxb`, 25,252 B, 14 sections; port matches capture on 99.9991 % of 84 M words);
(2) rolling = **grain player** over grains.big owned by SFXObj_SkateBoard (Class_rolling only sparse texture);
(3) one per-frame **audio state** (bridge sub_824B0DA8; record *(0x82083C38)+0x2F070 +240 +544*player).
Object model: CSTATEMGR_<X> → CSTATE_<X> slots → SFXObj_<Y> components; controller key
0x40000000 | cat<<16 | index<<11 | kind<<4. Per 60 Hz frame: bridge + state-controller inputs (sub_824B19C8) →
every component process (vtable+36: write inputs, post/release) → one MixMap evaluation → every component update
(vtable+40: rewrite held packets from mixer outputs, redeliver).
Audio-state fields: 208 ground speed; 212 |COM v|; 236 air time; 240 time to land; 260 jump height; 232 slip;
264 deck tilt; 332 known air; 333-335 push edges (335 rattle trigger); 336 brake; 339 manual brake; 340 balance;
341 grinding; 343/348/352 trick ids; 448-467 per-wheel touchdown impact + landed latch; 468 jump velocity;
496-611 ragdoll contacts; 620-660 per-wheel material (143 none) + seam pattern; 676/677 bail/end; 724/725 foot
down; 780 loose board; 796 footstep strength. Conditioner rules: drivers §6 / audio_state.rs.
Levels (music off): rolling -22.9 dBFS; takeoff -5.9 (+17 dB over bed); landing +0.4 (+23.3 dB).
Grain bed (drivers §3): 13 .grain soft/hard per surface; per-surface vault record (4x4 Bezier, max km/h 55-74,
boost); position = Bezier(clamp(v*3.6/maxKmh)), player B 0.1 behind; gain level(1)/32767*(1-max(turn,brake
slew)); pitch(3)/4096; GrainParams A (0.1,0.2,0.1,1.6,0.05) B (0.2,0.1,0.2,1.5,0.05); bus chains incl. SSB
frequency shift, HS 5 kHz; push envelopes; seam gain envelope; distortion send; gain wobble.
Contacts: pop (selector by jump velocity 0.25/0.42; Skate_Collisions ids 0x449-0x44B / 0x44F-0x451 by surface
category); landing = voice 1 fixed 0x447, voice 2 2x2 ladder (deck material × air >= 0.75 s) 0x35C-0x35F,
voice 3 class voice 3*kind+class; Contacts input 2 = 0/16000/32767 by class → output 15 = 2584/3103/3650.
Collision manager: 10 LRU slots, 48-B message (2 materials, tiers, pos, levels, flags), tier gate
10000/20000/32767; material table 0x8302D6E8; impact bands floor 0.005, 0.5, 1.0, cap 2.0.
Output: native 6 ch 48 kHz, downmix front + 0.707 C + 0.5 surround, OUTPUT_GAIN 0.6.
Formats: XMA2 via ffmpeg, pad chunks to 2048-byte packets (else 64-sample ticks); grain seek table; splc walk.
Unknowns: global G *(0x83083C38)+0x2FCB4; pop bank pick sub_824B9AD8; Contacts inputs 3/6/10/17/18/21; Dac stage.
Gotcha: rustc 1.98.1 -O miscompiles a hand-written clamp (use .clamp()).
## CORRECTION (2026-10-02, from retail code: sub_82976860/sub_82976360/sub_82975B08): SPLC member fields
PR #4 swapped them. **Member +4 = gain** (amplitude steps 0.7071, 1.4142, 0.315 … 11.31), random level spread
+44; **member +8 = pitch** (semitone ratios 0.9439, 1.0595, 0.5, 2.0) + random +48. Voice gain =
member+4 × spread(+44) × block[0] × fade envelope (+24..+40, mostly zero); pitch = member+8 (+48) × block[1].
Record header (sub_82975B08): block[0] ×= record +8 (record gain, e.g. 0.5 landing impact); block[1] ×= container
random (record +12/+16, pitch). block[0] = level rewritten by the owner every frame; block[1] = an owner output in
cents (1200/octave). **Our own main-checkout `audio_formats.splc_patches` / game_audio play_record read +8 as gain
too — fix in Phase B.**
Contacts (sub_824BE1B8 per frame): pop +60 = trunc(level(2)×K[sel])/32767 (vault 8BF3668CF7994CD4 / 212B6F75D11AA39B);
pop roll +96 = level(7); ollie voice 0x448 +52 = level(12); landing impact 0x447 (container 1095) +56 = level(13);
touchdown voices (sub_824B8D48, 4 slots +140) = trunc(level(3)×K[cat][3·kind+variant])/32767 (vault arrays
CEA5AFA8BA170B07, 951AD53718030327, 068C8C5EBEC1B45F, 6823F4910A1882AD); pitch = output 1 in cents; output 15 =
env send of the touchdown owner bus (not dry gain). Landing sound = touchdown voices from the per-frame wheel handler
sub_824B86E0 (per-wheel latches +132..135, newly-down count → kind, class 2 = variant 1 + second voice variant 2),
manual landing sub_824BB330 (kind 4); MixMap: only Contacts input 2 (landing class) moves outputs (3: 6590/7913/9309).
## Corrections from the voice-graph spec (2026-10-02; `aems-voice-graph-spec.md`)
- **`SKATE3_AUDIO_CAPTURE`** records the recomp HOST's stereo downmix (0.4·(FL + SL + 0.5·C) per side),
not the game's output stage. Absolute dBFS from captures are in capture space. PR #4's −22.9 dBFS rolling
was measured on the native 6-channel output, so compare **relative** levels (takeoff +17, landing
+23.3 dB over the bed, ±1.5 dB).
- **Trace columns:** `PLAY` "level" is a play counter, not a level. `SEND` per-channel values are each
channel's last sample × target, not gains (`tools/recomp-trace/send_vectors.py`).
- **Not retail in PR #4:** the 15 ms master delay and the 0.6·(FL + 0.707·C + 0.5·SL) stereo fold. The
title's own output stage routes, interleaves and clamps to ±1, with no master gain.
- **Prior art:** `rwaudio-prior-work.md`:
- nfsmw (CC0) and b5-decomp (reference only) name all 40 opcodes;
- Iir2 filters are RBJ with Q = 1; the resampler is linear 16.16; de-click is 64 samples.
@@ -0,0 +1,119 @@
# Tricks component (Class_Flips, cloth_trick) and Class_Treatment — spec (2026-10-02)
Our reading of the TU3 recompilation (`tools/recomp-code-search/fn.sh ADDR`, reference only); image constants by
`img.py` (local tool); vault values from the user's `skater-collections.json`. Port: `crates/skate-audio`
`player/tricks.rs`, `player/treatment.rs`, `player/globals.rs`.
Tags: [R] read in the asm, [V] vault, [IMG] image, [T] retail trace, [U] UNCERTAIN.
## 1. Tricks component (vtable 0x822FC7B8, owner Tricks controller 0x40010050 = `keys::tricks(0)`)
Object fields [R] (`sub_824CBD98`): +16/+28 owner refs (+72 local byte), +32 audio state, +36 Class_Flips object,
+40 cloth A, +44 cloth B, +48 last frame's +348 (−1), +52 posted flip id (−1), +56 cloth A id (−1), +60 latched
+352 (−1), +64 running trick time (0.0), +68 cloth B time (0.0), +72 w12 slew (0), +76..+100 trick-event latches,
+84 event timer.
**Process `sub_824CBEB0(dt)`**, only when `[this+28]+72` (local):
1. `+60 := state+352` while `state+348 ≠ −1`.
2. Flip poster `sub_824CBFB8(id = +348)`:
- off = !air(+332) && local && +310; go = air ? +343 : off. Not go, or +36 held → return.
- flip id +52 := off ? 34 : id. Ids −1, 35, 36 → return (no flip for grabs 35, coffin 36).
- Spin words from state +480/+484/+488 (deck ω on its Ri/Up/At rows): x = trunc(|v| / div × 1000)
(`0x82256FE8` = 1000.0), min 1000, then 0 if x < threshold (subf/xoris/addc/subfe select).
- Posts the 116-byte object `sub_824AFAD8` (28 words, below). Class handle 0x8302EE48 = object 4 `Class_Flips`.
3. Cloth A `sub_824CC590(id)`: id = −1 → nothing. Not held → post cloth_trick(id), +56 := id. Held with another
id → release (re-posted the next frame).
4. Cloth B `sub_824CC680(id, dt)`: id ≠ −1 → +64 += dt; else if +48 ≠ −1 (the trick just ended) → +68 := +64,
+64 := 0, ended. If +68 > 0: when ended and +60 ≠ −1 → release any cloth B, post cloth_trick(+60). Else
(+68 ≤ 0) release cloth B.
5. +48 := id; +68 := +68 > 0 ? +68 − dt : 0.
6. Slew `sub_824CD170(dt)` (dt ≤ 0 → +72 := 0): target = `sub_824898C8()` ? T₁₃ : first set bit of G+96
(0x8000 → T₁₅, 0x4000 → T₁₄, 0x2000 → T₁₃) else 0; step down trunc(R↓·dt), up trunc(R↑·dt) [V below].
`sub_824898C8` = G+96 bit 0x2000 || (X+932 && copied X+476 byte) || X+1060 == 8 (X = *(0x830CFDC4)).
7. `sub_824CD390`: game events 24685–24697 through `sub_824AA858` (trick-event system, no AEMS) — not ported.
**Update `sub_824CBF78`** = `sub_824CC7D8`, `sub_824CCE48`, `sub_824CCFE8`:
- Flip: id = off ? 34 : +348; if !(air || off) or id ≠ +52 → release. Else rewrite: w9/w8/w7 spins (Ri/Up/At),
w10 = trunc(+220 × 500) 0..1000, w4 = pitch(2) 0..8192, **w0 = level(1)**, w3 = raw(0) 0..65536, w1 = 32767,
w2 = 0, w5 = level(3) 0..25000, w6 = 0, w16 = (+224 == 0), w26 = local ? level(6) : 0, w20 = level(8),
w22 = level(7), w12 = +72 0..1000; redeliver.
- Cloth A: bail (+676) or (+348 == −1 and !air) → release; else w0 = 32767, w4 = 25000, w5 = w6 = 0,
w7 = level(4), w1 = raw(0) 0..65535, w2 = pitch(5) 0..8192; redeliver. Cloth B: bail → release; else same.
**Class_Flips words** (`sub_824AFAD8`, payload 28 = bank's): w0 0, w1 32767, w2 0, w3 0, w4 4096, w5 25000,
w6 0, w7 At spin 0..1000, w8 Up spin, w9 Ri spin, w10 time-scale word 0..1000, w11 trick id 0..40,
w12–w15 0, w16 (+224 == 0), w17 27646, w18 14848, w19 6656, w20 0, w21 10000, w22 0, w23 bool class
`0x11A631878B239355` (not modelled: 0), w24 local && +64 == 0, w25 local, w26 local ? level(6) : 0, w27 eEQ 6.
**cloth_trick** (`sub_824B71C0`, 11 words, handle 0x8302EED8 = object 22): w2 4096, w4 25000, w8 1,
w9 id 0..40, w10 eEQ 0, rest 0.
Banks: `Sk8_Air_Flip_Tricks` (Class_Flips, capacity 3), `Foley_Cloth` (cloth_trick capacity 3; its
`c_foley_utility` is posted once at boot in retail — POST object 20 once per session [T]).
## 2. Class_Treatment (vtable 0x822FCDA0, owner Treatments 0x40010070 = `keys::treatments(0)`)
**Process `sub_824DD408`** (local only): while +36 empty → `sub_824B0080` posts the 92-byte object (handle
0x8302EE70 = object 9), never released. Companion `hall_of_meat_slo_mo` (handle 0x8302F058 = object 70, 64-byte
`sub_824AF368`) held while (G+96 & 0x04000000 && !`sub_8279E180(G)` && +220 < 1.0) || X+1060 ≠ 7, else released.
Words (15): w2 4096, w3 25000, w6 32767, w9 1, w10 = level(3), w11 8, w14 = clamp(X+1060 − 7, 0, 3).
**Constructor words** (22): w1 32767, w4 4096, w5 25000, w10 500, w15 7000, w16 28000, w17 32767, w18 bool class
via `sub_82484460` (0), w19 local && +64 == 0, w20 local, w21 8, rest 0.
**Update `sub_824DD6F0`**: w0 = level(2), w3 = raw(0) 0..65535, w4 = pitch(1) 0..8192, w10 = trunc(+220×500),
w14 = (+224 == 0), w7 = trunc(+236 × 1000) 0..10000, w8 = trunc(+240 × 1000) 0..10000, w9 = trunc(clamp(+260 ×
166.66667 (`0x822F9408`), 0, 1000)), w11 = w12 = 0; B+16 → w11 = trunc(B+24 × 1000) 0..1000; B+164 → w12 = 1,
w13 = trunc(B+168 × 10000) 0..10000 (else w13 keeps its value). Companion: w0 32767, w7/w8/w13/w10 = level 4/5/6/3,
w12 = trunc(clamp(|COM v| (+212) / 40, 0, 1) × 10000).
B = `*(*(0x83083C38)+0x2FCB4)`, sub-object B+16 reset by `sub_827AB6E0` (from `sub_8279E800`); B+16 is the byte
PlayerPhysics.in11 reads (`sub_824B19C8`). Writer not located [U].
## 3. Audio-state fields read (bridge `sub_824B0DA8`, record writer `sub_827A1B78`)
| offset | meaning | source | our engine |
|---|---|---|---|
| +332 | in the air | rec+148 & 0x80000000 | `airborne` ✓ |
| +343 | trick active | rec+148 & 0x01000000 | `trick_active` (= scorable present) ✓ |
| +348 | audio trick | rec+180 = scorable record +164 (`8C3025DB4D1761AF`) | `audio_trick` (host-resolved) ✓ |
| +352 | 2nd audio trick | rec+184 = scorable record +172 (`A2C5C22C5BE725F8`) | **new** `audio_trick_2`; 28 flips, 35 grabs, −1 ollie/nollie/footplants |
| +344 | rec+148 & 0x00800000 = scorable record +176 bool (`7B9298C7883E2FA2`) | — | not read here |
| +310 | off-board hold expired: +309 (rec+164 bit 0 = [r30+72]+309) held > 0.4 × (1 − min(0.12·v₂₀₈, 1)) s (`0x8208EA70` 0.12, `0x82181B90` 0.4) | bridge | **new** `offboard_310`, not published: false |
| +480/+484/+488 | deck ω · rows Ri/Up/At (B40+0, `sub_82772748`) | | +488 = `deck_spin`; **new** `deck_spin_xy` (host: ω·basis col 0 / col 1) |
| +220 / +224 | G+0 time scale (1.0) / G+4 byte (c_tazer input) | G = *(0x83083C38)+0x2F070 | **new** `time_scale` (1.0), `global_224` (false) |
| +236 / +240 / +260 | air time / time until predicted landing / jump height | Air+176 / +184 / +200 | `air_time` ✓; **new** `air_until_landing`, `jump_height` (skate-core `KnownAirOutput::time_until_collision_184`, `jump_height_200`) |
| +676 | bail | | `bail` ✓ |
| +212 | |COM v| | | `com_speed()` ✓ |
## 4. Globals (`player::globals::Globals`)
G+96 (writer not located): free skate 0 [T: no slow-mo post in 4 sessions]. X+1060 game-flow mode: free skate 7
[T: object 70 never posted]. `sub_8279E180(G)` taken as false.
## 5. Vault (holder `*(0x830CFDA4)`: +72 = `C1831BDB6CB1B1EA`/`tricks`, +68 = `C1831BDB6CB1B1EA`/`EE7B8A8A893A4E30`,
+140 = eEQChain `42AFE160E647167C`/default) [V]
- tricks: Ri thr `5C73CF6A0D50C8D8` 297 / div `1494BB20854C155C` 3.0; Up `EE157886DE5D3C97` 703 /
`02D39586635FB1A3` 4.4; At `9A0316625B63CD99` 603 / `8EDBACCBA6FE46AD` 10.2; w17 `99E6FF024834E4C7` 27646,
w18 `D2D0EBAC43842F6D` 14848, w19 `13C155A181A55BA4` 6656, w21 `B565D4D763128252` 10000.
- slew (`47EC76B4F9FC79F6`): bit15 `B601DFAB3AF7DE66` 250, bit14 `08441EA8E8019665` 700, bit13 `36F8D12486A929D1`
1000, down `6B57BD44C0E0B267` 1000/s, up `57AED5FBC374D8F1` 10000/s.
- eEQChain: Flips `D9BE1F2F1A72FEE8` 6, cloth `4B6E2D79A8452D9B` 0.
- treatment: w15 `32F9111CBF746F34` 7000, w16 `E4BC6FE030A553C4` 28000, w17 `1F11951C2AF58CC7` 32767, companion
speed `5A49D603680EE3FF` 40.0.
Export: `stage_tricks_treatment.py`, a local tool (dry run prints; `--write`; `--bank` stages Treatments).
## 6. Retail evidence and checks
Timing [T] (`post_timing.py` (local script), session 164620): cloth A posts ~215 ms (13 frames) before
Class_Flips; Class_Flips a median 16.7 ms (1 frame) after the pop's jump-velocity write; cloth B 150–650 ms after
(the trick's end). Treatments streams 13/14 start in the air, 14–326 ms before the touchdown contact
(`treat_timing.py`); scripted hops are 0.61–0.64 s.
Levels through the real banks (`tests/player_tricks.rs`, `--nocapture`), vs `retail_voices.py` gains:
- Flips whooshes slots 0–3: 0.370 vs retail 0.347–0.365 (+0.5 dB); slot 5: 0.133 vs 0.134; ollie slot 12 0.030 vs
0.035–0.038; shove-it/360 slots 24/25 0.199 vs retail 0.015–0.078 (few retail voices).
- cloth_trick slots 8/9/12: 0.18–0.24 vs retail 0.21–0.25.
- Treatments 13/14: 0.0736 / 0.0184 vs 0.0746 / 0.0186 (−0.1 dB); they start when w8 falls through ~0.37 s.
- First trigger: the first trick's Flips voices equal the second's.
[T] Treatments slots 16/17 (a long-air layer scaled by w17, started after w8 rises from 0 at takeoff) DO play in the
recomp (TREAT session all_20261002_223306: 18 + 18 voices, peak 0.004–0.029, median 0.0149; ours on its inputs 20 + 20,
median 0.0155). Their samples equal sense_of_speed 3/4, which hid them from `retail_voices.py` until the address
disambiguation. The recomp's +240: 0 on the ground, the whole prediction on the first air tick, −1/60 per tick, still
counting on the landing tick, then 0. +236 (Air+176) also counts on the landing tick (ours since 2026-10-02). [U] B+164/B+168 (PR #4: "set, ≈1.0 most of play"): with them set slots 0–12 play, which
retail never does → reset values kept. [U] G+4 byte: w14/w16 = 1 needed for 13/14 to play (retail plays them).
[T] 2026-10-02 overnight: the ramp air's late 16/17 start (538/577 ms, 223306) and the 1113 ms one in 223613 follow
~1 s recomp audio-thread stalls (no PLAY/GAIN/SEND/MOD/XMA lines while TREAT/GREC continue;
`audio_gaps.py` (local script)). Short airs (< 0.5 s) with a 16/17 voice: ours 11/22, the recomp 10/22 (walk
phase + shared RNG). No change.
@@ -0,0 +1,734 @@
# Skate 3 voice graph and output mix: behavioural spec for the native port
Written 2026-10-02. This describes behaviour, formulas and constants in our own words, as the basis for a native Rust
re-implementation (Phase B of the native-port plan (doc 11, "Native audio port")). Nothing here is code from PR #4 or the recomp; both have
no licence and were read for behaviour only. Companion notes: `aems-reference.md` (banks, evaluator, property ids),
`rwaudio-prior-work.md` (RenderWare Audio prior art: b5-decomp, BP-Decomp, nfsmw), `ems-emitters-re.md` (ambience).
## 0. Sources and confidence tags
- **[V]**: PR #4 marks the function verified (shadow-compared against the running game) and/or it replays recorded
vectors with 0 disagreements.
- **[T]**: transcribed by PR #4 from the lifted code, never compared.
- **[R]**: our own reading of the recomp's generated C++ (`skate3recomp/generated/skate3_recomp.*.cpp`).
- **[IMG]**: a constant or table read from a dump of the TU3 image
(base 0x82000000).
- **[I]**: inference.
- **[PA]**: corroborated by the RenderWare Audio prior art (`rwaudio-prior-work.md`; b5-decomp, DWARF names).
- **UNCERTAIN** marks anything a port must not treat as settled.
Addresses are TU3 (title update 3.0.3.0). Offsets are object-relative bytes, big-endian guest layout.
## 1. Clocks, blocks and latency
- **Mixer**: 48000 Hz, blocks of **256 frames** (5.333 ms) [V]. Every module processes exactly one 256-frame output
block per call. Per-block time step = 256/48000 as f32 (0x3BAEC33E).
- **Evaluator tick** (AEMS patch programs, incl. the voice op 27 that pushes voice parameters): accumulate block
deltas until the next one would reach 1/30 s. That gives **6 blocks = 32.0 ms** per tick, and the tick-scale global =
32.0 ms [T][PA]. Boot image holds rate 41.6; game init overwrites it with 30.0. Parameter changes therefore reach a
voice only every 6th block. The rest of the time the modules hold their last values; Gain/Send/Pan ramp for 64
samples after a change.
- **Game frame**: 60 Hz. Per frame the audio-state bridge writes the MixMap inputs, the MixMap evaluates once, and then
every SFX component re-reads outputs and re-delivers its held packets. Inputs written during a component's update
are seen by the next evaluation, one frame later [T].
- **Latency chain (game event → sound)** [I]:
1. ≤ 1 frame (16.7 ms) until the component posts or redelivers;
2. ≤ 32 ms until the next evaluator tick runs the program;
3. the command ring, drained at the start of the next block;
4. plus PR #4's stand-in 15 ms master delay, if adopted (see §6.3; not retail);
5. plus device buffering.
A play command can also carry an absolute start time (SndPlayer1 record +0, §4.1), which delays the start to the
sample.
- **Graph pass order** [T]: every graph ("player") has an order byte (+73). Players run in ascending order each block.
- voices: 0
- board chain graph 1: 2
- graph 3: 3
- owner one-shot bus: 4
- graph 2: 5
- effect returns: 150
- material buses: 253
- PR #4's master stand-in: 255
A contributor must run before the bus it feeds. Sends accumulate into a bus that flushes when its own graph runs
in the same block, so there is no extra block of delay per bus hop [I].
## 2. Graph mechanics (common to all modules)
- **Signal convention** [V]:
- The pass object holds two plane descriptors: +28 is the current signal, +32 is scratch.
- A descriptor is {+4 base of channel 0, +14 u16 floats between channels}.
- A processing module reads current, writes scratch, then swaps the pair. A module that bypasses does not swap, so
the next module reads the untouched input.
- Pass +60 holds the current channel count; pass +48 the frame count of the current round; pass +52 the round's
sample rate.
- **Pull negotiation** [T][PA]: each block, the graph asks its sources, in reverse order, how many input frames they
can take for the remaining output (prepare). It then runs them forward (process). Only SndPlayer1, Rechannel and
Resample have a prepare:
- Resample answers how many source frames it needs for the block (§4.3).
- SndPlayer1 stores that as its block size (+460).
- Rechannel passes the request through.
If a round produces fewer than 256 frames, the graph repeats rounds and concatenates, then zero-pads the tail. A
source that declines falls back to a clear-and-advance.
- **Parameter delivery** [V]:
- A module's parameters are 8-byte slots from +48: tag word 0x7FF7FFF1, then an f32. So parameter i is at
+52 + 8i.
- The game thread writes them through the command ring (handler 0x82B463A8). The value passes f32 → f64 → f32
and is **not clamped** there.
- Commands are drained once per block before the graph pass, so a new value applies from the next block boundary.
- **Per-voice CPU cost** [T]: each module adds a constant to the player's +40 cost (Iir2 450, PeakingIir2 1500,
Resample 6). The Dac voice-retirement budget uses these costs (§6.5).
- **Release tail** [PA]: rwaudio voices keep running for the filters' decay before being expelled. UNCERTAIN in
Skate.
## 3. Voice open and parameter mapping
### 3.1 The voice graph
Opened by the device (`sub_824A3140`) when the patch's voice op starts a sample [T]. Shape (the `msgs1` trace's
119-build shape):
`SndPlayer1 → Rechannel → Resample → HighPassIir2 → LowPassIir2 → [Send A] → Gain → [Send B] → Pan2D1 (6 out) → Send (6 ch)`
- Every module up to Gain runs at the sample's channel count (1, 2, 4 or 6).
- **Send A** (pre-Gain, environment/reverb bus): present only when the bus manager's env bus `[[0x830CFDEC]+52]` is
non-zero. Its level is posted 0 at open.
- **Send B** (effect send): present only when the voice's routing records ask for an effect bus (§3.3).
- **Final Send**: 6 channels, to the voice's output bus, or to the default bus when none is set.
The other graph shapes seen:
| graph | shape |
|---|---|
| ambience bed | `SnP1 → Rch0 → Rsp0 → Gai0 → LI20 → Sen0` (no panner: the 5.1 bed plays as authored; `ems-emitters-re.md`) |
| grain voice | `SnP1 → Rsp0 → GaF0 → Sen0` (mono), sending into the board chain (§6.2) |
### 3.2 Voice properties
The voice op pushes 12-byte records {u8 id, applied, wanted}. A record is pushed only when it changed, and only on
an evaluator tick [T][PA].
The setter (`sub_82B1BE30`) clamps:
- ids 0, 6, 7 → 0..65535;
- ids 2, 5, 8 → 0..32767;
- 1, 4 and 9..136 pass through;
- 3 goes to the "alternate" setter.
The voice's own setter (voice vtable 0x822FBCA8 +12) then maps them [T, matches the DWARF/b5 player input names PA]:
| id | name [PA] | effect |
|---|---|---|
| 0 | PITCHMULT | Resample parameter 0 = value / 4096 (4096 = unity pitch) |
| 2 | VOL (master) | m = value/32767 (f32), stored at voice +40 |
| 8 | DRYLEVEL | d = value/32767, at voice +44; **Gain parameter 0 = m·d** |
| 5 | FXWET0 | w = value/32767, at voice +48; **Send A level = m·w** (only if Send A exists) |
| 6 | LOWPASS | LowPassIir2 parameter 0 = value, **in Hz, no scaling** (25000 = open, §4.4) |
| 7 | HIGHPASS | HighPassIir2 parameter 0 = value in Hz (0 = off) |
| 11 | (user) | Send B level = value/32767, only if an effect bus exists |
| 3 | AZIMUTH | Pan2D1 parameter 0 = value × 360/65536 degrees (mono, voice mode 1). Before the first such post, if voice byte +69 is set, parameter 7 = 30.0 and parameter 8 = 110.0 are posted and +69 is cleared [R, `sub_824A2C58`] |
| 1, 4 | TIMEMULT, ELEVATION | ignored |
- Any change of 2, 5 or 8 re-posts both products. Each setter stores its f32 first, then multiplies the stored
singles.
- MixMap levels are 15-bit (0..32767), so full scale = 1.0.
- 1/32767 is the constant at 0x822F8898 (0.000030518509).
### 3.3 Routing records (at open) [T]
Only records with id ≥ 9 are read. Their value selects the bus:
| value | bus |
|---|---|
| 0–7 | material bus N (`sub_82491108`, not created) |
| 10–17 | material bus N−10, created on first use |
| 512 / 2048 | `[[[0x830CFDC4]+688]+48]` / `+40`; also sets voice mode +96 = 0; purpose UNCERTAIN |
| 4096 or 8192 | effect return `[[manager+116]]`, only if the next record's value is 1; level = next-next value / 32767, at voice +80 |
| 16384 | effect return `[[manager+120]]`, same rule as 4096/8192 |
| 256, 1024 | ignored |
### 3.4 Other open-time values
- **Descriptor byte 2** × 0.01 is published to the player graph's +56 (constructor default 100.0).
- PR #4 calls it "gain %". No DSP kernel we know reads player +56: Resample multiplies the pass's +56 accumulator
by its ratio, which looks like a pitch/time accumulator, and rwaudio has mTotalPitch.
- **UNCERTAIN**: it may be a priority or a time scale, not an audible gain. Do not apply it as gain until proven.
- **Panner angles** come from descriptor bytes 3..8 (each <<8, × 360/65536 → degrees):
- mono, mode 1: parameter 0;
- multichannel: parameter 1 = 0 (distance 0), parameter 7 = angle; with more than 2 channels also parameter 8.
- Which descriptor word feeds which parameter depends on the channel count: frame words +100/+104/+112 hold word
indices (1 ch → 0; 2 ch → 0, 1; 4 ch → 0, 1, 3; 6 ch → 1, 2, 4) [T].
- **SndPlayer1 play** (configure id 5) enqueues a play command with:
- the sample (EAAC stream header);
- a running **play counter** float (wraps above 4194304.0) at command +48. **Our trace's PLAY "level" column is this
counter, not a gain.**
- the byte at +46 = round(block +60), which the open sets to 1.0 (our PLAY "flags" = 1). Meaning UNCERTAIN.
- the start time (double).
## 4. Modules
### 4.1 SndPlayer1 (source) — process `sub_82B34278` [T, gate-1], play `sub_82B32DC8` [T]
**State.**
- A ring of up to 20 48-byte play records (table offset +464; cursor +469; write index +467; ring size +470).
- 20 decoder-feed slots of 16 bytes (consumer +474, producer +473; slot +113 = 0 empty / 1 ready / 2 done; slot +112
the stream id).
- +42 current channel count; +456 current source rate; +460 block size (set by the prepare = frames Resample asked
for); +432 position in the current record; +462 offset of a per-channel "last sample" table; +471 "has rendered";
+472 pending fade frames.
**Record (48 B).**
| offset | field |
|---|---|
| +0 | f64 start time (0.0 = immediately) |
| +8 | stream |
| +12 | play counter |
| +16 | f32 source rate |
| +20 | total frames to play (end point, or loop end) |
| +24 | s32 loop start (negative = no loop) |
| +28 | pre-roll frames to decode and discard |
| +32, +36 | start offset (summed into the position on the first block) |
| +44 | decode scratch bytes |
| +46 | state: 0/4 idle, 1 queued, 2/3 playing |
| +47 | channels |
**Per block** (output = up to +460 frames at the **source rate**; Resample converts to 48 kHz).
1. If a fade is pending and something was rendered: run the fade block (step 6) and stop.
2. Skip records whose total is 0, marking them done. A non-playing record renders nothing.
3. **Format change**: if the record's rate or channel count differs from the current one, publish the new format with
0 frames this block. Resample re-caches its rate and passes the block through unprocessed. So the first block
after a format change is silent [T][PA].
4. **Scheduled start**: if the start time ≠ 0 and lies ahead of the pass clock:
- delay frames = trunc(rate_block × (start − now) × frames/sec), clamped to one block;
- write that many zeros and return. If the delay is beyond a limit cell (0x822F8A48), write nothing this block.
- Once reached, the start time is cleared. This gives **sample-accurate scheduled starts**.
5. **Render**:
- discard the pre-roll in 256-frame chunks;
- render min(block, remaining) frames;
- remember each channel's last sample;
- advance the position (pre-roll included in the decoded count);
- publish frames and rate.
6. **End**: when the position equals the record's total:
- loop start ≥ 0: jump the position to the loop start (seamless, the decoder loops as well);
- otherwise: mark the record done, move the cursor to the next record and continue with it in later blocks.
Records queue back to back on one voice.
- A natural end has **no fade**: the sample's own tail is the end.
7. **Stop / flush** (`sub_82B32328`) [R]: retires every queued record, resets the ring indices and sets **fade = 16**.
On the next render, each channel's remembered last sample ramps linearly to zero over min(16, block) frames
(value − k·value/16). The voice output becomes that ramp, not the signal. This is a de-click "hold-and-decay".
The bus applies its own 16-tap fade on Send release as well (§4.8).
**Grain voices** start at a seek frame: start frame = trunc(rate × seconds). Every grain stream is one block, so a
seek is "restart at 0, decode a 384-frame pre-roll, skip target − 384" [T].
**Native-port notes.**
- Decode with vgmstream at setup (our decision).
- Model the record queue, loop points (start, end), scheduled start, format-change silent block and the 16-frame stop
fade.
- UNCERTAIN:
- exact pre-roll/skip values per bank sample (they come from the EAAC header via `sub_82B335A8`);
- the admission check `rate × span ≤ queued` (`play.rs`);
- the spans +32/+36.
### 4.2 Rechannel — `sub_82B2C8F0` [V]
- If the incoming count (pass +60) differs from its declared count (+42), the module converts through the route
matrix (§6.1). The first route into an output overwrites, later routes add, and unrouted owned outputs are zeroed.
- For non-standard counts it copies min(n, m) channels at unity and zero-pads.
- Then it swaps and records the new count.
- **No-op when the counts are equal**, which is always the case in the voice graph unless a stream's channel count
differs from the descriptor's.
### 4.3 Resample — prepare `sub_82B2DAC8` [V], process `sub_82B2DBA8` [T], kernel `sub_82B43FB8` [V]
**Parameters.**
- +52 = pitch scale (property 0 / 4096);
- +64 = requested rate, i.e. the sample's native rate (measured: 48000, 44100, 36000, 22050);
- +56 = ratio in use; +60 = last computed ratio; +68 = u32 16.16 step; +72 = 16.16 fraction (low 16 bits used);
- +78 = output frames this block; +80 = u8 carried tail frames; +81 = u8 extra look-ahead frames;
- +64 is also reused as the rate cache in the process.
**Prepare, every block.**
- ratio = f32(f32(requested / mixer_rate) × scale), two single roundings. mixer_rate = the pass format's rate,
48000.
- Recompute only when the ratio differs from +60:
- step = trunc(ratio × 65536 ± 0.5), rounded half away from zero;
- if step > 2^18, step = 2^18 and the stored ratio = the ceiling cell 0x82257308 (4.0 = MAX_RESAMPLE_RATIO [PA];
the cell value is UNCERTAIN, unit tests assume 4.0).
- Every block:
- the pass's +56 accumulator is multiplied by the ratio (a pitch/time accumulator; not applied to samples);
- the source frames requested = ((step × 256 + fraction) >> 16) − carried(+80) + lookahead(+81), clamped at 0.
- Measured in our MOD trace: requested 44100, scale 0.9749 → ratio 0.8956 = 44100/48000 × 0.9749 ✓.
**Process.**
- If the stream rate changed, re-cache it and pass the block through unchanged once [T][PA].
- Otherwise, per channel:
- build a run = this channel's carried tail (up to 6 floats per channel kept at +76's table) + the new input;
- output n = min(256, ((usable + 1)·65536 − fraction − 1) / step), where usable = carried + input − lookahead
(8192 if step = 0);
- each output sample is **linear interpolation**: out = a + (b − a)·(frac × 1.5258e-5), with a = run[i],
b = run[i+1], frac = the low 16 bits of the phase.
- The weight constant is the f32 at 0x377FFC9C, **not exactly 1/65536** (it differs by about one part in 2^21).
- The last step is one fused multiply-add (difference·weight + a).
- The phase advances by step; the index advances by the phase's high half plus carries.
- Unconsumed input frames become the new tail; the fraction is kept for the next block.
- **No anti-alias filtering**: pitching up aliases, as retail does.
- Bit-exactness detail [V]: the 8-sample unrolled loop forms its 8 phases from the phase at the top of the trip (fine
for any step ≤ 2^18).
### 4.4 HighPassIir2 `sub_82B26568` / LowPassIir2 `sub_82B27E20` + builder `sub_82B43CC0` [V: 576 + 576 replayed]
**Parameter.** +52 = cutoff in **Hz** (f32). Class defaults [IMG]: HPF 0.0, LPF 96000.0 (range 0..96000), so both
bypass by default.
**State.**
- history per channel {x1, x2, y1, y2} at +56 + 16·ch (up to 8 channels);
- coefficients {a1, a2, b0, b1, b2} at +184, already normalised by a0;
- cached ω at +204. The constructor stores the raw Hz there, so the first filtering block always rebuilds.
**ω.** ω = f32(f32(fc / fs) × 2π_f32), with 2π_f32 = 6.2831855 and fs = the block's rate (48000 after Resample).
**Bounds** [IMG]: FLOOR = 0.0031415930 (π/1000, about 24 Hz at 48 kHz); CEIL = 3.1384511 (0.999π, about 23976 Hz).
**LPF.**
- ω ≥ CEIL, or NaN → **bypass**:
- no swap (bit-exact pass-through);
- history cleared only if the previous block filtered;
- cache = ω.
- So **raw 25000 = open** because 25 kHz is past 0.999·Nyquist, not because of a special case. Raw 23975 filters;
23976 bypasses.
- Otherwise ω is floored at FLOOR; rebuild coefficients if ω ≠ cache (exact compare); filter all channels; swap.
**HPF** (mirrored).
- ω ≤ FLOOR, or NaN → bypass (raw 0..24 Hz is off; raw 25 filters).
- Otherwise ω is capped at CEIL.
**Coefficients.** RBJ cookbook (bilinear transform) at **fixed Q = 1**, all single precision, with s = f32(sin ω) and
c = f32(cos ω):
- α = 0.5·s; a0 = 1 + α; inv = 1/a0; a1 = (−2c)·inv; a2 = (1 − α)·inv;
- LPF: n = 1 − c; b0 = b2 = n / (2·a0) (true division); b1 = n·inv. So b1 is not bit-equal to 2·b0.
- HPF: n = 1 + c; b0 = b2 = n / (2·a0); b1 = −(n·inv).
- Response: 0 dB at fc, a peak of about +1.25 dB near 0.72·fc (LPF) or 1.41·fc (HPF).
- sin/cos are the image's double-precision CRT routines, rounded to f32. libm + f32 rounding matches except for rare
1-ulp cases.
**Updates.**
- Coefficients change only at block boundaries when ω changes. No smoothing or interpolation (zipper possible,
retail-faithful).
- History is kept across a change.
**Worked coefficients** (fs 48000, f32) [our model from the formulas, 1-ulp caveat]:
| case | ω | a1 | a2 | b0 = b2 | b1 |
|---|---|---|---|---|---|
| LPF 5000 Hz | 0.65449846 | −1.2164445 | 0.53329474 | 0.079212569 | 0.15842514 |
| LPF 1000 Hz | 0.1308997 | −1.8614084 | 0.87747043 | 0.0040154932 | 0.0080309864 |
| HPF 77 Hz | 0.010079277 | −1.9898703 | 0.98997140 | 0.99496043 | −1.9899209 |
Measured retail values (session 20261001_211347 MOD lines):
- HPF raw 77 (most voices) or 0;
- LPF 24971 or 25000 (open); some voices 0.7–9 kHz.
### 4.5 Biquad kernel `sub_82B43AF8` [V: 1000 replayed]
- Direct Form I, processed 8 samples at a time.
- Feed-forward: b0·x + b1·x1 + b2·x2 + **1e-18** (a denormal guard, cell 0x822F87B0), using fused single multiply-adds.
The association order differs between sample 0, sample 1 and samples 2..7 of each group of 8 (matters only for
bit-exactness).
- Feedback: y = (t − a1·y1) − a2·y2, as two fused negative multiply-subtracts.
- History is written back after the block.
- The recomp runs with flush-to-zero; Xenon hardware may not. This is mostly moot because of the bias. UNCERTAIN.
### 4.6 Gain (Gai0) — `sub_82B23B50` [V: 185 replayed]
- Parameter 0 = target gain (+52, linear amplitude, class default 1.0). +56 = applied gain.
- Per block:
- **restart flag** (the pass's "module not yet reached in this graph's progress" flag, i.e. first/restarted block;
UNCERTAIN meaning): applied := target first, so no ramp.
- step = f32(f32(target − applied) × 1/64) (two roundings, never fused).
- For every channel: out[k] = in[k] × (applied + k·step) for k = 0..63, then × (applied + 64·step) for k = 64..255.
- All channels start from the same applied value.
- The kernel's lane arithmetic rounds twice (no FMA), with group multipliers 1..8.
- applied + 64·step may differ from target by an ulp.
- Afterwards applied := target; swap.
- So a **64-sample linear de-click on every gain change** (1.33 ms), then flat [PA confirms GAIN_DECLICK 64].
- The retail GAIN trace hook logs +52/+56 (change-only).
### 4.7 GainFader (GaF0) — `sub_82B238A8` [T] (grain voices, not the standard voice graph)
- A fade request carries a start time (f64, sample-accurate), a duration, a target and a curve:
- 0 linear; 1 square-root (rsqrte + one Newton step); else sine (four-lane sine kernel).
- Length = max(1, trunc(duration × rate)) samples. It may span many blocks.
- The envelope (256 values per block) multiplies every channel; the last envelope value becomes the current gain.
With no ramp running and gain ≠ 1.0, a flat envelope is applied.
- Grain player usage: fader to 0 immediately, then attack to 1 over the attack time; release to 0 over the release
time.
### 4.8 Send (Sen0) — process `sub_82B31838` [V: 933,816 calls], configure `sub_82B31370` [T]
**Fields.**
- +52 target gain (parameter 0); +112 current gain (constructor 1.0); +116 dirty;
- +41 incoming channel count; +64 the bus accumulator; +76 offset of the bus's contributor counter; +78 bus channel
count;
- **+80..+108 = per channel: last sample of this block × target.** This is the "last contributed value" for the
release de-click.
- **Our recomp SEND hook prints +80.. as "channel gains"; they are NOT gains** (they read 0 or sample-like ±0.27).
Fix the hook or ignore that column.
**A Send is a tap.** It does **not** swap: the signal continues unchanged to the next module.
**Linking.** Configure id 0 enqueues a "send to bus" command. It pushes the Send onto the target Sub0's contributor
list and copies the accumulator pointer, counter offset and channel count. Ids 1 (by name) and 2 are not ported.
**Per block.**
1. If the restart flag or +116 is set: current := target.
2. If the bus has 0 channels: set dirty and return.
3. Bump the bus's contributor counter.
4. Mix according to the owner player's mode byte (+72):
- mode 1: ramp current → 0;
- mode 3: ramp 0 → target;
- otherwise: ramp current → target if they differ, else flat.
- **Flat**: for each route byte (§6.1) of the (source count → bus count) pair, bus[d] += g_route × target × src[s].
- **Ramp** (`sub_82B46810`): per route, start = g·current and step = g·(target − current)·**(1/65)** (cell
0x822F8A00 = 0.015384615). Samples 0..63 use start + k·step; samples 64..255 hold start + 64·step.
- So the ramp **lands at 64/65 of the way** and the next block jumps the last 1/65 (retail arithmetic, keep it).
- Mode 1 therefore holds current/65 for the rest of the fade-out block.
5. current := target; store the +80 last values.
**Accumulate**: always adds into the bus accumulator.
**Release de-click** (`sub_82B31480`, `sub_82B3C668`) [V]: the +80 last values are mapped through the same route
matrix into the bus layout and added to the bus's delta array. At the bus's next flush they are faded into its first
16 samples with taps 16/17, 15/17, …, 1/17 [IMG 0x822F8A04..]. That smooths the step a removed voice leaves.
**Mode values.**
- PR #4's host sets suspend = 3 and resume = 1. That looks inverted against the kernels (1 = fade out, 3 = fade in);
retail `sub_82B1E8D8` / `sub_824A2E98` are unverified. **UNCERTAIN**: check before relying on pause behaviour.
- **Bad channel counts** 3, 5 and 7 hit empty route ranges and execute a garbage route once. Never send those.
### 4.9 Pan2D1 (Pn21) — process `sub_82B29BE0` [T; its parts V]
**Instance.**
- +748 source count (= the voice's channels); +752 destination count (6 for voices);
- 8×8 gain matrix at +444 (row = source, 8 floats);
- parameter cache at +704..+740; law gain at +744;
- speaker tables at +128 (built by `sub_82B44FE8`).
**Constructor arguments.**
- front 30°, side 110°, rear 150°;
- law: class default 2; the voice open passes **0**.
- **Two outputs force front = 90°.** Burnout used 45°/135° [PA]; Skate uses 30°/110°.
- Law gain: 0 → 1.0; 1 → 1/n (n < 6) or 1/(n−1); 2 → 1/√n or 1/√(n−1). **Voices use law 0 → 1.0.**
**Parameters** (no clamping on write) [IMG defaults]:
| id | meaning | default |
|---|---|---|
| 0 | angle A, **degrees, positive = to the right** | 0 |
| 1 | distance D (1 = on the speaker circle) | 1.0 |
| 2 | entry radius about the centre (multichannel layout) | 1.0 |
| 3 | turn: degrees added for multichannel entries | 0 |
| 4 | focus: centre-extraction weight, 0..1 | 1.0 |
| 5 | level: output weight, 0..1 | 1.0 |
| 6 | **LFE send level**, written raw (not × level or law) | 0.0 |
| 7 | spread 1 (front pair angle) | 30 |
| 8 | spread 2 (side pair angle) | 110 |
| 9 | spread 3 (7.1 only) | 150 |
The names are ours; DWARF lists 7 attributes: azimuth, radius, width, spread, focus, level, centre level [PA].
**Internal channel order** (6 outputs): **0 L (+30° internal), 1 C (0°), 2 R (−30°), 3 Ls (+110°), 4 Rs (−110°),
5 LFE**.
- The internal angle is θ = −A·π/180, so A = −30 is L and A = +30 is R.
- The voice azimuth word maps as 65536 = 360°, × 360/65536 degrees (cell 0x822F88E8).
- So **word 0 = front centre, increasing words turn clockwise** (to the right). This is consistent with the route
tables and the interleave [I, PA "positive degrees pans right"].
- **UNCERTAIN**: no impulse test yet confirms that +A is right on a real console.
**Source placement (mono).**
- Position p = D·(cos θ, sin θ).
- Unit-disc clamp on r² = |p|²:
- r² > 1 → normalise, r² = 1;
- 0.999 < r² < 1 → r² := 1, position kept;
- otherwise unchanged.
- D ≤ 0 reflects the stored angle by π.
**Gains** (5 speaker slots + LFE).
1. **Distance spread** (only if r² < 1):
- w_k = 1 − 0.5·|s_k − p| for each speaker unit vector s_k.
- front share F = 0.5(x + 1), back share B = 1 − F; each snaps to 0 below 5e-4.
- Front group: g = √(F / (wL² + wR² + (wC·focus)²)) × {wL, wR, wC·focus}.
- Back group: g = √(B / (wLs² + wRs²)) × {wLs, wRs}.
- Every gain × √(1 − (r²)²).
2. **Angular term** (added):
- Wrap the angle into one of four sectors (6 outputs):
- front [−30°, +30°) between L and R;
- [30°, 110°) between L and Ls;
- [110°, 250°) between Ls and Rs across the back;
- the remainder between Rs and R.
- Pairwise VBAP: for speakers at α and β, g_b = sin(θ−α)/sin(β−α) and g_a = sin(β−θ)/sin(β−α), via a
precomputed inverse 2×2 matrix per pair.
- **Front sector centre extraction**: share = min(gL, gR)·focus; subtract it from both; gC = 2cos(30°)·share =
√3·share.
- Scale the pair (or the L/R/C triple) by r² / √(Σ g²) (constant power, amplitude r²).
3. **Final**: s = level × law gain. If r² < 1, also divide by √(Σ g² over the 5 slots). Multiply all by s.
On the circle (D = 1): pure constant-power pairwise panning with centre extraction. Inside: a renormalised blend. At
D = 0: L/C/R 0.408 each, Ls/Rs 0.5 each.
**Multichannel sources** (`sub_82B45C50` [T]):
- The layout centre is D·(cos, sin)(−A·π/180). The constant used here is 1 ulp different from the mono path's.
- Each entry is placed at centre + radius·(cos, sin)(−(A + turn)·π/180 ± spread·π/180), then clamped; its angle =
atan2.
- 2 ch: ±spread 1. Spread 1 exactly 90 uses a mirrored pair.
- 4 ch: ±s1, ±s2.
- 6 ch: entry 1 at the centre angle, 0/2 at ±s1, 3/4 at ±s2; the 6th input is LFE.
- 3, 5, 7 ch: nothing is placed.
- With the open's defaults (distance 0, radius 1, spreads = speaker angles), source channel k lands on output k.
**LFE.**
- 6 or 8 inputs: the LFE input goes only to the LFE output, at parameter 6.
- Otherwise every source row's LFE column = parameter 6. **Voices default 0, so the LFE output is normally silent.**
**Stereo** (2 outputs): R = (y + 1)/2, L = 1 − R, normalised to unit power × level × law.
**Matrix application, per block.**
- Compare the 10 parameters with the cache (NaN counts as a change).
- Unchanged: apply the matrix flat. Source 0 overwrites each output; other sources accumulate.
- Changed: save the old matrix, rebuild, then apply with a **64-sample ramp per cell**: delta = (new − old)/64, flat
from sample 64. With the restart flag set, apply flat.
- Then swap.
**Worked gains** (mono, focus = level = 1, LFE 0; columns L, C, R, Ls, Rs) [our f64 model, not bit-exact]:
| A, D | L | C | R | Ls | Rs |
|---|---|---|---|---|---|
| 0, 1 | 0 | 1 | 0 | 0 | 0 |
| −30, 1 | 1 | 0 | 0 | 0 | 0 |
| +30, 1 | 0 | 0 | 1 | 0 | 0 |
| −15, 1 | 0.7071 | 0.7071 | 0 | 0 | 0 |
| −15, 1, focus 0 | 0.9391 | 0 | 0.3437 | 0 | 0 |
| −90, 1 | 0.3673 | 0 | 0 | 0.9301 | 0 |
| +90, 1 | 0 | 0 | 0.3673 | 0 | 0.9301 |
| 180, 1 | 0 | 0 | 0 | 0.7071 | 0.7071 |
| −150, 1 | 0 | 0 | 0 | 0.8374 | 0.5466 |
| 0, 0.5 | 0.4196 | 0.6791 | 0.4196 | 0.3055 | 0.3055 |
| −90, 0.5 | 0.4927 | 0.3226 | 0.2477 | 0.7437 | 0.1970 |
| 180, 0.5 | 0.2411 | 0.2211 | 0.2411 | 0.6461 | 0.6461 |
| any, 0 | 0.4082 | 0.4082 | 0.4082 | 0.5 | 0.5 |
**Verification status.**
- Replayed [V]: clamp, place, distance, angular, final scale, matrix flat/ramped, sin, cos, atan2, floor.
- Unit-tested only: layout of multichannel sources, matrix fill, the process itself.
- Unverified: constructor and speaker tables.
### 4.10 Bus-only modules (not per voice)
| module | behaviour |
|---|---|
| **HighShelfIir2 (HS20)** `sub_82B26740` [V: 2,996 replayed] | +52 corner Hz, +60 gain (linear plateau amplitude, A = √gain). Filters only if ω < CEIL **and** gain ≠ 1.0; otherwise bypass (history cleared once). RBJ high shelf, slope S = 1, α = sin ω × 0.70710653 (not exactly 1/√2). Used in the board chain graph 3 (5000 Hz, 0.65) and the stream bus. |
| **PeakingIir2 (PI20)** `sub_82B2C658` [unit-tested only] | +52 Hz, +60 linear gain, +68 Q (clamped 0.2..20 for the math). ω is clamped to [FLOOR, CEIL]. Bypass only when gain == 1.0 exactly. RBJ peaking: A = √gain, α = sin ω/(2Q). Used on material buses and effect returns. Our MOD trace saw 36 PEAK modules around 930–1393 Hz, gain 0.1, Q 20, slowly drifting (probably music/radio processing; UNCERTAIN). |
| **DCl0** hard clip `sub_82B22678` [V] | clamps to ±level; bypass when level ≥ 100 or NaN. Board chain graph 3 uses 0.09. |
| **Del0** delay [T] | fixed delay (0.015 s on the effect-return graphs, cell 0x820D0190). |
| **Sub0** submix `sub_82B34E08` [V] | see §6.2. |
| One-pole stage `sub_82B399D0` [V] | belongs to ReverbModel1 (six instances), not to voices. |
| FrequencyShiftSsb | board chain only (grain/chain notes). |
## 5. MixMap's role per voice
The MixMap (`MixMapSK8.mxb`, EA PathFinder 5.03) runs once per 60 Hz game frame. It turns each SFX object's
controller inputs into 16-bit outputs. Each owner reads them:
- vfunc52 = azimuth word 0..65535;
- vfunc56 = cents → 2^(c/1200) × 4096;
- vfunc60 = 15-bit level & 0x7FFF;
- vfunc64 = filter word 0..25000.
The owner writes them into its held packet words, which the patch program wires to voice properties. So for a
player voice:
| MixMap output | voice property |
|---|---|
| level outputs (/32767) | properties 2/8 (master × dry) → **Gain**; env/effect sends → 5 / 11 |
| pitch output (/4096, unity 4096) | property 0 → **Resample** scale |
| filter outputs (Hz, 25000 = open) | properties 6/7 → **LPF / HPF** |
| azimuth | property 3 → **Pan2D1** parameter 0 |
- The MixMap's own envelopes (attack/hold/release in ms) are the main **level smoothing**. Voice-side smoothing is
only the 64-sample de-click.
- Exact per-object wiring lives in each bank's patch program, so the evaluator port (Phase B, `aems-evaluator-spec.md`)
reproduces it by running the program.
- Grain voices bypass properties: owner gain → Sen0 parameter 0, pitch → Rsp0 parameter 0, written each scheduler
tick.
- Ambience bed: level = MixMap out0/32767 × zone volume; pitch out2/4096; LP out3; send set once.
PR #4's MixMap evaluator matches the capture on about 99.999 % of words; use it as the oracle.
## 6. Buses, master and output
**Superseded in part (2026-10-02):** the env/reverb bus, the master path and the output channel count are recovered in
`aems-env-bus-spec.md`; the material (eEQChain) buses, their EQ re-roll / jitter and the owner one-shot buses in
`aems-eqchain-buses-spec.md`. Both are ported (`skate_audio::bus`).
### 6.1 Channel layouts and route tables [IMG, V]
- **Internal 6-ch order**: L, C, R, Ls, Rs, LFE.
- **Internal 8-ch order**: L, C, R, Ls, Rs, Lx, Rx, LFE.
- Route byte: bits 0–2 destination, bits 3–5 source, bits 6–7 gain index into {1.0, **0.707** (exactly 0.7070000172,
not 1/√2), 0.5}.
- Ranges per (src, dst) at 0x820ED700, routes at 0x820ED780.
Conversion matrices (unlisted gains are 1):
| src → dst | matrix |
|---|---|
| 1→2, 1→4 | 0.707 to both fronts |
| **1→6/8** | **centre only** |
| 2→6/8 | L→L, R→R |
| 4→6 | FL, FR, SL, SR |
| **6→2** | **L = L + 0.707·C + Ls; R = R + 0.707·C + Rs; LFE dropped** |
| 6→1 | L+C+R+Ls+Rs |
| 6→4 | L + 0.707C, R + 0.707C, Ls, Rs |
| 6→8 | identity on 0–4, LFE → 7 |
| 8→6 | Lx folded into Ls, Rx into Rs, LFE → 5 |
### 6.2 Buses [T unless noted]
**Bus = a graph starting with Sub0** [V]. Sub0 owns an accumulator (channels × 256 floats) and a contributor counter.
When its graph runs:
- no contributor and no pending delta → the block is cleared;
- otherwise it copies the accumulator out, folds the release deltas (16-tap fade), and zeroes the accumulator.
The rest of the graph processes the sum like a voice.
| bus | shape and notes |
|---|---|
| **Material buses ×8** (`sub_82490CA0`, manager 0x830CFDEC) | `Sub0 → DCl0 → PI20 → PI20 → Sen0`, 6 ch, order 253, → default bus. Clip level and two peaking EQs come from authored data interpolated in 11 steps (`sub_82491DF0`); **the values are not recovered** (UNCERTAIN). |
| **Effect returns ×2** (`sub_82490270`, order 150; targets of sends 4096/8192/16384) | `Sub0 (1 ch) → Del0 15 ms → PI20 → Sen0 (→ env bus) → Gai0 → Pn21 (1→6) → Sen0 (→ default bus)`. A mono slap-back/early reflection [I]. |
| **Env/reverb bus** `[[manager+52]]` | **not recovered** (ReverbModel1 exists; the world reverb zones come from SFXObj_Reverb, `ems-emitters-re.md`). PR #4 leaves the env sends disabled. |
| **Board grain chain** (`sub_824C8878`) | graph 1: `Sub0 → HI20 → LI20 → FSS → Sen0 (→ graph 3) → Gai0 → Sen0 (→ graph 2)`.<br>graph 3: `Sub0 → DCl0 ±0.09 → Gai0 → HS20 5 kHz/0.65 → Sen0 (→ graph 2)`.<br>graph 2: `Sub0 → Sen0 (→ manager+116) → Sen0 (env, vfunc60(13)/32767) → Pn21 → Sen0 (→ default bus)`. |
| **Default/master bus** (`[0x830CFDBC]+44` / `[0x830775EC]`) | **not recovered** (UNCERTAIN). The output stage reads 8 planes and the 6→8 route maps LFE to plane 7, so retail's master is most likely an 8-channel submix [I]. **PR #4's master (Sub0 → Del0 15 ms, 6 ch, order 255) is its own invention**: it reuses the effect-return delay. Do not copy the 15 ms delay without evidence. |
### 6.3 Output stage (Dac) — `sub_82B21F58` → `sub_82B21D98` [V: 800 replayed]
1. **Route planes.** The 8 master planes go to N outputs, where N = global byte 0x8306705D (0 in the static image;
PR #4 sets 6; the retail runtime value is UNCERTAIN). Output tables at 0x820ED6CC/0x820ED6DC:
| N | outputs |
|---|---|
| 6 | L, C, R identity; Ls+Lx; Rs+Rx; LFE |
| **2** | **Lo = 0.707·L + 0.5·C + 0.5·Ls + 0.5·Lx; Ro mirrored; LFE dropped** |
| 4 | L + 0.707C, R + 0.707C, Ls+Lx, Rs+Rx |
| 1 | 0.707·(L+C+R) + 0.5·(surrounds) |
2. **Interleave** to 256 frames × 6 = 6144 bytes, with plane → slot 0→0, 1→2, 2→1, 3→4, 4→5, 5→3. Device order:
**FL, FR, C, LFE, SL, SR** (XAudio default).
3. **One-shot fade-in**: if flag byte 0x83067057 is set, frame k < 128 is multiplied by k/128 (cell 0x820300D4 =
0.0078125), then the flag is cleared. A 2.7 ms fade after an output (re)start [I]; what sets it is UNCERTAIN.
4. **Clamp** every sample to [−1, +1]. Only out-of-range values are stored; NaN passes.
5. **No master gain** in this stage.
Retail's own native mix exceeds 0 dBFS before the clamp on landings: PR #4 measured landing peaks at +0.4 dBFS in the
native 6 ch.
### 6.4 Downstream of the game: XAudio and the host
- The 6144-byte interleaved block is handed to the console audio API. The recomp's host driver (rex
`sdl_audio_driver.cpp`) receives **planar** 6 × 256 big-endian frames from the guest's XAudio render path.
- **UNCERTAIN**: whether a guest-side XAudio2 mastering stage between the Dac block and the render frame applies
volume.
- **Recomp capture** (`SKATE3_AUDIO_CAPTURE`, stereo f32 LE 48 kHz) uses the host conversion, **not game code**:
Lc = 0.4·(FL + SL + 0.5·C), Rc = 0.4·(FR + SR + 0.5·C), LFE dropped. The same conversion feeds stereo playback in
the recomp.
- A front-centre voice at amplitude a reaches the capture at 0.2a (−14 dB).
- A hard-left voice reaches it at 0.4a (−8 dB).
- **Any level comparison against the capture must pass our 6-ch output through this exact conversion.**
### 6.5 Downmix choices for our engine
- **Retail-native candidates** (both are the title's own matrices):
- (a) device 6-ch, with OS/driver downmix;
- (b) the output stage's N=2 table (0.707/0.5/0.5, LFE dropped) when the device is stereo. Recommended for stereo
output: it is the game's own stereo fold.
- **PR #4's host fold is its own choice, matching neither retail table**: L = clamp(0.6·(FL + 0.707·C + 0.5·SL)), LFE
dropped, OUTPUT_GAIN 0.6 as headroom.
- **For validation against the capture**, always use the rex conversion above.
### 6.6 Voice stealing (Dac budget) [T]
- Each player's cost = a running average of its node times.
- If the Dac load (+224) < 100 and 64 × (smoothed Dac time + Σ player costs) exceeds load × 170666.67 (ticks per
block), voices are retired (`sub_82B49100`, reason 2) until the budget fits.
- The selector `sub_82B3C440` (which voice) is unknown. rwaudio has ExpelReason "cpu limit" and PRIORITY_PERMANENT
[PA].
- A native port can ignore CPU-based stealing but needs a voice cap. Per-bank instance capacity (live < capacity) is
in the evaluator spec.
## 7. Validation plan
**Principles.**
- Measure internally (no A/B comparison pages).
- Use existing captures before asking for new sessions (no repeated play sessions). New retail data comes only from
short scripted recomp runs.
- Every comparison is per block (256 frames) at 48 kHz.
### 7.1 Per-module golden vectors from the PoC
PoC = a local worktree running upstream PR #4, never committed. Add a local example in its
`crates/skate-audio-core` (keep a local copy of the source).
The example loads the TU3 image (`assets/private/stock/audio-runtime-image/g_8200.bin`), builds module objects with
PR #4's constructors, drives them with synthetic inputs, and writes one binary file per case (input block, parameters
per block, output block). Our crate's tests read those files (gitignored, regenerated locally).
| module | cases | tolerance |
|---|---|---|
| Resample | ratios 0.5, 0.8956, 1.0, 1.1478, 3.99, 4.0+ (clamp); a ratio change mid-stream; 1 and 6 ch; sine 1 kHz and white noise; 64 blocks | **bit-exact** f32 (kernel [V]); fallback max abs error 1e-7 |
| LPF / HPF | fc ∈ {0, 24, 25, 77, 1000, 5000, 23975, 23976, 25000}; a cutoff step mid-stream; bypass ↔ filter transitions (history clear) | coefficients bit-exact with image trig, ≤ 1 ulp with libm; output max abs 1e-6 relative to peak |
| Gain | target steps 1→0.5→0, restart flag, multichannel | bit-exact |
| Send | flat, ramp (64/65 landing), modes 1/3, 1→6 / 6→6 / 6→2 routes, release de-click (16 taps) | bit-exact |
| Pan2D1 | A ∈ {−180..180 step 15}, D ∈ {0, 0.5, 0.9995, 1, 2}, focus {0, 1}, LFE 0/0.5, 1/2/4/6-ch sources, parameter-change ramp | gains abs 1e-6 (f64 trig) or bit-exact (image trig) |
| SndPlayer1 | one-shot end, loop wrap, two queued records, scheduled start mid-block, stop fade (16 frames), format change (silent block) | sample-exact positions; values bit-exact |
| Output stage | 8→6 and 8→2 routes, interleave order, clamp, NaN pass-through | bit-exact |
### 7.2 Whole-voice renders (PoC vs ours)
- Use the PoC's authored runtime (`AuthoredRuntime::post`/`pump_once`) to open real bank voices: one-shots (pop
0x449, landing 0x447), a looping bed, a pitched grind, a panned emitter.
- Log per block: the voice's property pushes (id, value, block index) and the final 6-ch Dac block.
- Feed the same property timeline into our graph.
- **Pass criteria per voice**:
- per-block RMS within 0.1 dB;
- per-block peak within 0.1 dB;
- sample-level max abs error ≤ 1e-5 × full scale (larger only where PR #4 approximates, e.g. decode differences);
- onset sample within 0 frames.
- Run with the master delay and host downmix off on both sides (compare the native 6 ch).
### 7.3 Against retail captures
**Data.**
- Recomp session `20261001_211347` (music and NPC speech off): `audio.f32` (stereo, rex conversion),
`trace.tsv` with PLAY, GAIN, SEND, MOD (PITCH/LPF/HPF/SHELF/PEAK), POST, SPLC, CONTACT, CSET, and `CAPTURE <frames>`
every 24064 frames for alignment.
- Older sessions have music (contaminated).
**Per-voice parameter checks** (no audio needed):
- Run our evaluator on the same POST timeline. Compare per voice: Gain target (GAIN), pitch requested/scale/ratio
(MOD PITCH), LPF/HPF Hz (MOD).
- Tolerance: exact words at evaluator ticks; timing within one tick (32 ms).
- The SEND hook's cells are last samples × target, not gains (§4.8). Use only its target column.
**Mix-level checks.** Render our 6 ch, apply the rex conversion, and compare windowed levels with `audio.f32`:
- use the same edges as PR #4 §8: p50 of a 12-frame window after takeoff/landing edges versus the p50 of rolling
stretches;
- PR #4's numbers (**rolling −22.9 dBFS, takeoff +17.0 dB, landing +23.3 dB over the bed**) were measured on the
**native 6-ch** output pass (hook on `sub_82B21F58`, the peak of the 6-ch block), not on the capture;
- **re-measure them in capture space** before using them as capture targets. Expect absolute levels several dB lower
(rex 0.4 factor; centre at 0.2).
- **The relative numbers (+17 / +23.3 dB) are the primary criterion. Tolerance ±1.5 dB** (random sample choice and
physics differences dominate).
- Band profile per event class: ±1 dB per octave, 60 Hz–8 kHz.
**Missing hooks** (cheap recomp additions, if needed):
- a 6-ch native capture (the `OUT` hook on `sub_82B21F58`) to remove the rex conversion and recover LFE;
- a Pan2D1 matrix dump (+444) per voice;
- a Send hook fixed to log +52/+112 and the route pair;
- the output-count byte 0x8306705D at runtime (settles 6 vs 2, and the 8-plane master question).
**Helper**: `tools/recomp-trace/send_vectors.py` summarises SEND lines. It found that +80 is not a
gain: all 6-ch cells read 0, and mono cells look like samples.
## 8. Open questions (priority order)
1. The master/default bus graph and any gain or effect on it; the runtime output channel count (0x8306705D; system
+252); whether a guest XAudio2 mastering volume sits between the Dac and the capture.
2. The env/reverb bus graph (ReverbModel1 parameters) and the material buses' authored DCl0/PI20 values.
3. The sign of the azimuth on real speakers (positive = right is inferred), and the Pan2D1 restart flag meaning.
4. Descriptor byte 2 (×0.01 → player +56): gain or not.
5. The suspend/resume Send modes (1/3 possibly inverted in PR #4).
6. SndPlayer1 pre-roll/skip and span fields per sample; the admission check; the release decay tail before expel.
7. Resample look-ahead byte (+81) value and tail length (6 floats per channel per PA); the ratio ceiling cell value.
8. The PeakingIir2 port has no replay vectors; verify it against the image before the bus port.
9. The 512/2048 routing buses, and the voice-steal selector.
@@ -0,0 +1,344 @@
# Retail world emitters (.ems): how TU3 evaluates them
> **2026-10-02 19:00: the recomp sessions behind the measured sections below were deleted (pre-fix trace writer, a few malformed lines). Treat those findings as "re-verify on clean data" (only all_20261002_163809 / _164620 / _180430 and world_hooks are clean).**
From reading the recompiled TU3 code (reference only; 2026-10-02). Addresses are evidence; never copy
code. Record offsets are decimal.
## File and records
- Layout: BE u32 count, then 72-byte records: index, flags, pos[3], extent[3], scalars[4], u64 sound id,
gains[4]. Our reader: `tools/asset_pipeline/audio_formats.py` `ems_emitters`.
- The sound id is lookup8 of a name, written as named or lower-cased (254 vs 144 on the disc). Our
`name_id`. 28 ids name banks that are not on the disc (e.g. restaurant_amb_2, metal_groans_a).
- Loader `sub_824A24F8`, run from the audio tick `sub_82485190`, owns the EmitterSystem at audio+488. It
takes up to 5 file names from the map database entry and loads `data\audio\<name>` asynchronously.
Records are registered (`sub_828EA7F0`) in a spatial tree as cubes `pos ± max(extent)`. Unload:
`sub_824A2808`.
## Routing is per record
The sound id keys the **attribute database** (`sub_8248CDC8`). Its type field (hash 0x6D18B8674D7E5337,
read by `sub_824A1608`) decides:
- 1 = looping emitter, with its bank loaded on demand;
- 5 = reverb zone (`SFXObj_Reverb` `sub_824DE548` via `sub_82488278`);
- 4 = single-winner **music zone** (`sub_828EB410`, `sub_824A2198`, `sub_824A2330`): attribute RefSpec
`442A158E27F2B5AD` → class `E93ABF17955A5264` playlists (ePlayListMode, eChannelMapPreset, IPodSongInfo /
DJSongInfo); gate = playlist `+4` == audio+1608; the winner (smallest d) is stored at emitter system +208..+256.
Licensed-music player: not ported (2026-10-02).
- 6 / 7 (speakers_ / crowds_ files, Maloof / MegaPark sfx): no bank, always flagged; no caller of the type getter
checks 6 or 7 (only 1, 4, 5). Event PA / crowds: not ported.
Other attribute fields: volume (+4), patch index (+12), variant list 0xC48AB73A227B3B00 (`sub_824A1D38`
picks one at random), curve type 0xF209C093F40A4CCC.
## Per frame (`sub_828EA120` → query `sub_828EA918`, gate `sub_828EAA30`)
- **Query:** the tree is searched with a box of listener ±0.68 m (0x82099764). The listener comes through
pointer 0x820CFDD4.
- **Flags:** `flags != 0 && (flags & system+24) == 0` skips the record. The mask = audio+1032, or 4 when that is 0 or
0x80000 (`sub_8248B258`, message 0). The free-skate value of audio+1032 is not measured.
- **Sphere** when ex == ey == ez: `d = |L−P| / ex`.
- **Ellipsoid** otherwise:
- forward `F = (s1, s2, s3)`; `side = norm((0,1,0) × F)`; `up = norm(F × side)`;
- `d = sqrt((Δ·F/ex)² + (Δ·up/ey)² + (Δ·side/ez)²)`.
- Active while d < 1.
- **Inner core s0:** d < s0 gives 0 (full level); otherwise `d = (d−s0)/(1−s0)`, or 0 when s0 ≥ 1.
- **Level** = attribute volume × curve(d): type 0 → (1−d)², 1 → 1−d, otherwise 1. Computed at start
(`sub_824A1E48`) and each frame (`sub_824A2030`).
- **gains[4] (+56..68) are never read**, as far as traced.
## Posting
- **Start:** a 48-byte tEmitterInfo (patch index, level, position) goes to `CSTATEMGR_Emitter`. Its pool
has **5 states** (`sub_824F6FE0`). When the pool is full, start retries every frame.
- No priority: emitters take states in discovery order.
- **`SFXObj_Emitter` activation (`sub_824DCE60`):** creates an AEMS `c_emitter` object (`sub_824D0E48`) with
the patch index clamped to 0..500. The position is set once (emitters are static).
- **Every frame (`sub_824DCF08`, `sub_828E2D18`)**, parameters in `c_emitter` object slots:
- +8 = MixMap ch4 × level, +12 = MixMap ch8 × level (0..32767)
- +16 = MixMap ch0 (0..65535)
- +20 = MixMap ch5 via `sub_824C5910` (0..8192, 4096 default: pitch?)
- +24 = MixMap ch6 (0..25000: low-pass Hz?)
- How these channels depend on position is not verified.
- **Stop (`sub_828EB0F0`):** a node that wasn't hit this frame is released at once, with no fade here. Its
bank unloads when no node uses it. Timed one-shots (`sub_824A1A20`) expire after 10 s by default.
## The attribute database = our skater collections (verified 2026-10-02)
`assets/private/stock/skater-collections.json` (`skatercollections.vlt`), class **Hash_F0CEF367088EFFF8**,
360 records. Key = `Hash_<sound id>` (the `.ems` id exactly; no name needed). Inheritance runs through
`parent`: e.g. water_fountain → E11BD62E74030160 → 07AF07ECC07F3828 → default. Fields:
| Field | Meaning | Example (water_fountain) |
|---|---|---|
| `volume` | attribute volume (level = volume × curve(d)) | 0.5 |
| Hash_9908F2D75D7381BD | float, default 10.0; probably the timed one-shot duration (`sub_824A1A20`, 10 s default; unverified) | 15.0 |
| Hash_BE88128A30BE926E | bank file (text) | `water_fountain.abk` |
| Hash_C493ED34D1D32521 | patch index (clamped 0..500 for `c_emitter`) | 81 |
| Hash_6D18B8674D7E5337 | `Sk8::Audio::eVolumeType`: 1 = looping emitter, 5 = reverb zone, 4 = single winner; 0/2/3/7 also exist | 1 (inherited) |
| Hash_F209C093F40A4CCC | `eVolumeFalloffType`: 0 = (1−d)², 1 = 1−d, other = flat | 0 |
| Hash_C48AB73A227B3B00 | variant list (RefSpec array) | — |
Examples (volume / f9908 / patch):
- water_lapping 1.0 / 15 / 82; water_lapping_pond 0.8 / 15 / 83; trees_rustle 0.25 / 38 / 76
- pub_amb 1.0 / 36 / 33; clock_bell 0.35 / 40 / 38
- unresolved-by-name `086CD0AEE99DB825` = `fountains_waterlaps_left.abk`, patch 347, vol 0.5 (the University
channel, where the user heard bad water)
- `1F94F2F815C00368` (37× in the sfx files) = type 5 reverb zone (RefSpec 99FD793BC30CF0FA → a reverb preset)
## What the banks' programs do (PoC evaluator run, 2026-10-02; probe `emitter_probe.rs` in the PoC)
- **Evaluator gotcha:** the tick delta must be rounded to f32 (`(256f32/48000f32) as f64`). With an f64
delta the period is recounted every block and nothing runs.
- **`c_emitter`:** 291 banks bind to it. The payload is 9 words:
- w0 = level (0..32767), w1 = dry (prop 8), w2 = send (prop 5)
- w3 = pan (prop 3, 65536 = 360°), w4 = pitch (4096 = 1.0)
- w5 = low-pass (prop 6), w6 = high-pass (prop 7), w7 = unused
- w8 = selector: a bank plays only when w8 equals its own base. Bases: water_fountain 81,
water_lapping 82, pond 83, trees 76, pub_amb 33, Siren_city_1 225, DogBarks_medium 186.
- So the attribute **patch index (Hash_C493ED34D1D32521) is the selector** (water_fountain patch 81 =
base 81; the first reading's "+1" is the 1-based form).
- The programs do no distance or position work: the game supplies level, pan and filters.
- Release (op 4) ends the instance at once. Capacity is 10 instances per bank.
- **water_fountain:** a two-voice relay of one-shots. Each voice draws from its own 10-sample shuffle bag
(no repeat until the bag empties; samples 1.6–2.5 s). When the playing voice has about 4–206 ms left
(curve over remaining ms × 0.126), the other voice starts. That gives about 24 starts in 30 s, first
after 60–90 ms. (Re-run 2026-10-02 with selector 81: **17** starts in 30 s, first at 59 ms;
see aems-evaluator-spec.md §6.)
- Level = w0 × (26000 + a 4 s triangle LFO with depth from a 3.5 s sine of amplitude 5000) / 32767,
which is 0.64–0.95.
- Pitch = (3600 ± 250, 4 s sine)/4096 = 0.818–0.940.
- Caveat: the mock reports remaining time at pitch 1.0.
- **water_lapping:** the same relay. Samples 0.96–2.45 s; level 25000 ± ≤10000 (0.47–1.0); pitch
(4100 ± 250)/4096 (0.94–1.06).
- **trees_rustle:** one voice looping a 38.5 s sample. Level 20000 ± ≤20000 (0.03–1.0, gusts); pitch
(3900 ± 250)/4096.
- **pub_amb:** a single 36 s sample, started once.
- **`c_emitter_utility`** (posted once at boot) sets `random_pitch_gbl` / `random_vol_gbl`:
- Every 2.5 s it draws an index 0..30 into curves: level 16384→32700→16384, pitch 3700→4400→3700.
- Ramps take 1.0 s (level) and 1.5 s (pitch).
- 43 banks use the level global (AC units, vents); 12 use pitch (ships, propane, liquid flow).
- It also answers the `*_msg` broadcasts by drawing the next shuffle value into the `*_snd` globals.
- **Sirens, dog barks, distant bangs:** ordinary `c_emitter` banks. One post plays once (siren: a 19.3 s
sample plus a layer 0.19 s later at 16000/32767, pan sweeping about −15°/s). There are **no timers** in
any program, so the game schedules them; who posts them, and how often, is open. No `.ems` record names
them.
- **`c_main_ambience_crossfade`** (DT/Ind/Uni):
- 28 voices in 7 groups of 4, at quad pans 45°/225°/135°/315°.
- w9 (1..7) selects the group, by slot switch: instant, not a timed crossfade.
- Front voices at w0, rear at w8 × 23000/32767; w1 dry, w2 send, w4 pitch, w5 LP, w6 HP.
## Distant random one-shots: sirens, dogs, bangs, alarms (TU3, 2026-10-02)
A location-based random one-shot scheduler inside the same EmitterSystem (audio+488). Not the ambience
manager, and never in the `.ems` files.
**Data: attribute class `aud_wp_emitters`** (Hash_39A2DE0232912CE5, 59 sets). Names come from db.big
`skatercollections_summaryreport.txt`; key = name_id(name). Fields:
- +0 29C68772… = min level (usually 0.2); +8 B6AD5C13… = max level (0.2..1.0)
- +4 B1E6821F… = min interval (2..20 s); +12 A306BD1F… = max interval (5..40 s)
- 5CA06CA0… = RefSpec list of `aud_emitter` (F0CEF367…) sounds; 07DDABF9… = Int32 weights (default 10)
- 79460EEF… = level steps (default 10)
Example sets (interval / level):
- e_dwtn_office_buildings 2–5 s / 0.2–1.0; e_dwtn_lot_5 3–8 s
- e_dwtn_mall 5–15 s / 0.4–0.65; e_dt_main 2–5 s / 0.5–1.0
- e_old_town 2–7 s / 0.5–1.0, with Siren_euro_1/2/4
Where sirens appear:
- City sirens only in the downtown "dwtn" sets (lot_5, city_center, mall, office_buildings, canal_plaza,
rez_plaza, spillway_core_plaza, spillway_brewery, skatepark).
- Distant sirens: dwtn core_plaza, parkade, mem_plaza, wall, brewery, skatepark, and e_univ_low_plaza.
- Suburb sirens: e_dt_main, e_dt_matrix, e_soho, e_urban_res, e_projects_below, e_univ_suburbs.
Per sound (`aud_emitter` record):
- patch selector (Siren_city_2 = 226); volume (sirens 1.0, suburb 0.75, dogs and bangs 0.8);
- the +8 float (the 10.0-default field) = **maximum play time / timeout**: sirens 19.5 s, dogs 5.0,
alarms 15.5, mcycle 9.4.
- Dist_Dog_Bark_* has no aud_emitter record.
**Code**
- **Set selection:** getter vtable+0x60 `sub_824A1560` returns the u64 at `*(0x82083C38)+0x2F0B0`, the
current location's audio record. It returns 0 when system+28 == 0, `*(0x820CFDE4)+1096` == 5, or the
audio mode `*(0x820CFDC4)+892` is 18/19/20.
- **Set change:** the per-frame update `sub_828EA120` (enabled at +76, +28 == 3) calls `sub_828EA4E0`
SetRandomSet when the key changes. That rebuilds 32-byte entries (key, weight, state, age, bank slot,
handle) and draws the first interval.
- **Bank loading:** `sub_828EA290` keeps at most **2** banks loaded, each a weighted pick among unloaded
entries (`rand() % Σweights`).
- **Timer:** `sub_828EA3D0` adds dt; past the interval it plays the loaded idle entry with the largest age
(the oldest loaded), then resets and redraws even if nothing played.
- **Interval** (`sub_824A1980`) = min + (max−min) × (rand()%1000) × 0.001, timed trigger to trigger.
- **Post** (`sub_824A1A20`), through the same CSTATEMGR_Emitter pool:
- patch; level = volume × L, with L = (min×10 + rand() % ((max−min)×10)) / 10;
- one-shot; pan = rand() & 0xFFFF, a random fixed 360° pan;
- position 0 (not positional); timeout = the play-time float.
- **Per frame** (`sub_824DCF08`, non-positional branch):
- +8 = MixMap ch2 × level, forced to 0 when `*(0x820CFDBC)+104` != 0 (a mute?);
- +12 = ch7 × level; +20 = ch1 (pitch 0..8192); +24 = ch3 (LP 0..25000); +16 = the fixed pan.
- **Stop:** when the program ends or the timeout passes. The bank unloads and a fresh weighted pick loads
another; nothing is excluded after playing.
**Open**
- Who writes the location record (`*(0x82083C38)+0x2F0B0`): no e_* names or hashes appear in the world,
missions or livingworld data. Lead: the surfaceless zone boxes (doc 05: they are retail trigger volumes).
- The MixMap ch1/2/3/7 values, the mode codes, and the units of dt.
**Passive recomp trace plan** (no repeat sessions):
- `sub_828EA4E0` entry r4 (set key); `sub_828EA290` loaded entry key;
- `sub_824A1980` return f1; `sub_824A1A20` sound key and info (+0 patch, +8 level, +16 pan, +24 timeout);
- `sub_824DCF08` objects with info+4 == 0: c_emitter +8/+12/+16/+20/+24;
- per frame: the location u64, the mode at `*(0x820CFDC4)+892`, the mute byte.
- A short scripted teleport through downtown plazas would show the location → set mapping.
## Location → set: world-painter region layers (SOLVED 2026-10-02, verified against the recomp)
- **Base address:** `*(0x83083C38)+0x2F0B0` (lis 0x8308; the "0x82083C38" written above is a typo, as was
0x820CFDxx → 0x830CFDxx).
- **The 152-byte record:** +0 emitter-set key, +8 ambience key, +16 reverb key, +24 another key, +32 flags.
- **Writer:** module tick `sub_827A78A8` → `sub_827A11B0` → `sub_827A2E88` with the focused skater's x, z.
- If byte 323 at 0x830B7AE8 is 0 and `*(*(0x830CFD94)+292)+6013` is set, it uses the vector at
that object +5808 instead (camera?).
- Then `rec+0/+8/+16 = sub_82C0EAC0(R, layer 8/7/10, pos)`, where `R = *(*(0x830CFD94)+232)`.
- **Lookup:**
- 128 m tiles (`sub_82C0EFA0`), each with 19 layer slots matched by `name_id(layer name)`:
7 audio_ambience, 8 audio_emitters, 10 audio_reverb, plus districts, fog, sky, bloom, etc.
- Each slot is a quadtree (`sub_82C0F0B0`): header centre (x, z) +0, half-size +16, node count +32,
node offset +36.
- Nodes are 10 bytes: four u16 children and a u16 value. child[0] 0xFFFF = leaf; value 0xFFFF = no key.
- Children are tested in order −x−z, −x+z, +x−z, +x+z, with inclusive edges.
- **Data:** district `worldDIST_<D>.big` → `cSim_*.xsf` (SFIL, index `*_Sim.xst`), RW4 arenas with ATOC
processor 0xAB329A6A.
- Dictionary type 0x00EB000F = layer record {tree object index, key-table object index, layer hash};
0x00EB0010 = quadtree; 0x00EB0011 = key table.
- The key table holds {offset, type 2} entries; the u64 keys are stored **low word first**.
- **Our code:**
- `audio_formats.region_layers` / `region_key`; `audio_export.regions` → manifest `regions`
{district: {layer: tiles}};
- engine `library::RegionTile::key`; `random_sets.rs` queries at the board position.
- **Verified against the recomp:**
- Aletown spawn (−182.9, 430.5) → e_dwtn_spillway_brewery;
- University start (348.8, −725.9) → e_univ_megapark. Axes: x, z as-is.
- **Coverage:** DownTown 128, University 122, Industrial 100 and SkateSchool 62 emitter tiles. The small
parks have only ambience and reverb layers, so no random one-shots there, as in retail.
- **Next use:** the audio_ambience layer gives the zone bed key (`aud_wp_ambiences`, e.g. dt_open at
Aletown), replacing our per-map bed pick; audio_reverb gives `aud_reverb` presets.
## Recomp trace confirmation (session sirens1, Aletown, 2026-10-02)
- Hooks WPPOS / WPSET / WPINT / WPFIRE (skate3recomp `src/skate3_audio_trace_hooks.cpp`).
- e_dwtn_spillway_brewery (4–8 s, level 0.2–1.0) fired 9 sounds in about 75 s: Metal_Bang_1,
car_horn_city_1, **Siren_distant_2**, Jet_Passby_1, truck_horn_city_2, Delivery_truck_leave_3,
**Siren_city_8**, bus_by_3, Jet_Passby_1.
- Our engine at the same spot selects the same set and fires the same kinds of sounds.
- Bank programs measured for all 113 set banks (PoC probe) → `game_audio/random_programs.rs`:
- 56 banks play one layer per post, 53 two, 3 loop;
- 4 never answer their attribute selector (Siren_city_5/6, car_horn_city_2/3), so retail is silent
for those too.
## 15-location validation (recomp session locations_listen, 2026-10-02)
- Teleports via Challenge Map > Locations: 5 each in Downtown, Industrial and University, 45 s listening
at each. Script: `locations_listen.txt`.
- Checked with the local tool `check_sets_vs_trace.py <trace>` (each stop against its
own district; stale post-teleport samples skipped).
- **Our region lookup = retail's location set on 1859/1859 samples**, 15 sets: dwtn spillway_brewery,
mem_plaza, office_buildings, spillway_start, skatepark; ind reclaimed, quarry, drydocks, spillway_top,
shipyard; univ new_campus, clocktower_sq, training_facility, megapark, observatory.
- **Silent banks confirmed:** retail fired Siren_city_5 (Hotel District), but no Siren_city_5 sample
played; the next new samples were delivery_truck_in_1, the next fire.
- **Real sirens start their own samples:** Siren_city_4 layers at +16 ms and +216 ms (two layers ≈0.2 s
apart, as probed); Siren_city_10 at +208 ms. Matched by the PLAY lines' 48 sample bytes against the bank
files.
- Location lists (screenshots): Downtown 14, Industrial 12, University 10, then skate.School, skate.Park (8),
Maloof Money Cup (2), Black Box Park, Maloof 2010 NYC, Danny Way's Hawaiian Dream, The Sanatorium and
Art Gallery (DLC todo).
- Menu path: start, a, rt, rt, down×district, a, down×location, a, a, a.
## Zone ambience beds (TU3, 2026-10-02; local asm dumps)
- **Getter `sub_824D3E40`:** ambience key = `*(0x83083C38)+0x2F0B0+8` (region layer audio_ambience).
- Overrides: front end (`*(0x830CFDC4)+1204` set and +1196 == 10) uses press_start_screen
(0x5EADF1818A721AF9); a game-state record of class C1831BDB6CB1B1EA (from `*(0x830CFDA4)+76`, RefSpec
51BAD6E2DB03B4AE) can replace the key.
- Returns 0 until the bed's stream entry is ready.
- **Beds:** `sub_824D4630` reads `ambienceresident.big`; index = atoi of the first 2 chars of each `.snr`;
stream path = `data\audio/ambience.big|NN_name.sns` (`sub_824D3590`). There are 16 more DLC slots
(`%d_dlc_ambience.snr/.sns`).
- **Index 19 exists twice:** `19_reclaimed_b_fix` and `19_reclaimed_b`; the first in TOC order wins
(likely `_fix`).
- **aud_wp_ambiences layout:** +0 volume, +4 bed index, **+8 (Hash_17C3…) = fade-OUT time**,
**+12 (Hash_2E2A…) = fade-IN time**.
- **State machine** (`sub_824D3C28` update; `sub_824D3FE0` on key change, else `sub_824D41E0`; obj+64
state, +68/+72 timers):
- **0 silent:** a non-zero key starts its stream, obj+48 = key, go to state 3.
- **3 fade-in:** timer += dt until the new zone's fade-in time, then state 1.
- **1 steady:** a key change goes to state 2.
- **2 fade-out:** over the OLD zone's fade-out time, then stop and free the stream (`sub_824D3B40`) and go
to state 0; the new bed starts the next frame.
- **Reversal:** back to the old key mid fade-out → state 3 with t = (1 − t_out/out) × in.
- A key change during fade-in waits until the fade-in ends.
- **One stream only:** beds never overlap.
- Fade = linear attenuation 0..32767 as MixMap input 0 (state 0 → 32767, state 1 → 0).
- **Level** (`sub_824D42C8`, state ≠ 0) = MixMap out0 / 32767 × zone volume.
- Pitch = out2 / 4096; LP = out3; the send is set once at start.
- The graph is SnP1 → Rch0 → Rsp0 → Gai0 → LI20 → Sen0: no panner, the 5 channels play as authored.
- Ducking (which MixMap inputs feed out0..3) is not settled.
- **Crossfade:**
- The district bank comes from records of class F4917ACACAFAF913 field 33526BC9… (dist_downtown →
Main_Ambience_Crossfade_DT, industrial → _Ind, university → _Uni; parks none). It loads into a 205 KiB
slot (542D…); 153 (8B06…) is unused.
- The pair list is {from, to, group 69359D…, level 875B…}. At the start of a fade-out (1→2) with
new ≠ 0, the pair is searched **in either order**; with no match, the default is group 1, level 1.0.
- `sub_824D0F38` posts `c_main_ambience_crossfade` with w1 32767, w4 4096, w5 25000 and w9 = group
(1..25). In states 2 and 3, w0 = w8 = clamp(MixMap out1 × level).
- It plays for the whole transition (fade-out + fade-in) and stops when the fade-in ends
(`sub_828E2C78`) or a new transition replaces it. Never just near a border.
- **Reverb (+16):** `sub_824DE548` SFXObj_Reverb (default key 0xA2782D75A971CC8C) is merged with the type-5
reverb emitters (`sub_82488278`); presets via `sub_824DE468` / `sub_824DEAE8` / `sub_824DE970` /
`sub_824DE850`.
- **Trace hooks to confirm:**
- `sub_824D3C28` entry (f1 dt, obj+48/+64/+68/+72);
- obj+12 vtable+8 (r5 fade input);
- `sub_824D42C8` (MixMap outputs 0..3);
- `sub_824D0F38` (r4 group, obj+116 level);
- `sub_824D3590` (r4 bed index, path at r1+256);
- `sub_824D4D40` (bank load).
## Caveat (2026-10-02, evaluator spec)
The PoC evaluator (PR #4) mishandles op 5 (CallFunction): `*_msg` → `*_snd` sample counters never advance.
So the probe-measured SAMPLE CHOICE of function-driven banks (sirens, dogs, birds, bangs) is not retail;
timing, layers, pans and levels are. Ops 19/20/38 are missing in PR #4. water_fountain re-measured: 17 starts
in 30 s (not 24). Details: `aems-evaluator-spec.md`.
## Long runs: Downtown (2026-10-02; 11 of 14 locations, 5 min each; `long_run_stats.py`)
- **Location sets:** ours = retail on 6,350/6,350 position samples (10 sets).
- **Zone ambience:** retail's AMBST zone = our `audio_ambience` lookup at every stop:
- Aletown dt_open, All Mart dt_open, Carverton dt_parks, Crystal Towers dt_less_busy;
- Hotel District dt_main, Kube Tower dt_apt, Rippon dt_rez, Rosalita dt_main, Slappy's dt_rez;
- Tresoutta dt_main, Uptown dt_apt.
- **Intervals:** all within their set's min..max (2 exceptions are carry-overs from the previous stop).
`e_dwtn_skatepark` has min 7 > max 5; retail draws in 5..7, like ours.
- **Fires vs intervals:** about 60% (e.g. Aletown 32 fires / 53 intervals), the 2-loaded-banks skip rule.
Compare with our engine's ratio (simulate the same sets) when validating.
- **Rare sounds not seen in 5 min:**
- All Mart: Racey_cars_2/4, Tire_Squeals, car_backfire, car_crash;
- Hotel: car_alarm_city_2, semi_truck_by_1, truck_by_1;
- others: 1–4 per set.
- **Silent banks:** Siren_city_5 is posted (WPFIRE) but silent. Open: does retail keep its slot "playing"
until the timeout? Ours frees it at once.
- Data: recomp session `long_downtown_a`, `long_dt_c` (`long_dt_b` re-run pending).
- **Update:** Downtown complete (14/14) and Industrial 8/12 (`long_dt_b`, `long_in_a`, `long_in_b`):
- sets 5,766/5,766 more (Downtown total 12,116/12,116);
- zone ambience = ours at every stop (indu_quarry, indu_drydock, indu_new_factory, indu_reclaimed_a; where
retail logged no AMBST change the zone equalled the previous stop's, and ours agrees);
- intervals all in range except post-teleport carry-overs;
- rare: `work_whistles` never fired in about 20 min over four `e_ind_newfactory` stops.
## Zone bed level: −11 dB base VERIFIED (2026-10-02)
`check_bed_level.py` compared retail capture levels (1 s RMS percentiles, long-run stops) with the bed's
5-channel stream × zone volume folded like the capture (host fold 0.4·(front + surround + 0.5·centre)).
- Medians, retail vs predicted with −11 dB: dt_open −42.1 / −42.1; dt_main −39.2 / −38.7;
dt_rez −44.2 / −43.6; indu_quarry −47.7 / −47.8. Without the base, every prediction is 11 dB too loud.
- The stream channel order L R C Ls Rs fits slightly better than L C R Ls Rs (0.3 dB). Unverified.
- Engine: `ambience.rs` `BED_BASE` × `cues::RETAIL_SCALE`.
- **Update:** Industrial complete (12/12) and University 5/10 (`long_in_c`, `long_un_a`): sets 2,570 + 3,170
more = **23,622/23,622** over 31 locations and 25 sets. The zone ambience equals ours everywhere
(indu_spillway, indu_old_factory, indu_shipyard, indu_reclaimed_a, univ_campus, univ_housing). All
intervals in range. University sets fire sparsely (5–25 s ranges): 21–41 fires per 5 min.
- **FINAL (all 36 locations, 2026-10-02):** `long_un_b` adds 3,195/3,195 → **26,817/26,817** samples over 28 sets
(Downtown 11, Industrial 7, University 10). The zone ambience equals ours at all 36. Intervals in range.
Rare in 5 min: hawks, rattle_snake (megapark), cicadas (observatory), car_horn_suburb_1 (low_plaza),
work_whistles (factories). Recomp sessions `long_*` (large: traces with MOD/BRD lines,
f32 captures, shots; see todo disk-cleanup).
@@ -0,0 +1,550 @@
# Skate 3 granular rolling bed (grains.big + GrainPlayer): behavioural spec for the native port
Written 2026-10-02. This describes **behaviour, formulas and constants in our own words** so the rolling bed can be
re-implemented natively in Rust (Phase B of the native-port plan (doc 11, "Native audio port")). It replaces the
current approximation (each `.grain` cut into 6 looped speed bands, `tools/asset_pipeline/audio_export.py`
`GRAIN_BANDS`, `crates/skate-game/src/game_audio/cues.rs::grain_for`).
Nothing here is code from PR #4/PR #1 or the recomp (no licence: read for understanding only). Constants, offsets,
addresses and vault hashes are facts and are quoted. Companion specs: `aems-voice-graph-spec.md` (modules, buses, block
clock), `aems-evaluator-spec.md` (patch programs, global RNG), `aems-reference.md`, `mixmap-spec.md` (MixMap; being
written in parallel — the MixMap output ids used here are listed in §2.9 so the two can be joined).
## 0. Confidence tags
- **[V]** verified by us against data (disc, vault, image) or a retail trace in this session;
- **[R]** read by us in the recomp's generated C++ (`skate3recomp/generated/skate3_recomp.*.cpp`, TU3 addresses);
- **[C]** PR #4 states it was confirmed against its retail memory capture (bit-exact object dumps, timers);
- **[T]** PR #4 transcribed it from the lifted code, not compared; we have not re-read it;
- **[G]** reproduced by our golden-vector run of PR #4's ported functions in the PoC (§5.1);
- **[I]** our inference. **UNCERTAIN** = a port must not treat it as settled.
All guest structures are big-endian; addresses are TU 3.0.3.0.
---
## 1. Data
### 1.1 `grains.big` [V]
EB BIG v3 archive, 3,446,976 bytes, 14 uncompressed `.grain` members (survey: `grain_survey.json`,
script `tools/audio-file-inspect/grain_survey.py`):
| member | H | duration (s) | rate | samples | seek row 0 (bytes, side, samples, key) | side bytes |
|---|---|---|---|---|---|---|
| asphalt_rough_hard | 160 | 19.503 | 44100 | 860080 | 232349, 118, 860080, 1 | 128 |
| asphalt_rough_soft | 176 | 19.919 | 48000 | 956100 | 259515, 131, 956100, 1 | 144 |
| asphalt_smooth_hard | 176 | 21.968 | 44100 | 968807 | 284227, 143, 968807, 1 | 144 |
| asphalt_smooth_soft | 176 | 21.842 | 44100 | 963222 | 280983, 142, 963222, 1 | 144 |
| concrete_aggregate_hard | 176 | 21.117 | 48000 | 1013604 | 277250, 140, 1013604, 1 | 144 |
| concrete_aggregate_soft | 176 | 19.470 | 48000 | 934573 | 263906, 133, 934573, 1 | 144 |
| concrete_rough_hard | 176 | 21.842 | 44100 | 963228 | 259879, 131, 963228, 1 | 144 |
| concrete_rough_soft | 160 | 20.070 | 44100 | 885096 | 250225, 127, 885096, 1 | 128 |
| concrete_smooth_hard | 176 | 21.090 | 44100 | 930067 | 254494, 129, 930067, 1 | 144 |
| concrete_smooth_soft | 160 | 20.324 | 44100 | 896308 | 251178, 127, 896308, 1 | 128 |
| metal_smooth_hard | 176 | 21.870 | 48000 | 1049760 | 277104, 140, 1049760, 1 | 144 |
| wood_ramp_hard | 144 | 19.053 | 44100 | 840219 | 219096, 111, 840219, 1 | 112 |
| wood_ramp_soft | 144 | 17.617 | 44100 | 776894 | 210104, 107, 776894, 1 | 112 |
| x_jet_rolling | 112 | 12.152 | 48000 | 583305 | 123424, 65, 583305, 1 | 80 |
There is no `metal_smooth_soft` and no wood_ply member, although the vault has a `wood_ply_soft.grain` collection
(key `1258126B657708F0`) that no code path selects [V] — a cut asset.
### 1.2 `.grain` layout [V, field roles C/T]
| offset | type | meaning |
|---|---|---|
| +0 | u32 | **H** = header length = offset of the EAAC stream (112..176) |
| +4 | f32 | duration in seconds. The player uses this stored float, not samples/rate: it equals samples/rate rounded to single in 11 members and is 1–3 ulps off in asphalt_rough_soft (1), concrete_aggregate_soft (1), concrete_aggregate_hard (3) |
| +8 .. H | | seek table (§1.3) |
| +H | | one EA Audio Core stream: header `version 0, codec 3 (EA-XMA), mono, 44100 or 48000 Hz, type 0 (RAM), no loop flag`, sample count, then **one block** spanning the rest of the member |
Each recording is one continuous roll from slow to fast. Measured (20 equal bins over each file, vgmstream decode,
`grain_survey.py --rms`) [V]: level rises monotonically by **9–12 dB** from start to end (e.g. concrete_rough_hard
−22.8 → −13.6 dBFS RMS, asphalt_smooth_hard −25.3 → −12.9, x_jet_rolling −34.2 → −18.9) and the spectral centroid rises
(e.g. concrete_smooth_hard 1.08 → 2.0 kHz, wood_ramp_hard 0.42 → 1.48 kHz). The first ~10 % of most files is
near-stationary slow rolling. The "read position" therefore *is* the speed axis.
### 1.3 Seek table (same format as the `.sek` files of `wheels.big`) [V layout, T semantics]
Header, 8 bytes, identical in every grain: `00 10 0180 00000018`:
| byte | meaning |
|---|---|
| 0 | kind: 0 = run-length columns (all grains); 1 = another table kind (unused here); ≥2 = seek fails |
| 1 | high nibble = column layout (1 for all grains; 0 = another layout, unused); low nibble = a 4-bit value copied into the seek result (0 here) |
| 2–3 | u16 **pre-roll** in samples = 384 (decode this much before the target and discard it) |
| 4–7 | u32 offset of per-entry side data from the table start (0x18 = 24), or 0 |
From byte 8: four interleaved **run-length columns** read row by row through one shared byte cursor:
column 0 = byte step of the entry in the EAAC stream, column 1 = side-data step, column 2 = samples in the entry,
column 3 = 1 on a key (restartable) entry. A row whose sample count is negative ends the table.
- **Column coding.** Each column alternates *run headers* and *deltas*, all signed varints. A header h ≥ 0 means "the
next delta applies to each of the next h+1 rows"; h < 0 means "each of the next 1−h rows reads its own delta". A
column's value is the running sum of its deltas. All four columns of a row are advanced in order 0,1,2,3, so their
bytes interleave.
- **Signed varint** (first byte b0 decides the length; the lowest bit of the *last* byte is the sign, a set sign means
value = −1 − magnitude):
- b0 < 0xC0: 1 byte, magnitude = b0 >> 1;
- 0xC0 ≤ b0 < 0xF0: 2 bytes, magnitude = (((b0·256 + b1) >> 1) with bits 13–14 cleared) + 96;
- 0xF0 ≤ b0 < 0xFC: 3 bytes, magnitude = ((((b0·256 + b1) with bits 12–15 cleared)·256 + b2) >> 1) + 6240;
- 0xFC ≤ b0 < 0xFF: 4 bytes, magnitude = ((((b0 & 3)·2^24 + b1·2^16 + b2·2^8 + (b3 & 0xFE)) >> 1) + 0x60000 + 6240;
- b0 = 0xFF: 5 bytes, a raw big-endian u32 (no sign step).
- **What it indexes:** sample position → (byte offset of the XMA entry, side-data offset). It does **not** index speed
or grain segments. In every grain row 0 already covers the whole stream (`samples = num_samples`, key 1) [V], so
every in-range seek resolves to *"restart the decoder at byte 0/sample 0, decode 384 pre-roll samples… "* — in
practice: entry start 0, pre-roll used = min(target, 384), skip = target − pre-roll used; entry + pre-roll + skip =
target. The side data (~1 byte per 2 KiB XMA packet) is for the hardware XMA decoder to locate packets [T].
- **Port:** decode each grain once to PCM at its native rate (vgmstream at setup); a seek to t seconds is simply
"read from frame ⌊rate × t⌋" (§2.6). The seek table only needs parsing to validate members. Keep the varint/column
reader (it is the same for `wheels.big` `.sek` tables) — our implementation in `grain_survey.py` reads all 14.
### 1.4 Which grain plays [T; surface table V]
Per board the owner (`SFXObj_SkateBoard`, constructor `sub_824C5058`) loads the 13 surface members into 26 slots
(two per member, one per player A/B, both point to the same bytes) and keeps **two trucks × two players**.
1. **Material → surface.** Per truck, the audio state's per-wheel material (state +620 + 4·wheel; wheel 0 for the
primary truck, wheel 3 for the other) is `collision tag & 0x7F` minus 1, with tag 0 (or anything out of 0..143)
meaning **143 = none** [T]. Rolling surface = word +4 of element `min(material, 94)` of the vault's
`Sk8::AudioSurfaceMap` (class `C1831BDB6CB1B1EA`, key `C489459A0C07D154`, field `4CA607558B1CF440`, 95 × 72 B);
material 143 gives surface 3 [T]. In terms of our engine's 7-bit `audio_surface` tag (= material + 1) [V, vault]:
| rolling surface | what plays | tags (`tag & 0x7F`) |
|---|---|---|
| 1 asphalt_rough | grain | 2 |
| 2 concrete_rough | grain | 4, 66 |
| 3 asphalt_smooth | grain | 1, 55, 56, 61, 62, 71–83, 86–88, ≥ 95, and 0 (none) |
| 4 concrete_smooth | grain | 3, 51–54, 57, 60, 63–65, 92, 93 |
| 5 wood_ramp | grain | 6, 7, 41–50, 58, 59, 94 |
| 6 concrete_aggregate | grain | 5 |
| 9 metal_smooth (hard only) | grain | 9, 11–36, 38–40, 84, 85, 89, 91 |
| 7, 8, 10, 12, 13 | **no grain**: a per-surface `Class_rolling` patch (selectors 1, 2, 10, 11, 9) | 7: 10, 70 · 8: 8 · 10: 67 · 12: 68, 69 · 13: 37 |
| 0 | UNCERTAIN (no grain member, not a Class_rolling surface) | 90 |
| 14 | **no contact**: stop this truck's sound | (forced, see 3.) |
Our current `grain_for` table differs from this (e.g. tag 6/7 wood, tags 5/8/10 aggregate, tag 90 wood); the port
must use the vault map.
2. **Soft/hard:** soft member when `sub_824B23C8() == 1` = audio state +684 = (Motion+200 < 0.5) [T]. Surface 9 has
only `metal_smooth_hard`. What Motion+200 is (wheel durometer from the board setup?) is UNCERTAIN.
3. **Forced 14 (no contact):** while grinding (state +341), or on the local player while the manual latch (§2.7) is set
and the truck's reference wheel has not landed (state +464 + wheel) [T].
4. Collection key per member (grain-class vault `7AB23C11B6ADA2DE`), owner slot pair A/B [T]:
asphalt_rough soft `943B1CB05BA9A6BA` 14/15, hard `7EB8015B4C02405E` 0/1; concrete_rough soft `B29FADBBBC2F39C2`
16/17, hard `03721D0FA99A03C8` 2/3; asphalt_smooth soft `DC1009D50E327F8F` 18/19, hard `7C5912FC2DABF98C` 4/5;
concrete_smooth soft `607A6BC3D427DA49` 20/21, hard `FFB5E3E62E0B4943` 6/7; wood_ramp soft `382B12636ED9D8DA`
22/23, hard `7947A259F181FDB4` 8/9; concrete_aggregate soft `863C58AC34BAD599` 24/25, hard `B303AED8241530E2`
10/11; metal_smooth_hard `1C9C52CC0E1CD4CF` 12/13. Anything else → `default` (`D7EDBD362D7D2152`), nothing bound.
5. **x_jet_rolling** is not a surface grain: it is the "AudBoard Rocket" speed layer of `SFXObj_SenseOfSpeed` (§2.8).
**Surface routing** (`sub_824C5CA8`, every frame, game thread) [T]: for the primary truck, then (local player only) the
other truck, compare the new surface with the stored one. On a change away from a real surface it pulses owner MixMap
input 0 (32767 for that frame, else 0). If the truck had a live sound it is stopped (Class_rolling released, or both
grain players stopped) — except on the local player when the other truck has no live sound, in which case the sound
is **handed to the other truck** (the primary-truck index flips) instead of being stopped. Then, for a new surface
≠ 14, a sound starts **only if the other truck is not already on that surface**: grain surface → bind both players of
that truck (§2.5); other surface → post its Class_rolling. So **one sounding truck per distinct surface**: normally
one truck (2 grain players) sounds; both trucks sound only while they straddle different surfaces. Owner input 6 =
32767 while truck 0's surface is 9 (metal). PoC `Routing::route` (skate-game `player_audio/components/board.rs`) is
the oracle for golden vectors of this bookkeeping.
### 1.5 Per-surface tuning (grain-class vault `7AB23C11B6ADA2DE`) [V values; field roles T]
`default` holds everything; members override a few fields. P0..P3 = position-curve control points (column 1 of rows
3, 2, 1, 0 of the 4×4 matrix `A985FBAA9326718D`; column 0, unused by the code, holds 1, ~0.47, ~0.25, 0 — probably the
authoring curve's speed axis [I]).
| collection | max km/h | P1 | P2 | turn cap | B slope gain / ramp km/h | A shift/slope | B base shift | notes |
|---|---|---|---|---|---|---|---|---|
| default | 74 | 0.3621 | 0.7069 | 0.8 | 1.5 / 10 | −100 Hz | −10 Hz | |
| asphalt_rough_hard | 65 | 0.2586 | 0.7069 | 0.8 | 1.5 / 10 | −100 | −10 | |
| asphalt_rough_soft | 65 | 0.0690 | 0.7069 | 0.8 | 1.5 / 10 | −100 | −10 | |
| asphalt_smooth_hard | 65 | 0.0517 | 0.8276 | 0.8 | 2.0 / 5 | −150 | −10 | |
| asphalt_smooth_soft | 65 | 0.5517 | 0.6034 | 0.8 | 2.0 / 5 | −150 | −10 | |
| concrete_rough_hard | 60 | 0.0345 | 0.9138 | 0.6 | 2.0 / 10 | −100 | −10 | |
| concrete_rough_soft | 60 | −0.0690 | 1.0690 | 0.6 | 2.0 / 10 | −100 | −10 | curve can leave [0,1] slightly |
| concrete_smooth_hard/soft | 65 | −0.0517 | 0.7069 | 1.0 | 2.0 / 5 | −150 | −10 | |
| concrete_aggregate_hard | 65 | 0.5345 | 0.4828 | 0.3 | 1.5 / 5 | −100 | −10 | B shift/slope −50; rise step 0.1 |
| concrete_aggregate_soft | 65 | 0.2586 | 0.6379 | 0.3 | 1.5 / 5 | −100 | −10 | B shift/slope −50 |
| wood_ramp_hard | 65 | 0.3621 | 0.7069 | 0.8 | 2.0 / 5 | −150 | −25 | |
| wood_ramp_soft | 65 | 0.6207 | 0.7069 | 0.8 | 2.0 / 5 | −150 | −25 | |
| metal_smooth_hard | 55 | 0.0690 | 1.0517 | 0.45 | 2.0 / 10 | −150 | −20 | special gain 0.6, special shift 100 Hz, push shift low −50 |
P0 = 0 and P3 = 1 everywhere. Defaults used by all (unless noted above): **GrainParams** (`D18D1174735E5CDE`, an array
of two 5-float elements, only in `default`): player A = attack 0.1 s, sustain 0.2 s, release 0.1 s, search window 1.6 s,
drift threshold 0.05; player B = 0.2, 0.1, 0.2, 1.5, 0.05. Turn-intensity rise/fall steps 0.06 per frame; special gain
0.65 / special shift +150 Hz; B shift per slope +50 Hz; push envelope: speed ramp 45 km/h, speed-scale peak 1.4 (slow)
→ 1.1 (fast), shift peak −52 Hz → −20 Hz, timing 35/200/600 ms (scale) and 30/200/600 ms (shift); slope divisors
−10 (down) / +10 (up). Field hashes are listed in `grain_survey.py`.
Owner/rocket tuning (class `6E878344774A7999`, `default`) [V]: rocket start 35 km/h, top 60 km/h, gain word 22000,
rocket GrainParams 0.1, 0.4, 0.1, 2.4, 0.1; graph-3 clip 0.09, shelf 5000 Hz / 0.65; graph-3 send ramp 46 → 70 km/h up
to level 3.0; graph-1 level ramp 52 → 74 km/h down to a floor of 0.45; gain wobble ramp 42 → 70 km/h, wobble 0 =
15–30 ms segments of magnitude 0–0.30, wobble 1 = 5–15 ms of 0.10–0.25.
---
## 2. The grain player
### 2.1 Objects and counts
- Per board owner: 2 trucks × 2 **GrainPlayers** (A at owner+1176+8t, B at owner+1180+8t), both of a truck bound to
the **same** grain file; A and B differ in GrainParams and in their per-frame record. Plus one rocket GrainPlayer in
SenseOfSpeed (local player only). [T]
- GrainPlayer (372-byte object; layout confirmed against the retail capture [C]): the record {gain, pitch, —,
position}; the 5 GrainParams; a hold flag; send target; bound flag; grain data/duration/stream/seek pointers; **two
voice slots** {graph, state timer, start seconds, position at pick, state 1 attack / 2 sustain / 3 release}; the
active-slot index; a scheduler instance; a 16-entry **recent-windows list** {start, end, next} plus used-head,
last-inserted and free-head indices. Constructor defaults: gain 1, pitch 1, position 0, params 0.01 / 0.5 / 0.01 /
4.0 / 0.05 (always overwritten on bind).
- **Voices:** at most 2 per player (one fading out while the next fades in). Typical: one sounding truck → 2 players →
≤ 4 grain voices; both trucks → ≤ 8; + ≤ 2 rocket voices.
### 2.2 Per-frame record (owner update `sub_824C6BD8`, 60 Hz, after the frame's MixMap evaluation) [T; offset C]
Inputs per truck: ground speed v (m/s, audio state +208), this truck's surface tuning, MixMap outputs (§2.9), the
owner's slewed values (§2.7). Single-precision arithmetic throughout.
1. Effective speed v' = v × s, where s is the **push speed-scale envelope** value while it runs (else v' = v) (§2.7).
2. t = clamp(v' × 3.6 / max_kmh, 0, 1).
3. **Position A** = cubic Bézier in t with control points P0..P3:
pos = (1−t)³·P0 + 3t(1−t)²·P1 + 3t²(1−t)·P2 + t³·P3. Not clamped (two members can leave [0,1] slightly).
4. **Position B** = max(pos − 0.1, 0) — B always reads 0.1 (≈2 s of recording) behind A. Measured on retail: exactly
−0.100 at every speed [C].
5. **Pitch (A and B)** = MixMap pitch output 3 (cents → ×4096) / 4096. Retail medians: 3209 at rest, ~3500 at 1 m/s,
~4080 from ~5 m/s up [C capture medians] — i.e. grains play ~22 % slow at a crawl, ~unity when rolling.
6. **Gain A** = level(1)/32767 × (1 − max(I, Bk)) [× special_gain while "special"] [× seam envelope while active],
where I = slewed turn intensity (+1164), Bk = slewed brake (+1168) (§2.7).
7. **Gain B** = I × level(2)/32767 [× seam envelope]; plus, while the downhill slope level D (+1508) > 0:
Gain B += D × ramp × slope_gain(primary truck) × Gain A, clamped to ≤ 1, with ramp = clamp(v' × 3.6 /
slope_ramp_kmh, 0, 1) (or 1 if that km/h ≤ 0). So **B is the turning (and downhill) layer**: retail B is silent
≥ 40 km/h in straight rolling and peaks around 15 km/h; turn input +204 is 0 in 87 % of retail frames [C].
8. The four floats are stored into the player; the audio thread reads them each block (§2.4). No other smoothing
than the MixMap's own envelopes and the per-frame slews.
The record of a truck is written only while that truck's grains are running (`+1328+t` set and its surface plays grains).
### 2.3 Choosing a grain (pick, `sub_828ECAB0`) [T; G]
Let L = attack + sustain + release (grain length, seconds of source at pitch 1), W = search window, D = stored
duration, pos = record position.
1. Target T = (D − W) × pos; region = [T, T + W]. (So the region never runs off the end of the file for pos ≤ 1.)
2. The recent list holds the windows played since the last reset, sorted by start. Its free gaps — from a sentinel
before 0 to the first window, between consecutive windows, and from the last window to D — are each clamped to
the region and **tiled with back-to-back windows of length L** starting at the gap's (clamped) start, while a whole
window still fits (in single precision; the 4th 0.4-s window of a 1.6-s region does *not* fit because 1.2+0.4
rounds above 1.6) — at most 64 candidates.
3. If there is no candidate, or the list is full (the last inserted entry is index 15): **collapse** the list to just
the most recent window and retry; if the region holds at most one window (⌊W/L⌋ ≤ 1) return T instead.
4. Otherwise draw r from the **title-wide generator** (`sub_82A8AF10`, 6 words at `0x82FD7D74`, seeded at game init
with the time base; shared by ~100 call sites) and take candidate ⌊r × 2⁻³¹ × 0.5 × count⌋ (uniform). Insert it into
the sorted list; return its start.
Behaviour this produces [G, `grain_vectors.tsv`]: a **shuffle bag without replacement** over the 3–4 windows of the
current region (A: 0.4-s windows in a 1.6-s region → 3–4; B: 0.5 in 1.5 → 3; rocket 0.6 in 2.4 → 3–4); when the bag is
empty it refills but never repeats the window just played. The grid is anchored to windows already played, so small
position changes keep reusing aligned windows. A bind resets the list (sentinel only), so the first pick after a bind is
a window starting exactly at T.
Edge (port note): r within 128 of 2³² rounds to 2³² in single precision, making the index = count and reading an
unfilled candidate {−1, −1}; the start is then clamped to 0 (§2.6). Probability ~3·10⁻⁸ per pick; reproduce or clamp.
Pick sequences are **not reproducible** against retail (global RNG); only the distribution and the bag rule are. For
exact validation log the 6 RNG words at each pick (§5.3).
### 2.4 The per-block scheduler (`sub_828EC6F0`, via stub `sub_828ECE98`) [R for the drift test and timers; T rest]
Registered on bind as a plug-in in scheduler bucket 0 of the audio system ("Grain Player"); the block driver
`sub_82B48530` runs bucket 0 in **phase 1 of every 256-frame block** (48 kHz → every 5.333 ms), before the command
drain and the graph pass, with δ = 256/48000 as f32 (`0x3BAEC33E`) [C: attack timer 0.1 → 0x3DAC0831 → 0x3D962FC9 …].
So this is **per audio block, not per 32-ms evaluator tick and not per game frame.** Each block:
1. **Drift test.** d = (position recorded at the active slot's pick) − (current record position). If |d| > drift
threshold (0.05 ≈ 1 s of recording) and hold is clear and the active slot has a voice:
- if the other slot has a voice: release it (unless already releasing) — nothing new starts this block;
- else: release the active slot (unless releasing), pick (§2.3), start a voice in the other slot (§2.5) and make it
active. (A fast speed change therefore cuts the current grain short with a normal release.)
2. For each slot 0, 1 that has a voice:
- post the record **gain** to the voice's Send (Sen0 parameter 0) and the record **pitch** to its Resample
(parameter 0) — every block, applied this block (phase 1 precedes the drain);
- timer −= δ in attack/release; **timer −= δ × pitch in sustain** (fused multiply-subtract);
- if timer < 0:
- attack → sustain, timer = sustain;
- sustain → release (timer = release, fade to 0); then, unless hold: stop the other slot's voice if any, pick,
start a new voice in the other slot, make it active;
- release → stop the voice.
3. Ordering detail (bit-exactness): a voice started in slot 1 while processing slot 0 is processed (gain/pitch posted,
timer decremented) in the same block; one started in slot 0 from slot 1 waits for the next block [T]. A voice started
by the drift branch (step 1) is processed in the same block.
The hold flag (+36) is cleared on bind and never set by any caller we know of (UNCERTAIN; treat as always clear).
**Resulting rhythm at pitch p** (blocks quantise each phase up to the next 5.33 ms):
| player | grain period (start to start) | overlap (release ∥ next attack) | sounding per grain | source used per grain |
|---|---|---|---|---|
| A | 0.1 + 0.2/p s (0.3 s at p = 1) | 0.1 s | 0.4 s | 0.2·p + 0.2 s (= L at p = 1) |
| B | 0.2 + 0.1/p s (0.3 s) | 0.2 s | 0.5 s | 0.4·p + 0.1 s |
| rocket | 0.1 + 0.4/p s (0.5 s) | 0.1 s | 0.6 s | 0.2·p + 0.4 s |
Retail evidence [V, our trace from recomp session `20261001_211347`, `grain_trace_stats.py`]: 8,484 grain voice
starts in 10.5 min. Rocket starts cluster at **475–525 ms** apart (163 of 557), surface-grain starts of one member
never exceed ~350 ms apart while rolling (period 0.3 s + quantisation + pitch < 1), the modal rate is **6 starts/s**
(= 2 players × 1/0.3 s, one sounding truck), with bursts up to 42/s (drift restarts, both trucks, other skaters). This
matches the timing above.
### 2.5 Starting and stopping a voice [T; fade curve T]
**Start (`sub_828EC3F0`)**: build a mono voice graph `SndPlayer1 → Resample → GainFader → Send` (graph order 0),
play the grain's EAAC stream from start = clamp(pick, 0, D) seconds (§2.6), set the fader to 0 immediately, then
**fade in to 1 over `attack` seconds**, point the Send at the player's bus (graph 1 of its chain, §3.1) and stamp the
current record gain on it; remember the start and the record position at this pick.
**Fades (GainFader, `aems-voice-graph-spec.md` §4.7)**: requested with start time "now", the curve code is 1 →
**square-root curve**: fade-in g(n) = √(n/N), fade-out g(n) = √(1 − n/N), N = ⌊seconds × 48000⌋ samples (≥ 1),
sample-accurate inside the block. The overlapping release and attack of consecutive grains therefore form an
**equal-power crossfade** (g_in² + g_out² = 1).
**Release (`sub_828EC2F8`)**: fade to 0 over `release` seconds. **Stop (`sub_828EBCB0`)**: deferred "player stop"
command → the graph is torn down at the next drain (Send release de-click, §4.8 of the voice-graph spec); used after a
finished release (voice already at 0), and abruptly in the cases below.
**Bind (`sub_828EC040`, from surface routing)**: reset the recent list, register the scheduler plug-in, take H,
duration, seek table and stream from the file, copy the GrainParams element (A = 0, B = 1) into the player, **pick and
start a voice in slot 0 at once** (game thread). In retail the truck's bus chains are also torn down and rebuilt on
every bind (`sub_824C4D50` + `sub_824C8878`), so filter, frequency-shifter and pan histories restart at a surface change
(PR #4 keeps its chains instead; a port should reset those states).
**Stop the player (`sub_828EBF90`)**: when bound, stop both voices **without a release fade** and detach the plug-in.
Used on a surface change and when the truck leaves contact (surface 14). The rocket stops its player twice when it
drops to ≤ 35 km/h (second call is a no-op).
### 2.6 Reading the recording [T; V for single-block streams]
The play command converts the start time into a frame at the stream's native rate: frame = ⌊rate × start⌋ (only when
> 0; else 0). With the single-entry seek table the decoder restarts at sample 0, decodes and discards up to 384 pre-roll
samples and skips the rest, so output begins exactly at `frame`. Port: PCM decoded once at native rate; start reading at
`frame`; play forward (no loop flag; a grain never reaches the end because T + W ≤ D at pos ≤ 1 and a grain lasts ≈ L,
but a pitched or drifting grain may run past its window — let it). Resample converts to 48 kHz with ratio = native rate
/ 48000 × pitch, linear interpolation, no anti-alias filter (`aems-voice-graph-spec.md` §4.3).
### 2.7 Owner-side modulators [T unless noted]
Run in the owner's process (`sub_824C6A78`, before the MixMap evaluation) except where noted.
- **Push envelopes** (on every push plant, state +335; `sub_824C6198`): speed-scale envelope from its current value
(or 1.0) to peak = lerp(1.4, 1.1, t) over 35 ms, hold 200 ms, back to 1.0 over 600 ms; frequency-shift envelope 0 →
lerp(−52, −20, t) Hz over 30 ms, hold 200, → 0 over 600; t = clamp((v − 1) × 3.6 / 45, 0, 1); tuning from the
primary truck's collection; linear segments advanced by the frame dt while not idle. Effect: each push moves the
read position forward (up to 40 % more speed at a standstill) and detunes the bed by tens of Hz.
- **Turn intensity** I (`sub_824C8588`, owner +1160/+1164): raw = clamp(|COM velocity| (state +96) × 0.24, 0, cap) ×
turn (state +204), clamped to ±cap; forced 0 while the manual latch or the trick latch holds; slewed toward raw by at
most the rise/fall step (0.06, aggregate_hard rise 0.1) per frame; I = |slewed|. Cap and steps from the primary
truck's collection.
- **Brake** Bk (+1168): while braking (state +336) against the direction of travel (cosine of velocity delta and
velocity < 0, or undefined) slew toward 1, else toward 0, by 0.05 per frame.
- **Slope** (`sub_824CA738`): with the primary truck on a grain surface, downhill level D = clamp(slope (state +712;
PR #4 docs call it the slope, the PoC field is named `pump_absorption_712` — meaning UNCERTAIN) /
−10, 0, 1), uphill U = clamp(slope / 10, 0, 1); posted as owner inputs 2/3 (×32767).
- **Manual latch** (+1504): set while balancing (state +340); once set, cleared when wheel count (state +200) is 0 or 4.
**Trick latch** (+1505): set while state +372 (an airborne-trick flag); cleared when both feet are back in the deck
box (+615 and +616). "Special" = manual latch, else trick latch.
- **Seam-pattern gain envelope** (`sub_824CA448`, local player): when wheel 0's seam pattern (state +636) changes to a
pattern whose vault wobble (class `7242F32831ED3332`, patterns spidercrack … special_2) is not {1, 1}, **one** linear
segment from 1 to a random gain in [low, high) (steps of 0.01) over a random duration in [ms_low, ms_high) ms (title
RNG, `sub_824CA318`), then hold; it multiplies **both** gains A and B while active. **Correction 2026-10-02** (read in
the asm): not a random walk — the same pattern only advances the segment. In the vault only spidercrack has a wobble
≠ {1, 1} (0.8 → 0.6, 30 → 80 ms).
### 2.8 The rocket layer (`x_jet_rolling.grain`, SenseOfSpeed `sub_824E7980` / `sub_824E7CB0`) [T; timing V]
Local player only. When ground speed > 35 km/h and not running: bind to the **default output bus directly** (no chain),
rocket GrainParams (0.1, 0.4, 0.1, 2.4, 0.1), start. At ≤ 35 km/h: stop. While running, record: gain =
level(5)/32767 × 22000/32767 (SenseOfSpeed controller), pitch = pitch(3)/4096 (same controller), position =
clamp((v × 3.6 − 35) / (60 − 35), 0, 1). No hysteresis.
### 2.9 MixMap outputs consumed (for `mixmap-spec.md`) [T]
Board owner controller (owner vfuncs 52/56/60/64 = raw u16 / pitch cents→×4096 / level & 0x7FFF / filter & 0x7FFF):
| output | used as |
|---|---|
| level(1) | gain A scale (retail active-rolling p90 7859 ≈ 0.24) |
| level(2) | gain B scale |
| pitch(3) | grain pitch (both players) |
| level(11), level(12) via vfunc64 | LowPass / HighPass Hz on both chains (retail modal 24971 / 77) |
| raw(0) via vfunc52 | azimuth: Pan2D1 degrees = raw × 360/65535 |
| level(13) | environment send /32767 |
| level(21), level(22) | graph-2 first send A / B /32767 (local player) |
SenseOfSpeed controller: level(5), pitch(3) (rocket). Owner MixMap *inputs* written by this code: 0 surface-change
pulse, 2/3 slope, 4 push foot planted, 5 heading rate, 6 on metal (and 1 skid). Which inputs drive level(1)/(2) (e.g.
wheel-contact inputs that silence the bed in the air) is for the MixMap spec. Chain values (§3.2) are computed in
process (before this frame's evaluation, i.e. one frame old); records in update (this frame's).
### 2.10 Airborne, grinds, manuals, bails
- **Grind**: surface 14 → both players of the truck stop (abrupt); Class_rolling layer surface becomes 13 for the held
layers.
- **Manual**: the lifted truck goes to 14 (its sound stops or is handed to the other truck); manual/trick latch also
zeroes I (B silent) and scales A by the special gain with a +150 Hz shift.
- **Airborne**: nothing in the grain code stops the players on take-off; voices keep cycling and their level must come
from MixMap level(1)/(2) (contact inputs). Whether the wheel material holds its last value in the air or becomes
"none" (→ surface 3, which would rebind asphalt_smooth on every jump) is **UNCERTAIN** — first thing to measure
(§5.3, hook on routing). The trick latch only affects I/special.
- **Bail / off board**: the board owner keeps running; the result depends on contact/material (UNCERTAIN).
---
## 3. Voice graph, buses and interplay
### 3.1 Per-player bus chain (`sub_824C8878`; one per player, 4 per board) [T; shape in voice-graph spec §6.2]
```
grain voices ──► graph 1 (order 2, mono): SubMix → HighPass → LowPass → FrequencyShiftSsb → Send(→ graph 3)
→ Gain (wobble) → Send(→ graph 2)
graph 3 (order 3, local player only, mono): SubMix → Clip ±0.09 → Gain (wobble) → HighShelf 5 kHz × 0.65 → Send(→ graph 2)
graph 2 (order 5): SubMix → Send(→ manager+116 target, level 0) → Send(→ environment bus, level(13))
→ Pan2D1 (1 → 6 ch) → Send(→ eEQChain bus 8 = the default bus)
```
- **FrequencyShiftSsb** (`sub_82B22898`): two cascades of two allpass biquads form an I/Q (Hilbert) pair; out = I·cos φ
− Q·sin φ, φ advancing 2π·shift/rate per sample, wrapped per block. At 0 Hz it is a pure allpass (phase-shifted copy),
never a bypass. B's base shift (−10 Hz; wood −25, metal −20) decorrelates B from A (same file, 0.1 behind, shifted).
- The four chains run in parallel; graph 3 adds a gently clipped, top-shelved copy at high speed (§3.2).
### 3.2 Values pushed each frame (`sub_824C9058`, per truck whose grains run; plus helpers) [T]
| target | value |
|---|---|
| HighPass, LowPass (A and B) | level(12), level(11) Hz |
| FSS A | (special ? special_shift : 0) + push shift envelope (while running) + D × A_shift_per_slope(primary) |
| FSS B | B_base_shift + push shift envelope + D × B_shift_per_slope(primary) |
| Pan2D1 (A, B) | raw(0) × 360/65535 degrees |
| env send (A, B) | level(13)/32767 |
| graph-2 first send (local) | level(21)/32767 (A), level(22)/32767 (B) |
| graph-1 → graph-3 send (`sub_824CAEC0`, local, all 4 players, on change) | 0 below 46 km/h, linear to 3.0 at 70 km/h |
| graph-1 Gain, graph-3 Gain (`sub_824CB078`/`sub_824CB180`, local) | wobble: gain = 1 + e × ramp, e = a random-walk envelope alternating sign (segments 15–30 ms, |e| ≤ 0.30 for A's chains; 5–15 ms, 0.10–0.25 for B's), ramp = clamp((km/h − 42)/(70 − 42), 0, 1); graph-1 also × level ramp (1 at ≤ 52 km/h → 0.45 at ≥ 74 km/h) |
Quirks to keep [T]: the graph-1 level ramp has no branch below 52 km/h, so it **keeps its last value** when speed
drops (it only returns towards 1 while between 52 and 74 km/h); the graph-1 → graph-3 send is posted only when it
changes. The "last posted" wobble value is shared between the two trucks, so after truck 0 posts, truck 1
always compares equal and its wobble is never posted (retail behaviour).
### 3.3 Interplay with the other board sounds [T]
- **Class_rolling** (`PatchBank_Rolling_*`): per-surface patch for the non-grain surfaces (7, 8, 10, 12, 13) instead of
grains; held layers 0 and 3 always posted for the local player (sparse random texture, not the bed); layer 5 while
wheel 0 is on the spidercrack seam pattern. Same speed word shape (clamp(v·3.6/maxKmh) × 10000, push-scaled).
- **Rolling_Rattle_Class**: posted on each push plant while the primary truck is on a grain surface (surface code 1..5
from the grain key), released on the next.
- **Class_Seams**: per-wheel seam clicks (separate component); the seam-pattern *gain envelope* above modulates the
grain bed itself.
- **SenseOfSpeed** rattle (≥ 30 km/h) and wind (15–55 km/h) patches + the rocket grain (> 35 km/h).
- **Wheel spins** (`SFXObj_Wheels`, `wheels.big` `.snr` + `.sek`): a separate SndPlayer1 path using the same seek
mechanism (§1.3), on take-off / manual; spin-down gain × (1 − speed/max).
- Skid, squeaks, board slide: bank patches, independent.
- Levels [C, PR #4 native 6-ch]: rolling bed −22.9 dBFS; takeoff +17.0 dB and landing +23.3 dB over the bed.
Retail grain transfer (truck 0, both players, by km/h): gain A ≈ 0.06 at 5, 0.08–0.17 from 10 up; position A 0.12
(10 km/h) → 0.63 (40 km/h); B peaks at 15 km/h (+2.7 dB over A) and is silent ≥ 40 km/h. Compare at matched surface
and speed with narrow bins and medians, never session averages (three false findings came from that).
---
## 4. Native port outline (behaviour, not code)
1. Setup: decode the 14 members to PCM at native rate (we already decode them; keep whole files, drop the 6 bands);
validate header/seek table; load the vault tuning (§1.5) and AudioSurfaceMap.
2. Game thread, 60 Hz: surface routing (§1.4), modulators (§2.7), records (§2.2), chain values (§3.2), rocket (§2.8).
3. Audio thread, per 256-frame block at 48 kHz: for each bound player run §2.4 before mixing; voices = PCM reader +
linear resampler + sqrt fader + send gain (64-sample de-click on change); chains per §3.1. The RNG is our own
instance of the add-with-carry generator, seeded at startup (retail is unreproducible anyway).
4. Keep it moddable (grain files, GrainParams, curves as data), per the port plan's goals.
---
## 5. Validation plan
### 5.1 Golden vectors from the PoC (done: first set) [G]
`crates/skate-audio-core/examples/grain_vectors.rs` in the local PoC worktree (local only, never committed) drives PR #4's ported functions; output
`grain_vectors.tsv`:
- `POS`: speed 0..80 km/h → position A/B (bit patterns) for concrete_rough_hard and default (e.g. concrete_rough_hard
30 km/h → A `3EF611A8` = 0.4806, B 0.3806; saturates at max km/h);
- `PICK`: 24 successive picks for A and B params at pos 0 / 0.25 / 0.63 / 1.0, generator from zero and from the image
state (shows the bag rule: e.g. A, pos 0.63 → windows 12.7524/13.1524/13.5524/13.9524 s);
- `CAND`: candidate tiling (3 windows in [0,1.6] with L 0.4; 4 in [19,20.6]).
**Next vectors** (needs the full runtime): copy PR #4's `crates/skate-data/examples/grain_repro.rs` (in
PR #4's branch; not in the PoC worktree) into the PoC locally and log per block: slot states, timers, starts,
picked positions, posted gain/pitch, the RNG words before each pick, and the 6-ch output RMS — for (a) constant
speeds 2/4/6/8 m/s per surface, (b) a speed ramp crossing the drift threshold, (c) a surface change (stop + bind), (d)
pitch 0.78 vs 1.0. Our port must match timers, state transitions, start seconds and posted values exactly (given the
same RNG words), and the audio to ≤ 1e-5 FS per sample before the chain (decode differences aside).
Also unit vectors for: modulators (push envelopes, turn slew, brake slew, slope, latches), chain values, wobble and
send ramps — all pure functions in the PoC's `grain::board` / `grain::envelope` and `player_audio/components/board.rs`.
### 5.2 Retail evidence already in hand [V]
`grain_trace_stats.py <trace.tsv>` matches PLAY lines (SndPlayer1 play hook) whose sample header equals a grain
stream: per-member counts, starts/s and start-interval histograms (§2.4). Results for session 20261001_211347 in
`trace_stats_20261001_211347.json`. The PLAY hook does not log the start offset.
### 5.3 Passive recomp hooks to add (wrap and call through, like `src/skate3_audio_trace_hooks.cpp`)
| tag | function | log |
|---|---|---|
| GBIND | `sub_828EC040` bind (r3 player, r4 bus, r5 data) | player, data, duration word ([data+4]) → member, params after return (+16..+32) |
| GPICK | `sub_828ECAB0` pick (r3 player; f1 result) | before: position +12, the 6 RNG words at `0x82FD7D74`, recent head/last/free (+364/+366/+368); after: f1 |
| GSTART | `sub_828EC3F0` start voice (r3 player, r4 slot, f1 start) | slot index ((r4 − r3 − 88)/28), start, gain, pitch |
| GTICK | `sub_828EC6F0` per block (r3 player, f1 δ) | on any state/active change only: δ bits, +0 gain, +4 pitch, +12 position, +144 active, per slot +92 graph, +96 timer, +100 start, +104 pick position, +108 state |
| GSTOP | `sub_828EBF90` stop player | player, caller chain |
| GROUTE | after `sub_824C5CA8` (r3 owner) | owner +768/+772 surfaces, +1320/+1324, +1328/+1329, +1496/+1497, +1500, + audio state +620/+632 materials, +332 air, +341 grind, +684 soft |
| GREC | after `sub_824C6BD8` (r3 owner) | the 4 players' records, owner +1028/+1032, +1152/+1156, +1164, +1168, +1456/+1464, +1508, state +204, +208 |
| GCHAIN | after `sub_824C9058` | values posted per truck (read graph-1 FSS +52, HP/LP +52, Pan +52) |
| — | `sub_824E7980`/`sub_824E7CB0` | rocket running flag, record |
Use short scripted background recomp runs (never ask the user to replay): on one known surface,
push to ~10, 20, 30, 45 km/h and coast 5 s each; a straight roll vs a carve; a manual; an ollie and a 1-s air; a grind;
a crossing between two materials. Acceptance: start intervals, state timers and pick regions exact given logged RNG
words; record gain/pitch/position per frame within 1 ulp of our model fed with the logged inputs; per-player pick
distribution χ² consistent with uniform; airborne behaviour settled (§2.10).
---
## 6. Open questions (priority order)
1. Airborne: does the wheel material hold, or become none → surface 3 (rebinding asphalt_smooth each jump)? Does
level(1)/(2) go to 0 in the air, and how fast? (GROUTE/GREC + MixMap spec.)
2. Surface 0 (tag 90): what retail does (no member, not Class_rolling).
3. ~~Soft flag source (Motion+200 < 0.5)~~ **Settled 2026-10-02:** Motion+200 = Processed+2764 = the board
profile's wheel hardness (`animation_phase_packet.rs` `wheel_hardness`, default 0.7 = hard).
4. Hold flag (+36): any writer?
5. GainFader curve code from the fade request (+28 rounded = 1 → square root) — confirm with a GTICK/fader dump.
6. Exact block of the first tick after a game-thread bind vs a scheduler start (the 3-block capture fits both).
7. Chain rebuild on bind: confirm the teardown/rebuild order and that histories reset.
8. PR #4 notes that its capture's "GP ret" value was the window end, not the start — re-check with GPICK.
---
## 7. Sources & credits
| source | author / owner | link | licence | contribution |
|---|---|---|---|---|
| Upstream PR #4 (`skate-audio-core/src/grain/*`, `skate-audio-formats/src/grain.rs`, `skate-data/src/audio/grains.rs`, `skate-game/.../board.rs`, `speed.rs`, `examples/grain_repro.rs`, docs `player-audio-retail-drivers.md` §3/§11, `wheel-audio-handoff-2026-09-17.md`, `engine-defects.md`) | andrewnakas (Andrew Nakas) | github.com/SK8-ENGINE/skate-3-rust-engine/pull/4 (fork github.com/andrewnakas/skate-3-rust-engine, commit 368a433; read from a local clone and a PoC worktree) | none — read only | Discovered that the bed is a granular player; recovered the GrainPlayer object, pick, scheduler, voice graph, bus chains, FSS module, board records, vault mapping, push envelopes, retail capture comparisons and transfer functions. Almost everything tagged [T]/[C] here is their finding, re-described. |
| Upstream PR #1 (`mx/audio-engine-vehicles`) | andrewnakas | github.com/SK8-ENGINE/skate-3-rust-engine/pull/1 | none — read only | Earlier version of the same grain port and docs. |
| skate3recomp (generated C++, audio-trace hooks) | mchughalex (recomp); trace hooks ours (local branch `audio-trace`) | github.com/mchughalex/skate3recomp | none stated — read only | Lifted code we read for §2.4 [R]; the retail trace used in §2.4/§5.2. Built on rexglue-sdk. |
| Skate 3 (EA Black Box), TU3 image, disc `grains.big`, vault `skatercollections.vlt` | Electronic Arts | — | proprietary, owner's copy, never committed | The data measured in §1. |
| vgmstream (`data/tools/vgmstream-cli`) | vgmstream contributors | github.com/vgmstream/vgmstream | ISC-style | EA-XMA decode for the RMS/centroid survey. |
| Our notes `aems-voice-graph-spec.md`, `aems-evaluator-spec.md`, `aems-reference.md`, `rwaudio-prior-work.md` | this project | — | — | Module semantics (Resample, GainFader, Send, buses), block clock, RNG. |
| CC0 prior art (nfsmw Snd9, tw2004) | dbalatoni13, mitsevox | see `rwaudio-prior-work.md` | CC0 | Checked: no GrainPlayer there; nothing used. |
## 8. Files
- Survey script: `tools/audio-file-inspect/grain_survey.py` (`--rms` adds level/centroid profiles).
- Trace stats: `tools/recomp-trace/grain_trace_stats.py`.
- Golden-vector example: `grain_vectors.rs` (local, in the PoC worktree).
- Data (local): `grain_survey.json`/`.txt`, `grain_vectors.tsv`, `trace_stats_*.json` (≈0.1 MB).
@@ -0,0 +1,610 @@
# MixMap (`MixMapSK8.mxb`) — behavioural specification for a native Rust port
Written 2026-10-02 in our own words (no code transliterated from PR #4/#1, the recomp or b5-decomp; constants,
offsets, addresses and EA's record names are facts). Companion notes: `aems-reference.md`,
`aems-voice-graph-spec.md`, `ems-emitters-re.md`, `rwaudio-prior-work.md`.
**Status: the evaluation model below is proven against PR #4's port.** Our own reference evaluator
(`mxb_tool.py`, a local tool written from this spec; not published because it carries a name table
taken from the game) gives the same owner reads as
PR #4's MixMap port (run through the PoC) on 2 scripted scenarios: 900 evaluations × 41 watched outputs each,
73,800 cells, **0 mismatches**. The scripts cover fades, envelopes, pause, menu, reverb and HOM ducks, distance
and angle lookups, Doppler, a disabled controller, and dt changes. PR #4 in turn reports 99.9991 % word-exact
agreement with a retail capture (84 M output words). So: spec ≡ PR #4 ≈ retail. What is *not* proven here is
the game side: which game values feed which inputs (§7) and what reads each output (§6).
Terminology: EA's own record names come from the NFS ProStreet PDB, as reproduced in b5-decomp's
`NFSMix/NFSMixRecords.hpp` (same lineage: 564-byte host, 96-byte state instance, same field order).
Our letters: **A** = MixCtl (input product), **B** = 3DMixCtl (distance/angle), **F** = EvtMixCtl (envelope),
**C** = SubMixCh (clamped sum), **E** = MasterMixCh (output sum), **G** = Preset (output conversion list).
"Slot" = EA "state" = one SFX slot of the game (14). All file data is big-endian.
---------------------------------------------------------------------------------------------------------
## 1. Where it sits in the frame
- **Load** once at audio-system init (`sub_82484FE8` → manager init `sub_8294B918`, file load `sub_8298ED88`,
build `sub_8294BA18`). The host object is 564 bytes (vtable `0x82316B78`). The build needs, per slot, the
number of instances the game created (§4.1).
- **Per game frame**, in `sub_82485190`:
1. Inputs are written. The audio-state bridge `sub_824B0DA8` ends in `sub_824B19C8`, which writes the
PlayerPhysics inputs. Then every SFX object's process slot (+36) writes its own controller's inputs.
2. The MixMap tick runs (`sub_8294BAE8` → host slot 2 `sub_8294F5E8(host, dt)`).
3. Every SFX object's update slot (+40) reads **this** tick's outputs and posts or updates its AEMS
packets and voice graphs.
- An input written during step 3 (e.g. SkateBoard input 4 in `sub_824C6198`) is seen one tick later.
- **dt / rate** (PR #4's reading of `sub_82485190`, threshold `0x822F8DE8` = 0.02 s):
- If the call's dt ≤ 0.02 and its r5 flag is 0, the two halves (1 and 2+3) alternate between calls,
each using the sum of the last two dts.
- Otherwise both halves run on every call.
- In the recomp capture, calls came every ≈7.4 ms, so the MixMap ran every ≈14.7 ms.
- **Port rule:** run inputs → tick → reads once per 60 Hz frame with dt = 1/60. That is what retail does
for any dt > 0.02.
- Only envelopes use dt. The Doppler slew is per evaluation, so its speed depends on the frame rate (§5.4).
- **Mode word** (EA `m_CurCamState`, host +128; previous +124): taken from the manager's +4 at every tick. It
selects the B "camera state" variant. In retail it stays −1, so variant 0 is always used. Every B record
in this file has exactly one variant, so the mode has **no effect** for `MixMapSK8.mxb`.
---------------------------------------------------------------------------------------------------------
## 2. File format
### 2.1 Header (EA `stMixMapHeader`, 16 bytes)
| off | type | value in SK8 | meaning |
|---|---|---|---|
| 0x00 | u32 | 0 | MixMapID. Also mixed into the build's sharing key (no visible effect). |
| 0x04 | u32 | 14 | NumStates = number of slots |
| 0x08 | u32 | 0x10 | offset of the slot table |
| 0x0C | i32 | −1 | DynamicMapOffset (unused) |
| 0x10 | i32[14] | 0x44, 0x2A48, 0x42D8, 0x4418, 0x461C, 0x4B1C, 0x534C, 0x54E0, 0x5640, 0x5850, 0x5BA8, 0x5CE4, 0x5D04, 0x5E10 | section offset per slot (from file start; −1 = none) |
Quirk: slot 0's section starts at 0x44, so its first word overlaps slot 13's table entry. That word
(StateIndex) is never read; in the other sections it is `0x001F0000 | slot`.
### 2.2 Section header (EA `stMixMapStateHdr`, 32 bytes at section offset S)
`+0` StateIndex (unread). Then offsets from S, each −1 when absent:
`+4` A, `+8` B, `+12` C, `+16` E, `+20` G, `+24` F, `+28` reserved (−1).
### 2.3 Tables. Each starts with a 16-byte header; records follow at +16.
**A — MixCtl (input products).** Header `{count, count again (NumNewMixDataProcs), 0, 0}`.
Record = (2 + n) words:
- `w0` = input key (§3). Bits 24–27 are the curve kind applied to the input.
- `w1` = bits 16–20: n, the number of scale references; low 16 bits: the **swing** (depth).
- then n reference keys.
**B — 3DMixCtl (distance/azimuth lookups).** Header `{count (low byte), 0, 0, 0}`. Record = 4 + 24·v bytes:
- `w0` = controller selector: bits 24–27 = v (variants, always 1 here); slot/object bits name the SFXCTL
controller whose input block is read (§3).
- v × 24-byte variants (EA `st3DStateParams`):
- `+0`: bits 24–27 camera-state id; bits 16–23 slot; bits 12–15 distance select (0 → input 1,
1 → input 0, ≥ 2 → −1.0); bits 8–11 azimuth select (0 → input 3, 1 → input 2, ≥ 2 → 0);
low byte = record index.
- `+4`: curve kind per quadrant: q0 bits 28–31, q2 bits 24–27, q3 bits 20–23, q1 bits 16–19. The low 16
bits are the Doppler speed constant c (0 = no Doppler).
- `+8, +12, +16, +20`: one range per direction d0..d3. Bits 0–14 = min distance; bits 16–30 = max
distance (integers, metres).
**C — SubMixCh (clamped sums).** Header `{count, …}`. Record = (2 + n) words:
- `w0` = `0xD0` top byte (type C), bits 16–23 = n, bits 8–15 = slot, low byte = index.
- `w1` = bits 16–30: upper clamp; low 16 bits (signed): lower clamp.
- then n reference keys.
**E — MasterMixCh (output sums).** Header `{count, number of distinct destination controllers
(NumUniqueSFXOBJs), …}`. Record = (3 + n) words:
- `w0` = top byte `0xC0 | type` (0 volume, 1 pitch, 2 filter); bits 16–23 = n; bits 8–15 = slot; low byte
= index.
- `w1` = high 16 bits (signed): base value in mB/cents. The low half is always 0xD8F0 (−10000) and unread.
- `w2` = destination controller key (§3). Records with the same destination are consecutive in this file.
- then n reference keys. B references ("specials") always come first.
**G — Preset (output conversion list).** One entry per E record, in order. Each entry is a head word plus
`count` output words:
- head: bits 24–27 = type (same as E), 16–23 = slot, 8–15 = E index, 0–4 = count.
- output word:
- bit 31: raw-azimuth flag;
- bits 26–30: output id (0..29);
- bits 21–25: special index (which B reference; ≥ the number of specials = none);
- low 16 bits (signed): offset added to the sum.
**F — EvtMixCtl (envelopes).** Header `{count, …}`. Record = (6 + n) words (EA `stMixEvtParams`):
- `w0`:
- bits 24–27: kind (0 = AR, 1 = AHR, 3 = ADSR; other kinds are inert);
- bits 16–23: slot;
- bit 10: retrigger;
- bit 9: "linear", where the level is used directly as gain (no record in SK8 sets it);
- bit 8: gate — hold while the trigger is on;
- low byte: index.
- `w1` = bits 16–19: n scale references; low 16 bits: the swing.
- `w2` = trigger key (§3). A non-zero value means "on".
- `w3` = attack: bits 0–11 frames, bits 12–15 curve kind.
- `w4` = decay frames in bits 16–27; low half: bits 0–11 hold frames, bits 12–15 decay curve kind.
- `w5` = sustain level in bits 16–31 (signed Q15); release frames in bits 0–11; release curve kind in
bits 12–15.
- then n reference keys.
- Times are 60 Hz frames, converted to ms with the image float at `0x822F8F04` (16.6667). Attack and release
of kinds 0/1/2, and hold of kind 1, are raised to at least 1 frame at build time.
### 2.4 Counts in `MixMapSK8.mxb` (records per slot; instances from §4.1)
| slot | name | inst | A | B | F | C | E | output ids written |
|---|---|---|---|---|---|---|---|---|
| 0 | Global | 1 | 180 | 1 | 245 | 12 | 65 | Announcer, Music, Master, CameraMan, Reverb, NIS, Bloom |
| 1 | Player | 2 | 122 | 14 | 37 | 18 | 92 | SkateBoard 0–27, Contacts 0–22, Wheels 0–7, Rail 0–6, Cracks 0–6, Tricks 0–8, Clothing 0–6, Treatments 0–6, SenseOfSpeed 0–5, OffBoard 0–16, HandGrabs 0–4, Takedown 0–5, DropIn 0–5 |
| 2 | Ambience | 1 | 3 | 0 | 0 | 1 | 4 | Ambience 0–3 |
| 3 | Collision | 10 | 2 | 2 | 1 | 1 | 5 | Collision 0–22 |
| 4 | Traffic | 4 | 12 | 14 | 0 | 4 | 19 | Engine/Skids/Horn/Woosh |
| 5 | Pedestrian | 15 | 14 | 24 | 0 | 7 | 29 | Speech/SFX/BodyFall/Tazer |
| 6 | Emitter | 5 | 0 | 1 | 0 | 2 | 8 | Emitter 0–8 |
| 7 | Crowd | 5 | 2 | 3 | 0 | 2 | 3 | |
| 8 | Dynamic | 5 | 1 | 1 | 0 | 2 | 9 | Dynamic, Moving |
| 9 | Speaker | 3 | 12 | 12 | 0 | 1 | 12 | |
| 10 | Whoosh | 3 | 2 | 2 | 0 | 1 | 2 | |
| 11 | ObjectInstance | 0 | — | — | — | — | — | (empty section) |
| 12 | NISCharacter | 5 | 0 | 1 | 0 | 2 | 5 | |
| 13 | PlayerSpeech | 7 | 13 | 14 | 3 | 2 | 9 | |
Instantiated: 547 input entries, 853 A, 635 B, 350 F, 240 C, 1044 E; 154 output blocks; **247 controllers**,
the same count as the retail capture. Envelope kinds: 281 AHR, 4 ADSR, 1 kind-4 (inert); 24 retrigger,
11 not gated. Full record dump: `catalog.txt` (and `.json`); every output with its type
and base: `outputs.txt`.
---------------------------------------------------------------------------------------------------------
## 3. Keys (32-bit identifiers everywhere)
| bits | meaning |
|---|---|
| 29–31 | type: 000 A, 001 C/E (bit 28: 1 = C, 0 = E), 010 SFXObj controller, 011 SFXCTL controller, 100 B, 101 F |
| 24–27 | input keys: curve kind (0–9). In records: other per-type fields. |
| 16–23 | slot |
| 11–15 | instance ("group"); always 0 in the file, filled in at build |
| 4–10 | controller keys: object id within the slot |
| 0–3 | controller keys: input id (0–15) |
| 0–7 | node keys (A/B/C/E/F): record index in that slot's table |
- Controller identity = key & `0xE0FFFFF0` (type + slot + group + object).
- SFXObj keys look like `0x4SSG GOO0`, SFXCTL keys `0x6…`. For example `0x40020000` = Ambience,
`0x40060000` + g·0x800 = Emitter instance g, `0x60010000` = player 0's PlayerPhysics (audio state),
`0x60010010` / `0x60010020` = the player's two 3DObjPos, `0x60060000` = emitter 3-D input.
- Object names per (slot, id) are the game's factory table (`0x8302CF60..0x8302D3A0`), listed in `mxb_tool.py`.
---------------------------------------------------------------------------------------------------------
## 4. Build (load-time)
### 4.1 Instances
- Every slot with a section is instantiated once per game object of that slot: the manager's slot-8
callback returns `[sys[164+slot]+20]`.
- Retail free-skate counts: `[1, 2, 1, 10, 4, 15, 5, 5, 5, 3, 3, 0, 5, 7]`. PR #4 derived these from the
capture's 247 keys; we cross-checked that slot 6 = 5 matches the CSTATEMGR_Emitter pool of 5.
- Each instance has its own copy of every A, B, F, C and E record of its slot, with its group number g in the
keys.
### 4.2 Reference expansion
A reference to node or controller X in slot s, made from a record of slot o in instance g:
- s == o → X in the same instance g (exactly one reference);
- s ≠ o → **one reference per instance of slot s**, all of them. They are all summed (C/E) or multiplied
(scale lists).
- For A and F records, o is taken from the record's own key byte. In this file that always equals the
section slot.
- Example: Ambience's sum contains `A[Player.5]`, which expands to player 0's and player 1's copies.
### 4.3 Input entries
- One entry per distinct input key (curve bits and group included): `{key, source, mB = 0, lin = 32767}`.
- Entries are stored in runs by curve kind (all kind-0 entries, then kind 1, …, kind 9). Within a run they
keep their first-use order.
- This order matters (§5.1).
### 4.4 Controllers and blocks
- A controller is 16 bytes `{vtable 0x82316B54, key, inputs*, outputs*}`.
- The **input block** is 16 × i32, zeroed. It is created for any controller named by an input, reference,
trigger or B record.
- The **output block** is 16 × u32, zeroed. Word 15 = enable, set to 1 at build. It is created for every E
destination.
- Output id n lives in word n>>1: even n in the low 16 bits, odd n in the high 16 bits.
- Retail hands each new controller to the game (manager slot 4 = `sub_82485850`). The game stores it at the
owning SFX object's `+12`, which is what the owner readers dereference.
- A port can simply look controllers up by key.
---------------------------------------------------------------------------------------------------------
## 5. Evaluation (one tick, in this exact order)
All math is i32 with arithmetic `>>` unless marked f32. "trunc" = round toward zero (fctiwz). Values are
Q15 linear (0..32767), mB (1/100 dB, 602 per halving), or cents.
### 5.0 Shared helpers and tables
- **POW[r]**, r = 0..601: `int(32768·10^(−(r+1)/2000))`, except POW[601] = 16384. Exact against the image
(read backwards from `0x82FDBFAC`).
- **mB → lin(m)**, used only for m ≤ 0:
- k = trunc(m / −602); rem = −m − 602·k;
- k outside 0..15 → 0; otherwise POW[rem] >> k.
- So 0 mB → **32730**, not 32767. −10000 mB → 0.
- **LOG[i]**, i = 0..511 (`0x82FDBFB0`): max(0, 601 − #{r : POW[r] ≥ 16384 + 32·i}). Exact.
- **lin → mB(x)**:
- x ≤ 0 or x > 32767 → −10000.
- Otherwise c = 14 − floor(log2 x) (0..14).
- idx = (x − 2^(14−c)) >> (5−c) for c ≤ 5; else (x << (c−5)) − 512 + 2^(c−5) − 1.
- Result: LOG[idx] − 602·(c+1). So 32767 → −1, 16384 → −602, 1 → about −9030.
- **CURVE[i]**, i = 0..511 (`0x82FDC7B0`): floor(32767·cos(π/2·i/511)). Exact. Entry 512 reads as 0.
- **shape(x, kind)**, x a Q15 input:
- kind 0: i = x>>6. If i < 0 or i ≥ 511 → 0. Otherwise
t + (((CURVE[i+1] − t) · frac) >> 15), with t = CURVE[i] and frac = 1023 + 1024·((x>>1) & 15).
Retail quirk: frac only uses x bits 1–4, so it interpolates at most halfway between entries.
- kind 1: shape(32767−x, 0)
- kind 2: s0² >> 15
- kind 3: kind 2 of (32767−x)
- kind 4: a = 32767 − CURVE[511 − (x>>6)], b = 32767 − CURVE[512 − (x>>6)], result a + ((b−a)·frac >> 15)
- kind 5: kind 4 of (32767−x)
- kind 6: s4² >> 15
- kind 7: kind 6 of (32767−x)
- kind 8: 32767 − x
- kind 9: x
- kind ≥ 10: 0
- In words, over u = x/32767: 0 cos↓, 1 sin↑, 2 cos²↓, 3 sin²↑, 4 1−sin↓, 5 1−cos↑, 6 (1−sin)²↓,
7 (1−cos)²↑, 8 1−u↓, 9 u↑.
- **curve01(kind, f)**: shape(trunc(f·32767), kind) · (1/32767), in f32.
- **cents → ratio(c)** = 2^(c/1200), built from whole octaves (×2 or ÷2) × SEMI[(|c| mod 1200) div 100] ×
FINE[|c| mod 100]. For negative c the factors are divided out.
- SEMI = f32(2^(i/12)) (`0x82FDCFB4`).
- FINE = f32(2^(i/1200)) (`0x82FDCFE8`). Entries 15, 36, 56 and 65 are 1 ulp off naive f32 rounding, so
read them from the image or patch them.
- **ratio → cents(r)**: r > 1 → trunc(f32(lin→mB(trunc(32767/r)) × K⁻)); otherwise
trunc(f32(lin→mB(trunc(32767·r)) × K⁺)). K⁻ = −1.99316 (`0x822F8AE0`), K⁺ = +1.99316 (`0x822F8AE4`)
(cents per mB).
- `mxb_tool.py tables` checks the generated tables against the image: all identical except those 4 FINE ulps.
### 5.1 Input stage
For each input entry, in run order (§4.3):
- raw = the source value in **linear flavour**:
- controller → the raw i32 input word;
- A key → the referenced product's *shaped input* (its entry's lin, not the product value);
- B → its Q15 roll-off;
- F → its level (Q15);
- C/E → their value.
- lin = shape(raw, kind); mB = lin→mB(lin).
Entries that alias another entry (A-type input keys, e.g. Ambience A1 reads A2's input) see that entry's
value from this tick only if it sits in an earlier run. Otherwise they see last tick's value (one tick of
lag, or the initial 32767 on the first tick).
### 5.2 A — MixCtl products (per instance, in build order: slot, instance, record)
- From the swing w (low 16 bits of w1):
- w bit 15 clear ("boost"): offset = w; ratio = 32767 − (mB→lin(−w)).
- w bit 15 set ("cut", w is negative mB): offset = 0; ratio = 32767 − (mB→lin(w)).
- x = 32767 − (((32767 − lin)·ratio) >> 15); dB = offset + lin→mB(x).
- Result: a cut gives ≈ w mB at lin 0 and ≈ 0 at lin 32767; a boost gives ≈ 0 → ≈ +w.
- Scale list: acc = 32767; for each reference, acc = (v·acc) >> 15, with v in **linear flavour**.
- **value = (acc·dB) >> 15.** The scales shrink the dB amount, which acts as a gate or depth control.
### 5.3 B — 3DMixCtl lookups (per instance)
Read the instance's SFXCTL input block `in` (§7.3).
- If in[15] bit 0 is clear (inactive): dB = −10000, lin = 0, azimuth = 0, Doppler = 0. Done.
- d = f32 distance per the variant's distance select. az = the azimuth input, a u32 (0..65535).
- q = (az >> 14) & 3; f = az − 16384·q. The range pair (a, b) for q = 0..3 is (d0,d1), (d1,d2), (d2,d3),
(d3,d0). kind = the quadrant's curve.
- If d > max_a **and** d > max_b: dB = −10000, lin = 0, Doppler = 0 (the azimuth is kept). Done.
- ra = trunc(f32((clamp(d, min_a, max_a) − min_a) / (max_a − min_a)) × 32767); rb likewise on b.
- Note: both use the same distance.
- ca = shape(ra, kind); cb = (f ≠ 0) ? shape(rb, kind) : 32767; w = 2f.
- **lin = ((32767 − w)·ca >> 15) + (w·cb >> 15); dB = lin→mB(lin).** The roll-off is interpolated by angle
between the two directional ranges.
- **Doppler**, only when c ≠ 0:
- s = in[13] if distance select = 1, else in[14] (f32 signed relative speed, negative = approaching);
flag = bit 31 resp. bit 30 of in[15].
- If the flag is set: clear it **in the input block** and use target = 0.
- Otherwise span = s + c, using c instead when span ≤ 0, and target = ratio→cents(c/span).
- Then Doppler += trunc(0.2·(target − Doppler)). Written exactly: D −= trunc(f32((target − D)·(−0.2))),
with −0.2 = `0x8208EA7C`.
- This slews per evaluation, regardless of dt.
- Also tracked, but unused: previous distance and |Δdistance|.
- File facts:
- Distances span 0–100 m; c ∈ {275, 340, 380, 520, 554, 866, 1557, 3740}.
- Only the select pairs (0,0), (0,1) and (1,0) occur.
- No range has min = max (that would divide by zero).
### 5.4 F — envelopes (per instance)
State: stage (0 off, 1 attack, 2 decay, 3 sustain/hold, 4 release); el = elapsed ms (f32); t0 = stage start
offset (ms); start = level at stage start; level (Q15); out (mB).
- **Idle:** stage 0 and trigger 0 → reset everything; out = 0 (or −10000 if linear). Skip.
- Otherwise el += dt·1000 (f32), then step the stage machine (loop until a stage stops):
- Progress p = (el − t0)/(span − t0) when span − t0 > 0, otherwise el − t0.
- **Attack** (stage 0 counts as 1):
- If gated and the trigger is off (kinds 1/3 only): carry into release.
- If el < A: level = start + trunc(curve01(attack curve, p) · (32767 − start)). A zero-length attack
(kind 3: A ≤ 16.666 ms) gives level 32767.
- Otherwise enter the next stage at level 32767: AR → release, AHR → hold, ADSR → decay.
- **Decay** (ADSR):
- Gated and trigger off: release, carried proportionally from the decay.
- el ≤ D: level = start + trunc((1 − curve01(decay curve, 1 − p))·(S − start)).
- Otherwise sustain at S.
- **Hold / sustain** (AHR stage 3 at 32767, ADSR stage 3 at S):
- If gated: stay while the trigger is on (el is pinned to 0); release when it goes off.
- If not gated: release once el > H.
- **Release:**
- el ≥ R → reset to off (level 0).
- Retrigger flag and trigger on → carry back into attack.
- Otherwise:
- AR: level = start − trunc(curve01(release curve, 1 − p)·start).
- AHR/ADSR: level = start − trunc((1 − curve01(release curve, 1 − p))·start).
- **Carry** from a stage of length L into stage X of length M: when L < 16.666 ms (`0x822F8ADC`), start X
at its end (t0 = el = M). Otherwise t0 = el = ((L − el)/L)·M. In both cases start = the current level.
- **Enter** = el = 0, t0 = 0, start = the given level.
- **Output:** with ratio/offset from the swing as in A:
- cut (swing ≤ 0 as signed): out = lin→mB(32767 − ((level·ratio) >> 15)). Full level → ≈ swing mB
(a duck).
- boost: out = offset + lin→mB(((level·ratio) >> 15) − ratio + 32767).
- linear: out = lin→mB(level).
- A scale list multiplies the mB amount (or the linear gain, in linear mode) as in A.
- Kinds other than 0/1/3 never leave stage 0 (level 0, so out = 0 mB). F184 is kind 4.
### 5.5 C — sums (per instance, build order)
- value = Σ references in **mB flavour**:
- A → its value;
- B → its dB;
- F → its out;
- C/E → their value;
- controller → the raw input word.
- Clamp to [lower, upper].
- Records read whatever is currently stored. An earlier record has this tick's value; a later index or a later
stage has last tick's value (e.g. Global C3 reads C4 from the previous tick).
### 5.6 E — output sums
- Output block disabled (word 15 bit 0 = 0) → value = −10000.
- Otherwise value = base + Σ non-B references (mB flavour). B references are not summed; G uses them.
### 5.7 Output write (per E record, per G output word)
v = value + offset; b = the special B reference, if any.
| type | no special | with special B |
|---|---|---|
| 0 volume | mB→lin(clamp(v, −10000, 0)) → 0..32730 | mB→lin(clamp(b.dB + v, −10000, 0)) |
| 1 pitch (cents) | clamp(v, −4800, 2400) | x = b.Doppler + v; x > 2400 → 2400; **x < −4800 → 0** (quirk) |
| 2 filter (Hz) | trunc(f32(cents→ratio(clamp(v, −10000, 0)) · 25000)) → 77..25000 Hz | clamp(v, −10000, 0), not converted |
| 3 / ≥5 raw | clamp(v, 0, 25000) | the sum unchanged |
| any + raw-azimuth bit | — | b.azimuth & 0xFFFF |
- Type 4 behaves as type 0. Only types 0–2 occur in this file.
- Disabled block: only the record's first output id is written: −10000 for volume (owners read it as
0x58F0 = 22768!), 0 for pitch, 25000 for filter.
- Typical offsets: filter −1 or −2 cents → 24985 / 24971 Hz (LPF open); −19931 → clamped to −10000 → 77 Hz
(HPF "off").
---------------------------------------------------------------------------------------------------------
## 6. Outputs → audio objects
### 6.1 Readers
Owner virtuals 52/56/60/64 read `[[owner+12]+12]` = that object's output block:
| reader | function | returns | used for |
|---|---|---|---|
| vfunc52 | `sub_824C2870` | half & 0xFFFF | pan angle (0..65535 = 0..360°; graphs scale by 360/65535) |
| vfunc56 | `sub_824C5910` | trunc(cents→ratio(half as signed) · 4096) | pitch, 4096 = 1.0 (256..16384) |
| vfunc60 | `sub_824AF240` | half & 0x7FFF | gains and sends, /32767 |
| vfunc64 | `sub_824AF240` | half & 0x7FFF | filter cutoffs in Hz |
Controller slot +16 (`sub_8294BCC0`, type 0–4) does the same conversions.
### 6.2 Ambience (`0x40020000`, 1 instance). Input 0 is written by the bed state machine `sub_824D3C28`.
The fade: 0 = bed fully up, 32767 = silent.
| out | type | formula (mB/cents before conversion) | read by |
|---|---|---|---|
| 0 | volume | −1100 + C0 + Σplayers A[Player.5] + F35 + F73 + A0 | bed gain = out0/32767 × zone volume (`sub_824D42C8`) |
| 1 | volume | −1400 + C0 + A1 + Σplayers A[Player.5] + F73 | crossfade packet w0 = w8 = clamp(out1 × level) (`sub_824D0F38`) |
| 2 | pitch | 0 | bed pitch = out2 via vfunc56 / 4096 (always 1.0) |
| 3 | filter | −2 + F36 + F40 + F211 (all Global) | bed LI20 cutoff (24971 Hz open) |
- **A0** = in0 through cos, cut −10000: in0 = 0 → 0 dB; in0 = 32767 → ≈ −90 dB.
- **A1** = A2's shaped input, where A2 = in0 through 1−u, then cos, cut −10000. This is the complementary
equal-power crossfade. It reads A2's value one tick late (§5.1).
- **C0** (clamped −10000..0) = Global ducks:
- A1 (Master.in2 = 0 → −100 dB);
- A41 (Master.in4, gated by NIS.in2 / Master.in0);
- A54 (VU.in0 −7 dB);
- F1 (Master.in6), F4 (NIS.in1), F13 (Pause.in0, × A15/A16), F41 (Reverb.in1 −2 dB);
- F65 / F66 / F91 / F121 / F139 (Menu.in0 / in1 / in3 / in2 / in8);
- F122 (Music.in5 −2 dB × Master.in1);
- F148 / F149 / F150 / F201 (Challenge.in8 / in7 / in9 / in1);
- F186 / F210 / F233 (NIS.in8 / in6 / in9).
- **A[Player.5]** = PlayerPhysics.in1 (speed, 30 km/h full scale) through 1−sin, cut −300, scaled by A9
(= 32767 − PlayerPhysics.in9). **Correction 2026-10-02:** in9 is 0 for the local player (PoC writer
`[[state+16]+72]` set → 0; confirmed by the board's high-pass: 77 Hz = retail modal only with in9 = 0,
1109 Hz with 32767), so for the local player this speed duck (up to −300 mB) is active; it is 0 for
the second player.
- **F35** = Reverb.in2, −200. **F73** = HOM.in0 (× A98). **F36 / F40 / F211** = Reverb.in2 / in1 / in6 low-pass
ducks of −4374 / −3406 / −3671 cents (to ≈ 2.0 / 3.5 / 3.0 kHz).
### 6.3 Emitter (`0x40060000` + g·0x800, g = 0..4; one per CSTATEMGR_Emitter state)
B0 reads SFXCTL `0x60060000` + g·0x800:
- distance = input 1, azimuth = input 3;
- all quadrants 4..70 m with curve 1−sin;
- Doppler c = 3740 (unused: no pitch output uses B0).
| out | type | formula | c_emitter slot (`sub_824DCF08`) |
|---|---|---|---|
| 0 | raw | azimuth of B0 | +16 (w3 pan), positional branch |
| 1 | pitch | 0 | +20 (w4 pitch), non-positional |
| 2 | volume | −988 + C0 + F21 (Reverb.in1 −100 dB) | +8 (w1 dry) × level, non-positional; forced 0 when `[*(0x820CFDBC)]+104` ≠ 0 |
| 3 | filter | −2 + F40 + F36 + F211 | +24 (w5 LPF), non-positional |
| 4 | volume | −600 + C0 (**no distance roll-off**) | +8 (w1 dry) × level, positional |
| 5 | pitch | 0 | +20 (w4), positional |
| 6 | filter | −2 (open) | +24 (w5), positional |
| 7 | volume | −2100 + C1 (= C0) | +12 (w2 send) × level, non-positional |
| 8 | volume | −2600 + C1 **+ B0 dB** | +12 (w2 send) × level, positional |
- C0 = Global ducks: A13 (Master.in4), A1 (Master.in2); F1, F4, F10, F11 (Pause.in0), F65, F66, F71, F90,
F91, F118 (Menu/HOM), F139, F168, F180, F186, F201, F210.
- Mapping the slot offsets to payload words assumes the 4-byte packet header (the `ems-emitters-re.md`
payload: w1 dry, w2 send, w3 pan, w4 pitch, w5 LPF).
- Consequence: a positional emitter's dry level falls off only by the emitter's own shape level ((1−d)² etc.).
The MixMap rolls off the **send** with camera distance (4 → 70 m) and supplies the pan angle.
- Our ems note's "ch0/2/3/4/5/6/7/8" are exactly these output ids.
### 6.4 Player components (slot 1, per player instance g)
Common pattern for each SFX object:
- out0 = raw azimuth of B[Player.2] (or B0 for body-centred objects such as Clothing, OffBoard, HandGrabs,
Takedown, DropIn).
- Volume outputs = base + B dB (via B2/B0/B4) + Player C sums + Global ducks + per-object A/F terms.
- Pitch outputs = B1 Doppler (c 275) + C1 / C3 / C7 / C9… (each clamped ±5000).
- Filter outputs: −1/−2 (LPF open) or −19931 (HPF 77 Hz), some with A/F sweeps.
Per-packet word mapping (which vfunc id lands in which AEMS word) is in PR #4's
`docs/player-audio-retail-drivers.md` §3–§4; for example rolling w1 = vfunc52(0), w2 = vfunc56(8),
w11 = vfunc60(7), and grain gain vfunc60(1)/(2). Not repeated here.
Player B lookups:
- B0: 3DObjPos `0x60010010`, 4–30 m, 1−sin.
- B1: 3DObjPos, 4–60 m, cos, the Doppler source (c 275).
- B2: 3DObjPos#2 `0x60010020`, directional 4–50 / 4–40 / 1–30 / 4–40 m, (1−sin)².
- B4: #2, 4–60 / 2–50 / 1–40 / 2–50 m.
- Others are listed in `catalog.txt`.
### 6.5 Ducking catalogue (Global section; all AHR envelopes, gated on a controller input)
- **Master.in1–in4** are category gains:
- via A4/A0 → Music;
- A5/A1 → nearly every SFX sum;
- A3/A2 → Announcer, NIS, CameraMan speech;
- Master.in4 via A14→A13 → emitters, and via A42→A41 → ambience.
- Retail keeps all four at 32767 (likely the option sliders; unverified). **A port must write 32767, or
everything is −100 dB.**
- **Pause.in0:** F11, F13 and others ramp the player, ambience and emitter levels to silence. In retail
capture: down over ≈175 ms, back over ≈88 ms.
- **Menu.in0–8, NIS.in0–13, HOM.in0–4, Challenge.in1–12, Speech.in0–4, Announcer.in0, CameraMan.in0,
Reverb.in0–6, Music.in3/5/8/9, VU.in0:** each drives one or more AHR ducks with its own A/H/R times
(e.g. HOM.in0 drives 15 envelopes). Full list in `catalog.txt` (`trig=`).
- **Music.in3/in6** are the combo-multiplier emphasis. They raise SkateBoard 21/22, Wheels 4, Rail 6 and
Tricks 6–8 (see PR #4 `MusicEmphasis`).
---------------------------------------------------------------------------------------------------------
## 7. Inputs: who writes what (game side; from PR #4 docs + our notes, not re-verified here)
### 7.1 PlayerPhysics `0x60010000` + g (`sub_824B19C8`, v = ground speed m/s)
| in | value |
|---|---|
| 0 | clamp(v × 23592.24) (5 km/h full) |
| 1 | v × 3932.04 (30 km/h) |
| 7 | v × 2359.22 (50 km/h) |
| 8 | v × 1685.16 (70 km/h) |
| 14 | |COM vel| × 1685.16 |
| 2 | 32767 when no wheel touches |
| 10 | wheels in contact × 8191.75 |
| 4 / 5 / 6 | brake / manual-brake / trick flags (0/32767) |
| 9 | 0 if `[state+16]+72` (the local player) else 32767 |
| 11 | bail-related global flag (writer unknown) |
| 13 | slewed |state+96 − [G+0x2F078]+32| (100 /s, cap 35, /35): `G+0x2F078` is the audio-state record array (player 0 first), so both are the local player's own COM velocity → **0 for the local player** (corrected 2026-10-02; PR #4 read it as a listener distance) |
| 3 | listener-facing factor |
| 12 | soft/hard surface selector |
### 7.2 Per-object inputs
- SkateBoard 0/2/3/4/6; Contacts 1/2/6/7; Rail 0/1; OffBoard 0; HandGrabs 0.
- Collision: input 15 bit 0 is the bus switch (PR #4 defect note).
- See PR #4 `mixmap/inputs.rs` docs and `player-audio-retail-drivers.md` §1–§2.
### 7.3 3DObjPos SFXCTL (`sub_824AEC70` / `sub_824AEB28` / `sub_824AEE60`), the block B reads
- in0 = f32 |followed point (skater) − emitter|;
- in1 = f32 |camera point (pulled back 0.25 m along the view) − emitter|;
- in2 / in3 = azimuth (u16 scale) in the skater frame / camera frame;
- in10 = facing difference; in11 = flag;
- in13 / in14 = f32 signed relative speed vs skater / camera (negative when closing);
- in15: bit 0 active; bits 31/30 = speed sign flipped (resets the Doppler slew).
- Inactive: in0 = in1 = −1.0, in2 = in3 = 0, bit 0 cleared.
### 7.4 Globals
Free-skate values (PR #4 capture statistics):
- Master in1–in4 = 32767; Music in1, in2, in5 = 32767; Reverb in5 = 32767;
- Pause in0 = 32767 while the pause menu is up;
- Music in3/in6 from the combo multiplier; Jitter in0–4 random every frame; VU in0 = output meter level.
- Everything else is 0 in free skate.
### 7.5 Ambience in0 = the bed fade; emitter SFXCTL `0x60060000` — writer not traced (see §10).
---------------------------------------------------------------------------------------------------------
## 8. Port checklist (behaviour that is easy to get wrong)
1. 0 mB → 32730, not 32767. Volume outputs never exceed 32730.
2. The kind-0 curve interpolates with a 4-bit fraction of at most ≈0.5 (§5.0).
3. Evaluation order and one-tick lags: input runs by curve kind (§5.1); C and E read earlier records of this
tick and later ones of the previous tick.
4. First tick: input entries start at lin 32767; nodes start at 0; E starts at −10000. Outputs on tick 0
differ, e.g. emitter levels read 0 on the first tick.
5. Cross-slot references expand to all instances of the other slot.
6. Pitch with Doppler: below −4800 the result is **0**, not −4800.
7. Doppler slews per evaluation, not per second. The flag bits are cleared inside the input block.
8. A disabled output block writes only its first id, with −10000 (read back as 22768 through & 0x7FFF).
9. Envelope time constants are 60 Hz frames × 16.6667 ms. The "too short" threshold is 16.666 ms
(`0x822F8ADC`), a different float.
10. f32 rounding matters (B ratios, curve01, cents tables). Use f32 for every float step.
11. Master.in1–in4 must be written as 32767.
---------------------------------------------------------------------------------------------------------
## 9. Validation plan
### 9.1 Golden vectors (done; rerun after any port change)
- Scripts (local): `golden1.txt` and `golden2.txt` (golden2 adds dt changes at evaluations
300/320/520).
- Format: `dt`, `dtat <eval> <dt>`, `frames`, `watch <key> <id> level|raw|pitch`,
`set <eval> <key> <id> <int|0xhex|f:float>`.
- PoC side (local only, never committed): `crates/skate-data/examples/mixmap_golden.rs` in the PoC worktree
- Build: `cargo build --release --locked -p skate-data --example mixmap_golden`
- Run: `target/release/examples/mixmap_golden.exe <poc>/assets golden1.txt golden1.poc.csv`
- Our reference: `python mxb_tool.py eval <mxb> <tu3 image> golden1.txt golden1.py.csv` (local tool)
- Diff: `mxb_diff.py a.csv b.csv` → today 0 / 36,900 cells for each script.
- The Rust port should emit the same CSV from the same script (an example or test), diffed against
`golden*.poc.csv`.
- Add scripts for: Collision (10 instances; input 15 bit 0), Pedestrian/Traffic B lookups with Doppler sign
flips, ADSR records (Global F133/F137/F138), Jitter-driven sums.
### 9.2 Retail capture through recomp hooks (passive; the existing `skate3_audio_trace_hooks.cpp` REX_FUNC-override style, TU 3.0.3.0 addresses, all present in our generated code)
- **MXTICK** at `sub_8294F5E8` entry (r3 = host, f1 = dt):
- log the eval counter, dt, and host+128 (mode);
- walk controllers: array `[host+160]`, count `[host+180]`; each controller has +4 key, +8 input block,
+12 output block;
- **MXIN**: each controller's 16 input words, change-only per controller.
- **MXOUT** after the original returns: each output block's 16 words, change-only.
- These two lines are a complete golden vector. Convert to a `set`/`dtat`/`watch` script, replay through
`mxb_tool.py eval` and the port, and diff every output word.
- **MXSET** at `sub_8294BC50` (r3 = controller, r4 = id, r5 = value; key = [r3+4]) plus caller LR,
change-only per (key, id): who writes which input. Use it for the open writers (emitter `0x6006…`,
PlayerPhysics 11, Reverb 1/2/6).
- **MXREAD** at `sub_824C2870` / `sub_824C5910` / `sub_824AF240` (r3 = owner, r4 = id, return r3) plus LR,
logged once per distinct (LR, owner vtable, id): the output → packet-word map for every family.
- Ambience: `sub_824D42C8` (out0..3 applied), `sub_824D0F38` (r4 crossfade group, obj+116 level).
Emitter: after `sub_824DCF08`, the c_emitter slot words +8..+24.
- Cadence: `sub_82485190` entry (f1 dt, r5 flag) confirms the half alternation.
- Size: about 250 controllers × change-only. Gzip the log. Use short scripted recomp runs, never
user-performed sessions.
---------------------------------------------------------------------------------------------------------
## 10. Open questions
- Who writes the emitter SFXCTL `0x60060000+g` (distance/azimuth for B0) and when it is active.
- Candidates: 3DObjPos constructors `sub_824AE098` called from `sub_824AE008`, `sub_824B07A8`,
`sub_824B0890`, `sub_824B25A0`, `sub_824B2688`, `sub_824B2EE0`, `sub_824B3570`…`sub_824B3A58`,
`sub_824B6098`…`sub_824B6B40`.
- Hook MXSET to settle it.
- Meaning of Master.in1–in4 (option volume sliders?) and of Reverb.in1/in2/in6 (indoor/underwater/zone?).
These are the inputs behind the ambience/emitter low-pass and Reverb.in1 dry mute.
- Instance counts come from the capture, not from code; other modes (career, online) may differ.
- The camera-state mode word (manager+4) is never seen changing. That is harmless for this file (1 variant).
- The 3DObjPos "followed point vs camera" frames and the 0.25 m pull-back are PR #4's reading; they were not
re-derived here.
- PlayerPhysics in11 (bail global G+16) writer unknown (PR #4).
- Whether retail on real hardware evaluates at ≈68 Hz (recomp cadence) or 30/60 Hz. This matters only for
the per-evaluation Doppler slew.
---------------------------------------------------------------------------------------------------------
## 11. Sources & credits
| source | author | link | licence | what it contributed |
|---|---|---|---|---|
| Upstream PR #4 (`skate-audio-core/src/mixmap/*`, docs `player-audio-retail-drivers.md`, `wheel-audio-handoff-2026-09-17.md`, `engine-defects.md`) | andrewnakas | https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/4 (commit 368a433) | none (read-only reference) | The retail function map, stage order, controller/reader functions, input writers, instance counts, capture match rate. Its port is our golden oracle via the PoC. Described in our words only. |
| Upstream PR #1 (`mx/audio-engine-vehicles`) | andrewnakas | https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/1 | none | Same MixMap core (PR #4 is a superset); first "PathFinder 5.03 MixMap" identification |
| skate3recomp generated C++ (TU 3.0.3.0) | mchughalex (+ rexglue SDK) | https://github.com/mchughalex/skate3recomp | none | Spot checks: the curve function's out-of-range / zero branches (`sub_8294B668`); that every hook address exists. Measurements only. |
| b5-decomp `NFSMix/*.hpp` (NFSMixMap, NFSMixRecords from the ProStreet PDB) | BurnoutDecomp | https://github.com/BurnoutDecomp/b5-decomp | none | EA's record/field names (MixCtl, 3DMixCtl, EvtMixCtl, SubMixCh, MasterMixCh, Preset, camera state, envelope stages). Confirms the same lineage (564/96-byte objects). |
| BP-Decomp_Workflow (DWARF/PDB pointers) | BurnoutDecomp | https://github.com/BurnoutDecomp/BP-Decomp_Workflow | MIT (content EA's) | Background only, via `rwaudio-prior-work.md` |
| dbalatoni13/nfsmw | dbalatoni13 | https://github.com/dbalatoni13/nfsmw | CC0 | Not needed for the MixMap (AEMS only); listed for completeness of the prior-art search |
| Skate 3 retail files (`MixMapSK8.mxb`, TU3 image tables at `0x82FDBFB0`/`0x82FDC7B0`/`0x82FDCFB4`/`0x82FDCFE8`) | EA Black Box | user's own disc / TU | proprietary | Measured data. Never committed; the tables are regenerated by formula (§5.0) or read from the user's image. |
| Our notes `ems-emitters-re.md`, `aems-*.md`, `rwaudio-prior-work.md` | this project | — | — | Ambience/emitter consumer side, payload words |
## 12. Files
- Spec: this file.
- Tools (local, not published):
- `mxb_tool.py`: `catalog` / `tables` / `eval` — decoder + reference evaluator, our own code;
- `mxb_diff.py`.
- Data (local): `catalog.txt/.json`, `outputs.txt`, `tables-check.txt`,
`golden{1,2}.txt`, `golden{1,2}.{poc,py}.csv`.
- PoC (local, uncommitted): `crates/skate-data/examples/mixmap_golden.rs`.
@@ -0,0 +1,553 @@
# Retail living world (NPCs, traffic, security): how TU3 does it
From the TU3 image, the recompiled code and disc data (reference only; 2026-10-02). Addresses are
evidence; never copy code or game data into the repo. Describe behaviour in our own words.
Tools: local research scripts (listed at the end). Data: local extracts.
## 0. Big picture
- The living world (`LivingWorld`, LW*) has four entity managers, each with Memory/Execution/Factory/
Census parts: **pedestrians** (`LWPedestrian*`, strings 0x82063E5C..), **vehicles** (`LWVehicleMan::*`
0x820BD4C8..), **dynamic objects / DMOs** (`LWDynamicObjectMan::*` 0x820BAB40..) and **props**
(`LWPropCensusMan` 0x820670C8; waypoint groups, conversation and gathering areas).
- Entity types (enum strings 0x82084684): Pedestrian, Vehicle, Skater, DMO, StaticObject. Ped
sub-types: MaleTeen, FemaleTeen, FemaleAdult, MaleAdult, BusinessMan/Woman, Female/MaleSkater,
Female/MaleTourist, Granny, SecurityGuard, Bum, Jock, Pro. Vehicle types: Car, SportsCar, MuscleCar,
SUV, Truck.
- **Behaviour is data-driven**: AI state graphs ship as plain XML plus compiled `.stategraph` files in
`data/big/miscload.big` (`data/state/livingworldentities/{pedestrian,vehicle,marquee,test_pro}/…`,
`data/livingworld/PluginDescriptor/*`). 52 ped `aigraph/*.xml`, 18 motion graphs, 13 plugins
(sit, ATM, vending machine, water fountain, newspaper box, trash bin, look-at, conversation,
spectate, actor-tracker spectate). The C++ side supplies named behaviours and conditions.
- **Tuning is in the attribute database** (`skatercollections.vlt` → our skater-collections.json):
classes `livingworld`, `livingworld_census`, `livingworld_census_ranges`, `livingworld_categorygroups`,
`livingworld_entitycategories`, `livingworld_entities` (157), `livingworld_models` (145),
`livingworld_entities_{chase,perceptions,navigation,locomotion,patrolzone,protect,knowledge,
moodreactions,moodresults}`, `livingworld_moodeventcategories`, `livingworld_entity_takedown`,
`livingworld_vehicle_{characteristics,drivers}`, `livingworld_dynamicobject_*`, `livingworld_handprops*`,
`livingworld_conversations*`, `ai_skater_profiles` (193), `ai_characters` (179), `aud_traffic_engine`.
Class and record names resolve by `name_id`; **field names are compiled out** (schema has only type
names like `Sk8::LivingWorld::tLWCensusEntry`, `tLWGroupEntry`, `tLWCensusCircle`). Fields read by hash
have code sites (`fieldxref.py`); fields with no site are read through the class layout by offset.
- Navigation middleware: **NavPower (BabelFlux)** — `bfxSystem/bfxPlanner/bfxMover(3D)/bfxBuilder`
0x8216E1AC.., `NavPower Runtime` 0x820BD170, `NavPower Crosswalks` 0x82077068, `NavPower Obstacle/Link`.
- World scripts can drive NPCs: `WorldScriptSim::mPedestrianManager / mVehicleManager / mDmoManager /
mSplineManager` 0x82176658.., commands Spawn/Unspawn/MoveToLocation/ReleaseToLW/KnockDown,
`SplineManager::cFollowSpline`, Attach/DetachPedestrian|Vehicle. Challenge script functions:
`IsLocalPlayerBeingChased` 0x82192554, `GetIfPlayerBeingChased`, `ForceCullLivingWorld` 0x82192948,
`Activate/DeactivateSpectatorWaypoint`, `LoadLargeCrowd` (challenge crowds are separate:
`Sk8ChallengeSystem:LargeCrowdGenerator` 0x82096B20, crowd imposters).
## 1. Population (census)
### Data
- Region layers (world painter, RW type 0x00EB000F): `livingworld_npc_census`,
`livingworld_vehicle_census`, `livingworld_security_guard_areas`, `livingworld_dmo_safety`. Each painted
cell holds a `livingworld_census` record key. Painted area (`census_layers.py`, output
`census_layers.txt`):
- DownTown peds: residential 13.9 ha, business_center 12.5, memorial 6.7, aletown 5.1, mall 2.2;
vehicles: `dwntwn` 89.7 ha.
- Industrial peds: reclaimed 5.0, loadingdocks 3.0, `downtown` 4.2; vehicles `indust` 52.5 ha.
- University peds: campus 17.8, observatory 1.2; vehicles `univ` 96.8 ha.
- Parks have no census layers (no peds/traffic there unless a challenge spawns them).
- `livingworld_census` record = `tLWCensusEntry`: RefSpec to a `livingworld_categorygroups` record +
(8 bytes runtime) + **u32 max population**. Values: peds default 40; downtown/university/industrial 20;
aletown/business_center/mall/memorial/residential/campus 15; reclaimed 10; loadingdocks 8;
observatory 6. Vehicles: default 25; dwntwn 30, indust 25, univ 10.
- Extra fields: peds `Hash_4936C5A55AB38AF3` (6 / univ 7.5 / downtown 6.5 / industrial 5) and
`Hash_4432C5ACB168F5BA` (5 / 7 / – / 4.5): meaning unknown (no hash-read site; probably spawn
rate or per-second budget). Vehicles `Hash_FFE5E258BD468196` (15; dwntwn/indust 20; univ 5), read at
sub_826B7EE4 (fork result below).
- `livingworld_categorygroups` = array of `tLWGroupEntry` (RefSpec to `livingworld_entitycategories` +
f32 weight at +24). Examples: downtown peds adult .2/jock .1/tourist .1/skater .1/teen .05/business .3/
bum .15; university adult .15/jock .2/tourist .1/skater .15/teen .25/business .1/bum .05; generic
`pedestrians` includes `ped_class_security_street` .1. District sub-areas (aletown, campus…) use a
single category listing 6–23 concrete entities (e.g. campus = tourists, teens, skaters, jocks,
`male_ambassador_big`, a granny). Vehicles: dwntwn hatchbacks .1, minivans .1, muscles .075, sedans .2,
sports .175, suvs .125, taxis .1; univ adds pickups .125, sedans .2…; indust pickups .175.
Full dump: `census.txt` (`census_dump.py --cats`).
- `livingworld_census_ranges` (records pedestrians/vehicles/props/dynamicobjects/…): two float[5] sets
per type. Read in sub_826B7530 (+ siblings) via accessor sub_82553128.
- **Set chosen by player speed**: `speed = |velocity| * 3.6` (km/h, constant 0x822F8628). The two
sets are lerped by `t = clamp((speed − A[4]) / (B[4] − A[4]), 0, 1)` (sub_826B7D60); if
`A[4] ≥ B[4]` set A is used as is.
- Elements: [0] spawn ring inner radius, [1] spawn ring outer radius (both passed to the spawners),
[2] **cull radius** (entities beyond it are removed, squared-distance test in sub_826BA8B0),
[3] **forward offset** of the census circle along the velocity direction, [4] speed key.
- Probable layout order (A = field E0C6647F889A71FC, B = D993B45FFEB335F6; the other order would make
the lerp dead code): pedestrians A (50, 60, 70, 0 @45 km/h) → B (50, 80, 90, 20 @80 km/h);
vehicles 80/100/110/0 both; props 65/80/90/0 (30 vs 10 km/h); dynamicobjects 0/90/100;
skatepark DMOs 0/150/200; extended-challenge DMOs 0/100/130.
So at skating speed: peds spawn 50–60 m away and vanish beyond 70 m, i.e. **just out of view**.
- Density scalars at census object +136/+140 come from a global at 0x830B7AE8 (mode 3 → floats
+336/+332 clamped 0..1) in sub_826B7010 (probably challenge/online density overrides; unverified).
### Code
- LW census tick **sub_826B71F0** (called from sub_826BDB50 ← sub_82859E70). Per type, in several
game modes: peds **sub_826B7530** (range record at census+104), vehicles **sub_826B7760** (+112),
DMOs **sub_826B7980** (+128), props **sub_826B7BA8** (+120, cull only).
- Peds: cull sub_826BA8B0 (circle centre, cull radius); spawn **sub_826B9940** (ring spawn) and
**sub_826B90F8** (larger; uses entity/category data, probably waypoint/plugin-placed peds — unverified).
- Ring spawn: per tick 2 attempts and at most 1 spawn. Each attempt: random point in the ring
(sub_82E17508, onto the nav mesh), random heading, then **sub_826B8B88** picks a category from the
census at that point (census lookup sub_826B8A28 → `livingworld_census` accessor sub_8269AF00) and
checks the budget; then the factory spawns through the manager vtable (+4).
- **Initial populate** (flag r7): 6000 attempts, up to 600 spawns, ring **8–80 m**
(0x82099250 / 0x820E5748).
- Vehicles: spawn sub_826B9B90 (same pattern), cull sub_826BAAB8. DMOs: spawn sub_826B9D58 (→
sub_82C4A130), cull sub_826BAD98; props cull sub_826BAC48.
- Global `livingworld` record: `entities` (two flags), `dynamicobjects` (2.5, 450, 2, 250, 300; read in
sub_82C4A130 / sub_82C55160), `trafficlights` (7, 0.5, 0.4, 1; sub_826B1540), `roadnetwork` (1.15, 1.3,
10, 10, 5, 4; getter sub_826B3738 via LW object +13340).
## 2. Paths and navigation (streams)
cSim tile streams (RW type ids from the table at 0x830384A0: (id, name) pairs; 0x00EB000F = layer):
- **0x00EB0027 NAVPOWERDATA**: 2 per DownTown tile (240) — NavPower nav meshes for peds.
- **0x00EB0014 AIPATHDATA**: ~1 per tile (100 in DownTown). Header u32 count (e.g. 0x18) + u32 0x10,
then 0x60-byte entries: bbox min/max (vec4), district tag (`dwtn`), 64-bit ids, offset + point count,
a small int (1–3), 7, mask 0x3FFFFFFF_FFFFFFFF. Point records (36 bytes) hold a position at street
height plus small floats and packed normals; a second block holds position + velocity triples with
upward components (2–3.5 m/s) and 0.85 — looks like **AI-skater lines with jump launches**, not roads.
Challenge versions: `data/content/aipaths/AIPATH_Challenge_*.rx2` (59 files, miscload.big).
- **0x00EB001A WAYPOINTDATA** (18 in DownTown): waypoint groups with name strings, e.g.
`gd_DMO_GLBL_DrPepperVendingMachine_1002 | WayPoints1 | WP_VM_position_1001` with position and
facing — the plugin anchors (`waypoint_vendingmachine`, `_atm`, `_sit`, `_patrol`, `_spectator`,
`_conversation`, `_lookat`, … strings 0x82066F00). `DMO Waypoint Group (From Hotpoint)` 0x820BAD18:
DMOs (vending machines, benches, trash bins) also create waypoints.
- 0x00EB001D DMODATA (226 in DownTown): DMO placements.
- 0x00EB0004 SPLINEDATA (~2/tile): straight segments at ledge height with editor GUIDs — grind splines,
not roads.
- **0x00EB0013 (table name "RAINDATA", really the road network)**: standalone arena in 52 DownTown
tiles; loader sub_82C9B3A0 (registered in stream dispatcher sub_82C990A8). Header bbox (2× vec4) +
counts/offsets; segments with from/to node ids (u64), link id, length (e.g. 143.5 m), width 8 m,
speed 14.17 m/s (51 km/h), lanes per direction (2/2); lane samples every 4 m (pos, next pos, dir),
lanes 4 m apart; 25-sample stride blocks. Sample: `cSim_-150_-50_high_*_00eb0013_1.bin`.
Runtime object LivingWorld+0xC0A0, global pointer 0x830854C0 (set in sub_826BBC40); vtable slot 16 =
segment by id, slot 64 = query; 248-byte records.
- Other stream callbacks (sub_82C990A8): NavPower 0x82C9B250, AIPATH 0x82C9B4F0, WAYPOINT 0x82C9B3F0,
shared unload 0x82C9B2A0.
- AIPATH = recorded human runs (`d:\aipaths\out\` strings 0x82255D08, AIPath::Node / ExtendedNodeData,
flags Race/Vert/Street/Shortcut/Slowdown/AllOffBoard/SomeOffBoard/Spectate/Escape, difficulty
Easy–Extreme).
- Census layer lookup: sub_82C0EAC0(painter, layer index, pos); index 2 = npc census, 3 = vehicle census
(via sub_826B8A28); fallback record `default`.
## 3. Behaviour runtime (state graphs)
- Loader sub_826BC250 (from LW init sub_826D9040), path table base 0x821DE6BC. Each `.xml` has a
`.stategraph` beside it = the same tree in binary with includes merged (tag, attr count, name/value
pairs, children). Which one the runtime reads is unverified (no ".stategraph" literal).
- Registration: static initialisers 0x82F76000–0x82F7B000 (peds) and 0x82F8C568–0x82F8CC88 (vehicle) call
sub_826C3D18(registry, name, factory) — name-sorted tree. Registries: behaviours 0x826C1670 (160),
conditions 0x826C1730 (127), plugin-descriptor conditions 0x826C2458 (23), plugins 0x826C2518 (6:
LookAt, UseWorldProp, PedestrianConversation, GatherBehaviourGeneric, SpectatorWaypointGeneric,
ActorTrackerSpectate), motion-graph hooks 0x826C25D8. Full list `aireg.txt`
(762 incl. vehicle), `ai_registry.txt`. Condition evaluate = vtable slot 12, behaviour update = slot 13.
- Example behaviour SuggestVelocity: factory 0x826C9488, vtable 0x8230A384, attribute `linear` → +32;
begin sub_8269F6A8 sets the mover's suggested speed (mover vtable +168).
- Managers: LWPedestrianCensusMan created in sub_82E25EE0 (712 B, vtable 0x8232D1C0); ExecutionMan
sub_82E25CE8; per-frame ped tick probably sub_82E239A8 / sub_82E238C0 (unverified).
- Graph shape (ped): every state runs MonitorEnvironment; InitialState → Plugin | AddressWants | Wander.
Wander (speed 3.0) → Plugin, Scatter, AddressWants, ZombieFollow, WanderOnRoad (on road, not at an
intersection), RunFromHonker (≤30 s), Colliding. Road wander waits at crosswalks until the walk sign.
AddressWants handles wants in a fixed priority: observe (collision/slam/trick reactions, 3.5 s
watch + cheer speech), warn, throwhandprop, greet, startconversation, startspectate, taunt, taze,
chase; interruptible: alert, flee (escaped at > 15 m).
- Vehicle graph: FollowingLane ↔ PassingIntersection / Impatience → ChangingLane / PullingOver →
StayingParked (alarm) → PullingOut. Handlers (slot 13): FollowingLane 82C376E8, Impatience 82C38390,
ChangingLane 82C38730, PullingOver 82C38C70, StayingParked 82C39138, PullingOut 82C39208,
PassingIntersection 82C39490. Conditions (slot 12): 82C39CF0..82C3A850, StopAlarming 82C3A4D0.
## 4. Mood → wants (reactions to the player)
- Events (`livingworld_moodeventcategories`): each has a range pair (collision 20/20, skatertrick 10/50,
nearbysession 100/100, presence radius 20 every 0.5 s, skaternoise 0/40, nearbychase 20/20 prio 3), a
continuous flag, priority. Presence/trespass monitor sub_82E3CA20.
- Per ped type, `livingworld_entities_moodreactions` = prerequisites + weighted list of
`moodresults` (fields: event, target filter, Nth occurrence, probability 0.4–0.8, cooldown 10–31 s,
exposure time, distance 3.5/5/20). Evaluator sub_82BFE010 / sub_82E41EB0 / sub_82BFDF28.
Decoded: `mood.txt`, `moodresults.txt`.
- Want enum (0x820646BC / 0x820C9080): 0 none, 1 angrychase, 2 warn, 3 taunt, 4 returngreet, 5 greet,
6 startconversation, 7 alertto, 8 joinchase, 9 throwhandprop, 10 flee, 11 startle, 12 taze,
13 nearbycollisionreaction, 14 slamreaction, 15 startspectate, 16 nearbyskatertrick.
- (Data reading; **measured in §5d: male peds bump 1 warn, bump 2 angrychase; female peds warn+taze or
flee**.) Ordinary peds: bump #1 warn, #2 warn, #3 angry chase; damaging an in-use object → chase; nearby trick
→ watch + cheer; nearby slam → watch; nearby session → spectate (p 0.8, cd 30 s); females flee on the
2nd bump, warn+taze on the 3rd; jocks also warn/chase on a close avoid (3.5 m); hand-prop throw p 0.8.
- Perception sub_82E26F38 (from sub_82E27240): near sphere (range × 1.0, mongo 1.5) = seen at once;
else range × D238, 120° cone (sub_82E16588) and line of sight through the collision world
(sub_82E169C8). Global record (10, 0.01, 100, 0.1, 0.2, 0.6; sub_82E24B60): use unknown.
## 5. Security guards and chases
- `livingworld_security_guard_areas` (HighAlert_Area / NoSkateZone_Area, test sub_82C0E890 from
sub_8289B7E8) is **empty or absent on the shipped maps** — no area-painted zones in free roam.
- **CONCLUSION (2026-10-02): Skate 3 has no active security guards / busted system in free roam.** User
source: Skate 2's MongoCorp guards chased and busted you in specific downtown areas; Skate 3 removed
that to make Port Carverton a relaxed sandbox. Our evidence agrees: guard-area layer empty on all three
districts; zero SECCHASE/SECTAKE/SECTAZE/SECZONE calls in both NPC runs. The guard code, chase records,
nsz types and `security_street01` census entries are Skate 2 leftovers. **Not part of the Skate 3 port** — but keep the guard research: the user wants Skate 2 support eventually (a planned item).
- **But ordinary pedestrians DO chase and take you down (measured 2026-10-02, user session
recomp session `npc_20261002_143554`, the user angering peds):** a regular ped (red jacket) got a
**red down-arrow marker over its head** (angry), chased the player and tackled: SECTAKE attempt 59.04 s →
success 59.20 s, screenshot shows a **"KAPOW!!"** caption over the downed skater, then the ped gloats
(gesture, 61.5 s). The chase lasted ~10 s (49.9–60.4 s): every frame (dt 0.0333 = 30 fps) one chaser
(ctx r4 4074F9C0) evaluated escape `sub_826AC668`, rest `sub_826AC898` and give-up `sub_826AD690` (all
returned 0 = keep chasing), plus the takedown entry choice `sub_82E3C000` (f1 = 13.06 constant, ret 7 on
39 calls then 1 once = an entry chosen just before the attempt). The quoted claim that Skate 3 removed
pedestrian hostility is **wrong for peds**; only guards/busting are gone. The "SEC*" hook kinds are the
generic chase code (named after the chase records), not guard-only.
- NPCSTATE bursts at load (8.8 s, 27.6 s: PedestrianColliding/Scatter/InterceptChasee factories) are
state-graph setup, not reactions.
- (Earlier) **User (2026-10-02): guards don't wander, they stay in specific areas.** So guards are not (only) census
peds: find the placement source. Searched: world sim streams have no patrol/security/nsz names
(`tools/stream_strings.py`); census groups do list `ped_class_security_street` (weights 0.05–0.1, e.g.
`district_park01_peds_census`), which would spawn them anywhere in the painted census area. Open: patrol
zones (`livingworld_entities_patrolzone` 5/10, EntityOfInterestIsInZone sub_826AF310) and where a guard's
zone centre comes from (spawn position? waypoint_patrol in another stream type? challenge hulls?).
The user offered a hand-played session angering peds/guards (a traced recomp session with the NPC hooks).
- Scripted "wander" runs (make_wander_script.py) are open-loop and get stuck against walls (user saw it
stuck in a corner): unsuitable for reaching NPCs. Use the user's passive session instead.
- Guards are census peds: `security_street01` (weight .05–.1 in many groups), `security_nsz01` (University
group .1); patrol cars `vehicle_class_patrol`. Types: nsz01 (chase `mongo`, perceptions 1.5×),
nsz02 (`no_skate_zone`), street01 (`street`); reactions `security01` / `security_street`.
- Patrol zones: conditions EntityOfInterestIsInZone sub_826AF310, …BufferRegion sub_826AF3F0,
…MaintainInterestRadius sub_826AF5B0, ReturnToPatrolZone sub_826AEE80; patrolzone record 5 / 10
(which is buffer vs interest radius unverified); zones probably from `waypoint_patrol` groups.
- Triggers: no-skate-zone guard: first bump = chase; presence → trespass warn (0.5 s), still on board
→ chase (3.5 s); chase while it walks back to its zone; joins anyone's chase; other skaters nearby:
warn p .4 / chase p .6. Street guard: skitch → warn (2 s). Bumps as ordinary peds.
- Chase fields (livingworld_entities_chase): 240EB11A0CB469C3 **escape distance** (peds 65, security 500,
street 45, zombie 8; ChaseeEscaped sub_826AC668); 4686FEC68A60BD69 **exhaustion limit** (30/45/jocks 60;
NeedToRest sub_826AC898); 78E2E1B19721FD4E **give up after N takedowns** (2, street 3, zombie 1;
sub_826AD690); 314597EDA6B921F2 alert distance 25; FA762B2EA065A6D7 alert timer 30 s;
5A3C4A7700DDA13C unreachable timer 5 s; probable 1AE29113978BE5AA investigate time (10/20/jocks 2);
unmapped CD6575C0E03860E7 (5–12, run speed?), CEA5D982FD05BE0B (max chasers?).
- Chase graph (disc XML): 1.25 s hold before starting (speech), intercept 4–10 m/s with ≤20 m lead and
22.5° prediction; takedown attempt 3 s timeout; unreachable → warn, wait 5 s, taze (face ≤20°, ≤15 m,
1.5 s draw, 2 s cycle), give up, mood suppressed 10 s; mood suppressed 5.5 s when a chase can't
start. End: escaped, lost + investigate time out, exhausted (rest, music cooldown), takedown cap,
successful takedown ends the whole chaser group ("aggressivecapture").
- Takedown: AttemptTakeDownTargetable sub_826A49D8, success sub_826A4E50, entry choice sub_82E3C000 (by
approach side and relative speed; 7 entries generic_male, 2 bum; windows 0.08–0.3 / 0.1–0.5 s, reach
≤1.2 m, angles −70..30°). Skater-side bail response not found (skater condition `IsTakeDownByBoard`
0x820DC888 exists). Taze: DrawTazer sub_826A8258, TazeWantTarget sub_826A8398. Timer set/get
sub_82E40940 / sub_82E40A80 (index = position in the name list at 0x82064BCC).
> **2026-10-02 19:00: the recomp sessions behind the measured sections below were deleted (pre-fix trace writer, a few malformed lines). Treat those findings as "re-verify on clean data" (only all_20261002_163809 / _164620 / _180430 and world_hooks are clean).**
## 5b. Ped chase decoded (user session `npc_20261002_143554`, Aletown, 2026-10-02)
**Object layout (from the trace's address spacing):** peds are 0x1780 bytes apart (spawn returns 47B9C480 +
i·0x1780); perception object = ped + 0x920 (NPCSEE r3); takedown context = ped + 0x728 (SECTAKE entry r3);
the "brain" (mood + timers, NPCMOOD/SECTIMER r3) is a separate array 0xCE0 apart from 47BDD8E0, same slot
index. The chaser was **slot 1**: ped 47B9DC00, perception 47B9E520 (583 checks, the most), brain
47BDE5C0 (299 timer sets, the most).
**Timer indices = order of the name list at 0x82064BCC** (sub_82E40940 r4; debug string
"Tried (%s) with probability (%f) -- Set Timer (%d, %f)" at 0x82064B94):
0 NextWarnTimer, 1 InvestigateTimer, 2 ChaseSkaterOutViewTimer, 3 ChaseExhaustionTimer, 4 RestTimer,
5 OutOfViewTimer, 6 LookAtTimer, 7 AlertTimer, 8 TimedRangeRandom, 9 SpectatorBoredomTimer,
10 SpectatorCheerTimer, 11 AmbientBehaviourTimer, 12 CellPhoneTimer, 13 GatherTimer, 14 WanderWaitTimer,
15 PatrolZoneMaintainInterestTimer, 16 AttemptTakeDownTimeout, 17 TakeDownSuccessTimeout,
18 ReturnToPatrolZone, 19 ProRecTimer, 20 TargetConverse, 21 StartChase, 22 ForceWarnTimer,
23 WaitAfterCheer, 24 SitTimer, 25 PresenceCheckTimer, 26 TrespassCheck, 27 PatrolTimer, 28 IdleTimer,
29 TimeUntilICanBePrimaryChaserTimer, 30 ConversationSpeakingTimer, 31 HandPropActionTimer,
32 HandPropUsageTimer, 33 HandPropUsageTimer2, 34 ThrowHandPropTimer, 35 ThrowHandPropReactionTimer,
36 InterestTimer, 37 TazerIntoTime, 38 TazerWaitTime, 39 TazerPlayerDropTime, 40 TazerCycTime,
41 WanderRoadCheck, 42 TargetUnreachableTimer, 43 WanderTargetTime, 44 CollisionVolumeUnlock,
45 PluginTimeout, 46 ScatterTime, 47 UnspawnTime.
Every ped re-arms **25 PresenceCheckTimer 0.5 s** and **26 TrespassCheck 0.1 s** continuously.
**Timeline (trace time; screenshots `shots/` at CLOCK + ms):**
| t (s) | trace | screen |
|---|---|---|
| 32.1 | mood eval → 1 | player running at the ped, board in hand |
| 41.0 | mood → 1 | player bailed into the ped |
| 44.2 | mood → 1 | bump on foot (board on the ground) |
| 45.7 | mood → 1 | another bump, ped stumbles |
| 48.5–48.6 | mood → 1, **timer 21 StartChase = 1.25 s** | **yellow down-arrow** over the ped, pointing = warn |
| 49.9 | chase starts (= 48.6 + 1.25) | **red arrow** = angry chase |
| 49.9–60.4 | every frame (dt 0.0333): give-up sub_826AD690, escape sub_826AC668, rest sub_826AC898 → 0 (keep chasing); takedown entry choice sub_82E3C000 → 7 (none of the 7 entries fits) | ped standing on/at the player |
| 59.00 | entry choice → **1** | |
| 59.04 | takedown attempt sub_826A49D8, f1 = **3.0** (= AttemptTakeDownTimeout 3 s, chase graph) | |
| 59.20 | takedown success sub_826A4E50 | **"KAPOW!!"**, player knocked down |
| 61.5 | | ped gloats (gesture) |
| 64.8–66.7 | mood → 1, timer 8 TimedRangeRandom 5.0 s; 71.7 timer 36 InterestTimer 3.5 s (5 s later); again 84.6 / 89.6 | post-chase interest |
- So: warning (yellow) → 1.25 s StartChase hold → chase (red) → takedown (3 s attempt window) → gloat.
- The entry-choice floats: f1 = 13.06 constant, f2 ≈ −126 drifting slowly; hypothesis: target position
components (x ≈ −126 lies in `ots_dwtn_05`'s X range where the player was) — unverified.
- Not measurable from this session: how many bumps escalate (mood calls were rate-limited; 4 positives
before the warn), chase speed (no positions logged), chase timers 2/3/16/17 (SECTIMER lost 956 of ~1,256
calls to the 20/s limit during the chase). Hook fix (2026-10-02): SECTIMER named, unthrottled except
25/26 — see hooks_npc.cpp. Positions need a ped/skater position read (offset TBD).
## 5c. Second user session (`npc_20261002_144545`, ~200 s, many peds angered, improved hooks)
Analysis: the local tool `ped_chase.py <trace>` (saved
`ped_chase_144545.txt`). 4 chases, 3 takedowns, 1 tazer ped, 159 mood hits.
**Chase sequence, measured 4 times:**
| ped | trigger mood hit | StartChase (1.25 s) | chase evals | end |
|---|---|---|---|---|
| slot 3 | 42.99 | 43.89 | 44.9–59.6 (14.7 s) | takedown attempt 59.65 → success 59.72 |
| slot 3 | 79.85 | 80.74 | 81.7–82.1 | attempt 82.09 → success 82.31 |
| slot 3 | 89.32 | 90.37 | 92.0–111.2 (19.1 s); InvestigateTimer 10 s set/cleared at 94.5/95.3 (lost then regained sight) | attempt 111.30 → success 111.36 |
| slot 10 | 173.62 | 174.52 | 176.2–189.6 (13.4 s); InvestigateTimer 10 s 177.8; **TargetUnreachableTimer 5 s at 187.05** (player in a deep pit, screenshot) | gave up, no takedown |
- **Trigger → StartChase ≈ 0.9 s** every time (0.90, 0.89, 1.05, 0.90; the warn reaction plays), then the
1.25 s hold, then the chase. The trigger hits are the frame-rate evaluations (f1 = 0.0333/0.0667); the
1 Hz hits with f1 = 0.1 are periodic presence/interest evaluations (followed by InterestTimer 3.5 s and
TimedRangeRandom 5 s), not escalations.
- **No lasting cooldown:** the same ped (slot 3) chased three times in ~70 s.
- **Takedown:** entry 1 chosen → AttemptTakeDownTimeout 3 s → success 0.06–0.22 s later (all three).
Entry-choice floats: f1 10.54, f2 −211.2…−211.7 here (13.06 / −126 last session) — per-chase constants,
probably target position (hypothesis).
- **Tazer (slot 8; §5d: tazer peds are FEMALE — 'heavy man' was a misread; YELLOW arrow = warn stage, not a chase):** draw at 142.87
and 144.63 (TazerIntoTime **0.13 s**, TazerWaitTime **1.5 s**), at 146.29 TazerPlayerDropTime **0.3 s**,
TazerCycTime **2.0 s**; again 162.63. Screenshot 142.9 s: aiming the tazer at the player. So tazing is a
warn-stage reaction of some ordinary peds (matches the mood data: warn + taze), no guard needed.
- **Correction:** the tazer ped is most likely female: `tazeperp` / `fleeperp` are default_female results
(mood.txt); male peds use skatercollisionwarn/chase. Re-check with the next session's MOODOUT.
- **Position + mood-output hooks added (2026-10-02):** ped position getter `sub_82E3D0D8` (vtable slot 9
of the component at owner+188; owner = ped − 0xB0; r3 → out vec) → PEDXYZ; PEDSEE (target position,
range, seen); MOODOUT (evaluator output words: r5/r6/r8/r9/r10 words, r7's 4 words, result list). Smoke
run: PEDXYZ addresses = slot bases, plausible walking motion; background hits read result 6, word 8,
handles 020001D0/02000091.
- Still unmeasured: chase speed (no positions), how many bumps per escalation (bump events aren't hooked;
mood hits don't carry the event id yet).
## 5d. Third user session — reactions decoded (`npc_20261002_150519`, ~340 s, PEDXYZ/PEDSEE/MOODOUT hooks)
Tool: the local `ped_chase.py <trace>` now prints decoded reactions.
**MOODOUT decoded:** result list words = **want enum** (0x820646BC: 0 none, 1 angrychase, 2 warn, 3 taunt,
4 returngreet, 5 greet, 6 startconversation, 7 alertto, 8 joinchase, 9 throwhandprop, 10 flee, 11 startle,
12 taze, 13 nearbycollisionreaction, 14 slamreaction, 15 startspectate, 16 nearbyskatertrick); output word 2
(r6) = **mood event id**: **10 = skater collision (bump)**, 11 = collision nearby, 2 = slam nearby,
6 = trick nearby, 8 = presence (greet/conversation), 16 = greeted (returngreet). Counts this session:
startconversation 350, greet 117, returngreet 26, warn 11, slamreaction 7, angrychase 6, flee 5,
nearbycollisionreaction 9, nearbyskatertrick 4. r7's first word = 1 for target-based results, 0 for event
results; words 4/5 are floats on some results (5.0 / 0.5, 0.1, 0.8, 0.3) — likely duration/probability (?).
**Escalation rule (measured):**
- **Male peds: bump 1 → warn, bump 2 → angrychase**, every time: slot 8 (44.25 warn, 45.56 chase),
slot 5 (75.69 → 76.96; again 121.34 → 122.41: the count resets after a chase), slot 10 (307.74 → 308.91;
330.75 → 333.04). The data's "bump #1 warn, #2 warn, #3 chase" reading (§4) is off by one: the second
collision chases.
- **Female peds (tazer users): bump → "warn + taze" (two results) or flee**, never chase: slot 6 (96.59
warn+taze → 101.18 flee), slot 13 (61.0 flee, 142.41 flee, 148.55 warn+taze, 155.77 flee), slot 9 (190.28,
275.40, 286.10 warn+taze, 289.26 flee). Screenshot 190.3 s: a woman in a red top, yellow arrow — confirms
the tazer ped is female (§5c's "heavy man" was wrong).
- Tazer: TazerIntoTime 0.13 s + TazerWaitTime 1.5 s per draw (repeated draws while aiming); a hit adds
TazerPlayerDropTime 0.3 s + TazerCycTime 2.0 s (slot 9 at 193.60 and 293.32).
- Bystanders: event 11 → nearbycollisionreaction; slams nearby → slamreaction; tricks → nearbyskatertrick.
**Speeds (PEDXYZ, 4 Hz per ped, horizontal):** walking median **1.30 m/s** (p90 1.57; 9,300 moving samples);
during the 1.25 s StartChase hold ~0.8 m/s; chase bursts p90 **7–9 m/s** (graph: intercept 4–10 m/s).
Takedowns 4/4 successful (attempt → success 0.10–0.13 s). Chases were short (player stayed close).
## 6. Traffic driving (beyond the graph)
- Manoeuvre decider sub_82C41CD0 (from 82C3D830) → vehicle+4396 (1 lane change on a timer, 2 overtake a
slow/stopped car, 3 pull over), target lane +4380. Impatience: stopped (≤0.1 m/s) + gap test.
- Speed planner sub_82C3FA08 → target speed +3408 (lane speed + driver offset); look-ahead braking over
20 m, 5 m stop/reverse distance. Car alarm stops after 8 s (+3716).
- Traffic lights sub_826B1540: 7 / 1 / 0.5 / 0.4 s phases via sub_82E156D8 (which is which unknown).
- Sounds: SFXObj_TrafficHorn sub_824D6BE8, SFXObj_TrafficSkids 824D7440, banks Traffic_Horn,
Traffic_Skid, car_alarms.abk; engine `aud_traffic_engine` (idle 800–1870 rpm, max 2100–4000, patch
0–7; sub_824D6110).
- Peds' IsBeingHonkedAt (826ABF98) reads ped+5896 → +3232 honker id; setter not found. Skater vs car:
no traffic-specific bail found; the contact-force bail code has a vehicle term (string 0x820CFF14).
- **Vehicle position (2026-10-02):** component at vehicle+144 (vtable 0x82322240, all vehicles) slot 9
`sub_82C45CB0` → `sub_82C48248` copies the world matrix at `*(vehicle+164)+16` (row 2 forward, row 3
position); slot +88 returns the current speed (f1, used by the speed planner). Traffic hooks:
`hooks_traffic.cpp` (VEHSTATE etc.); smoke run Crystal Towers: positions/speeds plausible.
- `livingworld_vehicle_characteristics` layout and `livingworld_vehicle_drivers` (default/reckless/taxi/
fast/normal): `fieldxref_traffic.txt`.
## 6b. Vehicle session (user, `npc_20261002_152302`, ~140 s, hooks_traffic.cpp)
Tool: `py -3.13 tools/recomp-trace/veh_trace.py <trace> [--vehicle ADDR]` (saved
`veh_152302.txt`).
- 17 vehicles tracked. **Moving traffic: median 5.8 m/s (21 km/h), p90 14.9 m/s (54 km/h)**, typical max
15–18.6 m/s (jumps of 39/47 m/s = spawn/teleport glitches, ignore).
- **Cars stop for the skater:** the user landed on a car roof (screenshot 85 s: heelflip onto the green
car) → that car stops; standing in the lane in front of it (112 s) → it stays stopped and **traffic queues
behind** (yellow cab, red car): 3 cars stop/go in step 84–107 s (2–4 s stops), then all held 14–20 s at
108–123 s while the skater blocked the lane. Several later stops 125–140 s.
- Manoeuvre (+4396) / target lane (+4380) changed only twice (→ 0 at 30.05 / 44.81 s): no lane changes or
overtakes around a blocking skater in this session — cars wait.
- **+3408 is not a plain target speed:** it ranges −76…+5 (negative while stopped/queued). Treat as a speed
delta / planner output (?) until decoded.
- **Traffic light timers (TRAFPHASE, set once at load, 5.93 s):** 20 phase timers in two orders — 0.5 / 8.0 /
0.5 / 7.0 / 1.0 and 0.5 / 7.0 / 1.0 / 0.5 / 8.0 s → per direction **green 7 or 8 s, amber 1 s, all-red 0.5 s**
(interpretation; notes §6 had 7 / 1 / 0.5 / 0.4 from data).
- **Horn / skid hooks fired only at load** (4 each at 6.15 s, constructors): actual honks/skids are not in this
trace. Use a session with every trace category (audio + npc + world + traffic + audio capture): the audio
hooks log every horn/skid/speech POST and PLAY, so they can be tied to vehicle/ped state. PEDHONKED: 0.
## 6c. Skitching + AI skaters (combined session `all_20261002_153038`, ~8.5 min, audio+npc+world+traffic)
**Skitch (measured, screenshots 490 s holding the yellow cab, 509.5 s let go):** vehicle 47CAEDA0.
- The car's flags byte **+4403 becomes 0xC0 for the whole skitch** (484.15 → 508.46 s; seen nowhere else:
91 of 12,740 VEHSTATE samples). User: no special skitch sounds, "the vehicle just changed its logic".
- While 0xC0: the +3408 field locks at **0.20**, the car accelerates 5.5 → **16.8 m/s (≈60 km/h)** over ~4 s and
holds 13–16.8 m/s for ~24 s / ~270 m — faster than normal traffic (median 5.8, p90 14.9 m/s).
- When 0xC0 clears (skater lets go): +3408 → −7.25 and the car **brakes to a stop**: 16.8 → 0.9 m/s in
~2.6 s (≈6.2 m/s²), then waits.
- Before the skitch (472–477 s) the car crept/stopped (skater near it). Other +4403 bits: 0x10 toggles
often (braking/yield, 353 samples), 0x20 with manoeuvre 3 (pull over, 166), 0x08 on +3424 (28).
- Next: find who sets 0xC0 (store to +4403 with 0xC0 / 0x40 / 0x80 bits) = the skitch attach/detach code,
and the skater side (skitch state, attach offset).
**AI skaters — user (2026-10-02): "There are AI skaters ALL OVER", most at PCU Library; they skate what the
player skates and features around him: grinding rails, small basic tricks off features.** Now traced: category
`aiskater` (hooks_physics.cpp): `SKATEB` = every board's post-physics contact pass `sub_82C07D20` (player and
AI, each frame), per board ≤4/s: contacts w0–w3 / front truck / back truck / deck, deck contact point
(0 in the air), deck velocity. PCU Library check (60 s): player board 469434F0 + ~12 other boards; contact
patterns 0000000 air 367, 1111000 rolling 362, **0000110 trucks only = grind 9**, 0000001 deck slide 32,
0011000/1100000 manual 37. `SKATER` (GroundAnimation fill sub_82D34150) is event-driven, few lines. The
skater list (index 0 = player, 1.. = AI) is `*(*(ctx+1588)+8)` (vtable +4 count, +12 get; position via
skater+52 component vtable +12) — sub_82C74EB8 GetDistanceToNearestAISkater; manager string
`Sk8::AI::TheAISkaterManager` (0x8218A99C), `IsAISkater` 0x820DCD00, `GetAISkatersSetting` 0x8221E670.
**AI skaters' board audio (2026-10-03): the second CONTACT object `0x40C710A0` is the Player slot's 2nd instance, which
retail gives to one NPC skater within 30 m of the camera — `world-npc-skater-audio.md`.**
Earlier note (wrong, kept for the record): **Other skating NPCs: no evidence in this trace.** No tracked LW ped moves at skating speed outside a chase
(PEDXYZ). The two board-contact audio objects (CONTACT 0x40C70C80 / 0x40C710A0) are both the player's:
frames where only the second fires (83.2 s, 277.9 s) show only the player. AI skaters (if they spawn in free
roam) use skater physics, not the LW ped census, so none of our hooks cover them yet: candidates are the
`vlt_ai_skater*` records, AIPATH skate lines (§2) and `ai_*` mood results (ai_spectatecollision,
ai_skaterpresence_chase…). Hook plan: find the AI skater spawner/controller and log its skaters'
positions/states.
## 6d. Long combined session (`all_20261002_160126`, ~10.5 min, light-mode hooks, all 5 categories, NO crash)
Light-mode tracing held under real play (the only crash record is the earlier 15:53 one). 526 of ~390k lines
malformed (mostly empty) — parsers skip them (`trace.py` now ignores unparsable MARK/CLOCK/CAPTURE).
**Peds:** 4 chases, 3 takedown attempts / 2 successes, reactions: slamreaction 13, nearbycollisionreaction 7,
nearbyskatertrick 4, bump→angrychase 4, bump→warn 1. **Chase without a preceding warn** on a single bump
(slot 2 at 242.96, slot 13 at 347.44), and slot 1: warn 290.39 → chase 291.49 → takedown 302 → next bump
326.01 → chase immediately. With §5d (a ped re-warned 45 s after its chase) this suggests the "already
warned" memory lasts ~35–45 s — or hard bumps skip the warn (?). **Hand-prop throws:** ThrowHandPropTimer
0.20 / 0.92 s and ThrowHandPropReactionTimer 0.81 s on several peds (throwing held items).
**Skitches (6, vehicle +4403 bits 0xC0):** 17.0 / 14.6 / 18.3 / 22.2 / 3.5 / 21.7 s. Speed at grab 0–17 m/s
(grabbing a stopped car makes it drive), peak 15.6–22.7 m/s (56–82 km/h; one 27 = likely a jump), reached
after 3–11 s, mean 8–14 m/s, 28–281 m per skitch. Release: the car does NOT always brake to a stop
(2 of 7 with the §6c one); others keep driving at 7–19 m/s. Traffic overall: moving median 6.0, p90 14.9 m/s.
**AI skaters (SKATEB):** player board 469434F0 + **72 AI boards** over the session (6 alive > 60 s; 4–14
active per minute — they spawn/despawn around the player). Contact mix — player: rolling 60.5 %, air
27.4 %, manual 5.3 %, slide 2.8 %, grind 0.2 %; AI: rolling 70.5 %, air 15.2 %, manual 6.0 %, slide 2.7 %,
grind 0.6 %. Speed |v| moving: player median 8.05 / p90 13.3 m/s; AI median 8.2 / p90 11.8 m/s.
**Gap:** SKATEB's position is the deck contact point only (239 of 6,375 AI samples), so "skate the same
features as the player" (user) can't be measured yet → next hook change: log the first touching part's
contact point (wheels/trucks/deck) or the board matrix, then measure AI-to-player distance and shared
features.
**Fixed (2026-10-02):** SKATEB now logs the first touching part's contact point + part index; PCU check:
531 of 885 lines carry a position (rest = air). Sessions before this fix have deck-only points.
## 6e. Speech (measured 2026-10-02, session all_20261002_161849; tool `speech_reads.py` (local tool))
Ped/world speech is NOT in AEMS banks: it streams from `data/audio/english/livingworldspeech.big` (EB v3,
3,014 entries; also maincastspeech / cameramanspeech / announcerspeech; each has `<x>_Events.evt`,
`<x>hdr.big`, `<x>sth.big` read at boot = event tables). 132 clips in ~11 min. Clip names
`<line id>_<voice id>[_<voice>]_<line>`: voices torf1/torm2/torm3 (tourist f/m), busm1/busm2/busw2/busw3
(business), bum2/bum3, joc2/joc3 (jock), tenm2 (teen m), sktf1/sktf3 (skater f), adtf4, grn2, secg2 (security
guard), generic 91. **Trigger → clip (start 0.03 s after the reaction):** nearbyskatertrick → SpecPos_n;
slamreaction → Slam_n; nearbycollisionreaction → SpecCol; StartChase → SpecChs (bystander) + maincast
`906_<pro>_aislam` (pro/AI-skater lines, e.g. Ratt, Kost, Dill); greet/startconversation/returngreet → Shout,
Int_c<n>_<partner> (ped-ped conversation), Rct_c2; cell phone HelCel → ConCell → ByeCel (+ Silence spacers);
bums PanHndle/RandmBum; guard radio RadioChirps / secg2_radio; player grinds → GrindLand / GrindMid; Gasp,
grunt; cameraman `704_40_spot_ots` (own-the-spot). Levels: this session had no `dsp` category (levels 0);
The all-categories session now includes dsp.
Second speech session (all_20261002_163809, 260 s, WITH voice levels — dsp on): bump→warn → `501_<voice>_Warn_n`
(also for warn+taze, e.g. 501_49_adtf4_Warn_n); collision nearby → SpecCol / `HImpRct_n` (high-impact
reaction) / Gasp; slam nearby → Slam_n / `Rct_c14_tour`; maincast pro lines around the player (`104_21_Ladd_Slam`,
`130_21_Ladd_col`, `101_09_Ratt_pos`). Levels (GAIN×SEND, linear) in its report.md: Skate_Collisions median
0.168 / p90 0.379, sk8_foley 0.244, ped footsteps fstep_livingworld median 0.005 / p90 0.072 (distance),
skater shoes 0.014 / 0.146, sirens up to 0.26, people_yelling up to 0.20.
## 6f. Session all_20261002_164620 (8.3 min, all categories incl. dsp, single writer: 522,380 lines, 0 malformed, NO crash)
- **Peds:** slamreaction 17, nearbycollisionreaction 16, nearbyskatertrick 8, bump→warn+taze 4, →flee 3,
→warn 2; no chases this time; 3 tazer hits on the player (TazerPlayerDropTime).
- **Speech by trigger (speech_reads.py):** bump→warn → Warn_n; female warn+taze → Warn_f; bump→flee → ChsFlee;
slam nearby → Slam_n; collision nearby → SpecCol (+ grunt/Gasp); trick nearby → SpecPos_n; greet/conversation
→ Int_c14_tour, Shout, grunt, Gasp, PanHndle (bums); ambient (no reaction nearby): phone HelCel/ConCell/ByeCel,
guard radio RadioChirps/secg1_radio, pro lines (Duff_pos, Duff_AiSlam, Cole_col). 168 clips.
- **Skitches:** 5 (1.6–14.1 s). Traffic moving median 6.0 / p90 14.7 m/s.
- **AI skaters (positions valid now):** 54 boards; distance to player median 76 m (p10 27, p90 108), within
10 m only 2 %; their grinds/slides never within 2 m of the player's grind/slide spots. **User: "they don't
skate the same thing as you all the time, but you might skate something and do it over and over and find an
AI skater has come to hit that spot as well."** Measured: at 5 of 6 spots the player hit ≥3 times, an AI came
within 8 m (e.g. (−174, 650): player 6 hits 138–165 s, an AI 13 samples within 8 m rolling/manual; (−400, 429):
player 342–346 s, AIs arrived 386 s and 397 s). Not yet seen: an AI grinding the same feature. Next: find the
AI skater's spot/target selection (Sk8::AI::TheAISkaterManager, AIPATH skate lines §2, `nearbysession`
mood / session spots) and hook its target choice.
- Levels: the report of recomp session `all_20261002_164620` (GAIN 90,231 / SEND 77,650).
- **All sessions use the user's 100 % completion save** (user: "they will skate all over"): AI skater density
(4–14 around the player, 54–72 per session) may depend on progression — check the AI skater spawn rules
(`GetAISkatersSetting` 0x8221E670) before porting the counts.
- Traffic/world levels (GAIN×SEND, linear; median / p90): Traffic_Horn 115 starts 0.000 / 0.207, Traffic_Skid
139: 0.003 / 0.164, Tire_Squeals 0.018 / 0.088, car_alarms 0.024 / 0.516, truck horns ~0.02 / 0.06–0.15,
engines C00_heavy01 0.011 / 0.088, C01_family01 0.008 / 0.062, C03_sports01 0.005 / 0.085, C04_taxi01
0.016 / 0.079, C05_truck01 0.011 / 0.096, C06_sports02 0.003 / 0.038, C07_family02 0.008 / 0.076; ped
footsteps fstep_livingworld 0.010 / 0.086; sirens Siren_city_* up to 0.19.
## 7. Hook plan (recomp trace, kind prefix NPC*/TRAF*/SEC*)
1. NPCPOP — sub_826B7530 / 7760 exit: speed, lerped ranges, census circle; sub_826B9940 / 9B90 entry
(f1/f2 ring, r7 fill flag) + spawn result; sub_826BA8B0 / BAAB8 culls (id, distance). → population,
spawn/despawn radii, rates.
2. NPCMOOD — sub_82E41EB0 / sub_82BFE010: entity, event, chosen result, roll → reaction probabilities.
3. SECCHASE — sub_826AC668 (escape), sub_826AC898 (rest), sub_826AD690, sub_826AD3F8 results;
sub_82E40940 timer sets (r4 index, f1 length; indices 1–4, 15, 25, 26) → chase timings per guard.
4. NPCSEE — sub_82E26F38 result with distance/angle → perception.
5. NPCSTATE — sub_8269F6A8 (suggested speed per state), factories InterceptChasee 0x826C75A8, Scatter
0x826C8618, PedestrianColliding 0x826C5D90 → state timeline.
6. SECTAKE — sub_826A49D8 / sub_826A4E50 / sub_82E3C000 → takedown success rule.
7. TRAFSTATE — vehicle slot-13 handlers: id, state, speed, +3408, +4396/+4380; sub_82C41CD0 exit.
8. TRAFSND / TRAFLIGHT — horn/skid constructors, alarm +3716, sub_82E156D8.
9. Stream — sub_82C9B3A0 / 82C9B250 / 82C9B4F0: which road/nav/AIPATH blobs load where.
Guard every read with Readable(). Short scripted runs: stand still in a census area (population), bump a
ped 3× (mood), skate past a guard (chase), stand on a road (traffic).
## 7b. Live trace, first pass (2026-10-02, `hooks_npc.cpp`, recomp session `npc_hooks`)
Run: Locations Midtown_Promenade then Hotel_District, standing still 45 s each (`scripts/npc_hooks.txt`,
`SKATE3_TRACE=npc,world`, 147 s, no crash). Raw-register hooks; summary via
`tools/recomp-trace/first_pass.py <trace> [KIND…]`. Measured (retail code, recomp timing):
- **Ped ring spawn `sub_826B9940`: f1 = 50 m, f2 = 60 m on every call** (free skate; the 8–80 m ring is the
initial populate, r7 = 1, never seen here: teleports didn't use it). Called ~12×/s from the census tick
(caller 826B7740 ← 826B73C8 ← 826BDFB8 ← 82859F6C); returns a ped pointer on 47 of 1,627 calls (~3 %).
- **Placed ped spawn `sub_826B90F8`**: same 50/60, returns 1 on 81 of 1,626 calls.
- **Ped cull `sub_826BA8B0`: f1 = 70 m** (1,646 of 1,675; 90 m on 12 calls, ? which mode).
- **Vehicles: spawn ring 80–100 m (`sub_826B9B90`), cull 110 m (`sub_826BAAB8`).**
- **Mood `sub_82E41EB0`**: r3 = ped (13 peds seen), f1 = 0.1 (step, mostly), returns 1 on 63 of 1,255.
- **Perception `sub_82E26F38`**: r3 = ped, f1 = range by caller: **20 m** (caller …82E3DAC4, presence),
**30 m** (…82E38028), **10 m** (…82E3D224); returns seen on 84 %.
- **Timers `sub_82E40940`** (r3 = ped, r4 = index, f1 = length): **index 26 = 0.1 s and index 25 = 0.5 s,
re-armed on every ped continuously** (presence monitor; 25 matches "presence every 0.5 s"); index 31 and
8 (5 s) rarer. So the "chase timer" indices 25/26 are general periodic timers, not chase-only.
- **Suggested speed `sub_8269F6A8`** returned f1 = 3.0 on every call (? check that f1 is its result).
- **States while standing still:** PedestrianColliding ×5, Scatter ×2, InterceptChasee ×2 (factories called
from 8241C06C ← 82C16424/82416114: the state-graph runtime).
- **AI stream blobs** (`sub_82C9B250/B4F0/B3A0`): r6 = asset type **0x00EB0027 (26), 0x00EB0014 (24),
0x00EB0013 (15)**, r8 = 0x34, f1 = streaming distance (13–36). Map these types to road/nav/AIPATH next.
- No security chase calls (SECCHASE/SECTAKE) on this route — expected: Skate 3 has no active guards (§5).
- Trace writer: a few lines were interleaved (hooks fire on several threads; `audio_trace::line` isn't
atomic per line). `first_pass.py` skips malformed lines.
Next: name the fields (spawn result → ped type/category, cull distances, mood event/result), a guard-area
run (security), a bump run (pad script pushing into a ped: mood → warn/chase).
## 8. Export for our engine
Census layers + census/category/entity records (done as text), road graphs (type 0x13) per district,
waypoint groups (0x1A), AIPATH lines (0x14) for AI skaters, NavPower meshes (0x27; format unknown —
probably replace with our own navmesh from collision), ai_skater_profiles trick weights, chase/
perception/mood records, vehicle characteristics/drivers. State graphs: re-implement the structure in our
own code (don't ship the XML).
## 9. Prior work / reuse
- Nothing upstream (PRs, issues) on retail NPCs, traffic AI or guards. Upstream main `crates/skate-dynamics`
(Rapier island: spawn/remove bodies, convex colliders, kinematic proxies, spring rays, joints; cars
composed in Lua) + `mods/Skyline_Drive_Mod`, branch `skyline-driving-update`; @andrewnakas fork
`mx/vehicle`, `mx/engine`, PR #1 "Mx/audio engine vehicles" — all player-driven. Reusable: traffic car
bodies as kinematic proxies on skate-dynamics (skater collision), engine-sound work for
`aud_traffic_engine`. No licence upstream: describe, don't copy.
## Tools (local research scripts, mostly not published; `veh_trace.py`, `first_pass.py`, `callctx.sh`,
`big_list.py` and `sim_types.py` are in `tools/`)
- `img_strings.py <regex>`, `img_range.py a b`: strings in the TU3 image.
- `hashxref.py <hex64|name>`: code sites that build a 64-bit attribute hash (lis/ori halves + rldimi).
- `vlt_resolve.py <class-regex>`: resolve hashed classes/records/fields of skater-collections.json →
`vlt_<class>.json` (arrays kept whole as `items`); cache `name_ids.json`.
- `fieldxref.py <class>…`: per field, values per record and reading code sites.
- `census_dump.py [--cats]`: census → category group → categories → entities.
- `census_layers.py [DIST…]`: census record per painted area from the world-painter layers.
- `rwtypes.py [regex]`: RW object type id table. `sim_dump.py DIST tile type…`: save stream objects.
- `big_list.py <big> [regex] [--out dir]`: list/extract .big entries.
- Added later: `ai_registry.py`, `aireg.py`, `reg_table.py`, `reg_vtables.py` (graph name → factory →
handlers), `mood_dump.py`, `guard_areas.py`, `vlt_show.py`, `vlt_arrays.py`, `callctx.sh`,
`vcalls.py`, `castcalls.py`.
- Disposable data: `name_ids.json` (9 MB cache), `disc/data/db/skatercollections.bin`
(1 MB).
@@ -0,0 +1,111 @@
# Ragdoll contact response vs the bail-sound gap — research spec (2026-10-03, research only, no engine change)
Question asked: retail's per-step Δv along the contact normal (p99 1.8–13 m/s, max 7.6–20 m/s, one-step "contact
stops") vs ours (~2 m/s peak, "lands and slides"). Find retail's ragdoll contact mechanism, compare with ours, say what
would have to change. Sources: the skate3recomp TU3 lifted code (reference only, our own words), recomp traces from our
own scripted runs, our code (read only). Credit: skate3recomp by @mchughalex, rexglue SDK, Xenia.
## TL;DR (what the user decides on)
1. **The physics gap is mostly a measurement artefact.** The retail Δv figures came from the scripted runs at the
Super-Ultra Mega-Park spawn, where the skater fell ~2 s into the gap (or slid down the MegaRamp roll-in) and hit at
**11–23 m/s**. In ordinary-height tumbles (first 2 s of the bails in `bailx3_20261003_110208`) retail's per-step
|Δv·n| is p90 2.3 / p99 3.7 / max 3.7 m/s with approach speeds ≥ −4.3 m/s — the same size as ours (~2 m/s).
Retail's large "one-step stops" are 1–3-step stops of 15–20 m/s impacts, removing 70–100 % of the normal velocity per
step: what restitution 0 in the shared contact solver does, and what ours (a port of the same solver) also does.
2. **The bail-sound gap is audio-side: a missing speed curve.** The audio bridge `sub_824B0DA8`, after copying the
conditioner's region impacts into the audio state (`+496..+524`, 8 regions), multiplies each by an 8-point graph
(`sub_82481E10`) evaluated at the PREVIOUS frame's |COM velocity| (`+216` ← `+212`). The graph (read from the running
game, watch `[[[0x830CFDA4]+36]+4]`): 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. So any bail moving faster than ~0.95 m/s gets ×5. Proven with the new `BANDQ` hook:
the body poster's impact = 5.0 × the clamped region impact (p50 of the ratio 5.0 over 218 posts; 4.106 = 5 × 0.821,
1.033 = 5 × 0.207, the 0.001 floor → 0.005). Our `skate_events::region_impacts` → `body_impact` skips this, so our
impacts are 1/5 of retail's at the poster. Our max 0.21 per bail × 5 = 1.05 → tier 2 for every body material (bands
0.25–0.65), and tier 1 / 2 on concrete (bands 1.0 / 1.85) become reachable — exactly the "concrete 1047 / 991 from a
different impact" open question in doc 11.
3. Recommendation: **fix the audio side first (the curve), keep the physics as is**, then re-measure the bail density
against the recomp before considering any physics change.
## 1. Retail mechanism (recomp code + traces)
### 1.1 The solve
- The ragdoll is solved in the same `rw::physics::Simulation` as the board: Horus iterative solver (`82AE27D0`, contact
iteration `82AE2914..2B8C`), contacts built by ContactBatchBuild `82AE10C8` from the collision-to-contact writers
(`8277A828` / `8277BFAC`). Contact rows solve velocity + position error together, a 4th lane position-only; normal
impulse clamped to [0, ∞), friction cone static → dynamic. **50 iterations** (Simulation +176, doc 12).
- Skeleton world query: its own query record (`82BE5094` → `82768728` with edge threshold −1); the ground job's shared
values (volume padding 0.05, max separating distance 0.5 — speculative contacts).
- 60 Hz fixed step: the recomp's BAILSTEP shows 16.6 ms steps and SkeletonState dt 0.01667 always.
### 1.2 Materials, masses, damping (stock vault, `physics_skeleton` / `physics_wipeout`)
- Normal (riding / driven) collision: friction 0, restitution 0 (`FrictionNormal` / `RestitutionNormal`).
- Ragdoll (requests 8–11 → modes 7–10, `82BE6D60`): `FrictionRagdoll` 0.9 / `RestitutionRagdoll` 0 (head / hands the
same values), mode 8 overrides all bones to 0.5 / 0.3 / 0.4 (static, dynamic, restitution). Contact combine: friction =
max of the two, restitution = min (`82763078`) → a ragdoll part on any surface has restitution 0.
- `DoInverseMass` / `DoInverseInertia` true (ragdoll masses from the skeleton definition), `LinearDrag` 0,
`AngularDrag` 0.5 × 60. `CollisionDriveScalar` 1.0.
- The audio "mass" SS `+4560` (0.0003–0.0098) is the bone-box volume used only for the impact formula, not the body
mass; identical to ours (doc 11, masses section).
### 1.3 How velocities change at contact
- Restitution 0 → the solver drives the normal relative velocity to ~0 (plus position correction). Measured in the
MegaRamp impacts: v·n −23 → −12 → −1 (foot), −17 → −3 (leg), −19 → −1.8 (arm): 1–3 steps.
- Bail-specific velocity writes (all ported in ours): Wipeout response trigger `82D3E678` (adds normal × control ×
strength to all 23 parts), the two material responses `82D91708..82D92440` (`contact_response.rs`), retained velocity
`82D3E348/82D3E8C0`; wipeout drives (`physics_skeleton_drives` per part, root scalars, `TimeToRemoveDrives`,
`DynamicDriveWeightVel*`).
### 1.4 What the audio sees
- `82BEBD28`: SkeletonState velocities = finite differences × 60 of the part frames (body COM ⊗ local mass frame,
`82585CB0`), Δv = v − v_prev (ours: `SkeletonPhysicalRecord::update`, identical).
- `82BD60C8`: region impact = clamp01(max(0.001, |Δv·n| × mass × 10)) → Collision `+80 + 4i` (8 regions; note retail
takes |Δv·n|, ours the signed value — doc 11 masses section).
- `82773298` (conditioner): 4-frame ring, per-region max → block `+92` → skater entry `+320` → audio state `+496`.
- **`824B0DA8` (bridge): `+496 + 4i` ×= graph(`+216`), i = 0..7; then `+216` := `+212` (|COM v|).** ← missing in ours.
- Body poster `824BC188`: per region 0..5 with impact > 0 and no cooldown, tier = `82497088(material, impact)`
(bands: tier 2 > `+0`, tier 1 > `+8`, none < `+12`; our exported bands match the game's exactly, BANDQ: 97 = 0.0025 /
0.05 / 0.375 / 0.65, 98 = 0.0025 / 0.1 / 0.25 / 0.45, 99 = 0.0025 / 0.1 / 0.35 / 0.6, 100 = 0.005 / 0.2 / 0.65 / 1.25,
2 / 3 / 65 = 0.12 / 1.0 / 1.85 / 2.0, 40 = 0.01 / 0.15 / 0.55 / 1.25). Tier 2 adds a second message at tier 1.
## 2. Ours (read only)
- `crates/skate-core/src/physics/{board_step.rs, solver/*, contact*.rs}`: the same Horus solver and contact builder,
skeleton parts as attached bodies in the board's solve (`skate-game/src/physics/solve.rs`), 50 iterations.
- Skeleton world volumes `skate-game/src/physics/skeleton_colliders.rs`, query `board_world.rs::query_primitives` with
the board's `query_settings()` (padding 0.05, separating distance 0.5) and edge threshold −1 for the skeleton.
- Materials / drag / masses / joint limits from the same vault fields (`wipeout_states/ragdoll/settings.rs`,
`skeleton_body/collision_mode.rs`), wipeout responses and drives ported (`skate-core/src/player/wipeout_state/`).
- Record and region impact: `skeleton_body/record.rs`, `game_audio/skate_events.rs::region_impacts` → `body_impact`
(4-frame ring) → `skate_audio::player::contacts::body`. **No speed graph.**
- No code-level difference found in the contact response. Not verified empirically: our per-step v_old·n distribution
in a matched bail (no per-step skeleton log exists in our engine; adding one is a code change).
## 3. What would have to change
A. **Audio (recommended, small, skate-game / skate-audio only, no unsafe, skate-core untouched):** in the audio-state
build, multiply each region impact (after the 4-frame max) by the bridge graph at the previous frame's |COM v|. Data:
the 8-point graph (find its vault record: the global at `0x830CFDA4` +36 → +4 points at an attribute record; or stage the
values with the other player tuning). Interpolation = `sub_82481E10` (already ported in `player/footsteps.rs`).
Also the |Δv·n| abs (doc 11 masses section) and the 30 fps cadence of `Contacts::body`'s cooldown (doc 11 open item 3).
B. **Physics (not recommended now):** nothing identified that differs from retail. Only consider after A, if a matched
comparison (same drop in both engines, our per-step log) still shows a gap.
## 4. Expected effect (A)
- Bails gain tier-1 / tier-2 body hits (1030–1032, 948 / 950 / 951 / 953 / 957) and concrete tier 1 / 2 (1047 / 991):
louder, punchier impacts, closer to the recomp's 208 Collisions voices / Σg² 4.7 per bail (ours 46 / 1.5–1.9).
Feel / look of bails: unchanged (audio only).
- Risks: louder bails than the user expects (retail parity says this is right; verify against the recomp capture and
ask the user to listen); every slow body contact while moving (> 1 m/s) also ×5 → more tier-0 posts at the floor
(0.001 × 5 = 0.005 ≥ the arm floor 0.005, ≥ 0.0025 for the others): retail does the same (BANDQ shows thousands of floor
queries per run, mostly tier 3 for arms); the cooldown limits posts. `+212` = |COM v| in m/s is our port's reading of
the state; the curve's x range (0–0.95) means "moving at all", UNCERTAIN whether retail's `+212` is in m/s — the
measured ×5 at running / falling speeds and ~×1 at rest agree with that reading.
- Optimisation / regression: e2e bail scenarios change (expected), everything else must stay byte-identical.
## 5. Decision points for the user
1. Accept that the ragdoll physics is not the cause (no physics change) — yes / no.
2. Port the bridge's speed graph on the region impacts (audio only) — yes / no.
3. Together with it: the |Δv·n| abs and the 30 fps body-poster cadence (both already documented) — yes / no.
4. If wanted later: a per-step skeleton contact log in our engine (diagnostic, behind an env var) for a matched-drop
comparison with the recomp.
## 6. Evidence / tools
- Runs (0 malformed lines): `bandq_20261003_105210` (BANDQ, first build), `bailx_20261003_105626` (curve watch
`impact_curve.watch`), `bailx3_20261003_110208` (6 / 6 bails with `lt rt l3 r3`); older
MegaRamp-gap runs `bailrun_t2/ok/ok2/ok3`.
- Local scripts: `stop_anatomy.py` (large steps: time, run, first touch), `dv_window.py` (tumble vs later),
`tier_thresholds.py`, `vault_named.py` (vault fields by name).
- Recomp hook `BANDQ` (`hooks_audio.cpp`, category `audiox`, 7 fields) on `sub_82497088`.
@@ -0,0 +1,145 @@
# RenderWare Audio (rwaudiocore) + Snd9 AEMS — prior work for the native evaluator / voice-graph / mixer port
Research 2026-10-02. Knowledge only: everything below is written in our own words. Never paste code from these
repos into ours, whatever their licence (project rule "no copied game content": they transcribe EA code; we
re-implement). Companion to `aems-reference.md` (our ABKC/csi/opcode notes from PR #4 / sk8audio).
## Local copies (small sparse clones; delete freely, re-create with the commands below)
- BP-Decomp (4 MB) — BurnoutDecomp/BP-Decomp_Workflow, sparse: `progress/audio_faithfulness/`,
`progress/scratch_dossiers/*aems*|*sndplayer*`, `references/DecFIGS/dwarfdump/SDKs/{EATech,Packages}/…audio/csis…`,
`references/DecFIGS/dwarfdump/GameShared/GameClasses/Sound/`, `tools/assets/bundles/aems_x360_port.py`.
- b5-decomp (5 MB) — BurnoutDecomp/b5-decomp (branch `dev`), sparse:
`vendor/renderware/{include,src}/rw/audio/`, `src/SDKs/EATech/include/{snd,NFSMix}/`,
`src/GameShared/GameClasses/Sound/Playback/{AEMS,RWAC,Plugins}/`.
- nfsmw Snd9 (117 KB) — 6 files fetched with `gh api … -H "Accept: application/vnd.github.raw"`
from dbalatoni13/nfsmw `src/Speed/Indep/Libs/snd/9/` (aemsdef.h, saems.c, saemsi.h, saemsmbf.c, saemstimupdt.c, sndo.h).
- Sparse-checkout gotcha under Git Bash: leading-slash patterns get rewritten to the Git install folder; prefix
the command with `MSYS_NO_PATHCONV=1`.
- The plug-in descriptor layout dossier in BurnoutDecomp/BP-Decomp_Workflow `progress/scratch_dossiers/` read via gh api only (not cloned).
## Sources, licence, applicability
| Source | What it is | Licence | Applies to Skate 3? |
|---|---|---|---|
| BP-Decomp_Workflow `references/DecFIGS/dwarfdump/SDKs/...` | DWARF outlines (no bodies) from an internal Burnout PS3 ELF: rwaudiocore **3.03.00**, csis **2.12.00**, Snd9 AEMS (`aemsdef.h`, `sndcmn.h`, `saemsi.h`). Enums, consts, struct field lists, GUIDs. | MIT (repo) — but the content is EA's | High: same rwaudiocore line; FourCCs identical to ours |
| BP-Decomp_Workflow `progress/scratch_dossiers/*` | Machine-assisted decode dossiers of Burnout X360 ARTIST XEX: SndPlayer1 bodies (350 KB), AEMS/RWAC factory, plug-in descriptor rodata dumps | MIT | High for SndPlayer1/descriptors |
| BP-Decomp_Workflow `tools/assets/bundles/aems_x360_port.py` | X360→x64 ABKC bank porter; per-opcode block sizes + op-27 layout validation | MIT | High (same ABKC bytecode banks) |
| BP-Decomp_Workflow `IDA Files/ProStreet08Milestone.pdb` (referenced; 62 MB, not fetched) | Full PDB of NFS ProStreet X360 Oct-2007 (EA Black Box) — authoritative rwaudiocore types | in repo, EA content | High (Black Box, same era) — fetch only if a layout question is unanswerable otherwise |
| b5-decomp `vendor/renderware/src/rw/audio/core/*` | Reconstructed C++ of rwaudiocore: Iir2 + LowPass/HighPass/BandPass/Shelf/PeakingIir2, Gain, GainFader, Resample, Rechannel, Pan2D1, Pan2D, Send, SubMix, Mixer, MixKernels, Voice, SndPlayer1, Dac, Limiter1, ReverbModel1, Butterworth | **none** → reference only | High; but "PowerPC asm authoritative" claims are unverified, partially stubbed (Pan2D1 ComputeInteriorTerm = 0) |
| b5-decomp `src/SDKs/EATech/include/snd/sndaems.cpp` + `sndaemssampleplayer.cpp` | Full AEMS bytecode interpreter (all 40 opcodes) + AemsStandardSamplePlayer (player input → plug-in attributes) | none | Very high — same 40-op bytecode as Skate's ABKC |
| b5-decomp `src/SDKs/EATech/include/NFSMix/*` | NFSMixMap/NFSMixShape (EA "NFS mix" dynamic mixer; record layouts from the ProStreet PDB) | none | Medium: related lineage of Skate's MixMap (PR #1 already ports MixMapSK8.mxb at 99.999 %) |
| dbalatoni13/nfsmw `src/Speed/Indep/Libs/snd/9/` | **Matching** (1:1) decomp of NFS Most Wanted 2005 Snd9 incl. `saems.c` (AEMS interpreter) and DWARF-named `aemsdef.h` (every opcode's SETTINGS/STATE struct with offsets) | CC0 | High for names/semantics; older (2005, GC/PS2 gen) — Skate's op-27 sample selection format differs |
| mitsevox/tw2004 `docs/reference-builds/tw07-ps3/cu/*.txt` | Per-CU DWARF function listings from Tiger Woods 07 PS3 (rwaudiocore **2.08.01**): function names, source line, PS3 address, size, locals, inlines. No bodies. Covers gain, gainfader, highpassiir2, lowpassiir2, iir2, resample, rechannelgainwrite, pan2d1, matrixpanner, send, smixer, sndplayer1, plugin, pluginregistry, voice, snd9, sndi_sin/cos | CC0 | Medium: names/local-variable hints only, older version |
| Kxboo/EA-Playground `_bevy/src/aems.rs`, `_bevy/docs/*` | Rust/Bevy reimplementation of EA Playground (Wii). ABKC there = **native PowerPC code** per class + fixups (not bytecode) | none | Low: confirms ABKC header field meanings; different flavour |
| Frostbite `LowPassIir2NodeData` hits (VU-Docs etc.) | Frostbite engine audio nodes — unrelated | — | No |
Version evidence: Skate 3 graph FourCCs (`SnP1 → Rch0 → Rsp0 → Gai0 → LI20 → Sen0`, `ems-emitters-re.md`)
are byte-identical to Burnout X360's registered descriptors (the plug-in descriptor layout dossier: AiW0 BI20 Dac0 Gai0 GaF0
HI20 HPB0 HS20 Li10 LI20 LPB0 LS20 Pn20 Pn21 Pau0 PI20 Rch0 Rsp0 RM10 Sen0 SnP1 Sub0). Skate 3's exact rwaudiocore
version is unknown (Burnout PS3 = 3.03.00, TW07 = 2.08.01, a Feb-2007 leak referenced by b5 = 2.11.00, not public).
AEMS timer formula reproduces our measured 6-block tick exactly (see 1 below) → same Snd9 AEMS runtime.
## Top facts (verify against Skate's XEX before relying on any of them)
### AEMS evaluator (b5 sndaems.cpp; names from nfsmw aemsdef.h)
1. Tick: timer callback per mixer frame (period δ = 256/48000 s default). Interval = max(1, ceil((1/rate)/δ) − 1)
frames; step ms = interval·δ·1000. Burnout rate = 41.6 Hz → 4 frames; Skate's rate 30 → ceil(6.25)−1 = **6**
frames = 32.0 ms — exactly our measured value. All instances on the update list run their program each tick.
2. Program record: u8 op, u8 assignment count, 2 pad, N × {s32 src, s32 dst}, s32 data advance; op 0xFF ends;
op ≥ 40 aborts. src −1 = "the op result". Matches our grammar note.
3. Opcode names 0–39: ClassDestructor, ClassData, ClassDataTail / GlobalVariable (MW), Create, Destroy,
CallFunction, Counter, Random, RandomShuffle, RandomWeighted, RangeTrigger, DelayTrigger, StateGenerator,
Merge, Envelope, Table, DelayLine, **Mux (17)**, **Demux (18)**, **Minimum n-ary (19)**, **Maximum n-ary (20)**,
Scale, Add, Subtract, Multiply, Divide, Modulo, Player (27), Oscillator, Ramp, AddMaximum (capped sum),
SubtractMinimum, MultiplyMaximum, Minimum2, Maximum2, Scale2, Add2, Function (37), **ControlClass (38)**,
SetGlobalVariable (39). → our "unknown/not ported" 19/20/38 are min/max of a u8-counted list and Csis class
create/update/release (returns class ref count); our "stack top/push" for 17/18 are really Mux/Demux.
4. Semantics worth matching: Counter returns the input when inside [min,max], else steps & wraps when enabled;
RangeTrigger = enter range fires 1 once, re-arms only inside a separate reset range; DelayTrigger counts ms by
step, −1 idle; Envelope controls 1 start / 2 hold / 3 release (jump to release point), linear segments, value
zero after last point; Table clamps, nearest when scale == 1.0 else linear between entries; DelayLine length =
round(delay_ms/step) capped to capacity−1; Ramp delta = (target−cur)·step/duration/4096 × per-tick scale input.
5. Random: one global 6-word add-with-carry generator, fixed seeds F22D0E56 883126E9 C624DD2F 0702C49C 9E353F7D
6FDF3B64 (same in Burnout X360 and XB1). Deterministic from boot — our "not reproducible run to run" is because
other banks consume draws first, not because it's seeded from time (worth re-checking in Skate).
6. Rounding: b5 uses round-half-away; nfsmw (1:1 matching) uses SNDI_ftoifast and a 1024-step sine table
(iSNDsin) for the oscillator — b5's std::sin is a host approximation. Prefer the table (our note: Skate table
0x82FD36B8).
7. Player (op 27) block = nfsmw PLAYERSETTINGS: +0 bank, +4 sample group, +8 handle, +0xC prev control[2], +0xE input
count, +0xF update-outputs flag, +0x10 sample type; state +0x14 sample select, +0x18 play control (0 stop, 1 play,
2 pause). Outputs: timeleft/timecurr then up to 8 more. Inputs are 12-byte {selector, previous, value} records
pushed only when changed. Skate/Burnout selection entry: u16 sample index (0xFFFF none), u8 level, 6 × azimuth
bytes (<<8), s32 stream offset at +8 (MW's 12-byte SAMPLEENTRY {type, priority, union} is the older format).
8. ABKC header (DWARF names, nfsmw ModuleBank, 0x5C bytes): id, ver, veraimex major/minor/patch, **+8 platform
(5 = Xenon)**, +0xA nummodules, +0xC debugcrc, +0x10 uniqueid, +0x14 totalsize, +0x18 residentsize,
+0x1C moduleoffset, +0x20/+0x24 sfx bank offset/padded size, +0x28/+0x2C midi bank, +0x30 funcfixupoffset
(native-code banks only; must be empty for bytecode), +0x34 staticdatafixupoffset, +0x38 interfaceOffset (Csis
bindings: kind 0 global var, 1 class, else function), +0x3C.. runtime. → our "+0x08 variant 0x0503…" field
should be re-read as platform byte + pad + nummodules.
9. Per-opcode data block sizes (aems_x360_port.py): 0→20, 3→4, 4→16, 6→24, 7→16, 10→24, 11→16, 15→16, 23/24/25→8,
28→16, 29→28, 31→12, 33/34→8, 35→12, 36→8; op 27 = 28 + 12·inputs + (8 if updateoutputs) + (32 if outputs).
### Player inputs → voice graph (b5 sndaemssampleplayer.cpp, aemsdef PLAYER_INPUT_*)
10. Selectors: 0 PITCHMULT, 1 TIMEMULT (ignored), 2 VOL, 3 AZIMUTH, 4 ELEVATION (ignored), 5 FXWET0, 6 LOWPASS,
7 HIGHPASS, 8 DRYLEVEL, 9–136 user. Clamps: 0/6/7 → 0..65535; 2/5/8 → 0..32767.
Pitch → Resample attr 0 = v/4096. Vol/dry/wet = v·(1/32767) (constant 0.000030518509); Gain attr = vol·dry;
Send attr = vol·wet. LPF/HPF attr = **raw value in Hz**. Azimuth → each Pan2D1 attr 0 =
((legacy table entry + v) & 0xFFFF)·180/32768 degrees, per channel voice (max 5 panner voices).
Pause zeroes nothing in the snippet seen; Unpause re-applies gain/send/pitch.
### Voice graph plug-ins (b5 rwaudiocore reconstruction + DecFIGS DWARF)
11. Frame = MIXER_FRAME_SIZE 256 samples; every gain change de-clicks over GAIN_DECLICK_FRAME_SIZE 64 samples
(linear from old to new over samples 0..63, then flat for 64..255). Same rule for Send and Pan2D1 ramps.
12. Biquad (Iir2, direct form I): y = b0x + b1x1 + b2x2 + 1e-18 − a1y1 − a2y2; coefficient order {a1,a2,b0,b1,b2}.
LowPassIir2/HighPassIir2 are RBJ cookbook with **Q = 1** (alpha = sin(ω)/2), ω = 2π·fc/fs, sin rounded to f32.
LPF: bypass + state clear when ω ≥ 3.1384511 (≈ 23.98 kHz at 48 k → 25000 = open), ω floored at 0.0031416.
HPF: bypass when ω ≤ 0.0031416 (fc ≈ 24 Hz; default 0 = off), ω capped at 3.1384511. Coefficients recomputed
only when ω changes. Both set decay 450 samples.
13. Resample: 2-tap linear interpolation, 16.16 fixed-point phase, increment = round(ratio·65536), ratio =
(sourceRate/outputRate)·pitch clamped to 4× (MAX_RESAMPLE_RATIO). Fraction scale constant is 1.5258e-5
(0x377FFC9C), not exactly 1/65536. 6 floats history per channel. PreProcess multiplies a context
"pitch/rate" accumulator by the clamped ratio (b5 calls it a gain; DWARF Mixer has mTotalPitch/ScalePitch —
probably that). On a source-rate change, the frame passes through unprocessed once.
14. Rechannel: only acts when in ≠ out channels. Kernel table: mono→5.1 = centre only; stereo→5.1 = FL/FR;
5.1→stereo L = 0.707·C + FL + BL (LFE dropped); quad→stereo L = FL+BL. rwaudio 5.1 order:
**0 FL, 1 C, 2 FR, 3 BL, 4 BR, 5 LFE** (DecFIGS channel.h). MAX_CHANNELS 6.
15. Pan2D1 (GUID 'Pn21', 7 attributes: azimuth°, radius, width, spread°, focus, level, centre level; ctor params
front angle 45°, rear 135°, normalisation mode 2 = 1/sqrt(N emitters)). Speakers: dir0 +45 (FL), dir1 0° (C),
dir2 −45 (FR), dir3 +135 (BL), dir4 −135 (BR); azimuth is negated (positive degrees pans right). Pairwise
inverse 2×2 matrices (VBAP style), per-source 5-vector normalised to unit power × norm × level; stereo
out = constant-power pair from target y. 6-ch input → 5 emitters + LFE copy at centre-level gain.
**b5's ComputeInteriorTerm is a stub** — the radius<1/spread interior term is still unknown.
16. Send: accumulates (MixWithGainRamp) into a SubMix; gain attr 0, de-click ramp; connect by pointer or by SubMix
name. Gain: attr 0 linear, init 1.0.
17. Plug-in types: 0 INPUT_SOURCE (SndPlayer1), 1 INPUT_RECHANNEL, 2 INPUT_RESAMPLE, 3 INPUT_PROCESS, 4 STANDARD.
Descriptor 52 bytes: name, GetSize, CreateInstance, PreProcess, Process, channel maps, param desc, event desc,
tool desc, list node, guid, type, #ctor params, #attributes, #events, variable in/out flags, registry index.
Only Resample, Rechannel and SndPlayer1 have a live PreProcess (pull-model sample count negotiation).
18. Voice: ExpelReason {not, error, cpu limit, playback over}; decay samples accumulate per stage (filters add 450)
so a released voice keeps running for the filters' tail before expel. PRIORITY_PERMANENT exists.
19. SndPlayer1: header bitfields version 4, codec 4, channels 6, sample rate 18, play type 2, loop flag 1,
num samples 29, loop start 32; chunk header {bytes, samples}; PlayParams {startTime f64, streamFileOffset f64,
path, pRamData, streamPoolGuid, expelMode, requestHandle}; MAX_DECODERFEEDS 20; declick on start/stop.
350 KB of decoded bodies in `sndplayer1_bodies_decode.md` if we need exact start/loop/stream behaviour.
20. Util constants exist for dB/cents conversion (DECIBEL_MIN, CENTS_MIN, PITCHLINEARTOCENTSCONST) but values are
not in the DWARF; NFSMixShape gives 2^(cents/1200) pitch and Q15↔hundredths-dB tables (MixMap side).
## Uncertainties
- b5-decomp is an unverified reconstruction ("asm authoritative" but unverified by us); parts are host adaptations
(x64/XB1-guided), some bodies stubbed. nfsmw is a byte-matching decomp but 2005-era. Always confirm against
Skate's XEX (recomp build) before treating a detail as retail truth (project rule: retail parity).
- Skate 3 rwaudiocore version unknown; possible small differences (e.g. Resample kernel taps, Pan2D1 interior term).
- MixMap: Skate uses "PathFinder 5.03 MixMap" (PR #1); NFSMixMap is a sibling, not proven identical.
- ProStreet PDB not fetched (62 MB). Fetch only for a specific unanswered layout question.
## Recommendations for our port (summary)
- Evaluator: adopt the 40 official op names and nfsmw state-struct field names as our Rust names; implement
19/20 (n-ary min/max), 17/18 (mux/demux) and 38 (control class → our Csis bridge) per the semantics above;
the timer formula with rate 30; the 6-word global RNG with the fixed seeds (one shared generator across all
instances, drawn in list order).
- Voice graph: implement stages as 256-sample block processors with 64-sample linear de-click on every gain change;
RBJ Q=1 biquads with the exact bypass thresholds; 16.16 linear resampler with the 0x377FFC9C fraction constant
and 4× clamp; rwaudio 5.1 channel order internally, reorder only at the device.
- Pan2D1: port SpeakerConfig/EmitterConfig/ComputeLevels/Ramp as described; measure the interior term from the
Skate XEX (b5 lacks it) — for mono point sources at radius 1 the perimeter path alone may suffice.
- Validate each stage with short scripted recomp runs, comparing per-block output.
@@ -0,0 +1,688 @@
# World audio hook-in: design spec (2026-10-03)
Goal (user, 2026-10-03): "We will want to build out everything that can then easily be hooked to by the game
engine once they are added", and then "no make it this PRs goal". The world audio (traffic, peds, ped speech, NPC
skaters' boards) becomes complete and trivially hookable by future engine systems (peds, vehicles, AI skaters) that
the engine does not have yet. It stays in **PR #32** (branch `gameplay/audio`). Implementation comes after the
current code-fix pass (PR #32 readiness plan, step 1), because the same files are touched.
Inputs: `world-traffic-audio.md`, `world-ped-audio.md`, `world-speech.md`, `world-npc-skater-audio.md`,
`npc-livingworld-re.md`, doc 11 "World sound sources" / "NPC skaters' board sounds" / "NPC skaters' second grain bed".
Code read: `crates/skate-audio/src/world/*`, `game_audio/{world_sources,npc_skaters,emitters,native,skate_events}.rs`,
`tools/asset_pipeline/world_audio.py`.
## 1. Prior work (checked 2026-10-03)
| where | what | relevance |
|---|---|---|
| Upstream SK8-ENGINE PRs (#1–#35), issues (#2–#34), branches (`main`, `audio-integration` = main, `skyline-driving-update` merged) | **No ped / traffic / AI-skater / NPC system, open or closed, and no issue asking for one.** `gh search` "npc", "traffic" → only our #32. | Nothing to fit, nothing to duplicate. The API defines the contract. |
| Upstream `skate_data::skate_map::Route { rail, skaters, speed, spacing }` (`.skate` maps) and `skate_world.rs:367` "SKATE LIMITATION: N NPC routes parsed; supplied game has no NPC controller." | The custom map format already carries **AI-skater routes** (a rail, a skater count, speed and spacing). Nothing consumes them. | The likeliest first AI-skater system is "skaters following `map.routes`". The NPC-skater API must work for a route follower. The dev publisher can use the same routes. |
| Upstream `crates/skate-dynamics` (Rapier island: bodies, kinematic proxies, joints) and `mods/Skyline_Drive_Mod` (a Lua-composed, player-driven car with its own WAV engine sound through `sdk.audio`) | Player-driven vehicles only, built in Lua. No traffic. | Traffic cars would likely be kinematic proxies on skate-dynamics. A mod car *could* publish as a traffic vehicle (optional, not retail: question Q5). |
| Upstream PR #15 (laaledesiempre, open) "Dynamic props" | DMO props as live instances with rigid bodies | Adjacent only: DMO impacts are a different audio path (not in this spec). |
| Upstream PR #1 "Mx/audio engine vehicles" (andrewnakas, open) | The diff holds only `skate-audio-core` (a player audio engine). No vehicle files despite the title. | None. |
| Fork `andrewnakas/skate-3-rust-engine` `mx/vehicle` (unmerged, 23 behind) | A **Vehicle SDK**: crate `skate-vehicles` (Rapier 0.35.3), `skate-game/src/modding/vehicles/{mod,audio,engine_sound,network}.rs`, `docs/vehicle-sdk.md`. Lua `sdk.vehicle.spawn/enter/control/tune/read/exit/reset/remove`. Keys belong to the calling mod; at most 8 vehicles per mod, 32 in all; vehicles are removed on mod disable / reload. `read` returns position, rotation, heading, signed speed, phase. Its engine sound "follows speed/throttle" with its own samples. | **Shape to copy for the Lua angle**: keys owned per mod, per-mod / global limits, removal on disable. A drivable vehicle publishes exactly what `VehicleState` needs (position, heading, speed, throttle → `load`). No licence upstream or in the fork: describe, don't copy. |
| Other forks (michaelmoskie, patchedsoul, Vorobey1234, jdpc-dev, samwhosung, d4tect, …) | Only `main` / `macos` / `audio-integration` / `skyline-driving-update` / `rtx-path-tracing` branches | Nothing. |
| `chasmlol/skate3clone` (Rust/Bevy), `chasmlol/2010-rust-rewrite-mashup` | No ped / traffic / NPC files in the tree | Nothing. |
| `mchughalex/skate3recomp` (+ ports) | The retail code we read (no licence: run / read only) | Our oracle: recorded recomp sessions. |
| Our own (the world / NPC audio plan, the PR #32 readiness plan, doc 11, the notes above) | Foundation built (inert hosts). The readiness plan already lists this work: engine API, complete sources, map-change fix, dev publisher, "hooking up world audio" doc. | This spec. |
**Conclusion:** there is no engine-side representation to fit. The API should be ECS-native (components on the
engine's own entities) so that whatever system arrives (route-following AI skaters, a census / navmesh ped system,
traffic on road graphs, Lua mods) only adds components. It must not need to know about AEMS.
## 2. Inventory
Legend: **done** = ported and harness-tested headless; **partial**; **missing**.
| source | retail object(s) | state | what is missing |
|---|---|---|---|
| Traffic engine | SFXObj_TrafficEngine (4.0), `TRAFFIC_CAR`, `C00`–`C08` by patch | **done**: RPM model (gears, wobble, slew, rise / fall), the patch override, the front / rear split, Doppler through MixMap B0–B2 | **settled 2026-10-03 (§7.3 G1), to port:** the model → record table (entity → vehicle spec → `aud_traffic_engine`), `+112` = heading, `+144` = acceleration m/s², the `TrafficCarPhysics.in0` writer (slewed relative speed / 35, opens the A11 gate). Still open: the 3DObjPos 4.1 / 4.2 / 4.3 binding (front / rear points at ±1 m found, R+80 / R+96) |
| Horns | SFXObj_TrafficHorn, `TRAFFIC_HORN` | **done**: posted once, variant `rand()%9`, kind = horn state 1–5, patch 0 never honks | the AI's honk decision (engine side: blocked lane, the player in the way; `IsBeingHonkedAt` ped+5896) |
| Car alarms | TrafficHorn state 6, `c_car_alarm` (`car_alarms`) | **done** | the trigger (car hit) and the 8 s stop (vehicle +3716) are AI behaviour → the bridge offers an `Alarm` event with retail's 8 s (§3.4) |
| Skids | SFXObj_TrafficSkids, `TRAFFIC_SKID` | **done**: plays on the skid flag | the AI's skid flag (hard braking / swerve) |
| Woosh | SFXObj_TrafficWoosh (4.3) | n/a: no MixMap E record writes its outputs, so retail leaves it unused | nothing |
| Doppler / drive-by | MixMap Traffic B lookups | **done** (ObjPos rates) | velocity must be continuous: a teleporting proxy needs `AudioVelocity` (§3.2) |
| Traffic lights | `sub_826B1540` phases 7 / 1 / 0.5 / 0.4 s | no audio object found in the traces | **ask the user** whether retail has crossing / light sounds (Q7). Probably none. |
| Instance assignment (4 traffic, 15 peds) | retail's SFX object manager | **partial**: nearest-N (`owners::Pool`) | **confirmed 2026-10-03 (§7.3):** peds = the first 15 of a nearest-first list cut at 50 m (manager `sub_824F2890`); traffic holders were the 4 nearest (horizontal) in 311 / 311 holder-seconds, list cut at 40 m (horizontal). Add the 50 m / 40 m cuts |
| Ped footsteps | PedestrianSFX (5.1): `livingword_footstep` × 2 + `sk8_foley` steps | **done**: 97 % of retail's `fstep_livingworld` starts fall in our sample groups | **settled 2026-10-03 (§7.3 G2), to port:** per model (= voice id) shoe class / kind / type / variant / far threshold from `aud_characteristics`; `+68` = the 3 nearest peds. Still open: `[obj+28]+144` (dynamic 1–5, not a model weight), `sub_824D8E60` flag, the 3 % of slots (w9 / w11 / jump / collision combinations) |
| Ped speech request | PedestrianSpeech (5.0) | **done**: value change → request, 7 / 8 remap, near / far flag. **Not done**: value 49 (`sub_824D9C70`), the value 29 photographer timer | |
| Speech manager + library | `TheSpeechSystem`, `.evt` rules | **done** (`speech_manager.rs`, `speech_rules.rs`); the recomp's take reads agree 57 / 57 clips, 15 / 15 sequences | **not wired in the host**: `world_sources` only logs `AUDIO_WORLD speech …`. The library's request queue (16 slots, 8 streams, priority, timeout) and the interrupt by priority (`sub_824A73F0`) are not ported. **Level / pan mapping unknown** (which PedestrianSpeech output sets the stream's gain). `+148` / `+156` (the near / far pair) = distance to the listener / the model's far threshold (20 m for peds; §7.3 G2). Takes decode only opt-in (`SKATE_SETUP_SPEECH=1`, about 2.4 GB of PCM for the free-roam events). |
| PedBodyFall | 5.2 | **missing** (object not decoded) | decode the object; knock-down trigger from the ped system |
| Tazer | 5.3, `Tazer.abk` `c_tazer` / `c_tazer_grn_play` | **missing**: needs AEMS op 38 ControlClass in the evaluator | op 38; the tazer state from the ped (guards, warn + taze) |
| Ped hand props, ATM, vending, plugins | plugin objects | not looked at | research, later |
| Maincast / cameraman / announcer speech (pro lines around the player, the cameraman's "own the spot") | same library, other managers (`sub_824AC560`) | the `.evt` files parse; not indexed or hosted | outside the ped API; later |
| NPC skater board | Player slot instance 1 (`CSTATEMGR_Player`, the first NPC within 30 m, held until ≥ 30 m) | **done**: `Slots`, every non-local branch of the board components, the soft word, the second grain bed, collisions into the shared manager | the NPC instance's **Wheels spin-down, Tricks, Treatment, OffBoard footsteps, Clothing, board slide** (all per instance in retail); the released instance's grain players; the manager gate (`[0x830670B8+20]` == 5, `sys+560` == 1) |
| NPC skater speech | `sub_824BF5F8` bail grunt (message 8206 / 115 to its `CSTATE_SkaterSpeech`), SkaterSpeech manager `sub_824F7D10` (one record per skater, no distance gate) | **missing** | part of the speech stage |
| NPC skater audio state | `AudioState`, filled like the local player's | **partial**: the host takes a ready `AudioState` | `skate_events::audio_state` is private and tied to the single `GamePhysics` / `SkaterRuntime` resources plus the `Seen` memory: it must become a per-skater function (§3.5) |
| Event PA / crowds / music zones (.ems types 6 / 7 / 4) | Crowd / Speaker slots | not ported | events, challenges and a music player: outside this API |
| Process / update order | retail: process before the tick, update after | **partial**: both run after `native::mixmap_frame`, so the inputs are one console frame old | move into `mixmap_frame` (§3.8) |
### 2.1 The map-change bug (confirmed by reading)
`emitters.rs` (`world_emitters`, on a new `(map.name, map.generation)`) calls `Native::unload_map_banks`
(`native.rs:406`). That unloads every bank except `emitter_utility`, the seams utility bank and the player
(`player_audio::BANKS` / `OPTIONAL_BANKS`). It therefore unloads **all 13 world banks** (`TRAFFIC_BANKS`,
`PED_BANKS`). `Runtime::unload_bank` → `Evaluator::unload_bank` destroys every instance of the bank and drops its
class constructors. The posted **nodes stay in `eval.nodes` (refcount 1) with no instances**: node ids are never
reused (`next_node` counts up), so nothing is corrupted. But:
- `WorldHost.nodes` still maps `(owner, slot)` to those dead nodes. `WorldHost.vehicles` / `ped_objects` still hold
each owner's `Vehicle` / `PedSfx` with "packet posted". `Engine::process` posts only when it has no packet yet, so
**an owner that keeps its instance across the change is silent for the rest of its life**: every redeliver goes to a
node with no instances. This happens whenever ids survive the change, such as a counter restarting at 0, or a
reused Bevy `Entity`'s bits.
- The `Pool`s keep their holders and the 3DObjPos blocks stay active until the old ids disappear. `host.banks` stays
`Some(_)`, which is harmless because `ensure_bank` reloads the banks every frame with owners.
- Dead nodes leak until the release that never comes (small).
- `NpcHost` is mostly safe: its posts go into the player banks, which are kept. The old map's skaters vanish, and
`Slots::assign` releases them. Its `beds` / `objects` / `nodes` are still per old id, and a reused id would carry
over held state, so it is reset for the same reason.
- `Pool::clear` and `Slots::clear` exist but nothing calls them.
**Fix (Phase 0):** give `Native` a `map_epoch: u64`, incremented by `unload_map_banks`, which already bumps
`prefetch.clears()` (the same idea). `WorldHost` and `NpcHost` remember the epoch they last ran in. On a mismatch,
**before** the frame's work:
1. Release every held node (`rt.release`; harmless on a dead node, and it frees the leak).
2. `Pool::clear` / `Slots::clear`, and deactivate each held instance's 3DObjPos blocks.
3. Drop the per-owner objects and the NPC beds (`grain_bed::stop_npc`).
4. Release the ped Splice steps.
5. Reset `banks = None` and `last_camera = None`.
The banks reload at the next owner, through the prefetch when `expected` is set.
Better still, release **before** the unload: `world_emitters` runs first, so the host would need a hook. The epoch
reset after the fact is correct because releasing a dead node is safe. It is also simpler and keeps the
emitter / world order free. Regression test: publish a vehicle and a ped, step, `unload_map_banks`, keep publishing
the same ids, step → the engine posts again, and voices render again (headless, data-gated like
`world_banks_are_prefetched…`). Also check the reset on `SKATE_AEMS_WORLD=0` and with zero owners (no work).
Also note: the hosts compute `dt` from `m.ticks - last_tick` (`CONSOLE_DT × evaluations`). The current fix pass
reworks the host clock (pr32-ready step 1: host clock = physics steps), so Phase 0 re-checks this arithmetic after
that lands. A MixMap rebuilt with `ticks = 0` would underflow `m.ticks - host.last_tick` (u64); reset `last_tick` with
the epoch, and use `saturating_sub`.
## 3. The hook-in surface
### 3.1 Principles
- **Engine systems only add components to their own entities** and send a few events. They never see `WorldOwners`,
`NpcSkaters`, NodeIds, MixMap keys, packets or banks. The current resources stay as the internal seam. A bridge
system fills them from the components each frame. This keeps the tested hosts unchanged.
- **Retail semantics are kept:** values the retail objects read as *state* (horn state, skid flag, speech value) are
per-frame component fields. Events are only conveniences that write the state for a retail-defined time (alarm
8 s) or once (speech value).
- **The audio decides who is audible.** The engine publishes everything it simulates. The fixed retail instance
limits (4 traffic, 15 peds, 1 NPC skater within 30 m) are applied by the hosts. The engine can read back which
objects hold an instance (`WorldAudioInstance`) to skip work (§3.6).
- **Lifetime = the entity.** Spawn = insert the component; despawn or remove = release, with no extra call. Ids are
`Entity::to_bits()` (generation included, so a reused index is a new owner).
- Everything stays inert when no component exists (the current "returns at once" path). `SKATE_AEMS_WORLD=0` /
`SKATE_AEMS_NPC_SKATERS=0` keep working.
### 3.2 Module and components
New public module `crates/skate-game/src/world_audio.rs` (engine-facing; re-exports only plain types). The bridge
lives in `game_audio/world_bridge.rs`. `skate-audio` stays engine independent: the components are thin wrappers that
convert into `VehicleState` / `PedState` / `NpcSkaterAudioState`.
Position, velocity and heading come from the entity's `GlobalTransform` by default. The bridge derives velocity from
the transform delta over the frame's dt. An optional `AudioVelocity(Vec3)` overrides it (needed for teleports or
kinematic proxies, because Doppler and the ObjPos rates read it).
```rust
// ---- vehicles (traffic) ----
#[derive(Component)]
pub struct TrafficAudio {
/// The vehicle model's `aud_traffic_engine` record name ("c04_taxi01", …; `world_tuning.traffic_engine`).
/// Unknown → "default" (patch 2 = silent; logged once).
pub engine: String,
/// `+148` speed (m/s, ≥ 0). `None` = |velocity|.
pub speed: Option<f32>,
/// `+144` × 3000 → engine w15 / skid w6 = the driver's signed acceleration in m/s² (§7.3 G1: −15.6 hard
/// stop … +3 pulling away; 0 cruising). `None` = the bridge derives it from the speed change.
pub load: Option<f32>,
/// `+156`.
pub horn: HornState, // None | Honk(1..=5) | Alarm
/// `+160`.
pub skidding: bool,
}
// Heading (`+112`) = GlobalTransform forward: confirmed (§7.3 G1, the world matrix's forward row), no override.
// `engine` can be filled from the vehicle model through the setup-exported entity → spec → record table (§7.3 G1).
// ---- pedestrians ----
#[derive(Component)]
pub struct PedAudio {
/// Speech voice id 41–96 (the clip names' voice; `speaker_bits` → type / variant bits). None = mute.
pub voice: Option<u32>,
/// `+132` shoe class 1..=5 (1 = silent in `fstep_livingworld`); `+96 == 64` (security type). Both follow from
/// `voice` through `aud_characteristics` (§7.3 G2), so they default from the voice. `[obj+28]+144` ("weight")
/// is dynamic per ped (1 beyond ~12 m, 2–5 near), meaning open; default 1.
pub shoe_class: u8, pub weight: u8, pub close_range: bool,
/// `+73/+74`: the walk animation's foot plants (A, B).
pub feet_down: [bool; 2],
/// `+140/+144`: material under each foot (AudioSurfaceMap material; `None` → the bridge looks the
/// ground up under the foot, or 143 = none).
pub foot_materials: Option<[u32; 2]>,
/// `+68`: None = retail's rule (§7.3 G2): on for the 3 nearest peds of the nearest-first list (≤ 50 m).
pub footsteps_on: Option<bool>,
/// `+148` / `+156` near / far pair (§7.3 G2): distance to the listener / the model's far threshold (20 m).
/// None = the bridge computes both (retail).
pub speech_distance: Option<(f32, f32)>,
/// `+136`: written by `PedSpeechEvent` (below); the state the footstep packets also read (jump 4/5,
/// collision 6/7). Engine systems normally leave it to the event.
pub speech_value: i32,
}
// ---- NPC (AI) skaters ----
#[derive(Component)]
pub struct NpcSkaterAudio {
/// The skater list position (retail iterates its list in order; spawn order is fine). Set by the bridge from a
/// spawn counter when 0.
pub list_order: u32,
/// The full audio state, filled like the local player's. Only needed while within `AUDIO_RADIUS` + margin
/// (see `WorldAudioInstance`).
pub state: Option<skate_audio::player::AudioState>,
}
// ---- read back (inserted / removed by the bridge) ----
#[derive(Component)]
pub struct WorldAudioInstance { pub slot: WorldAudioSlot, pub instance: u32 } // Traffic | Ped | PlayerSlot
// ---- optional ----
#[derive(Component)] pub struct AudioVelocity(pub Vec3);
```
### 3.3 Resources
- `LivingWorldAudio { expected: bool }`: the living world will publish on this map, so the world banks are
prefetched (maps to `WorldOwners::expected`). A system sets it at map load / spawner start and clears it when the
world stops. When not set, the bridge sets `expected` itself as soon as any component exists (no prefetch, the old
game-thread load).
- `SpeechClock` is not needed: the manager clock is the audio host's time.
- `WorldAudioStats` (debug, read only): published counts, holders per pool, speech requests and refusals. For the
overlay and logs.
### 3.4 Events (Bevy `Message`s in 0.18)
| event | effect | retail basis |
|---|---|---|
| `PedSpeechEvent { ped: Entity, value: SpeechValue }` | sets `PedAudio.speech_value` → PedestrianSpeech sees the change → manager → line | `SendSpeechEvent speechvalue=N` on state entry (`speech::SPEECH_VALUES`: 10 CollisionNearbyReaction, 11 warn, 20 Flee, 23 LongCheer, 25 StopCheer, …). `SpeechValue` is a typed enum with `Raw(i32)`. |
| `VehicleHorn { vehicle, kind: 1..=5, seconds }` | holds `horn = Honk(kind)` for `seconds`, then back to `None` | horn state `+156`; the duration is the AI's (not retail data: the caller chooses) |
| `VehicleAlarm { vehicle }` | `horn = Alarm` for **8 s** | car alarm stops after 8 s (`npc-livingworld-re.md` §6, vehicle +3716) |
| `PedKnockDown { ped }` | reserved for PedBodyFall (Phase 3) | not decoded |
| `PedTazer { ped, phase }` | reserved for Tazer (needs op 38) | |
| `NpcSkaterBail { skater }` | the NPC bail grunt (SkaterSpeech message 8206 / 115) | `sub_824BF5F8`, Phase 3 |
Skater-vs-car / skater-vs-ped impacts are **not** a world-audio event. They are the local player's collision
manager's (ported). What retail posts for a car contact (its material / class) is open (§6).
### 3.5 NPC skater state: a reusable builder
`skate_events::audio_state(&GamePhysics, &SkaterRuntime, AudioFrame)` is the only correct way to fill an
`AudioState` (retail bridge semantics: air time, grind family / material latch, the deck and region rings, the step
code, push plant). Refactor (behaviour-identical for the local player; prove it with the existing state-log
replays, per the regression check's identical-behaviour proof) into:
```rust
pub struct SkaterAudioMemory { /* the per-skater part of `Seen`: air_time, grind_family, grind_material,
jump_velocity, grind_impact, deck/region rings, step_code, push / plant edges */ }
pub fn skater_audio_state(physics: &GamePhysics, skater: &SkaterRuntime, memory: &mut SkaterAudioMemory, dt: f32)
-> AudioState;
```
An AI-skater system that runs its own `GamePhysics` / `SkaterRuntime` per skater (the obvious way to make AI
skaters skate like the player) calls this once per frame, only for skaters near the camera. A skater that is not
simulated with the player's physics (a cheap route follower) fills `AudioState` itself; `AudioState::rolling(...)`
gives a documented minimal fill (speed, 4 wheels down, material, board / COM positions and velocities) for that case.
### 3.6 LOD and limits (all retail, applied by the hosts)
| pool | instances | rule |
|---|---|---|
| Traffic (MixMap slot 4) | 4 | nearest 4 by horizontal distance within the 40 m vehicle-audio list (§7.3 G1: 311 / 311; the manager itself not read). The census culls at 110 m, so the engine may stop publishing beyond 110 m. |
| Pedestrian (slot 5) | 15 | the first 15 of the nearest-first ped list cut at 50 m (§7.3 G2, manager `sub_824F2890`); footsteps for the first 3 |
| Player slot instance 1 | 1 NPC skater | the first in `list_order` within **30 m** of the camera while free; held until ≥ 30 m (`skaters::Slots`, retail) |
| Speech | living-world channel not exclusive (the recomp overlaps lines); the queue is 16 requests / 8 streams | Phase 3 |
`WorldAudioInstance` on the holders lets an engine system skip per-frame audio work (foot material lookups, the
NPC `AudioState`) for non-holders. For NPC skaters the engine fills `state` for every skater within 35 m, so that
a claim sees a state in the same frame (the claim happens in the same pass).
### 3.7 Map changes and ownership
- The engine despawns its objects on map change; the bridge sees them vanish and the hosts release them.
- Independently, the hosts reset on `Native::map_epoch` (§2.1), so stale state never survives a bank unload, even if
an entity survives (e.g. a persistent test publisher).
- `LivingWorldAudio.expected` should be re-set by the system for the new map; the epoch clears the prefetch.
- Mods (§3.9): a mod's objects are despawned when the mod is disabled or reloaded.
### 3.8 Ordering
`world_bridge` runs before `native::mixmap_frame`: it converts components into `WorldOwners` / `NpcSkaters`. The
hosts keep running after `mixmap_frame` until the follow-up moves process into the pre-tick part and update into
the post-tick part of `mixmap_frame`. That is retail's split; today the inputs are one console frame old (33 ms).
Do that move only with a headless before / after render showing the expected one-frame shift and nothing else.
### 3.9 Lua SDK (mods publishing objects): superseded by §8
The user's requirement (2026-10-03): "we want to make the audio engine moddable with the rest of the engine so
build with that in mind for all features". §8 is the full mod-facing surface. The sketch below is only its object
part. Mods are first-class publishers (P1, not P4). The sketch is modelled on the fork's vehicle SDK conventions
(keys owned per mod, limits, removal on disable).
New `Command` kinds in `skate-mods` (JSON-validated like `audio_*`):
```lua
sdk.world_audio.spawn('car1', 'traffic', {engine='c04_taxi01', position={x,y,z}, heading=h})
sdk.world_audio.update('car1', {position=…, velocity=…, speed=12, load=0.2, horn=0, skidding=false})
sdk.world_audio.spawn('ped1', 'ped', {voice=59, shoe_class=3, weight=2})
sdk.world_audio.update('ped1', {position=…, feet={true,false}, speech=11})
sdk.world_audio.remove('car1')
```
The host spawns an entity per key with the components above (transform from the updates). Limits: 16 per mod,
64 in all; an update older than 0.5 s parks the object (speed 0). `skater` is **not** offered to Lua at first: a full
`AudioState` from Lua is impractical. Phase 4, after the Rust API settles.
### 3.10 Dev test publisher (audible now)
**It is a Lua mod** (§8.5, `mods/world-audio-test/`, dev only), so it proves the mod surface and the engine
surface at once: the mod commands become the same components. One part stays in Rust: the **ghost skater**,
because a full `AudioState` cannot come from Lua. The mod asks for it with
`sdk.world_audio.spawn('ghost', 'skater', {source='record'|'state_log:<name>'})`, and the host records or replays
the local player's `AudioState`. The behaviour below is the same whichever side runs it.
- **Cars:** four lanes around the spawn locator (or the player position at enable). Each is a closed loop of
straights at 8–15 m/s with accelerate / brake phases (`load` ±, skid on hard braking), models cycling through
`c00_heavy01 … c08_family03`. One car honks every 10–15 s (kind 1–5), and one parked car fires `VehicleAlarm` on a
debug key. Sixteen cars in all, so the nearest-4 pool and its hand-over are exercised.
- **Peds:** 20 peds on back-and-forth lines, walking at 1.3 m/s (some jogging at 4 and one running at 8), so all
three `sk8_foley` ids play. Foot plants come from a gait clock (dev only, not retail), with shoe classes 2–5 and
voices cycled over the 40 voices that have 501 lines. Speech: a warn (11) when the player passes within 1.5 m
above 3 m/s, trick nearby (23) on the local player's landed trick within 10 m, and slam (25) on the player's bail.
It uses the existing `Cues`.
- **Skater:** a **ghost**: on a debug key, record the local player's `AudioState` and transform for 20 s (a
`VecDeque`, about 1,200 frames), then replay it as an NPC skater offset 5 m to the side. It loops, and walks out
past 30 m and back, so the claim / release shows. That gives full-fidelity board sounds with no AI. Alternative,
when the map has `routes`: a rolling-only skater on the first route.
- Gizmos (Bevy gizmos): a box per car, a capsule per ped, a ring for the skater, coloured by `WorldAudioInstance`
(holder or not), plus a 30 m circle around the camera.
- Logs: `AUDIO_WORLD` / `AUDIO_NPC` lines, as today, plus `world_bridge` counts once a second.
### 3.11 Verifying against the recomp (no new user sessions needed for 1–3)
1. **Harness** (headless, existing `tests/world_sources.rs` style, real banks):
- the map-change regression (§2.1);
- bridge unit tests in a minimal Bevy `App`: components → `WorldOwners` / `NpcSkaters`, despawn → release, and
events → state for the right time;
- the ghost skater's replay renders the same board voices as the local player's recording (instance 1 against
instance 0, minus the local-only parts).
2. **Level distributions against the recomp's GAIN × SEND**, rendered with the dev publisher's motion: per bank /
sample group, p50 / p90 against session 164620 (`fstep_livingworld` 0.000 / 0.024, C01 0.002 / 0.049 / 0.281
max, soft grain members around NPC bursts as in `world-npc-skater-audio.md` §2). Retail mixes over all distances,
so compare distance-binned once a listener position exists in a trace.
3. **Trace-replay publisher** (headless test + research tool): drive vehicles / peds / the NPC skater from an
existing session's VEHSTATE / PEDXYZ / SKATEB lines (positions; speeds from position differences; landings).
The listener is the session's own **`WPPOS`** (the listener in world coordinates, 2 Hz; checked: 164620 at
100.2 s puts WPPOS at (−199, 4, 639) among PEDXYZ / VEHSTATE / SKATEB points 20–60 m away), or `GRECX` (the
listener every frame) in the audiox sessions. Compare starts per bank and sample group, GAIN × SEND against
distance, and the instance-1 contact bursts (`instance1_voices.py`) with the session's own audio lines.
**Section 7 maps every source to the session that validates it.**
4. **User listening** in game with the dev publisher (the user's verdicts quoted verbatim in doc 11 / the #32
description), then regression-check (all maps) before calling it ready.
## 4. Phases (all in PR #32, after the code-fix pass)
- **P0: map-change fix.** The epoch reset in `WorldHost` / `NpcHost`, `saturating_sub` on ticks, and the regression
test. Small and isolated.
- **P1: engine-facing API.** `world_audio.rs` (components, events, resources), `game_audio/world_bridge.rs`,
`WorldAudioInstance` read-back, `SkaterAudioMemory` + `skater_audio_state` refactor (behaviour-identical proof),
`AudioState::rolling`. Doc: "Hooking up world audio" (docs/hails-additions, doc 11 subsection, plus module docs)
with the per-field table: retail offset, meaning, who should fill it, default.
- **P2: dev test publisher** (§3.10) and gizmos. The user listens.
- **P3: complete the sources** (research and port, each separately verified):
1. speech playback in the host (manager + library + slots wired; `speaker_bits`; `GateInputs` from `Cues`);
the level / pan mapping (research: the SpeechBank stream consumer); the queue / stream limits; values 49 and 29;
2. NPC instance Wheels / Tricks / Treatment / footsteps / Clothing / board slide; the NPC bail grunt and
SkaterSpeech;
3. PedBodyFall (decode), Tazer (AEMS op 38);
4. the TrafficCarPhysics.in0 writer, the 3DObjPos 4.1–4.3 binding, vehicle `+112` / `+144`;
5. the retail instance managers (traffic / ped) replacing nearest-N; the ped `+68` footsteps-on rule and the
`+148` / `+156` pair; the model → engine record / shoe class / weight / voice tables from `livingworld_models`
(setup export, so the engine names a model and gets the rest);
6. process / update into `mixmap_frame` (§3.8).
- **P4: the wider mod surface** (§8.2–§8.4): the content layer (bank / sample / program / speech overrides),
mod emitters on the native voice graph, the tuning read / write, and audio event hooks. `sdk.world_audio.*`
(§8.1) moves into P1 / P2, because the dev publisher is a mod.
- **P5: verification + docs** (§3.11, §7), the doc 11 sections, PULL-REQUESTS row, and the #32 description
checklist.
Each P3 item is checked against the existing recordings (§7) as it lands, not in a batch at the end. The two
scripted runs in §7.3 are optional and only fill the listed gaps.
## 5. Things the API deliberately leaves to the engine
Honk and alarm decisions, skid detection, foot plants (animation), speech values (state graphs), the AI skater's
physics, census / culling. The audio side never invents behaviour (retail parity): where retail's rule is not traced
(the footsteps-on gate, the near / far pair, the instance managers) the bridge uses a **flagged provisional rule**,
documented as such, until it is traced.
## 6. Open questions (research)
- What retail posts when the skater hits a car (a collision material class for vehicles?).
- `sub_824D8E60`; the ped owner `[obj+28]+144`; the horn kinds (traffic AI); the 3DObjPos 4.1–4.3 binding. (`+148` /
`+156`, `+68`, vehicle `+112` / `+144` and the TrafficCarPhysics writer: settled, §7.3.)
- The speech stream gain / pan; the living-world queue (why lines overlap).
- What the inactive Player instance's inputs hold after a release.
- Whether remote **multiplayer** skaters count as non-local skaters for the Player slot's second instance, and with
or without the 30 m gate. `sub_824F1FB0` gates only NPCs by distance, so remote humans might take it without the
gate. Our remote state carries body / pose only, not a full `AudioState`.
## 7. Data plan: the existing recordings first (user, 2026-10-03)
User: "you SHOULD have most of the data you need to at least get most of it done with the number of play sessions
I've provided". So the build-out and its checks use the recordings we already have. A short **scripted background**
recomp run is proposed only for the gaps in §7.3. A user session is never needed.
Before any use, run `py -3.13 tools/recomp-trace/trace.py <trace>`: **0 malformed lines**, or the
session is deleted (project rule "bad data is unusable"). Every number is "the recomp", not "retail" (the recomp runs uncapped
and stalls; the logic is the strong evidence and timings are weaker).
### 7.1 What is on disk (local recomp sessions, counted 2026-10-03)
Times are the trace's ms. "Listener" = the camera position: `WPPOS` (2 Hz, world coordinates; checked: 164620 at
100.2 s puts it at (−199, 4, 639), with PEDXYZ / VEHSTATE / SKATEB points 20–60 m away) or `GRECX` (every frame).
| session | length | living-world content | audio | listener | NPC-owner caveat |
|---|---|---|---|---|---|
| `all_20261002_163809` | 261 s | PEDXYZ 7,952, PEDSEE 3,320, MOODOUT 142, SECCHASE 1,486, SECTAKE 360, SECTAZE 18, VEHSTATE 2,693, SKATEB 3,455, NPC/VEH spawn + cull | PLAY 23 k, GAIN 36 k (**dsp on**: voice levels), SPLC, POST, READ 436 (speech) | WPPOS | no GREC |
| `all_20261002_164620` | 496 s | PEDXYZ 18,194, PEDSEE 9,016, MOODOUT 631, PEDTIMER 603, SECTAZE 9, **VEHSTATE 10,018**, TRAFENGINE 4,480 (first-pass), SKATEB 7,361, SKATER 267 | PLAY 43 k, GAIN 90 k, SEND 78 k, READ 726; report.md: Traffic_Horn 115, Traffic_Skid 139, car_alarms 12, fstep_livingworld 4,949 starts | WPPOS | no GREC; the reference session (single writer, 0 malformed) |
| `all_20261002_180430` | 676 s | PEDXYZ 7,706, VEHSTATE 1,742, SKATEB 9,040, MOODOUT 102 | PLAY 40 k, GAIN 97 k, CONTACT 28,626 | WPPOS | **GREC 460,476, of which the NPC owner = 227,295** (the NPC's grain bed per frame) |
| `all_20261002_223306` / `223613` | 124 s / 488 s | PEDXYZ 3,963 / 1,813, SKATEB 1,510 / 1,872 | GAIN 10 k / 48 k | WPPOS | GREC 57 k / 352 k; 223613 also TREAT 352 k, SEAMPAT 139 k, SEAMHIT 4 k, with NPC rows |
| `all_20261002_214002`, `214346`, `222155` | 56–104 s | PEDXYZ 1–3 k, VEHSTATE 655 (214002), MOODOUT 9–115 | GAIN 4–8 k | WPPOS | GREC 14–35 k, with NPC rows |
| `all_20261002_204336`, `232153` | 58 s / 234 s | small | small | WPPOS | **clean** (no NPC owner) |
| `audiox_grind_20261003_120315`, `audiox_ride_20261003_141046` | 493 s / 275 s | — | GAIN 53 k / 23 k, CONTACT, **COLLPOST 2,398 / 554** | **GRECX** | after the local72 fix: GREC / GRECX are local only. COLLPOST `local72 = 0` rows are the **NPC skater's contact / collision posts**, with positions. |
| `bailx*`, `bandq`, `localtest*`, `riderun`, `bailrun_ok3` (2026-10-03, scripted, Mega-Park spawn) | 76–140 s | — | COLLPOST 186–1,216 | GRECX | `localtest2`: the NPC appears at 21.6 s (LOCALTEST proves the gate) |
| `audiox_20261003_095054`, `audiox_bail_094336`, `bailrun_*` before 10:30 | 52–193 s | — | GREC / GRECX | GRECX (both owners) | pre-fix: NPC rows mixed in |
The 2026-10-02 sessions that older notes cite (`all_…153038`, `…160126`, `…161849`, `npc_*`) are no longer on disk.
Their extracts remain locally (`ped_chase_*.txt`, `speech_*.txt`, `veh_*.txt`). Our own state logs
(`state_*.tsv`, 27 real-play logs) are the input for the ghost-skater tests; `e2e.rs` already
replays them headless.
**The local72 caveat, turned into data:** before the 10:30 fix, GREC / GRECX / FIRSTHIT / SKID / TREAT / SEAMPAT /
SEAMHIT also logged the NPC skater's objects (15 of 18 sessions; `owner_scan.py` (local script)). Filtering
**by owner** gives the NPC instance's own per-frame records: the inverse of `filter_local.py`, which keeps the first
object each trace logs, so it needs a `--npc` switch. That is retail's instance-1 data for the grain bed, the skid
object, Treatment and Class_Seams, which no new run would give more cheaply.
### 7.2 Source → validating session
| source | session(s) | what it validates (counts / levels / timings / rules) |
|---|---|---|
| Engine bank choice by patch | 164620 (+ 163809, 180430) | every TRAFFIC_CAR start sounds in one `C0n` bank; the share of 7 / 8 against 1 and of 6 against 3 (the `rand()%100` overrides, per vehicle spawn; VEHSPAWN ids join the posts) |
| Engine RPM / Doppler / levels | 164620 VEHSTATE (position + forward, ≤ 4/s → speed) + WPPOS + PLAY / GAIN / SEND for the `C0n` voices | GAIN × SEND against distance (the ~45 m front-layer cut-off, the B7 4–90 m layer); pitch against speed (gear steps at 7 / 14 / 21 / 28 m/s) and the approach / recede shift. Trace-replay: publish the VEHSTATE path headless and compare the per-voice level curves. |
| Front / rear split, heading `+112` | same | the w1 / w2 level ratio against dot(forward, pos − listener): if VEHSTATE's forward explains it, `+112` = heading |
| 3DObjPos 4.1–4.3 binding | same | per-layer level against distance to the body / front / rear points (whichever fits best) |
| Horns / alarms / skids | 164620 (115 / 12 / 139 starts) + VEHSTATE (`+3716` alarm, manoeuvre `+4396`, target speed) | alarm: starts when `+3716` arms and stops 8 s later, variants spread over 4; horn: variants spread over 9, none from heavy01 (patch 0); skid starts against VEHSTATE decelerations |
| Instance assignment (4 / 15) | 164620 / 163809: concurrent `C0n` voices and footstep owners against WPPOS distances | at most 4 engines at once, and whether they are the nearest 4. This settles nearest-N or shows that another rule is needed. |
| Ped footsteps | 164620 (4,949 `fstep_livingworld` + `sk8_foley` starts) + PEDXYZ (→ speed) + WPPOS | sk8_foley ids 62 / 63 / 64 against ped speed (2.5 / 7.5 m/s); steps per second against speed; GAIN × SEND against distance (p50 0.000 / p90 0.024); the sample groups per class (2, 4, 5 seen); the 151 unexplained starts against MOODOUT times (the jump / collision words) |
| Ped speech: reactions, lines, takes | 163809, 164620 (READ + MOODOUT) + the `speech_*.txt` extracts | event per reaction, start 0.03 s after MOODOUT; the take history (57 / 57 already); `_n` / `_f` against the speaker's distance. The MOODOUT ped address = the PEDXYZ address, measured against WPPOS, which is the join `speech_nearfar.py` lacked. **`+148` / `+156` is likely a distance and its threshold**, fit from the data. |
| Speech level / pan | **163809** (dsp on: GAIN 36 k; the speech voice GAIN 0 → 1, PITCH 0.52–0.70) + the speaker's PEDXYZ + WPPOS | the speech voice's GAIN against speaker distance → which PedestrianSpeech output it follows (B2 out2 / B20 2–70 m) |
| Speech overlap / queue | every session with READ (`overlap.py`) | concurrent living-world lines and the per-speaker spacing (the timers) |
| Tazer | 163809 (SECTAZE 18), 164620 (SECTAZE 9; 49 Tazer starts) | c_tazer / c_tazer_grn_play start / stop against SECTAZE draw / zap: the check for the op-38 port |
| PedBodyFall | 163809 (SECTAKE 360: takedowns knock peds down) | the POST / PLAY lines at takedown times → which class / bank PedBodyFall posts; decode the object against them |
| NPC skater: who gets instance 1 | 164620, 180430 (SKATEB per AI board + WPPOS; the NPC GREC owner appears / disappears) | the 30 m gate: NPC GREC rows start when the first skater in list order comes within 30 m and stop at ≥ 30 m |
| NPC grain bed | **180430** (227 k NPC GREC rows), 223613, 223306, 214002 / 214346 / 222155 | the NPC instance's per-frame bed records (levels, members, soft against the local rider's hard wheels) against our `npc_grains`, driven by the matching SKATEB motion |
| NPC contacts / collisions | audiox_grind_120315, audiox_ride_141046, bailx*, bandq, localtest*, riderun (COLLPOST `local72 = 0`, positions, GRECX listener) | materials, tiers and poster per NPC impact; the non-local branches (no pitch override, spread 0) |
| NPC Class_Seams / Treatment / skid | 223613 (TREAT / SEAMPAT / SEAMHIT NPC rows); pre-fix SKID | the Treatment port for instance 1 (missing now) and the seams that run for NPCs |
| NPC wheels / tricks / footsteps / clothing | POST / SPLC lines with instance-1 MixMap keys (`0x40010820` Wheels, …) in 180430 / 164620 | **checked 2026-10-03: POST lines do not name the instance** (class slot, fresh payload pointer, caller chain) → gap G3, **settled 2026-10-03** (PLAYERPOST run `gapg3_20261003_154858`; see §7.3 "G3 result") |
| Ghost NPC skater (headless) | our `state_*.tsv` logs through `e2e.rs` | instance 1 renders instance 0's board voices, minus the local-only parts |
| Model tables (record / class / weight / voice) | `vlt_livingworld_models.json`, `vlt_livingworld_vehicle_characteristics.json`, `fieldxref*.txt` | whether the record key, shoe class and voice can be read as data; if no field has a reading code site → G1 / G2 |
### 7.3 Gaps the recordings could not close → scripted background runs (DONE 2026-10-03: G1, G2 and G3)
The user approved the runs on 2026-10-03. Each gap got one guarded, read-only hook in skate3recomp `research-hooks`
(uncommitted), fixed field counts in `tools/recomp-trace/trace.py` `FIELD_COUNTS`. Runner `recomp_script_run.sh`,
`BACKGROUND=1 MUTE=true SKATE3_TRACE=audio,npc,traffic`, autostart 8000, one game at a time. Sessions (all 0 malformed
lines, generic check and fixed-layout check): `gapproof_20261003_152333` (Crystal Towers 30 s: both hooks fire),
`gapg1_20261003_152507` (Crystal Towers: stand 60 s, forward 6 s, stand 30 s), `gapg2_20261003_152734` (Aletown:
stand 60 s, walk 10 s, turn, walk 10 s, stand 20 s). Scripts `gap_{proof,g1_traffic,g2_peds}.txt`.
Analysis: the local tool `world_gaps.py <trace> [--veh|--ped]`; outputs saved as
`gaps_g1_152507.txt`, `gaps_g2_152734.txt`, `gaps_g2_from_g1_152507.txt`. Most answers came from
reading the code and the attribute database first; the runs confirm them. All numbers are "the recomp".
Hook line formats:
- `VEHAUD` (traffic, 15 fields): TrafficEngine update `sub_824D6478`, read after it runs. obj | R = `[obj+28]` | entry id
`[[R+40]+64]` | key R+168 (hex) | patch obj+48 | rpm obj+52 | pos R+48 | fwd R+112 | R+144 | R+148 | R+152 | horn
R+156 | skid R+160 | R+176 | listener `*(0x830CFDD4)`+0. Per object ≤ 4/s, at once when horn / skid / key / patch change.
- `PEDAUD` (npc, 21 fields): PedestrianSFX process `sub_824D8078`, active instances, read after it runs. obj | S =
`[obj+32]` | id S+64 | list index | list count | bytes "+68 +69 +71 +80" | feet "+73 +74" | S+84 | record key of S+84
| S+96 | "S+88 S+92 S+120 S+124" | S+132 | S+136 | "S+140 S+144" | S+148 | S+152 | S+156 | O+128 (O = `[obj+28]`) |
O+144 | O+48 | listener. Per object ≤ 2/s, at once when id / +68 / +136 change.
#### G1 result: the vehicle audio record (settled)
- **Writer:** `sub_824B2A28` fills the record R each frame from a 32-byte **vehicle-audio entry** in the array at
`[G+0x2F0A0]` (G = `*(0x83083C38)`), matched by entry +12 == `[[R+40]+64]`: entry +0 → R+144, +4 → R+148 (speed),
+8 → R+152, `+16 >> 28` (signed) → R+156 horn state, bit 27 of +16 → R+160 skid, +24 (u64) → R+168 record key.
When no entry matches, the record is not touched (see "stale" below).
- **R+112 = the heading:** row 2 (forward) of the vehicle's world matrix (the same matrix VEHSTATE reads), normalised.
Trace: |R+112| = 1.0000 ± 0.0001; R+112 · VEHSTATE forward p05 0.9986 / p50 1.0000 (328 joins); R+112 · direction of
travel (moving > 2 m/s) p50 1.000 (445 pairs). So `TrafficAudio` takes the heading from the transform (no velocity
override needed).
- **R+128 = heading × speed** (velocity along the heading). **R+80 = pos + heading × 1 m, R+96 = pos − heading × 1 m**
(constants +1.0 / −1.0 at `0x8231A844` / `0x8216DEE0`): front and rear points 1 m from the body, the likely targets of
3DObjPos 4.1 / 4.2 (binding still to confirm; was "all at the body").
- **R+144 = the driver's signed acceleration (m/s²)**, not a throttle: −15.64 through a hard stop, then 0.4, 1.0, 1.4,
2.0, 2.4, 3.0, 3.02 while pulling away; the speed changes by exactly R+144 × 0.2 per traffic step (15.6 → 0 in steps of
3.128 = 15.64 × 0.2; 3.004 → 6.93 in steps of 0.604 = 3.02 × 0.2). Range in the run −15.64 … 3.02; 0 while cruising.
So `TrafficAudio.load` = acceleration in m/s² (the engine / skid words use it × 3000), and the bridge can derive it
from the speed change when the engine does not give it.
- **R+152 = horizontal (x/z) distance to the listener** (matches to ~0.02 m on live records). **The list is cut at
40 m**: R+152 never exceeds 39.994 (1,175 lines).
- **R+176 + the `TrafficCarPhysics.in0` writer (static, `sub_824B2A28` tail):** |R+128 − `[listener+48]`| (the
relative speed; listener +48 is taken to be its velocity), clamped to 35 (`relative_velocity` record, class
`0xC1831BDB6CB1B1EA`, field `11FAA9AADDC78EC0` = 35), slewed by 100 × dt (field `AB85397C101B0752` = 100), stored at
R+176, then `R+176 / 35 × 32767` → `[R+12]` vfunc +8 input 0. The same formula as the NPC skater's PlayerPhysics in13.
In the run R+176 tracks the speed (listener standing; max 24.6). This opens the A11 near boost gate: port it.
- **Model → engine record (static, attribute database; confirmed):** `livingworld_entities` field `92A043B4A11F1A2A` →
`livingworld_vehicle_characteristics` record → field `BA2DDD830C731EE4` → `aud_traffic_engine` record:
| entity (and children) | vehicle spec | engine record (patch) |
|---|---|---|
| sedan, sedan01–04, old_sedan, hatchback01 | vehicle_spec_family01 | c01_family01 (1 → 7 / 8 by `rand()%100`) |
| sports, sports01–03, muscle01, z_pipeline_lw_vih | vehicle_spec_sports01 | c03_sports01 (3 → 6 at 50 %) |
| taxi, taxi01, patrol01 | vehicle_spec_taxi01 | c04_taxi01 (4) |
| suv, suv01–02, pickup01–02 | vehicle_spec_truck01 | c05_truck01 (5) |
| minivan01 | vehicle_spec_minivan01 | c05_truck01 (5) |
| vehicles / pickup / hatchback / muscle / minivan (abstract parents) | default | default (patch 2: silent) |
`c00_heavy01` (patch 0) and `c02` are never referenced, so "heavy01 never honks" does not arise in free roam.
`c06_sports02` / `c07_family02` / `c08_family03` are reached only through the patch override (their records also
exist; c08's own patch field is 7). Trace: the record keys are only c01 / c03 / c04 / c05; patches chosen per vehicle
c01 {1: 6, 7: 3, 8: 4}, c03 {3: 1, 6: 1}, c04 {4: 4}, c05 {5: 8} (27 vehicles).
- **Instance assignment:** every engine instance holder was among the 4 nearest vehicles by horizontal distance (311 of
311 holder-seconds, VEHSTATE positions), consistent with nearest-4 within the 40 m list. Keep nearest-4 (no longer
provisional for the choice; the manager itself is still not read).
- **Stale records:** when a held vehicle leaves the 40 m list, its record keeps its last values (position, speed 10.65,
R+144 3.02 …) while the instance stays held, so retail keeps sounding the frozen car until the instance is reassigned.
Recorded as behaviour, not ported unless a listening check asks for it.
- **Horns:** one horn in the run: kind 1 together with skid 1, at the start of a c01 car's hard stop (−15.64 m/s²) 18 m
from the listener. **Horn kinds per model: still open** (only kind 1 seen; the kind is packed by the traffic AI into the
top nibble of entry +16, writer not found). They are the AI's choice, so the API keeps `HornState::Honk(1..=5)` as an
engine input.
#### G2 result: the ped audio state (settled)
- **Instance manager `sub_824F2890`** (vtable slot at `0x822FD618`, called once per frame): reads the packed 20-byte **ped
audio entries** at `[G+0x2F070+56]` (count `[G+0x2F070+60]`; zero in game modes 18–20 or when `[*(0x830CFDC4)+560]` ≠
1), takes the first min(count, 15), and fills each held instance's state S (instances matched by S+64 == entry +12;
unmatched instances are deactivated through vfunc 28, new entries claim one through the manager's vfunc 3):
- S+136 = entry +0 & 0x7F (the speech value); S+73 = bit 29 (foot B), S+74 = bit 14 (foot A), S+71 = bit 13;
- S+140 = (entry +0 >> 16) & 0x7F, S+144 = (entry +0 >> 23) & 0x7F (materials A / B);
- S+120 = 1 << (((entry +4 >> 25) & 31) − 1) (0 when ≥ 18); S+80 = bit 17 of +4; S+92 = field `02D9BFDA94A8B35E` of the
`aud_characteristics` record of model (entry +4 >> 18) & 0x7F (the partner's / target's type: varies per line);
- S+148 = entry +8, S+76 = entry +16;
- **S+68 (footsteps on) = (the ped's list index < 3)**, 3 = `aud_speech/default` field `53364DFA09A499DD`;
- S+69 = (entry +8 < 50), 50 = `aud_speech/default` field `7F0FFDF7A90577C6`.
- **The list is sorted nearest first and cut at 50 m.** S+148 = the 3-D distance from the ped (O+48) to the listener:
corr 1.0000, median |diff| 0.047 m (G2 run, 1,130 lines); list order agrees with S+148 in 1,935 of 1,936 same-moment
pairs; S+148 never reaches 50 (max 49.9995 over 3,146 lines in three sessions). So the 15 instances = the 15 nearest
peds within 50 m (nearest-15 confirmed), and **footsteps play for the 3 nearest only**: +68 == (index < 3) in 2,803 of
2,803 lines; 3 instances had +68 = 1 in 369 of 388 half-second bins (the rest are hand-over moments).
- **+148 / +156, the near / far pair:** +148 = distance to the listener (above); **+156 = the model's
`aud_characteristics` field `A27215A909135B62`** = 20 m for every `regular` ped (5 in `default`, 30 for pros / IP /
marquee). PedestrianSpeech's flag 1 (= the `_f` / far lines) ⇔ distance > 20 m. Trace: S+156 = 20.0 in all 2,803 lines.
- **Per-model fields (activation `sub_824F91B0`, from the `aud_characteristics` record of model S+84; the per-model record
keys live in a table at `*(0x830CFDDC) + (model + 14287) × 8`, default record `default`):**
- **S+84 = the model = the speech voice id** (the record's `Character` field: 41 adult_male_1 … 96 ai_skater_female_3);
- S+132 shoe class = field `871BDC669F2B1844` (default 2); S+96 kind = the speech type bit, field `492964E71634DA6D`
(so "kind 64" = the three `security_guard_*` records, the close-range level output); S+124 = field
`68BB61E508841729` (1 female, 2 male); S+156 = field `A27215A909135B62`; S+88 = the voice variant bit
(1 << (n − 1) for the `_n` record: business_male_1/2/3 → 1/2/4, adult_female_4 → 8); S+152 = a per-voice float
(`sub_824A9E48`(voice), 0.80–1.15, e.g. 53 → 0.80, 94 → 1.15; probably the speech pitch, unconfirmed).
- Trace: 20 models seen over both runs, every S+96 and S+132 equals the database (e.g. 41 → 1 / 5, 59–61 → 4096 / 5, 66 → 8192 / 4,
52 → 4 / 4, 73 → 16 / 2, 87 → 16384 / 2, 91 → 512 / 2).
- Shoe classes in the database: 2 for most; 3 security_guard_1–3 and adult_male_3; 4 business_female_2/3,
adult_female_3, granny_2; 5 business_male_1–3, adult_male_1, tourist_male_1 (all 112 records checked, with
inheritance). **No model uses class 1 (silent).**
- Full table: the local tool `vlt_show.py aud_characteristics` (written by
`vlt_resolve.py '^aud_characteristics$'`).
- **O+144 is not a per-model weight:** it is 1 for every ped beyond ~12 m and 2–5 for some peds close to the listener
(model 73 within 12 m: 2 / 3 / 4 / 5), so it is dynamic per ped (meaning open: a gait / animation LOD state?). O+128 is a
noisy per-frame speed (corr 0.38–0.41 with position differences; p50 0.3–0.8 m/s for walking peds).
- **Materials:** S+140 / S+144 were 0 in every line (Aletown and Crystal Towers pavements), never 143.
- Speech values seen: 3 (most), 1, 2, 4, 33, 36, 38–41, 48, 56, 63.
#### G3 result: what the NPC skater's Player instance plays (settled 2026-10-03)
The older `POST` lines do not name the instance (class slot, fresh payload pointer, caller chain; the posters are the
same code for both Player instances), so G3 got a new hook (user-approved 2026-10-03).
**Hook `PLAYERPOST`** (category `audiox`, skate3recomp `research-hooks` `hooks_audio.cpp`, uncommitted; 12 fields in
`trace.py` `FIELD_COUNTS`): the per-player SFX objects' vtable process / update functions are wrapped (SkateBoard
`0x822FC770` +36 / +40, Contacts `0x822FC698` +32..+40, Tricks `0x822FC7B8`, Wheels `0x822FC848`, Clothing
`0x822FCD10`, Treatments `0x822FCDA0`, OffBoard `0x822FCF98`), so the object being run is known on the thread; every
`POST` (`sub_828E2B48`) and `SPLC` (`sub_82975700`) made inside one is logged with that object, plus the Wheels stream
start / stop (`sub_824CEAF0` / `sub_824CEF60`) and the NPC bail grunt helper `sub_824BF5F8`. Fields: class | via (POST /
SPLC / WSTART / WSTOP / BAILGRUNT) | object | local72 `[[obj+28]+72]` | local16 `[[obj+16]+72]` | MixMap key
`[[obj+12]+4]` | record id `+64` | record skater index `+68` | a (slot / bank / layer) | b (payload / id / started) | words
| chain.
**Runs** (background, muted, `SKATE3_TRACE=audio,audiox`, autostart 8000; one game at a time): `g3proof_20261003_154754`
(Mega-Park spawn, 4 pushes, stand; 270 lines, both instances fire) and **`gapg3_20261003_154858`** (script
`gap_g3_ride.txt` = `ride_spawn.txt` + 60 s standing at the marker + segment 1 again; 160 s; 851
lines). Both 0 malformed (fixed-layout and generic checks); the audio captures were deleted. Analysis
the local tool `player_posts.py <trace>` → `gaps_g3_154858.txt`
(`gaps_g3_proof_154754.txt`). All numbers are "the recomp".
**Instances and keys:** instance 1's objects carry the instance-1 keys, base `0x40010800` + the class offset:
SkateBoard `0x40010800`, Contacts `…810`, Wheels `…820`, Clothing `…860`, OffBoard `…890` (instance 0: `0x400100xx`,
also Tricks `…050`, Treatments `…070`). local72 = 1 on every instance-0 line and 0 on every instance-1 line (851 / 851).
Instance 1 was claimed 12 times in 150 s (each claim re-creates the OffBoard's two footstep packets) by three skaters
(record ids 1, 2, 3) in turn, ~74 s held in all (a lower bound: window ends are the last line or the stop-all at the
deactivation); handovers between skaters are frequent at the park (one 0.1 s and one 0.5 s hold).
**Per component, local (per run second, 150 s) vs NPC (per held second, ~74 s):**
| component | local rider (instance 0) | NPC skater (instance 1) | port consequence |
|---|---|---|---|
| SkateBoard posts | 74 (0.49/s): wheel skid (slot 1) 44, rattle (10) 20, Class_rolling held layers (42) 10 | 67 (0.90/s): wheel skid 42, squeaks / powerslide (7) 14, rattle 11; **no slot 42** | as ported (one rolling pass, no held layers 0 / 3); skid / squeaks / rattle run per instance |
| Contacts Splice | 243 (1.6/s): sk8_foley 94 / 95 / 92 / 93, Skate_Collisions 1112 / 1115 / 1095 / 1060 … | 264 (3.6/s): the same families (sk8_foley 95 / 94 / 92 / 93, Skate_Collisions 1112 / 1115 / 1111 / 1079 / 1091 / 1124 / 1126 …) | as ported (non-local branches in `player::contacts`) |
| Contacts foot drag (slot 0) | 1 | 0 | — |
| **Bail grunt** (`sub_824BF5F8`) | called once, does not post (local72 = 1) | **called once and posts** the speech message (8206 / 115) at an NPC bail (140.3 s, with the NPC's Clothing body slide and cloth falls posts) | reachable: port in the speech stage (PlayerSpeech record of that skater) |
| **Wheels** spin streams (`SFXObj_Wheels`) | 14 starts, all layer 0 (state `+332` = in the air), level 4.2–13.7 | **18 starts: layer 0 × 17, layer 1 (`+340`) × 1**, level 0–12.9 (p50 6.3); each stopped (layer off), plus a stop-all at each release | **runs for the NPC**: port Wheels for instance 1 (key `0x40010820`); layer 2 (on foot / `+308` / `+760`) and the extra parameter writes after a start are gated by `[[obj+16]+72]` = local only |
| **Tricks** (Class_Flips slot 4, cloth_trick slot 22) | 12 posts | **0** | **local only**: the Tricks process `sub_824CBEB0` returns at once when local72 = 0 (static) → no NPC trick sounds |
| **Treatment** (slot 9) | 1 | **0** | **local only**: the Treatments process `sub_824DD408` returns at once when local72 = 0 (static) |
| **OffBoard** | 2 footstep-packet creations; 38 Splice starts (Skate_Collisions 963, sk8_foley 86 / 105 / 107) | 24 packet creations (2 per claim), **0 Splice starts** | the packets exist per instance, but the per-foot step sounds require local72 (spec `aems-offboard-clothing-spec.md` §2.1) → no NPC footsteps. The walking / jump voices (`sub_824E9D10` / `sub_824E9678`) are not gated (local72 only selects one start-block float) but the NPC was never on foot in the run: unobserved |
| **Clothing** | 8 posts (body slide 21 × 6, cloth falls 23 × 2); 28 Splice (sk8_foley 74 × 20, 73 × 8) | 3 posts (body slide × 2, cloth falls × 1, at the bail); **20 Splice (74 × 11, 73 × 9)** | **runs for the NPC**: port Clothing for instance 1 (key `0x40010860`); non-local start block: float `+112` = 0.0 instead of 1.0 (the "spread 0" of the Contacts non-local one-shots) and local72 passed to the eq-chain pick `sub_82491108` (create flag) |
| board slide (slot 15) | 0 | 0 | not reached in the run (SkateBoard process `sub_824C6A78` → `sub_824CB3C8`, no local gate statically) |
Still open: the walking / jump voices for an NPC on foot (not seen); board slide for either instance; the Wheels start
words `r5` / `r6` are two alternating stream records (pointers), not ids; the NPC bail grunt's speech playback (speech
stage).
Not gaps: reactions, chases and speech events (MOODOUT, READ, the extracts); traffic driving and culling (VEHSTATE); the
NPC skater's board data (GREC NPC rows, COLLPOST).
## 8. Mod-facing surface (user, 2026-10-03: "make the audio engine moddable with the rest of the engine")
The engine-facing surface (§3) is ECS components. The mod-facing surface is **API-2 commands** (`skate-mods`
`Command`, JSON-validated like `audio_*`, Lua wrappers in `sdk/skate.lua`). The modding bridge
(`skate-game/src/modding/`) turns those commands into the same components / events. There is one path into the
audio, and mods and engine systems are equal publishers. General rules come from the existing mod rules: keys belong
to the calling mod; per-mod and global limits; validation before allocation; commands from a failed callback are
dropped; disabling or reloading a mod removes everything it published or overrode, and the retail behaviour comes
back deterministically. The existing `audio_preload / play / update / stop / stop_all` keep working unchanged.
### 8.1 World objects and their one-shots (Phase 1–2)
```lua
-- spawn / update / remove: kind = 'traffic' | 'ped' | 'skater'
sdk.world_audio.spawn('car1', 'traffic', {engine='c04_taxi01', position={x,y,z}, heading=h})
sdk.world_audio.update('car1', {position=p, velocity=v, speed=12, load=0.2, skidding=false})
sdk.world_audio.event('car1', 'horn', {kind=3, seconds=1.2}) -- 'alarm' (8 s, retail), 'horn'
sdk.world_audio.spawn('ped1', 'ped', {voice=59, shoe_class=3, weight=2, position=p})
sdk.world_audio.update('ped1', {position=p, velocity=v, feet={true,false}, materials={40,40}})
sdk.world_audio.event('ped1', 'speech', {value='warn'}) -- SpeechValue name or number
sdk.world_audio.spawn('npc1', 'skater', {source='lite'}) -- or 'record' / 'state_log:<name>' (ghost)
sdk.world_audio.update('npc1', {position=p, velocity=v, speed=6, wheels={true,true,true,true},
material=40, grinding=false, air=false, land=false})
sdk.world_audio.remove('car1')
local info = sdk.world_audio.read('car1') -- {audible=true, instance=2} (`WorldAudioInstance`)
```
- `skater` `source='lite'`: the host builds an `AudioState` from the few fields a script can give (§3.5
`AudioState::rolling` plus grind / air / landing flags). Rolling, grind and landing sound right; tricks, foot
and body foley stay silent. This is documented as such.
- Limits: 16 objects per mod, 64 in all (counted with engine objects for the audible pools only: the retail pools
decide who sounds).
- An object not updated for 0.5 s is parked (speed 0, feet up), like the fork's vehicle SDK's control timeout.
### 8.2 Content layer: replace or add banks, samples, programs, speech lines (Phase 4)
This is the "Data-driven overrides" goal in the native-port plan ("Modular and moddable"). The world
sources need it too, so it is designed once here:
- **Resolution by retail identity:** `Library::bank_source(stem)` and the sample / program lookups gain an overlay.
Mods first (in load order), then the install. Identities:
- a bank stem (`C04_taxi01`, `Traffic_Horn`, `fstep_livingworld`);
- a bank + sample index (one WAV replaces one sample);
- a bank + patch program (an `.abk` built by the existing pipeline, or a whole replacement bank);
- a speech clip + take (`501_59_busm1_Warn_n` take 3), or a new clip for an existing event / voice. The speech
index and the `.evt` records gain mod rows, so the rules can pick them.
- Declared in `mod.json` (`"audio": {"replace": {...}, "add": {...}}`), validated (paths, PCM16 WAV limits as
`audio.rs`, sizes against a per-mod budget). A bad entry is reported and skipped; the retail sound stays.
- Mods with overrides of loaded banks force a reload of those banks at enable / disable (the same path as the
map-change reload; the §2.1 epoch reset makes the world hosts re-post).
- A **new** bank needs a class to post to. Mod banks bind to an existing class (e.g. a new engine sound bound to
`TRAFFIC_CAR` with its own patch number above 9) or to a mod class name posted via §8.4.
### 8.3 Emitters and tuning (Phase 4)
- `sdk.world_audio.emitter(key, {bank='Siren_city_1', patch=225, position=p, extent=e, volume=v, falloff=f})`:
an `.ems`-type-1 emitter record added to the map's list, played by `emitters.rs` through `c_emitter` on the native
voice graph (reverb, buses and ducking included). This replaces the separate Bevy voices for mods that want
"a sound in the world".
- **Tuning read / write:**
- `sdk.world_audio.tuning('traffic_engine', 'c04_taxi01')` returns the record, and `…set(…)` overrides fields
for this session;
- the same for `ped_footsteps` and the speech `EventTuning` (probability, timers per event).
- Retail values stay the defaults; overrides are per mod and are dropped on disable.
- **Not** the retail constants that define instance layouts (4 / 15 / 2 instances, 30 m): those are the MixMap's
shape. Changing them needs a MixMap rebuild; listed in §8.6.
### 8.4 Posts, globals and event hooks (Phase 4, shared with the player audio)
- `sdk.audio.post(class, words)` → handle; `redeliver(handle, words)`; `release(handle)`. Rate-limited; at most 32
held handles per mod. This plays any retail sound with real payload words.
- `sdk.audio.global(name)` / `set_global(name, v)`; `sdk.audio.mixmap(slot, instance)` returns the outputs (read
only).
- Event hooks: `on_audio_event(event)` with `event.kind` = `speech_request` (ped, value, event id, line), `horn`,
`alarm`, `npc_claim` / `npc_release`, and the player ones (pop, land, grind on / off, zone change). A hook can
return `suppress` or layer its own post. Callbacks get these as plain data; nothing AEMS-internal is exposed.
### 8.5 Example mod = the dev test publisher
`mods/world-audio-test/` (`mod.json` api 2, `main.lua`; dev only, not shipped upstream unless the user asks). It does
§3.10 in Lua:
- cars on loops around the player's position at enable, honking, skids on braking, an alarm on a menu button;
- peds on lines with a gait clock and speech on proximity / tricks (reads the player state from the existing
`event` fields);
- a ghost skater (`source='record'`);
- settings: counts, speeds, which kinds;
- UI: a small mod window with toggles and the `read()` audible flags.
`check_mod` validates it. The mod-making guide gets a "World audio" section and the "Planned audio modding" block
is updated. Phase 4 adds a second example, a "louder horns / custom engine" mod using §8.2–§8.3.
### 8.6 What is not moddable yet (input for later passes; read from the code 2026-10-03)
| hard-coded today | where | what a mod cannot do |
|---|---|---|
| Bank lists `TRAFFIC_BANKS`, `PED_BANKS`; the player `components::BANKS` / `OPTIONAL_BANKS` | `skate-audio/src/world/mod.rs`, `game_audio/player_audio.rs:598–605` | add or swap a bank for a source |
| The keep-list of `unload_map_banks` (utility, seams, player banks) | `native.rs:406` | keep a mod bank across maps; mod banks would be unloaded like map banks |
| Bank resolution only from the install manifest (`Library::load` / `bank_source`) | `library.rs:837 / 1027` | override any bank, sample or program (no overlay) |
| Class names as constants (`TRAFFIC_CAR`, `TRAFFIC_HORN`, `c_car_alarm`, `TRAFFIC_SKID`, `livingword_footstep`, the `sk8_foley` step ids 62 / 63 / 64) | `world/traffic.rs:27–30`, `world/peds.rs:27–31` | point a source at another class |
| World tuning (`aud_traffic_engine`, ped footsteps, speech tuning) only from `audio_manifest.json` `world_tuning`, read once (`host.ped_tuning` at the first owner) | `world_sources.rs`, `world_audio.py` | change it at run time |
| The player's vault tuning (`PlayerTuning`, contact tuning, the bridge's speed graph) | `player_audio` / `library.rs:1049` | tweak player sound tuning |
| Speech index / takes from setup only (`speech/livingworld.json`, decoded WAVs); playback not hosted | `world_audio.py`, `world_sources.rs` | add lines, voices or events |
| Emitters only from the map's `.ems` files; the `PROFILES` fallback table | `emitters.rs` | add or move emitters (mods use the separate Bevy voices instead) |
| Location sets, zone ambience, crossfade groups, region layers from the manifest | `random_sets.rs`, `ambience.rs`, `crossfade_groups.rs` | change set data, beds or weights |
| Mod sounds are plain Bevy voices outside the native mixer | `modding/audio.rs` | use the reverb / buses / ducking / MixMap distance curves |
| Instance layout: 4 traffic, 15 peds, 2 player instances, 30 m (MixMap `RETAIL_INSTANCES`) | `world/keys.rs`, `world/skaters.rs:60–63` | more audible cars / peds / NPC skaters (needs a MixMap rebuilt with more instances: a deliberate non-retail option) |
| The `SKATE_AEMS_*` switches are process env vars | throughout `game_audio` | per-mod or per-session toggles |
| No AEMS post / global / MixMap read access and no audio event callbacks in the SDK | `skate-mods` | play retail sounds by name, react to game sounds |
| The world owners' `rand()` generator (`Lcg(0x5EED)`) | `world_sources.rs:216` | seed for reproducible mod tests (minor) |
@@ -0,0 +1,94 @@
# NPC (AI) skaters' board sounds — spec and port status
2026-10-03, overnight step 4 (headless). Code: `skate_audio::world::skaters` (runner), host
`crates/skate-game/src/game_audio/npc_skaters.rs` (inert until an AI-skater system publishes). Doc 11 "NPC
skaters' board sounds". Plan: the world / NPC audio inventory (row "NPC skaters' boards"; see doc 15).
Source: the TU3 recompilation (reference only, addresses are facts); "the recomp" = its traces, which run
uncapped and stall.
## 1. Which skaters get sound (retail mechanism)
| what | where | fact |
|---|---|---|
| records | `CSTATEMGR_Player` init `sub_824F1F40` | exactly **2** `CSTATE_Player` records (88 bytes, creator `sub_824F8D60`: +64 id −1, +68 skater index, +72 local byte, +76 39, +80 −1, +84 soft) = the MixMap Player slot's 2 instances (`RETAIL_INSTANCES[1]`) |
| manager update | `sub_824F1FB0` (vtable `0x822FD660`) | gated by a game state word == 5 and `sys+560` == 1 (free roam; not decoded further). Finds the local skater (entry +152 bit 31); if its index / id changed since last time, every record is deactivated. Then for every skater entry in list order (`*(*(0x83083C38)+0x2F070)`, count +12, entries of 544 bytes at +240): NPC skaters only when `sub_824F8EF8` holds; an existing active record with the same id (+152 bits 16–19) keeps it (re-created if character +76 / +80 changed); else `create` |
| distance gate | `sub_824F8EF8` | \|entry position − `*(0x830CFDD4)`\| < `0x820D4924` = **30.0 m**. `0x830CFDD4` is the listener point = the camera (GRECX notes) |
| create | `sub_824F2238` | the first record with active byte +52 == 0; when none is free only the **local** skater evicts the first record. NPCs get nothing |
| record update | `sub_824F8E18` | a non-local record whose skater index ≥ count, or whose skater is ≥ 30 m from the listener (or NaN), is deactivated (vfunc 28) |
| record init | `sub_824F8ED0` | copies +64..+80 and sets +84 = 0 |
So: the local player holds instance 0; **instance 1 goes to the first NPC skater (list order) that comes within
30 m of the camera while it is free, and stays with it until it is 30 m or more away.** At most one NPC board
sounds at a time; the other 4–14 AI skaters around the player are silent (their boards; speech is separate).
Ported: `skaters::Slots::assign` (unit tests in the module).
**Recomp check** (`instance1_voices.py SESSION` (local script)): the second Contacts object
(`0x40C710A0`, the CONTACT hook on `sub_824B86E0`) fires in bursts whose impact speeds match a nearby AI board's
SKATEB speed and landing (e.g. 164620: 326.4 s and 329.1–329.5 s = board `468B18F0`, 25 m from the player,
10.4–11.3 m/s, landing 0000000 → 1111000; 394.3 s = `468B1500`, 14 m) while the local player stands still or is
in the air — the earlier note "both contact objects are the player's" was wrong.
## 2. What runs for the NPC instance
Every player SFX object of instance 1 runs; the audio-state bridge `sub_824B0DA8` fills the instance's state from
the skater entry at the record's index (+68), so the components see the NPC's physics. Local tests
(`[record+72]`) select the non-local branches:
| component | non-local behaviour | ported here |
|---|---|---|
| PlayerPhysics inputs (`sub_824B19C8`) | in9 = 32767; in12 = `sub_824B23C8` (below); in13 = \|state+96 − `[G+0x2F078]`+32\| (the NPC's COM velocity against the local player's), capped at 35, slewed ±100/s (state +784), × 32767 / 35 — vault class `0xC1831BDB6CB1B1EA`: `204DCC9296DC9400` = 35, `0395642CA6FC543A` = 100 | `Physics::write_against` |
| 3DObjPos 1.1 / 1.2 (g) | the NPC's COM / board; distances against the local skater and the camera | `ObjPos` at `obj_pos(g)` / `obj_pos2(g)` |
| Contacts / Rail / OffBoard inputs | landing pulse and bucket; landing-material flag local only; OffBoard local only | `inputs::Contacts { instance }`, `write_rail_at`, `write_off_board_at` |
| SkateBoard routing / Class_rolling (`sub_824C5CA8`) | one pass, no held layers 0 / 3 | `Rolling` (already ported non-local); grain binds collected in `NpcSkater::routed` |
| grain bed (SkateBoard update `sub_824C6BD8`) | runs per instance; soft member via `sub_824B23C8`; local only: seam envelope `sub_824CA448`, graph-3 send / level ramp `sub_824CAEC0`, wobbles `sub_824CB180`, graph 3 (`sub_824C8878`), FlangeSub sends 21 / 22, rocket | yes (2026-10-03): `Runtime::npc_grains`, `grain_bed::Bed::for_instance`, shared pick generator (doc 11 "NPC skaters' second grain bed") |
| Rattle | as the local | yes |
| Class_Seams (`sub_824C14C8` / `sub_824C1F18`) | gate = record active byte (+52), not +72: runs for NPCs | yes (the port's `!local` early returns were removed — they were never retail) |
| Class_grind + on / off sounds | w13–w15 = 0 (local-only words); on/off bus `create` = false | yes |
| Class_wheels_skid, Class_Squeaks, Class_foot_drag | soft word via `sub_824B23C8`; w14–w16 local only | yes |
| SenseOfSpeed wind / rattle (`sub_824E7980`) | returns at once for non-local | yes (posts nothing) |
| SFXObj_Contacts Splice one-shots | non-local branches already in `player::contacts` (no landing impact, spread 0, bus create false) | yes |
| collision messages | local byte clear (no pitch override), into the shared `CSTATEMGR_Collision` | `NpcSkater::take_collisions` → host |
| body poster bail helper `sub_824BF5F8` | non-local only: finds the skater's `CSTATE_SkaterSpeech` record (sys+708, PlayerSpeech slot) by id and posts message 8206 / 115 to its +144 object = **an NPC bail grunt (speech)** | no (speech) |
| Wheels spin-down, Tricks, Treatment, OffBoard footsteps, Clothing, board slide | run per instance in retail | not yet (body foley / streams; next step) |
**Soft word `sub_824B23C8`:** local → state +684. Non-local → walk the Player records (`*(0x830CFDC4)+660` = the
slot-1 container, list at +16) for the one with +72 set (the local) and return `(+84 == 0)`; +84 is written by the
bridge with the same bit as +684 (`sub_824B0DA8`: `stw r11,684(r31); stw r11,84([r31+16])`). So an NPC posts the
**soft** words / grain members exactly when the local player's wheels are **hard** (no local record → 1).
Recomp 164620 (user's board hard): soft grain members play 0.6–4.9 voices/s around the NPC bursts against 0.3–0.9
over the session; their GAIN × SEND levels p50 / p90: `concrete_smooth_soft` 0.026 / 0.172,
`asphalt_rough_soft` 0.002 / 0.106, `concrete_aggregate_soft` 0.012 / 0.090, `asphalt_smooth_soft` 0.000 / 0.000
(the local's hard `concrete_smooth_hard` 0.238 / 0.737). Implemented as `skaters::component_state`, and the
components now post `s.soft_wheels` where they called `sub_824B23C8` (seams w11, skid w8, contacts tier,
PlayerPhysics in12) — identical for the local player (`local` is always true there).
## 3. MixMap slots, keys, attenuation
Instance g = 1 of the Player slot: SkateBoard `0x40010800`, Contacts `0x40010810`, Wheels `…820`, Rail `…830`,
Cracks `…840`, SenseOfSpeed `…880`; PlayerPhysics `0x60010800`, 3DObjPos `0x60010810` / `0x60010820`
(`mixmap::keys::*(1)`). Distance roll-off and azimuth come from the slot's own B lookups on those 3-D blocks
(no Collision / Whoosh / Dynamic slot is involved: the earlier guess is withdrawn). Cross-slot sums that read
`A[Player.n]` expand to both instances (mixmap-spec §4.2), so an active NPC instance also feeds e.g. the
Ambience sum — handled by the MixMap itself.
## 4. What the engine must publish (`NpcSkaterAudioState`)
Per NPC skater and frame, in the skater list's order (spawn order is fine; retail iterates its list):
- `id`: stable while the skater lives (retail matches records by id);
- `state`: an `AudioState` filled exactly like the local player's (`skate_events::audio_state`): speeds, COM /
board positions and velocities, wheel contacts / materials / seam patterns / wheel world positions, grind
family / material / impact, air, brake / manual flags, deck tilt / spin, slip, revert, feet in the deck box,
push flags, landing data, `audio_trick` / `audio_trick_2` resolved (−1 none), `dt`. `local` and `soft_wheels`
are overwritten (§2).
The host needs the camera (listener) and the local player's COM velocity and soft flag, which it already has.
Publishing: insert into `game_audio::npc_skaters::NpcSkaters::skaters` (a `Vec` in list order) each frame;
remove despawned skaters. Off switch `SKATE_AEMS_NPC_SKATERS=0`.
## 5. Open / next
- ~~Per-owner grain bed~~ done 2026-10-03: `Runtime::npc_grains` (no graph 3, the local bed's generator), driven by
`grain_bed::step_with` from `NpcSkater::routed` and `skateboard(1)` outputs; dropped + stopped at the release.
Open: what retail's released instance does with its players; the owners' pick order on the shared generator.
- Wheels / Tricks / Treatment / footsteps / clothing for the NPC instance (all per instance in retail).
- What the inactive instance's inputs hold (the bridge skips an inactive record: retail keeps the last values;
we deactivate the 3-D blocks and release the packets).
- The manager's gate (`[0x830670B8+20]` == 5, `sys+560` == 1) and the skater list order of the AI manager.
- The NPC's speech (`sub_824BF5F8`, SkaterSpeech manager `sub_824F7D10`: one record per skater, no distance gate
there) belongs to the speech stage.
@@ -0,0 +1,93 @@
# Pedestrian audio — retail spec, port state, hook points (2026-10-03)
Port: `crates/skate-audio/src/world/peds.rs`. Host: `game_audio/world_sources.rs`. Harness: `tests/world_sources.rs`
(`a_ped_walks_past_and_steps_on_each_plant`). Speech is in `world-speech.md`.
The MixMap Pedestrian slot (5) has 15 instances. Objects: PedestrianSpeech (5.0), PedestrianSFX (5.1), PedBodyFall (5.2), Tazer (5.3).
Every B lookup reads Ctl 5.1, the ped's 3DObjPos.
## Ped record the objects read
- **Audio state `[object+32]`:**
- +52 = active.
- +68 = footsteps on. Who sets it is not traced; probably distance or visibility.
- +73 = foot B down, +74 = foot A down.
- +96 = kind (64 → the close-range outputs).
- +116 = speech target.
- +132 = shoe class 1..5 → footstep w14.
- +136 = the speech value last sent by the state graph (`SendSpeechEvent`).
- +140 / +144 = materials A / B (143 = none → 3).
- +148 / +156 = the speech flag pair.
- **Owner `[object+28]`:** +128 = speed, +144 = 1..5 → footstep w16 (weight?).
## PedestrianSFX (vtable `0x822FCCC8`, factory `sub_824D7E00`)
- **Process `sub_824D8078`:**
- +416 = trunc(curve(speed)), using the OffBoard-tuning curve `90B47430C4ED2CCC` (not the player's walk curve).
- Copy the feet into +52/+236 and zero +53/+237.
- If footsteps are on:
1. Poster `sub_824D81F8`. Post two `livingword_footstep` packets (B first) through `sub_824B77F0`(foot 0 / 1000, +416, eEQChain
`A9023782094771B5` + 10): `[32767,0,4096,foot,25000,0×8,speed,1,1,1,0,0,0,eq]`.
2. Steps `sub_824D8320`. On a foot's rising edge: stop that foot's last Splice sound and start `sk8_foley` (bank 7)
id = run 64 if speed > 7.5, jog 63 if > 2.5, else walk 62. The Clothing thresholds and OffBoard ids are the
same fields as the player's walking voices. Block = [0,1,0,dt,0,1].
3. `sub_824D8E60`: a one-shot +412 flag by camera distance. Not ported, because nothing the footsteps read uses it.
- Finally copy this frame's foot flags into +54/+238.
- **Update `sub_824D81A8`:**
- `sub_824D8658` (if footsteps on) rewrites both packets:
| word | value |
|---|---|
| w1 | raw 0 |
| w2 | pitch 5 (≤ 8192) |
| w4 / w5 | filters 7 / 8 (≤ 25001) |
| w6 | level 9 |
| w7 | level 1, or 2 when kind = 64 |
| w8 | foot down |
| w9 | +53 (always 0 from process) |
| w10 | speech value ∈ {4,5} (jump) |
| w12 | speech value ∈ {6,7} (collision) |
| w13 | +416 |
| w14 | class |
| w15 | footstep surface(material), 1..7 (AudioSurfaceMap word 6) |
| w16 | weight |
| w17..19 | OffBoard `62A2E642…` = 32767 / 7000 / 25000 |
- `sub_824D84D0`: the Splice steps follow block = [level(4 if kind 64 else 3)/32767, pitch 5/4096, raw 0·360/65535,
dt, 0, 1], and a step that has ended is freed.
- **Bank behaviour** (harness and `program_trace` sweeps):
- The program plays a step on w8's rising edge.
- **w14 = 1 is silent.** Classes 2..5 each have their own shoe sample set: 2 → 0–4 / 19–24 / 112–116, 3 → 5–9 / 25–30 /
43–50 / 118–133, 4 → 10–14 / 31–36 / 66–69, 5 → 15–18 / 37–42 / 70–74. Surface layers are 51–60.
- **4,798 of retail's 4,949 `fstep_livingworld` starts (97 %, session 164620) fall in these groups.** Retail used
classes 2, 4 and 5 that session. The other 151 starts (slots 78–100, 117) need word states we have not driven:
w9 / w11 / jump / collision combinations.
- In the harness, a ped at 4 m walking 1.3 m/s gives a step per plant, AEMS voice gains 0.02–0.17 and sk8_foley 0.22–0.28.
Retail fstep_livingworld GAIN×SEND is 0.000 p50 / 0.024 p90 over all distances.
## PedestrianSpeech (vtable `0x822FBF40`): `world-speech.md`
## Not ported yet
- **PedBodyFall:** `Bodyslide`-like falls when a ped is knocked down. Not decoded.
- **Tazer** (`Tazer.abk`, `c_tazer` + `c_tazer_grn_play`): uses AEMS op 38 ControlClass, which the evaluator has not ported.
Retail had 49 starts in 164620.
- **Ped hand props, ATM / vending machine sounds:** the plugins' own objects. Not looked at.
## Waiting on the engine
1. **A ped system** (census spawns, state graphs; `npc-livingworld-re.md`). Per ped it fills
`WorldOwners.peds[id]` with a `PedState`:
- position, velocity and speed;
- **foot plants** from the walk animation;
- materials under the feet;
- shoe class (from the model; 1 = silent);
- weight;
- speech value (the state graph's `SendSpeechEvent` on each state entry);
- footsteps on (near the camera).
2. Speech voice id per ped model (for the speech index).
## Open
- **2026-10-03, gap run G2 (`world-audio-hookin-spec.md` §7.3): settled** +68 (the 3 nearest of a nearest-first
50 m list, manager `sub_824F2890`), +148 / +156 (listener distance / the model's 20 m far threshold), and the
per-model class / kind / voice (`aud_characteristics`, model = voice id). `[obj+28]+144` is dynamic, not a weight.
- Which model → class / weight / kind / voice. These are probably `livingworld_models` fields; find their readers.
- What sets +68 (footsteps on) and +148 / +156.
- The `sub_824D8E60` flag.
- The 3 % of retail slots we do not produce (above).
- Instance assignment: nearest 15, provisional.
@@ -0,0 +1,234 @@
# World speech (peds) — data, mechanism, port state (2026-10-03)
Port:
- `crates/skate-audio/src/world/speech.rs`: the index;
- `speech_manager.rs`: value → event, the gate, the request words;
- `speech_rules.rs`: the `.evt` parser and the line and take choice;
- `peds.rs`: `PedSpeech`.
Setup: `tools/asset_pipeline/world_audio.py`:
- `speech_index`, with `rules` and each clip's `id` / `history`;
- `world_tuning` → `speech_tuning`;
- opt-in `decode_speech`.
Tests: the skate-audio `world::speech_*` unit tests, and `tests/world_speech.rs` on the dev install export.
Trace check: the local tool `speech_takes.py <sessions…>` (the take of each READ, the history rule, the sequence
records).
Dev install staging: `stage_world_audio.py [--decode 501,104,205,101|all]` (local tool).
Harness: `tests/world_sources.rs` `a_bumped_ped_warns_with_a_line_of_its_voice`.
## Data (`data/audio/english/livingworldspeech.big`, EB v3)
- **Clips.** 3,011 `.dat` clips named `<event>_<voice>[_<voice name>]_<line>.dat`. Examples:
- `501_59_busm1_Warn_n`
- `1901_53_Shout`
- `806_47_adtf2_Int_c14_tour`
- Voice ids run 41–96 (`adtm1`, `adtf1/2/4`, `grn1/2`, `joc2/3`, `busm1–3`, `busw1–3`, `tenm1–3`, `stdf1`,
`tenf2/3`, `secg1/2`, `torm1–3`, `torf1`, `bum1–3`, `sktm1/4/5`, `sktf1–3`). Clips without a name in the file name use
a generic voice (53, 91, …).
- **Archives inside** (EB v3, read at boot):
- `livingworldhdr.big`: one `<clip>.hdr` per clip. Layout: +2 u16 take count, +14 u16 offsets of each take's `.sth` row.
- `livingworldsth.big`: one `<clip>.sth` per clip. 12-byte rows = u32 byte offset of the take in the `.dat` + the
take's 8-byte EA SNR header.
- **Takes.** All are codec 3 (EA-XMA2), mono, 36 kHz. A clip holds 1–48 takes, 20,096 in all and 14.3 h. Free-roam events come to
about 9.3 h, about 2.4 GB as PCM16; events 101/104/205/501 alone are 518 MB.
- **Decoding.** vgmstream decodes `<take>.snr` (the 8 header bytes) plus `<take>.sns` (the `.dat` slice) pairs.
- **Recomp reads.** READ lines point at a take's offset inside the clip, not only the clip start (fixed in
`speech_nearfar.py`). `speech_takes.py` maps each READ to its clip and take.
- **`livingworld_Events.evt`** (114 KB): the speech library's event table, 81 events (`501_warn` = 0x2012, …).
- **Decoded:** `world_audio.parse_evt` and `speech_rules::EventTable::parse`. All four banks parse: maincast bank 0,
living world 1, announcer 3, cameraman 4.
- **Header:**
- +4: u32 offset of the name table (32-byte rows);
- +8: bank; +9: sub-bank;
- +0x10: u16 event count;
- +0x18: u16 event offsets × 4.
- **Event:**
- u16 id, u16 queue timeout (60–180), u16 priority (500–550);
- u8 record count;
- u8 external conditions (0 in all banks);
- u8 flags (high nibble = field count);
- u8 probability % (100);
- u8 flags2 (0), then one more byte;
- u16 record offsets × 4;
- 3-byte field descriptors `FF <request word> 04`.
- **Record:**
- weight code (0x39 = 4 × 25 everywhere in the living world);
- probability (100);
- clips << 2 | mode;
- locals;
- field count, then 3 pad bytes;
- one byte per clip (offset × 4);
- u32 field masks (0 = any);
- 8-byte clip entries (u16 `.hdr` id; the rest is 0 in the living world).
- **Fields:**
- 1 = speaker type bit;
- 2 = voice variant bit;
- 3 depends on the event: the near/far flag, zombie (1901, 201, 606, 609), the conversation partner's type (806 / 807),
or the conversation word (550–557);
- 4 = zombie for 603.
- **Type bits** (from the voices of their records):
| bit | type | voices |
|---|---|---|
| 0x1 | adult m | 41 / 42 / 43 |
| 0x2 | adult f | 46 / 47 / 49 |
| 0x4 | granny | 51 / 52 |
| 0x8 | jock | 55 / 56 |
| 0x10 | teen f | 72 / 73 / 74 |
| 0x20 | teen m | 69 / 70 / 71 |
| 0x40 | security | 75 / 76 / 77 |
| 0x80 | tourist m | 82 / 83 / 84 |
| 0x100 | tourist f | 85, **53** |
| 0x200 | skater m | 89, 91, 92, 90 |
| 0x400 | skater f | 94 / 95 / 96, 93 |
| 0x1000 | business m | 59–61 |
| 0x2000 | business w | 64–66 |
| 0x4000 | bum | 87, 88, 86 |
Variant bits: 1, 2, 4, 8 and 16.
- 133 of the 3,348 living-world clip references have no clip (for example 603's tazer lines for some voices). Those
records never play.
- **`.hdr`:**
- u16 id;
- +2 flags (0 in all banks);
- +3 take count;
- +4 runtime;
- +8 history length: the take count, or 0 for 685 clips (conversations 550–557, zombie lines, 497 Silence);
- +9 / +10 size scale;
- +16 u16 `.sth` row offsets;
- then the history ring (cursor + entries, 0xFF = empty).
## Mechanism (TU3)
- **State graphs.** The ped state graphs (`data/state/livingworldentities/pedestrian/aigraph/*.xml`) send
`SendSpeechEvent speechevent=… speechvalue=N` on state entry. The values are listed in `speech::SPEECH_VALUES`, for example
10 CollisionNearbyReaction (wanttoobserve), 25 StopCheer (slamreaction), 23 LongCheer (nearbyskatertrick),
20 Flee, 14 JoinChase, 17 AttemptTakeDown, 19 TakeDownSuccess, 12 ChaseResting, 66 EscapedEndChase.
- The warn (11, `pedestrian_dowarning.xml`) and takedown success are commented out ("moved to the code").
- The value lands in the ped audio state +136. Footstep packets read the same field (jump / collision).
- **SFXObj_PedestrianSpeech** process `sub_824D9908`:
1. When +136 changes, it builds a request to the speech manager `*(0x830CFDDC)`: `sub_824AB6C8` (or `sub_824AC438` with a
target).
2. flag = 1 if +148 > +156 else 2.
3. Values 7 / 8 are remapped to 30 after a 29, else 51.
4. Value 49 goes to `sub_824D9C70`.
5. Value 29 (photographer) repeats on a timer while a global flag is set.
- Ported: `PedSpeech::process` (49 and the 29 timer are not).
- **Speech manager** (`Sk8::Audio::TheSpeechSystem`, global `*(0x830CFDDC)`). **Ported** (`speech_manager.rs`).
- **Request block** (`sub_824D9908`):
- w0 = `+96` type bit, w1 = `+88` variant bit, w2 = flag, w3 = `+92`, w4 = `+120`, w5 = `+124`;
- speaker slot = `+84`.
- The living-world path needs `+116 != 0`. Otherwise `sub_824AC438` sends main-cast events (pros; not ported).
- **Value → event** (`sub_824AB6C8`): the table in `event_for_value`.
- Coin flips use `rand()`; bums use `rand() % 3`; guards get radio lines.
- Value 6 with `+71` also sends main-cast event 0x8017 (not ported).
- **The request** (`sub_824ABA18`), in order:
1. Game-state preconditions.
2. The vault tuning of the event (class `Hash_9C1F48F5D637E275`, `SPCHType_1_EventID`).
3. Timers (`sub_824A8C78`): one per speaker slot × 81 events, f32 seconds, starting at 0.
- The same event needs ≥ `+24` s.
- Any event needs ≥ `+8` s, when `+8` > 0.
- Not-follow needs strictly more than its time. Ids 0 / 294 / 8318 / 33245 / 24752 are skipped.
4. `sub_824A75F0`:
- probability: `rand() % 1000 × 0.1f` < `+20`, or ≥ 99.9;
- the player's speed × 3.6 within `+32` / `+36`;
- the challenge list;
- the `+40` / `+44` timers;
- the `+49..51` game flags;
- zombie mode needs `+60`.
5. w9 = 2 in zombie mode (virtual `[-620]+212` vfunc 156; the `zomb_Shout` records prove it).
6. `sub_824A73F0`: interrupt by priority (not ported).
7. `sub_824ABD90`: the request words of each event.
- The timers restart when the line starts (`sub_824A90B8` from `sub_824A84B8`).
- **Speech library** (generic, `sub_829717A0` …). **Ported** (`speech_rules.rs`).
- `sub_82971480`: the event probability (fails when `(draw>>16)·100>>16 > p`), then the queue slot (not ported).
- `sub_82973CB8`:
- the weighted record order (`sub_82972980`): weight = 4^(b>>5)·(b&31), scale table at 0x82FDB3D8;
- per record: a probability draw, the field match (`sub_82973BD8`) and the clips (`sub_82972D70`: at most 12; a
missing clip fails the record).
- Candidates per clip (`sub_82972660`): the takes not in the history ring; when every take is in it, the oldest one.
- Pick (`sub_82974220`):
- index = `(draw>>16)·n>>16`;
- redrawn (at most 32 draws) while it is among the last min(n/2, 10) picks of the same header in a global 32-entry
(index, header) ring;
- if every draw hits, the one whose match is oldest.
- `sub_82973408`: writes the history when the line starts.
- Generator: add-with-carry at 0x82FDB3C0. Retail shares this state with the grain player's title generator.
- **The queue is not ported** (`sub_82971340` / `sub_82971890` / `sub_82971DA8`):
- 16 request slots and 8 streams;
- highest priority wins, then the newest; older requests are dropped;
- timeout = event +2, in a clock whose unit is not known.
- The recomp plays several living-world lines at once (`overlap.py` (local script)), so the living-world
channel is not exclusive. How is not resolved.
## Reaction → event (measured; `speech_reads.py`, sessions 161849 / 163809 / 164620; the clip starts 0.03 s after)
| reaction | events (evidence) |
|---|---|
| bump → warn | 501 Warn_n (M) |
| bump → warn + taze (female) | 501 Warn_f (M); 330 / 331 tazer lines (name) |
| bump → flee | 108 ChsFlee (M) |
| slam nearby | 104 Slam_n (M) |
| collision nearby | 205 SpecCol, 204 Gasp, 202 HImpRct (M) |
| trick nearby | 101 SpecPos_n (M) |
| chase start | 105 SpecChs bystanders (M), 603 chaser (name) |
| greet / returngreet / conversation | 1901 Shout, 806 Int_c<n>, 807 Rct_c<n> (M) |
| ambient (no reaction) | phone 4402 → 805 → 4405 with 497 Silence; bums 206 / 207; guard radio 102; pro lines from maincast (906/101/130) |
`speech::REACTION_CUES` holds this table, for logs and as evidence. The real choice is `SpeechManager::request`.
`speech::choose` (uniform) is only a fallback for hosts without the rules export.
- **`_n` / `_f` lines: solved from the data.** Request flag 1 (ped `+148 > +156`) means far.
- The flag-1 records name `101_51_GenPos_Grn1_far` / `104_51_grn1_Slam_far`, and the flag-2 records `…_near`.
- All 366 `_f` / `_n` records of 101 / 104 / 202 / 203 / 501 split the same way (`tests/world_speech.rs`).
- **Settled 2026-10-03 (`world-audio-hookin-spec.md` §7.3 G2):** `+148` = the ped's distance to the listener,
`+156` = `aud_characteristics` field `A27215A909135B62` (20 m for peds, 30 for pros), so `_f` lines play beyond 20 m.
- (Was:) what `+148` / `+156` hold (a distance and a threshold?) is not traced. `speech_nearfar.py`'s distance join is still
unreliable.
- **Take choice vs the recomp** (`speech_takes.py`, 10 clean sessions; reads inside > 500 ms audio stalls left out):
- 57 of 57 clips with 2+ plays follow the history rule, including a full cycle. A uniform pick would have repeated a
take in about 13.6 of them.
- 15 of 15 multi-clip lines are one record, in order.
- The first takes of 806 lines are often 0 (6 of 13 in 164620). This may be chance, or a pairing we cannot see. Keep an
eye on it.
- **Coverage.** 40 voices have 501 and 104 lines, 39 have 205, 36 have 101 (harness print).
## Playback (port)
- `speech::SPEECH_BANK` (`1 << 22`) mixer bank. `SpeechSlots` maps (clip, take) → slot. Takes play as direct voices.
- The harness renders a busm1 warn take (5.49 s, peak −9 dBFS at unity gain).
- **Level / pan: open** (next step).
- The stream request (`sub_82973408` → `sub_82C5F310`) is 9 words: offset, size, channel, and the context (ped `+64`).
It goes to a SpeechBank stream. The voice's level is set where those requests are used; that code is not found yet.
- A lead: in 163809 a speech READ comes with a `GAIN … 0.0 → 1.0` and a `PITCH` ratio of ~0.52–0.70 on one voice
(0x40C43350).
- The speech manager reads the PedestrianSpeech owner outputs itself, and which of its 24 outputs is
not known (E0 out2 −1000 dB via B2 is the main volume candidate; out13/14 filters; out11/12 via B20 = 2–70 m
camera distance).
## Waiting on the engine
- **The ped system** must provide:
- speech values on state entry (`PedState.speech_value`);
- the speaker fields (`Speaker`: slot, type bit, variant bit, partner type);
- the near/far pair;
- zombie mode;
- the player's speed;
- a manager clock.
- **The game host:** world_sources logs requests (`AUDIO_WORLD speech …`) but plays nothing. To play, it needs:
1. the index and `rules` from `speech/livingworld.json` → `SpeechIndex::set_ids`, `EventTable`,
`Library::new(ClipHeader…)`;
2. `world_tuning.speech_tuning["1"]` → `EventTuning`;
3. `SpeechManager::request` for each `SpeechRequest`;
4. `SpeechIndex::picks_to_lines` → `SpeechSlots` with the decoded takes, played in sequence;
5. the level mapping.
The JSON → struct mapping is in `tests/world_speech.rs` `load()`.
- Maincast / cameraman / announcer speech (pro skater lines around the player, the cameraman's "own the spot"):
same format, not indexed yet.
## Next steps
1. Level / pan: find the code that uses the SpeechBank stream requests (the 9-word requests `sub_82C5F338` queues) and the
speech voice's GAIN / SEND. Or hook it (category `audio`) and join it with the ped distance.
2. What `+148` / `+156` are: find who writes the ped audio state, or add a first-pass hook on `sub_824D9908` that logs
both.
3. The request queue and stream count for the living world (why lines overlap in the recomp).
4. Main cast / cameraman / announcer: they use the same library and already parse. Their managers differ (other vault
types, `sub_824AC560`).
@@ -0,0 +1,144 @@
# Traffic audio — retail spec, port state, hook points (2026-10-03)
Port: `crates/skate-audio/src/world/traffic.rs` (+ `owners.rs`, `keys.rs`). The game host is
`crates/skate-game/src/game_audio/world_sources.rs`. Harness: `crates/skate-audio/tests/world_sources.rs`.
Read from the TU3 recompilation with `tools/recomp-code-search/fn.sh`; image constants come from `img.py` (local tool).
This is reference only and our own code.
## Objects (one set per Traffic MixMap instance; 4 instances in free skate)
| object | vtable | process (vfunc 9) | update (vfunc 10) | packet | class / bank |
|---|---|---|---|---|---|
| SFXObj_TrafficEngine (obj 4.0) | `0x822FCBA8` | `sub_824D6110` | `sub_824D6478` | `sub_824D5B20`, 18 words | `TRAFFIC_CAR` / `C00_heavy01` … `C08_family03` |
| SFXObj_TrafficSkids (4.1) | `0x822FCC38` | `sub_824D7650` | `sub_824D76B8` | `sub_824D5D20`, 11 words | `TRAFFIC_SKID` / `Traffic_Skid` |
| SFXObj_TrafficHorn (4.2) | `0x822FCBF0` | `sub_824D6E88` | `sub_824D6F98` | `sub_824D5C30` (horn) / `sub_824D0D58` (alarm), 9 words | `TRAFFIC_HORN` / `Traffic_Horn`, `c_car_alarm` / `car_alarms` |
| SFXObj_TrafficWoosh (4.3) | — | — | — | — | No MixMap E record writes its outputs, so it is unused |
- Class handles are in the table at `0x8302EF00..` (engine `EF00`, horn `EF08`, skid `EF10`, alarm `EF20`). Their names are resolved by
`sub_828E3250` from `0x8302D57C` and nearby entries.
- Posts go through `sub_828E2B48` (= AEMS post) and redeliveries through `sub_828E2D18`. Both run inside lock `*(0x83086E10)`.
- **Bank selection by patch.** Each `C0n` program destroys its instance unless packet w14 equals n. All 8 banks are loaded
at once (recomp `all_20261002_164620`: READs at 8.8 s), and every retail post sounds in exactly one bank.
- Harness `engine_banks_answer_only_their_patch`: patch n → only `C0n`. Patch 2 (the `default` record) and patch 9
are silent because there is no C02 bank.
## Vehicle record the objects read (`[object+28]`)
| offset | meaning | used in |
|---|---|---|
| +48 | position (vec4) | engine front/rear split |
| +112 | a direction vec4 (taken as heading; **unverified**) | engine split |
| +144 | signed scalar × 3000 → engine w15 / skid w6 (**meaning unknown**; throttle or acceleration?) | |
| +148 | speed m/s | RPM, w13 / skid w4 |
| +156 | horn state: 0 none, 1–5 horn kind, 6 car alarm | horn |
| +160 | skidding flag (0/1) | skid w5 |
| +168 | `aud_traffic_engine` record key | engine, horn |
The owner context `[object+16]` holds +52 = active (byte) and +60 = frame dt.
## TrafficEngine
- **Activation** (6110: active and no packet yet):
- Look up the record and store its patch at +48. Patch 1 becomes 7 if `rand()%100` < 33, 8 if < 66, else stays 1.
Patch 3 becomes 6 if `rand()%100` < 50.
- Post `[0,0,0,0,0,0,0,0,4096,25000,0,32767,0,0,clamp(patch,0,9),0,0,0]`.
- Set rpm +52 = 0, wobble +60 = 0, direction +64 = 1, idle/max (+72/+76) from record +0/+4, and the tuning
+80..+112 from the fields below.
- **Record `aud_traffic_engine`** (class `0x259095163B974174`; setup exports it as `world_tuning.traffic_engine`):
| field | default | role |
|---|---|---|
| idle `10C7…` | 850 | |
| max `DD02…` | 4000 | |
| patch `C436…` (u16) | 2 | |
| `2C15…` | 8 | wobble bound |
| `D048…` | 4 | wobble rate |
| `E6B3…` | 0.5 | rise |
| `7EA0…` | 2 | fall |
| `7FD8…` | 2000 | slew RPM/s |
| `E67C…` | 7 | gear speed step |
| `07CE…` (u16) | 4 | gears |
| `763D…` | 20000 | rear bias |
The named records (`c00_heavy01` … `c08_family03`) override only idle, max and patch.
- **Update** (6478, per audio-manager pass):
1. TrafficEngine.in0 = trunc(clamp01(speed·0.05)·32767).
2. Wobble += rate·dir·dt (fused), and dir flips past ±bound.
3. The gear top is the first n·gear_speed (n = 1..gears) above the speed, or gears·gear_speed.
Target = speed·max/top + wobble, clamped to [idle, max].
4. Slew: step = slew·dt. If diff ≤ −step, rpm −= fall·step and w17 = +1 for 3 updates. If diff ≥ step, rpm += rise·step and
w17 = −1 for 3 updates. Otherwise rpm = target. Then clamp to [idle, max].
5. Words:
| word | value |
|---|---|
| w0 | rpm |
| w1 / w2 | level 3 / 4 split by dot(direction, pos − camera) × rear_bias × −1/32767 (front loses, rear gains when driving away) |
| w3 = w4 | level 5 |
| w5 / w6 / w7 | raw 0 / 1 / 2 |
| w8 | pitch 6 (B0's Doppler) |
| w9 / w10 | filters 7 / 8 |
| w12 | level 9 (the B7 distant layer, 4–90 m) |
| w13 | speed·2000 |
| w15 | +144·3000 |
- MixMap: B0 = Ctl 4.1 (c 520), B1 = Ctl 4.2 (c 1557), B2 = Ctl 4.3 (c 554). A11 = B13 near boost +650 mB, gated by
`TrafficCarPhysics.in0` (Ctl 4.0), whose writer is not traced (we write nothing, so the gate is closed).
- Harness (C01, 12 m/s, lane 8 m):
- Pitch is 4182 approaching and 4007 receding (Doppler).
- out3 peaks at the closest approach and is 0 beyond about 45 m.
- RPM rises to 3434 (12·4000/14).
- Only C01 voices sound, and voice gain peaks at 0.199 at 8 m.
- For comparison, the recomp's C01 GAIN×SEND is 0.002 p50 / 0.049 p90 / 0.281 max, mixed over all distances.
## TrafficHorn / car alarm
- **Process:**
- Post the horn once, with variant `rand()%9`: `[0,var,0,4096,0,25000,0,32767,0]`.
- While horn state = 6, also post `c_car_alarm` with variant `rand()&3`: `[0,32767,32767,0,4096,25000,0,0,var]`.
Release it when the state leaves 6.
- **Update, state 6:**
- Horn.in0 = 0 and horn w2 = 0.
- Alarm words: w0 = 32767, w1 = level 6, w2 = level 10, w3 = raw 5, w4 = pitch 7 (≤ 8192), w5 = filter 9.
- **Update, other states:**
- A horn kind sounds only when the vehicle record's patch ≠ 0 (heavy01 never honks).
- in0 = 32767 while honking.
- Words: w0 = 32767, w2 = kind (≤ 6), w3 = pitch 2, w4 = raw 0, w5 / w6 = filters 3 / 4, w7 = level 1, w8 = level 8.
- In 164620, Traffic_Horn had 115 starts and car_alarms 12.
## TrafficSkids
- Process: post `[0,0,0,4096,0,0,0,0,25000,32767,0]` once.
- Update:
| word | value |
|---|---|
| w0 | level 1 |
| w1 | level 2 |
| w2 | raw 0 |
| w3 | pitch 3 (≤ 20000) |
| w4 | speed·2000 |
| w5 | skid flag |
| w6 | +144·3000 |
| w7 | filter 5 |
| w8 | filter 4 |
| w10 | level 6 |
The program gates on w5. Traffic_Skid had 139 starts in 164620.
## Waiting on the engine
1. **A vehicle system** (traffic AI on the road network, notes `npc-livingworld-re.md` §2/§6). Per vehicle it fills
`world_sources::WorldOwners.vehicles[id]` with a `VehicleState`: position, velocity, direction, speed, load,
horn state (honk when blocked, alarm when hit), skid flag, and the `EngineRecord` of its model
(`Library::world_tuning().engine("c04_taxi01")`). Which record each model uses is not traced
(`livingworld_models` / `vehicle_characteristics` → the record key at +168).
2. Nothing else: banks load on the first published frame, and instances, inputs, posts and releases are handled.
## Open
- **2026-10-03, gap run G1 (`world-audio-hookin-spec.md` §7.3): settled** the model → record table, +112 = heading,
+144 = acceleration (m/s²), +152 = horizontal listener distance (40 m list), the TrafficCarPhysics.in0 writer
(`sub_824B2A28`), nearest-4 holders. The items below are superseded where they say otherwise.
- Retail's instance assignment (which 4 vehicles get the Traffic slots): ours is nearest-4, provisional (`owners.rs`).
- Where the 3DObjPos blocks 4.1 / 4.2 / 4.3 point (body / engine / exhaust?): find the binder that stores into
`[pos+32]` for traffic. We put all three at the body.
- Vehicle +112 (heading or velocity?), +144 (what), the TrafficCarPhysics.in0 writer.
- `rand()` = `sub_82A8AF10` (CRT): its generator is not identified; ours is an LCG behind `world::Draw`.
- Process/update order in our host: update first, then process. So inputs are one console frame old; move the
calls into `native::mixmap_frame` once a system exists.
- Validation still to do: compare per-voice gain vs distance with VEHSTATE positions. This needs a camera position
in the trace: hook the listener (`*(0x830CFDD4)`) or use PEDSEE targets with care. The `speech_nearfar` test
showed those joins are unreliable.