Files
skate-3-rust-engine/docs/hails-additions/11-audio.md
T
Hails df012ec5dd Docs and tools: riding collision posts settled, couch testing, recomp bail / collision analysis
- tools/recomp-trace: bail_impacts.py (per-bail ragdoll impacts and sound posts) and collision_posts.py (collision-sound posts by poster and owner, with the local-rider flag); README entries.
- Doc 11: listening verdicts, the riding body-collision posts are NPC skaters' (local only on big-drop landings and bumps).
- Doc 13: playing the recomp from the couch and closing it (settings overlay, LB+RB+Back hold).
- Doc 14: the added tools.
2026-10-03 14:30:17 -06:00

4406 lines
340 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 11 — Game audio (ambience, rolling, trick cues, footsteps, water)
**Branch:** `gameplay/audio` (from `gameplay/water`; partly committed 2026-10-02; the world-audio work since then is uncommitted until the user's batch commit). Upstream form: `audio/retail-audio`, draft PR #32, marked Work in Progress. **Status:** implemented and tuned
by ear over ~15 play sessions with the user (2026-10-01). Most cues are confirmed in play; the rest are marked
**not yet confirmed** below. All sample choices are project choices, not retail event data.
## Problem
The engine made no sound of its own. Only Lua mods could play audio
(`crates/skate-game/src/modding/audio.rs`, PCM WAV through Bevy's `bevy_audio`).
Skate 3's sounds are on the owned disc but nothing extracted or decoded them.
## What is on the disc
Everything audible is under `data/audio/`. All archives except `music/overlays.big` are EB BIG v3
archives readable with `tools/owned_game/big.py`.
| Archive | Contents |
|---|---|
| `ambience.big` + `ambienceresident.big` | 24 per-area ambience beds (`.sns` bodies, `.snr` headers), e.g. `04_dt_main`, `09_univ_campus`; 5-channel 48 kHz, ~2–2.5 min loops |
| `audiofiles.big` | 376 ABKC `.abk` banks, 20 SPLC `.bnk` banks, 23 per-map `.ems` emitter files, 9 MOIR `.csi` AEMS event scripts |
| `grains.big` | 14 rolling "grains", 7 surfaces × soft/hard wheels (`asphalt_smooth`, `asphalt_rough`, `concrete_smooth`, `concrete_rough`, `concrete_aggregate`, `wood_ramp`, `metal_smooth` hard only) + `x_jet_rolling` |
| `wheels.big` | `Whls_spins_Jump_1`, `Whls_spins_Man_1` (+ `.sek` seek tables) |
| `post.big`, `nis.big`, `english/…`, `music/…` | stingers, speech, cutscenes, licensed music — not used |
### Formats (worked out from the archives; `tools/asset_pipeline/audio_formats.py`)
- **Codec:** every sample is an EA SNR (EAAC) stream with codec 3 = **EA-XMA** (Xbox 360 XMA2),
mostly mono 48 kHz (also 44.1, 36, 24 and 22.05 kHz).
- **SNR header:** `u32 version(4)|codec(4)|channels-1(6)|rate(18)`, `u32 type(2)|loop(1)|samples(29)`,
optional `u32 loop start`. RAM streams are followed by blocks of `u8 flag (0x80 = last)`, `u24 size`,
`u32 samples`.
- **SPLC `.bnk`:** stream count at 0x18, 36-byte parameter records, then that many SNR streams back to
back at **unaligned** offsets. The scanner finds exactly the declared count in all 20 banks (2,266 streams).
- **ABKC `.abk`:** embedded SNR streams; the scanner's count matches vgmstream's subsong count for all
376 banks (5,168 streams).
- **`.grain`:** `u32` offset of the SNR stream, `f32` duration (matches samples/rate exactly), a
`.sek`-style seek table, then one SNR stream. Each recording is a **slow-to-fast rolling sweep**: over
17–22 s it gets ~10 dB louder and brighter (e.g. concrete_smooth_hard −24 → −14 dBFS, centroid
0.9 → 2.0 kHz), so looping the whole file sounds wrong.
- **5-channel ambience** order is L, C, R, Ls, Rs (from inter-channel correlation).
- **`.csi` (MOIR)** event scripts name events, classes and variables (`play_grind_start`, `Class_Flips`,
`Ollie_Rattles`, `surface_type`, `playercharacter_footstep`, `body_imp_Torso`, …) in 16-byte records
(name offset, 16-bit id, default value). The banks hold no per-sample names; linking events to samples
would mean re-implementing EA's AEMS runtime, which this change does not do.
- Retail mixes at runtime: many raw samples peak at 0 dBFS with RMS up to −6 dBFS, so playback levels
must be low; footstep recordings are the opposite (peak ≈ −11 dBFS, RMS ≈ −28 dBFS).
## Change — current design
### Setup: new `audio` asset group
- `tools/asset_pipeline/audio_formats.py` — SNR scanner/validator (block-chain check), SPLC and grain
readers, standalone `.snr` cut-out, gain-free 5→2 downmix (weights per output sum to 1), loopable
band cutting for grains.
- `tools/asset_pipeline/audio_export.py` — decodes only what the engine plays to PCM16 WAV in
`assets/private/audio/` with `audio_manifest.json` (version 2): 22 ambience beds (stereo), 14 grains
cut into **6 loopable speed bands** each (84 files, 0.08 s loop cross-fade), 2 wheel spins and 23 sample
banks (2,718 samples). No normalisation or gain. ~40 s, ~615 MB.
- **Decoder: vgmstream r2117** (`vgmstream-win64.zip`, SHA-256 `6c4a8a38…dc6c`, matching GitHub's
published digest), downloaded and verified by the existing `install.dependency()` into
`data/tools/vgmstream-cli/`. It bundles FFmpeg's XMA2 decoder (LGPL DLLs). vgmstream reads SNR/SNS and
ABKC itself but not SPLC or grain files, so every stream is cut out to a standalone `.snr` first and
decoded in batches of 256 (one vgmstream run stops accepting input files between 600 and 1,100 arguments).
- Registered as group `audio` (`versions.py` `GROUPS`/`SOURCES`, `group_receipts.py` `ROOTS`,
`asset_exports.audio`, `install.py`). **Optional content:** a failed download or decode records
`assets/private/audio-availability.json` and setup still succeeds; the game then runs silent. Existing
installs refresh only this group.
- `.gitignore`: retail audio extensions and `*.wav` (except the Skyline Drive mod's own WAVs).
### Engine: `crates/skate-game/src/game_audio/`
| File | Role |
|---|---|
| `mod.rs` | `AudioSettings` (master **75 % by default** — 100 % is unity gain; 25 % (−12 dB) was too quiet to judge retail parity in play, user 2026-10-02 —, ambience 100 %, effects 100 %, 5 % steps) saved to `settings/audio.json`; menu rows in GRAPHICS; `--mute`; `GlobalVolume` follows the master so mod audio obeys it; the single `SpatialListener` (moved out of `modding/audio.rs`) follows the gameplay camera; preloads every cue sample, grain band, wheel spin and water piece at startup (~207 clips, ~35 ms) so riding never reads the disk. |
| `library.rs` | Loads the manifest; reads WAVs into `Assets<AudioSource>`, measuring each clip's peak; rejects paths outside the audio folder; warns once per missing file. |
| `voices.rs` | Voice pool and **loudness rules** (below). Everything pauses while the menu is open or a replay runs. |
| `cues.rs` | The sample table, per-cue minimum gaps, metal-surface test, surface → grain table. |
| `skate_events.rs` | `observe` (FixedUpdate, after each physics tick) turns state changes into cues; `play` (Update) plays them and drives the loops (rolling, grind, powerslide, foot drag, wheel spin). |
| `ambience.rs` | One bed per map, level 0.6, 2 s cross-fade on map change. |
| `emitters.rs` | Retail world emitters from the map's `.ems` file (shape, falloff, 5-emitter pool) with measured bank programs (water banks). |
Outside the module (observation only; physics never reads any of it):
- `physics/animation_input.rs` — `AudioEvents` latch. `push_contact` (flags bit 27), `brake_contact`
foot-down/up (bits 28 + 30 / 23) and `AudibleFootStepStrength` are cleared within the tick
(`finish_output_publication`, `ScalarAttributeInputs::reset`), so they are latched while the animation
attributes are processed and taken by `observe`.
- `physics.rs` `foot_clearance` — each animated foot's height above its ground line test (via
`biped_ground::services::feet_input`).
- `skate-core` `WheelLineState.audio_surfaces` — audio surface (`surface_tag & 0x7F`) per wheel, next to the
existing `physics_surfaces`. Additive output only.
### Loudness rules (`voices.rs`)
- A requested volume may raise a quiet clip only until the clip's own peak reaches full scale, and never
more than ×4; category and master volumes (both ≤ 1) then scale it down. No clip plays hotter than
full scale × master.
- One-shots start at their final level on the first sample; loops and sounds that ask for a fade start
silent and fade in (≥ 10 ms). (Until 2026-10-01 every sound faded in and its gain was raised once per
frame from 0, which ate the attack of 25–35 ms impacts and knocks — pops and landings lost their
layering.) Retail layer offsets are kept with `Record::delay` (frame-granular). At most 32 voices, 3 of one sound, same sound again only
after 40 ms; each cue also has a minimum gap (`cues::min_gap`: step 0.2 s, run step 0.15, push 0.3,
bail 0.5, splash 1.0, others 0.12) as a safety net against flags that stay set for many ticks.
- One-shots get a random ±4 % pitch. Spatial attenuation scale 0.1 (Bevy's attenuation never amplifies).
### Cues (as in `cues.rs` / `skate_events.rs` now)
"Level" is the cue level in `cues.rs`; the scale column multiplies it. Fraction = board speed / 10 m/s.
| Cue | Trigger (per physics tick) | Samples | Level × scale | In play |
|---|---|---|---|---|
| Pop | `ground_animation.launched` rising | retail recipe: knock 1112 + 878 + 891 at once, crack 1096 + one of 1097-1099 at +20 ms, body 1111 +30 ms, low thud 876 +40 ms (traced offsets) | retail median × 2 | Rebuilt from 57 traced pops + captured sound; not yet confirmed |
| Flip / grab whoosh + catch | new trick name while airborne (a held grab re-announced by scoring stays silent) | `Sk8_Air_Flip_Tricks` 1–3 (retail's Class_Flips) + container 1115 (feet catching the board) | 0.45 / 0.8 | Retail mapping new |
| Land | filtered category Air → Ground, air not begun on foot; impact = `riding.ground.maximum_closing_speed` | container 1095 (wheels down, ~1 s) + impact family 1055 / 1058 / 1052 by impact (< 3, < 6, ≥ 6 m/s) | (0.6; 0.5 / 0.64 / 0.72) × (0.3 + 0.7·impact/12) | Retail mapping new; the impact-family choice is ours |
| Board down | Air → Ground when the air phase began on foot (on foot within the last 0.5 s) | container 1126 (foot on deck) + 1119 (knock) + 1054 (wheel touch) | (0.6 / 0.54 / 0.28) × (0.3–1.0 over 0–5 m/s) | Retail mapping new |
| Board down heavy | same, impact > 2.5 m/s ("caveman" jump-on) | + landing set 1095 | 0.6 × (impact − 2.5)/4 | Retail mapping new |
| Grind | `grinds.grinding_316` or a grind state, and not `leaving_317` | start: container 954 (ledge etc.) or 1146 (metal rail object); then metal: `GRINDS` 54–56 loop; other surfaces: container 955 pieces (202–206) re-fired every 63 ms | start 0.5 / 0.8, loop 0.26, pieces 0.3; × (0.5 + 0.5·fraction) | Retail mapping new |
| Powerslide | state 101 SlideGround: random piece every ~0.08 s (±20 %), 10 ms fade-in | `WHEEL_SKID_BANK` 0–7 | 0.2 × fraction | Not yet confirmed |
| Foot drag | between `brake_contact` foot-down ticks (ends at foot-up or 0.1 s after the last foot-down), riding on the ground above 0.5 m/s; piece every ~0.11 s, 50 ms fade-in | `FOOT_DRAG` 36–39, 44–47, 60–64 | 0.25 × (0.3 + 0.7·fraction) | **Confirmed** |
| Push | `push_contact` rising edge | `fstep_skateshoe1_sm` 74–84 | 0.6 | Plays at a sane rate (no flood); sound not judged separately |
| Step / run step | foot strike (below) while on foot (states 500/501) and moving > 0.3 m/s; run set above 3 m/s | `fstep_skateshoe1_sm` 74–84 / 6–17 | 0.85 / 0.68 × (`AudibleFootStepStrength`/4, 0.3–1.0) | **Confirmed** ("almost perfect") |
| Bail | entering WipeoutGround (300), not in water and no splash in the last 0.5 s; tier by body speed (≥ 3.5 / ≥ 7 m/s) | body hits container 1074 (records 667–671) + `Bodyslide` 0–4 / 5–9 / 10–15 layer | 0.52 × (0.6 / 0.8 / 1.0) + 0.25 | Bodyslide tiers heard as right; body hits = retail mapping, new |
| Splash | body water contact (`collision_feedback.flags.material_12`) rising while falling > 1 m/s (1.5 s cooldown); or entering a wipeout in water with no splash in the last 1 s (speed ≥ 2) | `Skate_Collisions` 476–478 | 0.5 × (0.4 + 0.6·speed/8) | **Confirmed** (found by the user); the wading-wipeout path not yet confirmed |
| Rolling (loop) | wheels in contact, not grinding, > 0.3 m/s; grain by majority wheel audio surface; speed band | `grains/<surface>_hard/<band>` | 0.15 × min(speed/1.5, 1) | **Confirmed** |
| Wheel spin | take-off above 1 m/s, cut on landing (0.05 s) | `Whls_spins_Jump_1` | 0.15 × fraction | Not yet confirmed |
**Rolling bands:** board speed is low-passed (0.25 s) and mapped over 0–10 m/s to the 6 bands; the band
changes only when the speed is a full band away and after ≥ 1 s on the current one; changes cross-fade
over 0.15 s, and within a band the pitch follows speed by ±6 %. A new surface must stay under the wheels
0.25 s before the grain changes; after 0.15 s off the ground the loop stops and restarts directly on the
surface it lands on.
**Footsteps:** `FootStrike` (in `skate_events.rs`) learns each foot's resting clearance (follows the
lowest value, drifts back up over ~2 s) and fires when a foot that lifted > 5 cm above rest comes back
within 2 cm. `AudibleFootStepStrength` only scales the volume.
**Surface → grain (`cues::grain_for`), CORRECTED 2026-10-02 to retail's own surface map.**
The old table was our own guess by surface name. The grain player research
(`.claude/notes/grain-player-spec.md` §1.4) decoded retail's chain: a wheel's 7-bit audio surface tag →
material (tag − 1) → the vault's `Sk8::AudioSurfaceMap` → rolling surface 1–14 → grain. The function now
follows it exactly:
| Rolling surface | Grain | Tags |
|---|---|---|
| 1 | asphalt_rough | 2 |
| 2 | concrete_rough | 4, 66 |
| 3 | asphalt_smooth | 1, 55, 56, 61, 62, 71–83, 86–88, ≥ 95, 0 (no material): **the default** |
| 4 | concrete_smooth | 3, 51–54, 57, 60, 63–65, 92, 93 |
| 5 | wood_ramp | 6, 7, 41–50, 58, 59, 94 |
| 6 | concrete_aggregate | 5 |
| 9 | metal_smooth | 9, 11–36, 38–40, 84, 85, 89, 91 |
**What changed audibly:**
- **Asphalt is the default:** unlisted and high tags now play asphalt_smooth, not concrete_smooth.
- **Wood:** tags 47–50, 58, 59 and 94 now play wood.
- **Asphalt:** 55 and 56 now play asphalt, not aggregate.
- **Concrete:** 51–54, 57, 60, 63–65, 92 and 93 stay concrete_smooth (now explicit).
- **Metal:** 84 is now metal.
**Still interim, marked in the code:**
- **No grain in retail.** Rolling surfaces 7 (tags 10, 70), 8 (8), 10 (67), 12 (68, 69) and 13 (37)
play a per-surface `Class_rolling` patch (selectors 1, 2, 10, 11, 9), which needs the AEMS evaluator.
Until then they keep stand-in grains: aggregate for 8/10/70, metal for 37/67–69.
- **Uncertain:** tag 90 (rolling surface 0, no grain member) keeps wood_ramp.
- **Soft wheels:** only the hard members play. Retail picks the soft member when an audio-state flag is
set (Motion+200 < 0.5, meaning not decoded yet). Surface 9 has only a hard member.
- **The speed-band loops are still interim:** retail's grain player reads windows from the sweep (two
players per truck, a Bézier speed → position curve, equal-power crossfades, a grain every 0.3 s). See
the native-runtime research.
### Measured retail mapping (2026-10-01)
The pop/flip/landing/board-down/grind/bail cues above use the retail SPLC patches that a **local
recompiled build** of the game (github.com/mchughalex/skate3recomp, built and run locally only, no code
taken) was measured playing for the same moments. The recomp was instrumented to log every sample start
with its final gain (gain modules × send), every SPLC patch request with the gameplay function that asked,
and every audio-object post; the user played normally while it logged, and the events were grouped by
the game's own requesting function. Exact moments came from a scripted ollie/kickflip:
| time after the flick | retail plays |
|---|---|
| +0.24 s (pop) | containers 1096 + 1098/1099, flip object (Sk8_Air_Flip_Tricks 1–3) |
| +0.3–0.6 s (catch) | deck knocks 1112 / 1115 / 1118 (548–552 + 530–535) |
| +0.95 s (landing) | 1095 (396–404, ~1 s) + one impact family of 1052/1055/1058 + wheel skid |
| getting on the board | 1126 (422–426) + 1054 + 1095 + knock 1119 |
Measured levels (linear, retail mix): knocks 0.36–0.57, pop/landing sets 0.2–0.46, board-down 1126 up to
0.76, ledge grind pieces ≤ 0.35, rail start 0.49, bail hits ≤ 0.26, Bodyslide ≤ 0.19, GRINDS ≤ 0.13,
rolling grains p90 0.065. Our levels = retail × 2.0 (`cues::RETAIL_SCALE`), calibrated so retail's
rolling level lands on our play-tested `ROLL_LEVEL`. Not decoded yet: how retail picks among the impact
families 1050–1059 (surface/force); we choose by impact.
### World emitters from the retail `.ems` files (`emitters.rs`, 2026-10-02)
Replaces our own water placement (`water.rs`), which grouped water collision into bodies and started a
random 1–2.5 s piece every 0.7–1.3 s. The pieces piled up 2–3 deep; the user heard the University plaza
water "overlapping or playing too many times".
**Data (setup, `audio_formats.py` / `audio_export.py`, manifest version 4):**
- `.ems` layout: BE u32 count + 72-byte records: index, flags, position[3], extent[3], scalars[4],
u64 sound id, gains[4].
- The sound id is Bob Jenkins' lookup8 64-bit hash (seed `0xABCDEF0011223344`) of a name, written as named
or lower-cased.
- Each id keys a record of the emitter attribute class `0xF0CEF367088EFFF8` in the disc's
`skatercollections` database, resolved through its parents:
- volume, bank file, patch (the selector the bank's program plays);
- `eVolumeType` (1 = looping emitter, 5 = reverb zone);
- `eVolumeFalloffType`;
- a float that defaults to 10 (probably a one-shot duration; unverified).
- The exporter writes all 23 `.ems` files with resolved attributes. It also decodes every bank the
`sfx_*` / `skateschool` emitters use: 100 banks, 95 of them new. All 486 sound emitters resolve to a bank
on the disc.
**Game side (from TU3, reference only):**
- **Shape:** a record is a sphere (equal extents) or an ellipsoid. Its semi-axes are the extents along
forward = scalars[1..4], up and side (side = Y × forward).
- **Core:** scalars[0] is an inner core with full level. Outside it the distance is rescaled to the edge.
- **Level** = attribute volume × falloff: (1−d)², 1−d or flat.
- **Pool:** at most 5 emitters play (`CSTATEMGR_Emitter`'s pool), taken in the order they were reached. An
emitter the listener leaves is released at once (we use a 50 ms fade only against clicks).
- **Flags:** records with non-zero flags are gated by a context mask we haven't identified, so they are
skipped.
- **Gains:** the four gains are never read by the game.
**Bank programs (interim):**
- What a bank plays is its own AEMS patch program. Run through the PoC evaluator:
- `water_fountain`, `water_lapping`, `water_lapping_pond` and `ocean_wave_small` are two-voice relays of
one-shots from per-voice shuffle bags, the next piece starting about 0.17 s before the current one ends;
- `water_dam_*` and `fountains_waterlaps_*` are single loops;
- each has slow level and pitch modulation (e.g. fountain pitch 0.82–0.94).
- `emitters.rs` `PROFILES` reproduces those measured patterns for the water banks until the AEMS evaluator
is ported. Banks without a profile (trees, birds, HVAC, interiors, …) are not played yet.
University: the plaza channel is `fountains_waterlaps_left` (a 37 s loop; patch 347) in a 6 × 4 × 47 m
ellipsoid, next to `water_fountain` in a 13 × 7 × 61 m one.
### Random distant one-shots: sirens, horns, dogs, jets (`random_sets.rs`, 2026-10-02)
Retail has occasional police sirens and other distant city sounds. They come from a location-based
random one-shot scheduler, not from placed emitters.
**Location → set:**
- The game queries the district's world-painter region layer `audio_emitters` at the skater's x, z.
- The layer is 128 m tiles of quadtrees in the `cSim_*.xsf` streams of `worldDIST_<district>.big`.
- Each leaf holds the key of an `aud_wp_emitters` record (57 sets). Examples:
- `e_dwtn_office_buildings`: a sound every 2–5 s at level 0.2–1.0, 23 weighted sounds including city
sirens;
- `e_dwtn_spillway_brewery` (the Aletown spawn): every 4–8 s, 15 sounds including 5 sirens.
- Setup exports the sets (`audio_export.random_sets`) and the region tiles (`audio_export.regions`,
parser `audio_formats.region_layers`), and decodes their banks. The manifest now has 222 banks.
**Scheduler (from TU3):**
- At most 2 banks are loaded, each picked by weight.
- A timer fires the loaded sound that was loaded longest ago, every uniform min..max seconds.
- Level = sound volume × a 0.1-step random factor in the set's range.
- A post comes from a random fixed direction (not positional) and ends with its program or its timeout:
sirens 19.5 s, dogs 5 s.
- Each bank's program (layers, delays, pan sweep) is measured per bank in `random_programs.rs` until the
AEMS evaluator is ported.
- Four banks never answer their attribute's selector (`Siren_city_5/6`, `car_horn_city_2/3`), so they
stay silent here as in retail.
**Verified:**
- A recomp trace at the Aletown spawn shows set `e_dwtn_spillway_brewery` firing a sound every 4.5–7.8 s,
including `Siren_distant_2` and `Siren_city_8`.
- Our region lookup returns the same set there, and `e_univ_megapark` at the University start, as the
recomp does.
- A 15-location recomp run (5 teleports each in Downtown, Industrial and University, 45 s at each) gives
our lookup the same set as retail on **1,859 of 1,859** position samples, across 15 different sets.
Retail's fired sounds fit each place: sirens, horns and a car crash downtown; seagulls at the docks;
wolves and coyotes at the Spillway; hawks and rockfalls at the mega-park.
- Retail fired `Siren_city_5` at Hotel District and no siren sample played, so the banks we keep silent
are silent in retail too.
- **Long runs (5 minutes per location, 2026-10-02)** — *re-verify on clean data: the long_* sessions were deleted
(malformed, interleaved trace lines; user, 2026-10-02):*
- **All 36 Challenge Map locations** (Downtown 14, Industrial 12, University 10). Our location sets match
the game on **26,817 of 26,817** samples, across 28 sets.
- The game's ambience zone (trace hook AMBST) equals our `audio_ambience` lookup at every location in
all three districts.
- Every drawn interval lies within its set's range.
- About 60% of intervals fire a sound, matching the 2-loaded-banks rule.
- Rare sounds need long listening: `work_whistles` never fired in about 20 minutes of the factory set,
nor hawks and rattle_snake at the mega-park or cicadas at the Observatory in 5 minutes each.
- Tools: `.claude/skills/audio-tuning/tools/{check_sets_vs_trace,long_run_stats}.py`.
- Muted in-game run: our engine at Aletown selects the same set and fires sirens, horns, buses and bangs.
- The small parks have no `audio_emitters` layer, so no random one-shots play there.
- `SKATE_AUDIO_SET=<name>` forces a set for testing.
### Zone ambience beds (`ambience.rs`, 2026-10-02): replaces the per-map bed choice
**Data:**
- The zone is the district's region layer `audio_ambience` at the skater's x, z: an `aud_wp_ambiences`
record (23 zones) with volume, bed number, fade-out time (+8) and fade-in time (+12).
- Bed number `NN` is the `ambience.big` stream whose name starts with `NN_`. Index 19 exists twice; retail
takes the first in TOC order, `19_reclaimed_b_fix`, and so do we now.
- `aud_wp_ambience_crossfades` (23 pairs, e.g. `dt_03_main2dt_open`) map a zone pair to a group of the
district's `Main_Ambience_Crossfade_DT/Ind/Uni` bank, with a level.
- Setup exports zones and crossfades (`audio_export.ambience_zones`) and decodes the three crossfade banks.
**Behaviour (TU3 SFXObj_Ambience, reference only):**
- **One bed at a time.** On a zone change the old bed fades out linearly over the old zone's fade-out
time and stops, then the new bed fades in over the new zone's fade-in time. A return during the
fade-out fades the same bed back in; a change during a fade-in waits until it ends.
- **Crossfade layer.** For the whole transition the crossfade group of the pair (either order; group 1,
level 1.0 by default) plays: four looping 2 s voices at quad directions, rear voices at ≈0.70 (measured
per group, `crossfade_groups.rs`). It stops when the fade-in ends.
- **Districts.** The parks have no crossfade bank, so no crossfade layer there.
- **No panning.** Beds aren't positional.
- **Old installs.** Without zone data (manifest v3) the old per-map table below is the fallback.
**Verified (muted run, DownTown):**
- The spawn is `dt_less_busy`. Moving into `dt_main` played crossfade group 2 at level 1.75 (the data's
`dt_02_less_busy2dt`), then the `dt_main` bed.
- The teleport to Aletown played group 3 at 1.25 (`dt_03_main2dt_open`), and `06_dt_open` started after
`dt_main`'s 1.75 s fade-out.
- The teleport to Hotel District used the same pair in reverse.
**Bed level (verified 2026-10-02):** the Ambience MixMap's bed output carries a **−11 dB base**. Retail
captures at four zones were checked with `.claude/skills/audio-tuning/tools/check_bed_level.py`: the bed's
5-channel stream × zone volume, folded like the recomp's capture.
| Zone (volume) | Retail median | Predicted with −11 dB | Without |
|---|---|---|---|
| dt_open (0.75) | −42.1 dBFS | −42.1 | −31.1 |
| dt_main (0.6) | −39.2 | −38.7 | −27.7 |
| dt_rez (0.75) | −44.2 | −43.6 | −32.6 |
| indu_quarry (0.8) | −47.7 | −47.8 | −36.8 |
The bed gain is now zone volume × 10^(−11/20) × `cues::RETAIL_SCALE`. Our levels run at 2 × retail, so
this keeps retail's bed-to-player balance. Before this the beds were 11 dB too loud relative to retail.
**Not decoded yet:** the other MixMap outputs feeding the bed (pitch, low-pass), the crossfade level
(taken as full scale), and ducking. Trace hooks for them are listed in the notes.
### Ambience per map: fallback only (installs without zone data; project choice by bed name)
| Map | Bed |
|---|---|
| University | `09_univ_campus` |
| StartPark | `10_univ_housing` |
| DownTown | `04_dt_main` |
| DownTownSkatePark, MegaPark | `05_dt_parks` |
| Industrial | `11_indu_shipyard` |
| IndustrialSkatePark | `20_indu_old_factory` |
| SkateSchool | `18_skate_school` |
| MaloofMoneyCup | `21_interior_arena_amb` |
| BlackBoxPark | `22_interior_tunnel_amb` |
| Test world | none |
"Ambience good so far" (user, University); other maps not yet judged.
### Logging (always on)
- `AUDIO_CUE <cue> <bank>:<index> volume=…` for every one-shot (`(voice limit)` if refused).
- `AUDIO_LOOP start <file>` for rolling/grind/water loops, `AUDIO_LOOP start <name> (<bank>)` for shuffles.
- `AUDIO_EVENT brake down|up`, `push`, `grind start|stop surface= ledge= flag= leaving=`,
`rolling surface= grain= band=` (on change only).
- Startup: `Game audio: 22 ambience beds, 14 rolling grains, 23 sample banks`, `Game audio: preloaded N clips`,
`Ambience: <bed> for map …`, `Water audio: N bodies (…)`.
- `SKATE_AUDIO_TRACE=1` adds per-tick `AUDIO_TRACE` lines (footstep strength, push/brake flags, foot heights).
### Dev tools
- `py -3.13 tools/audio_audition.py` → `.local/audio-audition/index.html`: every cue's current samples
(parsed from `cues.rs`), each bank in full grouped into runs of similar clips, grains and beds; players at
25 % volume; links the installed WAVs, copies nothing.
- `cargo run -p skate-data --release --example audio_surfaces -- <map.skate>` lists audio surface IDs per
map; with `AT=x,y,z[,r]` it lists the collision surfaces (audio ID, physics type, slope) within r m
(default 3) of a point, e.g. a HUD position the user reports.
### Retail patch records (SPLC) and prior work upstream
- **Prior work:** upstream PRs #4 ("Audio/retail exact player sound") and #1 (an earlier snapshot of it,
plus a procedural vehicle-engine synth) run retail's AEMS sound routing through a function-by-function
transcription of recompiled game code and a dumped TU3 memory image. That runtime conflicts with this
fork's rule (re-implement; disassembly is reference only; no game code/data), so it is not used. Their
format notes were used as *knowledge*, re-derived and verified here.
- **SPLC patch tree** (`audio_formats.splc_patches`): 60-byte header (+8 sample-table offset from byte 60,
+12 records, +16 containers, +20 extras = 0, +24 samples); 36-byte records (+4 id, +7 group count); 72-byte
containers (+4 record ids, +68 count, +69 mode); per record its groups (12-byte header: +8 member count,
+9 mode) of 72-byte members (+0 sample index, +8 gain, +44 pitch spread, +48 gain range, +64 probability;
+20/+52 delay, not used yet). Verified on all 20 SPLC banks: the walk ends exactly at the sample table,
every member names a valid sample (5,973 members → 2,266 samples), and sample-table offsets sit at a
constant distance from our SNR scan — **SPLC sample index n is our stream n**.
- **Semantics** (per PRs #1/#4, implemented independently in `skate_events.rs` `play_record`): a record's
groups are layers played together, one member each; group mode 0 random, 1 in sequence, 2 shuffled
without an immediate repeat; a member plays unless rand > probability; gain ± range; pitch random between
1/s and s for spread s > 1; ids ≥ the record count are containers that pick one record.
- **Validation of the ear-picked samples:** every group the user picked is exactly one retail record in
`Skate_Collisions`: pop 1074–1078 = record 818, landing 1088–1092 = records 824/825, heavy 1097–1100 =
records 832/833, board-down 0/1/2 = records 125/126/127, splash 476–478 = part of record 869.
- **Corrected 2026-10-02 from the retail Splice code** (`sub_82975CC8` / `sub_82976860`): member `+4` is the gain,
`+8` the pitch, `+44` the gain spread, `+48` the pitch randomisation, `+64` the probability (the field roles
above and PR #4's were swapped); the native Splice player uses these (see "Board contacts, skid and squeaks").
- **Splash now plays record 869** (three layers: 169 at 75 % probability + one of 476–478 + one of 171/172).
- Setup exports each SPLC bank's patch tree in `audio_manifest.json` (`patches`, manifest v3).
- PR #4's own pop/landing ids are different, layered patches (e.g. container 1097 → records 745–750, each
5 single-member layers from 260–267 / 635–657 / 1000–1015); they come from game tuning values we do not
read. Not adopted; the user's picks stay.
## How it was tuned (condensed session history)
Each step came from the user's report of an in-play session plus the `AUDIO_*` log lines of that session.
1. **First pass:** push, foot drag, footsteps silent — the animation events are cleared inside the tick
before any ECS system can see them → `AudioEvents` latch. Bails silent (`board_wiping_out` is reset by the
ground phase) → WipeoutGround state. Grinds silent (raw `category_12`) → `player_state.current()`.
2. **Flood:** the next log showed **push fired 1,723 times** and steps ~60/s: push contact and
`AudibleFootStepStrength` stay set for many ticks. That flood caused scratchiness, odd pick-up sounds and
likely the hitches (now fixed). → rising edges, per-cue minimum gaps, preloading.
3. **Footsteps:** a `SKATE_AUDIO_TRACE=1` run showed `AudibleFootStepStrength` is a **held level**
(2.5–4, higher when running), **not a step event**; a peak detector fired only when the level changed.
→ foot strikes from foot clearance (planted 0.02–0.04 m, swing up to 0.4–0.55 m; 56 steps over ~16 s at
~6 m/s). Footstep clips are ~10 dB quieter than impacts → the headroom rule replaced "volume ≤ 1".
4. **Foot drag:** `brake_contact` foot-down **repeats every tick** while braking (47 and 139 ticks seen),
then foot-up repeats while lifting (24–28 ticks); bit 28 is set by both, so the first latch also dragged
on foot-up. → drag only between foot-down ticks, above 0.5 m/s, overlapping 0.2 s scuffs.
5. **Rolling:** grain files are speed sweeps → 6 bands. Then 53 restarts in one session (band jumps right
after landings, concrete seams flickering) → smoothing, holds. On University's plywood ramp the sound
"played twice": `AT=` confirmed the ramp is audio 41 (Wood_1) throughout, so the mapping was right; 36 of
38 quick restarts were band changes → switch only a full band away, ≥ 1 s per band. Remaining doubling at
low speed: on returning to the ramp the loop **restarted on the debounced old surface** (concrete) and
switched to wood 0.25 s later → stop after 0.15 s off the ground and restart on the surface underneath.
User: "much better".
6. **Board down:** every touch-down after stepping on played `land`, because the physical state leaves
BipedGround a few ticks before the filtered category reaches Air → "began on foot" = on foot within 0.5 s;
level by impact (0.18–0.45 m/s seen for gentle set-downs).
7. **Deck family (found by the user):** the user picked out `Skate_Collisions` 1066–1092 as "noises heard a
lot while skating". Measured: 0.13–0.37 s knocks, most energy at 400–2500 Hz (the deck), unlike the low
thumps either side (1060–1065, 1093–1100; 70–90 % of energy under 400 Hz). Pop = sharpest
(1074–1078, 5–20 ms attack); land = 1088–1092; weight = 1097–1100. User: pop and landing "10000% better".
Earlier rejected landings: 35–40 (harsh), 18–20.
8. **Splash (found by the user):** a full decode of all 7,434 streams scored for splash shape found no
long splash; candidates (`water_misc`, `Skate_Collisions` 741–762) were rejected ("not water related at
all"). The user found **`Skate_Collisions` 476** ("literally a splashing noise") on a candidate page;
477–478 are its variations (same length 1.6–2.0 s and spectrum). 473–475 (shorter) were tried for small
falls and rejected → always 476–478. Each water entry also played a bail ~20 ms later → bail suppressed
in water. Wading in then falling gave no fresh fast contact → a wipeout in water splashes by itself.
9. **Grinds:** sound followed the named state, which lags the selector → `grinding_316` / `leaving_317`.
A University rail sounded like wood: `Skate_Metal` has no clip ≥ 1 s, so `GRINDS` (123 clips,
1.4–2.6 s) holds every material. **By tonality** (energy share of the strongest 1 % of spectral bins)
9 and 11 are among the least tonal (0.30–0.36), **54–56 the most tonal and brightest** (0.65–0.85,
4–5.6 kHz) → 54–56 on metal.
10. **Bail:** `Bodyslide` 0–15 is three blocks of five (one longer slide + four hits), heard as rising
impact → tiers. In play they sound like **additions to a fall, not the body impact** → kept as a quiet
sweetener layer (0.25) until a main impact is chosen (`.local/audio-audition/bail.html`; EA's scripts
name body impacts per body part: `body_imp_Torso`, `_leg`, `_Head`, `_foot`).
11. **Grab whoosh** repeated while holding a grab (3 in 0.8 s): scoring re-announces a held grab with the
same name → whoosh only for a new name in the air phase. Confirmed.
12. A game window that closed with no report was Windows Terminal crashing (the game was attached to its
console via `PLAY.bat`), not the game.
## Files
- New: `tools/asset_pipeline/audio_formats.py`, `audio_export.py`, `test_audio_formats.py`,
`tools/audio_audition.py`, `crates/skate-game/src/game_audio/{mod,library,voices,ambience,cues,skate_events}.rs`,
`crates/skate-data/examples/audio_surfaces.rs`.
- 2026-10-02 world audio:
- `game_audio/emitters.rs` replaces `water.rs`;
- new `random_sets.rs`, `random_programs.rs` (generated), `crossfade_groups.rs` (generated);
- `ambience.rs` rewritten for the zone beds; `library.rs` reads manifest v4;
- `audio_formats.py`: `.ems` reader, `name_id`, region layers; `audio_export.py`: emitters, location
sets, zones and crossfades, regions, emitter and crossfade banks.
- Changed: `tools/asset_pipeline/{asset_exports,install,versions,group_receipts}.py`,
`tools/asset_pipeline/test_versions.py` and `tools/test_setup_assets.py` (hand-written group lists),
`crates/skate-core/src/physics/board_ground.rs` (+ test), `crates/skate-game/src/{main,app,config,graphics_menu,physics}.rs`,
`physics/animation_input.rs` (audio event latch), `modding/audio.rs` (listener moved),
`retail_character.rs` and `tests/map_transition.rs` (`Config.mute`), `.gitignore`.
## Verification
- `test_audio_formats` (synthetic streams only); setup tests 42/42; all tracked tools tests identical to the
clean tree (the same 3 pre-existing import errors).
- Every SPLC bank: scanner count = declared count; every ABKC bank: scanner count = vgmstream subsongs.
- `cargo test`: `game_audio` (cue table sanity, bail tiers, band hysteresis, foot strikes, board-down levels,
water bodies and fade, voice limits, headroom), `graphics_menu`, `modding::audio`, `map_transition`,
`skate-core board_ground`.
- Export into the dev install: 22 beds, 14 grains (84 bands), 2 wheel spins, 23 banks; `--check-assets` →
`SKATE_ASSETS_READY`.
- Muted smoke runs (`--mute`): the startup lines above, no panics, errors or missing-sink warnings.
- ~15 in-play sessions by the user (University, DownTown); confirmed items are marked in the cue table.
- **Not yet done:** full `regression-check` pass and a setup refresh through the installer path (only the
`audio` group should rebuild).
### Native audio runtime: research (2026-10-02)
The measured per-bank tables (`emitters.rs` PROFILES, `random_programs.rs`, `crossfade_groups.rs`, the
`cues.rs` tables) are interim. The plan is to replace them with our own implementation of the retail
runtime:
- the AEMS patch-program evaluator;
- the voice graph: sample player, resampler, RBJ Q = 1 filters, gain with a 64-sample de-click,
6-channel pan, sends;
- the MixMap mixer and the granular rolling bed.
Specs written so far: evaluator (all 40 opcodes, checked against the recompiled game across all 376
banks), voice graph, and a prior-art survey. The MixMap and grain player specs are in progress.
**Design goal: modular and moddable (2026-10-02).**
- Each subsystem sits behind a clear boundary: formats, evaluator, voice graph, mixer and buses, MixMap,
grain player, world systems, output host.
- Every sound resolves by its retail identity (bank + patch, sample, set/zone key) through a content
layer: mod override first, then retail. Mods can replace samples, banks and programs, location sets,
emitters and ambience.
- The Lua SDK grows from today's play/update/stop of mod WAVs. Planned: posting retail sounds, setting
globals, reading MixMap values, hooking game audio events (to replace, suppress or layer), and mod
emitters on the same voice graph and buses. The existing commands keep working.
The evaluator research found that upstream #4's evaluator mishandles op 5 (CallFunction), so its
sample-selection functions never run, and lacks ops 19/20/38. Measurements taken through it are therefore
reliable for timing, layers and pans, but not for which sample a function-driven bank (sirens, dogs, birds)
picks.
### Native AEMS runtime: implementation (2026-10-02, `crates/skate-audio`)
**Problem.** Every world bank's behaviour was a measured table. Retail runs each bank's own patch
program through the AEMS evaluator and a fixed voice graph.
**Approach.** A new engine-independent crate, `crates/skate-audio` (`#![forbid(unsafe_code)]`, no Bevy, no
dependencies), written from our specs (`.claude/notes/aems-evaluator-spec.md`,
`aems-voice-graph-spec.md`):
- `formats/`: ABKC module banks (modules, programs, templates, interface exports, the `S10A` sample bank),
MOIR Csis projects, EA SNR sample headers. Bank load rejects opcodes ≥ 40 and programs whose block
walk does not end at the template size.
- `eval/`: the evaluator. Instance memory is a byte copy of the module's template, read and written as
big-endian words at retail's offsets, so programs run untranslated. All 40 opcodes, including 19/20
(n-ary min/max) and 38 (ControlClass, a child post); the shared 6-word RNG from zero; the 6-block tick
with tick scale f32 31.999998; Csis classes (constructor clients, newest bank first), functions
(synchronous delivery, seen at the subscriber's next op 37) and global variables (notify on change);
post / redeliver / release with refcounted nodes; Destroy releasing voices and child posts; the
Player op (open applies the full input set, changed inputs pushed, query → time left / time current,
restart only on a 0 → 1 edge, no restart of a voice that died while paused).
- `dsp/`: Resample (16.16 linear with retail's weight constant 0x377FFC9C, ≤ 4×), High/LowPassIir2
(RBJ Q = 1, bypass outside 24 Hz … 0.999·Nyquist), Gain (64-sample de-click), Send (64/65 ramp, 16-tap
release fold), Pan2D1 (6 outputs, pairwise constant power with centre extraction, distance spread,
multichannel layouts, 64-sample matrix ramp), the route tables and the output stage's stereo fold
(0.707·L + 0.5·C + 0.5·Ls, clamp ±1, no master gain).
- `mixer.rs`: voices built from those modules (first block silent = format change, 16-frame stop fade,
loops, source-time remaining/elapsed for the Player op), implementing the evaluator's `VoiceHost`;
routing codes (ids ≥ 9) are parsed and kept, every voice mixes into the default bus.
- `runtime.rs`: evaluator + mixer on one 256-frame block clock (render, then tick: a walk's voice
commands apply from the next block, as retail drains its command ring per block).
**Game side.** `game_audio/native.rs` hosts it: off by default; `SKATE_AEMS=1` or `"native": true` in
`settings/audio.json` turns it on. All Csis projects install at startup, `emitter_utility` is posted as at
boot, banks load on first use (their WAVs as PCM) and unload on map change; one device-paced rodio stream
pulls 48 kHz stereo; volume = master × ambience, paused while silenced. So far only the `.ems` world
emitters use it (`emitters.rs`): with it on, **every** emitter record whose bank is in the install plays
its retail program (DownTown: 176 of 176 records, against 30 with a measured profile), posting
`c_emitter` with w0 32767, w1 = dry level (our falloff level; MixMap not ported), w3 = azimuth
(0 = ahead, clockwise, 65536 = 360°), w4 4096, w5 25000, w8 = the attribute patch; redelivered every
frame, released when the listener leaves. Location sets, zone beds, crossfades and skate cues keep their
tables.
**Setup.** `audio_export.aems_files` copies every `.csi` (archive order) and the exported banks' `.abk`
files plus `emitter_utility.abk` byte for byte into `private/audio/aems/` (52 MB), listed under the
manifest's `aems` key; manifest version 5 (`library.rs` reads 3..5). All 376 banks keep their rebase and
interface lists after the sample data, so the whole file is needed. S10A slot i = WAV i on all 376 banks.
**Evidence.**
- Format census against the spec on every disc bank (`tests/disc_banks.rs`): 376 banks / 385 modules
parse; opcode counts equal the spec's census exactly; every op 0/1/2/37 block sits on the laid-out
subscription state; op 5/27/38 block sizes match their layouts; 1,059 exports resolve except exactly
the 4 the spec lists; 291 banks bind to `c_emitter`.
- **Evaluator vs the PoC oracle** (our probe `aems_golden` in the local PoC worktree, with its op 5
fixed and n-ary min/max added locally, never committed; identical mock voice device on both sides):
**234 of 235 scripts identical — 88,597 device calls (open / set / azimuth / release / end) and 599
voice opens, 0 differences.** Scripts: one per emitter and location-set bank in the manifest
(utility at boot, release at 20 s), both crossfade banks with group changes, sirens posted twice, and
28 player/world classes (grinds, footsteps, cloth, seams, rolling, traffic, crowds incl. op 20, car
alarms, moveables, flips…) with moving payloads. Every opcode used on the disc ran except 38. Before
the PoC's op 5 was fixed, 30 scripts differed, all at the first sample choice of a function-driven
bank: the native port delivers `*_msg` calls, as retail does (spec §11).
- The remaining script, Tazer (op 38), has no oracle: `tazer_control_class_owns_and_releases_its_child`
checks the child post's lifecycle (refcount 3 while the child lives, 1 after it ends itself; parent
release clears every instance, node and voice).
- **Voice graph vs the PoC's replay-verified kernels** (`tests/dsp_oracle.rs`, vectors from our local
probe `dsp_vectors`): Resample **16,384 / 16,384 samples bit-exact** (8 steps from 1/65536 to the 4×
clamp); LPF coefficients **40 / 40 words bit-exact** (image trig); biquad kernel **16,384 / 16,384
bit-exact** over 8 cutoffs (25 Hz … 23975 Hz) after fitting retail's per-position association of the
fused multiply-adds black-box against the oracle (`fit_biquad_association`, every position 100 %);
gain ramp 1,013 / 1,024 bit-exact, the rest 1 ulp (lane arithmetic for irregular steps not recovered).
- Pan gains match all 13 worked cases of the spec (≤ 1.5e-4).
- Headless renders (`examples/aems_render.rs`, real banks and WAVs through the whole graph and the
stereo fold): water_fountain relay −28 dBFS RMS, trees_rustle gusts −38…−44, Siren_city_4 two layers
then silence after its sample; about 2,000× real time.
**Files.** New: `crates/skate-audio/**` (lib, `tests/disc_banks.rs`, `tests/dsp_oracle.rs`, examples
`aems_replay`, `aems_list`, `aems_render`), `crates/skate-game/src/game_audio/native.rs`. Changed:
`Cargo.toml` (workspace member), `Cargo.lock`, `crates/skate-game/Cargo.toml`, `game_audio/{mod,library,emitters}.rs`,
`tools/asset_pipeline/audio_export.py` (+ test). Local tools: `.claude/skills/aems-port/tools/`.
**Verification run.** `cargo test -p skate-audio --release`: 45 unit + 3 disc + 3 oracle tests pass
(2 ignored fit tests). `cargo test -p skate-game --release --bin skate3rust`: `game_audio` 35/35, the
whole suite 354 passed with only the known `pipelines_accept_valid_group_outputs_when_fingerprint_changes`
failure. Pipeline tests (`test_audio_formats`, `test_versions`, `test_setup_assets`) 32/32. One muted
start with `SKATE_AEMS=1` on DownTown: runtime on, 176/176 records, `trees_rustle` started natively,
no panic; with it off, the old 30/176 path unchanged. **Needs a listening check in game by the user.**
**Not modelled yet / open** (marked UNCERTAIN in the specs, nothing invented): the environment send
(Send A) and effect returns (Send B, codes 4096/8192/16384), the material buses and 512/2048 buses (their
authored values are not recovered), so every voice mixes dry into the default bus; the sample-group
"level %" byte (not applied); pause/resume Send modes; loop time-left semantics (we report time left in
the current pass); SndPlayer pre-roll; the gain ramp's last ulp; who writes `c_emitter` w0 (we pass
32767) and the MixMap channels that should feed w1/w2/w3 (done since, next section). Next: retiring
`random_programs.rs`, `crossfade_groups.rs` and the cue tables one at a time.
### MixMap and the granular rolling bed: implementation (2026-10-02, `crates/skate-audio`)
**Problem.** The native runtime's `c_emitter` words were stubs (dry = our falloff level, no send, fixed
pitch/filter), and the rolling bed was our own approximation: each `.grain` recording cut into 6 looped
speed bands. Retail mixes every audio object through the MixMap (`MixMapSK8.mxb`: 247 controllers feeding
per-object volumes, pitches, filters and pans) and plays the bed with a granular player.
**Root cause / retail mechanism** (specs `.claude/notes/mixmap-spec.md`, `grain-player-spec.md`): the
MixMap is a fixed graph of input products (A), distance/azimuth lookups with Doppler (B), AHR/ADSR ducks
(F), clamped sums (C) and output sums (E) evaluated once per 60 Hz frame; the bed is two GrainPlayers per
truck reading windows of one slow-to-fast recording around a speed-driven read position, picked without
repeats and cross-faded with square-root fades, rescheduled every 256-frame block.
**Change.**
- `crates/skate-audio/src/mixmap/`: `format.rs` (file decoding), `tables.rs` (POW/LOG/CURVE/SEMI/FINE by
formula, the four FINE ulps and three image floats as stored: K = ±0x3FFF1FC4, 16.666, 16.66667), the
evaluator `mod.rs` (instances per slot, cross-slot reference expansion, input runs by curve kind, the
one-tick lags, envelopes, Doppler slew, the output conversions and their quirks: 0 mB → 32730, pitch
below −4800 → 0, a disabled block writes −10000 read back as 22768) and `keys.rs` (controller keys).
Owner readers `level` / `raw` / `pitch_4096` / `filter_hz`. Ported from our own Python reference
evaluator (`audio-tuning/tools/re/mxb_tool.py`), no PR #4 code.
- `crates/skate-audio/src/grain/`: `format.rs` (`.grain` header, stored duration, the run-length varint
seek table, EAAC header), `player.rs` (GrainPlayer: the pick with its 16-entry recent list and collapse
rule, the per-block scheduler with the drift cut and timer × pitch in sustain, voices SndPlayer1 →
Resample → square-root GainFader → Send into the player's mono bus), `board.rs` (per-surface tuning,
the Bézier speed → position curve in retail's factored single-precision form, the A/B records, push
envelope, slews, the rocket record), `bed.rs` (2 trucks × players A/B with their chains HighPass →
LowPass → Gain → Pan2D1 → default bus; the rocket straight into the default bus; one title-wide
generator seeded with the image constants). `runtime.rs` renders the bed on the block clock after the
AEMS voices.
- Game (`SKATE_AEMS=1` only; the default path is unchanged): `native.rs` loads the MixMap and runs
`mixmap_frame` before the cue systems (fixed 1/60 s steps): inputs Master.in1–4 / Music.in1, 2, 5 /
Reverb.in5 = 32767, Pause.in0 while the menu is open, the local player's PlayerPhysics speed words
(image scales, clamped), no-contact, wheel count, brake and id 9 = 0, its two 3DObjPos blocks (board seen
from the camera, 0.25 m pull-back), Contacts.in1 on landings, the board owner's 0/4/6 pulses. The 5
emitter states are the MixMap Emitter instances: `emitters.rs` writes each state's 3-D input and posts
`c_emitter` with w1 = out4 × level, w2 = out8 × level, w3 = out0, w4 = out5 (pitch reader), w5 = out6.
`grain_bed.rs` drives the bed when the install has the whole recordings: surface routing (one sounding
truck, `cues::grain_for`), records from SkateBoard level(1)/(2)/pitch(3), chains from level(11)/(12)/
raw(0) and the graph-1 level ramp, the rocket above 35 km/h; the interim speed-band loop then stays
silent. Volumes: stream = master, AEMS voices × ambience, bed × effects.
- Setup (`audio_export.py`): `mixmap_file` copies `MixMapSK8.mxb` to `aems/` (`manifest.aems.mixmap`);
`grain_whole` keeps each member's whole decoded recording (`grains/<stem>.wav`) and raw `.grain`;
`grain_tuning` exports the vault's grain class, owner class and `Sk8::AudioSurfaceMap` exactly
(`manifest.grain_player`). All keys are optional, so the manifest version stays 5. Dev installs:
`.claude/skills/aems-port/tools/stage_grain_mixmap.py` adds them without a full setup run.
**Evidence.**
- **MixMap vs the PoC's port** (PR #4's MixMap run locally as a black box, `mixmap_golden`) **and our
Python reference**: 3 golden scripts, 1,500 evaluations, **87,000 / 87,000 output cells identical**
(`aems-port/tools/mixmap_compare.py`). golden1/2: ambience fade/crossfade, emitter distance/angle,
player speed and Doppler, pause/menu/reverb/HOM ducks, dt changes; golden3 (new): 10 Collision
instances, a Traffic drive-by with Doppler and sign-flip resets, the ADSR envelopes (F133/F137/F138 with
retrigger), F24, Jitter-driven sums, a dt change.
- Disc census: 14 slots; 547 input entries, 853 A, 635 B, 350 F, 240 C, 1044 E; 154 output blocks;
**247 controllers** (= the retail capture). Generated tables equal the image's (all 512/602/512/12/100
entries and the four float constants).
- **Grain picks: all 16 golden sequences** (A and B params × positions 0 / 0.25 / 0.63 / 1 × generator
from zero and from the image seed, 24 picks each) **and the 3 candidate tilings identical** to the PoC's
vectors; **positions bit-exact on all 31 POS rows** (concrete_rough_hard and default, 0–80 km/h) after
using retail's factored Bézier rounding.
- The 14 disc members parse (`tests/disc_grains.rs`): single-entry seek tables, pre-roll 384, stored
durations; `cues::grain_for` agrees with the install's own AudioSurfaceMap for every grain surface
(tags 0–127).
- Board outputs with our inputs (`examples/board_mix_probe.rs`): grain pitch 0.790 at rest, 0.864 at
1 m/s, 0.996 from 5 km/h (retail medians 3209 / ~3500 / ~4080 of 4096); high-pass 77 Hz and low-pass
24971 Hz (retail modal 77 / 24971 — 1109 Hz with id 9 = 32767, which is how id 9 = 0 for the local
player was confirmed); gain A 0.23–0.26 from 5 km/h (retail p90 7859 = 0.24).
- Whole bed headless (`examples/grain_bed_render.rs`, concrete_rough_hard): 6.6 grain starts/s after the
bind (retail modal 6/s for one truck), at most 4 voices, stereo RMS −40.7 dBFS at 5 km/h rising to
−33.3 dBFS at 40 km/h (the recording's own rise plus the MixMap).
- Unchanged: the evaluator oracle still 234/235 scripts identical.
**Verification run.** `cargo test -p skate-audio --release`: 63 unit + 3 disc-bank + 1 disc-grain + 3 DSP
oracle tests pass (2 ignored fit tests). `cargo test -p skate-game --release --bin skate3rust`: `game_audio`
40/40; the whole suite 359 passed with only the known
`pipelines_accept_valid_group_outputs_when_fingerprint_changes` failure. Pipeline tests
(`test_audio_formats`, `test_versions`, `test_setup_assets`) 34/34. All headless; **the bed and the new
emitter words need a listening check in game by the user (`SKATE_AEMS=1`).**
**Files.** New: `crates/skate-audio/src/mixmap/{mod,format,tables,keys,tests}.rs`,
`crates/skate-audio/src/grain/{mod,format,player,board,bed}.rs`, `crates/skate-audio/tests/disc_grains.rs`,
examples `mixmap_golden`, `board_mix_probe`, `grain_bed_render`, `game_audio/grain_bed.rs`. Changed:
`skate-audio` `lib.rs`, `runtime.rs`; `game_audio/{mod,native,emitters,library,skate_events}.rs`;
`tools/asset_pipeline/audio_export.py` (+ tests). Local tools: `aems-port/tools/mixmap_compare.py`,
`stage_grain_mixmap.py`; golden3 in `.local/audio-re/mixmap/`.
**Not modelled / open** (nothing invented):
- MixMap inputs without a game source yet stay 0: Jitter (random every frame in retail), VU meter,
Menu/NIS/HOM/Challenge flags, Contacts 2/6, Rail, OffBoard, HandGrabs, PlayerPhysics 3/5/6/11/12/13, the
skater-frame azimuths (camera frame used for both), relative speeds (no Doppler), the second player.
Retail's medians of gain A (0.06 at 5 km/h, 0.08–0.17 above) are lower than ours (0.23–0.26, near
retail's p90); the missing inputs (Jitter scales A63, for one) are the first suspects.
- Bed: turn intensity (state +204 not mapped), slope, the seam-pattern envelope and the special latch are
not wired, so player B (the turning / downhill layer) is silent; FrequencyShiftSsb (allpass
coefficients not recovered), graph 3 (clip + shelf copy), the wobble gains and the environment send
are not modelled; the fade curve is our equal-power form of retail's square-root code (exact
rsqrte/Newton rounding not reproduced); the `Class_rolling` surfaces still use grain stand-ins; in the
air the last member keeps playing (retail UNCERTAIN); soft-wheel members unused.
- Emitter positions reach the MixMap one frame late (inputs written in the cue systems, ticked at the
next frame's start).
- `mixmap-spec.md` §6.2 said PlayerPhysics.in9 = 32767 for the local player; the PoC's input writer and
the board's 77 Hz high-pass say 0. With 0 the ambience bed's speed duck (A[Player.5], up to −3 dB) is
active — the ambience beds are not native yet, so nothing changes there today.
### Player inputs and the first native player components (2026-10-02, `skate_audio::player`)
**Problem.** The native runtime left most MixMap inputs at 0 (Jitter, Contacts 2/6, Rail, OffBoard,
PlayerPhysics 3/5/6/11/12/13, relative speeds), the rolling bed had no turn, slope, seam or special
input (player B silent), and every player sound still came from the measured cue tables.
**Retail mechanism** (read in the TU3 recompilation; word tables cross-checked with upstream PR #4's
driver notes): per 60 Hz frame the audio-state bridge `sub_824B0DA8` fills one record per player; the
state controller writes PlayerPhysics (`sub_824B19C8`); every component *processes* (owner inputs,
posts, releases), the MixMap evaluates once, then every component *updates* its held packets from the
owner's outputs and redelivers them.
**Change** (`crates/skate-audio/src/player/`, engine independent, unit-tested):
- `state.rs`: the audio-state record (`AudioState`, retail offsets in the docs). The game fills it in
`skate_events::observe` (`audio_state`): ground speed, COM velocity/position (SystemReckoning),
board position/velocity, per-wheel contact / material (tag − 1, 143 none) / **seam pattern** (new
in `skate-core` `WheelLineState.seam_patterns`, tag bits 12..15, the part `board_ground.rs` used to
drop), turn (`animation_input.fields.turn` = Processed+2676), slope `+712` (pumping absorption),
KnownAir (state 200..300 with no wheel down) and our own air timer, the State52/54/59/60 flags
(brake, manual brake, bail, balance), trick active / hippy jump (the score packet's scorable id with
flag bits 24/25), feet in the deck box (Skeleton 600/601), soft wheels (wheel hardness
Processed+2764 < 0.5), latched grind family / material.
- `inputs.rs`: PlayerPhysics 0–14 (3 = 0 and 13 = 0, see below; 11 = the bail-camera byte held 110
frames after a bail — PR #4's capture shape, writer unidentified), Contacts (1 landing pulse, 2 the
landing class from the per-wheel air buckets of `sub_824B2350`: ≥ 1 s air → 32767, ≥ 0.62 s →
16000; 6 the landing-flag material), Rail 0/1, OffBoard 0.
- `jitter.rs`: `SFXObj_Jitter`, 24 bounded random walks from the vault (6 write inputs 0–5), own
instance of the title generator.
- `objpos.rs`: the 3DObjPos writer as retail binds it — 60010010 follows the skater's COM (with its
velocity), 60010020 the board; camera distance from the camera itself, frame-A azimuth from the
0.25 m pulled-back origin, horizontal-plane angles, **signed relative speeds** (Doppler) and the
sign-flip bits 31/30.
- `components.rs`: `Class_grind` (poster `sub_824C28B0`, constructor `sub_824AF8C8`, updater
`sub_824C39E0`: layer by family, the family-0 layer-1 companion, V/F levels per grind surface, speed
word capped at 9000, the updater's w10 = 1 quirk for family 0), `SenseOfSpeed_rattle` /
`SenseOfSpeed_wind` (`sub_824E7980` / `sub_824E7CB0`: rattle ≥ 30 km/h ground speed, wind ≥ 15 km/h
COM speed or 1 km/h while bailing), `Class_foot_drag` (`sub_824BB540` / `sub_824AF498` /
`sub_824BEEE8`: brake or manual brake, Contacts outputs 4/5/16/17/18/22, foot-drag surface).
- `grain/board.rs`: turn intensity (`sub_824C8588`: min(clamp(|COM v|·0.24, 0, cap)·turn, cap),
forced 0 by a latch, signed slew with the rise step when the target is above), the manual / trick
latches, slope levels (owner inputs 2/3, A79 = +244 mB on gain A downhill) and the seam-pattern gain
envelope (`sub_824CA448` / `sub_824CA318`: **one** linear ramp from 1 to a random gain per pattern
change, then hold — the grain spec's "random walk" corrected; only spidercrack has a wobble ≠ {1, 1}).
- `mixer.rs`: per-bank volume groups (world / player, our user volumes) and a voice snapshot for
diagnostics.
**Game side.** `game_audio/player_audio.rs` hosts it inside `native::mixmap_frame` (inputs → process →
tick → update). `grain_bed.rs` now feeds turn, special, downhill and the seam envelope into the records
and picks the **soft** members when the wheels are soft. With `SKATE_AEMS=1` the components run
(`SKATE_AEMS_PLAYER=0` keeps them off); their banks (GRINDS, sense_of_speed, FOOT_DRAG) load at start
in the player group (effects volume) and stay across map changes; the interim metal GRINDS loop, the
sense_of_speed bed cue and the foot-drag pieces are then silent. The default path (native off) is
unchanged. Setup: `audio_export.player_tuning` exports the AudioSurfaceMap rows, Jitter channels, seam
wobbles, grind levels (grind surface keys from the image table `0x82249F90`, as constants), wheel
landing buckets and — only with a TU3 image, so not in setup — the landing-flag materials; dev
installs: `.claude/skills/aems-port/tools/stage_player_tuning.py`.
**Two values read as "0 for the local player".** PlayerPhysics.in13 is |state+96 − `[G+0x2F078]`+32|:
`G+0x2F078` is the audio-state record array (player 0 first), so for the local player both are its own
COM velocity (PR #4 read it as a listener distance). In3 is a facing factor of two PhysOut slot-0
vectors we have not identified; only A25 reads it, as a factor of the combo emphasis (Music.in3 = 0
outside combos).
**Evidence.**
- **Wind level vs retail**: SenseOfSpeed level(4) (= wind w7) at matched COM speeds against PR #4's
retail medians: 25 km/h 339 / 343 (−0.10 dB), 30 km/h 693 / 730 (−0.45 dB), 35 km/h 1239 / 1194
(+0.32 dB), 40 km/h 2074 / 2017 (+0.24 dB) — test `wind_level_matches_the_retail_medians`.
- **Components through the real banks, MixMap and voice graph** (test
`components_play_their_retail_banks`, 3 s each; gain = master × dry): Class_grind metal 6 m/s
median 0.018 / max 0.199, ledge (family 0, two layers) 0.026 / 0.237 — retail Class_grind 0.004–0.11
/ ≤ 0.28 (session 20261001_211347 — deleted, re-verify on clean data), GRINDS 0.004 / p90 0.027 / max 0.083 (all_20261002_163809);
wind + rattle 55 km/h 0.033 / 0.132, 40 km/h 0.004 / 0.015 — retail sense_of_speed 0.003 / p90 0.093
/ max 0.387; foot drag 4 m/s 0.023 / 0.057 — retail FOOT_DRAG 0.006 / p90 0.075 / max 0.281.
- Unit tests: Jitter walk bounds and a hand-computed step; 3DObjPos signs and flip bits; landing
buckets; grind posts (one layer, family 0 two layers, release, speed cap); rattle/wind thresholds and
words; foot drag words; turn intensity cap/slew/latch; latches; slope; seam envelope draw and ramp.
- Python: `PlayerTuning` export test (leaf channels, parent chain, zero-block patterns, grind defaults).
- Unchanged: evaluator oracle, MixMap oracle, DSP oracle tests.
**The bed's gain A, investigated** (test `bed_gain_a_with_the_full_inputs`, straight rolling, camera
3.5 m behind and 1.4 m above):
| km/h | level(1) before (no Jitter) | level(1) with Jitter p10 / p50 / p90 | gain A, turn 0 | gain A / B, turn 0.5 |
|---|---|---|---|---|
| 5 | 0.228 | 0.156 / 0.187 / 0.217 | 0.187 | 0.156 / 0.094 |
| 15 | 0.239 | 0.192 / 0.212 / 0.230 | 0.212 | 0.127 / 0.261 |
| 30 | 0.250 | 0.231 / 0.243 / 0.249 | 0.243 | 0.146 / 0.301 |
| 45 | 0.253 | 0.224 / 0.242 / 0.251 | 0.242 | 0.146 / 0.283 |
- Jitter (A63, A69: small ducks scaled by Jitter.in0/in2/in3) lowers gain A by 1.7 dB at 5 km/h and
0.4 dB from 30 km/h; the p90 under Jitter (0.217–0.251) sits on retail's level(1) p90 of 0.240.
So the MixMap side matches retail.
- Retail's lower medians (0.06 at 5 km/h, 0.07–0.17 above, PR #4's table) are of the *record* gain:
level(1) × (1 − max(I, Bk)) × special × seam, pooled over a session with turning, braking, manuals
and mixed surfaces; a turn of 0.5 alone takes gain A to 0.13–0.15. The 163809 session's per-start
grain levels (A and B pooled, idle and air time included) are lower still (asphalt_smooth_hard
median 0.000 / p90 0.032; concrete_rough_hard 0.009 / 0.071), as expected when every start at rest
or in the air counts. **Not settled:** a straight-roll retail measurement at turn 0 (GREC hook, grain
spec §5.3) would close it; we invented no factor.
**Not modelled / open:** `Class_wheels_skid` (needs slip `+232`: the vault pair 45 / −0.75 of holder
`0xBA9837A6CF4C26ED` does not fit PR #4's formula as written — check `sub_82772E18`), Class_Seams,
squeaks, rolling rattle, Class_rolling, cloth/body slide, footsteps, Class_Flips / Treatment, and the
SPLC Contacts (pops, landings, touchdowns: a Splice player plus the unrecovered pop bank pick
`sub_824B9AD8`) — their interim cues play. Contacts.in6 needs the TU3 material key table (dev only).
Grind SPLC starts/pieces (collision manager `sub_824D2318`) stay interim. The bool-class word of
Class_grind (a global setting) is 0. Owner input 5 (heading rate), skid input 1, PlayerPhysics 4/5 and
Rail.in1 reach no MixMap output. Retail's B layer is silent ≥ 40 km/h in PR #4's capture; ours would
sound with a turn input there (level(2) 0.72 at 40 km/h) — check in game.
**Later stages (data from the main session):** traffic (horn `sub_824D6BE8`, skids `sub_824D7440`,
engine `aud_traffic_engine`; vehicle positions in VEHSTATE lines for Doppler validation; no special
skitch sounds) and pedestrian speech (not AEMS: streamed from `livingworldspeech.big` with
`*_Events.evt` / `hdr.big` / `sth.big` event tables; reaction → clip map in
`.local/research/npc/speech_161849.txt`; summary `.claude/todo/audio-world-npc.md`,
`.claude/notes/npc-livingworld-re.md` §5b–§6e). AI skaters are full skaters with their own board
objects (voice budget question). Retail sessions with per-bank levels: `all_20261002_163809`, `all_20261002_164620` (clean trace; traffic horn / skid /
engine-class and ped-footstep levels for the world stages).
**In-game listening checklist** (`SKATE_AEMS=1`; `SKATE_AEMS_PLAYER=0` to hear the interim cues
instead):
1. Roll straight at 10–30 km/h on asphalt: the bed is 1–2 dB quieter at a crawl than before (Jitter)
and breathes slightly.
2. Carve left/right at ~15 km/h: a second, slightly different-sounding layer (player B) comes in with
the turn and the main layer dips; straight again → B fades out within ~10 frames.
3. Manual: the bed drops (special gain 0.65) and the turn layer stays off until four wheels are down.
4. Roll down a slope / pump a transition: when `+712` (our pumping absorption; what retail means by it
is UNCERTAIN) goes negative the bed gets a little louder (A79, up to +244 mB) and B joins in —
listen for a bed that swells on every pump (that would mean the mapping is wrong).
5. Ride over a spidercrack pavement: one dip of the bed (to 0.6–0.8) per pattern change.
6. Change the board's wheels to soft (hardness < 0.5): the soft grain members play.
7. Grind a metal rail and a concrete ledge: GRINDS loops (native) plus the interim start / scrape
pieces; the old interim metal loop is gone.
8. Ride fast (> 30 km/h): rattle comes in; the wind from 15 km/h of body speed, louder with speed;
bail at speed: wind keeps blowing while tumbling (1 → 10 km/h range).
9. Brake with the foot (and manual brake): native foot drag instead of the interim pieces (the interim
one was confirmed by ear — compare).
**Files.** New: `crates/skate-audio/src/player/{mod,state,tuning,inputs,jitter,objpos,components}.rs`,
`crates/skate-game/src/game_audio/player_audio.rs`, `.claude/skills/aems-port/tools/stage_player_tuning.py`.
Changed: `skate-core` `physics/board_ground.rs` (+ test), `skate-audio` `lib.rs`, `mixer.rs`,
`runtime.rs`, `mixmap/keys.rs`, `grain/board.rs`, `examples/grain_bed_render.rs`;
`game_audio/{mod,native,grain_bed,library,skate_events}.rs`; `tools/asset_pipeline/audio_export.py`
(+ test).
**Verification run.** `cargo test -p skate-audio --release`: 77 unit + 3 disc-bank + 1 disc-grain + 3 DSP
oracle tests pass (2 ignored fit tests). `cargo test -p skate-game --release --bin skate3rust --
game_audio::` 42/42. `skate-core` 621 pass; the 2 failures (`predictive_contacts_and_retention_match_full_scan_for_every_primitive`,
`a_moving_group_8_body_reaches_native_impact_feedback_for_a_stationary_actor`) fail identically with
our change reverted (pre-existing in release builds). The whole `skate-game` suite: 361 passed, only the
known `pipelines_accept_valid_group_outputs_when_fingerprint_changes` failure. Pipeline tests
(`test_audio_formats`, `test_versions`, `test_setup_assets`) 35/35. All headless;
**needs the listening check above.**
### Board contacts, skid and squeaks; the end-to-end PoC comparison (2026-10-02, `skate_audio::splice`, `player::contacts`)
**User listening check of the previous stage** (native build staged 16:59, `SKATE_AEMS=1`): "definitely closer,
but it still needs tuning"; rolling "wasn't quite right as far as sync and being too loud"; grinds "sounded ok but …
not at the right octave … just wasn't the same sound"; overall "pretty underwhelmed". Decision: "the PoC sounded
closer, use it as a reference" → the end-to-end PoC comparison harness below. A note on the reference: on one launch
of the PoC build the wheels-over-cracks (seam) sounds played louder/harsher, rattle-like; on the next launch at the
same spot they were the normal, quieter version; not reproduced since — a first-play effect of the PoC (it decodes XMA
into a PCM cache at run time). **Requirement recorded for our port:** the first trigger of every sample must sound like
the later ones (decoded before use, same gain/filter path): the native banks decode their WAVs when they load, and
`splice::tests::the_first_trigger_of_a_sample_renders_like_the_next` renders a sample twice, bit-identical.
**Problem.** Pops, landings and touchdowns, powerslides and squeaks still came from the measured cue tables; the
native build had no Splice (SPLC) player at all, so with the native components on the board's impacts were the
interim samples at interim levels, and the rolling bed dominated the mix.
**Retail mechanism** (read in the TU3 recompilation; addresses are facts, the code is ours):
- **Splice player** (`sub_82975700` start, `sub_829757D0` voices, `sub_82975A60` / `sub_82975CC8` draws,
`sub_82975B08` per frame, `sub_82976860` voice frame, `sub_82976360` start, `sub_82976CF0` / `sub_82976FF0` fades,
`sub_82976DD8` picks): an id below the bank's record count is a record, then containers (one record picked), past
both the last record. One voice per group; a member by pick mode (0 random, 2 shuffled halves from the disc's state
word, else sequential), skipped unless rand/32768 ≤ its probability (`+64`). Member fields (corrected): `+4` gain,
`+8` pitch, `+16` pan offset (× block[4]; −127 = no panner), `+20/+52` delay, `+24` start, `+28` length, `+32/+36`
fade-in end / fade-out start, `+40` curve (0: 1−cos, 1: (1−cos)², 2: linear, 3: sin, 4: sin² over π/2), `+44` gain
spread (gain × [s, 1/s], linear in 2U−1), `+48` pitch randomisation. Record `+8` gain and `+12 + U·+16` pitch
factor. A voice stops when its elapsed time (advancing by the previous frame's pitch × dt) passes
`+8 / pitch × +28 + 0.16` or the sample ends. The CRT `rand()` (MS LCG) drives every draw.
- **Contacts** (`SFXObj_Contacts`; vault class `0xC26949FCB638A2CA` `default`): frame order `sub_824B8218` →
manual landing `sub_824BB330` → touchdowns `sub_824B86E0` → process `sub_824B90D8` (pop `sub_824B9CC8` on entering
the air with an audio trick other than −1/31/32/35/36; landing `sub_824BA630`) → update `sub_824BE1B8` after the
tick. Pop: selector 2/1/0 by the jump velocity `+468` (|Air+112|/2.65 while Air440) over 0.42/0.25, ids
1097–1099 (1103–1105 on a hollow surface), gain trunc(level(2) × [0.5, 0.75, 1.0]) (hollow [0.58, 0.76, 0.99]);
roll 1111 above 4 m/s; ollie 1096 and landing impact 1095 for the local player (levels 12/13). Touchdowns: per wheel
the conditioner's landed latch (`sub_82772FD8`) and the bridge bucket `+448`; kind 0 four wheels, 1 a pair from
nothing, 2 the last pair, 3 one wheel, 4 manual landing; set = table[tier][3·kind + variant] (tier `sub_824BA310` =
2·hollow + soft wheels; the second voice of a bucket-2 landing reads tier 0), gain trunc(level(3) × K[tier][…]).
The block every sound gets: [level/32767, pitch(1)/4096, raw(0)·360/65535, dt, 1 (local 3-D), 1].
- **Class_wheels_skid** (`sub_824C7438` / `sub_824C72F0` / `sub_824AF678` / `sub_824C7A20`): held while the
predicate holds — any slip (with a wheel down; slip `+232` = clamp((|deck velocity · deck Ri| + 0.75)/45, 0, 1),
`sub_82772E18`, vault holder `0xBA9837A6CF4C26ED` 45 and −0.75: **settled**, so straight rolling keeps a skid at slip
word 1), not grinding (unless family 4), no audio trick but a grab; without slip the revert flag `+690` / counter.
w10 = clamp(counter + trunc(90 × slip), 0, 90).
- **Class_Squeaks** (`sub_824C7738` / `sub_824AFF48` / `sub_824C7DD0`): both feet in the deck box and > 1 wheel, posted
once |deck tilt `+264`| × 114.59 ≥ 15, a tilt sign change reposts; w9 = deck spin |`+488`| / 1.5 × 1000 (< 50 → 0).
**Change.**
- `crates/skate-audio/src/splice/` (`format.rs` patch tree, `mod.rs` player, tests): the Splice player; its voices
are mixer voices (`Mixer::open_direct` / `set_direct`: SndPlayer1 → Resample → Gain → Pan2D1 → default bus).
`Runtime::splice`, `Runtime::splice_host()`.
- `player/contacts.rs`: the Contacts component (pop, roll, ollie, landing impact, touchdowns with the second voice,
manual landing) behind the `SpliceHost` trait. `player/components.rs`: `Skid`, `Squeaks`.
- `player/state.rs`: `jump_velocity` (+468), `audio_trick` (+348, resolved by the host from the scorable through the
vault's eSk8AudioTricks), `scorable`, `slip` (+232), `revert` (+690), `deck_tilt` (+264), `deck_spin` (+488);
`state::slip`, `state::jump_velocity`. `tuning.rs`: `audio_tricks`, `name_hash` (lookup8).
- Game: `player_audio.rs` runs the contacts (when `Skate_Collisions`' patch tree is in the install) and the skid /
squeaks; `native.rs` loads the Splice banks with their WAVs at start; `skate_events.rs` fills the new state fields
(deck Ri / At from the part transform) and silences the interim pop layers, landing impacts and powerslide pieces
when the native ones run; `library.rs` reads `aems.splice` and `player_tuning.audio_tricks`.
- Setup: `audio_export.splice_trees` writes each SPLC bank's patch tree to `aems/<stem>.splc` (`manifest.aems.splice`;
optional key, version unchanged); `player_tuning.audio_tricks`. Dev install: `aems-port/tools/stage_splice.py`
(manifest backup `.local/audio-re/audio_manifest.before-splice-stage.json`) and `stage_player_tuning.py`.
- **Audio state log** (`game_audio/state_log.rs`): `SKATE_AUDIO_STATE_LOG=<path>` writes one TSV row per frame in the
e2e scenario columns plus elapsed ms and board / COM positions (buffered, flushed every 6 rows, no cost when unset,
with or without `SKATE_AEMS`). `scenarios.py --from-log LOG --cut A-B NAME` turns a window of it into a scenario both
stacks render.
**End-to-end comparison with the PoC** (`.claude/skills/aems-port/tools/e2e/`: `scenarios.py` writes per-frame
situations; our probe `game_audio::e2e::e2e_render` and the PoC's local probe render them headless to raw 6-channel
f32; `compare.py` folds both the same way and compares per segment: RMS, octave bands, centroid, pitch, onsets).
Ours vs PoC, RMS dB of the common fold:
| situation | before this stage | after |
|---|---|---|
| straight roll 10 / 20 / 30 / 45 km/h | +0.4 / +0.1 / 0.0 / 0.0 (bands ≤ ±1 dB) | same |
| speed sweep 0 → 40 → 0 km/h | +2.3 … −0.7 (+1.3…+2.3 below 8 km/h) | same |
| ollie at 20 km/h: air / after the landing | −24.4 / −9.2 (no pop, no landing) | −1.0 / 0.0 |
| powerslide 20 → 8 km/h | −9.6 / −8.9 / −4.6 (no skid) | +0.4 / 0.0 / −1.0 |
| foot brake | −1.0 … −1.3 | −0.7 … −1.1 |
| carve ±0.8 turn at 20 km/h | +3.4 | +3.4 |
| manual at 15 km/h | −4.3 | −4.3 |
| metal rail / concrete ledge grind | −3.1 / +1.7, different spectrum | +0.7 / +2.0, different spectrum |
Causes of the remaining differences, each checked against retail rather than the PoC:
- **Grinds:** the PoC plays every grind as surface 2 (its "user-accepted interim" policy, GRINDS samples 94/95);
retail's GRINDS voices in all_20261002_164620 / 20261001_211347 / long_dt_c (the last two deleted: re-verify on
clean data) are samples 13/14, 20–23, 55/56
(= our surfaces 0/1 and 4–9 through the program, `aems-port/tools/grind_census.py`), never 94/95, with pitch ratio
p50 0.98–1.00 and LPF 24971 / HPF 0 like ours (`tools/retail_voices.py`). So **pitch and sample choice are retail;
the PoC's grind is not**. Retail's grind sound is mostly the collision manager's Splice layers (ledge start 954 with
464 events, 968, 958, 969, 876–878 in 164620, levels 0.1–0.4) with GRINDS underneath (p50 0.007, p90 0.15): those
layers are the next port (below) and the likely reason ours "isn't the same sound". **Corrected in the next
section:** 954 / 955 / 956 are body-impact (skin / denim / arm) sounds, mostly outside grinds; the collision
manager's grind part is one start contact (board / truck against the grind material).
- **Carve:** our B layer follows the turn input (`sub_824C8588`, from the retail code); the PoC's B record stays 0 in
this harness even with its SkateBoard level(2) at 0.68–0.71. Retail's capture medians of gain B (0.01–0.10) are
pooled over mostly straight frames. Not changed; checking it needs the GREC hook below.
- **Manual:** ours applies the grain spec's special gain 0.65 to gain A in a manual (−3.7 dB); the PoC does not. Kept
(retail code reading); a GREC session settles it.
- **Rolling level ("too loud"):** the native bed equals the PoC's to ±0.4 dB at every speed, so the bed itself is not
louder than the PoC. What differs is the host: our stereo fold is the title's table (0.707·L + 0.5·C + 0.5·Ls, spec)
while the PoC trims its downmix (0.6 × (L + 0.707·C + 0.5·Ls), not retail) — ours is 1.4 dB (front) to 4.4 dB
(surround) hotter for the same mix — and until this stage the impacts that retail puts 17–23 dB over the bed were
missing, so the bed was all there was. Against the retail capture (`tools/e2e/retail_windows.py`) the bed cannot be
isolated: the capture's floor at rest is −32 dBFS (ambience, emitters) and only three clean straight-roll windows
exist; the per-voice levels agree (retail grain voices p50 0.15 / p90 0.27, ours gain A 0.21–0.24).
- **"Sync":** nothing headless shows a lag (our and the PoC's envelopes follow the same speed sweep); the in-game
path adds the device buffer and the bed is fed per rendered frame. The audio state log replays real play into both
stacks to look at this with the user's own runs.
**Contacts against retail** (all_20261002_164620 report, per-sample audible level medians): roll 1111 retail
0.13–0.18, ours 0.16–0.24; pop selector 1 (1098) retail 0.07–0.40, ours 0.15–0.35; touchdown kind 0 (1052) retail
0.42–0.64, ours 0.22–0.48. Ollie 1096 and landing impact 1095 are silent outside combos in both: their outputs 12/13
carry the envelope F[Global.135] triggered by Music.in3 (the combo emphasis), which no one writes yet — retail's
medians (0.01–0.12) are consistent with that.
**Not modelled / open:** the owner buses of the Splice voices (eEQChain presets, Contacts levels 14/15 as send levels)
— they mix dry; the landing's collision-manager pair contact; foot taps / scuffs on the deck (`sub_824B95A0`,
board knocks 1112/1115 — frequent and loud in retail, next with the collision manager); the grind start contact
`sub_824BB0E0`; body / deck impacts; Music.in3 (combo emphasis); `+308`, `+720`, `+814` (not published); the slip's
B40+16 ring. Still interim: seams (`Class_Seams`, `sub_824C14C8` / `sub_824C1698` / `sub_824C1CA0` grid / `sub_824C1F18`),
rolling rattle, `Class_rolling`, footsteps, cloth / body slide, flips / treatment, the collision manager
(`sub_824D2318`, `sub_82486EF0` posts from landing, grind start, body and deck impacts).
**Verification run.** `cargo test -p skate-audio --release`: 90 unit + 3 disc-bank + 1 disc-grain + 3 DSP oracle
(2 ignored fit tests). `cargo test -p skate-game --release --bin skate3rust`: 362 passed, only the known
`pipelines_accept_valid_group_outputs_when_fingerprint_changes` failure (game_audio 43/43). `cargo test -p skate-game
--release --no-run` builds every target. Pipeline tests (`test_audio_formats`, `test_versions`, `test_setup_assets`)
37/37. All headless. **Needs the user's listening check** (checklist below).
**In-game listening checklist** (`SKATE_AEMS=1`; `SKATE_AEMS_PLAYER=0` for the interim cues):
1. Ollie on concrete at ~20 km/h: a crack (pop) at take-off with a short roll; landing on four wheels: one impact (plus
a heavier second layer after ≥ 1 s of air). The interim pop/landing layers are gone.
2. Land on two wheels then the other two (nose/tail first): two separate touchdowns.
3. Manual, then put all four wheels down: one manual-landing touchdown.
4. Ollie on wood (a hollow surface): a different, hollower pop and touchdowns.
5. Powerslide: skid noise that follows the slide (louder the more sideways), with board squeaks when both feet are on
the board and the deck tilts past 15°; straight rolling has no audible skid.
6. Compare the overall balance: impacts should now sit well above the rolling bed.
7. Optional: play with `SKATE_AUDIO_STATE_LOG=<file>` set so we can replay your exact runs headless.
**Ready to retire after that check:** `cues::POP`, `cues::POP_TAIL`, `cues::LAND_NORMAL` / `LAND_HOLLOW` /
`LAND_KIND_CHANCE` / `land_tier` / `land_scale` and `cues::LAND` (the native contacts replace them),
`cues::POWERSLIDE` / `POWERSLIDE_SHUFFLE`; `cues::LAND_CLOTH` and the board-down / bail / step / splash cues stay.
**Next stage:** the collision manager (Splice layers of grinds, landings, impacts; then grind start, foot taps), seams,
rolling rattle, `Class_rolling`, footsteps, cloth / body slide, flips / treatment; Music.in3.
**Files.** New: `crates/skate-audio/src/splice/{mod,format,tests}.rs`, `crates/skate-audio/src/player/contacts.rs`,
`crates/skate-game/src/game_audio/{e2e,state_log}.rs`, `.claude/skills/aems-port/tools/{e2e/scenarios.py,
e2e/compare.py, e2e/retail_windows.py, retail_voices.py, grind_census.py, splc_fields.py, vault_fields.py, img.py,
stage_splice.py}`. Changed: `skate-audio` `lib.rs`, `mixer.rs`, `runtime.rs`, `player/{mod,state,tuning,inputs,
components}.rs`; `game_audio/{mod,library,native,player_audio,grain_bed,skate_events}.rs`;
`tools/asset_pipeline/audio_export.py` (+ tests). The PoC probe (`player_audio/e2e.rs` in the PoC worktree) is local.
### Collision manager, wheel spin, foot taps; the second listening test and the retail bed data (2026-10-02, `player::collision`, `player::wheels`)
**User's second listening test** (native build 18:02, `SKATE_AEMS=1`, relayed by the main session): (1) carving "too
easily triggered, just turning a little bit causes them to trigger"; (2) background ambience and noises like cars "a
little loud and don't sound like they're in the distance enough"; (3) "when jumping the rolling sound continues to
play"; (4) "manuals sound completely wrong"; (5) falling in water "almost right, maybe a tiny bit too loud"; (6)
grinding "still not in sync properly and doesn't play out the continuous grinding noise as in retail"; (7) "after
riding and then picking up the board, the riding noise continues too long"; (8) the put-down board sound "much better
but not quite right; it should sound like wheels hitting the ground and whatever effects the original engine uses
aren't being recreated".
**Validation data used.**
- Retail **GREC** session `all_20261002_180430` (11 min, the user playing the recomp; hook on `sub_824C6BD8` added by
the main session from our spec, tool `.claude/skills/aems-port/tools/grec_level.py`; credit: skate3recomp by
@mchughalex, run locally): per-frame grain records A/B of both trucks, turn intensity, brake slew, state words.
- Our engine's **real-play log** `.local/audio-state-logs/state_20261002_181631.tsv` (`SKATE_AUDIO_STATE_LOG`, the
user's own 4 min of play), cut with `scenarios.py --from-log` into `log_jump`, `log_ollie`, `log_manual`,
`log_grind`, `log_pickup`, `log_carve` (`.local/audio-re/e2e-log/`) and rendered headless.
- Clean retail level sessions only (`all_20261002_163809`, `all_20261002_164620`, `all_20261002_180430`); the
sessions with malformed trace lines were deleted by the user (see "re-verify" notes in this document).
**The bed's low-speed level ("~5–6 dB too loud") — not confirmed.** The pooled straight-roll medians (`grec_level.py`:
0.104 at 5–10 km/h, 0.114 at 10–15, 0.188 at 15–20, 0.233 at 30–35) mix in frames the bed is meant to be quiet
in: the `+336` word (brake, push) and the `+340` word (balance, **grinding**, trick) are not filtered, a paused game
(material 4 at 25–30 km/h: speed fixed at 7.9362 m/s for ~10 s, gain 0), the seam envelope (`+1456` 0.74–0.98 on
material 1), a second board owner (`40C34020`, material 65) and Doppler moments (pitch A 1.18–1.24). On **clean**
frames (`.claude/skills/aems-port/tools/grec_clean.py`: four wheels, |turn| ≤ 0.02, I/Bk ≈ 0, both words 0, 30 settled
rows, gain ÷ seam envelope) retail's level(1) is 0.219 (material 2, 5–10 km/h), 0.21 (material 40, 10–15), 0.229–0.244
(20–30), 0.245–0.25 (30–50) — our MixMap gives 0.231–0.256 at the test camera (3.5 m), i.e. within ~1 dB (Jitter −0.4
dB). On clean carves A/(1−I) = 0.20–0.27 (= level(1)) and B/I = 0.61–0.83 (ours level(2) 0.62–0.78): the bed formula
is retail's. Not changed. Open: the board's camera distance (B[Player.2] roll-off: 5 m −0.6 dB, 7 m −1.8, 10 m −3.9) is
not in GREC — adding the `0x60010020` block's input 1 to the hook would settle the residual. Analysis:
`.local/audio-re/grec_180430_clean.txt`.
**(1) Carving.** Our turn intensity follows `sub_824C8588` exactly: I vs |turn input| medians ours / retail 0.1 →
0.06 / 0.066, 0.3 → 0.18 / 0.185, 0.5 → 0.24 / 0.30, 1.0 → 0.50 / 0.60 (cap 0.8 vs retail p90 0.99 on some
surfaces). The difference is upstream: **our turn input (`Turn` animation attribute, ProcessedPhysIn+2676) is almost
binary** — in the user's log 29 % of riding frames sit at |turn| = 1.0 and 7 % in between, retail 16 % / 25 %; a stick
movement takes our value 0 → 0.5 → 0.75 → 1.0 in three frames. After the stick returns our I decays for ~13 frames
(retail's input decays through the middle values, so retail's I is 0 at turn 0: p90 0.0 vs ours 0.196). That is the
animation side (the `Turn` scalar our animation graph publishes), not audio; reported to the main session. No audio
change.
**(3) Rolling in the air / (4) manuals: `SFXObj_Wheels` was missing.** In the air our bed goes silent within ~5
frames (the MixMap's no-wheels duck F[Player.0] on level(1), confirmed headless on `log_jump`: gain A 0.25 → 0 in 3
frames). What kept playing was the interim `AIR_WHEELS` loop — `Whls_spins_Jump_1` from its beginning at a constant
level for the whole air time. Retail (`sub_824CD6F8` / `sub_824CDC70` / `sub_824CDD28`, our port `player/wheels.rs`):
a **spin-down recording started part-way in**, t0 = 14 s × (1 − clamp01(speed / 50 km/h)) (vault `0xC1831BDB6CB1B1EA`
`0x03B710C80E1AC13E`), slot 0 in the air, slot 1 **while balancing (manuals)**, the jump and manual recordings
alternating, gain Wheels level(1) (level(5) while balancing), pitch(2); stopped on landing / when the manual ends.
Retail levels (164620): `Whls_spins_Jump_1` 92 starts median 0.240, `Whls_spins_Man_1` 81 / 0.264. Ours on the user's
log: manual 0.20–0.21 (level(5)), air 0.21–0.24 after the pop. In manuals the bed itself matches retail (GREC manual rows gain A
0.141–0.157 at 10–40 km/h; ours 0.13–0.15); the missing spinning wheels were the wrong part. The +150 Hz frequency
shift of the special state is still not modelled.
**(6) Grinding: the collision manager** (`CSTATEMGR_Collision`, our port `player/collision.rs`; full mechanism in the
module docs): `sub_82486EF0` posts a 48-byte message (materials A/B, impact tiers, position, two contact levels) to an
LRU router over ten slots; each slot's `SFXObj_Collision` (`sub_824D1E00` / `sub_824D1F68` / `sub_824D2318`) starts one
Splice voice per material — the sample from the material's AudioSurface record (layout read from the disc schema,
`tools/vault_layout.py`) by its **kind** (the TU3 table at `0x8302D6E8`: 0 Skate_Collisions, 1 Skate_Metal, 2
HOM_Set_1), its tier and the other material's collision class — and updates it from the slot's Collision controller
outputs (category level 12..21, pitch 1/22, azimuth from the slot's 3-D block at the message position). Contact levels
`sub_82496C58` (the 13E20D39 window record), impact bands `sub_82497088` (the 7DAFF70B record). Ported posters:
- **grind start** `sub_824BB0E0`: truck 96 (board 95 for families 1, 2, 5) against the grind material, tier by the
last grind impact `+228` over Class_grind's 0.25 / 0.5, 0.5 s cooldown;
- **landing pair** `sub_824BA630`: on a landing-flag material only (66 materials: the metals and a few others), board
95 against the first landed wheel's material, tier by the latched air time / 0.4 over 0.1 / 0.3, levels × 0.65 / 1;
- **deck impact** `sub_824BD000`: board 95 (113 on foot or bailing) against the deck contact's material, each by its
impact band on `+668` = max of the last 4 clamp01(|deck acceleration · deck normal| × 0.00125); a band-2 hit posts
a second message at tier 1 × the window record's `+40`; 6-frame cooldown. Needs the deck contact's tag:
`skate-core` `BoardGroundState.part_audio_surfaces` (CollisionInfo+4/+8/+12, + test).
**Correction of the previous section:** Skate_Collisions sounds 954 / 955 / 956 / 947 / 948 are the **skin / denim /
arm / leg** body-impact sounds (materials 107, 108/109, 100, 99), posted by the body-impact posters (`sub_824BC188`,
ragdoll `sub_824E3BE0`); only 29 of 464 events of 954 in 164620 fall within 1.5 s of a GRINDS voice. The interim
grind start (954) and the re-fired "scrape pieces" (955 every 63 ms) were body sounds, which is part of why grinds were
"not the same sound"; with the native contacts they are now silent. Retail's grind = Class_grind's GRINDS loop
(native since the previous stage) plus this start contact. Retail pitch of the start samples (163809 MOD lines):
streams 690–692 p10 0.44 / p50 0.61–0.95 — the material's pitch word 2096 (0.51) shows in retail too. The body
posters are **not ported**: they need the skeleton's per-region contacts (Collision+80..195, `sub_82BD60C8`), which
the engine does not compute. "Sync" of the continuous grind layer: GRINDS starts on the grind's first frame in the
log replay (`log_grind`).
**Foot taps and scuffs** (`sub_824B95A0` → `sub_824B9508` / `sub_824B9268`, `sub_824B9948` / `sub_824B97A8`, updates
`sub_824BEBD8` / `sub_824BF4A8`): a foot coming back into the deck box (Skeleton 600/601) after more than 25 ms out
taps the deck — first foot 1112–1114, second 1115–1117, both 1118–1120 by toe speed (≥ 2 / ≥ 5 m/s, Skeleton+196/+212),
hippy-jump set 1176–1178 — at Contacts level(8); a foot in the box moving faster than 0.35 m/s across the deck (max |x|,
|z| of its local velocity) scuffs it (`sk8_foley` 95 / 94) at level(9). Retail (164620): board knocks 1112 ×152 / 1115
×132 (sample medians 0.11–0.41), sk8_foley 94/95 ×434/×488 (0.06–0.25); ours on the log 0.2–0.3. The engine already
publishes the toe velocities (`FootPhysicalOutput.local_velocity`). The soft variants (`+484` from the unidentified
`+769` flag) are unused.
**(8) Board put-down.** Natively the wheels' touchdown sets (Contacts) and the feet's deck taps now play from the
retail mechanism; the interim knock (1119) and wheel touch (1054) doubled them and are silent with the native
contacts. The interim foot-on-deck set (1126) stays: retail's step-on / step-off (`sub_824B85B0` → `sub_824B8310` 1124
in the air / 1125 on the ground, `sub_824B8448` 1126) needs Skeleton+602/+603 (feet planted on the deck), which the
engine does not publish. The owner buses (eEQChain presets) are still not modelled — that is the "effects" part.
**(2) Distance / (5) water: the interim layer's ×2.** Every interim cue plays at measured retail level × `RETAIL_SCALE`
(2.0), a factor anchored to the interim rolling loop (by ear). The native player sounds play at retail level, so with
them on, the interim layer (zone beds, location-set one-shots incl. cars, crossfades, splash, steps, bails) was 6 dB
hot relative to retail's balance. With the native player components running the interim voices now play at the
measured level (`Voices.scale` = 1/RETAIL_SCALE, `native::follow_volume`); the default path is unchanged. Measured
remainder (open, item 7 of the plan): our native host folds 6 channels to stereo with the title's table, so a mono voice
at per-voice gain g reaches each ear at 0.5 g (front) … 0.35 g (rear) (`examples/pan_fold_probe.rs`), while an
interim Bevy voice reaches 0.75 g (spatial, near) or g (beds) — the interim layer is still ~3–6 dB above the native
one. The distance cue retail adds through the emitters' positional send (MixMap out8 into the send buses) is not
modelled (buses not ported).
**(7) Riding noise after picking up the board — not reproduced.** In the `log_pickup` replay every voice is silent
within 0.1 s of state 500 (no wheels → the no-contact duck) and wind within 0.75 s. Retail's wind (SenseOfSpeed) also
keeps running on COM speed. Needs the user's timestamp (or a fresh state log) of the moment.
**Music.in3 ("combo emphasis") — a game-mode flag, not a gap.** `SFXObj_Music`'s process `sub_824D1208` writes in3 =
32767 when `sub_824898C8` holds: a bit of `*(0x83083C38)+0x2F0D0` (bit 13), or the manager's `+932` with `+476`, or the
manager's mode `+1060` = 8. None is part of free skating, so outputs 12/13 (ollie 1096, landing impact 1095) stay
gated — retail 164620: 1095 69 events, sample medians 0.002–0.115. Left at 0.
**First-trigger rule.** The grain recordings are now decoded when the bed is created (`grain_bed::Bed::new`), not at
first use; the wheel-spin streams and the Skate_Metal / sk8_foley Splice banks are decoded at start like
Skate_Collisions.
**End-to-end vs the PoC** (standard scenarios, RMS of the common fold, ours / PoC): rolling ±0.4 dB (unchanged),
ollie air −0.9, after landing 0.0; ledge grind +2.0 (start contact), metal +0.7; manual −4.0 (special gain; ours now
adds the wheel spin, +28 dB in the 125 Hz band); carve +3.4 (unchanged). Log replays (`.local/audio-re/e2e-log/`, ours
only): grind start voices Skate_Metal 194 + Skate_Collisions 619 on the first grind frame, GRINDS 0.66; knocks after
landings; wheel spin in the air and in the 6.5-s manual.
**Files.** New: `crates/skate-audio/src/player/{collision,wheels}.rs`, `crates/skate-audio/examples/pan_fold_probe.rs`,
`.claude/skills/aems-port/tools/{vault_layout,find_field,grec_clean}.py`, `.claude/skills/aems-port/tools/e2e/
voices_summary.py`. Changed: `skate-audio` `player/{mod,state,tuning,contacts}.rs`, `runtime.rs` (stream bank);
`skate-core` `physics/board_ground.rs` (+ test); `skate-game` `game_audio/{player_audio,native,e2e,library,
skate_events,grain_bed,state_log,voices}.rs`; `tools/asset_pipeline/audio_export.py` (`collision_tuning`, the image's
material kind / key table as constants) + test; `aems-port/tools/e2e/scenarios.py` (real-play extra columns).
Dev install: `stage_player_tuning.py` re-run (`player_tuning.collision`, optional key; manifest backup
`.local/audio-re/audio_manifest.before-collision-stage.json`).
**Verification run.** `cargo test -p skate-audio --release`: 102 unit tests + the disc / oracle tests pass (collision 5,
wheels 3, contacts 8). `cargo test -p skate-game --release --bin skate3rust`: 362 passed, only the known
`pipelines_accept_valid_group_outputs_when_fingerprint_changes` failure (game_audio 43/43); `--no-run` builds every
target. `skate-core` lib: 621 pass, the 2 known pre-existing failures. Pipeline tests (`test_audio_formats`,
`test_versions`, `tools/test_setup_assets`) 38/38. All headless; **needs the user's listening check**.
**In-game listening checklist** (`SKATE_AEMS=1`):
1. Ollie at ~20 km/h: in the air a wheel-spin that is already slowing down (fainter and lower the slower you go), gone
on landing — no rolling sound in the air.
2. Manual at ~15 km/h: the lifted wheels spin down audibly under a quieter bed; ends with the manual.
3. Grind a metal rail and a concrete ledge: a hit at the start (truck or board on the rail, metal ring on rails), then
the continuous GRINDS layer; no repeated skin/denim "pieces" any more.
4. Land a jump on a metal surface (e.g. a rail top or metal ramp): an extra board-on-metal contact over the
touchdowns.
5. Slam the deck onto a ledge / board slide hard: a deck hit (board 95 against the ledge).
6. Lift a foot and put it back on the deck (pushing, after an ollie): a board knock; dragging a foot on the deck: a shoe
scuff.
7. Put the board down from walking: the wheels' touchdown and a knock when the feet land on it.
8. World balance: ambience beds, cars, sirens and the splash are 6 dB quieter than in the 18:02 build.
9. Carving: unchanged (the turn input is the cause; see (1)).
**Ready to retire after that check:** `cues::GRIND_START`, `GRIND_PIECES`, `GRIND_PIECE_INTERVAL`,
`GRIND_METAL_START` (collision grind start), `cues::AIR_WHEELS` (SFXObj_Wheels), `cues::BOARD_DOWN_KNOCK`,
`BOARD_DOWN_TOUCH` (taps / touchdowns); plus the previous stage's list.
**Not modelled / open:** body impacts (needs skeleton region contacts), step-on / step-off (Skeleton+602/+603), the
owner buses / eEQChain presets and the emitters' send buses, the HOM override (global `+564`), the stamp counter of the
LRU (we use the post order), Class_Seams (next), rolling rattle, Class_rolling, footsteps, cloth / body slide,
flips / treatment, the +150 Hz special shift, the turn input (animation), camera distance in GREC.
### Listening tests 3–5, the native-audio stutter, the riderless board (2026-10-02, `SKATE_AEMS=1`)
**Stutter (19:01 build).** User: tricks with `SKATE_AEMS=1` were "super stuttery … kinda impossible to do anything".
No build was running at the time.
- *Measured first.* A headless replay of the user's state log (`state_20261002_190234.tsv` through the e2e
harness, `E2E_TIMING=1`) puts the native player audio on the game thread at p50 26 µs, p99 140 µs and max 1.5 ms per
frame. Render is p50 16 µs per 256-frame block. The audio work itself was not the cause.
- *Root cause.* `native.rs` spawned the native output stream with `PlaybackSettings::LOOP`. Bevy turns LOOP into rodio
0.20's `repeat_infinite()`, which wraps the source in `Buffered`. That has three effects:
- It pulls **32,768 samples (64 blocks, 341 ms) at a time inside the device callback**. The runtime's mutex is
taken 64 times back to back every 341 ms, so the game thread waits behind the burst.
- It keeps every chunk forever: about 23 MB of memory per minute.
- It adds up to 341 ms of variable latency between a game event and its sound.
- Burst cost (`examples/voice_load_probe.rs`): 24 voices ≈ 64 × 130 µs ≈ 8 ms per burst; 96 voices ≈ 33 ms. The
19:01 build added voices (collision manager, contacts, wheel streams), which lengthened the bursts.
- *Fix.* `PlaybackSettings::ONCE`. The stream never ends, so it plays forever without buffering. Same block
sequence, about 5 ms latency. Test `native::tests::the_stream_renders_one_block_per_pull_unless_looped`: a looped
stream renders 64 blocks on its first pull, ONCE renders 1.
- *Readout for real play.* `SKATE_AUDIO_TIMING=1` (`game_audio/timing.rs`) logs one `AUDIO_TIMING` line per second.
It gives max, average and count for each audio system, the game-thread and audio-thread lock waits, the render
per block and the slowest frame. It also reports `state_log_dropped=N` when rows were dropped.
**Third listening test (19:19 build, ONCE fix).** User:
- "much better for almost every sound";
- "only every now and again did a grind not quite sound right";
- "the ambience sounded great";
- "the rolling sound was almost perfect, there was one instance where it ran a tiny bit long after I jumped";
- "a few tiny hitches … should still be looked into at the end";
- "the landings and pops sounded almost perfect!".
User-confirmed, ready to retire in the "remove the interim cue tables" step (not deleted yet): `cues::POP`,
`POP_TAIL`, `LAND_NORMAL` / `LAND_HOLLOW` / `LAND_KIND_CHANCE` / `land_tier` / `land_scale`, `cues::LAND`,
`cues::POWERSLIDE*`.
**Hitches in `observe` (19:20 session, `AUDIO_TIMING`).** The per-second maximum of `observe` was 751 ms, 554 ms
and 143 ms on three frames. The median was 36 µs. `observe` wrote the `SKATE_AUDIO_STATE_LOG` rows on the game thread,
and a flush or OS stall blocked the frame.
- Fix: `state_log::write` now only formats the row and does a `try_send` into a bounded queue of 4,096 rows. A
thread, `audio-state-log`, creates the file, writes the rows in order and flushes every 6 rows. When the queue is
full the row is dropped and counted, and the count shows in AUDIO_TIMING.
- Two other hitches (197 ms and 144 ms frames) had every audio system under 0.2 ms. They are not audio, and are
noted for the frame-timing todo.
- The remaining small costs belong to the final optimisation pass (below): mixmap_frame p90 220 µs, grain_bed
58 µs, render_block 157 µs, game lock wait at most 131 µs.
**"Riding sound continues too long" (19:29 session) — the riderless board.**
- Replaying the windows headless (`.local/audio-re/e2e-1929`; new tool
`aems-port/tools/e2e/frame_levels.py`, which prints per-frame folded dBFS and per-voice-group gains next to the
state) shows the bed is not lingering after takeoff: gain A drops to 0 within 3 frames of air.
- The audible part came after the air phase. Our engine put the skater into Offboard (state 500) and Wipeout (300),
and in those states the **riderless board's** contacts (`physics.riding.ground`) kept flickering: 1/8/12/15 wheels
at 3–7 m/s.
- At 39.3 s that produced a bed + Class_wheels_skid burst at **−28.8 dBFS**: skid voice gain 0.245, grain 0.176,
against −53 dBFS in the air just before.
- Retail's audio record reports no wheels through such stretches. The clean GREC sessions show whole stretches moving
at speed with wheels 0, air 0 and material 143, with no flicker (e.g. 180430 at 246 s, 22 m/s for more than 1 s;
five stretches over 20 frames).
- Fix (`skate_events::board_unridden`, audio only): in states 300 and 500–502 the record gets wheel count 0, no wheel
or deck contacts, materials 143 and seam 0. Ground speed is kept, as retail does. The interim cues see
`rolling = false` there too.
- After the fix: **−79 dBFS**, then silence. In state 300 the bed stops at once.
- UNCERTAIN: the record writer itself (+152 bits 20–22) is not located; this rests on the measured retail behaviour.
- Physics note for the main session: the user called these "mid-air tricks", but our engine moved the skater into
Offboard or Wipeout.
**Walking with the board (19:32 test).** The user picked the board up and put it down repeatedly while walking:
"sounded right, nothing out of ordinary fired". The log still shows 23 skid posts and 48 wind post/release cycles in
state 500. Those voices are held at about 0 by the MixMap, the speed and the no-contact inputs. **Post counts are
not audible sound**: measure the rendered level before calling something a bug. The wasted posts belong to the
optimisation pass.
**Air tricks #1 and #3 (19:38 session, "played in the air and at the landing").** All 8 tricks rendered headless
(`.local/audio-re/e2e-1938`):
- In the air: the bed is at 0 within 4 frames, and every trick sits at −49…−56 dBFS.
- The pop's roll layer (id 1111 → record 742, samples 625/626/260) plays at **gain 0** throughout, as in retail:
163809 has 3 and 7 voices of streams 625/626, gain median 0.000. Its trigger is retail's (`sub_824B9CC8`: ground
speed above 12 / 8 / 4 m/s, vault `C26949FCB638A2CA`, or byte +344).
- Our "+344" stand-in is `audio_trick ≥ 0`. Retail's +344 is record+148 bit 23, meaning unrecovered. All three ids
are 1111, so this changes nothing.
- The audible in-air voice is SFXObj_Wheels' spin-down: 0.21–0.36, against retail's Whls_spins_Jump_1 median 0.240.
It is a recording of wheels spinning down, entered 5.6 s in at 30 km/h, and can sound like rolling. It is retail's
mechanism, the same in all 8 tricks.
- At landing: #3 lands on a new surface (tag 3 → 4), so the grain bed rebinds, as retail's does.
- The only state difference between #1/#3 and the rest is one frame of state 103 with no wheels and not airborne
before the air phase. It does not change the native render.
- Open: the interim layer and frame pacing are not in e2e. A short retail ollie at about 30 km/h in the recomp would
settle the spin-down.
**Grinds (19:20 session, "now and again a grind didn't sound right").** Six grinds: families 0/2/3/4 on tags 3/4,
including a family change 0 → 3 mid-grind (states 401 → 403 at 28.25 s). The GRINDS voices follow the family (slots
13/14 → 6/7/8). The routing records show the GRINDS voices go to eEQChain bus 5 (jittered EQ, below) and to the
FlangeSub return. That return is not modelled yet and is the remaining audible difference on grinds.
**Interim voices through the native fold** (native mode only, `voices::native_fold_gain`):
- With the native host running, every interim (Bevy) voice gets the gain that makes it reach the ears with the power
a native voice of the same per-voice gain has after Pan2D1 and the stereo fold.
- Non-positional: mono 0.5, stereo 0.707.
- Positional: by azimuth, compared with rodio's ear factors. Ahead −3.5 dB, behind −6.6 dB.
- **rodio 0.20's spatial source is mirrored.** It gives the ear *farther* from the source the larger factor:
((d_this − d_other)/gap + 1)/4 + 0.5. With the native host on, the listener's ears are swapped so interim sounds
come from the right side. The default path (no native host) is unchanged; a decision for the main session.
### The buses: environment (reverb) network, eEQChain buses, owner one-shot buses; Class_Seams (2026-10-02, `skate_audio::bus`, `player::seams`)
**Why.**
- User: ambience and cars "don't sound like they're in the distance".
- User: the board put-down's "effects the original engine uses aren't recreated".
- Every voice used to mix dry into one bus.
The specs come from our reading of the TU3 code with two research agents: `.claude/notes/aems-env-bus-spec.md` and
`.claude/notes/aems-eqchain-buses-spec.md`. They supersede §6 of the voice-graph spec.
**Environment network** (`bus/env.rs`; boot `sub_826DD3A8` → `sub_8248EEE8`):
- *Input.* `[[0x830CFDEC]+52]` is never 0 in retail, so **every standard voice has Send A**: pre-gain, N → 1 mono,
level = VOL × FXWET0 (property 5).
- *Graph.* EnvSendSub (199) splits into two sides A/B. Per side:
- EnvSub (200): Gai0 → PeakingIir2, with the EQ frequency swept by the LFO task;
- RvrbSub (201): pre-delay Del0 → **ReverbModel1** (six Moorer combs with prime delays from the space size, then
one all-pass; T60 / size / brightness) → Gai0 → two rvrbfiltsub (HPF → LPF → Gai0 → Pan2D1 at 270° / 90°, LFE
0.5);
- two rvrbdelaysub echo taps (Del0 with feedback → HPF → LPF → Gai0 → Pan2D1). Their delay and pan are swept per
block by the "LFO Task" (`sub_82490B60`).
- Everything goes into SFX Master.
- *Presets.* 24 vault presets (`204CAC1FD77088B8`, `aud_reverb/reverbNN`, 44 values by record offset) applied as
`sub_8248DD18` posts them.
- *Preset choice.* The district's `audio_reverb` region layer at the skater (default reverb01). A change loads the
other side with weight 0 and crossfades the two weights linearly over 1 s of game time (`sub_824DE548`).
- *Emitters.* FXWET0 is the MixMap send word: out8 = −2600 mB + camera-distance roll-off 4 → 70 m. A near emitter
gets a wet tail and echoes; a far one loses them. Its dry level has no distance roll-off. That is retail's
distance cue. The zone ambience bed has no Send A.
- New DSP modules: `dsp/delay.rs` (whole-sample delay, ±0.99 feedback, 128-sample tap crossfade),
`dsp/reverb.rs` (ReverbModel1) and `dsp/peaking.rs` (PeakingIir2, plus the DCl0 hard clip).
**eEQChain buses** (`bus/eqchain.rs`; `sub_82490CA0`, resolver `sub_82491108`, re-roll `sub_824916E8`, clear
`sub_82491180`):
- *Graph.* Eight buses (order 253): DCl0 → PI20 → PI20 → SFX Master. Index 8 = SFX Master itself.
- *Buses 0–4* (vault `AA801D9FC0ADBBBF`, records from the image table 0x8224DC78) re-roll all six EQ values on the
first create-flagged use after each clear. Each value is b + (k·(a − b))·0.1, with k = r mod 11 from our instance
of the title generator (no draw when a == b). Validated by the agent against retail: every non-default PEAK line of
buses 0–2 sits on that grid (all_163809: 228/228, 207/207, 2013/2016).
- *Buses 5–7* take the shared jitter walk's values: keys `E17029CE…` and five more. These are channels of our
existing `SFXObj_Jitter` table, now exported with their keys.
- *Clear.* Every second game frame, or every frame when frames are longer than 20 ms.
- *Who uses which bus:*
- routing records 0–7 / 10–17 (+10 = may re-roll) on AEMS voices. GRINDS → bus 5, skid → 5, foot drag / wind /
rattle → 7, as the programs' records say;
- pop → owner bus at Contacts level(14), then bus 0;
- touchdowns and the manual landing → owner bus at level(15), then bus 0;
- landing impact → bus 1;
- foot taps and scuffs → bus 1;
- collision manager → SFX Master (its Collision SubMix bus, `sub_824D25E0`, is UNCERTAIN).
**Owner one-shot buses** (`sub_82488DD0`): Sub0 → Sen0 (env, at the owner's level when built) → Sen0 (eEQChain bus).
Modelled per voice: the panned output summed 6 → 1 × the level goes into the env input, and the voice's output goes
to the bus.
**SFX Master / output.** The trace has SFX Master's gain at 1 and LPF/HPF open, so it is an identity here; its DCl0
level is not traced. Retail's output-channel byte 0x8306705D is **always 6** (Dac setup `sub_82B20EF8`): retail
hands 6 channels to the console, and the stereo downmix is the platform's. The recomp host's is 0.4·(FL + SL +
0.5·C). We keep the title's own N = 2 table (0.707 / 0.5 / 0.5) as our stereo fold. That is the game's matrix,
though retail does not run it. No change.
**Class_Seams** (`player/seams.rs`; ctor `sub_824C1198`, create `sub_824C13D0`, process `sub_824C14C8`, trigger
`sub_824C1698`, crossing `sub_824C1CA0` / `sub_824C1BA8`, hit `sub_824C1DF8`, update `sub_824C1F18`):
- *Packets.* Four 20-word per-wheel packets, held for the component's life.
- *Material change.* A hit with the transition surface.
- *Grid mode.* The wheels' world positions are rotated by the pattern's angle and divided by its grid. One hit per
axle per line crossing, after the minimum frames; the rear axle is skipped in manuals.
- *Distance mode* (slats): front then rear hits every `spacing` of ground speed × 100 × dt.
- *Update.* MixMap Cracks outputs.
- *Kept retail quirks:*
- the cells start at 0x7FFFFFFF and the frame counter runs per axis, so the first frame at speed fires one hit per
axle;
- the turn word slews ±100 per packet write.
- *Engine inputs.* AudioState gets `wheel_position` (the wheel bodies' world positions). The engine already
published the seam patterns (`WheelLineState.seam_patterns`). The state log gains `seam0`, `seam3`, `wheel_x`,
`wheel_z` and `heading` for replays.
- *Tuning.* The pattern fields are exported with the seam wobbles. Seams_Bank joins the player banks and is decoded at
start (first-trigger rule).
**Evidence and numbers.**
- *Dry identity.* With `E2E_BUSES=0` the 12 standard e2e renders are bit-identical to the previous stage's on 7.
The other 5 differ by at most 1.2e-7 (relative 1.4e-7), only from the changed summation order through the bus
inputs.
- *Wet.* Env + EQ on: the wet part is −6.7 / −6.9 dB under the dry on grinds (EQ bus 5 included), −9.7 / −9.9 dB on
brake and slide, −22.6 dB on the ollie, and below −40 dB on plain rolling (the bed has no Send A; its graph-2 env
send is not modelled yet).
- *Send A against retail* (`.local/audio-re/envbus/senda_bank.py` on 163809, per-voice send medians):
| bank | ours (mean) | retail (median) |
|---|---|---|
| GRINDS | 0.0142, send/gain 0.16 | 0.0062 / 0.19 |
| WHEEL_SKID | 0.0256 / 0.28 | 0.0160 / 0.51 |
| FOOT_DRAG | 0.0037 / 0.13 | 0.0112 / 0.17 |
| owner buses | 0.079 | 2590/32767 = 0.079 in the capture |
| sense_of_speed | **0** | 0.0150 |
| Skate_Collisions Splice voices | none | 0.0146, send/gain 0.35 |
sense_of_speed: the programs post property 5 = 0 in our evaluator; the source of retail's value is not found
(open). Skate_Collisions: retail's Splice voice graph has a Send A, its level rule is not recovered (open).
- *Seams through the real bank.* Sidewalk pattern at 10 / 20 / 30 km/h: voice gain median 0.045–0.049, p90
0.054–0.063. square_8 at 20 km/h: 0.027 / 0.050. Retail Seams_Bank per voice (163809): non-zero p10 0.010 /
median 0.074. First hit = later hits.
- *Cost.* Env network about 21 µs per block. All buses add about 105 µs per block at p50 (render p50 19 → 124 µs,
budget 5,333). The eEQChain buses filter every block and are the main share: an optimisation-pass item.
**Dev install.** `aems-port/tools/stage_bus_tuning.py` adds `bus_tuning` and refreshes `player_tuning` (jitter keys,
seam pattern fields). Manifest backup: `.local/audio-re/audio_manifest.before-bus-stage.json`. Both keys are optional
and older builds ignore them. Setup writes the same keys (`audio_export.bus_tuning`).
**Not modelled / open.**
- The FlangeSub returns (routing 4096 / 8192 / 16384: GRINDS uses one).
- Reverb-zone emitters (type 5: zone fades and the pan rotation).
- The grain chain's graph-2 env send (level(13)).
- The Splice voices' own Send A and Collision SubMix.
- sense_of_speed's FXWET0.
- The reverb module's timer delay before a new preset is heard (applied at the next block here).
- PI20 not bit-exact (no replay vectors).
- The +150 Hz manual frequency shift.
- Rolling rattle, Class_rolling, footsteps, cloth, flips/treatment.
- Step-on (Skeleton+602/+603).
**Final stage of the plan: an optimisation pass with adversarial reviews** (skill `.claude/skills/optimisation`,
foundation). Measure first (AUDIO_TIMING in real play, e2e timing headless). Change one thing at a time and keep
behaviour identical: e2e renders bit-identical, or a documented tolerance. An independent reviewer tries to break
each change, looking for hidden output changes and fake speed-ups. Then re-measure (worst case and 1 % lows).
First round done 2026-10-03: see "Optimisation pass over the native runtime" below. Candidates: eEQChain buses that filter silence, per-block `Vec` allocations in the mixer, the wind/rattle and skid
post/release thrash, the remaining small game-thread hitches.
### Listening test 6 (20:06 build): seams on brick and in the University park (2026-10-02)
User: DownTown (Aletown) brick "played too often or out of sync"; in the University's big park "an odd noise while
riding … when going over seams … too much reverb added possibly". Logs `state_20261002_200832.tsv` /
`_201324.tsv` (the clock restarts at each map load: cut by rows, `scenarios.py --cut rA-B`), rendered headless
(`.local/audio-re/e2e-2008*`, `e2e-rev-*`).
**Surfaces.** The brick is tag 66 with seam pattern 13 (`mini_tile`: 0.08 m grid, angle 30°) and tag 5 / pattern 7;
the park is tag 41 (Wood_1) / pattern 9 (`irregular_large`, 0.7 m, gain 0.41).
**Cause 1 — materials gated by contact (fixed).** The audio record's wheel materials (`+620..+632`) and seam patterns
(`+636..+648`) come from the wheel lines (82C079E0: each wheel's 0.2 m ray), not from contact. Our `audio_state`
gated them by `in_contact`, so every contact flicker was a material change, and Class_Seams fires a *transition* hit
(AudioSurfaceMap word 9 + 5, a different sound) on every change.
- Retail (GREC, `aems-port/tools/grec_material.py`, 180430, 683 s rolling): wheel 0 keeps its material on 100 % of
3-wheel frames and 96 % of 2-wheel frames; it changes 0.9 times a second.
- Ours (contact-gated): wheel 0 changed 5.0 / 8.9 / 6.6 times a second in the 20:08 / 20:13 / 19:29 logs, 19–27 changes
a second over all wheels (the park's wheel mask changes 16–19 times a second).
- Fix: `skate_events::audio_state` takes both from the lines (still 143 / 0 for the riderless board). The state log has
a new `lines` column; `e2e.rs` replays it (older logs: every line hits while a wheel is down; `E2E_CONTACT_MATERIALS=1`
replays the old gating).
- Park windows: transition hits 243 → 80 (of 635 → 531 hits) in `mp_seams2`, 118 → 40 in `mp_seams`; brick 58 → 36.
Scripted e2e: 11 of 12 renders bit-identical, `manual15` differs (the lifted truck keeps its material) within 0.1 dB.
**Cause 2 — interim seams and knocks still playing beside the native ones (fixed).** In native mode the interim
riding bed (`cues::BED_CUES`) still fired random `Seams_Bank` samples (about 0.45 a second, at random times: "out of
sync"), and `BED_RECORDS` fired the deck knocks 1112 / 1115 / 1119 and the scuffs `sk8_foley` 94 / 95 that the native
foot taps / scuffs play; the interim `CATCH` knock (1115, 0.22 s after a flip) doubled the native taps too. All
silent now when the native player / contacts run (`cues::bed_record_native`).
**Hit rate on brick: not above retail.** Ours on tag 66 / pattern 13 at ~8 m/s: 100–220 Class_Seams hits a second
(the 0.08 m grid; 116 a second over the brick window, 224 in the 30 km/h sweep), heard as about 11 seam voices a second — the program sees the
one-frame w7 pulse only at its 32 ms walks, as in retail. Retail on the same material (wheel-0 material 65 = tag 66,
180430 seconds 443 / 451 / 452): 27–40 Seams_Bank voices a second, per-voice gain median 0.088 (ours 0.07–0.09).
**Sample variety (resolved in Listening test 8: the missing `Start_up_Play_ctl` boot utility, no trace needed).** A sweep of the packet words through the real program
(`player_audio::tests::seam_samples_by_words`, `SEAM_SWEEP=word:value`) shows the program picks one sample per
(surface w10, speed w8, soft w11): surface 1 → sample 80 (72 at speed word 0, 88 at 10000), soft → 170. So at a steady
speed ours repeats one or two samples (80 / 32 on the brick). Retail on tag 66 plays 45, 83, 170, 80, 176, 34 … in
the same seconds — the soft-wheel set (170 / 176) and other surface blocks appear. Which words differ in retail is not
known: a recomp hook on the Class_Seams packet (`sub_824C1DF8` / `sub_824C1F18`, words 7–18 per wheel) would settle it.
**Reverb in the park: retail's preset, strong echoes.** The University's `audio_reverb` region gives preset
`407AFA1D6C7CEAD8` everywhere the user rode (DownTown: `68A9E6020DF2076D`). Retail used it too: in 163809 the env
EQ's sweep runs from 928.8 Hz = 1161 × (1 − 0.2) with gain 0.1 and Q 20, that preset's values. It has two echo taps
at 0.17 / 0.18 s, gain 1.0, feedback 0.2, with a ±20 % delay wobble, which makes short seam hits audibly slap back.
Rendered bus part on the park window: −16.1 dB re dry with it, −22.6 dB with reverb01, −21.1 dB with DownTown's.
The seams' Send A matches retail (send / gain 0.22, retail median 0.225). Our env network's wet level has not been
checked against retail *output* (no trace of the network's return); a capture of a seam hit in the park would.
### Listening test 7: rolling "busy", "continues into jumps", "rougher than it is" (2026-10-02)
User: rolling "can sound faster or like more is happening than actually is"; "sometimes while jumping the rolling on
ground sound continues"; "the materials sound rougher than they are for some things". Logs
`state_20261002_203746.tsv` / `_204047.tsv` (University, 0 malformed lines), plus a recomp trace-all session at the
same spot (`all_20261002_204336`, clean).
**Cause 1: interim rolling one-shots still playing beside the native layers (fixed, not yet in a test build).**
The interim bed table (`cues::BED_CUES`) still fired random `Rolling_Rattles` (0.31/s × bed rate, level 0.44) and
`PatchBank_Rolling_Surfaces` (0.14/s) samples under `SKATE_AEMS=1`. In 21 s of riding that is about 7.7 rattles and
3.5 surface one-shots. Retail posts the rattle only on a push and plays the surface layers as continuous textures
gated by the MixMap. These samples last 0.3–1.9 s, so one started just before takeoff rang on into the jump: about a
1 in 3 chance per jump for a rattle at this session's speeds. `skate_events.rs` now skips both entries whenever the
native rattle / rolling layers run (`PlayerAudio::rattle_on` / `rolling_on`). The native layers themselves go quiet
within 10 frames of takeoff: SkateBoard MixMap levels 1, 6, 7, 9 and 10 are 0 with no wheels down
(`skate-audio/tests/rolling_banks.rs::layers_go_quiet_after_takeoff`).
**Surface choice matches retail.** Retail's wheel-0 material (GREC `+620`) is 3 = tag 4 (concrete_rough) for all
32.5 s of four-wheel rolling, the same tag our engine reports (17.2 s of tag 4, 0.7 s of tag 64). Speeds match too
(retail median 11.0 m/s, p90 12.0; ours 9.4 / 12.1). So "rougher than it is" is not a wrong surface. The rendering
of `concrete_rough_hard` and the layering on top of it are being compared with the retail capture.
**Retail facts for the other layers.** The SenseOfSpeed rattle's gain is not gated on wheel contact (level 832 at 31 km/h
both on the ground and in the air), so it keeps playing in jumps and posts again as the speed crosses 30 km/h. That is
parity with retail. Retail keeps the riding speed during jumps (±5 km/h).
### Listening test 8: concrete "too rough", brick "firing too often, sounds faster" (2026-10-02, build 21:04)
User (build with the grain chain, board layers, seams taken from the wheel lines, interim cues off): rolling was
"much better", but "the same concrete still sounds too rough"; brick "sounds better but is still firing too much or
too often and sounds like you're going faster than you are". Logs `state_20261002_210721.tsv` (University, 22.8 s on
tag 4 at a mean 31 km/h) and `_210845.tsv` (University, then DownTown; 21.6 s on tag 66 / seam pattern 13 at a mean
19 km/h). Both logs are clean. Rendered headless (`.local/audio-re/seams8/`): brick 15 / 21 / 32 km/h, concrete
on patterns 2 / 8 / 11.
**Cause: a missing boot utility froze the Seams program's sample choice (fixed).** We traced the Class_Seams program
op by op (new example `skate-audio/examples/program_trace.rs`). Every player's sample index is built as
table(surface w10) + table(speed w8) + soft wheels + one of three globals, `send_random_0_to_9a` / `9b` / `9c`. The
program calls the function `rnd_call` on a hit. Only `Common.abk` implements that function, through its class
`Start_up_Play_ctl`. Retail posts that class once at audio boot, after `c_emitter_utility` and before
`c_foley_utility` (retail's three boot POSTs; upstream PR #4's player does the same). On each call it runs three
RandomShuffle draws over 0–9 and writes them into the globals. We neither loaded nor posted it, so the globals stayed
at their defaults (7 / 0 / 0) and each (surface, speed, soft) word set always played sample 0 of its block of eight:
- brick played 80 and 32 every time; retail 180430 plays all of 32–39 and 80–87;
- concrete played 32, 104 and 112; retail 204336 plays all of 32–39, 104–111 and 112–119.
At 20–30 hits a second, the same transient repeated as a regular buzz, which reads as "too often", "faster" and
"rough".
**What matched already (no change):**
- *Speed word.* w8 = clamp01((state+208 − 0.5) × 0.08) × 10000, with +208 in m/s. Constants `0x8209975C` 0.5 and
`0x8208EDA4` 0.08 come from `sub_824C1F18`; ours is identical. The speed table moves the block 72 → 80 → 88 with
speed; retail plays 80–87 at 30 km/h, as ours does.
- *Seam hit gating.* We re-read `sub_824C1698` (per axle: one wheel, minimum frames since the partner wheel's hit,
frame counter per axis), `sub_824C1CA0` (grid cell, surface-3 scale only for class 10) and `sub_824C1BA8`
(rotation). They match the port.
- *Rate.* We compared at matched speed and seam pattern, looking up the pattern at retail's WPPOS position in our
logs:
| surface | retail | ours, after the fix |
|---|---|---|
| concrete, pattern 8, 30–40 km/h | 21 voices/s, 16 walks/s | 28 voices/s, 18 frames/s (36 km/h) |
| concrete, pattern 8, 40–50 km/h | 28 voices/s | — |
| brick (material 65), 30 km/h | 27–40 voices/s | 29–31 voices/s |
- *Per-voice level.* Ours is 0.045–0.053 on concrete against retail's 0.052–0.074 (by block), and 0.08–0.09 on brick.
Ours is not louder than retail.
- *Concrete's other layers.* The spidercrack layer 5 only plays on seam pattern 1; these windows are on patterns
2 / 8 / 11. Graph 3 (the high-speed copy) only starts above 46 km/h; these windows top out at 44.7 km/h.
**Fix:**
- `audio_export.AEMS_EXTRA_BANKS` now includes `Common.abk` (it is programs only, with no samples).
- `native.rs` loads it and posts `Start_up_Play_ctl` after `c_emitter_utility`, and keeps it across map unloads.
- The e2e test and the player tests do the same (`seams::UTILITY_BANK` / `UTILITY`).
- For an existing dev install: `stage_extra_banks.py --tag seams-utility Common`, which now also stages program-only
utility banks.
**Verification:**
- Hits are unchanged (722 in brick21).
- Each eight-sample block is now used evenly. In brick21, 32–39 and 80–87 each get 2–10 starts.
- Whole-mix level: −0.6 to +0.05 dB.
- `E2E_SEAM_UTILITY=0` renders are byte-identical to the 21:04 renders.
- New test `player_audio::seam_hits_shuffle_their_samples`: 146 voices in 5 s on brick at 30 km/h, all 16 samples.
**Open:**
- On cells our logs mark as pattern 11 (sidewalk, 3 m grid), retail 204336 played 2 seam voices in 1.5 s of moving
at 40 km/h; ours plays about 12 frames a second at 33 km/h. The sample is too small to call: most of retail's
"pattern 11" seconds come from a stopped stretch with the speed still held. A recomp trace of `+636` (wheel 0's seam
pattern) next to WPPOS would settle whether retail sees pattern 11 there.
- Our boot order is foley utility, then `c_emitter_utility`, then `Start_up_Play_ctl`. Retail's is emitter,
Start_up, foley. The three share one RNG, so draw order differs from retail; behaviour does not.
### The board's other layers, the grain chain, tricks / treatment, the effect returns (2026-10-02, `player::{rolling, tricks, treatment}`, `grain::chain`, `bus::{flange, zones}`)
Specs (our reading of the TU3 code with research agents): `.claude/notes/aems-board-layers-spec.md`,
`aems-grain-chain-spec.md`, `aems-tricks-treatment-spec.md`, `aems-bus-leftovers-spec.md`.
**SFXObj_SkateBoard's rolling layers** (`player/rolling.rs`):
- *Surface routing* (`sub_824C5CA8`, surface of a truck `sub_824C82A8`, member `sub_824C8370`): two trucks, the
primary one reading wheel 0's material, the other wheel 3's; one sounding truck per distinct surface. A truck
that leaves its surface hands the sound to the silent truck instead of stopping it (retail never stops the
local player's last sounding truck; the MixMap's level(1) mutes it in the air). GREC 180430 agrees: neither
truck running on 621 of 310k frames. The bed now binds and stops from these events (`GrainEvent`) and the
routing writes SkateBoard inputs 0 / 6; `grain_bed.rs` keeps its old one-truck routing only when the native
rolling layers are off.
- *Class_rolling* (constructor `sub_824C4C18`): the non-grain rolling surfaces 7, 8, 10, 11, 12, 13 post a
per-surface patch (selectors 1, 2, 10, 12, 11, 9) instead of a grain — this replaces the interim stand-ins in
`cues::grain_for` (tags 8 / 10 / 70 → aggregate, 37 / 67–69 → metal) on the native path. Rolling surface 0
(tag 90) binds asphalt_rough_hard with the `default` collection's tuning (open question closed).
- *Held layers 0 and 3* (posted once at board create, `sub_824C9830` / `sub_824C9948`) and *layer 5* while wheel 0
is on the spidercrack pattern (`sub_824C9F68` / `sub_824CA038`).
- *Rolling_Rattle_Class* (`sub_824C6198`, `sub_824B0248`, `sub_824C80C0`): re-posted on every push plant while the
primary truck is on a grain surface (sample = 3 × surface code + speed band).
- *c_board_slide* (`sub_824CB3C8` / `sub_824B0670` / `sub_824CB4C0`): the loose board on its back / side while the
rider bails or walks. The `up_dot` vectors are UNCERTAIN (deck up · world up stands in).
- Every Class_rolling post reaches all four banks bound to the class, so setup now exports
`PatchBank_SpiderCracks`, `PatchBank_Objects` and `PatchBank_RocksBounce` (and `Treatments`).
- Levels through the real banks (per-start peaks, agent's `tests/rolling_banks.rs`): Rolling_Rattles 0.34–0.37
(retail p90 0.30–0.36, max 0.37); SpiderCracks max 0.355 (retail max 0.29–0.35); held layer slot 15 median 0.030,
p90 0.046 (retail 0.007–0.010 / 0.044–0.121); Objects up to 0.20 (retail p90 0.03–0.22); board_scrapes max
0.06–0.10 (retail p90 0.10–0.12). First rattle = later rattles. In the air every layer is silent within 10 frames
(SkateBoard levels 1, 6, 7, 9, 10 are 0 without wheels).
**The grain bus chain** (`sub_824C8878`; `dsp/fss.rs`, `dsp/shelf.rs`, `grain/chain.rs`):
- *FrequencyShiftSsb* (`sub_82B22898`): two cascades of two allpass sections (fixed image constants at
`0x82FCE2B0`) form an I/Q pair on the shared biquad kernel; out = I·cos φ − Q·sin φ with retail's polynomial
sin / cos; at 0 Hz it is the I path (an allpass), never a bypass. Phase error of the Hilbert pair ±0.6° at
100 Hz–1 kHz.
- *Values per frame* (`sub_824C9058`): A = (special ? +150 Hz : 0) + the push shift envelope + D × A's shift per slope;
B = B's base shift (−10 Hz; wood −25, metal −20) + push shift + D × B's per slope. **This is the +150 Hz manual
shift.**
- *Graph 3* (the high-speed copy): send 0 below 46 km/h → 3.0 at 70 km/h, DCl0 ±0.09, the wobble gain, HS20 5 kHz ×
0.65. *Wobbles* (`sub_824CB078` / `sub_824CB180`) and the graph-1 level ramp, with the quirk that truck 1 never
receives a wobble. *Graph 2*: the env send level(13)/32767 (pre-pan mono) into the reverb network, the
FlangeSub send level(21)/(22) carried but not rendered (no bed → return route yet).
- Retail (`dsp_20261002_185826`, `.local/audio-re/grain-chain/chain_check.py`): the graph-3 send matches exactly (189
posts), graph-1 / graph-3 gain = the level ramp within 1e-4, wobble depth 0.290 / 0.240 against the caps 0.30 /
0.25, only truck 0's graph-3 gains change.
- e2e: **manual15 went from −4.0 dB to +0.1 dB against the PoC** (A × 0.65 and +150 Hz; centroid 652 → 763 Hz, PoC
77x). Rolling scenarios unchanged (≤ 0.1 dB). Graph 3 adds about +5.7 dB at 60–85 km/h (not compared with a
retail capture).
**Tricks and Class_Treatment** (`player/tricks.rs`, `player/treatment.rs`):
- *Class_Flips* (`sub_824CBFB8` → `sub_824AFAD8`) posts on the first air frame of a trick (not at the pop: retail
median one frame after the jump-velocity write), held while airborne with the same audio trick, released at the
landing; ids −1 / 35 / 36 post nothing. *cloth_trick* A as soon as the trick registers (~215 ms before the air),
B with the scorable's second audio trick (`+352`, vault field `A2C5C22C5BE725F8`, now exported as
`player_tuning.audio_tricks_2`) when the trick ends.
- *Class_Treatment* (`sub_824DD408` / `sub_824B0080` / `sub_824DD6F0`): posted once, streams the predicted time to
landing (Air+184) and jump height; its landing streams 13 / 14 start ~0.37 s before touchdown.
- Levels through the real banks vs retail: flip whooshes 0.370 (retail 0.347–0.365), flips slot 5 0.133 (0.134),
cloth_trick 0.18–0.24 (0.21–0.25), Treatments 13 / 14 0.0736 / 0.0184 (0.0746 / 0.0186). First flip = second.
- Treatments slots 16 / 17 (the long-air layer) play in the recomp too, at our level (section "Treatments slots
16 / 17" below). The game-mode globals (G+96, X+1060, G+4) are free-skate defaults.
- The interim `cues::FLIP` is silent with the native Tricks.
**Effect returns and the other bus leftovers** (`bus/flange.rs`, `bus/zones.rs`, mixer Send B):
- *FlangeSub returns* (`sub_82490270`, presets `sub_824DDF58`, levels `sub_824DF220`): Del0 (≤ 15 ms) → PI20 → env
send → Gai0 → Pn21 → SFX Master, the delay swept 0.2–3.8 ms at 0.5 Hz (A) / 0.2–0.8 ms at 0.25 Hz (B). Routing
4096 → A: GRINDS; 8192 → A: WHEEL_SKID_BANK; 16384 → B: Flips. Send B = the post-gain mono at property 11; retail
GRINDS 0.0499 / skid 0.0561 (= Rail out6 / SkateBoard out20, which our components post). On a grind the return
is a centred copy 26 dB under the dry voice. Presets exported as `bus_tuning.flange`.
- *Send A corrections:* retail sense_of_speed Send A **is 0** (its updater writes w6 = property 5 = 0 every frame) and
Splice voices have **no Send A** (graph `PITCH GAIN SEND6`). The 0.0150 / 0.0146 in "The buses" came from a trace
script that attributed every line to an owner's last PLAY of the session (`.local/audio-re/busleft/senda_bank_fixed.py`
is the corrected one). Nothing to port.
- *Collision SubMix* (`sub_824D25E0`): collision voices play mono into their submix with an env send at the
category's Collision output (≈ 0.100 near the camera, as the trace) and the AudioSurfaceMap words 12–16 eEQChain bus
(all 8 = SFX Master in the vault). On by default (`SKATE_AEMS_SUBMIX=0` = the old direct route).
- *SFXObj_Reverb's selector* (`sub_824DE548`, zone blend `sub_824DEEF0`) runs in retail's order (`env.update`): the
first region preset fades in over 1 s from reverb01. The reverb-zone emitters (`.ems` attribute type 5) are not
wired: one record on the disc (`1F94F2F815C00368` → preset `BEEFC8E3DE04FBAE`). *Superseded: 1,040 records, now wired
— see "FootStep SubMix, the skeleton inputs, …".*
- Not applied: the global env-send scale (manager +104 = Reverb out4): it depends on Reverb.in5, whose writer is
unknown (the trace says in5 = 0; our hosts write 32767, which would drop every env send 4 dB). *Superseded: the writer
is `sub_824DF468` (by the preset number); applied — see "FootStep SubMix, the skeleton inputs, …".*
**Footsteps, clothing, hands on the deck** (`player/footsteps.rs`, `player/clothing.rs`, `player/step_on.rs`; spec
`.claude/notes/aems-offboard-clothing-spec.md`):
- *SFXObj_OffBoard* (`sub_824E9270`, poster `sub_824E9FD8`, updater `sub_824EAEA8`): two held
`playercharacter_footstep` packets (25 words) plus the foot-down Splice sounds (surface layers `sub_82493E60`,
step layers `sub_82493690`), the walking voices (`sk8_foley` 62 / 63 / 64; Skate_Collisions 1121–1123 with the
board in hand) and the jump take-off / apex voices. On the board the push plants and the brake count as feet
down, so this also plays the push steps.
- *Clothing* (`sub_824DBB68`): `c_cloth_falls` at a bail, the push foley `sk8_foley` 73 / 74 (eEQChain bus 0),
`c_body_slide`.
- *Hands on the deck*: Skeleton+602/+603 are the IK's **hand** targets (limbs 2 / 3) inside the padded deck box, not
the feet. 1124 / 1125 (hand on, air / ground) and 1126 (hand off) play when the board is picked up or put down
by hand (Contacts `sub_824B85B0`). The interim "1126 = foot on deck" was a wrong mapping and is silent natively.
- Caller 824D8164 (the 1017 `sk8_foley` 62/63/64 events in 164620) is `SFXObj_PedestrianSFX` — pedestrian footsteps,
not the player.
- Levels vs retail per-voice medians: walking foley 62 / 63 0.387 / 0.244 (retail 0.387 / 0.244); step layer 82 equal
per sample; board in hand 0.155–0.28 (0.17–0.34); metal surface 0.082 (0.082); push foley 0.257 (0.18–0.26);
hand on 0.125–0.153 (0.13–0.16), hand off 0.062–0.070 (0.071–0.081); Foley_Cloth +7 dB (guessed inputs below).
- Engine inputs: feet down (OffBoard 306 / 307, Air 449 / 450, push flags, brake), the feet's surfaces
(`processed.right/left_surface`), toe world speeds, footstep strength, the hand targets — published. APPROXIMATE:
the feet's vertical speeds (toe world velocity y), the body speed (COM speed). Not published: the step code
(Skeleton+144/+160), the limb speeds and the ragdoll body-part slide records (body slide stays silent). The
FootStep SubMix chain (HPF → LPF → PI20 → env → pan per sound) is passed through but not rendered by the mixer.
- Setup exports per material `B1CF62EA632CF13F` (has footsteps), `37320DF00471F91A` / `9D6068AB16703650` (gains).
`SKATE_AEMS_FOOTSTEPS=0` keeps the interim steps. The interim `STEP` / `RUN_STEP` / `PUSH` / `BOARD_DOWN` cues are
silent with the native footsteps / contacts. The state log gains `foot_down`, `foot_tag_a/b`, `hands`, `strength`,
`foot_vy_a/b` for replays.
**Retired interim cues (native path).** The native path no longer reaches `POP`, `POP_TAIL`, `LAND*`, `land_tier`,
`land_scale`, `LAND_CLOTH` (= the native scuffs `sk8_foley` 95), `POWERSLIDE*` (user-confirmed), nor `CATCH`, `FLIP`,
the interim bed's Rolling_Rattles / PatchBank_Rolling_Surfaces / Seams_Bank / sense_of_speed cues and its knocks /
scuffs, `STEP` / `RUN_STEP` / `PUSH` / `BOARD_DOWN` (native replacements, not user-confirmed). The tables stay for the default path (native off).
**Cost.** Render p50 87–117 µs → 114–178 µs per block over the e2e scenarios (the grain chain ≈ +27 µs per bound
truck, mostly `mul_add` library calls); game thread p50 32–38 µs → 35–38 µs per frame. `voice_load_probe 96`: p50
512–520 µs with or without the returns (budget 5,333).
**e2e against the PoC (all on, `.local/audio-re/e2e-on2`).** Unchanged within 0.3 dB everywhere except manual
(−4.0 → +0.1 dB, the chain) and the slide (st101 segments +3.0 → +2.2, +0.8 → +0.8, −0.2 → +0.6 dB against the PoC: the skid's flange
return and the rolling layers). With every new part off (`E2E_ROLLING=0 … E2E_CHAIN=0 E2E_FLANGE=0`) 11 of 12 renders are
bit-identical to the previous stage; roll45 differs by the graph-1 wobble (−36 dB under the signal), which the
host now posts.
### Listening after the components stage (2026-10-02, builds 21:04 and 21:12)
User verdicts, verbatim:
- Rolling "is much better, it just has issues with certain surfaces":
- University concrete (session `state_20261002_210721`, tag 4, about 31 km/h): "the same concrete still sounds too
rough on our engine".
- DownTown brick (`state_20261002_210845`, tag 66 / seam pattern 13, about 19 km/h): "the brick surface sounds better
but is still firing too much or too often and sounds like your going faster than you are".
- Footsteps, clothing and hands on the deck (`state_20261002_212254`): "the footsteps sound great, cant tell on the
clothing but nothing intrusive sounding. pickup / put down sounds were great."
Consequences:
- Footsteps and the hand pick-up / put-down are user-confirmed. The interim STEP / RUN_STEP / PUSH / BOARD_DOWN* cues
join the confirmed retirements.
- Both surface reports are being measured against retail (recomp sessions `all_20261002_204336` at the same University
spot and `all_20261002_180430` on brick). The lead is the Class_Seams packet words, especially whether our speed word
has the scale retail's program expects: a wrong scale would pick the "fast" samples at low speed.
### FootStep SubMix, the skeleton inputs, the Reverb inputs, the reverb zones (2026-10-02, `bus::submix`, `skate_events`, `bus::env`, `emitters::reverb_zones`)
**The FootStep SubMix** (`sub_82494188`, spec `.claude/notes/aems-offboard-clothing-spec.md` §2.5; `bus/submix.rs`):
- One mono graph per foot sound slot of `SFXObj_OffBoard` (two feet × four slots), built once:
`Sub0 → HI20 → LI20 → PI20 → Sen0 (env bus) → Pn21 (1 → 6) → Sen0 (SFX Master)`.
- The slot's Splice voices play into Sub0. Their 6-channel final Send sums L, C, R, Ls, Rs into the
mono input; LFE is dropped.
- At each start of the slot's sound, `sub_82494550` posts the slot's EQ record (HPF, LPF, PI20
centre / gain / Q), the holder's env level and its azimuth.
- The graph keeps those values, its filter history, its send ramps and its panner matrix until the
next start. `SpliceHost::set_submix` now reaches the mixer (`Output::Submix`, `Route::mono`).
- Off: `SKATE_AEMS_FOOTSTEP_SUBMIX=0` or `E2E_FOOTSTEP_SUBMIX=0` (the old direct route).
- UNCERTAIN: Sen0 #2's level (never posted, 1.0) and Pn21's law (class defaults); the release
de-click of a voice goes to SFX Master as for the other buses.
- Effect: the step layers keep the slot's open EQ, so they come out as before. A replay of the
user's walking (state log 21:22, rows 462–948) differs by 5e-8. The EQ records apply to the
surface layers (metal surface 5, special surface 7, and materials with their own footstep record).
**The engine inputs the components were missing** (no `skate-core` change; read from what the engine
already keeps; `skate_events::audio_state`). Retail's Skeleton::FillPhysOut (`sub_82BE1AE8`) and the
record writer (`sub_827A1B78`) read back from the TU3 code:
| audio state | retail | ours |
|---|---|---|
| `+740` step code | Skeleton `+144` / `+160` = the physical pose translations of toe parts 15 / 19 (Skeleton 9024 / 9280) at the OffBoard 306 / 307 plants | `StepCode::update(flags_306_307, left / right surface, record.pose[15/19][3].y)` |
| `+296` / `+292` foot "vertical" speeds | \|y\| of Skeleton `+304` / `+320` = the **angular** velocity (body `+48`; `+32` is linear) of the assembly bodies of parts 20 / 16 (assembly `+76 + 96·part`) | \|angular_velocity.y\| of `skeleton.bodies()[20 / 16]` (was the toes' linear speed, a proxy) |
| `+328` body speed | \|Skeleton `+288`\| = the angular speed of part 23's body | \|`bodies()[23]` angular velocity\| (was \|COM v\|, a proxy) |
| `+672` limb speed | 0.25 · (572 + 568 + 564 + 560): \|record velocity − Skeleton16176 (physical COM velocity)\| of parts 8, 4, 21, 17 | the same from `skeleton.record.velocities` and `reckoning.vector_16` (was 0) |
| `+528..+548` slide speeds | the ragdoll regions' tangential speed (SkeletonCollision `+944 + 4i`, published by `sub_82BD60C8` into Collision `+80..`; 0 without a contact part) | `collision_feedback.regions[i].tangent_speed` (was 0: body slide never played) |
| `+560..+580` slide tags | the regions' surface tags (`+976 + 4i`; the per-frame PhysOut reset leaves 0 without a part) | `regions[i].material_flags` |
| `+593` | SkeletonCollision byte 4009 = the face point's contact this frame (specific point 1) | `collision_feedback.specific[1].current` |
| loose board `up_dot` | SkateboardReckoning `+80` (the effective deck basis' Y = the physical deck up) · Ground `+80` (the retained wheel-contact normal); deck contact Collision `+3475`, material Collision `+12` − 1 | `skate_events::deck_up`; contact and material read raw (the riderless-board gate of the record's wheel fields does not cover them) |
- Lengths use `f32::sqrt`. Retail uses x · the twice-refined reciprocal-sqrt estimate; the two
differ by about 1 ulp.
- The state log appends `step`, `body`, `limb`, `slide`, `deck_up` and `deck_contact`.
`scenarios.py --from-log` copies them and the e2e replay reads them when present. Older logs keep
the old defaults.
**Reverb.in0..6 and the global env scale** (`sub_824DF468`, `sub_824DF390`; `bus::env::reverb_inputs`):
- *Who writes them.* `SFXObj_Reverb`'s update starts with `sub_824DF468`. It writes Reverb.in0..in6
= 0, then 32767 into one input.
- *Which input.* It comes from the number of the preset on the side being faded to (record `+48` =
the NN of reverbNN, jump table `0x824DF3D0`):
| presets | input |
|---|---|
| 1–5 | in4 |
| 6–8 | in0 |
| 9, 13, 14, 17, 18 | in1 |
| 10 | in2 |
| 21 | in3 |
| 11, 12, 15, 16, 22 | in5 |
| 19, 20, 23, 24 | in6 |
- *What it changes.* In5 drives F213 (Reverb out4 −400 mB) and F141 (music). In1 drives the
emitter / ambience ducks F21 / F40 / F41 and others.
- So our fixed in5 = 32767 was wrong. At reverb01 the MixMap now gives out4 = 32730 (scale 0.99887,
as the retail trace showed), and inside a reverb11 zone 20603 (−4 dB).
- The scale is applied each frame after the tick (`EnvNetwork::scale_frame`).
- Off: `SKATE_AEMS_REVERB_INPUTS=0` or `E2E_REVERB_INPUTS=0` (fixed in5 = 32767, no scale).
**The reverb-zone emitters** (`emitters::reverb_zones` → `native::reverb_frame` → `EnvNetwork::update`):
- *The earlier count was wrong.* The previous stage counted "one record". The disc has **1,040**
eVolumeType-5 records in 24 attributes, each naming a reverb preset (`99FD793BC30CF0FA`; reverb02…24):
- `reverb_downtown`: 616 records;
- `reverb_industrial`: 240;
- `reverb_university`: 133;
- the parks' `sfx_` files: 51.
- *Which files load.* The map database (`F4917ACACAFAF913` field `65FA976EF23A314E`) lists the files
a map loads. The districts load `music_`, `sfx_`, `reverb_`, `speakers_` and `crowds_`; the parks
load one file.
- *Per frame.* The zones holding the camera (sphere / ellipsoid with inner core, as for the sound
emitters) are listed in the order they were reached. The retail selector blends and rotates the
reverb toward them.
- *Setup.* The emitter export adds `reverb` (16 hex digits) to kind-5 records (`_value` now reads
an untyped `Attrib::RefSpec` as class + key). Dev install: `aems-port/tools/stage_reverb_zones.py`,
backup `.local/audio-re/audio_manifest.before-reverb-zone-stage.json`.
- Off: `SKATE_AEMS_REVERB_ZONES=0`.
- Test `emitters::a_downtown_reverb_zone_selects_its_preset_and_raises_reverb_in5`: in the core of a
`1F94F2F815C00368` zone, reverb11 fades in and commits, in5 = 32767, and out4 32730 → 20603.
**Boot order.** Retail posts `c_emitter_utility` → `Start_up_Play_ctl` → `c_foley_utility`. They share
one random generator, so the native host now posts in that order (the foley utility used to come
first, from the optional banks).
**Treatments slots 16 / 17** (settled 2026-10-02: the recomp plays them too):
- *Problem.* Ours plays Treatments 16 / 17 quietly in every ollie. Our recomp sessions seemed to show
they never play there, so `+240` (w8, the predicted time to landing) looked wrong around takeoff.
- *Evidence.* The user's TREAT session (`all_20261002_223306`, University: plain ollies, one ~2 s air
off a ramp; hook on `sub_824DD6F0`, 57,323 lines, 0 malformed) logs Class_Treatment's inputs per
rendered frame:
| input | ground | first air tick | in the air | landing tick | after |
|---|---|---|---|---|---|
| `+240` predicted time to landing | 0 | the whole prediction (0.70–0.77 s ollies, 2.01 s ramp) | −1/60 per tick | still counting (0.019, can go below 0) | 0 |
| `+236` air time | 0 | 1/60 | +1/60 per tick | still counting (0.767) | 0 |
| `+260` jump height | 0 | 0.22 m | rises to 1.19–1.21 m (ollies) | held | 0 |
| `+224` (G+4 byte) | 0 | 0 | 0 | 0 | 0 |
Values change per 60 Hz physics tick; the recomp updates the packet about 345 times a second. Our
audio state publishes the same shape: `+240` is KnownAir's Air+184 from the physics port.
- *Root cause.* A measurement error. Treatments 16 / 17 are byte-identical to sense_of_speed 3 / 4.
`retail_voices.py` matches PLAY payloads against the disc and took the first archive hit, so the
recomp's 16 / 17 voices were counted as sense_of_speed. Their sample addresses sit in the
Treatments bank's memory (0x4B22…/0x4B23…, next to 13 / 14 at 0x4B21…). The real sense_of_speed
3 / 4 voices are at 0x4AFF…/0x4B00…. Only 10 samples on the disc are shared across banks.
- *The recomp vs ours.* We replay the recomp's own per-frame inputs through our Class_Treatment, bank
and evaluator (`tests/player_tricks.rs` `treatment_replay_of_the_recomp_capture`).
- Voice counts:
| slot | 13 | 14 | 15 | 16 | 17 |
|---|---|---|---|---|---|
| the recomp | 18 | 18 | 1 | 18 | 18 |
| ours (replay) | 18 | 18 | 1 | 20 | 20 |
- In the 15 airs of 0.6–1.0 s, both play 16 and 17 once per air. The recomp starts them a median
87 ms after takeoff, ours a median 88 ms after (42–380 ms).
- Peak gains: the recomp 0.0042–0.0287 (median 0.0149), ours 0.0054–0.0320 (median 0.0155, +0.3 dB).
- *Change.*
- No change to 16 / 17 or `+240`.
- `retail_voices.py` now resolves duplicate samples by sample address (`play_resolve_all.json`;
`--no-disambiguate` gives the old attribution).
- `+236` now counts through the landing tick, as the recomp's does. It is Air+176, the time in the
air *state*, and the state is still 200..300 on that tick (`skate_events::air_time_236`). Before,
it dropped to 0 a tick early. Switches: `SKATE_AEMS_AIR_TIME_STATE=0` / `E2E_AIR_TIME_STATE=0`.
- The e2e scenarios now pass `to_land` / `jump_height` as `+240` / `+260`. Before they were 0, so
the e2e renders had no Treatments voices at all. Switch: `E2E_AIR_WORDS=0`.
- *Files.*
- `crates/skate-game/src/game_audio/skate_events.rs` (`air_time_236`, test
`air_time_holds_through_the_landing_tick`)
- `crates/skate-game/src/game_audio/e2e.rs`
- `crates/skate-audio/tests/player_tricks.rs` (the TREAT replay, a real test, and the diagnostic
`treatment_replays_the_recomp_capture`; the `_by_late_prediction` diagnostic is removed)
- *Verification.*
- skate-audio: all tests pass. game_audio: 48 pass.
- e2e on 13 scenarios, including a real-play cut with three ollies (`ollies_log`):
- with both switches off, every render is bit-identical to the previous renders;
- the `+236` hold alone is bit-identical too, even with Treatments playing (it also gives identical
voices in the replay);
- with the air words on, `ollie20` / `ollies_log` gain Treatments 13 / 14 at 0.0746 / 0.0186 (the
recomp: 0.0746 / 0.0186) and 16 / 17 at 0.013–0.030 (+0.01 dB overall).
**Evidence.**
- skate-audio: 180 lib tests plus the data tests pass (new: submix ×2, Reverb inputs).
- game_audio: 47 tests pass (new: the zone test).
- Python: 27 tests pass (new: the reverb RefSpec).
- e2e with every new part off is bit-identical to the previous stage (`.local/audio-re/e2e-on2`) on
11 of 12 scenarios. ollie20 differs in its random flip / cloth sample picks; that comes from the
other agents' work since 20:53, not from these parts, which consume no random numbers.
- All on against all off: ≤ 3.8e-5 peak difference and −0.00 dB on every scenario. That is the env
scale's −0.01 dB on the wet path, plus the submix.
- Tools: `aems-port/tools/e2e/render_diff.py DIR_A DIR_B`, renders in `.local/audio-re/skel/`.
### Leftovers: Treatments 16 / 17 timing, sense_of_speed figures, FootStep SubMix build values, the zone check, the other `.ems` files, fallbacks (2026-10-02, overnight)
**Treatments 16 / 17: the two remaining differences are the recomp, not the mechanism.**
- *Problem.* On the ~2 s ramp air (session `all_20261002_223306`, 102.0 s) the recomp started 16 / 17 538 / 577 ms
after takeoff at gains 0.051 / 0.026. Ours started them at once, at 0.10. In 2 of 7 airs under 0.5 s ours also
played a 0.004–0.005 voice where the recomp played none.
- *Root cause, the late start.* The recomp's audio thread stalled. The trace has **no audio lines at all
(PLAY / GAIN / SEND / MOD / XMA) from 101499 to 102500 ms**, while the game thread logged 1,724 GREC / TREAT lines.
At 102500 about 40 voices start in the same millisecond. The second late start in the long session
(`all_20261002_223613`, 443.4 s: 16 / 17 at 1113 ms, gain 0.005) follows a 1055 ms stall in the same way. That
session has 11 stalls > 250 ms, most of them about 1.0 s.
- Replaying the TREAT rows through our Class_Treatment with no audio rendered during that interval gives 17 / 16 at
650–680 / 780–810 ms after the blip, gains 0.062 / 0.046. Without the stall: 70–130 / 160–260 ms, 0.075–0.080.
- The 30 ms air blip before the takeoff plays no part: without the stall both renders start 16 / 17 in the
second air.
- *The short airs.* Whether a short air opens 16 / 17 depends on where the program's 32 ms walks fall against the
packet changes and on the shared random generator. Row-by-row replays at the recomp's cadence play none on the two
airs in question. Over both sessions the rate is the same: airs under 0.5 s with a 16 / 17 voice, ours (60 Hz
replay) 11 of 22, the recomp 10 of 22. In the long session's airs of 0.85–3.3 s both start 16 / 17 a median
50–90 ms after takeoff at matching levels (3 s airs: 0.15–0.19 in both).
- *Change.* None to the component.
- *Tools.*
- `.local/audio-re/treat/audio_gaps.py SESSION` lists audio stalls.
- `recomp_airs.py SESSION CSV` lists the recomp's Treatments voices per air, from the `retail_voices.py --csv` dump.
- Ignored tests in `tests/player_tricks.rs`: `treatment_airs_by_walk_phase` (row cadence, every walk phase, the ramp
air with and without the stall) and `treatment_replays_the_recomp_capture` (`TREAT_SESSION=` for the long session).
- **Check a session for audio stalls before reading timing out of it.**
**sense_of_speed recomp figures, redone with the shared-sample fix.** Script `.local/audio-re/sos/sos_figures.py`
(attributes each PLAY window's bank by sample address, as `retail_voices.py` now does; `--old` = first-match).
| session | sense_of_speed voices (old → fixed) | streams 3 / 4 (old → fixed) | Treatments 16 / 17 (hidden before) | Send A lines (non-zero) |
|---|---|---|---|---|
| 163809 | 645 → 624 | 125 / 124 → 114 / 114 | 11 / 10 | 282 (11, values 0.08–0.55) |
| 164620 | 1941 → 1827 | 299 / 303 → 244 / 244 | 55 / 59 | 801 (40) |
| 223306 | 269 → 233 | 43 / 43 → 25 / 25 | 18 / 18 | 72 (2) |
| 223613 | — → 1059 | — → 128 / 128 | 24 / 26 | 440 (9) |
- Send A stays 0 (FXWET0 = 0, as the updater writes). The few non-zero lines are module-address reuse by other
graphs; with the fix there are fewer of them, not more. The old "retail median 0.015" was such an artefact.
- *Ours against the recomp.* Ignored test `sense_of_speed_replay_of_the_recomp_speeds`: the local board's GREC
ground speed of 223613 per 60 Hz frame through SenseOfSpeed, the real bank, MixMap and evaluator. COM speed =
ground speed, so the wind is approximate in the air.
- Voice counts: 1068 for ours, 1059 for the recomp.
- Streams 0 / 1 / 3 / 4: 124 / 215 / 130 / 130 for ours, 111 / 219 / 128 / 128 for the recomp.
- 5–9: 24 in both. 10–14: 72 for ours, 61 for the recomp. 15 + 16: 343 for ours, 347 for the recomp.
- Streams 3 / 4 peak gain p90: 0.054 / 0.050 for ours, 0.058 / 0.056 for the recomp.
- **No difference to fix.** Before the fix the recomp seemed to play 3 / 4 about 20 % more often than our replay
would have.
**FootStep SubMix: the two "class default" values, read** (`sub_82494188`, plug-in descriptors in the image):
- *Pn21.* The build copies Pn21's four constructor arguments from its descriptor (`0x82FD0F98`, 40-byte records):
front 30 (1..90), side 110 (90..180), **normalisation 2** (0..2) and rear 150. It then overwrites argument 2 with
0.0, the same as the voice open (`sub_824A3140` passes 30, 110, 0). So the law is 0, a gain of 1.0, as `Pan2D`
already applies. Its parameters keep their defaults except the azimuth.
- *Sen0 #2.* It is only linked (0x7FF7FFF4 → SFX Master), never posted. Sen0's only parameter is `ATTRIBUTE_SETGAIN`
"Gain", default 1.0.
- The recomp's SEND lines confirm this: the eight FootStep SubMix graphs (2 feet × 4 slots; pattern `SEND1 SEND6`,
`.local/audio-re/busleft/submix_sends.py` on 223306) log Sen0 #2 = 1.0 from the build on and never change it.
- The same lines show Sen0 #1 at 1.0 from the build until the holder's level is posted (e.g. 0.059).
- *Change.* `bus/submix.rs` now starts Sen0 #1 at its class default 1.0, so the first posted level ramps from there
(`bus::env::Level::running`). Sen0 #2 and the law were already 1.0.
- *Effect.* e2e renders (13 scenarios incl. the walking replay `walk8`, `.local/audio-re/ovn/`) are bit-identical
with the change and with it reverted, submix on and off: the ramp runs in the block before the step's voice
starts.
**Reverb zones: the attribute check** (vfunc92 of the emitter manager, vtable `0x822FBDC8` +92 = `sub_824A2438`):
- It reads the attribute's reverb RefSpec (`99FD793BC30CF0FA`) and passes when its key is one of the **24 reverb
preset keys in the image table `0x8302E298`**. That is exactly the set of the 24 exported `aud_reverb` presets.
- A missing field reads the default RefSpec (key 0), which fails.
- *Change.* `emitters::zone_records` keeps type-5 records without a known preset as disabled zones instead of
dropping them: retail's query stops at the first type-5 node that fails. All 1,040 zone records on the disc
pass, so nothing changes in play.
**The music, speakers and crowds `.ems` files.**
- The emitter system loads all of a map's files and dispatches by eVolumeType. Every caller of the type getter in
TU3 checks only 1 (the looping-emitter pool, `sub_828E9ED8` / `sub_828EAD90` / `sub_828EB028` / `sub_828EB0F0`),
4 (`sub_828EB410`) or 5 (`sub_82488278`).
- *Type 4 (`music_*.ems`; 3 records in `reverb_downtown` / `reverb_industrial`) = music zones.*
- Their attributes reference class `E93ABF17955A5264` (RefSpec `442A158E27F2B5AD`): playlists with
`ePlayListMode`, `eChannelMapPreset` and `IPodSongInfo` / `DJSongInfo` song lists.
- `sub_824A2330` gates a node on the playlist record's `+4` = audio `+1608`. `sub_828EB410` keeps one winner
(smallest distance) and `sub_824A2198` stores its playlist key, volume, the float `463B21BB46415616` and its
distance / position in the emitter system.
- This feeds the licensed-music player, which we do not have. **Not ported.**
- *Types 6 / 7 (`speakers_*`, `crowds_*`, and the Maloof / MegaPark `sfx_` files).*
- They have no bank, and every record carries flags. The emitter system's flags mask is audio `+1032`, or 4 when
that is 0 or 0x80000 (`sub_8248B258`).
- Their nodes are never dispatched by type: speakers belong to the music / event PA, crowds to event crowds
(`0B5950C0184014C2` = crowd type 3 / 4). This needs event gameplay we lack. **Not ported.**
- *Change.* `emitters::update` now reads the sound emitters (type 1, flags 0) from all of the map's files, as
retail. Only the `sfx_` / `skateschool` files hold type 1, so the emitter list is unchanged.
**Native as the default with missing install data** (new data-gated test
`native::tests::missing_install_parts_fall_back_without_breaking`: copies of the dev manifest with parts removed,
data folders linked):
| case | result |
|---|---|
| no AEMS projects / no `emitter_utility` bank | runtime off, warning, measured tables |
| no MixMap | native on, no player components or bed (interim player cues and rolling loop) |
| no GRINDS (a core player bank) | native on, components off (interim cues) |
| no Treatments, or its file missing | native on, treatment off, the rest on |
| no `Common` (seam utility) / no bus tuning | native on, warning |
| no Splice trees | native on, contacts and footsteps off (interim cues) |
The per-frame host code (`native`, `player_audio`, `grain_bed`, `emitters`, `skate_events`) has no `unwrap` /
`expect` outside tests. Each interim cue is gated on its own part's flag.
**Files.**
- `crates/skate-audio/src/bus/submix.rs`, `bus/env.rs` (`Level::running`)
- `crates/skate-game/src/game_audio/emitters.rs` (zone check, all files for type 1)
- `crates/skate-game/src/game_audio/native.rs` (fallback test)
- `crates/skate-audio/tests/player_tricks.rs` (`render_frames`, `replay_treat_rows`, `treatment_airs_by_walk_phase`,
`sense_of_speed_replay_of_the_recomp_speeds`)
- Tools in `.local/audio-re/{treat,sos}/`
**Verification.**
- skate-audio: all tests pass (183 lib).
- game_audio: 50 pass.
- skate-game `--no-run` builds.
- Python: 27 tests pass.
- e2e: identical with every new part off, and with it on (above).
### Listening test 9: University water "overplaying itself, doubling or restarting" (2026-10-02, build 21:57)
**Report.** At (344.2, 68.03, −322.5), beside the dark water channel by the plaza trees: "It sounds like it is
overplaying itself and doubling or restarting? its hard to tell." Session `state_20261002_215843` (0 malformed),
`SKATE_AEMS=1`.
**What played.** The state log puts the user off board and still at (344.5, −322.5) from 29 s to 119 s. In that window
the only water is `.ems` record #27, `water_fountain` (13 × 7 × 61 m ellipsoid, patch 81), posted once and never
re-posted (03:59:08 → 04:01:00). Record #50, `fountains_waterlaps_left` (6 × 4 × 47 m, patch 347), was reached only
while walking along the channel (20.7–26.7 s, 46.1–47.2 s, 122.7–131.5 s). Its 4 m vertical semi-axis around y = 67.7
puts the listener (the camera) near its edge.
**Double-play candidates, all ruled out headless.**
- Interim water: with the native runtime the `PROFILES` relay is off for every bank in the install (log tag
`(native)`). `water.rs` is gone. Splashes fire only on water entry, and there were none. No location set played
water (only birds and jets).
- Two records: University's other fountain record (#46) is 109 m away. #27 and #50 overlap, and retail plays both
together too (sessions `all_20261002_180430` and `214002` show both banks in the same seconds).
- Cross-talk between banks: every `c_emitter` post reaches every loaded `c_emitter` bank. With `water_fountain`,
`fountains_waterlaps_left` and `trees_rustle` loaded and posts 81 and 347 live, only the matching bank plays
(`emitter_scene_probe`).
- Re-posting: release ends the post's voices at once. A re-post starts one fresh relay, never a second one. Two
voices at most per fountain post over 15 minutes.
**What the programs do** (`crates/skate-audio/examples/emitter_scene_probe.rs`, real banks and WAVs, redelivered every
60 Hz frame as `emitters.rs` does):
- `water_fountain` is a two-player relay of ten 1.6–2.5 s one-shots at pitch 0.82–0.94. Each player has its own op-8
shuffle bag (two op 8 blocks, 316 and 540).
- The next piece opens when the current one has about 0.17 s of source time left: 0.19–0.26 s before it ends in wall
time.
- Over 15 minutes that gives 14.4 starts per 30 s. The same piece plays twice in a row 11 % of the time (48 / 432),
because the two bags are independent. Each bag avoids its own repeats (0 repeats one start apart).
- `fountains_waterlaps_left` is one 37.46 s loop (loop start frame 189, clean wrap). It restarts from the top on
every post.
**Retail, earlier sessions.** `.claude/skills/aems-port/tools/retail_relay.py` lists a bank's PLAYs with their gaps.
- `water_fountain`: retail starts every 1.73–2.53 s, about one piece length apart (gap / length 0.99–1.07 at
the traced rate). That matches the relay above. No back-to-back repeat in the 5 retail transitions, which is too
few to tell from 11 %.
- `fountains_waterlaps_left`: retail re-posts it on fresh players 2–14 s apart as the listener crosses its edge, the
same restart-on-entry as ours.
**Retail at the spot (WATERRELAY, `all_20261002_222155`, clean).** The user walked to the channel and stood at
(346.0, −325.7) from about 61 s to 104 s. The fountain was posted once (c_emitter POST at 47.76 s, first piece 34 ms
later) and played 24 pieces. Run `retail_relay.py SESSION water_fountain [--from MS]`.
- **Steady stretch while standing (66.8–102.2 s), 18 starts:**
- Each relay turn draws from its own bag: A = 6 7 1 4 5 3 0 8 2 9 | 0 7, B = 6 7 1 8 2 9 0 5 3 4 | 6 8 (the first
three of each come from the approach stretch). Each is a complete pass of all ten pieces with no repeat. The
first pick of the next pass is never the last pick of the pass before. This is op 8 exactly as ported (avoid
flag at the wrap), with one bag per player, as in the bank (`emitter_scene_probe` prints both bags: range 10,
identity set).
- Back-to-back same piece: 1 of 17 (6 %; the 0 → 0 where bag A wraps). Ours is 11 % (48 / 432). With 17
transitions, retail's count is consistent with 11 % (P(≤ 1) ≈ 0.43).
- Cadence: gap / piece length p10 / p50 / p90 0.99 / 1.07 / 1.09, against ours 0.99 / 1.04 / 1.08. That is
15.3 starts per 30 s against our 14.4. The two retail outliers (1.19 and 1.58) sit on a recomp stall: no PLAY
and a 411 ms GREC gap at 76.4–77.6 s.
- **Approach stretch (47.8–64.2 s, listener walking in):** the first six pieces came in identical pairs (6 6 7 7
1 1): both bags made the same first three picks. They were also slow (gap / length 1.10–1.88, no overlap). Ours
doesn't do this. A fresh post draws both bags in its first walk and B's draw is never played (`PROBE_SHUFFLES=1`
prints every draw). Two explanations failed: a shared bag (ruled out by the steady stretch), and a slower relay
from the pitch word alone (w4 2600 slows ours but keeps the bags apart). The session has no MOD (pitch) lines, so
this is parked. It doubles **more** than ours, so it isn't what the user heard while standing.
- `fountains_waterlaps_left`: two brief activations while the listener settled (PLAY 57.78 s and 62.60 s, each
voice's player reused for other sounds within 1.1 s / 1.6 s), then silent while standing. That is the same
restart-on-entry and edge behaviour as ours. One difference: the second start came 0.57 s after its post (62.03 s)
instead of ~20 ms, probably retail reloading the bank (it unloads banks no node uses; we keep them until the map
changes).
**Verdict.** No doubling bug, and no code change. In the standing case the user reported, our relay matches retail
in mechanism (per-player op-8 bags, wrap rule), repeat rate and cadence. The "restarting" is authored:
- each piece's attack lands over the previous piece's tail;
- now and then the same splash plays again about 0.2 s before it ends;
- while walking, the channel loop starts over at each re-entry.
Open (parked):
- The approach-stretch pairing (both bags making the same picks), which needs an op-8 draw hook or MOD lines to
settle.
- The re-post bank-reload delay.
Side finding: the user's session logs
`OFFBOARD_GROUND_RESULT_NONFINITE` every frame while the user stood there (physics, off-board ground job). It did not
interrupt the emitter and is not audio, but it needs its own look.
**Files.** New: `crates/skate-audio/examples/emitter_scene_probe.rs` (scene probe; prints the op-8 bags and, with
`PROBE_SHUFFLES=1`, every draw); local tool `.claude/skills/aems-port/tools/retail_relay.py` (`--from/--to`, bags
by relay turn).
### Listening test 9 (seams): brick and University concrete; the console's 30 fps seam cadence (2026-10-02, build 21:34)
(Numbered with the water report above: both come from the same round of listening.)
**Problem.** The 21:34 build loads `Start_up_Play_ctl`. With it, the user said of both surfaces: "both sound better but
still need work". Earlier reports, verbatim:
- University concrete: "the same concrete still sounds too rough on our engine".
- DownTown brick: "still firing too much or too often and sounds like your going faster than you are".
Sessions (all clean, 0 malformed lines):
- Aletown brick: ours `state_20261002_213757`, the recomp `all_20261002_214002`.
- University at the PCU Library: ours `state_20261002_214224`, the recomp `all_20261002_214346`.
- A later recomp session with new seam hooks: `all_20261002_223306`.
- SEAMPAT logs `+636`/`+648`/`+620`/`+208`, the frame time and the four wheel positions on every Class_Seams process
call.
- SEAMHIT logs every hit.
Tools are in `.local/audio-re/seams9/`:
- `pairs.py`, `tagrate.py`, `agg.py`, `blocks.py`, `window_compare.py`, `hit_prominence.py`;
- `seampat.py` and `seamhit_match.py` for the hook session;
- `recomp_fps.py`.
Program probe: `skate-audio/examples/seam_pulse_probe.rs`. It drives the real Seams program and samples with per-block
w7 sequences or with a host model.
**Mechanism (asm).** `sub_82485190`, the audio manager, runs once per rendered frame: process (vtable +16), then the
MixMap tick, then update (+20).
- On every call, Class_Seams' process (`sub_824C14C8`) clears w7, then fires a hit when a wheel's grid cell changed.
- The program walks every 32 ms and sees only the latest w7.
- So a hit's pulse lasts one rendered frame, and that length decides how many hits the program sees and which of its two
players start.
Every hit starts a "base" block (samples 32–47, the same on every surface) and a surface block (80–87 on brick, 104–119
on concrete). The share of base-block voices works as a fingerprint of the pulse length:
| pulse length | base-block share |
|---|---|
| one 60 Hz tick | 2–32 % |
| 3–5 ms | 45–57 % |
| 33 ms | every hit is seen |
**The recomp is not the console.** The recomp renders uncapped. In 223306 it calls the process every 2.9 ms (frame-time
field median 2.5 ms). The speed changes on 18 % of calls (the 60 Hz physics step); the wheel positions change on 37 %
(the rendered pose, between the two rates). So the recomp's pulses last about 3 ms. On the shipped game (about 30 fps)
they last one 33.3 ms frame.
Measured in the recomp:
- Base-block share: 41–50 % in all sessions.
- Rates: 25–36 voices/s on brick (tag 66), 21–30 on concrete (tag 4, pattern 8).
- Pattern 11 (sidewalk), **settled.** The recomp reads `+636` = 11 there (SEAMPAT). It fires 15–21 hits a second at
20–40 km/h, but plays only about 0.5 voices per hit: 7.5 voices/s in 223306, about 3 in 214346. The sparse sidewalk
comes from its short pulse, not from the pattern.
- Our cell computation matches retail's. From the logged positions, 400 of 520 grid hits coincide with a cell change of
that wheel on the same call.
**Change: the console cadence at any frame rate.** User decision, verbatim: "match the shipped game, use the 30 fps
cadence however lets scale up and account for the higher framerates that the rust engine is going to be running at".
- **Virtual calls.** `player::seams::Seams::frame` runs on every rendered frame (`PlayerAudio::seam_frame`, from
`native::mixmap_frame`). It makes virtual process calls on a fixed 30 Hz grid of real time.
- **Wheel positions.** Each virtual call reads the wheel positions at its own time, interpolated along the rendered
trajectory: the physics samples interpolated by `Time<Fixed>::overstep_fraction`, as the presentation does. So it sees
the same crossings the console samples.
- **The pulse.** A hit's w7 is redelivered at once and cleared on the audio clock after 6 or 7 blocks (6.25 on average,
one console frame), via `SeamCommand::RedeliverAt` and the new `Runtime::redeliver_at`.
- **The update half** (levels, the turn word's per-write slew) also runs once per console frame.
- **The 60 Hz tick** only creates the packets and writes Cracks.in0 (`process_tick`), latched from any hit since the
last tick.
- **Below 30 fps,** several virtual calls run in one frame.
- **Default and off switch.** On by default. `SKATE_AEMS_SEAM_PULSE=0` restores the old path: the whole process per
60 Hz tick, at the physics positions.
**Proof of frame-rate independence.** E2E `E2E_FPS=f` runs the game's host at f fps. Over all straight rolling above
10 km/h in both logs, voices per second and base-block share per tag:
| tag | 30 fps family (30 / 29.7 / 30.3 / 20) | 59–370 fps (59, 60, 61, 140, 144, 150, 240, 360, 365, 370) |
|---|---|---|
| 3 | 16.9–20.6, 50–59 % | 17.5–21.0, 47–60 % |
| 5 | 32.9–37.6, 10–30 % | 28.0–41.0, 7–32 % |
| 66 brick | 18.4–20.7, 10–15 % | 16.6–21.0, 3–22 % |
| 4 concrete | 17.4–20.1, 20–26 % | 17.7–22.7, 19–38 % |
- Grid hits are the same at every frame rate: 2121–2141 (brick log) and 1189–1207 (University log).
- Per-voice gain is the same at every frame rate.
- The 30 fps spread is phase noise in the program's shuffle, and every higher frame rate falls inside it.
- Unit test `seams::tests::console_cadence_is_frame_rate_independent`:
- hits within 2 % from 30 to 365 fps (5 % at 20 fps);
- every pulse lasts 6 or 7 blocks, with a mean of 6.25.
- For comparison, our old 60 Hz host (the 21:34 build) gave tag 66 at 25.8 voices/s with a 25 % base share, and tag 4
at 26.0 voices/s with 20 %.
**Against the recomp (a high-fps reference, not the target).**
| | ours, console cadence | the recomp |
|---|---|---|
| brick | ~19 voices/s, base ~14 % | 25–36 voices/s, 46–50 % |
| concrete | ~19 voices/s, base ~23 % | 21–30 voices/s, 41–49 % |
| sidewalk | 14–32 voices/s (every hit is seen) | 3–7.5 voices/s (short pulses) |
A per-call model run at the recomp's own rate matched the recomp within about 20 % on rate and share, so the mechanism
reproduces both machines. No console capture exists to check the 30 fps numbers directly.
**Other comparisons (no change).**
- Grain bed (the sounding truck's player A), the recomp vs ours:
| window | gain | position |
|---|---|---|
| concrete, 43 km/h | 0.237 vs 0.245 | 0.78 vs 0.78 |
| brick | 0.208 vs 0.196 | 0.494 vs 0.494 |
- Rattles: about one per push in both.
- Spidercracks and Class_rolling: none in these windows.
- Octave spectrum above 125 Hz: within about 2 dB. The recomp's capture also holds ambience bass.
**Open.**
- **Grain pitch.** The recomp's GREC pitch is about 0.97 (0.966–0.991 by band). Ours is 0.995 in e2e, where the
listener is synthetic and gives no Doppler. SkateBoard pitch(3) carries the MixMap's Doppler term, so in game the
camera decides it. Not yet measured in our game.
- **Hit prominence.** A hit's high-frequency rise over the rolling median is 2.8–6.0 dB in the recomp's capture and
5.5–11 dB in ours. Voice gain matches, so the cause is downstream: pitch, bus or masking. *Re-measured in "The MixMap
on the console cadence; seam-hit prominence re-measured": the recomp figures were taken ~110 ms before the hits.*
- **Other per-frame rules.** On the console the whole audio manager is per rendered frame at 30 fps. That covers the
MixMap tick (dt 1/30), the eEQChain jitter every second frame (15 Hz on the console, 30 Hz in ours), the Doppler slew
per evaluation, and other components' one-frame pulses. Only Class_Seams follows the console cadence so far. These are
listed for the host owner. *Done for the MixMap, the Jitter and the eEQChain clear (next section); the eEQChain clear
is 30 Hz on the console, not 15 Hz.*
- **Landed latch.** Wheel 0's landed latch stays per 60 Hz tick. *Correct: the conditioners run per physics step (next
section).*
**Validation.**
- skate-audio: 181 lib tests and the data tests pass.
- `game_audio::`: 47 tests pass.
- The `--no-run` build and `cargo check` pass.
- Default e2e renders (one call per row, the old path) are byte-identical to the 21:34-era renders of both logs.
**Files.**
- Changed:
- `skate-audio/src/player/seams.rs` (`frame`, `process_tick`, `expire`, `RedeliverAt`, the Cracks latch);
- `skate-audio/src/runtime.rs` (`redeliver_at`);
- `skate-game/src/game_audio/{native,player_audio,e2e}.rs`.
- New: `skate-audio/examples/seam_pulse_probe.rs`.
- E2E switches: `E2E_FPS`, `E2E_CALLS`, and `E2E_SUBSTEPS` (diagnostic: the whole host N times per row).
### The MixMap on the console cadence; seam-hit prominence re-measured (2026-10-03, overnight)
**Problem.** Listening test 9 left two items open. First, the per-frame host rules other than Class_Seams (the MixMap
tick, the eEQChain jitter, the Doppler slew, wheel 0's landed latch) still ran on our 60 Hz step instead of the shipped
console's cadence. The user's decision for that: "match the shipped game, use the 30 fps cadence however lets scale up
and account for the higher framerates". Second, the seam hits measured more prominent above the rolling bed in ours
than in the recomp's capture (5.5–11 dB vs 2.8–6 dB high-frequency rise), and ours showed more high-frequency onsets.
**Mechanism (asm, `sub_82485190`, the audio manager; reference reading only).** The manager runs once per rendered
frame with that frame's dt and splits its work into two halves.
- Half 1: `sub_82491180` (the eEQChain clear and jitter post), the listener (`sub_8248CC08`), then every state
manager's process (vtable +16). That reaches each SFX object's process slot +36, which includes `SFXObj_Jitter`'s
walk step (`sub_824EF378`, vtable `0x822FC248` +36).
- Half 2: the MixMap tick (`sub_8294BAE8`, the halves' summed dt), then every update (vtable +20).
- When dt is above 0.02 s (`0x822F8DE8`), both halves run on every call. At or below it they alternate, each with the
sum of the last two dts.
- So the console at ~30 fps evaluates the MixMap, steps the Jitter and clears the eEQChain buses **once per 1/30 s**,
with dt ≈ 1/30. It would be the same at 60 fps, with the halves alternating at 30 Hz each.
- The eEQChain clear is in half 1, so it runs on **every** console frame (30 Hz). The "15 Hz" in the previous section was
wrong. Our old host's rule, every second 60 Hz frame, was already 30 Hz.
- Envelopes use dt. The Doppler slew `D −= trunc((target − D)·(−0.2))` and the jitter walk are per evaluation, so at our
old 60 Hz both ran twice as fast as on the console.
- The recomp renders uncapped (~345 fps), so its halves alternate at ~170 Hz: not the console's rate.
**Change.**
- `skate_audio::mixmap::cadence::Cadence` picks the 60 Hz host steps that carry a console evaluation: every second step
on the fixed grid of real time the 60 Hz steps already use. `native::mixmap_frame` keeps the inputs, the components'
process and update per 60 Hz step. On the steps that carry an evaluation it also:
- clears the eEQChain buses, before the inputs, as half 1 does;
- steps the Jitter walk once (`PlayerAudio::jitter_steps`);
- ticks the MixMap with dt = `CONSOLE_DT` (1/30).
- Below 30 fps one real frame can carry two evaluations; both run.
- **Held inputs.** Retail's writers run once per evaluation, so a one-frame flag lasts a whole console frame and the tick
always sees it. Ours write per 60 Hz step, so a flag set on the step between two evaluations would be cleared before
the tick. The new `MixMap::hold_input` makes the next tick see the largest value written since the last one; the
stored input keeps the last write. It is used for the six flag inputs (`native::hold_flag_inputs`):
- Contacts 1 / 6 (landing swell, landing material);
- Rail 1 (grind ended);
- SkateBoard 0 / 4 (surface change, push plant);
- Cracks 0 (seam hit).
Every other input the host writes is a level.
- On by default. `SKATE_AEMS_MIX_CONSOLE=0` restores the old host: an evaluation per 60 Hz step with dt 1/60, the Jitter
per step and the eEQChain clear every second step. In e2e the cadence is on with `E2E_FPS` (the game's host) and off in
the per-row renders; `E2E_MIX_CONSOLE=0/1` forces it.
- e2e fix: below 60 fps one call spans several rows, but the listener's velocity (Doppler) and the bed's frame took one
row's dt. They now take the call's dt, as the game passes the real frame time. This affects only `E2E_FPS` < 60
renders; the 30 fps renders in "Listening test 9 (seams)" had this error.
**Wheel 0's landed latch: no change.** The latch is the conditioner `sub_82772FD8` (called from `sub_82772748`). That is a
physics-side PhysOut conditioner (vtable `0x82310A78`, listed among physics jobs at `0x82348E98`), not an SFX object.
The recomp shows the conditioners' outputs change at the physics rate, not per rendered frame. In session 223306 the air
time `+236` (TREAT) changes on 860 of 5,094 in-air calls (17 %), always by 1/60 s (0.0167 / 0.0166). Its "more than 5
frames in the air" are physics steps on the console too, so our per-60 Hz-step latch (`Seams::process_tick`,
`contacts`, `rolling`) already matches.
**Proof (e2e, both user logs: full1 = brick 213757, full3 = University 214224; `.local/audio-re/cadence/`,
`fps_band.py`).**
- Identity with the new parts off: the default per-row renders, and `E2E_FPS=60 E2E_MIX_CONSOLE=0`, are byte-identical
(f32 and voices) to a pre-change build of the same tree (`.local/cb`, my files reverted).
- Frame rate, with the console cadence on:
| fps | full1 grain A gain p50 / p90 | full1 A pitch p50 | full3 A pitch p50 |
|---|---|---|---|
| 29.7 / 30 / 30.3 | 0.2204 / 0.2205 / 0.2019, p90 0.2491 / 0.2491 / 0.2488 | 0.9907 / 0.9907 / 0.9895 | 1.1069 |
| 20 | 0.2192 / 0.2491 | 0.9907 | 1.1069 |
| 60 / 144 / 240 / 365 | 0.2012 / 0.2488 (all four identical) | 0.9912 | 1.1074 |
- The MixMap-driven quantities are identical from 60 to 365 fps.
- Between 60 and 365 fps only the seams' shuffle phase moves: 18.3–20.0 seam starts/s, against 18.0–19.0 at 29.7–30.3.
- The 60+ fps values sit in the 30 fps band or within 0.0005 of it.
- The remaining 30-vs-60 fps difference in per-second levels was already there with the old cadence: median
|Δ| 0.61 / 0.24 dB old vs 0.42 / 0.18 dB new. It comes from the components' process running once per real frame below
60 fps (one state sample per two physics steps), not from the MixMap.
- New cadence vs old at 60 fps: per-second level median |Δ| 0.27 / 0.08 dB, p90 0.96 / 0.41 dB. The visible change is
the Doppler: grain A pitch p10 0.839 → 0.820, because the slew now runs at the console's 30 Hz.
- Unit tests: `mixmap::cadence::tests::evaluations_follow_the_steps_not_the_frames`,
`mixmap::tests::a_held_flag_written_between_ticks_reaches_the_next_tick_once`.
**Seam-hit prominence, re-measured (no change made).** Tools in `.local/audio-re/seams10/`: `windows.py`, `perhit.py`,
`lag.py` (event-triggered average), `jitter.py`.
- **The old comparison was misaligned.** `hit_prominence.py` took the recomp's high-frequency peak 0–40 ms after the
logged PLAY time.
- An event-triggered average of the capture's >2 kHz envelope around ~500 hits per session puts the hits **~110 ms
after** the logged time in every session (214002, 214346, 223306, 223613).
- Isolated strong hits spread from 75 to 160 ms (interquartile), so the capture's alignment jitters by about ±45 ms.
- Ours peak at +40 ms (the voice log is per 60 Hz frame).
- So the recomp's "2.8–6 dB" was mostly the bed before the hit.
- **Aligned comparison.** Event-triggered peak over the local median, by band (four recomp sessions vs our two renders):
| band | the recomp | ours |
|---|---|---|
| 0.5–1 kHz | 2.3–3.7 dB | 5.5–5.8 dB |
| 1–2 kHz | 3.4–5.0 dB | 7.0–7.5 dB |
| 2–4 kHz | 5.0–6.4 dB | 8.3–8.6 dB |
| 4–8 kHz | 6.4–7.7 dB | 8.8–10.0 dB |
| 8–16 kHz | 7.3–8.3 dB | 11.5–13.2 dB |
The recomp's figures are blurred by its alignment jitter. Adding Gaussian jitter of 30–60 ms to our hit times moves
ours by at most about 1 dB (the hits are dense), so a gap of about 2–3 dB below 2 kHz and 3–5 dB above 8 kHz remains.
- **Mechanism checks, all equal or not the cause:**
- **Pitch.** Seam voice pitch p10/50/90: the recomp 0.98 / 1.10 / 1.18 (163809 MOD); ours 0.99 / 1.08 / 1.16.
- **Record levels.** Seam voice gain over the bed's gain A while rolling at 20–50 km/h: the recomp −9.0 to −13.1 dB,
ours −11.1 to −13.4 dB. Ours is not louder at the record level.
- **Bus.** The seams post to eEQChain 8 = the default bus input (no EQ), like the bed's graph 2.
- **Voices per hit.** The same distribution (about 60 % single, 25 % pairs; identical samples per hit alike).
- **Fold.** 99 % of our render's energy is in C (the board ahead of the camera), so the capture's fold weights don't
change the comparison.
- **Bed spectrum.** Away from hits, relative to its 0.5–1 kHz band, the bed is within about 1.5 dB per octave up to
16 kHz.
- **Low-pass.** The recomp's seam voices often play low-passed: 54 % of their starts set the LPF below 24 kHz (down
to 8,990 Hz). The Seams program maps w15 = |turn·1000| (state +204, slewed ±100 per write) to the voice LPF:
w15 ≤ 200 → 24,971 Hz, 300 → 20,177, 600 → 15,382, 1000 → 8,990 (`program_trace`). Ours does the same. Our rolling
|turn| is distributed like the recomp's (both p75 0, >0.25 on 4–18 % vs 13–31 % of frames), so on straight rolling
the filter is open in both.
- **Grain start fades.** The fade request's curve is 1.0 (`0x8231A844` in `sub_828EC2F8`) → GaF0's square-root
curve, as ported. The window picks are bit-exact with the PoC vectors.
- **Onsets.** Our high-frequency onsets coincide with grain starts no more often than chance: 45 % and 41 % within
±25 ms, with ~9 grain starts/s across both trucks' players, where chance is ~45 %.
- The remaining 2–5 dB has no mechanism found. Candidates: what else the capture holds around the board (ambience at
6–17 dB below the bed in high frequencies, other skaters, the recomp's 25–36 seam voices/s raising the local median)
and the bed's post-record chain. No level change: retail parity needs a mechanism, not a fitted weight.
**Files.**
- New: `skate-audio/src/mixmap/cadence.rs`.
- Changed:
- `skate-audio/src/mixmap/{mod.rs, tests.rs}` (`hold_input`, tick split into hold + `evaluate`);
- `skate-game/src/game_audio/native.rs` (`mix_console_requested`, `hold_flag_inputs`, the cadence in
`mixmap_frame`);
- `skate-game/src/game_audio/player_audio.rs` (`jitter_steps`);
- `skate-game/src/game_audio/e2e.rs` (`E2E_MIX_CONSOLE`, the call dt fix).
**Verification.** skate-audio: 183 lib tests and the data tests pass; `game_audio::` 50 pass; the `--no-run` build
passes; the e2e identity and frame-rate band are above.
**Open.**
- The components' own per-call rules still run per 60 Hz step. Retail runs every process / update on the 30 Hz cadence
too, but its conditioners hold pulses over several physics steps for that. Examples: Seams' turn-word slew
(already 30 Hz), the bed's per-frame steps (`grain_bed::update` runs per rendered frame, dt-scaled), and frame
counters in the components. Moving those is a separate, wider change; it touches components other agents own.
- The remaining seam-prominence gap (above). A console capture, or a recomp capture with a time-exact voice log, would
settle it.
### World sound sources: traffic, pedestrians, speech (2026-10-03, `skate_audio::world`, `game_audio/world_sources.rs`)
**Problem.** The traces had already found retail's ped voices, ped footsteps, traffic engines, horns, alarms and skids
(todo `audio-world-npc.md`). The engine has no living world (no peds, no traffic), so none of them could play. The
user asked for the foundation: the retail sound objects ported and ready, so that a future ped or vehicle system
only has to publish state.
**Root cause.** These sounds are not emitters. Each comes from a per-object SFX owner on its own MixMap slot:
- Traffic slot 4: 4 instances. TrafficEngine, TrafficSkids, TrafficHorn.
- Pedestrian slot 5: 15 instances. PedestrianSpeech, PedestrianSFX, PedBodyFall, Tazer.
Each owner is driven by its game object's state, so the owners need that state before they can sound.
**Evidence** (TU3 recompilation, reference only; spec notes `.claude/notes/world-traffic-audio.md`, `world-ped-audio.md`,
`world-speech.md`):
- **TrafficEngine** (`sub_824D6110` / `sub_824D6478`): an RPM model.
- In each "gear" (4 × 7 m/s) the target rises linearly from 0 to the record's max at the gear's top.
- A ±8 RPM wobble is added and the result is clamped to [idle, max].
- The RPM follows at 2000 RPM/s, rises at 1000 and falls at 4000. A fall holds the shift word w17 = 1 for 3 updates.
- The levels are split front / rear by the car's heading against the camera.
- The `TRAFFIC_CAR` packet's w14 is the engine patch. The record's patch 1 becomes 7 or 8 for a third each, and patch 3
becomes 6 for half.
- Retail loads all eight engine banks at once (recomp READs at 8.8 s). **Each bank's program destroys its
instance unless w14 is its own number**, so every post sounds in exactly one bank. The recomp agrees: the starts of
session `all_20261002_164620` are single-bank.
- **TrafficHorn / car alarm / TrafficSkids:**
- Horn: posts `TRAFFIC_HORN` with a random variant 0..8. The kind is the vehicle's horn state 1–5, and a record with
patch 0 never honks.
- Car alarm: horn state 6 posts `c_car_alarm` (variant 0..3).
- Skids: post `TRAFFIC_SKID`, which plays on the vehicle's skid flag.
- **PedestrianSFX:**
- Two held `livingword_footstep` packets (one per foot), with the program stepping on the foot-down word.
- A `sk8_foley` Splice step per plant: walk / jog / run 62 / 63 / 64 by the ped's speed, above 2.5 and 7.5 m/s.
- The packet's shoe class (w14) picks the sample set, and **class 1 is silent**.
- **Speech:**
- The state graphs send a speech value on state entry (`SendSpeechEvent`). PedestrianSpeech hands it to the speech
manager on change (values 7 / 8 remapped).
- The clips stream from `livingworldspeech.big`: 3,011 clips × 1–48 takes, mono XMA2 at 36 kHz. Each take's offset and
SNR header are in the nested `livingworldsth.big`.
- Which reaction plays which event was measured in the recomp: warn → 501, slam nearby → 104, collision nearby → 205 /
204 / 202, trick nearby → 101, flee → 108, greet → 1901 / 806 / 807.
**Change.**
- `crates/skate-audio/src/world/` (new, engine independent), with the player components' process / update split:
- `traffic.rs`: `Engine`, `Horn`, `Skids`, `Vehicle`, `EngineRecord`;
- `peds.rs`: `PedSfx`, `PedSpeech`, `PedFootstepTuning`;
- `speech.rs`: clip index, reaction cues, line choice, mixer slots;
- `owners.rs`: instance pools and 3DObjPos blocks, through the existing `player::objpos`;
- `keys.rs`: the slot 4 / 5 controller keys.
- `game_audio/world_sources.rs` (new): the `WorldOwners` resource a ped / vehicle system fills each frame, and a system
after `native::mixmap_frame`. It assigns instances, updates and processes the owners, applies the posts, and loads the
world banks on the first published frame.
- **Inert by default.** With no owners it returns at once, so play is unchanged. `SKATE_AEMS_WORLD=0` turns it off.
- Shared files touched: one module line and one plugin line in `game_audio/mod.rs`, and one optional manifest field
(`world_tuning`) with its accessor in `library.rs`.
- Setup: `tools/asset_pipeline/world_audio.py`.
- The 13 world banks join the decoded set.
- The manifest gets `world_tuning` (the `aud_traffic_engine` records and the ped footstep fields).
- `speech/livingworld.json` holds the clip index (2.3 MB).
- Decoding the speech is opt-in with `SKATE_SETUP_SPEECH=1`: about 2.4 GB of PCM for the free-roam events.
- Dev install tools: `stage_extra_banks.py --tag world …` and `.claude/skills/aems-port/tools/stage_world_audio.py`.
**Verification.**
- `crates/skate-audio/tests/world_sources.rs`, headless through the real MixMap and banks:
- A car of patch 1 at 12 m/s passing 8 m away:
- only `C01_family01` sounds;
- the engine pitch shows the Doppler shift: 4182 approaching, 4007 receding (4096 = 1.0);
- the front layer peaks at the closest approach and is gone beyond ~45 m;
- the RPM settles at 12·4000/14.
- Patches 0–9 through all eight banks: patch n sounds only in `C0n`; 2 and 9 are silent.
- A ped walks past and plants its feet: an AEMS step and a `sk8_foley` step per plant.
- The sample groups our evaluation produces for shoe classes 2–5 cover **4,798 of retail's 4,949**
`fstep_livingworld` starts (session 164620).
- A bumped business man (voice 59) resolves to a `501` warn line of his own voice; the take renders for its 5.49 s.
- Unit tests:
- skate-audio `world::` (14): the RPM model, the patch override, the front / rear split, horn / alarm / skid
words, footstep packets, speech value remap, clip names, line choice;
- game_audio:: `world_sources` (2);
- Python `test_world_audio` (3).
**Open questions.**
- **Speech:**
- ~~the speech manager's `.evt` rules~~ and ~~the `_n` / `_f` lines~~: ported, see "World speech: the speech
manager's rules" below;
- which PedestrianSpeech output sets the speech level.
- **Assignment and positions:**
- retail's instance assignment (ours: nearest N);
- which points the traffic position blocks 4.1–4.3 follow (ours: all three at the body).
- **Fields not traced:** vehicle `+112` / `+144`, `TrafficCarPhysics.in0`, the ped model → shoe class / voice.
- **Not ported:**
- PedBodyFall;
- Tazer, which needs AEMS op 38;
- ~~NPC skaters' boards~~: foundation ported, see "NPC skaters' board sounds" below;
- event PA / crowds / music zones, which need events and a music player.
#### World speech: the speech manager's rules (2026-10-03, overnight; `world::speech_manager`, `world::speech_rules`)
**Problem.** The line and take choice was a uniform placeholder, and the `_n` / `_f` lines and the vault tuning were open.
**Root cause.** Retail's choice runs in two layers.
- The game's speech manager maps a speech value to an event and gates it with the vault tuning.
- A generic speech library then picks the line and its takes from the `.evt` table and the clips' `.hdr` headers.
- Neither layer was read.
**Evidence** (TU3 recompilation, reference only; `.claude/notes/world-speech.md`):
- **Value → event** (`sub_824AB6C8`): a switch over the speech value.
- A `rand()` coin picks between 203 / 205, 603 / 607 and 108 / 109.
- Values 1 / 3 play radio lines for security guards. For bums, `rand() % 3` picks 206, 207 or 1901.
- **Gate** (`sub_824ABA18`, `sub_824A8C78`, `sub_824A75F0`): the vault class `Hash_9C1F48F5D637E275`, with
`SPCHType_1_EventID` naming the living-world event. Its `Sk8::Audio::tSpeechTuning` holds:
- per-speaker timers: the same event after `+24` s, any line after `+8` s;
- a not-follow list (no line within t s after event e);
- a `rand() % 1000 × 0.1` probability against `+20`;
- the player's speed window in km/h (`+32` / `+36`);
- excluded challenge types;
- zombie mode (`+60`). The timers start at 0 and restart when a line starts (`sub_824A90B8`).
- **Request words** (`sub_824D9908`, `sub_824ABD90`): ped type bit, voice variant bit, the near / far flag, the
conversation partner's type, zombie 1 / 2. Which words an event reads is per event.
- **Line** (`sub_82973CB8`):
- the event's probability;
- the records in a weighted random order: weight = 4^(b >> 5) × (b & 31) of the record's first byte;
- the first record that passes its own probability and whose field masks share a bit with the request words (0 = any).
- **Takes** (`sub_82972660`, `sub_82974220`, `sub_82973408`):
- a record's clips play in sequence;
- per clip, the takes not in its history ring (`.hdr` +8, usually the take count) are the candidates; when every
take is in the ring, the one played longest ago is the only candidate;
- a random candidate index, redrawn while it is among the last min(n/2, 10) picks of that clip in a 32-entry ring;
- the chosen take goes into the clip's history when the line starts.
- **Near / far.** The flag is 1 when the ped's `+148` exceeds `+156`. The `.evt` itself shows that 1 means far:
`101_51_GenPos_Grn1_far`, `104_51_grn1_Slam_far`, and all 366 `_f` / `_n` records of 101 / 104 / 202 / 203 / 501
split that way.
- **The recomp agrees** (`.claude/skills/recomp-research/tools/speech_takes.py`, 10 sessions, reads in audio stalls
left out):
- The take of each speech READ follows from its offset. All 57 clips played twice or more follow the history rule,
including one full cycle (`806_82_torm1_Int_c14_tour`: 0, 1, 0). A uniform pick would have repeated a take in
about 13.6 of them.
- All 15 multi-clip lines are one record's clip list in order (radio chirp / line / chirp; phone greet / silence /
call).
**Change.**
- `crates/skate-audio/src/world/speech_rules.rs` (new):
- the `.evt` parser (`EventTable::parse`, the same layout for all four speech banks);
- `Library::start`: the record order, the field match, the clip sequence, the history and the recent ring;
- the generator: the add-with-carry with the image seed, which retail shares with the grain player.
- `crates/skate-audio/src/world/speech_manager.rs` (new):
- `event_for_value`, `request_block` / `request_words`;
- `EventTuning` and `SpeechManager::gate` / `request`;
- `speaker_bits`: voice id → type and variant bits from the records.
- `speech.rs`:
- `SpeechIndex::set_ids` / `clip_by_id` / `picks_to_lines`;
- `choose` is now documented as a fallback for hosts without the rules.
- `world/mod.rs`: two module lines.
- Setup (`world_audio.py`), with no change to `audio_export.py`:
- `speech_index` now also exports each clip's `.hdr` id and history and the parsed `.evt` (`rules`);
- `world_tuning` gains `speech_tuning` (per bank and event).
- The game host is unchanged and still inert.
**Not ported** (fails closed, or unused by living-world data):
- the per-channel request queue (16 slots, 8 streams, timeout = event +2). The recomp plays several living-world lines
at once;
- the interrupt rules (`sub_824A73F0`);
- external conditions and clip parameters;
- the main-cast path for peds without a living-world speaker (`+116 == 0`).
**Verification.**
- skate-audio unit tests `world::speech_rules` (6) and `world::speech_manager` (4).
- `tests/world_speech.rs` (4) on the dev install's export:
- near / far: 366 records agree, 0 disagree;
- voice ids map to their type and variant bits;
- a bumped business man gets `501_59_busm1_Warn_f` far and `_n` near, and is refused inside the 15 s repeat. His 3
takes play once each, then repeat in the same order;
- a guard's radio line is chirp / line / chirp with two different chirp takes.
- Python `test_world_audio` (6).
**Open.**
- Which ped values `+148` / `+156` are.
- The speech level and pan (which speech-manager output drives the stream voice).
- The time unit of the queue timeout.
#### NPC skaters' board sounds (2026-10-03, overnight; `world::skaters`, `game_audio/npc_skaters.rs`)
**Problem.** The AI skaters (4–14 around the player in the user's sessions) had no board sounds, and which MixMap slot
retail uses for them was open: the Player slot has only 2 instances in free skate.
**Root cause.** Retail does not give every AI skater sounds. `CSTATEMGR_Player` owns exactly two records, one per
Player-slot instance. The local player holds the first. The second goes to **one** NPC skater: the first in the skater
list that comes within 30 m of the camera while the record is free. It runs every player component for that skater.
**Evidence** (TU3 recompilation, reference only; spec `.claude/notes/world-npc-skater-audio.md`):
- **The records.**
- The manager's init `sub_824F1F40` creates two `CSTATE_Player` records (creator `sub_824F8D60`; +68 skater index,
+72 local byte).
- Its update `sub_824F1FB0` walks the skater list (544-byte entries at `*(0x83083C38)+0x2F070`). It keeps a skater's
record (matched by id) or asks for a new one.
- NPC skaters are considered only while `sub_824F8EF8` holds: distance to `*(0x830CFDD4)` (the camera) < 30.0
(`0x820D4924`).
- The create `sub_824F2238` hands out the first inactive record. Only the local skater may evict one.
- The record's update `sub_824F8E18` drops an NPC at 30 m or more, or when it leaves the list.
- **Every component runs for that skater.** The audio-state bridge `sub_824B0DA8` reads the skater entry at the record's
index. The components' local tests (`[record+72]`) pick the non-local branches the port already has: no wind /
rattle, one routing pass, no held layers, the collision local byte clear. Class_Seams gates only on the record's
active byte (`+52`), so it runs for the NPC too.
- **The soft-wheel word** (`sub_824B23C8`). For a non-local skater it returns 1 when the local player's record `+84` is 0.
The bridge writes `+84` with the local player's own `+684`. So **an NPC board plays the soft-wheel samples exactly
when the local player's wheels are hard**.
- **PlayerPhysics in13** (`sub_824B19C8`) for a non-local skater: |its COM velocity − the local player's|, capped at 35,
slewed by 100 /s, × 32767 / 35. The vault class is `0xC1831BDB6CB1B1EA`.
- **The recomp:**
- The CONTACT hook's second Contacts object (`0x40C710A0`) fires in bursts that match a nearby AI board's speed and
landing. Session `all_20261002_164620`: 326–329 s = board `468B18F0`, 25 m away, 10–11 m/s; the local player is still.
The earlier note that both contact objects were the player's was wrong.
- The user's board is hard. Soft grain members play 0.6–4.9 voices/s around those bursts, against 0.3–0.9/s over
the session. Their GAIN × SEND p50 / p90: `concrete_smooth_soft` 0.026 / 0.172, `concrete_aggregate_soft` 0.012 /
0.090, `asphalt_rough_soft` 0.002 / 0.106. The local's `concrete_smooth_hard` reads 0.238 / 0.737.
- Tool: `.local/research/npc-skater-audio/instance1_voices.py SESSION`.
- **The non-local collision helper `sub_824BF5F8`** is speech, not board audio. On a body impact during a bail it finds
the skater's `CSTATE_SkaterSpeech` record and posts message 8206 / 115 (a bail grunt).
**Change.**
- `crates/skate-audio/src/world/skaters.rs` (new):
- `Slots`: the record assignment above;
- `component_state`: an NPC's `AudioState`, with `local` = false and `soft_wheels` = the inverse of the local
player's;
- `NpcSkater`: the local player's component objects for instance g. It writes PlayerPhysics, the two 3DObjPos blocks,
Contacts, Rail and OffBoard inputs at g. It runs the rolling routing, rattle, seams, grind with its on / off
sounds, skid, squeaks, foot drag, sense of speed and the Splice contacts. It returns the same `Command`s and hands
its collision messages to the shared collision manager. No component code is forked.
- Shared code, each change identical for the local player (`AudioState::local` is always true there):
- `player/inputs.rs`: `Physics` and `Contacts` get an `instance`; `Physics::write_against` gives the non-local in13;
`write_rail_at` and `write_off_board_at` are added.
- `player/seams.rs`: an `instance` for the Cracks key. The `!local` early returns are gone; retail has none.
- `inputs.rs` in12, `seams.rs` w11, `components.rs` skid w8 and `contacts.rs` tier: post `s.soft_wheels`
(`sub_824B23C8`'s value) instead of `local && soft_wheels`.
- `game_audio/npc_skaters.rs` (new): the `NpcSkaters` resource an AI-skater system fills each frame, in list order, and
a system after `native::mixmap_frame`, like `world_sources.rs`. It uses the local player's tuning and loaded banks.
- **Inert by default:** with nothing published it returns at once.
- `SKATE_AEMS_NPC_SKATERS=0` turns it off.
- `PlayerAudio::post_collisions` takes the NPC's collision messages.
- Shared lines: one module line and one plugin line in `game_audio/mod.rs`.
**Verification.**
- skate-audio `world::skaters` unit tests (3): the assignment (30 m in / out, no eviction, list order, NaN, leaving the
list), the soft inverse, and in13's slew and cap with the instance's blocks.
- `game_audio::npc_skaters::tests::npc_skater_rolls_and_grinds_past_the_listener` (data-gated). An NPC rolls past 8 m in
front of the camera at 7 m/s, ollies onto a metal rail, grinds and lands, through the real banks, MixMap, Splice and
buses at the 30 Hz evaluation cadence:
- claimed at 30.0 m, released at 30.06 m; nothing opens a second after the release;
- Seams_Bank voices peak at 0.046–0.065 near the camera and 0.017 at 22–30 m;
- GRINDS 0.087–0.094 at 8 m, Skate_Metal on / off 0.10–0.12, the landing (Skate_Collisions) 0.205;
- the seam packets carry the soft word 1 with hard local wheels and 0 with soft ones.
- Identity: `e2e_bench.sh` before / after the change (13 scenarios and 4 real sessions, row and fps300) → every output
hash identical (`compare_runs.sh npc-pre npc-post`). skate-audio tests and `game_audio::` (60) pass.
**Open.**
- The NPC's granular rolling bed. The runtime has one `GrainBed`, driven from `skateboard(0)`; the routing's grain binds
are collected (`NpcSkater::routed`) and dropped by the host. Most surfaces' rolling needs that second bed.
- Wheels, tricks, treatment, footsteps and clothing for the NPC instance (per instance in retail).
- What an inactive instance's inputs hold. We deactivate the 3-D blocks and release the packets.
- The manager's gate (a game-state word == 5 and `sys+560` == 1) and the AI manager's list order.
- The NPC's speech (`sub_824BF5F8`, the SkaterSpeech manager `sub_824F7D10`) belongs to the speech stage.
### Optimisation pass over the native runtime (2026-10-03, overnight; skill `optimisation`)
**Problem.** The final stage of the plan: make the native audio cheaper without changing a single output bit (user:
"optimise only with proof of identical behaviour"). The audio thread renders each 256-frame block while it holds the
runtime lock. The game thread waits for that lock several times per frame (`game_lock_wait`), so render time turns
straight into frame-time jitter at 300+ fps.
**Measured first.**
- *Real play* (`SKATE_AUDIO_TIMING=1`, sessions 21:37, 21:58, 22:47):
- `render_block` averages 263–292 µs, with a per-second maximum of ~470 µs median and 680–830 µs p90.
- The game thread takes the lock 490–1,170 times a second (~3.5 per frame). In most seconds one of those waits
is about one render long: `game_lock_wait` per-second maximum median 276–332 µs.
- `mixmap_frame` averages 21–37 µs per frame (lock waits included); the MixMap tick itself is ~45 µs at 30 Hz.
- `emitters` has 3–36 ms spikes, a few per session (below).
- *Headless.* The new bench (`.claude/skills/optimisation/tools/e2e_bench.sh`) runs the 13 e2e scenarios and four
whole user sessions (state logs 21:37:57, 21:42:24, 21:58:43, 22:47:47; 24,264 rows), in `row` and `E2E_FPS=300`
modes, and hashes every `.f32` render and voice log. Render per block on the real sessions: p50 210 µs, p99
340–377 µs.
- *Where it goes* (temporary per-stage timers, real play, µs per block): eEQChain buses 95–104, grain bed 70–77 (its
FrequencyShiftSsb 59), env network 22–25, voices 8–20, evaluator 5–7.
- *Root cause.* Every `f32::mul_add` without the `fma` target feature is a call into the CRT's `fmaf`. A
256-sample biquad channel costs 1.4 µs that way and 0.43 µs with hardware FMA; an FSS `sin` costs 19 ns against
~1 ns. The eEQChain filters 8 buses × 6 channels × 2 peaking EQs every block, mostly on silence. The FSS evaluates
512 polynomial `sin` / `cos` per chain per block.
**Changes (each bit-identical by construction and by test).**
1. `dsp::biquad::kernel_block` (used by `Iir2`, `PeakingIir2`, `HighShelfIir2` and the FSS allpasses). The kernel is a
pure function of (coefficients, history, input), and its arithmetic depends on the position only through `n % 8`.
- *Settled filter:* on an 8-periodic block (silent or constant), run one group of 8. If the history comes back
bit-identical, the remaining groups repeat it exactly. Otherwise the rest runs from the state after the group,
so nothing is computed twice.
- *Silent tail:* on silent input, after the history's inputs reach +0, each feed-forward form equals the 1e-18
bias exactly. This is checked at run time with the kernel's own expressions, so only the two feedback FMAs run.
2. `PeakingIir2::process`: a silent channel whose history is bit-identical to an earlier silent channel's copies that
channel's output and history (jittered buses 5–7 idle with six equal histories).
3. `FrequencyShift::process`: a lane whose value is bit-identical to its previous group's (a 0 Hz shift, 40–45 % of
chain blocks) reuses its `sin` / `cos`.
4. `dsp::wrap` instead of `%` in the reverb combs and the delay lines. It equals `%` for every input, overflowed
ones included.
5. No allocations on the audio thread in steady state (was ~104 per block). The voice / bus / submix plane lists are
stack arrays (`each_mut` / `each_ref`), the pan's speaker weights are an array, and the grain pick's candidate
list and recent-window list keep their buffers. RNG draw order is unchanged: an explicit loop in
`eqchain::resolve`.
**Proof.**
- *e2e byte identity:* `compare_runs.sh base final` → IDENTICAL for scen-row, scen-fps300, real-row and
real-fps300 (every `.f32` and every voice log). The same against the original sources rebuilt for `E2E_FPS=30`
and `E2E_FPS=1000` (frame-rate extremes).
- *Test-only references, bit for bit:*
- `dsp::biquad::kernel_block_matches_the_plain_kernel`: every coefficient set, including NaN / ∞ / zero ones and
the FSS allpasses; lengths 0–264; silent, −0.0, constant, 8-periodic and noise blocks; fresh, noisy, settled and
non-finite histories; 3,000-block decays. It also asserts that both shortcuts ran.
- `dsp::peaking::silent_shortcuts_match_the_plain_kernel`: the old process as reference, with bus-like 6-channel
traffic, jitter, re-rolls, bypass and a NaN frequency.
- `dsp::fss::repeated_lanes_reuse_the_same_trig`: the old process as reference, with 0 / ±150 Hz, tiny, huge and
NaN shifts.
- `dsp::tests::wrap_is_the_remainder`.
- *No allocations:* `tests/render_alloc.rs` (a counting allocator around 300 steady-state blocks of a full scene)
asserts 0.
- *Suites:* skate-audio all pass; `game_audio::` 52 / 52; `--no-run` builds.
**Speed (two runs each, same machine, µs per render block).**
| Set | before p50 / p90 / p99 | after p50 / p90 / p99 |
|---|---|---|
| Real sessions, row | 210 / 250–260 / 340–377 | 131 / 167 / 233 |
| Real sessions, 300 fps | 211–214 / 250–293 / 347–417 | 128 / 166 / 231 |
| Scenarios, row | 120–122 / 210 / 281–288 | 84 / 140 / 193–198 |
Total render time on the real sessions is down 39 % (17.1 s → 10.4 s per 75,826 blocks). The spread between runs
is a few µs at p50 / p99. The maxima (10–38 ms) move between runs and blocks in both builds: OS preemption on a
shared machine, not code. The game-thread side is unchanged (p50 30–35 µs per tick call). The in-game lock wait
should shrink with the render; check `game_lock_wait` / `render_block` in the next `SKATE_AUDIO_TIMING` session.
**Rejected or left for the user.**
- *Hardware FMA* (`-C target-feature=+fma`, measured in a separate build, `.local/opt-target-fma`): render p50
130 → 52 µs, p99 233 → 130 µs. Every e2e output is byte-identical: the fused result is unique, and 100 M random
`fmaf` cases agree too. It would raise the CPU baseline to FMA3 (Intel Haswell 2013 / AMD Piledriver 2012) for
the whole game. **Done as runtime dispatch** (user decision "option 1"): see "Hardware FMA dispatch" below.
- *A safe software fma* (f64 product and sum, f32 midpoint check, library fallback): exact, but no faster (the
kernel is latency-bound). Dropped.
- *`a * b + c` instead of `mul_add`:* not bit-identical. Never.
- *Skipping silent grain chains / FSS trig on idle chains:* their bias-level output (~1e-18) reaches the master
bit-for-bit, so it is not skippable. Dropped.
- *Merging the game thread's lock sections:* it changes which intermediate states a render can see. Not provably
identical. Dropped.
- *Emitter bank loads* (the 3–36 ms `emitters` spikes): `Native::ensure_bank` read and decoded a world emitter
bank's WAVs on the game thread when its first emitter started. That cost 2.6–5.4 ms warm per bank (3.8–8.7 MB of
f32), more from a cold disk. **Done in a follow-up:** a distance prefetch on a worker thread, see "Emitter bank
prefetch" below.
**Files.**
- `crates/skate-audio/src/dsp/{biquad,peaking,fss,shelf,reverb,delay,pan,mod}.rs`, `bus/{eqchain,submix}.rs`,
`grain/player.rs`, `mixer.rs`.
- New test `crates/skate-audio/tests/render_alloc.rs`.
- `crates/skate-game/src/game_audio/e2e.rs`: with `E2E_TIMING=1` it also writes `<name>.ours.timing.tsv`.
- Bench tools: `.claude/skills/optimisation/tools/` (local).
**Open.** ~~The FMA decision~~ (done: "Hardware FMA dispatch" below). MixMap tick (~45 µs at 30 Hz, game thread) not examined in depth. The emitter-bank
prefetch is done (below).
#### Emitter bank prefetch (2026-10-03, follow-up)
**Problem.** The `emitters` system took 3–36 ms on a few frames per session (`AUDIO_TIMING`, 22:47 session:
`emitters=4129/23us`). When a world emitter of a bank started for the first time, `Native::ensure_bank` read the
bank's `.abk` and decoded every WAV to planar f32 on the game thread. That costs 2.6–5.4 ms per bank with a warm disk
cache and more from a cold disk.
**Retail.** No question about retail timing arises: the change never delays or defers a start. Every start happens on
the same frame as before, and `Runtime::load_bank` runs at the same point in `ensure_bank`. Only reading, parsing and
decoding move to another thread, and their result is the same data.
**Memory decides the shape.** Decoded, a map's emitter banks (`.claude/skills/optimisation/tools/
emitter_bank_memory.py`) take:
- University: 41 banks, 161 MiB;
- DownTown: 44 banks, 210 MiB;
- Industrial: 51 banks, 249 MiB;
- each park: 0–7 MiB.
Prefetching a whole district would cost too much. Instead the prefetch follows the listener:
- A bank is queued when the listener comes within `AHEAD` = 60 m of the bounding sphere (largest extent) of one of its
emitters, nearest first.
- An unused prefetched bank is dropped when the listener is more than `EVICT` = 90 m from all of its emitters.
- Loaded banks stay until the map changes, as before.
The simulation over the users' sessions (`prefetch_memory_sim.py`, board position as the listener) gives this extra
resident memory (prefetched, not yet loaded):
| Session | Map | Loaded banks (as before) | Extra, peak |
|---|---|---|---|
| state 21:37:57 | DownTown | 4 banks, 26.8 MiB | 29.7 MiB (the spawn ring) |
| state 21:58:43 | University | 3 banks, 17.8 MiB | 20.1 MiB |
| state 22:47:47 | University | 6 banks, 28.6 MiB | 19.3 MiB |
The other settings tried: 40 / 70 m gave a peak of 11–30 MiB, and 100 / 140 m gave 27–43 MiB.
**Change.**
- `library.rs`: a new `BankSource` (`Library::bank_source`) holds the root, `.abk` path and WAV paths, detached from the
library. `BankSource::load` reads, parses (`Bank::parse`) and decodes (`decode_wavs`, which `Library::bank_pcm` uses
too) with the same error strings `ensure_bank` had.
- New `game_audio/native/prefetch.rs`: one worker thread (`audio-bank-prefetch`, spawned on the first request) runs
`BankSource::load` for the queued banks. `Prefetch::take` at the start returns:
- done: the data;
- being decoded: waits for it, which is never longer than decoding it on the game thread;
- queued, failed, panicked or unknown: None. The worker then skips a queued bank, and `ensure_bank` loads on the
game thread as before, with the same errors and warnings.
- Dropped or cleared banks' data is handed to the worker to free. Freeing it on the game thread cost up to 0.5 ms
per `prefetch_near` call in the test below.
- `native.rs`: `ensure_bank` takes from the prefetch or else calls `library.bank_source(stem)?.load()?`, then runs
`load_bank` as before. `unload_map_banks` also clears the prefetch.
- `emitters.rs`: on map load, each native emitter gets the index of its bank and a status per bank
(idle / requested / loaded). Each frame, `prefetch_near` takes the distance to each bank's nearest emitter, queues
and drops banks, and touches no runtime, random or node state.
**Proof.**
- *e2e byte identity:* `compare_runs.sh pf_base pf_new` → IDENTICAL for scen-row, scen-fps300, real-row and
real-fps300. Both builds come from one snapshot of the tree (`.local/pf-base-src`), differing only in the four files
above. The e2e harness doesn't run world emitters, but it loads every player bank through the refactored
`bank_pcm`. The later move of frees to the worker only touches `prefetch.rs`, which the harness does not run. The
unit tests below were rerun on the final code.
- *Identical data:* `native::prefetch::tests::prefetched_banks_are_identical_and_not_decoded_at_the_start` covers the
66 emitter banks of DownTown and University. For each, the worker's bank equals `BankSource::load` on the game thread
(Debug-equal), and its PCM equals both that load and `Library::bank_pcm` bit for bit (`to_bits`).
- *No decode on the game thread:* a thread-local WAV-decode counter (test builds only) stays at 0 across
`ensure_bank` for every prefetched bank. The same banks loaded without the prefetch decode all their WAVs there.
- *Runtime untouched:* `emitters::tests::the_prefetch_follows_the_listener_and_touches_no_runtime_state` moves the
listener past all of DownTown's emitters (3,520 frames). It checks four things:
- requested = within `AHEAD`, minus the loaded banks;
- nothing is kept beyond `EVICT`;
- the starts take prefetched banks with 0 decodes;
- the evaluator's random state and block count are unchanged by the prefetch.
- *Map change:* `unload_map_banks` empties the prefetch and unloads the map's banks. The next start loads again (same
test).
- *Fallbacks:* `unknown_failed_and_dropped_banks_fall_back_to_the_game_thread`. A missing `.abk` gives the old error
text, there is one slot per bank, and drop and clear work.
**Speed (headless, 66 banks, warm cache, 5 runs).**
- `ensure_bank` on the game thread (the old path): median 2.2–2.4 ms, max 5.7–16 ms.
- Prefetched: median 0.001 ms, max 0.009–0.018 ms.
- `prefetch_near` over DownTown's 176 emitters / 44 banks (3,520 calls):
- mean 0.44 µs;
- the first call 52–61 µs (it starts the worker thread);
- the rest at most 14–48 µs (queuing requests).
- The e2e render and frame timings of both builds are the same within run-to-run noise. The e2e harness doesn't run
this code.
**Adversarial review (skill checklist, what was tried).**
- *Purity / hidden state:* `BankSource::load` reads only the files, and `Bank::parse` and `wav_pcm` are pure
functions of the bytes. The library's clip caches (`loaded` / `failed`) are not involved; `bank_pcm` never used
them. Nothing on the worker touches the runtime, the MixMap, the emitters' xorshift or the evaluator RNG. The test
checks the evaluator RNG and block count before and after 3,520 `prefetch_near` calls.
- *Order:* the runtime sees the same sequence of `load_bank` / `post` / `redeliver` / `release` calls. Bank ids are
handed out at `load_bank`, which has not moved, so they are unchanged. The order in which banks are requested
doesn't matter: the data waits outside the runtime.
- *Races:* `take` against a worker that is about to start on the same bank. The slot's mutex decides which comes
first: either the worker marks it Running first and `take` waits, or `take` marks it Skip first and the worker skips
it. A bank dropped while Running finishes on the worker and is freed there with the slot. No deadlock: `take` never
holds the runtime lock, and the worker never takes it.
- *Failures:* missing `.abk`, missing WAVs (None slots, same as before), a panicking load (`catch_unwind` → the game
thread loads it and panics as it always did), no worker thread (spawn failure or the channel closed) → game-thread
loads.
- *Map change / reload:* `unload_map_banks` clears the prefetch. The emitter state rebuilds its bank table on every
map identity change (same map, new generation included).
- *Teleport / spawn inside a reach:* the start finds its bank queued and loads it on the game thread, exactly as
before. This is not a regression, only no gain.
- *Other `ensure_bank` callers* (boot banks, `world_sources`): they take a prefetched bank when one is there (same
data), otherwise they load as before.
- *Is the speed-up real?* The cost did not move to the game thread: `prefetch_near` runs at mean 0.44 µs. The first
version freed evicted banks on the game thread (max 0.5 ms per call), which the review caught; frees now run on the
worker.
**Residual spikes.**
- An emitter that is already in reach on the first frame of a map, or right after a teleport, still loads on the game
thread (its bank is still queued). That frame is part of the map load.
- The world-source banks (`world_sources.rs`, traffic / peds) loaded on the game thread when the first vehicle or ped
was published. They now use the same prefetch once the living world sets `WorldOwners::expected` (see
"Session-review leftovers").
- Map change still frees the loaded banks on the game thread (`unload_bank`, as before).
- A file changed on disk between the prefetch and the start would differ. That only happens if setup runs while the
game is running.
**Real play still to check.** Look at the `emitters` per-second maximum in the next `SKATE_AUDIO_TIMING=1` session.
#### Hardware FMA dispatch (2026-10-03, follow-up; user decision "option 1")
**Problem.** Without the `fma` target feature every `f32::mul_add` is a call into the C runtime's `fmaf`. That was
the main render cost left after the optimisation pass (above). `-C target-feature=+fma` halved the render with
byte-identical output, but it would raise the CPU baseline for the whole game. The user's decision: one exe that picks
hardware FMA or the plain code at start-up, with `skate-audio` staying `#![forbid(unsafe_code)]`.
**Which loops.** Only the loops that call `mul_add` per sample: the Direct Form I biquad kernel and its silent-tail
feedback loop (`dsp::biquad`; used by the high/low-pass, PeakingIir2, the shelf and the FSS allpasses), the
FrequencyShiftSsb oscillator with its polynomial `sin` / `cos` (`dsp::fss`), and the linear resampler
(`dsp::resample`, every voice). Reverb, delay, pan, gain, send and the grain player have no per-sample `mul_add`. Their
`mul_add`s are once per block or per parameter change: coefficients, envelopes, phase wrap. Evidence: the dispatched
build reaches the whole-crate `+fma` build's render time (p50 ~54 µs vs 52 µs), so nothing else is left to gain.
**Change.**
- New crate `crates/skate-audio-fma` (no dependencies):
- `body.rs` holds each loop once (`#[inline(always)]`, moved verbatim from skate-audio).
- The `kernels!` macro wraps every body twice: `plain::name` with the normal features, and `fma::name` with
`#[target_feature(enable = "fma")]`, which implies AVX and SSE4.1. Same source.
- `Path` picks one. A `Path` naming the FMA copy only comes from `Path::fma()`, after
`is_x86_feature_detected!` confirmed fma, avx, sse4.2, sse4.1, ssse3 and sse3. That check is the whole safety
argument for the single `unsafe` call (in the macro, with a `// SAFETY:` comment; clippy
`undocumented_unsafe_blocks` is on).
- The process-wide choice is cached in an `AtomicU8` (`active()`; `init()` chooses, `force()` is for tests).
- `SKATE_AUDIO_FMA=0` forces the plain copy. `1` (or unset) takes FMA only when the CPU has it.
- `skate-audio`: `biquad::kernel` / `kernel_silent_tail`, `fss::{sin, cos}`, `FrequencyShift::process` and
`Resampler::render` call the crate. `Coefficients`, `BIAS`, `INV_TWO_PI` and `WEIGHT` come from it (same paths,
same values). `dsp::{init_fma, force_fma, fma_path, FmaPath, FmaChoice}` are re-exported. Still
`#![forbid(unsafe_code)]`.
- Game: `game_audio/native.rs` logs `AUDIO_DSP path=fma|plain cpu_fma=… SKATE_AUDIO_FMA=…` once, at native audio start
and before the first render.
**Proof.**
- *e2e byte identity:* 13 scenarios + 4 whole user sessions, row and `E2E_FPS=300`, `compare_runs.sh`. Three trees:
(a) the tree before the change, from a snapshot in `.local/fma-base-src`; (b) the new tree with `SKATE_AUDIO_FMA=0`;
(c) the new tree with FMA. All IDENTICAL: every `.f32` render and every voice log, a-b, a-c and b-c, in 9 runs
(`fma_base{,2,3}`, `fma_plain{,2,3}`, `fma_on{,2,3}`).
- *Both copies called directly* (`skate-audio-fma/src/tests.rs`):
- biquad and feedback: 4,000 random rounds with wild coefficients / histories / inputs (NaN, ±∞, denormals, ±0,
random bits) at lengths 0–1000; plus 60 stable filters × 400 blocks of finite input, compared exactly.
- `sin` / `cos`: 4 M points of the oscillator range, 4 M random bit patterns and the edge values.
- `fss_mix`: 3,000 rounds. `resample`: 3,000 rounds; step 0, 1×, 4× and random.
- *Whole runtime on both paths* (`skate-audio/tests/fma_paths.rs`, own binary): the `render_alloc` scene (24 voices
on all buses, eEQChain, flange, env reverb, grain truck) for 1,500 blocks. Finite: every bit identical. With a
NaN / −NaN-payload / ∞ / denormal in a voice's PCM, or a NaN eEQChain gain (NaN biquad coefficients): NaNs in the
same places, every non-NaN bit identical, the same voice counts.
- *Suites:* skate-audio 245 + skate-audio-fma 6 pass, with `SKATE_AUDIO_FMA=0` and with auto. `game_audio::` 60
pass. `--no-run` builds. Both link configs build: dev `cargo build -p skate-game --bin skate3rust --release
--locked`, and release-style `+crt-static --target x86_64-pc-windows-msvc --no-default-features` (in
`.local/fma-release-target`).
**The one difference: a NaN's sign / payload.** When several operands of one operation are NaN, x86 returns the
first source operand's NaN, and which operand is first is the compiler's choice per instruction. The CRT's
`fmaf(a, b, c)` keeps its order; an inlined `vfmadd` may be commuted. In the direct tests this happened 544 times in
the wild-input biquad rounds (NaN coefficients plus NaN histories), and never with one NaN source. ∞ − ∞ gives x86's
default −NaN on both copies. (LLVM's constant folder makes +NaN instead: the tests use `black_box`.) Why it can't
change an observable output:
- Every bit-level comparison of DSP state is a memo of a pure function on the same path. Examples: the settled-filter
repeat (history bits before / after a group), the twin-channel copy (equal history bits), the silent-tail check,
`periodic8` / `silent` on the input, and the FSS trig reuse (lane bits). When a memo fires, its result is what the
plain kernel gives on that path (tests `kernel_block_matches_the_plain_kernel` with NaN histories and
coefficients, `silent_shortcuts_match_the_plain_kernel`). A different payload can only change *whether* the memo
fires, never a value.
- NaN is absorbing in these loops: in the IIR feedback, the sums and the gains. No sample value feeds control flow:
voice lifetimes, the evaluator, grain picks and bus routing depend on parameters, positions and the RNG. So the
voice logs cannot see it. Casts (`as i32`), comparisons, `max` / `clamp` and `is_nan` ignore payloads.
- The output goes to rodio as f32. A NaN there is already a broken sample either way.
- In the whole-runtime NaN scenes, 0 of 4.4 M NaN output samples differed in bits: the bus sums re-pick the NaN.
- In practice NaN does not occur: 0 NaN / ∞ in the 287 M samples of the kept `base` renders (13 scenarios + 4
sessions, both modes).
**Adversarial review** (skill `optimisation` checklist):
- *Purity:* the bodies are the old code, moved. The only new state is the cached path, which only selects a copy.
- *Signed zero / NaN / denormals:* tested directly. Only NaN payloads differ (above). MXCSR is the same for both copies.
`round_ties_even` becomes `vroundss` in the FMA copy and is exact in both.
- *Position dependence:* `kernel_block` still passes `samples[8..]` from a group boundary. The `n % 8` association
is inside the moved loop.
- *Block sizes:* 0, 1, 2, 7, 8, 9, 15, 16, 24, 255, 256, 264, 1000.
- *State across blocks:* 400-block decays, and 1,500 runtime blocks with moving cutoffs and azimuths.
- *Frame-rate extremes:* e2e row and 300 fps. Host code is unchanged apart from the start-up log line.
- *Integer identities:* the resampler's 16.16 stepping is moved verbatim.
- *Allocation / RNG order:* no new draws. `init()` reads the environment once, at audio start; a lazy first use
outside the game only happens in tests and the e2e harness (`render_alloc` still asserts 0 after its warm-up).
- *Soundness:* the FMA copy can only run through a `Path` that `Path::fma()` returned after the CPU check. A race
between two first calls stores the same answer. Without OS AVX state support, `is_x86_feature_detected!("fma")`
is false.
- *Real speed-up:* 3 base runs and 2 clean plain / FMA runs. p99 improves with p50. The game thread is unchanged.
**Speed** (µs per 256-frame render block, real-session and scenario replays, headless; clean runs only: run 2 of plain / FMA was disturbed by other load on the machine, and run 1 of plain's scenario sets overlapped the release-style build):
| Set | before (base, 3 runs) p50 / p90 / p99 | plain (clean runs) p50 / p99 | FMA (runs 1, 3) p50 / p90 / p99 |
|---|---|---|---|
| Real sessions, row | 129 / 174–177 / 286–316 | 128–137 / 327–339 | 54–58 / 87–109 / 158–209 |
| Real sessions, 300 fps | 130–140 / 178–215 / 312–392 | 128–130 / 280–355 | 55–59 / 88–102 / 153–178 |
| Scenarios, row | 86–91 / 142–166 / 228–278 | 84–85 / 228–253 | 38–40 / 67–73 / 117–141 |
The total render time on the real sessions (row) drops from 10.4 s to 4.5–5.2 s per 75,826 blocks (−50 to −56 %). The plain
path matches the old code. The game-thread side is unchanged.
**Open.**
- In-game check: an `AUDIO_DSP path=fma` line, and `render_block` / `game_lock_wait` in a `SKATE_AUDIO_TIMING=1`
session.
- A CPU without FMA runs the plain copy, the old code. The tests skip the comparison there, so it is untested on
such hardware here.
- To rerun the proof:
- `cd .local/fma-base-src && CARGO_TARGET_DIR=../fma-base-target bash .claude/skills/optimisation/tools/e2e_bench.sh fma_base row fps300`
(the snapshot of the old tree);
- `CARGO_TARGET_DIR=.local/fma-target SKATE_AUDIO_FMA=0 bash .claude/skills/optimisation/tools/e2e_bench.sh fma_plain row fps300`,
then the same without the variable as `fma_on`;
- `compare_runs.sh fma_base fma_plain` / `fma_on`;
- `cargo test -p skate-audio-fma -p skate-audio --release --locked` (with and without `SKATE_AUDIO_FMA=0`).
- Build folders to delete once accepted: `.local/fma-target`, `fma-target2`, `fma-base-target`,
`fma-release-target`, and `fma-base-src` (rmdir its junctions `assets`, `maps`, `.local/audio-re` first).
**Files.** New: `crates/skate-audio-fma/{Cargo.toml, src/lib.rs, src/body.rs, src/tests.rs}`,
`crates/skate-audio/tests/fma_paths.rs`. Changed: `Cargo.toml` (workspace member), `Cargo.lock` (the path crate, no
external dependencies), `crates/skate-audio/Cargo.toml`, `crates/skate-audio/src/dsp/{mod,biquad,fss,resample}.rs`,
`crates/skate-game/src/game_audio/native.rs`.
### Session-review fixes: push plant timing, the push foot's plant / lift, the body poster, grind on / off sounds (2026-10-03, overnight)
Four ranked items of the session review (`.claude/notes/session-review-2026-10-03.md` #1, #2, #4, #5) and the e2e harness
gaps (#10). "The recomp" = skate3recomp run locally: its program logic is evidence, its frame rate and stalls are not
retail. Recomp figures below are from the nine clean sessions 163809 … 223613, stall windows excluded
(`.local/audio-re/review/rlib.py`).
**Problem.**
- Every push sound but the stroke foley played ~550 ms early, at the stroke instead of the foot plant, and the rattle
ran at a third of the recomp's rate.
- The Contacts' push-foot plant / lift sounds (sk8_foley 84 / 85 …) never played.
- Bails had a few body impacts where the recomp has a dense layer of skin / denim / arm / leg / head / torso hits, plus
the interim `bail_hit` / `bail_soft..hard` cues on top in native mode.
- Grinds had no on / off contact sounds (Skate_Collisions 1135–1175, Skate_Metal 525–564).
**Root causes.**
- *Push plant (#1).* The audio-state bridge `sub_824B0DA8` sets `+335` on State55's rise (the foot plant), and `+333` /
`+334` while it holds. The host fed the animation's push contact (`flags2468` bit 27), which fires near the stroke.
`grain_bed`'s push count used the same edge.
- *Plant / lift (#2).* `sub_824B8218` calls `sub_824BBB28` after the process. It was not ported.
- *Body poster (#4).* `sub_824BC188` was not ported. It needs the regions' impacts (audio state `+496..+516`), which the
host did not compute. A setup export bug also lost the head and torso materials (below).
- *Grind on / off (#5).* The Rail poster `sub_824C28B0` calls `sub_824C3FC8` after each GRINDS post and
`sub_824C4138` at the release. `components::Grind` skipped both.
**Evidence and changes.**
*#1 Push plant.*
- `skate_events::push_plant`: `push_planted` = State55, `push_trigger` = its rise (`+333 || +334` / `+335`).
`cues.riding.pushes` counts the same edge.
- The retail split (`+333` = State55 && !`+338` && State56, `+334` = State55 && `+338`) waits for `+338` to be mapped.
The recomp's GREC logs `+338` (the third byte of its `+336` word) if someone wants to settle it.
- State log: new `plant` column (State55). The `push` column keeps its old meaning (the animation's push edge).
`e2e.rs` prefers `plant` when present.
- Older logs have no State55. `E2E_PLANT_FROM_FEET=1` rebuilds it from their `foot_down` bits on ground states without
the brake (State55 && (State57 || State56)) — a proof aid only.
- Off: `SKATE_AEMS_PUSH_PLANT=0` / `E2E_PUSH_PLANT=0`.
| per push (the recomp: 137 pushes / 917 riding s; ours: 4 user logs, 32 pushes / 236 riding s) | the recomp | ours before | ours after (feet proxy) |
|---|---|---|---|
| plant foley sk8_foley 74, lag after the stroke | +548 ms | +0 ms | +550 ms (p10/p90 450/600) |
| rattle, lag after the stroke | +558–660 ms | +33–50 ms | +600 ms (p10/p90 433/650) |
| rattle per riding minute (`rattle_attr.py`, level > 0.05) | 31.3 (24.3 "other", 6.4 push) | 9.4 (0 other, 9.1 push) | 37.1 (26.7 other, 9.4 push) |
| rattle level p50 | 0.326 / 0.355 | 0.325 | 0.326 |
The plant now lands with the footstep. Most rattles come from State55 rises with no stroke nearby, in the recomp and
in ours. The remaining rate gap is +19 % (the foot-down proxy, not the game's own State55).
*#2 The push foot's plant / lift* (`player::contacts::plant_lift`, update `sub_824BF268`).
- Trigger: `+333 || +334` rising while `+123` is clear → plant (`sub_824BB8D8`); falling → lift (`sub_824BBA00`).
- Sound: `sk8_foley` by wheel 0's material kind. The kind is AudioSurfaceMap word 5 (`sub_82494EB8`; 0 for no
material).
- Plant ids [84, 88, 92, 80, 76], lift ids [85, 89, 93, 81, 77] (Contacts `default` fields in the code docs).
- Kinds above 4 post id 0.
- Routing: eEQChain bus 1 (class `42AFE160E647167C` field `748CBC9727A5347F`).
- Block: [0, 1, 0, dt, 1, 1] at the start, then [level(6), pitch(1), raw(0), dt, 0, 1].
- Off: `SKATE_AEMS_PLANT_LIFT=0` / `E2E_PLANT_LIFT=0`.
| | the recomp (`plantlift.py`) | ours after (feet proxy) |
|---|---|---|
| plant on pushes | 130 / 137 (95 %), +548 ms (498 / 682), level p50 / p90 0.220 / 0.463 | 29 / 32 (91 %), +550 ms (450 / 617), 0.222 / 0.490 |
| lift on pushes | 130 / 137, +664 ms (580 / 748), level 0.155 / 0.423 | 28 / 32, +683 ms (583 / 717), 0.213 / 0.493 |
| ids | 84 > 88 > 92 (materials 1–5) | 84 / 88 / 92 by kind |
*#4 Body poster* (`player::contacts::body`, messages into the collision manager).
- Gate: runs unless the rider is bailing with `+677` set.
- Per region 0..5 with an impact and no cooldown (15 frames, vault `6DD85F43C1B6E6AA`):
- the region material (head 97, torso 98, arms 100, legs 99) against the touched surface (`+560` tag − 1), each by
its impact band;
- the band-2 second message;
- the cloth contact (denim 109 / skin 107 / denim 108) at tier 0;
- the pad contact (110 head / torso, the face 112, 111 limbs) by `sub_824BCCF8`'s thresholds (class
`6EBA5BCD3E38A98A`: 0.75 / 0.7 / 0.65, face 0.05 / 0.3; highs 1.25 / 0.55).
- Region impacts (`skate_events::region_impacts`, from `sub_82BD60C8`): clamp01(max(0.001, Δv(part) · region normal ×
part mass × 10)).
- Δv = SkeletonState4048 `velocity_changes`.
- Mass = SkeletonState4560 = the bone-box volumes `animation_masses.part_weights`.
- 10 = physics_collision `default` +164, field `1430BD50F0A33475`.
- The conditioner `sub_82773298` keeps the max of the last 4 frames.
- **Export fix:** five AudioSurface records are keyed by name in the converted collections: head, torso, water,
drum_pylon, crumpled_paper. The band holder's `default` is keyed by name too.
- Materials 77 / 87 / 92 / 97 / 98 exported as missing, and every RefSpec to the `default` bands resolved nothing.
- Before the fix, an impact on those materials read tier 2 with an empty window.
- `audio_export.collision_tuning` now aliases name keys by hash64. Head = ids 1032 / 952 / 953; torso = 951 / 949 /
950 — the ids the recomp plays in bails (below).
- Not ported, and why:
- The first-hit torso message `sub_824BCEB0`: gated by an unidentified global at `*(0x82083C38) + 0x2FCB4`.
- The Hall of Meat layer: global byte `+859`.
- The non-local `sub_824BF5F8`.
- `sub_824BDA90`: game events, not sounds.
- `sub_824E3BE0` is not the player's ragdoll. It is a physics-prop SFX object: its own record from `[data+128]`,
materials `+360` / `+160`, caller `sub_824E2790`. It belongs to the world-sources stage.
- The interim bail cues are silent while the body poster runs (`PlayerAudio::body_impacts_on`).
- Off: `SKATE_AEMS_BODY_IMPACTS=0` / `E2E_BODY_IMPACTS=0`.
- **The recomp, per bail** (38 bails, `fixes/bail_splc.py`):
- 137 collision-manager voices.
- Ids 955 25.4, 954 20.7, 956 19.2, 947 13.0, 958 8.7 (concrete), 948 6.0, 949–953 (torso / head) 14, 1031 / 1030 /
1032 (tier 2) 5.
- **Ours.** The user's logs carry no region records yet, so no real bail can be replayed. A synthetic bail
(`fixes/e2e_bail_on`, region impacts set by hand) plays 31 collision voices instead of 3: torso, legs, denim and
skin, per-voice peaks 0.10–0.25. The recomp's mean is ≈ 0.15 (Σg² 4.3 over 185 voices). This shows the chain, not
the retail density. That needs a state log with the new `rimp*` / `rtag*` columns.
*#5 Grind on / off* (`components::Grind::sounds` / `update_sounds`, `sub_824C42A8`).
- At each Class_grind post: slot 0, slot 1 for family 0, and the updater's family change, which uses the new family's
layer.
- Sound: the grind surface's on id (`sub_824C35D0`): Skate_Metal on a metal surface (bool `02BAC36BCC8A30DE`), else
Skate_Collisions, one id per layer. At the release, the off id (`sub_824C37D8`).
- Routing: through eEQChain bus 0 (field `D1A87641CCB98787`), mono, env send = Rail level(5).
- Level = ((10000 − speed word) × 0.0001 × (B − A) + A) × layer factor × Rail level(1). A / B = 0.1 / 1.25 on, 0.1 /
1.0 off.
- Pitch = the same lerp (0.8 / 1.0) × pitch(2).
- Data: exported per grind surface (`player_tuning.grind[*].on / off / metal`, `grind_contact_eq`).
- Off: `SKATE_AEMS_GRIND_ONOFF=0` / `E2E_GRIND_ONOFF=0`.
| per grind | the recomp (17 grinds, `fixes/evpost.py`) | ours before | ours after (7 grinds) |
|---|---|---|---|
| on sound | 17 / 17, lag 0, level p50 / p90 0.290 / 0.454 | 0 / 7 | 7 / 7, lag 0, 0.259 / 0.383 |
| off sound | 17 / 17, lag 0, 0.204 / 0.285 | 0 / 7 | 7 / 7, first audible +50 ms, 0.245 / 0.313 |
*#10 Harness.*
- New state-log columns: `plant`, `stroke`, `deck_spin` / `spin_x` / `spin_y`, `bail`, `bail_end`, `held`,
`offboard_air`, `footplant`, `revert`, `soft`, `face`, and `rimp0..5` / `rslide0..5` / `rtag0..5`.
- `e2e.rs` reads them when a log has `plant`, and then also sets `on_foot` = state 500. Older logs render as before.
- `audio_trick` was already resolved from the scorable inside `PlayerAudio` (e2e included); only the Script's literal
read −1.
- `scenarios.py --from-log` copies the new columns and the skeleton columns it used to drop (step, body, limb, slide,
deck_up, deck_contact).
**Files.**
- `crates/skate-game/src/game_audio/`: `skate_events.rs`, `state_log.rs`, `e2e.rs`, `player_audio.rs`, `library.rs`.
- `crates/skate-audio/src/player/`: `state.rs` (`body_impact`), `contacts.rs`, `components.rs`, `tuning.rs`
(`GrindContact`, `grind_contact_eq`).
- `tools/asset_pipeline/audio_export.py`, `test_audio_formats.py`.
- Dev install: `stage_player_tuning.py` re-run. Backup: `.local/audio-re/audio_manifest.before-review-fixes-stage.json`.
**Verification.**
- skate-audio tests: 204 + integration, all pass.
- `cargo test -p skate-game --release --bin skate3rust --locked -- game_audio::`: 57 pass.
- Python `test_audio_formats` / `test_world_audio`: 31 pass.
- e2e of the four user logs (213757, 214224, 215843, 224747; `E2E_FPS=60`) with every new part off: **byte-identical**
(f32 and voices) to a build of the tree without this pass (`.local/cbf`, old manifest). The same holds with the
restaged manifest, so the export fix changes nothing in these logs.
**Open.**
- A user state log with the new columns (1 min of pushing; one or two bails). It checks the game's own State55 rate
against the recomp's 27 plants / riding minute (the feet proxy gives 29.5) and the bail density.
- ~~The lift's level is +2.7 dB over the recomp's (0.213 vs 0.155).~~ A measurement artefact; per id ours matches
(see "Session-review leftovers").
- `+338`'s mapping.
- The first-hit torso message's global: the gate is read; its writer is still not found (see "Session-review
leftovers").
- Needs an in-game listening check.
**Addendum: the user's session 084712 (2026-10-03, build 04:20, all fixes on).** University,
`.local/audio-state-logs/state_20261003_084712.tsv` (0 malformed, new columns). The user: "sounded SO MUCH BETTER, I WOULD
CALL IT ALMOST OR EXACTLY RETAIL"; the footsteps while running / walking "sounded amazing too". Tools:
`.local/audio-re/check0847/` (`plants.py`, `regions.py`, `bails_cmp.py`).
- *State55 plants: one per stroke, as in the recomp.* The lower rate is riding style (slow riding, walking between
bails; confirmed by the user), not a missing plant.
| | ours (084712) | the recomp (9 sessions, stalls excluded) |
|---|---|---|
| strokes (State56 rise) / riding min | 6.3 (4) | 8.8 (135) |
| plants (State55 rise) / riding min | 14.1 (9) | 27.0 (412 plant posts) |
| plants per stroke | 2.25 | 3.05 |
| strokes followed by a plant within 1.2 s | 4 / 4, lag p50 500 ms (400–517) | 116 / 135, lag +548 ms |
| plants with no stroke in the 1.2 s before | 5 (≈ 1 s apart, a foot rhythm) | 296 |
Riding = states 100–299 with wheels, plus air: 38 s here. The brief's 28 s / 19 per minute used a narrower riding set.
- *Bails (2): the state-log side.*
- Bail 1 at 18.25 s comes from off-board air (501 → 300, 6.5 m/s). Bail 2 at 36.23 s comes from 500 → 300 at
7.6 m/s, after an air.
- Region impacts are low. Bail 1: max 0.086 (region 1), the others ≤ 0.02. Bail 2: regions 0 / 4 / 5 reach 0.145 /
0.202 / 0.206, regions 2 / 3 0.03 / 0.06.
- Most contact frames sit near the 0.001 floor. All the touched surfaces are tag 4.
- Contact frames > 0.001 per bail: 84 and 104.
- *The recomp, per bail, same window (−0.5…+2.5 s, voices > 0.002; 38 bails):*
- Skate_Collisions: 207.7 voices, Σg² 4.68, peak p50 0.61.
- Skate_Metal: on 16 / 38 bails, 6.5 voices, Σg² 0.40.
- Bodyslide: on 33 / 38, 1.8 voices, Σg² 0.006.
- Collision-manager posts: mean 137, p50 133.
- Of these, ids 968 / 969 (7.9 + 3.5 per bail) are world / ped collisions, not the rider's body.
- *Ours, rendered:* done after the restart; results in "Bail density: ours rendered" below. The original note:
the headless e2e build (own target `.local/check-target`) was stopped mid-compile for a PC restart. To finish it:
- The scenario is already cut: `.local/audio-re/check0847/e2e/real_084712.tsv` (`scenarios.py --from-log … --cut
r0-999999`).
- Render: `CARGO_TARGET_DIR=.local/check-target E2E_DIR=.local/audio-re/check0847/e2e E2E_FPS=60 cargo test -q -p
skate-game --release --bin skate3rust --locked -- --ignored --exact game_audio::e2e::e2e_render --nocapture`.
- Compare: `py -3.13 .local/audio-re/check0847/bails_cmp.py - .local/audio-re/check0847/e2e:real_084712`. It reports
voices per bank, Σg², records → ids and regions per bail.
- Watch-point: with impacts ≤ 0.2 the bands may never reach tier 2, which the recomp plays at about 5 per bail
(ids 1030–1032). If ours lacks them, compare `skate_events::region_impacts` (Δv · normal · mass × 10) against the
recomp before changing the poster.
#### Bail density: ours rendered, the impact scale checked (2026-10-03, analysis only; no code change)
Session 084712 rendered headless through the e2e path (target `.local/fma-target`, `E2E_FPS=60`; `E2E_DIR` must be an
**absolute** path, a relative one fails with "path not found" because the test runs from the crate folder). The
state log has 0 malformed lines. `bails_cmp.py` took the time label from a missing `ms` column; it now uses row / 60.
| per bail, window −0.5…+2.5 s | the recomp (38 bails) | ours bail 1 (18.2 s) | ours bail 2 (36.2 s) |
|---|---|---|---|
| Skate_Collisions voices | 207.7 | 46 | 45 |
| Skate_Collisions Σg² | 4.68 | 1.89 | 1.53 |
| Skate_Collisions peak | p50 0.61 | 0.72 | 0.72 |
| three banks (Collisions + Metal + Bodyslide) Σg² | ≈ 5.1 | 1.95 | 1.84 |
| collision-manager posts | 137 (p50 133) | ≈ 30 (estimate: voices / 1.5, the recomp's voices per post) | ≈ 30 |
| tier-2 body ids 1030 / 1031 / 1032 (+ torso 951) | 1.2 / 2.5 / 1.3 (+ 1.8) | 0 | 0 |
**The gap is confirmed, and it is wider than tier 2.** Ours plays no tier-1 or tier-2 body id at all except the
head's 953 (3 in bail 2). The recomp's posts by material and tier (`fixes/bail_splc.py`, nine sessions):
| region material (bands: tier 1 > b8, tier 2 > b0; floor b12) | tier 0 | tier 1 | tier 2 | ours (both bails, voices) |
|---|---|---|---|---|
| head 97 (0.05 / 0.375; 0.002) | 952: 2.9 | 953: 3.1 | 1032: 1.3 | 952: 3, 953: 3 |
| torso 98 (0.1 / 0.25; 0.002) | 949: 3.9 | 950: 3.2 | 951: 1.8 | 949: 7 |
| legs 99 (0.1 / 0.35; 0.002) | 947: 13.0 | 948: 6.0 | 1031: 2.5 | 947: 9 |
| arms 100 (0.2 / 0.65; 0.005) | 956: 19.2 | 957: < 0.6 | 1030: 1.2 | 956: 6 |
| skin 107 / denim 108–109 (cloth, tier 0) | 954: 20.7 / 955: 25.4 | | | 954: 7 / 955: 25 |
| concrete 3 (1.0 / 1.85; 0.12) | 958: 8.7 | 1047: none | 991: none | 958: 0 |
In the recomp 40–60 % of the head / torso / leg posts are tier 1 or 2, so its region impacts are often above 0.1 and
several times per bail above 0.25–0.65. Ours never exceeds 0.21 in the whole log (bail 1 max 0.086).
**What scale retail expects: [0, 1], the scale we already have.** The thresholds are not in an AEMS program. The poster
picks the id by the collision material's impact bands (the AudioSurface records, `impact_band`), and the AEMS program
only plays the id. The body bands sit inside [0, 1] (floors 0.002–0.005, tier 2 at 0.25–0.65). The concrete record's
tier 1 / 2 (> 1.0 / > 1.85) cannot be reached by a clamped region impact, and the recomp never posts 1047 / 991 in a
bail, while 958 (> 0.12) plays 8.7 times. So retail's impacts are clamped to [0, 1] too. Re-read of `sub_82BD60C8`'s
region loop (0x82BD68F0…0x82BD69D4) matches `skate_events::region_impacts` term by term:
- part = SkeletonCollision `+1200 + 4i` (−1 = none);
- Δv = SkeletonState `+4048 + 16·part`;
- the region normal is `+1008 + 16i`;
- mass = SkeletonState `+4560 + 4·part`;
- × config `+164` (10);
- floor 0x82063A48 (0.001), then clamp to [0, 1];
- written to Collision `+80 + 4i`, slide speed to `+112 + 4i`.
`+4560` is the raw bone-box product that `82BEBAA8` normalises (`SkeletonAnimationMasses::part_weights`). Same
indices, same constants. **So the poster and the impact formula are not the gap. The input is:** ours has small
per-step velocity changes of the ragdoll parts along the contact normal. Rough sizes (full-extent bone boxes ≈ 0.003
arm … 0.015 torso m³): tier 2 needs Δv·n per 1/60 s step of about 1.7 m/s (torso) to 4–5 m/s (head, legs). Ours peaks
near 2 m/s, though the regions touch the ground for 1–2 s per bail (58–114 contact frames, sliding at 3–7 m/s). In
our ragdoll the parts land and slide smoothly. Retail's give sharp per-step stops and bounces.
**Not changed:** the mechanism on the audio side is already retail. Scaling the impacts or the bands would be a guessed
weighting. The cause is in the physics (the ragdoll's contact response / part velocities during a bail), not in
`contacts::body`.
Caveats:
- Two bails only (from off-board air at 6.5 m/s, and from 500 → 300 at 7.6 m/s after an air). The recomp's 38 are of
mixed heights.
- The recomp's post count is inflated by its frame rate. It runs the poster at ~345 fps, so the 15-frame body cooldown
is ≈ 43 ms there, against 0.5 s on the 30 fps console.
**Open (needs a decision or data):**
1. Measure retail's impacts directly: a recomp hook logging Collision `+80..+100` (or the audio state `+496..+516`)
and the touched parts' SkeletonState `+4048` Δv per physics step during bails. This would run alongside the
planned `PLAY_TRACE_AUDIOX.bat bail` session (the user's session). It would say whether retail's per-step Δv is
really 2–3× ours, and whether that comes from the contact solve or from the bail drives.
*Hook built 2026-10-03 (recomp `research-hooks`, category `audiox`; session pending).* Kinds `BAILSTEP` / `BAILREG`
log per pass of `sub_82BD60C8`, only during the local player's bails. For each of the **8** regions with a part they
log the impact the game wrote (Collision `+80 + 4i`; the loop runs 8 times, so `+80..+108`), |Δv·n|, the signed
normal velocity before and after, |Δv|, the normal, the mass, the slide speed and the tag. They also log the update
interval. The local block comes from the skater-entry update `sub_827A1B78`: entry `+152` bit 31, X =
`[[character + 1808] + 1800]`. Found on the way: the SkeletonState velocities are finite differences × 60
(`sub_82BEBD28`, constant `0x822F860C` = 60; `+3216` position, `+3632` velocity, `+4048` Δv), so Δv is per update and
assumes 1/60 s. Summary: `.claude/skills/recomp-research/tools/bail_impacts.py <trace>`.
*First session (the user's `audiox_bail_20261003_094336`, 3 min, 0 malformed lines): no BAIL\* lines at all.* Cause:
the hook accepted only entries inside the global table `*(0x83083C38) + 0x2F070`, but neither caller of
`sub_827A1B78` passes a table entry. The per-skater loop `sub_827A11B0` (return address `0x827A13FC`) passes a
stack array (its `r1 + 1064 − 152`, stride 544), and `sub_827A1030` (return `0x827A10A0`) a single zeroed stack entry
with r6 = 1. So the "local entry" test never held, BAILLOCAL never logged and no pass matched the local X. Entry
`+152` bit 31 is the r6 argument (in the loop: the skater's controller test). Fixed in the recomp (uncommitted): the
hook accepts only the loop's call (`lr == 0x827A13FC`) with bit 31 set, and a bail is active when the entry's bits
or `+676` / `+677` of a GREC-local audio state are set. A rate-limited `BAILCAND` line (7 fields) shows the lookup's
raw inputs (first 32 calls; each new X of the output pass), so a failed lookup is visible in the next trace.
*What the session shows without the BAIL\* lines* (`.local/research/bail/bail_session_summary.py`):
- **Two GREC owners pass the local test** in this session (board objects `40C33020` from the start, `40C34020` from
22.9 s), each once per frame. FIRSTHIT kept one set of last values, so the +676 / +677 bytes "alternated" 1 / 0
every call: one owner was bailing and the other was not. `40C34020` bailed once (154.7 s) with B+16 still 0, so it
is not the local skater. `40C33020` is. FIRSTHIT now keeps its last values per state. ~~Earlier sessions had one
owner.~~ (Wrong: 15 of 18 sessions had the second owner, an NPC skater; the hooks now gate on local72 — see "The
per-player recomp hooks logged an NPC skater too" below.) GREC / GRECX / TREAT / SEAMPAT readers should filter by owner (GREC's published local state alternates
too, so the TREAT / SEAMPAT local-mask bit 2 is unreliable when a second owner exists).
- **10 local bails**, 3.6–7.9 s from +676 rising to clearing. +677 (end) rises 1.4–4.0 s in and clears ≈ 1.0 s before
+676 clears.
- **B+16 rises in the same frame as +676** in all 10 bails, and clears 33–34 ms before +677 clears. So the first-hit
byte is "bail in progress" for the whole bail, not a single first contact. Its rising edge is the bail start.
- **B+24 is not 0.0000.** It resets to 0 at the bail start and then rises monotonically in steps about 17 ms apart
(8–41 steps per bail) to a final value of 0.42–1.0 (clamped at 1.0 in 3 bails). It reaches that value 0.5–4.0 s in
and holds it until the next bail. It is an accumulated bail strength, not a per-hit value.
- **Body posts per bail** (SPLC, window bail start −0.5 s … +676 clear; ids by tier as in the table above):
| bail (s) | posts | tier 0 / 1 / 2 | 1030 / 1031 / 1032 | 951 | 958 / 1047 / 991 | B+24 final |
|---|---|---|---|---|---|---|
| 39.8 | 149 | 106 / 27 / 16 | 2 / 7 / 4 | 3 | 4 / 2 / 0 | 0.68 |
| 81.2 | 67 | 55 / 8 / 4 | 1 / 0 / 2 | 1 | 8 / 1 / 0 | 0.42 |
| 91.9 | 49 | 35 / 9 / 5 | 0 / 2 / 2 | 0 | 2 / 3 / 1 | 0.56 |
| 101.8 | 108 | 74 / 17 / 17 | 5 / 6 / 4 | 1 | 11 / 3 / 1 | 1.00 |
| 121.1 | 45 | 29 / 10 / 6 | 1 / 3 / 0 | 2 | 0 / 0 / 0 | 1.00 |
| 136.0 | 61 | 38 / 13 / 10 | 1 / 3 / 2 | 0 | 3 / 4 / 4 | 0.96 |
| 144.4 | 67 | 46 / 13 / 8 | 0 / 6 / 1 | 1 | 2 / 0 / 0 | 0.64 |
| 157.4 | 46 | 32 / 9 / 5 | 0 / 2 / 2 | 1 | 0 / 0 / 0 | 0.55 |
| 168.7 | 43 | 27 / 7 / 9 | 6 / 0 / 2 | 0 | 1 / 4 / 1 | 0.82 |
| 184.2 | 79 | 52 / 16 / 11 | 2 / 4 / 2 | 1 | 3 / 6 / 2 | 1.00 |
Mean per bail: 71 posts; 1030 / 1031 / 1032 = 1.8 / 3.3 / 2.1 (all 74 posts of those ids fall in the 10 bails);
head 952 / 953 = 2.4 / 3.4, torso 949 / 950 / 951 = 2.1 / 2.1 / 1.0, legs 947 / 948 = 7.1 / 4.3, arms 956 / 957 =
8.8 / 0.8, skin 954 10.8, denim 955 14.8, concrete 958 3.4. **Concrete tier 1 / 2 (1047 / 991) do occur here: 2.3 /
0.9 per bail**, unlike the nine earlier sessions, so some impacts against that surface went above 1.0 / 1.85. Since
the region impacts are clamped to [0, 1], those posts come from a different impact value than the clamped region
impact. Open: which (e.g. the deck or another surface's bands).
*Measured with the fixed hook (2026-10-03, scripted background runs, 0 malformed lines).* Script
`.local/recomp/scripts/bail_ledge.txt`: the Super-Ultra Mega-Park spawn → off the board → a session marker → per attempt
go to the marker, run off the drop and use the bail input. Runs `bailrun_t2_100040` (2 bails), `bailrun_ok_100226` (4 full
bails), `bailrun_ok2_100514` (6 bails, cut ≈ 2.2 s in by the get-up press; the script now waits 6 s) and
`bailrun_ok3_101354` (5 local bails of 6 attempts with the COLLPOST build; one window was an NPC skater's bail and is
flagged by `bail_impacts.py`). No bail had an update > 20 ms, so no recomp hitch fell inside a bail. At spawn
BAILLOCAL logs once per load with `how` 1 and entry index 0 (one skater in the loop). Summary
`bail_impacts.py <trace>`:
- **The impact formula is confirmed line by line.** In 4763 / 4763 BAILREG lines, the impact the game wrote equals
clamp01(max(0.001, |Δv·n| × mass × 10)) within 0.002, with Δv = SS `+4048`, n = SC `+1008`, mass = SS `+4560` and
cfg `+164` = 10.
- **Update interval.** The ragdoll step runs at 60 Hz in the recomp too: step p50 16.6 ms (p10–p90 15.9–17.4) and
the SS `+5220` dt is always 0.01667. So retail's per-step Δv and ours (1/60 s) compare directly, despite the
recomp's ~345 fps render.
- **|Δv·n| per step, the recomp vs ours.** The recomp, all bails of the ok runs, per region: p90 0.01–1.7 m/s, **p99 1.8–13
m/s, max 7.6–20 m/s** (e.g. ok run: regions 3 / 5 / 6 / 7 p99 5.6 / 6.4 / 4.2 / 3.9, max 17 / 9.9 / 14.8 / 20).
Ours peaks near 2 m/s. The tier-2 needs (≈ 1.7 m/s torso, 4–5 m/s head / legs at our masses) are reached
several times per bail in the recomp. The largest steps are contact stops: in the top 10 % per region, the
into-surface v_old·n (−2 … −20 m/s) mostly goes to near 0 or reverses in one step ("stop" share 25–100 %). So the
gap is the physics: retail's parts hit the ground fast and stop in one step, while ours land and slide.
- **Masses (SS `+4560`, the recomp)** by part: 1 0.0052, 3 / 7 0.00034, 4 / 8 0.0017, 5 0.0031, 9 0.0031, 6 / 10 0.0015,
13 0.0058, 15 / 19 0.00031, 16 / 20 0.0010, 17 / 21 0.0051, 18 / 22 0.0067, 23 0.0098. They are smaller than
the ≈ 0.003–0.015 bone-box estimate above, so they are worth checking against ours (`part_weights`) too.
- The bail / end bits of the stack entry match +676 / +677 (BAILSTEP fields). With the GREC-state gate, a bail of the
second owner `40C34020` also opens the window for the local X (one 0.6 s stretch with bits 0 / 0 in run ok2). Filter
those passes by the bits.
- Posts per bail in these runs (higher drop; window bail start −0.5 s … +676 clear): ok run 84 / 399 / 324 / 144,
tier 2 = 7 / 42 / 30 / 10 (legs 1031 4 / 17 / 20 / 8 lead). Every full bail has tier-2 posts.
2. Then a physics-side investigation of the ragdoll's contact Δv (outside the audio port).
3. Frame-rate dependence (separate from the gap): `Contacts::body` decrements its 15-frame cooldown and the 4-frame
region max per rendered frame. At 60 fps that is 0.25 s / 67 ms; on the console it is 0.5 s / 133 ms. Under the
console-cadence rule both should run on the 30 fps cadence. That change would lower our post count further (up
to 2× at 60 fps). Not done here: it is not the asked gap, and it changes the output.
### Session-review leftovers: the lift level, the grind-off lag, world-bank prefetch, the first-hit gate (2026-10-03, overnight)
Four open items from "Session-review fixes". "The recomp" = skate3recomp run locally: its program logic is evidence,
but its frame rate and stalls are not retail. Recomp figures come from the nine clean sessions 163809 … 223613, with
stall windows excluded. Tools: `.local/audio-re/left/`.
**1. The push foot's lift, "+2.7 dB" (0.213 vs 0.155): no mechanism difference, so no code change.**
- *The level path is the same.* `sub_824BB8D8` (plant) and `sub_824BBA00` (lift) are the same function apart from
their slots (`+84` / `+80` and `+92` / `+88`). Both:
- post `sk8_foley` with the id of wheel 0's material kind;
- start with the block [0, 1, 0, dt, 1, 1];
- get their level from the one updater `sub_824BF268`: level(6) × 1/32767, pitch(1), raw(0), dt, 0, 1.
- Contacts level(6) is MixMap E27 (`-600 + B[Player.2] + B[Player.0] + C[Player.8]`). It has no plant- or lift-specific
duck: F9, the duck on Contacts.in6, feeds out3, not out6. So a lift plays at the plant's level(6). The only per-sound
differences are the splice records: lift id 85 is record 31 (gains up to 2.83); 89 is record 35 (members 0.35–0.40).
- *The gap came from the measurement.* The review's figure took the first `sk8_foley` voice within 60 ms of each push
edge, from any record (the clothing foley plays there too). It also pooled the sessions' different material mixes.
`left/plantlift_ids.py` matches every post to the voices of its own record's samples instead:
| id (kind) | the recomp: posts, level p50 / p90 | ours (`fixes/e2e_feet`): posts, level p50 / p90 |
|---|---|---|
| plant 84 (0) | 472, 0.176 / 0.491 | 147, 0.196 / 0.479 |
| plant 88 (1) | 123, 0.247 / 0.303 | 18, 0.235 / 0.283 |
| plant 92 (2) | 44, 0.221 / 0.342 | 0 |
| lift 85 (0) | 473, **0.201** / 0.491 | 111, **0.204** / 0.494 |
| lift 89 (1) | 121, 0.068 / 0.082 | 12, 0.080 / 0.083 |
| lift 93 (2) | 46, 0.160 / 0.205 | 0 |
- Per id, ours and the recomp agree within about 1 dB (−0.1 dB for lift 85). Pooled, the recomp's lift p50 is 0.169
against our 0.193, because its sessions have more kind-1 surfaces: 19 % of lifts against our 10 %, and kind 1's lift
record is quiet. The 0.155 also included other voices.
- `+338` doesn't enter the level. It only splits `+333` / `+334`, and the trigger reads their OR.
**2. Grind off "first audible +50 ms": an authored delay plus a measurement artefact, so no code change.**
- *Post timing is the same.* Ours posts the off sound (`sub_824C4138`) at the release, on the frame the grind ends.
The recomp posts it 0 ms after the grind end too (`fixes/evpost.py`).
- *The updater is the same.* `sub_824C42A8`, read again: per slot, the on and off sound get gain = their level ×
level(1)/32767, pitch = their pitch × pitch(2)/4096, raw(0), dt, 0, 1, and the env send level(5); a sound that
ended is released. `Grind::update_sounds` matches this word for word. It runs in the same frame's update, so the
console cadence plays no part.
- *The delay is in the data.* The off records' members carry authored delays. For example, Skate_Collisions record 835
has group 2 at 11 ms (+ up to 25 ms random) and group 1 at 50 ms (+ up to 50 ms). The splice player honours them.
- The recomp's PLAY lines after each off post (`left/grind_off_delay.py`, sample-matched) put the first audible member
at p10 / p50 / p90 = 4 / 14 / 47 ms. Its delayed groups play late as authored: d57 → 64–72 ms, d64 → 80–101 ms,
d162 → 176–190 ms.
- Ours: the first member comes 0–1 frames (0–17 ms) after the post in all 7 grinds, and the 50 ms groups come 3–6
frames later.
- `fixes/grind_cmp.py` took the first run in its list, which is in completion order, not the earliest. Fixed to take
the earliest: the off lag p50 is 0 ms (7 / 7).
**3. World-source banks through the prefetch** (`game_audio/world_sources.rs`, `native/prefetch.rs`).
- *Problem.* The traffic and ped banks (`TRAFFIC_BANKS`, `PED_BANKS`; 10 of 13 in this install) were read and decoded
on the game thread in `ensure_bank` when the first vehicle or ped was published. That is one frame with 10 bank
decodes.
- *Change.*
- `WorldOwners::expected` (new): the living-world system sets it as soon as it knows it will publish owners on this
map.
- While it is set, `prefetch_world_banks` queues every world bank that is neither loaded nor requested on the
prefetch worker. When it clears, the requested banks that no owner loaded are dropped.
- `Prefetch` counts its clears (`clears()`). A map change (`unload_map_banks`) clears the prefetch and unloads the
world banks, so the host asks again. `contains` is no longer test-only.
- *Identity.* The rules are the same as for the emitter prefetch:
- `ensure_bank` still runs `load_bank` at the first owner, with the same calls in the same order;
- only reading and decoding move;
- a queued, failed or missing bank loads on the game thread as before, with the same errors.
- *In play the host stays inert.* No system sets `expected` or publishes owners, so `frame` returns at its first line,
as before. The e2e harness doesn't run world sources.
- Off: `SKATE_AEMS_WORLD_PREFETCH=0` (game-thread loads at the first owner, as before). `SKATE_AEMS_WORLD=0` turns off
both.
- *Test* (`world_sources::tests::world_banks_are_prefetched_when_expected_and_load_without_decoding`, data-gated):
- nothing is requested until the world is expected;
- then every installed world bank is requested once (missing ones are listed as unavailable);
- the worker's bank and PCM equal `BankSource::load` bit for bit;
- the requests leave the evaluator RNG and the block count unchanged;
- `ensure_bank` for all of them decodes 0 WAVs on the calling thread;
- loaded banks are not requested again;
- `unload_map_banks` makes the host request them again;
- "not expected" drops them;
- without a prefetch the bank decodes on the game thread as before.
- The prefetch fallback test also checks the clear counter.
**4. The first-hit torso message's gate (`sub_824BCEB0`): read further, but the writer is still not found.**
- `sub_824BC188` sets Contacts input 7 to 0 on every frame. With the byte B+16 set, it also sets Contacts input 8 to
clamp(B+24 × 32767 (`0x821747FC`), 0, 32767), else 0. Here B = `*(*(0x83083C38) + 0x2FCB4)` (`lis -31992` =
0x8308; an earlier version of this note wrote 0x8208).
- The object's `+420` latches B+16's rising edge (`+421` = last frame's value). `sub_824BCEB0` then:
- pulses Contacts input 7 = 32767;
- for a region (loop index `r17`) other than 1 and a region tier (`r20`) ≥ 1, posts the torso material 98
against no material (143) through `sub_82496C58` (level) and `sub_82486EF0`;
- clears `+420`.
- Contacts.in7 triggers F7 in Player (+400 mB, hold 14, release 29). That feeds the **Collision** slot's C0: the bail's
collision layer is 4 dB louder for about half a second after the first hit. Contacts.in8 has no reader in the MixMap.
- B+16 is the same flag that PlayerPhysics.in11 (`sub_824B19C8`) and the `hall_of_meat_slo_mo` companion's w11
(`sub_824DD6F0`, B+24 × 1000) read. `sub_824C0AB8` and `sub_824BFA48` (Contacts state) read it too.
- B+16 is a large sub-object (≥ 12.5 KB), reset by `sub_827AB6E0` from `sub_8279E800`. The `sub_827AE9C8` /
`sub_827AEF08` / `sub_827AF4E0` / `sub_827AF748` / `sub_827F1DD0` family copies it into locals.
- No direct store to B+16 byte 0 or B+24 was found in the 15 functions that load B, or in the same class's methods
(`sub_827A*` / `sub_827B*` `stb …,0(r3)` / `stfs …,8(r3)`).
- Next: a recomp hook that logs B+16 (byte) and B+24 (float) per frame during bails and Hall of Meat runs. A
write-watch on that address would name the writer.
- *Hook added (2026-10-03):* `FIRSTHIT` in the recomp's research hooks (category `audiox`, sampled from the board
update hook, local player): B, the B+16 byte, the B+24 float and the audio state's bail / end bytes (`+676` / `+677`),
logged when any of them changes plus once a second. It gives the timing and strength but not the writer (the hook
framework has no write-watch). A short bail session (4–5 different bails in the same area) is queued. Until then the first-hit message and the Contacts.in7 pulse stay
unported (our Contacts.in7 / in8 stay 0, as with B+16 clear).
- *Measured 2026-10-03* (the user's session 094336 and the scripted bail runs; details under "Bail density … Open 1"):
- B+16 rises in the same frame as the audio state's +676 at every bail start. It clears 33–34 ms before +677 clears,
so it means "bail in progress".
- B+24 resets to 0 at the bail start and accumulates in ~17 ms steps to 0.42–1.0 (clamped).
- The first-hit torso post (COLLPOST from `sub_824BCEB0`, return `0x824BCFC8`) fires **once per bail**: torso 98
against 143, tiers 1 / 1, 0.16–0.28 s after the bail start (1.77 s once). It happens for an NPC skater's bail too
(local72 0).
- Port: on the rising edge of our bail flag, latch; at the first region contact with region ≠ 1 and tier ≥ 1, post
98 / 143 once and pulse Contacts.in7. In8 = B+24 × 32767 needs the accumulated strength, whose writer is still
unknown.
**Files.**
- `crates/skate-game/src/game_audio/world_sources.rs`, `game_audio/native/prefetch.rs`.
- Tools: `.local/audio-re/left/{splc_records,plantlift_ids,grind_off_delay}.py`; `fixes/grind_cmp.py` (earliest
voice).
**Verification.**
- skate-audio tests;
- `cargo test -p skate-game --release --bin skate3rust --locked -- game_audio::`;
- `--no-run`.
Results: skate-audio 228 pass; game_audio 58 pass (new world test included, run on the install's data); `--no-run`
builds. e2e doesn't run the changed code: `world_sources` is not in the harness, and
`prefetch.rs` only gained a counter and a public `contains`.
## Credits
The retail measurements in this document were taken with **[skate3recomp](https://github.com/mchughalex/skate3recomp)**
by @mchughalex, a native static recompilation of Skate 3. It is built on the
[rexglue SDK](https://github.com/rexglue/rexglue-sdk) and credits [Xenia](https://github.com/xenia-project/xenia)'s
research. We ran it locally with trace hooks and scripted teleports; it has no licence, so nothing from it is
included here. Upstream PRs #4 / #1 by @andrewnakas (research notes) were also a key reference.
Setup decodes the disc's EA-XMA audio with [vgmstream](https://github.com/vgmstream/vgmstream) (pinned
r2117, hash-checked).
Research for the native audio runtime (specs in progress, 2026-10-02) also draws on:
- [dbalatoni13/nfsmw](https://github.com/dbalatoni13/nfsmw) (CC0): a matching decompilation of Need for
Speed: Most Wanted's EA audio library. Official AEMS opcode and bank-header names and state layouts.
- [BurnoutDecomp/BP-Decomp_Workflow](https://github.com/BurnoutDecomp/BP-Decomp_Workflow) and
[BurnoutDecomp/b5-decomp](https://github.com/BurnoutDecomp/b5-decomp): RenderWare Audio plug-in
identities, the SndPlayer1 and filter/resampler details, opcode names. b5-decomp has no licence, so it
is reference only.
- [mitsevox/tw2004](https://github.com/mitsevox/tw2004) (CC0): Tiger Woods 07 RenderWare Audio function
listings.
No code from these is included.
The native runtime (`crates/skate-audio`) is our own code written from our specs. Its oracle was the PoC
worktree of upstream PR #4 by @andrewnakas (no licence; run locally as a black box: our probes drive its
evaluator and replay-verified DSP kernels; two local fixes to it, never committed). Its facts (offsets,
opcode numbers, constants) come from our reading of skate3recomp (no licence, reference only) and the disc
banks; official names from dbalatoni13/nfsmw (CC0).
The MixMap port and the granular rolling bed (2026-10-02) rest on upstream PR #4 / #1 by @andrewnakas
(no licence, reference only): they found that the bed is a granular player and recovered the MixMap's
function map, stage order and input writers, the GrainPlayer object, pick and scheduler, the board
records, the bus chains, the vault field roles and the instance counts. Their MixMap port and grain
functions, run locally in the PoC worktree, are our golden oracle (MixMap 87,000 cells, pick / position
vectors); the order of retail's single-precision operations in the Bézier, the speed scales and the
`id 9 = 0 for the local player` rule were read from their documentation. EA's record names (MixCtl,
3DMixCtl, EvtMixCtl, SubMixCh, MasterMixCh, Preset) come from
[BurnoutDecomp/b5-decomp](https://github.com/BurnoutDecomp/b5-decomp)'s NFSMix headers (no licence,
reference only). Spot checks and addresses: skate3recomp (no licence, reference only). Data: the user's
own Skate 3 disc and TU3 image (`MixMapSK8.mxb`, `grains.big`, `skatercollections.vlt`; never
committed), decoded with [vgmstream](https://github.com/vgmstream/vgmstream). No code from any of these
is included.
The player inputs and components (2026-10-02) are our own code from our reading of the retail functions
in the skate3recomp generated code (no licence, reference only; addresses in the source docs) and the
user's vault. Upstream PR #4's `player-audio-retail-drivers.md` and `mixmap/inputs.rs` notes by
@andrewnakas (no licence, reference only) gave the function map, the packet word tables, the Jitter /
Contacts / 3DObjPos writer descriptions and the retail wind and grain capture tables we validate
against. The retail level tables come from our local recomp trace sessions
(`.local/recomp/sessions/20261001_211347` — deleted 2026-10-02, re-verify on clean data — and
`all_20261002_163809`). No code from any of these is included.
The Splice player, the board contacts, skid and squeaks (2026-10-02) are our own code from our reading of the retail
functions in the skate3recomp generated code (no licence, reference only; addresses in the docs) and the user's vault
(`skatercollections.vlt`). Upstream PR #4's driver notes by @andrewnakas (no licence, reference only) gave the
Contacts / skid / squeak function map and word tables we checked against the code; their PoC, run locally as a black
box through our own headless probe, is the end-to-end oracle. No code from either is included.
The collision manager, the wheel spin, the foot taps / scuffs (2026-10-02) are our own code from our reading of the
retail functions in the skate3recomp generated code (no licence, reference only; addresses in the module docs) and
the user's vault (field layouts from the disc's own schema, `skaterschema.vlt`). The bed validation data (GREC hook)
was recorded with skate3recomp by @mchughalex, run locally by the user. Upstream PR #4's driver notes and
`collision_states.rs` docs by @andrewnakas (no licence, reference only) gave the manager's map (router, ten slots,
message layout, the material table at `0x8302D6E8`) that we re-read and corrected in the code (the update reads the
category output's level; materials ≥ 143 take pitch output 1; the impact bands come from the `+24` RefSpec). No code from
either is included.
The environment network, the eEQChain buses, the owner one-shot buses and Class_Seams (2026-10-02) are our own code
from our reading of the retail functions in the skate3recomp generated code (no licence, reference only; addresses in
the specs `.claude/notes/aems-env-bus-spec.md`, `.claude/notes/aems-eqchain-buses-spec.md` and the module docs), the
TU3 image and the user's vault; validated against the user's own recomp traces (skate3recomp by @mchughalex, run
locally). BurnoutDecomp/b5-decomp's reconstructed ReverbModel1 and Delay (no licence) were read only to orient; the
DSP follows the TU3 asm. Upstream PR #4's notes by @andrewnakas (no licence, reference only) named the eEQChain
holder fields, the owner bus builder and the Class_Seams function map. No code from any of these is included.
## Open questions
- World sound sources (2026-10-03): the speech manager's `.evt` rules, take choice and level; retail's SFX instance
assignment; the traffic 3DObjPos binding; ped model → shoe class / voice; PedBodyFall, Tazer (op 38), NPC skaters' grain bed / wheels / foley (foundation in "NPC skaters' board sounds"),
event PA / crowds / music zones ("World sound sources").
- Main bail body-impact sound (Bodyslide is only a layer); landing weight layer level; metal grind pick
(54–56) by ear; powerslide, wheel spin, push sound, fountain bank, caveman jump-on — not yet confirmed.
- Surface → grain now follows retail (2026-10-02); still open: the `Class_rolling` surfaces (interim stand-ins), tag 90, and what selects the soft-wheel grains.
- Retail plays rolling granularly; the default path plays banded loops, `SKATE_AEMS=1` plays the native
granular bed (player B silent until turn/slope inputs exist; FSS, graph 3, wobble not modelled).
- MixMap inputs: VU (output meter) and the menu/NIS/HOM/Challenge flags stay 0; the bed's gain A matches
retail's level(1) p90 with Jitter wired, the lower retail medians need a straight-roll trace at turn 0
(see "Player inputs and the first native player components").
- Native player components still to port: seams, rattle, Class_rolling, cloth, body slide, footsteps,
flips/treatment, the body-impact and step-on posters (need skeleton region contacts / Skeleton+602/+603 from the
engine). Native since 2026-10-02: skid, squeaks, the Contacts pops / landings / touchdowns, the collision manager
(grind start, landing pair, deck impacts), foot taps / scuffs, the wheel spin. Music.in3 is a game-mode flag
(0 in free skate), so the ollie voice and the landing impact stay gated as in retail.
- The turn input (`Turn` animation attribute) is almost binary in our engine (29 % of riding frames at 1.0 vs
retail 16 %; 7 % in between vs 25 %): carving layers trigger too easily (user, 18:02 build). Animation side.
- The interim layer still bypasses the native host's 6→2 fold (~3–6 dB above native voices of the same per-voice
level); the send buses (distance cue) are not modelled.
- User listening check 2026-10-02 (native build 16:59): rolling "too loud" and "not in sync", grinds "not the
same sound" — see "Board contacts, skid and squeaks; the end-to-end PoC comparison" for what the measurements
show; the PoC reference had a one-off louder/harsher seam sound on one launch (first-play decode).
- Zone ambience from `.ems` emitters (beds per map are a name-based guess); manual wheel spin
(`Whls_spins_Man_1`); music.
- ~615 MB of PCM (mostly ambience): a compressed format would need an encoder at setup.
- Retail's own sample choices could be measured from a recompiled build that logs which bank data the
game decodes (local research only, not part of this change).
- `AUDIO_*` logging is verbose for a release build.
- Native runtime: the unmodelled buses and sends (the MixMap's emitter send word w2 now has a value but no
bus to go to), loop time-left, `c_emitter` w0, and the gain ramp's last ulp (see "Native AEMS runtime:
implementation" and "MixMap and the granular rolling bed").
- Buses (2026-10-02): the FlangeSub effect returns (GRINDS routes to one), reverb-zone emitters (type 5), the grain
chain's graph-2 env send, the Splice voices' own Send A (retail Skate_Collisions median 0.0146) and Collision SubMix,
sense_of_speed's FXWET0 (retail median 0.015, ours 0), the reverb module's timer delay; SFX Master's DCl0 level.
- The riderless-board gate (states 300 / 500–502 report no wheels) rests on retail's measured record; the writer of
record +152 bits 20–22 is not located.
- rodio 0.20's spatial panning is mirrored (the default path still uses it; native mode swaps the ears).
- 19:38 air tricks #1 / #3: the audible in-air voice is retail's wheel spin-down; a retail ollie at ~30 km/h would
settle whether ours sounds like it.
- Port stage 2026-10-02 (rolling layers, chain, tricks, footsteps, returns), still open: the FlangeSub send of the grain chain
(no bed → return route); the bool-class word `0x11A631878B239355`; the seam pattern retail sees on University's sidewalk
cells (pattern 11, "Listening test 8"). The seam sample variety is fixed: it was the missing `Start_up_Play_ctl` boot
utility ("Listening test 8"). Closed 2026-10-02 (stage "FootStep SubMix, the skeleton inputs, …"): the FootStep SubMix, the
step code, limb / body speeds, ragdoll slide records, the loose-board vectors, Reverb.in0..6 + the env scale, the reverb
zones (1,040 records, not one), the boot order.
- Treatments 16 / 17: settled, including the ramp air's late start (a 1 s audio stall in the recomp) and the short
airs (same rate as the recomp). sense_of_speed figures redone with the shared-sample fix: no difference
("Leftovers: Treatments 16 / 17 timing, …").
- FootStep SubMix: Sen0 #2 = 1.0 and the Pn21 law 0 are read, not defaults. Left: a released voice's de-click tail
skips the submix's filters (as for every bus).
- Reverb zones: vfunc92 is ported (preset ∈ the 24 presets). Not ported: the music zones (type 4, playlists for the
licensed-music player we lack) and the speakers / crowds records (types 6 / 7, event PA and crowds; flags mask
audio +1032, 4 by default). The value of audio +1032 in free skate is not measured.
- The Send A table in "The buses" is superseded for sense_of_speed (0, as retail) and the Splice voices (none):
`.local/audio-re/busleft/senda_bank_fixed.py`.
### Native runtime is the default (2026-10-02)
(Superseded 2026-10-03: the tables and these opt-outs are removed; see "Interim cue tables retired; the water
splash is native".)
- The native AEMS runtime is now the default. `settings/audio.json` `"interim": true` or `SKATE_AEMS=0` selects
the interim measured tables; any other `SKATE_AEMS` value, or none, selects native.
- The old `native` setting key is ignored. Installs saved `"native": false` while the tables were the default, and
honouring it would keep those players on the tables.
- The tables stay in the code for now.
- Verification: `game_audio::` tests, including the new
`native_is_the_default_even_with_an_old_saved_native_false`; muted smoke runs (`--mute`) on StartPark and University
with the staged build: native runtime and every component on, render ready, no panics, errors or non-finite physics.
### Seam-hit prominence: closed (2026-10-03)
The remaining 2–5 dB gap between our seam hits and the recomp's capture (after the 110 ms alignment fix) has no
mechanism. Pitch, per-voice level, bus, voices per hit and the bed spectrum all match. The user closed it as a
likely recomp artefact: the recomp's frame rate is uncapped, its audio thread stalls, and its capture includes
ambience and the other seam voices. The user hears the seams as "WAY BETTER". No levels were changed.
### Interim cue tables retired; the water splash is native (2026-10-03, `player::footsteps::Splash`)
The user, after the listening check of build 04:20: "go ahead and remove the interim cues", then, on hearing some were
still in use: "oh.. if they are still being used then we aren't done." Decisions: splash "port it natively first";
the riding bed: remove it, then check it against the recomp; installs without the data: require it (no fallback).
**Listening (user, 2026-10-03, bin\ 10:06, session `state_20261003_101640`, 0 malformed):** "The sounds are SO GOOD.
I think that is basically retail, if there is a difference i couldn't tell. Splashes sounded correct. Riding sounded
right, jumping sounded right, falling sounded right. Cracks on bricks sounded correct at both higher speed and lower
speed. Manual sounded amazing."
**1. The water splash, from retail's mechanism (not the carried-over sound).** `SFXObj_OffBoard`'s process ends with
`sub_824EBB58`; its update with `sub_824EBE78`. Read from the TU3 recompilation (reference only):
- Inputs. The bridge `sub_824B0DA8` copies record `+172` bits 30 / 29 / 28 into audio state `+811` / `+812` / `+813`.
The conditioner `sub_827A1B78` writes them:
- `+811` in water = the current state's byte `+81`. In our engine that is Wipeout300's `special_surface_81`
(its water contact), already published into `state_flags`.
- `+812` under the surface = `+811` and the state's surface height `+32` (`surface_height_32`) above the Y of any
of the PhysOut Skeleton points `+128` / `+112` / `+32`. Skeleton::FillPhysOut (`sub_82BE1AE8`) writes them: part
15's and part 19's pose matrices applied to a per-part local point (Skeleton `+2560` / `+2816`, translation at
`+48`), and part 1's pose translation (Skeleton `+8128`).
- `+813` the board in water = Collision `+16` = 12. That is the board's surface vote `82C08818`, which forces 12
when a board contact is water: `physics::board_surface`, the value respawn already reads.
- Poster (every frame, record active; no local or on-foot gate). On `+811`'s rise the time in water `+480` starts at
0, then counts dt; on its fall the latches `+477` / `+478` clear. On `+812` once per stay (`+478`), unless the
game global `+224` is set (0 in free skate), the entry sound `+484` is stopped and a new one starts. The ids are
vault fields of the AudioSurface-class record `water` (`923CCB46EF5BF5BA` / `B2BD1F28DDE601B0`):
- Skate_Collisions **1187** (`35D3B06292CDA10B`) on the water contact's first frame;
- **1197** (`C17485220849574D`) once the time in water has reached 0.001 s (`9CD13431903E3719`), i.e. from the
next frame on;
- `+813` once per rise (`+492`): **1198** (`BA81E93AE985D1C7`) into `+488`.
- Sound path: the collision Splice object `sub_82497F48` into SFX Master, start block [0, 1, 0, 0, 1, 1]. The update
`sub_82498140` sends [OffBoard level(13) (entry) / level(16) (board) / 32767, pitch(14) / 4096, raw(0) × 360/65535,
dt, 0, 1] and the env send level(15) / 32767 (as the grind on / off sounds: the last update's env level is latched
at each start). A sound that ended is released.
- Cross-check: 1197 is the container of **record 869**, which is exactly the sound the user found by ear (476–478,
with 169 at 75 % and one of 171 / 172). 1187 → record 868 (170–172 + 169); 1198 → record 870 (473–475, the set the
user had rejected for small body falls: retail plays it for the board). The 2026-10-01 measured retail splash
(475 + 478 + 172 + 169) is 1197 + 1198.
- Port: `skate_audio::player::footsteps::Splash` (state `in_water` / `under_water` / `board_in_water`, set in
`skate_events::audio_state`; the ids and threshold in `FootstepTuning`). Logged in game as
`AUDIO_EVENT splash native Skate_Collisions:<id>`.
- **UNCERTAIN:** the engine does not publish the per-part local points at Skeleton `+2560` / `+2816`, so the toes'
pose translations stand in for `+128` / `+112`. That can only shift the entry frame by the local offset (a few
cm of depth).
- Through the real MixMap (test `the_water_splash_plays_through_the_real_mixmap`): OffBoard level 13 = 14585
(≈ 0.45), 16 = 14401, env 15 = 862, pitch 14 = 4086.
**2. The riding bed removed, checked against the recomp.** Under native the random bed still played Skate_Collisions
947–968 and sk8_foley 62 / 73 / 74 / 84 / 85 / 88 / 89 as interim voices. All of that is gone (below). Riding = GREC
/ state-log context roll + carve, audible voices (> 0.002), first-voice levels
(`.local/audio-re/retire/bed_vs_recomp.py`, `riding_posters.py`):
| per riding second | the recomp (6 sessions, 682 s) | ours native (5 user sessions, 229 s) | the removed bed (expected, same frames) | ours before (native + bed) |
|---|---|---|---|---|
| Skate_Collisions voices | 8.95 | 2.80 | 5.78 | 8.58 |
| Skate_Collisions Σg² | 0.424 | 0.218 | 0.120 | 0.338 |
| sk8_foley voices | 5.93 | 2.54 | 1.90 | 4.44 |
| sk8_foley Σg² | 0.229 | 0.103 | 0.079 | 0.182 |
(Recomp: all_20261002_180430, 223306, 223613, 214346, 214002, 222155. Ours: the e2e renders of 213757, 214224,
215843, 224747 (row) and 084712 (`E2E_FPS=60`). The native renders are byte-identical before and after this change.)
So without the bed ours is −2.9 dB (Skate_Collisions) and −3.5 dB (sk8_foley) below the recomp while riding. By
poster (the recomp, riding):
- **Skate_Collisions:** the collision manager (`sub_824D1F68` ← `sub_824D2318`) plays 3.3 posts/s while riding.
Most are the body materials **955 / 954 / 956 / 947** (denim / skin / arm / leg, 1.7/s, p50 ≈ 0.1); the rest are
969 / 958 / 968 / 1038 (≈ 0.6/s, mostly quiet). Ours: the body poster (`sub_824BC188`, ported) runs in every state,
but our skeleton publishes almost no body-region contacts while riding (4 of 1,770 riding rows in 084712, impacts ≤
0.011). The manager is shared, though: NPC skaters' Contacts and physics props (`sub_824E3BE0`) post into it too,
and the session review already put 969 / 958 / 968 down to world objects. The SPLC caller chain stops at the
manager's update, and no hook records the messages (`sub_82486EF0`: materials, tiers, levels, poster). So whether
the local rider's regions touch something while riding (and what) is **not established**. Not changed (no
guessed weighting).
*Measured 2026-10-03 (recomp hook `COLLPOST` on `sub_82486EF0`, category `audiox`; run `riderun_20261003_101135`,
scripted: three ~10 s pushing segments from a session marker at the Super-Ultra Mega-Park spawn, straight / carving;
0 malformed lines; summary `.claude/skills/recomp-research/tools/collision_posts.py`).* Each line has the posting
function (from the return address), its object, the object's audio state `[object+32]`, the GREC owner of that state,
`[[object+28]+72]` (local72), the materials, the tiers and the caller chain. Findings:
- **Clean pushing and carving on open ground: no body posts from the local rider.** Segment 1 (straight, 25.6–35.6
s) had none. Every local body-poster post (31, local72 = 1, owner `40C33020`) came in bursts at 52.7 s and
70.5–74.8 s, when the rider brushed and hit a rock wall at the edge of the park (screenshots). They paired legs 99
(tiers 0–2) against materials 2 / 65 / 143 with the denim cloth 108 / 143.
- **The other owner is an NPC skater.** The second GREC owner `40C34020` (doc above) posts with local72 = 0 through its
own Contacts object. Its bail at ≈ 38 s produced the run's body burst (39 posts, 34 body SPLC 956 / 954 / 955 /
947 at 38.2–38.4 s) while the local rider rode normally. So `[[object+28]+72]` tells the local rider from other
skaters, where GREC's `[[owner+16]+72]` does not.
- Deck impacts (`sub_824BD000`, deck 95 against the ground): local 1.28/s, the NPC 0.34/s.
- The 48-byte message's +32 / +36 words are not SPLC ids: mostly 0, otherwise 4-hex-digit values, e.g. 0x6590. The
sounds come later from the manager.
- Conclusion: while riding, retail's body-material sounds come from other skaters' bails and from the rider touching
walls, not from riding contacts. Ours having ~none on open ground matches. The 1.7/s in the user's riding
sessions likely include NPC skaters (no COLLPOST in those sessions; the session review's PCU / park sessions had
AI skaters). To check in a user session: `PLAY_TRACE_AUDIOX.bat` now logs COLLPOST.
- **sk8_foley:** every poster is ported. The rates differ with riding style. The recomp's riding has 0.42 plant /
lift posts per second; ours 0.19 / 0.16 (the user: "the 19 plants are correct, i didn't ride fast"). Push-driven
sounds scale with that: the Clothing push foley 74 / 73 (ours 0.16/s, the recomp 0.79/s) and the on-board
footsteps. Shoe scuffs 94 / 95: ours 1.61/s, the recomp 2.71/s (both p50 0.061). The pedestrians' sk8_foley 62 / 63
(0.53/s in the recomp, `SFXObj_PedestrianSFX`) are a world layer we can't have yet. No mechanism gap found; not
changed.
- **Needed to settle the Skate_Collisions part:** a recomp hook on the collision message post `sub_82486EF0`
(caller chain, the owner's local flag, materials A / B, tiers, levels), plus a short riding session by the user
(flat ground, some pushing and carving, no bails). Then compare the local rider's body-region messages with our
region contacts (`rimp*` / `rtag*` in the state log).
**3. Native data required.** No fallback any more:
- no AEMS banks → the runtime does not start and an error says that the skater's sounds, the world emitters and
rolling are silent;
- no MixMap → error, no player sounds, no rolling;
- no grain recordings / tuning → error, rolling silent;
- missing player banks or Splice trees → error, those sounds silent.
The zone beds, location sets and crossfades still play their measured layers through Bevy voices.
**4. The interim path removed.**
- `cues.rs` deleted. `grain_for` moved to `grain_bed.rs` (with its test); `RETAIL_SCALE` moved to `voices.rs`
(ambience and the Bevy-voice scale still use it).
- `skate_events.rs`: the `Event` enum, the event detectors (pop, landing, flip, bail, push, step, splash, foot
strikes), the `play` system and its loops (rolling bands, bed, grinds, powerslide, foot drag, wheel spin) are
gone. `observe` keeps the audio state, `Riding` (minus the fields only `play` read), the state log and the
`AUDIO_EVENT` brake / push / grind lines.
- `physics::foot_clearance` removed (only the interim steps used it).
- Opt-outs removed: `SKATE_AEMS=0`, `SKATE_AEMS_PLAYER=0`, `SKATE_AEMS_FOOTSTEPS=0`. `"interim"` / `"native"` keys in
`settings/audio.json` still load and are ignored (test `old_settings_files_with_interim_or_native_keys_still_load`).
The other `SKATE_AEMS_*` switches (they pick between native variants) stay.
- `library.rs`: the grain speed bands, the wheel-spin clips, cue preloading and the patch trees (`Patches` /
`Group`) are gone. Old manifests still load: unknown keys are ignored.
- `voices.rs`: the fold / scale stay, because the measured world layers still play through Bevy voices.
`Play::effect` is test-only now.
- Gone with the tables: the `AUDIO_CUE` / `AUDIO_LOOP` lines and `AUDIO_EVENT land` / `rolling` (interim-only).
**Verification.**
- `cargo test -p skate-audio --locked`: all pass, including `the_splash_plays_once_per_water_stay_by_its_time_in_water`.
- `game_audio::` (release): 54 pass, 4 ignored (60 before; the 8 interim tests went, 2 added).
- `cargo build --locked` (dev): OK. The 3 warnings are items that were already test-only at 45c6e65.
- e2e byte-identical to the 45c6e65 renders (`opt/runs/fma_plain3`): 13 scenarios + 4 real sessions, row and
`E2E_FPS=300` (`opt/runs/retire1`), and 084712 at `E2E_FPS=60` against `check0847`. The e2e path never ran the
interim layer, and the splash inputs are false there.
- Not run here: the muted 5-map smoke test (it opens a window) and bin\ staging.
**For the user's check in game:**
- a fall into deep water: one 1197 splash per entry, plus 1198 when the board lands in the water;
- riding on flat ground without the random bed (Skate_Collisions / sk8_foley pieces), against the recomp.
Rerun: `py -3.13 .local/audio-re/retire/bed_vs_recomp.py <recomp sessions> <DIR:NAME,…>`,
`py -3.13 .local/audio-re/retire/riding_posters.py <recomp sessions>` (PYTHONIOENCODING=utf-8).
### The ragdoll part masses against the recomp; the missing |Δv·n| (2026-10-03, analysis only; no code change)
Open item 1 of "Bail density" listed the recomp's SkeletonState `+4560` masses rounded to two digits and asked for a
check against ours. Ours = `SkeletonAnimationMasses::part_weights` = the raw bone-box product x·y·z of each
`PhysicsParamBoneData` size (`assets/private/stock/physics-skeletons.json`, f32 as the code multiplies). The recomp =
the `mass` field of every BAILREG line of the fixed-hook runs t2 / ok / ok2 / ok3 (12,054 lines, 0 malformed), printed
to six decimals:
| part | ours | the recomp | | part | ours | the recomp |
|---|---|---|---|---|---|---|
| 1 neck1 (head) | 0.005248 | 0.005248 | | 13 spine1 | 0.005832 | 0.005832 |
| 2 neck | 0.000784 | 0.000784 | | 15 / 19 toes | 0.000307 / 0.000307 | 0.000307 / 0.000307 |
| 3 / 7 hands | 0.000336 / 0.000336 | 0.000336 / 0.000336 | | 16 / 20 feet | 0.001015 / 0.001016 | 0.001015 / 0.001016 |
| 4 / 8 forearms | 0.001666 / 0.001667 | 0.001666 / 0.001667 | | 17 / 21 legs | 0.005109 / 0.005110 | 0.005109 / 0.005110 |
| 5 / 9 arms | 0.003134 / 0.003050 | 0.003134 / 0.003050 | | 18 / 22 uplegs | 0.006708 / 0.006708 | 0.006708 / 0.006708 |
| 6 / 10 shoulders | 0.001483 / 0.001483 | 0.001483 / 0.001483 | | 23 hips | 0.009753 | 0.009753 |
**The masses are identical** for all 20 parts the recomp's bails touched (the left / right arm asymmetry 0.003134 /
0.003050 included). The 2-digit list in "Bail density" was rounding, and the earlier "≈ 0.003 arm … 0.015 torso"
estimate was a guess. So the masses are not part of the bail-density gap. (Parts 11 / 12 / 14, the upper spine, never
touched in those runs: ours 0.007751 / 0.007001 / 0.005935.)
**Found on the way: retail takes the absolute value of Δv·n; ours doesn't.** `sub_82BD60C8`'s region loop computes
`vmsum3fp128 v44 = Δv · n`, then `vandc128 v42 = v44 & ~(v63 << 31)` (the sign mask): |Δv·n|. The recomp lines agree:
with Δv·n = v_new·n − v_old·n from each line, clamp01(max(0.001, |Δv·n| × mass × 10)) matches the written impact in
12,054 / 12,054 lines (within 0.002), the signed form in 11,776. `skate_events::region_impacts` uses the signed
`along`, so a step with Δv·n < 0 is floored to 0.001 there.
- How often: 5,355 of the recomp's 12,054 region steps have Δv·n < 0. Of the 1,729 above the floor, 447 (26 %) are
negative ones; above 0.1 it is 8 of 166, above 0.25 4 of 46.
- What it does to ours: about a quarter of the above-floor region contacts read 0.001, under every body floor (0.002
head / torso / legs, 0.005 arms) and the concrete floor (0.12). Those contacts post nothing. It barely touches tier 1
/ 2 (5 % of the impacts above 0.1). So it costs mostly tier-0 posts: a small part of the 4–5× gap, which stays the
ragdoll's small per-step Δv (open items 1–2).
- Not changed (this task was report-only). The fix is audio-side, not physics: `along.abs()` in `region_impacts`
(the doc's "matches term by term" missed the `vandc`). It changes the bail renders, so it needs the user's go.
*Applied 2026-10-03 with the user's approval: see "Region impacts take |Δv·n|" below.*
### The per-player recomp hooks logged an NPC skater too; measurements re-checked (2026-10-03, recomp hooks + analysis; no engine change)
**Problem.** GREC / GRECX / FIRSTHIT / SKID / TREAT / SEAMPAT / SEAMHIT were meant to log the local rider only. Once an
NPC skater spawned (20–80 s into a session), they also logged its board, treatment and seams objects, interleaved per
frame. The earlier note "earlier sessions had one owner" was wrong: 15 of the 18 sessions with these hooks had the
second owner (`.local/research/localtest/owner_scan.py`); only `all_20261002_204336`, `all_20261002_232153` and
`bailrun_verify_20261003_095507` are clean. In `all_20261002_180430` (the bed-level session) the NPC owner is 227,295 of
460,476 GREC lines.
**Root cause.** The hooks tested the 32-bit WORD at `[[object+16]+72]`. The game's local flag is the BYTE at +72 (the
skid updater `sub_824C7A20` reads `lbz 72` of `[owner+16]`; the eqchain / grain / tricks code reads `[[object+28]+72]`).
The NPC's component has byte 72 = 0 but a non-zero byte after it, so the word test passed. The TREAT / SEAMPAT mask bit 2
(state = GREC's last accepted state) alternated with the two owners, so it selected neither reliably.
**Change (recomp research hooks only, `src/research/hooks_audio.cpp`, uncommitted).** All seven kinds now gate on the
byte `[[object+28]+72]` (local72, as COLLPOST). SKID keeps its last holder per owner; FIRSTHIT keeps its per-state slots.
TREAT / SEAMPAT / SEAMHIT's last field: bit 4 = local72 (the gate), bit 1 = byte `[[object+16]+72]`, bit 2 = state equals
GREC's local state. New kind `LOCALTEST` (category `audio`, ≤ 256 lines, 6 fields; `trace.py` FIELD_COUNTS): per hook and
object, both bytes, the old word test and the decision, whenever the pair (old, new) changes.
**Proof (our own runs, `ride_spawn.txt` at the Mega-Park spawn, background, `SKATE3_TRACE=audio,audiox`, 0 malformed).**
`localtest2_20261003_102933`: at 21.57 s the NPC's board `40C34020`, treatment `40C58200` and seams `40C60560` start
passing the old test (bytes 0 / 0, word ≠ 0) and are rejected (`LOCALTEST … 0 0 1 0`); the local `40C33020` /
`40C581A0` / `40C60320` pass with bytes 1 / 1. GREC / GRECX / SKID / TREAT / SEAMPAT / SEAMHIT each logged one object
(33,048 / 33,048 / 28,533 / 33,048 / 33,047 / 929 lines) while the NPC was posting deck and body impacts (COLLPOST
local72 0, `40C710A0`) and visible in the screenshots. Run `localtest_20261003_102703` agrees (NPC seams object
rejected at 20.4 s).
**Measurements re-checked** (copies with only the local rows: `.local/research/localtest/filter_local.py [--mask2]`;
scripts in the same folder; the local object is the first one each trace logs, confirmed by LOCALTEST):
| measurement (session) | as published | local rider only | conclusion |
|---|---|---|---|
| Retail turn input: share of riding frames at full / in between (180430) | 16 % / 25 % | **17 % (0.95–1.0, median 0.992) / 8 %**; release ≥ 0.9 → 0 in a median 34 ms | **Changes.** The 25 % was the NPC's steering (81 % of its frames in between). Retail's local input is as binary as ours (29 % / 8 %, release 50 ms in `state_20261002_181631`). |
| I vs \|turn\| medians 0.1 / 0.3 / 0.5 / 1.0 (180430) | 0.066 / 0.185 / 0.30 / 0.60 | 0.062 / 0.180 / 0.301 / 0.600 | Holds. Retail's I is 0 by 50 ms after the input reads 0 (p90 0.000, 366 releases). |
| Wheel-0 material changes while rolling (180430, `grec_material.py`) | 0.9 /s | **0.45 /s** (keep rates 100 % / 96 % unchanged) | Value changes; the fix it supported (materials from the wheel lines; ours was 5–9 /s) holds and is stronger. The comment in `skate_events.rs` (≈ line 543) still says 0.9. |
| Trucks: neither running / (1,0)↔(0,1) swaps (180430, `grec_running.py`) | 621 of 310 k / 39 k swaps | **9 of 233 k / 86 direct swaps** (+ ~310 through both-running) | "Never stops the last sounding truck" holds. The 39 k swaps were the two owners alternating; the hand-over is rare. |
| Clean straight-roll level(1) per material (180430) | 0.219 (mat 2, 5–10), 0.21 (40, 10–15), 0.229–0.244, 0.245–0.25 | 0.219, 0.215, 0.228–0.246, 0.243–0.250 | Holds (ours within ~1 dB). Material 65's low 5–15 km/h values (0.079 / 0.024) were the NPC; local 0.208. |
| Manual rows gain A, 10–50 km/h (180430) | 0.141–0.157 | 0.139–0.158 | Holds. |
| Class_Seams cadence (223306, `seampat.py`) | calls every 2.9 ms, frame field 2.5 ms; speed changes 18 %, positions 37 % | 3.0 ms, 3.0 ms; 19 %, 41 % | Holds (~3 ms pulses). The old `mask & 2` filter kept only 14,861 of 33,785 local calls. |
| Pattern 11 hits (223306) | 15–21 /s at 20–40 km/h | 15–29 /s | Holds (sparse sidewalk = short pulse). |
| Grid hits on a cell change of that wheel (223306, `seamhit_match.py`) | 400 of 520 | 2,677 of 3,156 (85 %); pattern 11: 470 of 619 | Holds. |
| TREAT replay, air-time +236 changes (223306), sense_of_speed replay (223613) | 860 of 5,094 | same | Already filtered by object (`40C581A0`, `40C33020`). The "57,323 lines" count includes the NPC (local 33,786, ~305 /s). |
| Clean carves A/(1−I), B/I; bed per material (`review/bed.py`) | — | — | Already filtered by owner (`rlib` / local owner). |
**Not separable:** voice-based figures (PLAY, SPLC: the riding Skate_Collisions / sk8_foley rates, seam voice rates
and levels) include an NPC's sounds whenever one was near, and these sessions have no COLLPOST. That was already noted
for the 1.7 /s body posts; a user session with `PLAY_TRACE_AUDIOX.bat` (COLLPOST) settles it.
**For the main session (no engine change made):**
- "(1) Carving": the turn input is not the difference. If ours really keeps I up for ~13 frames after the stick
returns (retail: 0 within 50 ms), the cause is on our I side, not the animation's `Turn` scalar; worth a look.
- Hook consumers written before this fix need an object filter (or `filter_local.py`); new traces need none.
### The body poster on the console cadence (2026-10-03, `player::contacts`, open item 3 of "Bail density")
**Problem.** `Contacts::body` (retail `sub_824BC188`, called from the Contacts frame `sub_824B8218`) ran once per
`PlayerAudio::process`, and its 15-frame region cooldown counted those calls. The host runs the components once per
pass with at least one 60 Hz step (`native::mixmap_frame`), so the cooldown was 0.25 s at 60 fps and above but 0.5 s
at 30 fps. Retail runs the Contacts frame once per rendered frame: on the 30 fps console the cooldown is 0.5 s. The
standing rule applies: per-frame retail processes target the 360's ~30 fps cadence, at any engine fps.
**The 4-frame region max is not part of it.** The ring is the conditioner `sub_82773298`, called from
`sub_82772748` next to the landed latch `sub_82772FD8` (same caller, same pass). Those PhysOut conditioners run per
physics step (gotchas "MixMap console cadence": the recomp's `+236` air time steps by 1/60 on 17 % of its ~345 fps
calls), and the bail hook measured the ragdoll pass at 60 Hz in the recomp too. So "4 frames" = 4 physics steps =
67 ms on the console as well, which is what `skate_events::observe` (per physics tick) already does. Unchanged.
**Change.**
- `Contacts::body_calls: Option<usize>`: `Some(n)` runs the poster n times in this process (n console frames end
here), `None` once (the old cadence; tests).
- `PlayerAudio::body_console` (`SKATE_AEMS_BODY_CONSOLE=0` off; e2e `E2E_BODY_CONSOLE=0`) sets `body_calls` to the
MixMap cadence's evaluations of the pass (`jitter_steps`: `mixmap::cadence`, every second 60 Hz step on a fixed
grid). Without the MixMap console cadence (`SKATE_AEMS_MIX_CONSOLE=0`) the old cadence stays.
- The NPC skaters' host does the same for its instance (`NpcSkater::set_body_calls`, the evaluations since its last
call).
- Diagnostics: `Contacts::body_digest` (FNV-1a over every posted message) and, in the e2e harness,
`<name>.ours.body.tsv` (the row where the count changed, the count, the digest).
- Below 30 fps a pass spans more than one console frame and the host has only the newest step's state, so the poster
then runs twice on it (as the MixMap evaluations do).
**Proof of frame-rate independence.**
- Unit test `the_body_poster_keeps_the_console_cadence_at_any_frame_rate`: a 20 s 60 Hz stream of rising and falling
impacts on both arm regions, run through a host at 30 / 60 / 144 / 240 / 365 fps (process on passes that complete
steps, newest state, `Cadence`): the same messages on the same steps (counts and digests). The old cadence differs
between 30 and 60 fps. A held impact posts on steps 2, 32, 62, 92: every 30 steps = 0.5 s.
- e2e, the two bail sessions (`state_20261003_084712`, `state_20261003_101640`; 0 malformed; cut with
`scenarios.py --cut r0-999999`, 3,324 / 14,922 rows), `E2E_FPS` = 30 / 60 / 144 / 365: the body traces are
byte-identical at all four rates. With the old cadence 30 fps differs from 60 (144 / 365 equal 60).
- Identity of everything else: `E2E_BODY_CONSOLE=0` renders are byte-identical (f32 and voices) to the pre-change
build at all four rates. The optimisation bench (13 scenarios + 4 sessions, row and `E2E_FPS=300`,
`opt/runs/cad_t1` against `cad_base`): IDENTICAL. Those logs carry no region impacts (row mode also keeps the old
cadence).
**The e2e effect on bails (`E2E_FPS=60`, `bails_cmp.py`, window −0.5…+2.5 s; tools `.local/audio-re/bodycad/`).**
| | 084712 before | 084712 after | 101640 before | 101640 after |
|---|---|---|---|---|
| body-poster messages (whole session) | 35 | 30 (−14 %) | 102 | 91 (−11 %) |
| bails | 2 | 2 | 10 | 10 |
| Skate_Collisions voices / bail | 45.5 (46, 45) | 42.0 (43, 41) | 26.9 | 26.6 |
| three banks (Collisions + Metal + Bodyslide) voices / bail | 52.0 | 48.5 | 29.0 | 28.8 |
| three banks Σg² / bail | 1.895 (1.95, 1.84) | 2.114 (1.74, 2.49) | 0.954 | 0.997 |
The longer cooldown removes 11–14 % of the body messages, far less than the "up to 2×" bound: in our bails few
regions are hit again within 0.25–0.5 s. The voice counts drop slightly. Σg² moves both ways, because the posts that
remain fall on different steps of the impact curve (bail 2 of 084712 now posts on a stronger step: peak 0.715 →
0.976). The bail-density gap against the recomp (≈ 208 Skate_Collisions voices, Σg² 4.7 per bail) stays. Its cause is
the ragdoll's per-step Δv (open items 1–2) and, for a quarter of the floor crossings, the missing |Δv·n| (section
above). The recomp's post counts are inflated by its ~345 fps cadence (cooldown ≈ 43 ms there), so it is not the
reference for this rate either; the console's 0.5 s is.
**Files.** `crates/skate-audio/src/player/contacts.rs`, `crates/skate-audio/src/world/skaters.rs`,
`crates/skate-game/src/game_audio/{player_audio,e2e,npc_skaters}.rs`.
**Open.** The deck-impact poster `sub_824BD000` has the same shape (a 6-frame cooldown counted per process), and the
other Contacts / component processes still run per 60 Hz step. Not changed here (not asked; extending the rule is the
user's call).
### NPC skaters' second grain bed (2026-10-03, `grain::GrainBed`, `Runtime::npc_grains`, `game_audio/{grain_bed,npc_skaters}.rs`)
**Problem.** The NPC skater holding the Player slot's instance 1 (`world::skaters`, inert until an AI-skater system
publishes) ran its routing, but its grain binds were dropped: the runtime had one bed. Most surfaces (asphalt,
concrete, wood, aggregate, metal) are grain surfaces, so the NPC's rolling was silent there.
**Retail's mechanism (TU3 recompilation, reference only).** Every `SFXObj_SkateBoard` owns 2 trucks × 2 GrainPlayers
(owner `+1176 + 8t` / `+1180 + 8t`) and their chains. Its process `sub_824C6A78` (vtable `0x822FC780` +20) runs for an
active record (`[+28]+52`), and its update `sub_824C6BD8` (+24) has no local test, so instance 1's board runs its own
bed. The local tests (`[owner+16]+72`) in the bed's helpers, read one by one:
| part | function | for the NPC instance |
|---|---|---|
| slope inputs 2 / 3 (D, U) | `sub_824CA738` | runs (no local test) |
| routing, soft member | `sub_824C5CA8`, `sub_824C8370` | one pass, no hand-over (already ported in `player::rolling`); soft via `sub_824B23C8` = the local player's inverse |
| push envelopes, input 4, turn intensity | `sub_824C6198` → `sub_824C8588` | run |
| chain values (HPF / LPF / pan / env send / FSS) | `sub_824C9058` | run; the graph-2 FlangeSub sends level(21) / (22) are local only |
| seam-pattern gain envelope | `sub_824CA448` | returns at once (local only) |
| graph-1 → graph-3 send, level ramp | `sub_824CAEC0` | returns at once |
| gain wobbles | `sub_824CB180` (→ `sub_824CB078`) | returns at once: the graph-1 / graph-3 Gains stay at the module default 1 |
| graph 3 | `sub_824C8878` (chain build) | built only for the local player (`lbz 72` before the graph-3 build); the eEQChain create flag is the local byte too |
| records (gain / pitch / position), brake slew, latches | `sub_824C6BD8` | run, from the instance's SkateBoard outputs level(1) / (2), pitch(3) |
| rocket `x_jet_rolling` | SenseOfSpeed `sub_824E7980` | returns for non-local (already noted) |
| pick generator | `sub_82A8AF10`, `0x82FD7D74` | one title-wide generator for every GrainPlayer |
**Change.**
- `GrainBed::local` (true by default): false skips graph 3 (`process_full(…, graph3)`). `GrainBed::share_rng` runs a
closure with another generator swapped in.
- `Runtime::npc_grains: Option<Box<GrainBed>>`: rendered after the local bed on the local bed's generator (retail's
one generator), at the local bed's user gain and `chain_extras`. The env sends of both beds are summed when both
send; its dry mix is added after the local one. `None` = the previous render path exactly.
- `grain_bed::Bed` gains its instance. `Bed::for_instance(g)`: the same tunings and decoded recordings, no rocket.
`write_inputs` writes `SkateBoard(g)` inputs 2 / 3 / 4. `step_with` (the old `step` body, with the runtime handed
in) gates the local-only parts above and targets `npc_grains` (created at the NPC bed's first step; binds draw
through the shared generator). `step` keeps its signature and order for the local player.
- `npc_skaters::frame`: at the claim (native rolling layers on) the held skater gets `Bed::for_instance(1)`. Per
console evaluation: update → the bed step on this evaluation's outputs with the routing's binds of the last
process → `write_inputs` (2 / 3 / 4) → the instance's inputs → process. Push plants = `+335` rises
(`push_trigger`); brake = `+336` (`AudioState::brake`). At the release the bed is dropped and the trucks stop.
Logged as `AUDIO_NPC grain bind instance 1 truck …`.
**Verification.**
- `grain::bed::tests::a_non_local_bed_has_no_graph3_and_shares_the_generator`: without graph 3 the send level is
inert; with it, it adds the copy. At send 0 a built graph 3 adds only its filters' anti-denormal bias (< 1e-12;
retail builds none for the NPC). A bed on a shared generator renders exactly like one owning that state, and its own
generator never moves.
- `npc_skaters::tests::npc_skater_rolls_on_its_own_grain_bed` (real install; the synthetic pass of the existing NPC
test): the second bed binds `concrete_smooth_soft` (local wheels hard → soft member), 2–4 voices while held, record
A gain 0.004 at 29 m → 0.096 at 10 m → 0.009 at 29 m (the instance's MixMap distance roll-off), nothing after the
release, the local bed never bound. In the air before the rail, the fixture's "no material" wheels rebind
`asphalt_smooth_soft` (surface 3): the routing's known UNCERTAIN air behaviour (grain spec §2.10).
- The local player's renders: see "Verification of the 2026-10-03 cadence / NPC-bed stage" below.
**Open.**
- What the released instance's players do in retail (here they stop, as the packets are released): not read.
- Retail's order of the two owners' picks on the shared generator (ours: the local bed first per block). The picks
are random either way; it only matters while an NPC is held.
- The brake slew's direction test (`+336` against the direction of travel) is unported for both beds.
### Verification of the 2026-10-03 cadence / NPC-bed stage (the two sections above)
- `cargo test -p skate-audio --locked`: all pass (lib 220 + the integration tests; new:
`the_body_poster_keeps_the_console_cadence_at_any_frame_rate`, `a_non_local_bed_has_no_graph3_and_shares_the_generator`).
- `cargo test -p skate-game --release --bin skate3rust --locked -- game_audio::`: 55 pass, 4 ignored (new:
`npc_skater_rolls_on_its_own_grain_bed`).
- `cargo build --locked` (dev): OK, only the 3 known warnings (`library.rs` ×2, `prefetch.rs`).
- e2e identity (target `.local/fma-target`; tools `.claude/skills/optimisation/tools/e2e_bench.sh`,
`.local/audio-re/bodycad/render.sh`):
- the optimisation bench, 13 scenarios + 4 sessions, row and `E2E_FPS=300`: `cad_base` (before) = `cad_t1` (body
cadence) = `cad_t3` (+ NPC bed), byte-identical (f32 and voices), and equal to `retire1`;
- the bail sessions 084712 / 101640: `E2E_BODY_CONSOLE=0` = before at 30 / 60 / 144 / 365 fps; after Task 3 = after
Task 1 at 60 and 30 fps (f32, voices and body traces). So the NPC bed leaves the local player's renders untouched.
- Headless only: no game launch, no smoke test, bin\ / data\ untouched. For the user in game: bails (the body poster
now has the console's 0.5 s cooldown at 60+ fps). The NPC bed stays silent until an AI-skater system publishes.
### Region impacts take |Δv·n|; the deck poster on the console cadence (2026-10-03, user-approved follow-up)
The user approved both follow-ups of the sections above: retail's |Δv·n| and the console cadence for the deck poster.
**1. |Δv·n| in `skate_events::region_impacts`.**
- Change: `along = (Δv · n).abs()`, matching the `vandc128` sign mask in `sub_82BD60C8`. A part pulled away from the
surface now counts like one stopped by it, no longer flooring to 0.001.
- Tests:
- `region_impacts_scale_the_velocity_change_by_the_part_mass` pins it: −2 m/s gives 0.2; a tiny step still reads the
0.001 floor.
- `region_impacts_match_the_recomps_bail_lines` (data-gated: the fixed-hook runs t2 / ok / ok2 / ok3) rebuilds every
BAILREG line's Δv along its normal with its mass. Ours equals the written impact within 0.002 on **12,054 / 12,054
lines**, 5,355 of them with Δv·n < 0.
- **The e2e can't show it.** The harness replays the impacts the game logged (`rimp0..5`), and the two bail sessions
were logged with the signed formula. Re-rendered 084712 / 101640 at `E2E_FPS=60` with the new build (and the deck
cadence off): byte-identical to the current state. So the per-bail change (Skate_Collisions voices, Σg², tiers) needs
a new user session with this build.
- What it acts on: in the logs, contact region-frames at the 0.001 floor are 1,035 of 1,237 (084712) and 649 of
1,042 (101640), after the 4-step max. Some of these are negative steps that will now read their |Δv·n|.
- In the recomp, 26 % of the above-floor steps are negative, mostly small: 8 of 166 above 0.1.
- So expect more tier-0 body / cloth posts and few new tier-1 / 2 ones.
**2. The deck-impact poster `sub_824BD000` on the console cadence.**
- It had the body poster's shape: a 6-frame cooldown (vault `27D3C5DC3282B59D`) counted per `process`, so 0.1 s at
60+ fps and 0.2 s at 30 fps. The 4-step deck-impact max (`+668`) is the per-physics-step conditioner: unchanged.
- Change:
- `Contacts::deck_calls` (as `body_calls`); `PlayerAudio::deck_console` from the MixMap cadence's evaluations.
- The NPC instance follows: `NpcSkater::set_deck_calls`.
- Off switches: `SKATE_AEMS_DECK_CONSOLE=0` / `E2E_DECK_CONSOLE=0`.
- Diagnostics: `deck_posts` / `deck_digest`, and e2e writes `<name>.ours.deck.tsv`.
- Proof:
- Unit test `the_deck_poster_keeps_the_console_cadence_at_any_frame_rate`: the same messages on the same steps at
30 / 60 / 144 / 240 / 365 fps; the old cadence differs at 30. A held impact posts on steps 2, 14, 26, 38, 50, i.e.
every 12 steps = 0.2 s.
- The bail sessions at `E2E_FPS` 30 / 60 / 144 / 365: deck traces byte-identical across the four rates; the old
cadence's 30 fps differed.
- Off = before: `E2E_DECK_CONSOLE=0` renders are byte-identical to the current state (at all four rates for the bail
sessions; the bench `cad_dkoff` against `cad_t3`, row and fps300).
- Effect on the bench (`cad_dk` against `cad_t3`):
- Row mode: identical (no console cadence there).
- `E2E_FPS=300`: 5 outputs change, the scenario `ollies_log` and the sessions 213757, 214224, 215843, 224747. The
other 12 scenarios are identical.
- In each changed output the body trace is unchanged and the first differing voice row comes exactly one row after the
first differing deck post (`.local/audio-re/bodycad/deck_attr.py ON OFF`).
- Later rows keep differing: from that post on, the shared Splice picks shift.
| deck messages (`E2E_FPS`=300 bench / 60 bails) | old | console |
|---|---|---|
| ollies_log | 3 | 3 (timing moved) |
| 213757 / 214224 / 215843 / 224747 | 11 / 2 / 18 / 19 | 9 / 2 / 15 / 19 |
| 084712 / 101640 | 6 / 55 | 6 / 44 |
- Bails (`E2E_FPS=60`, against the current state, `bails_cmp.py`):
| | 084712 now | 084712 deck console | 101640 now | 101640 deck console |
|---|---|---|---|---|
| Skate_Collisions voices / bail | 42.0 | 41.0 | 26.6 | 25.7 |
| Skate_Collisions Σg² / bail | 1.926 | 1.600 | 0.982 | 0.970 |
| three banks voices / bail; Σg² / bail | 48.5; 2.114 | 47.5; 1.788 | 28.8; 0.997 | 27.6; 0.982 |
| body / cloth voices, tier 0 / 1 / 2 | 47 / 5 / 0 | 47 / 5 / 0 | 110 / 26 / 2 | 107 / 27 / 4 |
- 084712's bail 2 Σg² drop (2.485 → 1.834) has the same count of deck posts; the post timings moved and so did the
Splice picks after them.
- The tier counts come from `bails_cmp.py`'s top-24 ids per bail; the body traces themselves are unchanged.
**Verification.**
- `cargo test -p skate-audio --locked`: all pass (lib 221).
- `game_audio::`: 56 pass, 4 ignored.
- `cargo build --locked`: OK, with the 3 known warnings.
**Files.** `game_audio/{skate_events,player_audio,e2e,npc_skaters}.rs`, `skate-audio/src/player/contacts.rs`,
`skate-audio/src/world/skaters.rs`.
### Bail density: the missing speed curve, not the ragdoll (2026-10-03, research only; no engine change)
Spec: `.claude/notes/ragdoll-contact-spec.md` (retail mechanism, ours, decision points). Summary:
- **The retail Δv figures above (p99 1.8–13 m/s, max 7.6–20) are not comparable with our bails.** The scripted runs at
the Mega-Park spawn bailed into the gap or slid down the MegaRamp roll-in, so the body hit at 11–23 m/s (screenshots;
e.g. foot v·n −23 → −12 → −1 m/s). In the ordinary-height tumbles of run `bailx3_20261003_110208` (first 2 s of each
bail) retail's |Δv·n| is p90 2.3, max 3.7 m/s, approach speeds ≥ −4.3 m/s: the size of ours. The ragdoll solve
(Horus solver, 50 iterations, restitution 0, ragdoll friction 0.9, angular drag 0.5 × 60, ragdoll masses) is ported in
ours from the same code and data; no difference was found in the contact response.
- **What is missing: the audio bridge's speed curve.** `sub_824B0DA8`, after copying the conditioner's 8 region impacts
into the audio state (`+496..`), multiplies each by an 8-point graph (`sub_82481E10`) of the previous frame's |COM v|
(`+216` ← `+212`): x 0 / 0.366 / 0.448 / 0.521 / 0.593 / 0.741 / 0.863 / 0.946, y 1.0 / 1.2 / 1.486 / 1.914 / 2.429 /
3.6 / 4.571 / 5.0 (read from the running game with a watch on `[[[0x830CFDA4]+36]+4]`). New recomp hook `BANDQ` on
`sub_82497088` (impact, tier, band edges per query) shows the body poster's impact = 5.0 × the clamped region impact
(ratio p50 5.0 over 218 posts) and band edges equal to our exported table. Ours skips the curve, so our body impacts
are 1/5 of retail's at the poster: no tier 1 / 2, and concrete 1047 / 991 (> 1.0 / 1.85) unreachable — this also
answers the open question "which impact posts concrete tier 1 / 2" above.
- **Not changed** (needs the user's go): port the curve in `skate_events` (audio only; skate-core untouched), together
with the |Δv·n| abs and the body poster's 30 fps cadence; then re-measure bail density against the recomp.
*Ported 2026-10-03 (user: "Port it"): see "The bridge's speed curve on the body impacts" below.*
- Script: `bail_ledge.txt` now uses `lt rt l3 r3` (6 / 6 bails; user tip), with an X jump + mid-air `l3 r3 lt rt` fallback.
### The bridge's speed curve on the body impacts (2026-10-03, user decision "Port it"; `player::contacts`, `skate_events`)
The user decided (2026-10-03): port retail's speed curve on the body impacts ("Port it"); the ragdoll physics item is
closed with no physics change.
**Listening before the curve** (user, session `state_20261003_110725`, build with |Δv·n| and the body / deck posters on
the console cadence, no curve): "bails sounded good, a bit soft like you said. Deck hits sound really good and seem to
be either equal to retail or almost perfect to retail." The deck hits are approved, so the curve must leave the deck
posts alone (shown below).
**Retail's mechanism (TU3 recompilation, reference only).**
- The audio-state bridge `sub_824B0DA8` copies the skater entry's 116-byte block `+320` into the audio state `+496`.
This holds the conditioner's 8 region impacts, already clamped to [0, 1] by `sub_82BD60C8`.
- Then, for i = 0..7: `+496 + 4i` = `fmuls`(graph(`+216`), `+496 + 4i`). The graph is `sub_82481E10(8, rec + 16, rec + 48, v)`
with rec = `[[0x830CFDA4] + 36] + 4`.
- **No clamp after the multiply.** The poster sees up to 5.0. The recomp's BANDQ lines agree: the body poster's queries
(callers `824BC4C8` / `824BC530`) reach 4.11 (bandq run) and 4.88 (bailx3 run), concrete (material 2) up to 2.72 at
tier 2. The deck poster's queries (`824BD090` / `824BD0C4`) stay ≤ 1.0.
- The deck impact `+668` lies outside the multiplied range (`+496..+524`).
- **Which frame.** `+212` is written earlier in the same call from the skater entry's `+108`. `+216` is read by the
multiply, then set from `+212` after the loop. So the graph sees the *previous* bridge call's `+212`.
- **Units.** The entry's `+108` is written by `sub_827A1B78`: the length (`vmsum3fp128` + `vrsqrtefp128` with two
Newton steps) of the vector at `[r30 + 36] + 16`. That is SystemReckoning `+16`, the COM velocity, in m/s. It is the
same quantity as our `AudioState::com_speed()` (the footsteps' walk curve reads it too).
- The BANDQ floor queries agree with m/s. A lying body posts 0.00101–0.00116, i.e. ×1.0–1.16, and the graph gives that
below 0.37 m/s.
- **Interpolation** (`sub_82481E10`, already ported for the footsteps' 16-point curves):
- below x0 → y0; from x7 on, or for an unordered input → y7;
- in between, the first i with v < x[i]: slope first, then `fmadds`;
- y[i] where two x coincide.
- **Data: the install's own vault.** The words read from the running game equal the stock vault's class
`6EBA5BCD3E38A98A` `default` field `8B164823E008749C` (a `Sk8::PointNegGraphData8`, the same collection as the body
cooldown and the deck cooldown). Header 0 / 1 / 1 / 5, x at +16, y at +48:
- x 0, 0.3664494, 0.4478826, 0.5211726, 0.592834, 0.7410426, 0.863192, 0.946254;
- y 1.0, 1.2, 1.485714, 1.914286, 2.428571, 3.6, 4.571427, 5.0.
**Change.**
- `skate_audio::player::contacts::SpeedGraph8`: the 8-point graph and its evaluator. `ContactsTuning::body_speed_curve`
defaults to the vault words (`SpeedGraph8::BODY_SPEED`, bit patterns).
- Setup exports the graph: `audio_export.collision_tuning` posters `body_speed_x` / `body_speed_y`. `library.rs`
prefers them when present. Installs set up before today use the identical default.
- `AudioState::com_speed_216`: retail's `+216`. `skate_events::observe` sets it per physics tick to last tick's
`com_speed()` (`Seen::com_speed_212`).
- `AudioState::body_impact` stays the conditioner's (pre-graph) value. `Contacts::body` multiplies each region's
impact by `body_speed_curve.eval(com_speed_216)` (f32, no clamp). The cloth and pad contacts read the same product,
as retail's poster reads `+496`.
- Switch `Contacts::body_speed_on`: `SKATE_AEMS_BODY_CURVE=0` / `E2E_BODY_CURVE=0` turn it off, giving the impacts as
logged. The NPC instance's Contacts leaves it off. Its state has no `+216` (0 → ×1.0) and no ragdoll impacts yet.
- The deck poster, the 4-step region max and `region_impacts` are unchanged.
- **Replays.** The state log gains a `com_speed` column (`+212`, appended), and `scenarios.py` (both copies) carries
`com_speed` and `com_x/y/z` into cut scenarios. The e2e harness computes each row's `+216` from the previous row:
- from `com_speed` when the log has it;
- else (logs before today) |Δ COM position| × 60 from `com_x/y/z`, the reckoning's followed point at 4 decimals
(±0.006 m/s). The graph saturates from 0.95 m/s, so this only matters for slow bodies. A respawn jump reads as fast
(×5, as any speed > 0.95);
- else 0 (×1).
So the old bail sessions render with the curve.
- Diagnostics: `E2E_BODY_LOG=1` writes `<name>.ours.bodymsg.tsv` (every body message with its row, region, the impact
the poster read, `+216`, materials, tiers, levels). `Contacts::body_log` has no effect on the output.
**Tests.**
- `the_bridge_speed_graph_is_the_vault_curve_piecewise_linear_and_clamped` checks the knots, both clamps, NaN, the
midpoint, and is bit-exact against the slope-first `mul_add`.
- `the_speed_graph_scales_the_body_impacts_not_the_deck`: at rest (×1) a 0.3 arm hit on concrete is arm tier 1 /
concrete tier 0. Moving (×5) it is 1.5, i.e. arm tier 2 / concrete tier 1 (1047). 0.4 → 2.0 gives concrete tier 2
(991). Off equals at rest. 0.0011 × 5 crosses the arm floor. The deck messages are identical with the graph on and off.
- `the_body_speed_graph_is_the_stock_vault_record` (`library.rs`, data-gated on the converted `skater-collections.json`):
exported posters win; the default equals the vault record word for word.
**Frame-rate independence and identity** (`.local/audio-re/bodycurve/`: `render.sh`, `bails.py`, `body_attr.py`;
inputs re-cut with `scenarios.py --cut r0-999999`, 0 malformed).
- Body traces with the curve are byte-identical at `E2E_FPS` 30 / 60 / 144 / 365 for all three sessions.
- `E2E_BODY_CURVE=0` renders (f32 and voices) are byte-identical to the previous state's (`bodycad/dk`) at all four rates
for 084712 / 101640. The new COM columns are inert when the curve is off.
- **Deck posts unchanged:** the deck traces (count + FNV digest of every message) are identical with the curve on and
off in all three sessions, at every rate.
- In each session the first differing voice row is the row of the first differing body post (084712 row 1101, 101640
row 2427, 110725 row 1963, all in a bail, state 300). Before it, the voices are identical.
- After that the voices keep differing, outside the bails too: the shared Splice picks shift, as with the deck cadence
change. The deck hits' messages (timing, materials, tiers, levels) stay the same. Only the random variant a hit draws
may differ after the first bail.
- **Bench** (13 scenarios + 4 sessions, row and `E2E_FPS=300`, `opt/runs/curve` against `cad_dk`): IDENTICAL. Those
inputs carry no region impacts and no COM columns, so they cannot change. The graph acts only on body posts, and it
scales them only while the body moves (> 0 m/s): ×1.2 at 0.37 m/s, ×5 from 0.95 m/s.
**Per bail** (`E2E_FPS=60`; posts = message sides with a tier, each plays one collision id; window = bail rise − 0.5 s …
bail clear, as the recomp's 094336 summary; voices / Σg² = `bails_cmp.py`, −0.5…+2.5 s):
| per bail | the recomp (094336, 10 bails) | 084712 off → on (2) | 101640 off → on (10) | 110725 off → on (7) |
|---|---|---|---|---|
| body posts | 71 | 15.0 → 24.5 | 7.8 → 10.9 | 15.1 → 20.0 |
| tier 0 / 1 / 2 | ≈ 49 / 13 / 9 | 14.0 / 1.0 / 0 → 19.5 / 3.5 / 1.5 | 6.5 / 1.1 / 0.2 → 6.7 / 2.9 / 1.3 | 12.7 / 1.7 / 0.7 → 12.3 / 5.4 / 2.3 |
| tier-2 1030 / 1031 / 1032 | 1.8 / 3.3 / 2.1 | 0 / 0 / 0 → 0 / 0.5 / 0.5 | 0 / 0 / 0 → 0.1 / 0.6 / 0.3 | 0 / 0.1 / 0 → 0.3 / 0.9 / 0.3 |
| torso 951 | 1.0 | 0 → 0.5 | 0.2 → 0.2 | 0.3 → 0.4 |
| concrete 958 / 1047 / 991 | 3.4 / 2.3 / 0.9 | 1.0 / 0 / 0 → 2.0 / 0.5 / 0 | 0.1 / 0 / 0 → 0.5 / 0 / 0 | 0.6 / 0 / 0 → 1.9 / 0.4 / 0 |
| Skate_Collisions voices (recomp: 38 bails, nine sessions) | 207.7 | 41.0 → 59.5 | 25.7 → 30.6 | 35.6 → 43.7 |
| three banks Σg² | ≈ 5.1 | 1.79 → 2.52 | 0.98 → 1.03 | 1.58 → 1.78 |
| peak impact the poster read (max over the bails) | 4.9 (bailx3) | 0.21 → 1.03 | 0.32 → 1.59 | 1.00 → 5.00 |
- What changed: the tier-1 / 2 hits and concrete 1047 now occur. The posts per bail rise 30–60 %: floor contacts
0.001 × 5 = 0.005 pass the arm floor, and more regions clear their bands.
- Concrete 991 (> 1.85, i.e. a region impact > 0.37 while moving) still doesn't occur in our bails.
- The gap to the recomp's 71 posts stays, but the recomp is not the reference for counts. Its ~345 fps cadence makes
the 15-frame cooldown ≈ 43 ms (0.5 s on the console). With 6 regions and a 0.5 s cooldown, our poster can't post
more than ~12 groups a second.
- Σg² rises less than the counts. A tier-2 hit's level comes from its own window, and many of the new posts are
quiet tier-0 floor hits.
- 110725's last bail (155.9 s, 4.5 s long) has no region impacts at all (`rimp*` 0 throughout). Not investigated.
**|Δv·n| against the older sessions** (110725 was recorded with |Δv·n|, 084712 / 101640 without; from the logs):
| | 084712 | 101640 | 110725 |
|---|---|---|---|
| contact region-frames at the 0.001 floor | 84 % (1,035 / 1,237) | 62 % (649 / 1,042) | 76 % (1,950 / 2,554) |
| above-floor region-frames / bail | 101 | 39 | 86 |
| region-frames > 0.1 / > 0.25 | 12 / 0 | 68 / 16 | 71 / 29 |
| body posts / bail, no curve | 15.0 | 7.8 | 15.1 |
- No session-level signature of the abs. The bails differ more between sessions than the expected effect: 26 % of
above-floor steps in the recomp, mostly small, 8 of 166 above 0.1.
- The abs can't be isolated without the same bail logged both ways (the replay reads the logged impacts). 110725's
many hits above 0.25 are its larger falls (peak 1.0).
**Files.** `crates/skate-audio/src/player/{contacts,state}.rs`,
`crates/skate-game/src/game_audio/{skate_events,state_log,e2e,player_audio,library}.rs`,
`tools/asset_pipeline/audio_export.py`, `tools/audio-e2e/scenarios.py`.
**Open.**
- The NPC instance gets no `+216` (its state comes from `world::skaters`, and no ragdoll impacts are published).
- Old logs use the COM-position fallback. Sessions from this build log `com_speed`.
- For the user in game: bails should now have more tier-1 / 2 body hits and concrete tier 1 (louder, punchier). Deck
hits are unchanged.
### The turn intensity after release; trace / comment leftovers (2026-10-03)
**Turn intensity "linger": no change. The recomp's 50 ms is its frame rate.**
- Retail's `sub_824C8588` moves the signed I toward the raw target by the grain collection's step per call: `+1160` ±
the primary truck's rise / fall step (the vault field lookup `sub_82B72420`; 0.06, 0.1 on one surface). There is no dt.
- The local GREC rows of `all_20261002_180430` (filtered by `filter_local.py`; 1,874 calls where the input reads 0 and
I > 0.07) step by exactly 0.06 per call (1,832; 0.1 on 36), at 2.8 ms per call (the recomp's ~345 fps).
- So the recomp's I is back to 0 in ~10 calls ≈ 30 ms. That is the "0 by 50 ms" figure, a recomp artefact. On the
30 fps console the same 10 steps take ~333 ms.
- Ours: `TurnIntensity::step` with `frames = dt × 60` takes 0.06 per 1/60 s.
- In 110725 / 101640 our input (the animation's Turn) falls from ≥ 0.9 to 0 in 3 frames (p50; p90 4–5), like
retail's 34–50 ms.
- Our I then reaches 0 in 8 frames (p50; p90 9–10, simulated from the log at cap 0.6): the "~13 frames" from the
release start.
- That is already half the console's time, not a linger.
- The retail mechanism on the console-cadence rule would be 0.06 per console frame (`frames = dt × 30`; the brake slew
`BRAKE_STEP` has the same shape). That doubles our fall time to ≈ 330 ms and changes the rolling bed in every riding
scenario. Not changed: it is the opposite of the reported symptom and needs the user's call.
**Decided 2026-10-03 ("lets go for like retail"): see "The turn and brake slews on the console cadence" below.**
**Leftovers.**
- `skate_events.rs`: the wheel-material comment now says 0.45 changes / s (local rider only).
- The published `tools/recomp-trace/trace.py` FIELD_COUNTS gains BAILLOCAL, BAILSTEP, BAILREG, BAILCAND, COLLPOST,
BANDQ and LOCALTEST (scrubbed comments).
**Verification (both sections).**
- `cargo test -p skate-audio --locked`: all pass (lib 223).
- `cargo test -p skate-game --release --bin skate3rust --locked -- game_audio::`: 57 pass, 4 ignored.
- `cargo build --locked`: OK, with the 3 known warnings.
- Headless only (target `.local/fma-target`); bin\ and data\ untouched.
**Listening (user, 2026-10-03, bin\ 11:34 with the bail speed curve; session `state_20261003_113953`, 0 malformed):** "Sounds so much better!"
### The turn and brake slews on the console cadence (2026-10-03, user decision "on the decision lets go for like retail"; `grain::board::OwnerSlews`, `game_audio/grain_bed.rs`)
The user decided on the open item of "The turn intensity after release" above: "on the decision lets go for like
retail".
**Retail's mechanism (TU3 recompilation, reference only).**
- The SkateBoard process `sub_824C6A78` (vtable `0x822FC780`, called with the frame's dt in f1) runs, in order:
slope `sub_824CA738`, routing `sub_824C5CA8`, the owner modulators `sub_824C6198` (dt passed on), then
`sub_824C9058`, `sub_824C7438`, `sub_824C7738`, `sub_824C9F68` and the seam envelope `sub_824CA448` (dt).
- In `sub_824C6198`, everything that uses time takes dt: the push speed-scale and shift envelopes (`+912` / `+1036`)
advance through `sub_8248D510` with f1 = dt.
- Two values move by a fixed step per call with **no dt**:
- **Turn intensity** `sub_824C8588` (the call at `0x824C68F4`; the result is stored as `+1164`): the signed `+1160`
moves toward the raw target by the primary truck's rise or fall step from the vault (`sub_82B72420`; default
word at `0x830D0850`), with `fadds` / `fsubs`, then `stfs +1160`.
- **Brake** `+1168`, inline after it. While braking (state `+336`), the cosine of the two velocity vectors
(state `+128`, `+96`) is checked: below 0, slew toward 1 (f31); otherwise toward 0 (f27). Without braking, slew
toward 0. The step is the `lfs` at `0x82165A00` (0.05, grain-player-spec §2.7). The value snaps to the target
once within one step.
- The function has no other per-call slews. The manual latch (`+1504`, inline in `sub_824C8588`) and the trick latch
(`sub_824CA6E0`) are set / clear latches, not steps. They stay per rendered frame here: they are idempotent on a
repeated state, and sampling them at 30 Hz would drop one-step pulses (the reason the MixMap flags needed
`hold_input`).
- **Call site cadence.** The process runs once per call of the audio manager's half (`sub_82485190`, "The MixMap on
the console cadence"). On the ~30 fps console both halves run on every frame, so: one step per console frame. A
full release from the cap 0.6 to 0 takes 10 calls (f32 leaves 7.45e-9, which the 11th call removes) = 333 ms. A
full brake release 1 → 0 takes 20 calls = 667 ms.
- Ours stepped by 0.06 / 0.05 × (dt × 60), i.e. per 1/60 s: twice as fast (167 ms / 333 ms). The NPC bed ran at
`dt = CONSOLE_DT × evaluations`, so its step was also 0.12 per evaluation.
**Change.**
- `skate_audio::grain::board::OwnerSlews { turn, brake }` and `board::BRAKE_STEP` (moved from `grain_bed.rs`).
`OwnerSlews::step(tuning, com_speed, turn, special, braking, calls, frames)`:
- `calls = Some(n)`: n retail calls (each step × 1);
- `None`: once, with the steps × `frames`, the old code path.
- `grain_bed::Bed`:
- `slews` replaces `turn` / `brake`.
- `slew_calls` is set by the host before each step and taken by it: the console evaluations of this pass from
`mixmap::cadence`.
- `slew_console` (`SKATE_AEMS_SLEW_CONSOLE=0` off) is copied to the NPC beds.
- Hosts:
- `native::mixmap_frame` sets `slew_calls = Some(0)` on every frame (also frames without a 60 Hz tick) and
`Some(calls)` on frames that tick. With `SKATE_AEMS_MIX_CONSOLE=0` it sets `None`.
- The NPC host sets `Some(evaluations)`.
- e2e: `E2E_SLEW_CONSOLE=0` off. The cadence follows `E2E_MIX_CONSOLE` (on with `E2E_FPS`, off in the per-row
renders).
- Between console frames the bed keeps feeding the records the held |I| and Bk.
- Diagnostics: `E2E_SLEW_LOG=1` writes `<name>.ours.slew.tsv` per call (turn input, |COM v|, I, signed I, braking,
Bk). It has no effect on the output.
**Proofs** (tools `.local/audio-re/slewcad/`: `render.sh`, `compare.py`, `localise.py`, `release.py`).
- Inputs: the sessions 084712, 101640, 110725, **113953** (cut with `scenarios.py --cut r0-999999`; 5,484 rows =
lines − 1, so 0 malformed) and the bench's 213757, 214224, 215843, 224747.
- **Off = byte-identical.** `E2E_SLEW_CONSOLE=0` renders (f32, voices, body, deck) equal the pre-change tree's at
`E2E_FPS` 30 / 60 / 144 / 365 for all eight sessions. `E2E_SLEW_LOG=1` was on in those renders.
- **Frame-rate independence.**
- Unit test `owner_slews_are_the_same_at_any_frame_rate`: a host that runs once per rendered frame with the newest
60 Hz state and the cadence's calls gives bit-identical I / Bk on every console frame at 30 / 60 / 144 / 240 / 365
fps. The old per-frame host differs between 30 and 60.
- e2e: the slew logs' values on every evaluation are identical at 30 / 60 / 144 / 365 for all eight sessions.
- **Step count.** Unit test `owner_slews_take_one_step_per_console_frame`:
- one 0.06 / 0.05 step on each console frame, none on the steps between;
- turn 0.6 → < 1e-6 after 10 console frames (20 steps), exactly 0 after 11;
- brake 1 → 0 after 20 (40 steps);
- the old host takes 11 steps;
- without a primary truck the brake still slews and the turn holds (reads 0).
- **What changes (on vs off, `E2E_FPS=60`).**
- Only grain-bed records differ: grain0/1 A/B gain, pitch and position. No other bank's voices differ in any
session.
- Every differing voice frame lies within 0.5 s of a call where I or Bk differ.
- The first differing voice frame is at or after the first slew difference.
- Per session:
| session | calls where I / Bk differ | voice frames that differ |
|---|---|---|
| 084712 | 615 / 0 of 3,384 | 17.9 % |
| 101640 | 2,905 / 104 of 14,982 | 19.6 % |
| 110725 | 1,788 / 0 of 9,804 | 17.5 % |
| 113953 | 646 / 0 of 5,544 | 11.6 % |
| 213757 | 565 / 0 of 2,838 | 19.7 % |
| 214224 | 137 / 0 of 1,746 | 7.6 % |
| 215843 | 844 / 0 of 11,712 | 6.9 % |
| 224747 | 1,893 / 1 of 8,208 | 22.6 % |
- **Bench** (13 scenarios + 4 sessions; `opt/runs/slew_on` against `slew_base`):
- row mode: IDENTICAL (the per-row renders keep the old cadence);
- `E2E_SLEW_CONSOLE=0` (`slew_off`): IDENTICAL in both modes;
- fps300: brake20, carve20 and ollies_log change (the scenarios with braking or turning) and the 4 sessions. The
other 10 scenarios are identical (roll10/20/30/45, grind_ledge / metal, manual15, ollie20, slide20, sweep).
**Before / after release times** (`release.py`; a turn release = a continuous fall of I from a peak to 0, from the last
row at the peak; the eight sessions pooled; `E2E_FPS=60`, the same at 30):
| | before (off) | after (on) |
|---|---|---|
| turn, peak ≥ 0.5 | n 193, p50 217 ms, p90 400 ms | n 61, p50 367 ms, p90 800 ms |
| turn, peak ≥ 0.3 | n 320, p50 167 ms, p90 300 ms | n 168, p50 267 ms, p90 500 ms |
| brake (sessions, n 2–3) | 333 ms | 517–533 ms |
| brake20 scenario | 1 → 0 in 333 ms, 0 → 1 in 333 ms | 667 ms each (the scenario ends at 0.25, 30 rows after the release) |
| turn from the cap 0.6, input to 0 (theory) | 11 steps = 183 ms | 10 console frames = 333 ms (+ 1 for the f32 remainder) |
- Fewer releases reach 0 after: with the slower fall the rider often turns again before I is back at 0.
- The long tails (≈ 1–5 s) are slow input ramps, not the slew.
**Files.** `crates/skate-audio/src/grain/board.rs`,
`crates/skate-game/src/game_audio/{grain_bed,native,npc_skaters,e2e}.rs`.
**Verification.**
- `cargo test -p skate-audio --locked`: all pass (lib 225, 2 new).
- `cargo test -p skate-game --release --bin skate3rust --locked -- game_audio::`: 57 pass, 4 ignored.
- `cargo build --locked`: OK, with the 3 known warnings.
- Headless only (target `.local/fma-target`); bin\ and data\ untouched.
**For the user in game.** After a carve the turning layer (grain player B, the turn gain) fades out over about a third
of a second instead of a sixth. The brake layer fades in and out over 2/3 s instead of 1/3 s. Straight rolling, tricks,
bails and grinds are unchanged.
### Concrete ledges grinded as metal: the grind material is the tag − 1 (2026-10-03, `skate_events`, `e2e`)
**Report (user, 2026-10-03, session `state_20261003_115334`, 0 malformed):** "the last area I was in, the concrete
sounded like metal where I was grinding". The spot is on University, around board (258, 64, −257) / (198, 64, −207) /
(145, 64, −168).
**Cause.** Our grind material skipped the tag − 1 step.
- The engine's grind audio surface is the 7-bit collision tag (Grinds+216 = probe surface & 0x7F), the same kind of
value as the wheel tags.
- Retail's packer `sub_827A1B78` writes every material into the record as tag − 1 (0 or out of 0..143 → 143): the
wheels at `+464..+476`, and the grind at `+512`. The bridge `sub_824B0DA8` copies `+512` to the audio state's `+692`.
The grind start `sub_824BB0E0` reads `+692` as material B (143 → 10), and Class_grind / the on / off sounds look it
up in the AudioSurfaceMap.
- Ours used the tag directly (`skate_events::audio_state`), so every grind read the next entry of the surface map:
- tag 66 (concrete: material 65, rolling `concrete_rough`, grind surface 1) read entry 66, which is grind surface 4
(metal: Skate_Metal on / off, the metal GRINDS layer);
- tag 5 (material 4, grind surface 0) read entry 5 (surface 8, metal);
- tag 3 read surface 0 instead of 1. Tag 16 (a rail) gave surface 6 either way.
- The map's tag is right. Rolling over the same ledges reads tag 66 too, and the recomp's material at the same
positions is 65.
**Evidence (the recomp, `audiox_grind_20261003_120315`, 0 malformed, local rider `local72` = 1).** The user rode the
same route from the PCU library to the spot. Grind start COLLPOST `824BB0E0` material B (= `+692`) and position, against
our grind tags at the same places:
| retail material B | positions (x, z) | our tag there | GRINDS samples, retail | Skate_Metal voices, retail |
|---|---|---|---|---|
| 65 | (255, −257), (198, −206), (150, −213), (259, −239…−189) | 66 | 13–23 | 0 in 17 grinds |
| 2 | (149, −390), (266, −246), (230, −232) | 3 | 13–23 | 0 |
| 4 | (175, −116) | 5 | 4–8, 14 | 0 |
| 15 | (237, −413), (84, −403) | 16 | 55–65 | 5–17 per grind |
Every retail value is our tag − 1. Retail also never reads 66 or 3 here.
Our replay of the session's tag-66 grinds (e2e, `E2E_FPS=60`):
- before: GRINDS 55–65 (the metal layer) plus 3–11 Skate_Metal voices per grind;
- after: GRINDS 13–23, no Skate_Metal, as retail;
- tag 16 rails: unchanged (GRINDS 55–65 plus Skate_Metal).
**Change.**
- `skate_events::audio_state`: `grind_material = material_of_tag(Grinds+216)`, as the wheels, the deck and the feet
already do.
- The state log keeps the engine's tag in `grind_tag`, so the logs don't change. `e2e` converts it the same way.
- `AudioState::grind_material` doc updated.
- No off switch: this is the retail mechanism, and the old path read the wrong table entry.
**Bench** (`.local/audio-re/grindmat/bench.sh`: the 13 scenarios plus 5 sessions incl. 115334, before / after):
- Changed: grind_ledge, grind_metal, real_115334, real_215843, real_224747. These are exactly the inputs with grinds.
- All other outputs are byte-identical.
- In the changed ones, nothing differs before the first grind. Within 1 s after a grind, the differences are the
grind's own voices.
- Later differences are Splice voices only (Skate_Collisions / sk8_foley member picks). The shared CRT rand stream
moves when a grind's on / off record has a different member count. No AEMS or grain voice differs after a grind.
- The bench's `grind_metal` scenario (tag 9) used to render a concrete grind (entry 9 = surface 1). It now plays
metal (entry 8 = surface 4), as `player_audio`'s tests (which already used `material_of_tag(9)`) intended.
**Verification.**
- `cargo test -p skate-audio --locked`: all pass (lib 225).
- game_audio tests: 57 pass, 4 ignored.
- `cargo build --locked`: OK, with the 3 known warnings.
- Headless only (`.local/fma-target`); bin\ and data\ untouched.
- Tools: `.local/audio-re/grindmat/{bench.sh,ours.py,retail.py}`.
**For the user in game.** Concrete ledges and curbs (e.g. that University spot) grind with the concrete GRINDS layer
and the Skate_Collisions on / off sounds, no metal ring. Metal rails still ring. Some other concrete / stone grinds
change surface too (tag 3: surface 0 → 1; tag 5 was metal, now concrete).
**Listening (user, 2026-10-03, couch launcher, bin\ with the turn / brake console cadence and the grind tag - 1 fix; session `state_20261003_135706`, 0 malformed; asked about the University concrete ledge, a metal rail, carving, braking, enclosed spots (reverb) and fast riding above 46 km/h):** "all of it sounded really good"
### Riding body-collision posts settled (2026-10-03, recomp session `audiox_ride_20261003_141046`, 0 malformed)
User ride (~4.5 min, 2 bails). Outside the bails (+3 s): body-poster posts from NPC skaters 163 (0.70/s, local72 0), from the local rider 49 (0.21/s), and the local ones are only three bursts, no steady riding posts: 45.9 s (4), 172.7 s (19; screenshot: landing a 7.5 ft stair gap) and 202.6 s (26; pushing into a parked car, vehicle material 36). So steady riding gives 0 local body posts in retail, as in ours; the earlier ~1.7/s was NPC skaters plus such events. Follow-up: check that ours posts body hits on a big-drop landing and on bumping a car (a state-log session with both).