- Ped speech plays: speech manager -> library -> the living world's two streams, with queue / interrupts and the near / far lines by distance; levels and filters from the retail mechanism (filters equal to the recomp's, gain median within 0.5 %). Silent and logged when the opt-in speech decode is absent. - NPC skater: wheels and clothing on its own instance and a bail grunt; tricks, treatment and footsteps stay off for NPCs as in retail. - Per-model ped data (shoe class, voice, far threshold) and traffic engine records per model exported by setup; traffic relative-speed input and front / rear sound points; world hosts process before the mixer tick. - Test mod: opt-in (off by default), higher world-object limits (48 per mod, 128 total), a per-second summary log. - Specs published in docs/hails-additions/audio-specs/; code comments point at them; test data paths behind env vars (no .local defaults).
42 KiB
Skate 3 granular rolling bed (grains.big + GrainPlayer): behavioural spec for the native port
Written 2026-10-02. This describes behaviour, formulas and constants in our own words so the rolling bed can be
re-implemented natively in Rust (Phase B of the native-port plan (doc 11, "Native audio port")). It replaces the
current approximation (each .grain cut into 6 looped speed bands, tools/asset_pipeline/audio_export.py
GRAIN_BANDS, crates/skate-game/src/game_audio/cues.rs::grain_for).
Nothing here is code from PR #4/PR #1 or the recomp (no licence: read for understanding only). Constants, offsets,
addresses and vault hashes are facts and are quoted. Companion specs: aems-voice-graph-spec.md (modules, buses, block
clock), aems-evaluator-spec.md (patch programs, global RNG), aems-reference.md, mixmap-spec.md (MixMap; being
written in parallel — the MixMap output ids used here are listed in §2.9 so the two can be joined).
0. Confidence tags
- [V] verified by us against data (disc, vault, image) or a retail trace in this session;
- [R] read by us in the recomp's generated C++ (
skate3recomp/generated/skate3_recomp.*.cpp, TU3 addresses); - [C] PR #4 states it was confirmed against its retail memory capture (bit-exact object dumps, timers);
- [T] PR #4 transcribed it from the lifted code, not compared; we have not re-read it;
- [G] reproduced by our golden-vector run of PR #4's ported functions in the PoC (§5.1);
- [I] our inference. UNCERTAIN = a port must not treat it as settled.
All guest structures are big-endian; addresses are TU 3.0.3.0.
1. Data
1.1 grains.big [V]
EB BIG v3 archive, 3,446,976 bytes, 14 uncompressed .grain members (survey: grain_survey.json,
script tools/audio-file-inspect/grain_survey.py):
| member | H | duration (s) | rate | samples | seek row 0 (bytes, side, samples, key) | side bytes |
|---|---|---|---|---|---|---|
| asphalt_rough_hard | 160 | 19.503 | 44100 | 860080 | 232349, 118, 860080, 1 | 128 |
| asphalt_rough_soft | 176 | 19.919 | 48000 | 956100 | 259515, 131, 956100, 1 | 144 |
| asphalt_smooth_hard | 176 | 21.968 | 44100 | 968807 | 284227, 143, 968807, 1 | 144 |
| asphalt_smooth_soft | 176 | 21.842 | 44100 | 963222 | 280983, 142, 963222, 1 | 144 |
| concrete_aggregate_hard | 176 | 21.117 | 48000 | 1013604 | 277250, 140, 1013604, 1 | 144 |
| concrete_aggregate_soft | 176 | 19.470 | 48000 | 934573 | 263906, 133, 934573, 1 | 144 |
| concrete_rough_hard | 176 | 21.842 | 44100 | 963228 | 259879, 131, 963228, 1 | 144 |
| concrete_rough_soft | 160 | 20.070 | 44100 | 885096 | 250225, 127, 885096, 1 | 128 |
| concrete_smooth_hard | 176 | 21.090 | 44100 | 930067 | 254494, 129, 930067, 1 | 144 |
| concrete_smooth_soft | 160 | 20.324 | 44100 | 896308 | 251178, 127, 896308, 1 | 128 |
| metal_smooth_hard | 176 | 21.870 | 48000 | 1049760 | 277104, 140, 1049760, 1 | 144 |
| wood_ramp_hard | 144 | 19.053 | 44100 | 840219 | 219096, 111, 840219, 1 | 112 |
| wood_ramp_soft | 144 | 17.617 | 44100 | 776894 | 210104, 107, 776894, 1 | 112 |
| x_jet_rolling | 112 | 12.152 | 48000 | 583305 | 123424, 65, 583305, 1 | 80 |
There is no metal_smooth_soft and no wood_ply member, although the vault has a wood_ply_soft.grain collection
(key 1258126B657708F0) that no code path selects [V] — a cut asset.
1.2 .grain layout [V, field roles C/T]
| offset | type | meaning |
|---|---|---|
| +0 | u32 | H = header length = offset of the EAAC stream (112..176) |
| +4 | f32 | duration in seconds. The player uses this stored float, not samples/rate: it equals samples/rate rounded to single in 11 members and is 1–3 ulps off in asphalt_rough_soft (1), concrete_aggregate_soft (1), concrete_aggregate_hard (3) |
| +8 .. H | seek table (§1.3) | |
| +H | one EA Audio Core stream: header version 0, codec 3 (EA-XMA), mono, 44100 or 48000 Hz, type 0 (RAM), no loop flag, sample count, then one block spanning the rest of the member |
Each recording is one continuous roll from slow to fast. Measured (20 equal bins over each file, vgmstream decode,
grain_survey.py --rms) [V]: level rises monotonically by 9–12 dB from start to end (e.g. concrete_rough_hard
−22.8 → −13.6 dBFS RMS, asphalt_smooth_hard −25.3 → −12.9, x_jet_rolling −34.2 → −18.9) and the spectral centroid rises
(e.g. concrete_smooth_hard 1.08 → 2.0 kHz, wood_ramp_hard 0.42 → 1.48 kHz). The first ~10 % of most files is
near-stationary slow rolling. The "read position" therefore is the speed axis.
1.3 Seek table (same format as the .sek files of wheels.big) [V layout, T semantics]
Header, 8 bytes, identical in every grain: 00 10 0180 00000018:
| byte | meaning |
|---|---|
| 0 | kind: 0 = run-length columns (all grains); 1 = another table kind (unused here); ≥2 = seek fails |
| 1 | high nibble = column layout (1 for all grains; 0 = another layout, unused); low nibble = a 4-bit value copied into the seek result (0 here) |
| 2–3 | u16 pre-roll in samples = 384 (decode this much before the target and discard it) |
| 4–7 | u32 offset of per-entry side data from the table start (0x18 = 24), or 0 |
From byte 8: four interleaved run-length columns read row by row through one shared byte cursor: column 0 = byte step of the entry in the EAAC stream, column 1 = side-data step, column 2 = samples in the entry, column 3 = 1 on a key (restartable) entry. A row whose sample count is negative ends the table.
- Column coding. Each column alternates run headers and deltas, all signed varints. A header h ≥ 0 means "the next delta applies to each of the next h+1 rows"; h < 0 means "each of the next 1−h rows reads its own delta". A column's value is the running sum of its deltas. All four columns of a row are advanced in order 0,1,2,3, so their bytes interleave.
- Signed varint (first byte b0 decides the length; the lowest bit of the last byte is the sign, a set sign means
value = −1 − magnitude):
- b0 < 0xC0: 1 byte, magnitude = b0 >> 1;
- 0xC0 ≤ b0 < 0xF0: 2 bytes, magnitude = (((b0·256 + b1) >> 1) with bits 13–14 cleared) + 96;
- 0xF0 ≤ b0 < 0xFC: 3 bytes, magnitude = ((((b0·256 + b1) with bits 12–15 cleared)·256 + b2) >> 1) + 6240;
- 0xFC ≤ b0 < 0xFF: 4 bytes, magnitude = ((((b0 & 3)·2^24 + b1·2^16 + b2·2^8 + (b3 & 0xFE)) >> 1) + 0x60000 + 6240;
- b0 = 0xFF: 5 bytes, a raw big-endian u32 (no sign step).
- What it indexes: sample position → (byte offset of the XMA entry, side-data offset). It does not index speed
or grain segments. In every grain row 0 already covers the whole stream (
samples = num_samples, key 1) [V], so every in-range seek resolves to "restart the decoder at byte 0/sample 0, decode 384 pre-roll samples… " — in practice: entry start 0, pre-roll used = min(target, 384), skip = target − pre-roll used; entry + pre-roll + skip = target. The side data (~1 byte per 2 KiB XMA packet) is for the hardware XMA decoder to locate packets [T]. - Port: decode each grain once to PCM at its native rate (vgmstream at setup); a seek to t seconds is simply
"read from frame ⌊rate × t⌋" (§2.6). The seek table only needs parsing to validate members. Keep the varint/column
reader (it is the same for
wheels.big.sektables) — our implementation ingrain_survey.pyreads all 14.
1.4 Which grain plays [T; surface table V]
Per board the owner (SFXObj_SkateBoard, constructor sub_824C5058) loads the 13 surface members into 26 slots
(two per member, one per player A/B, both point to the same bytes) and keeps two trucks × two players.
-
Material → surface. Per truck, the audio state's per-wheel material (state +620 + 4·wheel; wheel 0 for the primary truck, wheel 3 for the other) is
collision tag & 0x7Fminus 1, with tag 0 (or anything out of 0..143) meaning 143 = none [T]. Rolling surface = word +4 of elementmin(material, 94)of the vault'sSk8::AudioSurfaceMap(classC1831BDB6CB1B1EA, keyC489459A0C07D154, field4CA607558B1CF440, 95 × 72 B); material 143 gives surface 3 [T]. In terms of our engine's 7-bitaudio_surfacetag (= material + 1) [V, vault]:rolling surface what plays tags ( tag & 0x7F)1 asphalt_rough grain 2 2 concrete_rough grain 4, 66 3 asphalt_smooth grain 1, 55, 56, 61, 62, 71–83, 86–88, ≥ 95, and 0 (none) 4 concrete_smooth grain 3, 51–54, 57, 60, 63–65, 92, 93 5 wood_ramp grain 6, 7, 41–50, 58, 59, 94 6 concrete_aggregate grain 5 9 metal_smooth (hard only) grain 9, 11–36, 38–40, 84, 85, 89, 91 7, 8, 10, 12, 13 no grain: a per-surface Class_rollingpatch (selectors 1, 2, 10, 11, 9)7: 10, 70 · 8: 8 · 10: 67 · 12: 68, 69 · 13: 37 0 UNCERTAIN (no grain member, not a Class_rolling surface) 90 14 no contact: stop this truck's sound (forced, see 3.) Our current
grain_fortable differs from this (e.g. tag 6/7 wood, tags 5/8/10 aggregate, tag 90 wood); the port must use the vault map. -
Soft/hard: soft member when
sub_824B23C8() == 1= audio state +684 = (Motion+200 < 0.5) [T]. Surface 9 has onlymetal_smooth_hard. What Motion+200 is (wheel durometer from the board setup?) is UNCERTAIN. -
Forced 14 (no contact): while grinding (state +341), or on the local player while the manual latch (§2.7) is set and the truck's reference wheel has not landed (state +464 + wheel) [T].
-
Collection key per member (grain-class vault
7AB23C11B6ADA2DE), owner slot pair A/B [T]: asphalt_rough soft943B1CB05BA9A6BA14/15, hard7EB8015B4C02405E0/1; concrete_rough softB29FADBBBC2F39C216/17, hard03721D0FA99A03C82/3; asphalt_smooth softDC1009D50E327F8F18/19, hard7C5912FC2DABF98C4/5; concrete_smooth soft607A6BC3D427DA4920/21, hardFFB5E3E62E0B49436/7; wood_ramp soft382B12636ED9D8DA22/23, hard7947A259F181FDB48/9; concrete_aggregate soft863C58AC34BAD59924/25, hardB303AED8241530E210/11; metal_smooth_hard1C9C52CC0E1CD4CF12/13. Anything else →default(D7EDBD362D7D2152), nothing bound. -
x_jet_rolling is not a surface grain: it is the "AudBoard Rocket" speed layer of
SFXObj_SenseOfSpeed(§2.8).
Surface routing (sub_824C5CA8, every frame, game thread) [T]: for the primary truck, then (local player only) the
other truck, compare the new surface with the stored one. On a change away from a real surface it pulses owner MixMap
input 0 (32767 for that frame, else 0). If the truck had a live sound it is stopped (Class_rolling released, or both
grain players stopped) — except on the local player when the other truck has no live sound, in which case the sound
is handed to the other truck (the primary-truck index flips) instead of being stopped. Then, for a new surface
≠ 14, a sound starts only if the other truck is not already on that surface: grain surface → bind both players of
that truck (§2.5); other surface → post its Class_rolling. So one sounding truck per distinct surface: normally
one truck (2 grain players) sounds; both trucks sound only while they straddle different surfaces. Owner input 6 =
32767 while truck 0's surface is 9 (metal). PoC Routing::route (skate-game player_audio/components/board.rs) is
the oracle for golden vectors of this bookkeeping.
1.5 Per-surface tuning (grain-class vault 7AB23C11B6ADA2DE) [V values; field roles T]
default holds everything; members override a few fields. P0..P3 = position-curve control points (column 1 of rows
3, 2, 1, 0 of the 4×4 matrix A985FBAA9326718D; column 0, unused by the code, holds 1, ~0.47, ~0.25, 0 — probably the
authoring curve's speed axis [I]).
| collection | max km/h | P1 | P2 | turn cap | B slope gain / ramp km/h | A shift/slope | B base shift | notes |
|---|---|---|---|---|---|---|---|---|
| default | 74 | 0.3621 | 0.7069 | 0.8 | 1.5 / 10 | −100 Hz | −10 Hz | |
| asphalt_rough_hard | 65 | 0.2586 | 0.7069 | 0.8 | 1.5 / 10 | −100 | −10 | |
| asphalt_rough_soft | 65 | 0.0690 | 0.7069 | 0.8 | 1.5 / 10 | −100 | −10 | |
| asphalt_smooth_hard | 65 | 0.0517 | 0.8276 | 0.8 | 2.0 / 5 | −150 | −10 | |
| asphalt_smooth_soft | 65 | 0.5517 | 0.6034 | 0.8 | 2.0 / 5 | −150 | −10 | |
| concrete_rough_hard | 60 | 0.0345 | 0.9138 | 0.6 | 2.0 / 10 | −100 | −10 | |
| concrete_rough_soft | 60 | −0.0690 | 1.0690 | 0.6 | 2.0 / 10 | −100 | −10 | curve can leave [0,1] slightly |
| concrete_smooth_hard/soft | 65 | −0.0517 | 0.7069 | 1.0 | 2.0 / 5 | −150 | −10 | |
| concrete_aggregate_hard | 65 | 0.5345 | 0.4828 | 0.3 | 1.5 / 5 | −100 | −10 | B shift/slope −50; rise step 0.1 |
| concrete_aggregate_soft | 65 | 0.2586 | 0.6379 | 0.3 | 1.5 / 5 | −100 | −10 | B shift/slope −50 |
| wood_ramp_hard | 65 | 0.3621 | 0.7069 | 0.8 | 2.0 / 5 | −150 | −25 | |
| wood_ramp_soft | 65 | 0.6207 | 0.7069 | 0.8 | 2.0 / 5 | −150 | −25 | |
| metal_smooth_hard | 55 | 0.0690 | 1.0517 | 0.45 | 2.0 / 10 | −150 | −20 | special gain 0.6, special shift 100 Hz, push shift low −50 |
P0 = 0 and P3 = 1 everywhere. Defaults used by all (unless noted above): GrainParams (D18D1174735E5CDE, an array
of two 5-float elements, only in default): player A = attack 0.1 s, sustain 0.2 s, release 0.1 s, search window 1.6 s,
drift threshold 0.05; player B = 0.2, 0.1, 0.2, 1.5, 0.05. Turn-intensity rise/fall steps 0.06 per frame; special gain
0.65 / special shift +150 Hz; B shift per slope +50 Hz; push envelope: speed ramp 45 km/h, speed-scale peak 1.4 (slow)
→ 1.1 (fast), shift peak −52 Hz → −20 Hz, timing 35/200/600 ms (scale) and 30/200/600 ms (shift); slope divisors
−10 (down) / +10 (up). Field hashes are listed in grain_survey.py.
Owner/rocket tuning (class 6E878344774A7999, default) [V]: rocket start 35 km/h, top 60 km/h, gain word 22000,
rocket GrainParams 0.1, 0.4, 0.1, 2.4, 0.1; graph-3 clip 0.09, shelf 5000 Hz / 0.65; graph-3 send ramp 46 → 70 km/h up
to level 3.0; graph-1 level ramp 52 → 74 km/h down to a floor of 0.45; gain wobble ramp 42 → 70 km/h, wobble 0 =
15–30 ms segments of magnitude 0–0.30, wobble 1 = 5–15 ms of 0.10–0.25.
2. The grain player
2.1 Objects and counts
- Per board owner: 2 trucks × 2 GrainPlayers (A at owner+1176+8t, B at owner+1180+8t), both of a truck bound to the same grain file; A and B differ in GrainParams and in their per-frame record. Plus one rocket GrainPlayer in SenseOfSpeed (local player only). [T]
- GrainPlayer (372-byte object; layout confirmed against the retail capture [C]): the record {gain, pitch, —, position}; the 5 GrainParams; a hold flag; send target; bound flag; grain data/duration/stream/seek pointers; two voice slots {graph, state timer, start seconds, position at pick, state 1 attack / 2 sustain / 3 release}; the active-slot index; a scheduler instance; a 16-entry recent-windows list {start, end, next} plus used-head, last-inserted and free-head indices. Constructor defaults: gain 1, pitch 1, position 0, params 0.01 / 0.5 / 0.01 / 4.0 / 0.05 (always overwritten on bind).
- Voices: at most 2 per player (one fading out while the next fades in). Typical: one sounding truck → 2 players → ≤ 4 grain voices; both trucks → ≤ 8; + ≤ 2 rocket voices.
2.2 Per-frame record (owner update sub_824C6BD8, 60 Hz, after the frame's MixMap evaluation) [T; offset C]
Inputs per truck: ground speed v (m/s, audio state +208), this truck's surface tuning, MixMap outputs (§2.9), the owner's slewed values (§2.7). Single-precision arithmetic throughout.
- Effective speed v' = v × s, where s is the push speed-scale envelope value while it runs (else v' = v) (§2.7).
- t = clamp(v' × 3.6 / max_kmh, 0, 1).
- Position A = cubic Bézier in t with control points P0..P3: pos = (1−t)³·P0 + 3t(1−t)²·P1 + 3t²(1−t)·P2 + t³·P3. Not clamped (two members can leave [0,1] slightly).
- Position B = max(pos − 0.1, 0) — B always reads 0.1 (≈2 s of recording) behind A. Measured on retail: exactly −0.100 at every speed [C].
- Pitch (A and B) = MixMap pitch output 3 (cents → ×4096) / 4096. Retail medians: 3209 at rest, ~3500 at 1 m/s, ~4080 from ~5 m/s up [C capture medians] — i.e. grains play ~22 % slow at a crawl, ~unity when rolling.
- Gain A = level(1)/32767 × (1 − max(I, Bk)) [× special_gain while "special"] [× seam envelope while active], where I = slewed turn intensity (+1164), Bk = slewed brake (+1168) (§2.7).
- Gain B = I × level(2)/32767 [× seam envelope]; plus, while the downhill slope level D (+1508) > 0: Gain B += D × ramp × slope_gain(primary truck) × Gain A, clamped to ≤ 1, with ramp = clamp(v' × 3.6 / slope_ramp_kmh, 0, 1) (or 1 if that km/h ≤ 0). So B is the turning (and downhill) layer: retail B is silent ≥ 40 km/h in straight rolling and peaks around 15 km/h; turn input +204 is 0 in 87 % of retail frames [C].
- The four floats are stored into the player; the audio thread reads them each block (§2.4). No other smoothing than the MixMap's own envelopes and the per-frame slews.
The record of a truck is written only while that truck's grains are running (+1328+t set and its surface plays grains).
2.3 Choosing a grain (pick, sub_828ECAB0) [T; G]
Let L = attack + sustain + release (grain length, seconds of source at pitch 1), W = search window, D = stored duration, pos = record position.
- Target T = (D − W) × pos; region = [T, T + W]. (So the region never runs off the end of the file for pos ≤ 1.)
- The recent list holds the windows played since the last reset, sorted by start. Its free gaps — from a sentinel before 0 to the first window, between consecutive windows, and from the last window to D — are each clamped to the region and tiled with back-to-back windows of length L starting at the gap's (clamped) start, while a whole window still fits (in single precision; the 4th 0.4-s window of a 1.6-s region does not fit because 1.2+0.4 rounds above 1.6) — at most 64 candidates.
- If there is no candidate, or the list is full (the last inserted entry is index 15): collapse the list to just the most recent window and retry; if the region holds at most one window (⌊W/L⌋ ≤ 1) return T instead.
- Otherwise draw r from the title-wide generator (
sub_82A8AF10, 6 words at0x82FD7D74, seeded at game init with the time base; shared by ~100 call sites) and take candidate ⌊r × 2⁻³¹ × 0.5 × count⌋ (uniform). Insert it into the sorted list; return its start.
Behaviour this produces [G, grain_vectors.tsv]: a shuffle bag without replacement over the 3–4 windows of the
current region (A: 0.4-s windows in a 1.6-s region → 3–4; B: 0.5 in 1.5 → 3; rocket 0.6 in 2.4 → 3–4); when the bag is
empty it refills but never repeats the window just played. The grid is anchored to windows already played, so small
position changes keep reusing aligned windows. A bind resets the list (sentinel only), so the first pick after a bind is
a window starting exactly at T.
Edge (port note): r within 128 of 2³² rounds to 2³² in single precision, making the index = count and reading an unfilled candidate {−1, −1}; the start is then clamped to 0 (§2.6). Probability ~3·10⁻⁸ per pick; reproduce or clamp.
Pick sequences are not reproducible against retail (global RNG); only the distribution and the bag rule are. For exact validation log the 6 RNG words at each pick (§5.3).
2.4 The per-block scheduler (sub_828EC6F0, via stub sub_828ECE98) [R for the drift test and timers; T rest]
Registered on bind as a plug-in in scheduler bucket 0 of the audio system ("Grain Player"); the block driver
sub_82B48530 runs bucket 0 in phase 1 of every 256-frame block (48 kHz → every 5.333 ms), before the command
drain and the graph pass, with δ = 256/48000 as f32 (0x3BAEC33E) [C: attack timer 0.1 → 0x3DAC0831 → 0x3D962FC9 …].
So this is per audio block, not per 32-ms evaluator tick and not per game frame. Each block:
- Drift test. d = (position recorded at the active slot's pick) − (current record position). If |d| > drift
threshold (0.05 ≈ 1 s of recording) and hold is clear and the active slot has a voice:
- if the other slot has a voice: release it (unless already releasing) — nothing new starts this block;
- else: release the active slot (unless releasing), pick (§2.3), start a voice in the other slot (§2.5) and make it active. (A fast speed change therefore cuts the current grain short with a normal release.)
- For each slot 0, 1 that has a voice:
- post the record gain to the voice's Send (Sen0 parameter 0) and the record pitch to its Resample (parameter 0) — every block, applied this block (phase 1 precedes the drain);
- timer −= δ in attack/release; timer −= δ × pitch in sustain (fused multiply-subtract);
- if timer < 0:
- attack → sustain, timer = sustain;
- sustain → release (timer = release, fade to 0); then, unless hold: stop the other slot's voice if any, pick, start a new voice in the other slot, make it active;
- release → stop the voice.
- Ordering detail (bit-exactness): a voice started in slot 1 while processing slot 0 is processed (gain/pitch posted, timer decremented) in the same block; one started in slot 0 from slot 1 waits for the next block [T]. A voice started by the drift branch (step 1) is processed in the same block.
The hold flag (+36) is cleared on bind and never set by any caller we know of (UNCERTAIN; treat as always clear).
Resulting rhythm at pitch p (blocks quantise each phase up to the next 5.33 ms):
| player | grain period (start to start) | overlap (release ∥ next attack) | sounding per grain | source used per grain |
|---|---|---|---|---|
| A | 0.1 + 0.2/p s (0.3 s at p = 1) | 0.1 s | 0.4 s | 0.2·p + 0.2 s (= L at p = 1) |
| B | 0.2 + 0.1/p s (0.3 s) | 0.2 s | 0.5 s | 0.4·p + 0.1 s |
| rocket | 0.1 + 0.4/p s (0.5 s) | 0.1 s | 0.6 s | 0.2·p + 0.4 s |
Retail evidence [V, our trace from recomp session 20261001_211347, grain_trace_stats.py]: 8,484 grain voice
starts in 10.5 min. Rocket starts cluster at 475–525 ms apart (163 of 557), surface-grain starts of one member
never exceed ~350 ms apart while rolling (period 0.3 s + quantisation + pitch < 1), the modal rate is 6 starts/s
(= 2 players × 1/0.3 s, one sounding truck), with bursts up to 42/s (drift restarts, both trucks, other skaters). This
matches the timing above.
2.5 Starting and stopping a voice [T; fade curve T]
Start (sub_828EC3F0): build a mono voice graph SndPlayer1 → Resample → GainFader → Send (graph order 0),
play the grain's EAAC stream from start = clamp(pick, 0, D) seconds (§2.6), set the fader to 0 immediately, then
fade in to 1 over attack seconds, point the Send at the player's bus (graph 1 of its chain, §3.1) and stamp the
current record gain on it; remember the start and the record position at this pick.
Fades (GainFader, aems-voice-graph-spec.md §4.7): requested with start time "now", the curve code is 1 →
square-root curve: fade-in g(n) = √(n/N), fade-out g(n) = √(1 − n/N), N = ⌊seconds × 48000⌋ samples (≥ 1),
sample-accurate inside the block. The overlapping release and attack of consecutive grains therefore form an
equal-power crossfade (g_in² + g_out² = 1).
Release (sub_828EC2F8): fade to 0 over release seconds. Stop (sub_828EBCB0): deferred "player stop"
command → the graph is torn down at the next drain (Send release de-click, §4.8 of the voice-graph spec); used after a
finished release (voice already at 0), and abruptly in the cases below.
Bind (sub_828EC040, from surface routing): reset the recent list, register the scheduler plug-in, take H,
duration, seek table and stream from the file, copy the GrainParams element (A = 0, B = 1) into the player, pick and
start a voice in slot 0 at once (game thread). In retail the truck's bus chains are also torn down and rebuilt on
every bind (sub_824C4D50 + sub_824C8878), so filter, frequency-shifter and pan histories restart at a surface change
(PR #4 keeps its chains instead; a port should reset those states).
Stop the player (sub_828EBF90): when bound, stop both voices without a release fade and detach the plug-in.
Used on a surface change and when the truck leaves contact (surface 14). The rocket stops its player twice when it
drops to ≤ 35 km/h (second call is a no-op).
2.6 Reading the recording [T; V for single-block streams]
The play command converts the start time into a frame at the stream's native rate: frame = ⌊rate × start⌋ (only when
0; else 0). With the single-entry seek table the decoder restarts at sample 0, decodes and discards up to 384 pre-roll samples and skips the rest, so output begins exactly at
frame. Port: PCM decoded once at native rate; start reading atframe; play forward (no loop flag; a grain never reaches the end because T + W ≤ D at pos ≤ 1 and a grain lasts ≈ L, but a pitched or drifting grain may run past its window — let it). Resample converts to 48 kHz with ratio = native rate / 48000 × pitch, linear interpolation, no anti-alias filter (aems-voice-graph-spec.md§4.3).
2.7 Owner-side modulators [T unless noted]
Run in the owner's process (sub_824C6A78, before the MixMap evaluation) except where noted.
- Push envelopes (on every push plant, state +335;
sub_824C6198): speed-scale envelope from its current value (or 1.0) to peak = lerp(1.4, 1.1, t) over 35 ms, hold 200 ms, back to 1.0 over 600 ms; frequency-shift envelope 0 → lerp(−52, −20, t) Hz over 30 ms, hold 200, → 0 over 600; t = clamp((v − 1) × 3.6 / 45, 0, 1); tuning from the primary truck's collection; linear segments advanced by the frame dt while not idle. Effect: each push moves the read position forward (up to 40 % more speed at a standstill) and detunes the bed by tens of Hz. - Turn intensity I (
sub_824C8588, owner +1160/+1164): raw = clamp(|COM velocity| (state +96) × 0.24, 0, cap) × turn (state +204), clamped to ±cap; forced 0 while the manual latch or the trick latch holds; slewed toward raw by at most the rise/fall step (0.06, aggregate_hard rise 0.1) per frame; I = |slewed|. Cap and steps from the primary truck's collection. - Brake Bk (+1168): while braking (state +336) against the direction of travel (cosine of velocity delta and velocity < 0, or undefined) slew toward 1, else toward 0, by 0.05 per frame.
- Slope (
sub_824CA738): with the primary truck on a grain surface, downhill level D = clamp(slope (state +712; PR #4 docs call it the slope, the PoC field is namedpump_absorption_712— meaning UNCERTAIN) / −10, 0, 1), uphill U = clamp(slope / 10, 0, 1); posted as owner inputs 2/3 (×32767). - Manual latch (+1504): set while balancing (state +340); once set, cleared when wheel count (state +200) is 0 or 4. Trick latch (+1505): set while state +372 (an airborne-trick flag); cleared when both feet are back in the deck box (+615 and +616). "Special" = manual latch, else trick latch.
- Seam-pattern gain envelope (
sub_824CA448, local player): when wheel 0's seam pattern (state +636) changes to a pattern whose vault wobble (class7242F32831ED3332, patterns spidercrack … special_2) is not {1, 1}, one linear segment from 1 to a random gain in [low, high) (steps of 0.01) over a random duration in [ms_low, ms_high) ms (title RNG,sub_824CA318), then hold; it multiplies both gains A and B while active. Correction 2026-10-02 (read in the asm): not a random walk — the same pattern only advances the segment. In the vault only spidercrack has a wobble ≠ {1, 1} (0.8 → 0.6, 30 → 80 ms).
2.8 The rocket layer (x_jet_rolling.grain, SenseOfSpeed sub_824E7980 / sub_824E7CB0) [T; timing V]
Local player only. When ground speed > 35 km/h and not running: bind to the default output bus directly (no chain), rocket GrainParams (0.1, 0.4, 0.1, 2.4, 0.1), start. At ≤ 35 km/h: stop. While running, record: gain = level(5)/32767 × 22000/32767 (SenseOfSpeed controller), pitch = pitch(3)/4096 (same controller), position = clamp((v × 3.6 − 35) / (60 − 35), 0, 1). No hysteresis.
2.9 MixMap outputs consumed (for mixmap-spec.md) [T]
Board owner controller (owner vfuncs 52/56/60/64 = raw u16 / pitch cents→×4096 / level & 0x7FFF / filter & 0x7FFF):
| output | used as |
|---|---|
| level(1) | gain A scale (retail active-rolling p90 7859 ≈ 0.24) |
| level(2) | gain B scale |
| pitch(3) | grain pitch (both players) |
| level(11), level(12) via vfunc64 | LowPass / HighPass Hz on both chains (retail modal 24971 / 77) |
| raw(0) via vfunc52 | azimuth: Pan2D1 degrees = raw × 360/65535 |
| level(13) | environment send /32767 |
| level(21), level(22) | graph-2 first send A / B /32767 (local player) |
SenseOfSpeed controller: level(5), pitch(3) (rocket). Owner MixMap inputs written by this code: 0 surface-change pulse, 2/3 slope, 4 push foot planted, 5 heading rate, 6 on metal (and 1 skid). Which inputs drive level(1)/(2) (e.g. wheel-contact inputs that silence the bed in the air) is for the MixMap spec. Chain values (§3.2) are computed in process (before this frame's evaluation, i.e. one frame old); records in update (this frame's).
2.10 Airborne, grinds, manuals, bails
- Grind: surface 14 → both players of the truck stop (abrupt); Class_rolling layer surface becomes 13 for the held layers.
- Manual: the lifted truck goes to 14 (its sound stops or is handed to the other truck); manual/trick latch also zeroes I (B silent) and scales A by the special gain with a +150 Hz shift.
- Airborne: nothing in the grain code stops the players on take-off; voices keep cycling and their level must come from MixMap level(1)/(2) (contact inputs). Whether the wheel material holds its last value in the air or becomes "none" (→ surface 3, which would rebind asphalt_smooth on every jump) is UNCERTAIN — first thing to measure (§5.3, hook on routing). The trick latch only affects I/special.
- Bail / off board: the board owner keeps running; the result depends on contact/material (UNCERTAIN).
3. Voice graph, buses and interplay
3.1 Per-player bus chain (sub_824C8878; one per player, 4 per board) [T; shape in voice-graph spec §6.2]
grain voices ──► graph 1 (order 2, mono): SubMix → HighPass → LowPass → FrequencyShiftSsb → Send(→ graph 3)
→ Gain (wobble) → Send(→ graph 2)
graph 3 (order 3, local player only, mono): SubMix → Clip ±0.09 → Gain (wobble) → HighShelf 5 kHz × 0.65 → Send(→ graph 2)
graph 2 (order 5): SubMix → Send(→ manager+116 target, level 0) → Send(→ environment bus, level(13))
→ Pan2D1 (1 → 6 ch) → Send(→ eEQChain bus 8 = the default bus)
- FrequencyShiftSsb (
sub_82B22898): two cascades of two allpass biquads form an I/Q (Hilbert) pair; out = I·cos φ − Q·sin φ, φ advancing 2π·shift/rate per sample, wrapped per block. At 0 Hz it is a pure allpass (phase-shifted copy), never a bypass. B's base shift (−10 Hz; wood −25, metal −20) decorrelates B from A (same file, 0.1 behind, shifted). - The four chains run in parallel; graph 3 adds a gently clipped, top-shelved copy at high speed (§3.2).
3.2 Values pushed each frame (sub_824C9058, per truck whose grains run; plus helpers) [T]
| target | value |
|---|---|
| HighPass, LowPass (A and B) | level(12), level(11) Hz |
| FSS A | (special ? special_shift : 0) + push shift envelope (while running) + D × A_shift_per_slope(primary) |
| FSS B | B_base_shift + push shift envelope + D × B_shift_per_slope(primary) |
| Pan2D1 (A, B) | raw(0) × 360/65535 degrees |
| env send (A, B) | level(13)/32767 |
| graph-2 first send (local) | level(21)/32767 (A), level(22)/32767 (B) |
graph-1 → graph-3 send (sub_824CAEC0, local, all 4 players, on change) |
0 below 46 km/h, linear to 3.0 at 70 km/h |
graph-1 Gain, graph-3 Gain (sub_824CB078/sub_824CB180, local) |
wobble: gain = 1 + e × ramp, e = a random-walk envelope alternating sign (segments 15–30 ms, |
Quirks to keep [T]: the graph-1 level ramp has no branch below 52 km/h, so it keeps its last value when speed drops (it only returns towards 1 while between 52 and 74 km/h); the graph-1 → graph-3 send is posted only when it changes. The "last posted" wobble value is shared between the two trucks, so after truck 0 posts, truck 1 always compares equal and its wobble is never posted (retail behaviour).
3.3 Interplay with the other board sounds [T]
- Class_rolling (
PatchBank_Rolling_*): per-surface patch for the non-grain surfaces (7, 8, 10, 12, 13) instead of grains; held layers 0 and 3 always posted for the local player (sparse random texture, not the bed); layer 5 while wheel 0 is on the spidercrack seam pattern. Same speed word shape (clamp(v·3.6/maxKmh) × 10000, push-scaled). - Rolling_Rattle_Class: posted on each push plant while the primary truck is on a grain surface (surface code 1..5 from the grain key), released on the next.
- Class_Seams: per-wheel seam clicks (separate component); the seam-pattern gain envelope above modulates the grain bed itself.
- SenseOfSpeed rattle (≥ 30 km/h) and wind (15–55 km/h) patches + the rocket grain (> 35 km/h).
- Wheel spins (
SFXObj_Wheels,wheels.big.snr+.sek): a separate SndPlayer1 path using the same seek mechanism (§1.3), on take-off / manual; spin-down gain × (1 − speed/max). - Skid, squeaks, board slide: bank patches, independent.
- Levels [C, PR #4 native 6-ch]: rolling bed −22.9 dBFS; takeoff +17.0 dB and landing +23.3 dB over the bed. Retail grain transfer (truck 0, both players, by km/h): gain A ≈ 0.06 at 5, 0.08–0.17 from 10 up; position A 0.12 (10 km/h) → 0.63 (40 km/h); B peaks at 15 km/h (+2.7 dB over A) and is silent ≥ 40 km/h. Compare at matched surface and speed with narrow bins and medians, never session averages (three false findings came from that).
4. Native port outline (behaviour, not code)
- Setup: decode the 14 members to PCM at native rate (we already decode them; keep whole files, drop the 6 bands); validate header/seek table; load the vault tuning (§1.5) and AudioSurfaceMap.
- Game thread, 60 Hz: surface routing (§1.4), modulators (§2.7), records (§2.2), chain values (§3.2), rocket (§2.8).
- Audio thread, per 256-frame block at 48 kHz: for each bound player run §2.4 before mixing; voices = PCM reader + linear resampler + sqrt fader + send gain (64-sample de-click on change); chains per §3.1. The RNG is our own instance of the add-with-carry generator, seeded at startup (retail is unreproducible anyway).
- Keep it moddable (grain files, GrainParams, curves as data), per the port plan's goals.
5. Validation plan
5.1 Golden vectors from the PoC (done: first set) [G]
crates/skate-audio-core/examples/grain_vectors.rs in the local PoC worktree (local only, never committed) drives PR #4's ported functions; output
grain_vectors.tsv:
POS: speed 0..80 km/h → position A/B (bit patterns) for concrete_rough_hard and default (e.g. concrete_rough_hard 30 km/h → A3EF611A8= 0.4806, B 0.3806; saturates at max km/h);PICK: 24 successive picks for A and B params at pos 0 / 0.25 / 0.63 / 1.0, generator from zero and from the image state (shows the bag rule: e.g. A, pos 0.63 → windows 12.7524/13.1524/13.5524/13.9524 s);CAND: candidate tiling (3 windows in [0,1.6] with L 0.4; 4 in [19,20.6]).
Next vectors (needs the full runtime): copy PR #4's crates/skate-data/examples/grain_repro.rs (in
PR #4's branch; not in the PoC worktree) into the PoC locally and log per block: slot states, timers, starts,
picked positions, posted gain/pitch, the RNG words before each pick, and the 6-ch output RMS — for (a) constant
speeds 2/4/6/8 m/s per surface, (b) a speed ramp crossing the drift threshold, (c) a surface change (stop + bind), (d)
pitch 0.78 vs 1.0. Our port must match timers, state transitions, start seconds and posted values exactly (given the
same RNG words), and the audio to ≤ 1e-5 FS per sample before the chain (decode differences aside).
Also unit vectors for: modulators (push envelopes, turn slew, brake slew, slope, latches), chain values, wobble and
send ramps — all pure functions in the PoC's grain::board / grain::envelope and player_audio/components/board.rs.
5.2 Retail evidence already in hand [V]
grain_trace_stats.py <trace.tsv> matches PLAY lines (SndPlayer1 play hook) whose sample header equals a grain
stream: per-member counts, starts/s and start-interval histograms (§2.4). Results for session 20261001_211347 in
trace_stats_20261001_211347.json. The PLAY hook does not log the start offset.
5.3 Passive recomp hooks to add (wrap and call through, like src/skate3_audio_trace_hooks.cpp)
| tag | function | log |
|---|---|---|
| GBIND | sub_828EC040 bind (r3 player, r4 bus, r5 data) |
player, data, duration word ([data+4]) → member, params after return (+16..+32) |
| GPICK | sub_828ECAB0 pick (r3 player; f1 result) |
before: position +12, the 6 RNG words at 0x82FD7D74, recent head/last/free (+364/+366/+368); after: f1 |
| GSTART | sub_828EC3F0 start voice (r3 player, r4 slot, f1 start) |
slot index ((r4 − r3 − 88)/28), start, gain, pitch |
| GTICK | sub_828EC6F0 per block (r3 player, f1 δ) |
on any state/active change only: δ bits, +0 gain, +4 pitch, +12 position, +144 active, per slot +92 graph, +96 timer, +100 start, +104 pick position, +108 state |
| GSTOP | sub_828EBF90 stop player |
player, caller chain |
| GROUTE | after sub_824C5CA8 (r3 owner) |
owner +768/+772 surfaces, +1320/+1324, +1328/+1329, +1496/+1497, +1500, + audio state +620/+632 materials, +332 air, +341 grind, +684 soft |
| GREC | after sub_824C6BD8 (r3 owner) |
the 4 players' records, owner +1028/+1032, +1152/+1156, +1164, +1168, +1456/+1464, +1508, state +204, +208 |
| GCHAIN | after sub_824C9058 |
values posted per truck (read graph-1 FSS +52, HP/LP +52, Pan +52) |
| — | sub_824E7980/sub_824E7CB0 |
rocket running flag, record |
Use short scripted background recomp runs (never ask the user to replay): on one known surface, push to ~10, 20, 30, 45 km/h and coast 5 s each; a straight roll vs a carve; a manual; an ollie and a 1-s air; a grind; a crossing between two materials. Acceptance: start intervals, state timers and pick regions exact given logged RNG words; record gain/pitch/position per frame within 1 ulp of our model fed with the logged inputs; per-player pick distribution χ² consistent with uniform; airborne behaviour settled (§2.10).
6. Open questions (priority order)
- Airborne: does the wheel material hold, or become none → surface 3 (rebinding asphalt_smooth each jump)? Does level(1)/(2) go to 0 in the air, and how fast? (GROUTE/GREC + MixMap spec.)
- Surface 0 (tag 90): what retail does (no member, not Class_rolling).
Soft flag source (Motion+200 < 0.5)Settled 2026-10-02: Motion+200 = Processed+2764 = the board profile's wheel hardness (animation_phase_packet.rswheel_hardness, default 0.7 = hard).- Hold flag (+36): any writer?
- GainFader curve code from the fade request (+28 rounded = 1 → square root) — confirm with a GTICK/fader dump.
- Exact block of the first tick after a game-thread bind vs a scheduler start (the 3-block capture fits both).
- Chain rebuild on bind: confirm the teardown/rebuild order and that histories reset.
- PR #4 notes that its capture's "GP ret" value was the window end, not the start — re-check with GPICK.
7. Sources & credits
| source | author / owner | link | licence | contribution |
|---|---|---|---|---|
Upstream PR #4 (skate-audio-core/src/grain/*, skate-audio-formats/src/grain.rs, skate-data/src/audio/grains.rs, skate-game/.../board.rs, speed.rs, examples/grain_repro.rs, docs player-audio-retail-drivers.md §3/§11, wheel-audio-handoff-2026-09-17.md, engine-defects.md) |
andrewnakas (Andrew Nakas) | github.com/SK8-ENGINE/skate-3-rust-engine/pull/4 (fork github.com/andrewnakas/skate-3-rust-engine, commit 368a433; read from a local clone and a PoC worktree) | none — read only | Discovered that the bed is a granular player; recovered the GrainPlayer object, pick, scheduler, voice graph, bus chains, FSS module, board records, vault mapping, push envelopes, retail capture comparisons and transfer functions. Almost everything tagged [T]/[C] here is their finding, re-described. |
Upstream PR #1 (mx/audio-engine-vehicles) |
andrewnakas | github.com/SK8-ENGINE/skate-3-rust-engine/pull/1 | none — read only | Earlier version of the same grain port and docs. |
| skate3recomp (generated C++, audio-trace hooks) | mchughalex (recomp); trace hooks ours (local branch audio-trace) |
github.com/mchughalex/skate3recomp | none stated — read only | Lifted code we read for §2.4 [R]; the retail trace used in §2.4/§5.2. Built on rexglue-sdk. |
Skate 3 (EA Black Box), TU3 image, disc grains.big, vault skatercollections.vlt |
Electronic Arts | — | proprietary, owner's copy, never committed | The data measured in §1. |
vgmstream (data/tools/vgmstream-cli) |
vgmstream contributors | github.com/vgmstream/vgmstream | ISC-style | EA-XMA decode for the RMS/centroid survey. |
Our notes aems-voice-graph-spec.md, aems-evaluator-spec.md, aems-reference.md, rwaudio-prior-work.md |
this project | — | — | Module semantics (Resample, GainFader, Send, buses), block clock, RNG. |
| CC0 prior art (nfsmw Snd9, tw2004) | dbalatoni13, mitsevox | see rwaudio-prior-work.md |
CC0 | Checked: no GrainPlayer there; nothing used. |
8. Files
- Survey script:
tools/audio-file-inspect/grain_survey.py(--rmsadds level/centroid profiles). - Trace stats:
tools/recomp-trace/grain_trace_stats.py. - Golden-vector example:
grain_vectors.rs(local, in the PoC worktree). - Data (local):
grain_survey.json/.txt,grain_vectors.tsv,trace_stats_*.json(≈0.1 MB).