mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
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:
122 files changed
+10368
-485
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user