Audio: world gaps closed (tazer, ped falls, speech details, echo, main-cast speech, NPC board slide)

Tazer and PedBodyFall ported from the retail mechanism and checked against the recordings; ped speech gets its PEAK filter, the voice float, the speech inputs, the phone and photographer lines, first-free-stream choice and the queue clock; the speech echo send (pre-gain send -> high pass -> distance delay -> low pass -> environment bus); the main-cast speech channel for pros, NPC skaters' own speech and message pairs, with its opt-in setup decode; the NPC skater's board slide. Engine and mod surfaces extended (reactions, tazer, body fall, phone / photo events); retail values are setup-data defaults. Local player output byte-identical.
This commit is contained in:
Hails committed 2026-10-03 21:17:18 -06:00
1 parent 7f120de0b8
commit 7ef35c45b0
35 files changed
+2997 -201

No files matched your search

+21
View File
@@ -4634,6 +4634,27 @@ Full write-up: [15](15-world-audio.md), section "P3–P5". In short:
- **The dev test mod is opt-in** (`"enabled_by_default": false`, `SKATE3_MODS_ENABLE`). The mod limits are now
48 / 128, and a `WORLD_AUDIO` summary is logged once a second.
### World audio gaps closed: Tazer, PedBodyFall, speech details, the NPC board slide (2026-10-03, headless + one scripted run)
Full write-up: [15](15-world-audio.md), section "Gaps closed". In short:
- **Tazer** (`SFXObj_Tazer` decoded): `c_tazer` held while the ped tazes. Recomp 164620: 19 starts per 2 s hold
against 10 / 20 / 19, the same gaps (192 / 192 / 128 / 96 ms vs 190 / 190 / 130 / 90–100) and order, first-start
gain recomp / ours 0.85–1.00.
- **PedBodyFall** (decoded; trigger = the animation's `BodyFallType`): 75 recorded starts (Splice, not POST);
containers 73 / 73 by type; voice gain recomp / ours p50 0.79.
- **Speech:** the stream voice's PEAK = azimuth curves (6 / 6 recomp pairs on them); the per-voice float; the
pre-gain send (out21) feeds the slot's **echo submix** (ported: HPF → camera-distance delay → LPF → env) and the
post-filter send (out15) goes to the env bus (17 / 29 and 19 / 27 recomp lines); value 49 = the phone ring → 64,
value 29 = the photographer's 1 s repeat, `Obj:Speech` in0 / in1 / in4, first-free stream; the queue clock =
visual game ticks.
- **Main cast (pros, special cast):** its own channel (`maincastspeech.big`, decode 6596 takes / 922 MB, opt-in
with the living world's), pro peds, NPC skater reactions and crash (`skater_speech`), the message pairs; all 82
recorded main-cast lines reachable through the ported words.
- **NPC:** the board slide runs for the NPC instance (static evidence; a 4-min scripted run saw no NPC bail); the
NPC bed against 180430's NPC rows: near band A gain 0.097 / 0.108.
- Local player byte-identical (e2e bench, all sets).
### PR #32 review fixes, step 2 (code half): spec references and private-data paths (2026-10-03, headless)
**Problem.** Code comments in `crates/`, `tools/asset_pipeline/` and the dev test mod cited the working notes and
+153 -13
View File
@@ -48,7 +48,7 @@ remove the component. Owner ids are `Entity::to_bits()`, so a reused index count
| field | retail | meaning | default |
|---|---|---|---|
| `voice` | `S+84` | the model = the speech voice id 41–96. Its `aud_characteristics` record (setup export `world_tuning.ped_models`) gives the shoe class, the security kind, the speech type / variant / gender words and the far threshold (G2) | none (no speech, the defaults below) |
| `voice` | `S+84` | the model = the speech voice id 1–96 (41–96 living-world peds; a pro 1–29 or the special cast 30–38 speaks on the main-cast channel). Its `aud_characteristics` record (setup export `world_tuning.ped_models`) gives the shoe class, the security kind, the speech type / variant / gender words and the far threshold (G2) | none (no speech, the defaults below) |
| `shoe_class` | `S+132` | 1–5 (`None` = the model's). Most models are 2; no model uses 1, which is silent. | the model's, else 2 |
| `weight` | `[obj+28]+144` | 1–5. It changes per ped in retail (1 beyond about 12 m, 2–5 nearer) and its meaning is open. | 1 |
| `close_range` | `S+96 == 64` | the security guards' close-range footstep levels and their speech levels (`None` = the model's type bit) | the model's, else false |
@@ -57,6 +57,8 @@ remove the component. Owner ids are `Entity::to_bits()`, so a reused index count
| `footsteps_on` | `S+68` | footsteps on | retail's rule: the 3 nearest peds in the list |
| `speech_distance` | `S+148` / `S+156` | distance to the listener / the model's far threshold. Beyond the threshold the far `_f` lines are used. | the 3-D distance / the model's threshold (20 m for regular peds, 30 for pros) |
| `speech_value` | `S+136` | the state graph's speech value | 0. Send `PedSpeechEvent` rather than writing it. |
| `tazing` | `S+80` | the ped tazes (retail: its state graph's `TazeEntity` state): SFXObj_Tazer holds `c_tazer`, the zap burst | false. `PedTazerEvent` holds it for a time. |
| `body_fall` | `S+76` | the ped animation's `BodyFallType` channel: each change to a non-zero value starts one PedBodyFall sound (8, 9, other) | 0. `PedBodyFallEvent` sends one key. |
### `NpcSkaterAudio` (the MixMap Player slot's second instance)
@@ -65,7 +67,9 @@ remove the component. Owner ids are `Entity::to_bits()`, so a reused index count
| `list_order` | the skater list position (retail walks its list in order) | spawn order |
| `state` | this frame's `AudioState`. A skater simulated with the player's physics uses `game_audio::skate_events::skater_audio_state(physics, skater, &mut memory, dt)`, the same builder the local player uses (proved identical, below). Anything else uses `AudioState::rolling(&LiteSkater { .. })`: speed, wheels, materials, grind / air flags. With that fill, rolling, surfaces, seams, grinds and landings sound; tricks and foot / body foley stay silent. | none (not published) |
| `remote` | a remote multiplayer player (see below) | false |
| `voice` | the skater's speech voice (the AI skaters' models 89–96): its bail grunt says a line of it | none |
| `voice` | the skater's speech voice: the AI skaters' models 89–96 (living-world channel) or a pro 1–29 / the special cast 30–38 (main-cast channel). Its bail grunt and its reactions say lines of it | none |
| `reactions` | the skater record's reaction bytes this frame (`SkaterReactions`: a slam seen and by whom, a second slam reaction, a trick seen and by whom, its own crash, the chase flag): its speech process (`skate_audio::world::skater_speech`) says `101_pos` / `104_slam` / `150_pro_pos` / `151_pro_slam` (pros) or `101_pos` / `404`–`405` / `105_spec_chase` (AI skaters), and `906_aislm` / `131_collide_object` on its crash | none. `NpcSkaterReactionEvent` raises one for a console frame. |
| `loose_board` | the conditioner's loose-board state (`+780`: 0, 1 upside down, 2 on its side; `rolling::loose_board`): the board slide holds while set | 0 (ghosts: from their log) |
### Messages, resources and the read-back
@@ -79,8 +83,15 @@ remove the component. Owner ids are `Entity::to_bits()`, so a reused index count
- `VehicleHorn { vehicle, kind, seconds }`: holds horn kind `kind` for the caller's time. The length
is the AI's choice, not retail data.
- `VehicleAlarm { vehicle }`: retail's alarm, horn state 6 for **8 s**.
- `LivingWorldAudio { expected }`: set it at map load when the system will publish. The 13 world
banks then decode on the prefetch worker instead of the game thread.
- `PedTazerEvent { ped, seconds }`: the ped zaps: `tazing` held for `seconds` (None = the state graph's
`TazerCycTime`, setup `world_tuning.ped_objects.tazer_seconds`, 2.0 s).
- `PedBodyFallEvent { ped, kind }`: one `BodyFallType` key of a knock-down animation. Keys queue; each is on for
one console frame with a frame of 0 after it, so repeated keys sound.
- `NpcSkaterReactionEvent { skater, reaction, by }`: an NPC skater reacts (`SkaterReaction::{Slam, SlamB, Trick,
Crash, Chase}`, `by` = the other skater's model, 0 = the player); held for one console frame.
- `LivingWorldAudio { expected, photo_flag }`: set `expected` at map load when the system will publish (the
world banks then decode on the prefetch worker). `photo_flag` is the game flag of the photographer's repeat
(a ped holding speech value 29 repeats it every second; retail system byte `+912`, meaning not traced).
- `WorldAudioInstance { slot, instance }` (read-back): the bridge inserts it on the entities that
hold a MixMap instance. Only those are audible. An engine system can use it to skip per-frame
audio work for the others, such as an NPC's `AudioState`.
@@ -157,7 +168,13 @@ sdk.world_audio.event('car1', 'horn', {kind=3, seconds=1.2}) -- or 'alarm' (8
sdk.world_audio.spawn('taxi', 'traffic', {engine='c04_taxi01', body='chassis'}) -- a mod car opts in to a retail engine sound
sdk.world_audio.spawn('ped1', 'ped', {voice=59, shoe_class=3, position=p})
sdk.world_audio.update('ped1', {position=p, feet={true,false}})
sdk.world_audio.event('ped1', 'speech', {value='warn'}) -- name or number
sdk.world_audio.event('ped1', 'speech', {value='warn'}) -- name or number (49: a phone call)
sdk.world_audio.event('ped1', 'tazer') -- the zap burst (seconds = 2 by default)
sdk.world_audio.event('ped1', 'body_fall', {kind=9}) -- one knock-down key (8, 9, other)
sdk.world_audio.update('ped1', {tazing=true, photo_flag=true}) -- held tazing; the photographer's game flag
sdk.world_audio.update('npc1', {loose_board=1}) -- lite skater: the board slide
sdk.world_audio.event('npc1', 'reaction', {value='trick', by=0}) -- it saw the player's trick (slam, slam_b, trick, crash, chase)
sdk.world_audio.update('npc1', {voice=24}) -- a pro's voice: the main-cast channel
sdk.world_audio.spawn('npc1', 'skater', {position=p, speed=6}) -- lite: rolling from these fields
sdk.world_audio.spawn('ghost', 'skater', {source='state_log:state_20261003_143434', from=30, seconds=20, position=p})
local r = sdk.world_audio.read('car1') -- {kind='traffic', audible=true, instance=2, parked=false}
@@ -378,14 +395,8 @@ The hosts match the measured rules:
- Peds: the 15 nearest within 50 m (3-D, nearest-first), footsteps for the 3 nearest.
- NPC skater: the first in list order within 30 m, held until 30 m.
### PedBodyFall and Tazer: gaps
- **Tazer.** The recordings support it: in 164620, each of the 3 tazer zaps (`SECTAZE want`) is followed 0.03 s
later by a burst of 10–20 Tazer-bank starts, 0.1–0.2 s apart, for 1.3–2.1 s (49 starts). No POST is attached,
which fits op 38 child posts; op 38 is ported. But `SFXObj_Tazer` itself (its trigger on the ped state, its
words) is not decoded. Not ported.
- **PedBodyFall.** No recording shows a post from it (no event group with a caller in its code in the
163809 / 164620 reports). Not decoded.
- Neither message is offered in the API.
### PedBodyFall and Tazer
Gaps at the end of P3; closed below ("Gaps closed").
### Mod surface
**Done in this pass (cheap):**
@@ -446,6 +457,135 @@ and §8.6.
- **NPC:** board slide; the walking / jump voices of an NPC on foot; the NPC grain bed against 180430's NPC GREC
rows (not compared in this pass).
## Gaps closed: Tazer, PedBodyFall, speech details, the NPC board slide and bed (2026-10-03)
Built from the P3–P5 state and checked headless against the existing recordings, plus one scripted background
recomp run for the NPC board slide (user-approved gap runs; muted, one game at a time). All numbers are "the
recomp". Reference only: the TU3 recompilation (skate3recomp / rexglue / Xenia); our own code.
### Tazer (`skate_audio::world::peds::PedTazer`)
- **Decoded:** `SFXObj_Tazer` (Pedestrian object 3, factory `sub_824F12D0`). Its process posts the 9-word
`c_tazer` packet while the ped audio state's byte `S+80` is set (the manager's bit 17 of the ped entry) and
releases it when it clears; its update writes raw 0, pitch 1, levels 3 / 2. The program (op 38, ported earlier)
owns a `c_tazer_grn_play` child post and plays the burst while the packet is held.
- **`S+80` = tazing:** each post came 22 ms after the state graph's `TazeEntity` entry (`TazerCycTime` 2.0 s).
- **Checked** (`tazer_bursts_follow_the_recomp`, session 164620: three zaps, 49 Tazer starts): per 2 s hold ours
start 19 sounds (recomp 10 / 20 / 19; its first burst stopped after 1.33 s, cause not traced); gaps
192 / 192 / 128 / 96 ms against 190 / 190 / 130 / 90–100 (the 11th gap 128 against 129 / 131); the order 8, 7,
then shuffles of 0–6 in both; the first start's gain recomp / ours 0.85 / 0.93 / 1.00 at each zap's geometry.
### PedBodyFall (`PedBodyFall`)
- **Decoded:** `SFXObj_PedBodyFall` (object 2). Its trigger `S+76` is the ped animation's `BodyFallType` channel.
Each change to a non-zero value starts one Skate_Collisions sound through the collision Splice object: type 8 →
1184, 9 → 948, any other → 1183 (vault record `923CCB46EF5BF5BA` / `DFEFC9212E0CBD2C`); 8 and 9 round-robin two
slots. Each type follows its own volume / pitch outputs (8: out1 / out2, 9: out3 / out4, other: out5 / out6).
- **The recordings had it after all:** the starts are Splice starts, not POSTs (75 in six sessions). Retail's
knock-downs key 9, other, 9, 9 (0.10 / 0.48 / 0.16 s apart) or 9, 8, 8, other, 9.
- **Checked** (`body_falls_follow_the_recomp`, 73 starts): container by type 73 / 73; voice gain recomp / ours
p50 0.79 (p10 0.25, p90 1.25, n 40; the knocked-down ped is not logged, the ped nearest the player is taken).
### Speech details
- **PEAK filter:** a head shadow by azimuth. The owner's raw azimuth, folded front / back, goes through three
curves of the speech record (centre 600 → 4000 Hz at the side → 600, gain 0.4 → 0.1, Q 3; setup export
`world_tuning.speech_voice`). Every recomp PEAK (6 / 6) lies on the curves at one azimuth; ours at the estimated
geometry 3 / 6 within 10 %. The mixer plays speech on the stream graph (PEAK, Send A, gain, then the filters).
- **Two sends, and the echo submix (ported):** the stream system (`sub_82C5CEF0`) multiplies the gain and both
sends by the speaker's per-voice float (`S+152`, `aud_characteristics` `2087A3290483BB4F`, 0.8–1.4). The
**pre-gain** send (`desc+16`, recomp module `+0x570`) carries PedestrianSpeech **out21** (PlayerSpeech out13)
and feeds the stream slot's **echo submix** (`skate_audio::bus::speech_echo`, built per stream slot by
`sub_82C5E2D8`: high pass = owner filter 22 (skater 14), delay = camera distance × the record's factor / 344 m/s
up to 0.15 s (recomputed every 4 console frames, posted only on change), low pass = filter 23 (skater 15), then
the environment bus). The **post-filter** send (`desc+32`, `+0x7D0`) carries out15 (skater out10) straight to
the environment bus. Correction to the first decode: it had out15 feeding the echo; the code says the pre-gain
send does. Recomp: the pre-gain send = out21 in 17 of 29 lines within 25 % (out15: 10); the post-filter send =
out15 in 19 of 27. Modelled mono: the graph's two-channel Pn21 / Sen0 routing is a unity tap (its gains are not
traced).
- **Value 49 = a phone call:** the ped's phone rings (CellPhone_Rings container 5, now part of setup) and when the
ring ends the ped asks for value 64 (`4402_cell_greet`). Recomp 164620: both rings answered 1.00 / 1.19 s
later; the ring records last 0.84–1.13 s. Without the bank (an install from before this setup) the answer
follows at once (logged).
- **Value 29 (the photographer):** repeats each second while the game flag is set (`LivingWorldAudio::photo_flag`).
- **`Obj:Speech` inputs:** in0 / in1 / in4 are raised while a playing line's speaker is 37–38 / 75–77 (the guards)
/ 1–29 (the pros); in2 / in3 are not traced. They don't touch regular peds, so the recomp near lines' extra
~0.6 dB stays open.
- **Which stream:** the first free one (k0 when both are free: 95 of 109 new lines).
- **Queue timeout unit (settled from the code):** the library's clock `[[0x830CFD94]+16]` is the function behind the
`GetVisualGameTick` Lua binding (`0x8283C2D8`): visual game ticks, one per rendered frame. The console renders
at its ~30 fps cadence, so the port's console-frame count is the same unit.
- **Main-cast channel (ported):** the pros' and the special cast's speech (`maincastspeech.big`, speech-manager
bank 0, channel 0, its own tuning `speech_tuning["0"]`, timers, two streams and mixer bank). Who speaks there:
a model with no living-world type bit and a cast bit / word (`aud_characteristics` `6F2933E977CF40DD` /
`14FD437D190677C8`, plus `D6EA428C2B43E23A` for the pro-on-pro lines; setup `world_tuning.ped_models`). Request
words per event: `sub_824AC898` (`speech_manager::main_cast::request_words`); the main cast's near / far flag is
1 near, 2 far. Senders:
- a pro **ped** (`sub_824AC438`): value 29 → `1014_bored` (141), 28 → `601_ai_greet` (11, outside a challenge),
30 / 51 → 115 or 6, 53 / 54 → 77;
- an NPC **skater's** own process (`SFXObj_PlayerSpeech` non-local, `sub_824DA1B0`;
`skate_audio::world::skater_speech`): pros (`sub_824DA768`) say `101_pos` / `150_pro_pos` on a seen trick and
`104_slam` / `903_race_slam_pc` / `151_pro_slam` on a seen slam, every frame the byte holds; AI skaters
(`sub_824DAAC0`) `101_pos`, `404` / `405` / `903` / `104_spec_slam`, `105_spec_chase`, on change; its crash
(`sub_824DB688`, latched) the message pair `131_collide_object` / `906_aislm`;
- the skater messages by speaker kind (`sub_824DAC00`): bail grunt 8206 / 115, crash 8229 / 125, impact
8233 / 6, hit reaction 8309 / 253, race punch 8310 / 250, gesture 8291 / 247 (the senders of the last four are
engine systems the game does not have yet).
- **Main-cast decode:** setup indexes it always (`speech/maincast.json`, 1283 clips) and decodes its free-roam
events with the living world's (`SKATE_SETUP_SPEECH=1`): **6596 takes, 3.0 h, 922 MB** of 44.1 kHz PCM16.
- **Checked:** `main_cast_lines_are_reachable`: all **82** main-cast lines the recomp streamed in 11 sessions
(906 `AiSlam` ×45, 101 `pos`, 150 `pro_pos`, 104 `Slam`, 130 `col`, 201 `grunt`) are reachable through our
words for their speaker (76 of them of an event the port sends; 130 `_col` has no ported sender).
`a_pro_skater_says_a_main_cast_line`: Ryan Smith's model as an NPC skater sees the player's trick and the main
cast streams `101_24_Smit_pos` at its PlayerSpeech level.
- **Re-verified** (`speech_levels_follow_the_recomp`, 39 lines, now with the voice float): filters 4 of 5, gain
recomp / ours median 0.988 (p10 0.55, p90 1.36), pre-gain send = out21 17 / 29, post-filter send = out15 19 / 27,
PEAK on the curves 6 / 6.
### NPC skater: the board slide and the bed
- **Board slide ported for the NPC instance:** retail's slide code has no local test and its input (the
loose-board state) is computed per skater, so `NpcSkater` runs it from `NpcSkaterAudio::loose_board` (ghosts:
per row of their log). A scripted background run (Mega-Park, 4 min standing, the PLAYERPOST hook, 0 malformed
lines): instance 1 was held 78 s in 12 holds, no NPC bailed, no slide post by either instance: still unobserved.
- **NPC grain bed vs session 180430's NPC rows** (the GREC rows of the NPC object, filtered by owner, joined with
the board's distance; `npc_bed_follows_the_recomp_rows`, 289 straight-roll rows): truck A gain recomp / ours
0–10 m 0.097 / 0.108, 10–20 m 0.045 / 0.065, 20–30 m 0.005 / 0.014; pitch 0.93–0.97 / 0.96. The far bands are
louder in ours: the lookups measure the distance to the local skater, which the rows don't give.
### Mod surface and data (moddability)
- Engine: `PedAudio::{tazing, body_fall}`, `PedTazerEvent`, `PedBodyFallEvent`, `NpcSkaterAudio::{loose_board,
reactions}`, `NpcSkaterReactionEvent`, `LivingWorldAudio::photo_flag`; ped / skater `voice` 1–96 (the pros and
the special cast speak on the main cast).
- Mods: ped options `tazing`, `photo_flag`; lite-skater option `loose_board`; events `tazer` (seconds) and
`body_fall` (kind), skater event `reaction` (value `slam` / `slam_b` / `trick` / `crash` / `chase`, `by`); speech
value 49 works through `speech`; `voice` accepts 1–96. The test mod: F7 tazer, F6 a retail knock-down's keys, F5
a phone call, F4 the photographer, F3 the ghost sees your trick, F2 the ghost switches between its AI voice and
a pro's (24) and crashes.
- Setup data (`world_tuning`): `ped_objects` (fall containers, eq bus, ring container, the tazer hold from the ped
state graph) and `speech_voice` (the PEAK curves, the echo delay factor / per-metre / cap / refresh frames);
`ped_models` gained `cast_bit` / `cast_word` / `cast_word2`; `speech_tuning["0"]` (the main cast's) is now used;
`speech/maincast.json` + the opt-in decode; `CellPhone_Rings.bnk` decoded with its patch tree;
`world_audio.py` joined the audio group's fingerprint. Every field defaults to the retail value and the world
tuning overrides it.
### Proofs and tests
- Local player byte-identical: the e2e bench (13 scenarios + 4 sessions, `row` and `fps300`) from this pass's
starting point (`wg_base`) against the result (`wg_new`, and again after the echo submix and the main cast:
`wg_new2`): all four sets IDENTICAL (26 + 26 + 8 + 8 outputs) both times.
- Main-cast / echo tests: `main_cast_lines_are_reachable` (82 / 82), `a_pro_skater_says_a_main_cast_line`; units
`skater_speech` (pro / living-world choices, crash latch), the echo delay and the echo graph; the mod event
`reaction` and the test mod's F3 / F2.
- New data tests: `tazer_bursts_follow_the_recomp`, `body_falls_follow_the_recomp`,
`npc_bed_follows_the_recomp_rows`, `the_phone_ring_lasts_until_the_recomp_answers`; re-verified
`speech_levels_follow_the_recomp`. New unit tests: peds (tazer, falls, ring, photographer), the PEAK curves, the
mod options / events, the test mod's new keys, the setup export.
### Still open
- Speech: Obj:Speech in2 / in3; the near lines' ~0.6 dB; the echo graph's two-channel routing gains; the
main-cast speaker-slot repeat times for slots 30 / 31 (record `+52` / `+56`); values 30 / 51 also stopping the
speaker's playing line; the game modes the skater choices test (19 / 20 / 55: free skate is none of them, the
port passes 0); the cameraman line the crash can request; senders of the hit-reaction / race-punch / gesture /
skater-collision lines (no such engine systems yet).
- NPC: the board slide is ported but unobserved; the walking / jump voices.
- Traffic: the 3DObjPos binding's writer; horn kinds per model.
## Open questions before P3 (kept for the record)
- **Speech playback** in the host: the manager, the library and the streams. The level and pan
@@ -686,3 +686,25 @@ is updated. Phase 4 adds a second example, a "louder horns / custom engine" mod
| The `SKATE_AEMS_*` switches are process env vars | throughout `game_audio` | per-mod or per-session toggles |
| No AEMS post / global / MixMap read access and no audio event callbacks in the SDK | `skate-mods` | play retail sounds by name, react to game sounds |
| The world owners' `rand()` generator (`Lcg(0x5EED)`) | `world_sources.rs:216` | seed for reproducible mod tests (minor) |
## 10. Gaps closed (2026-10-03, doc 15 "Gaps closed")
- **Tazer** ported: `SFXObj_Tazer` decoded (`world-ped-audio.md` "Tazer and PedBodyFall ported"); `c_tazer` held
while `S+80`. vs 164620: 19 starts per 2 s hold (recomp 10 / 20 / 19 = 49), gaps 192 / 192 / 128 / 96 vs
190 / 190 / 130 / 90–100, first-start gain recomp / ours 0.85–1.00, order 8, 7, shuffles of 0–6.
- **PedBodyFall** ported: the trigger is the animation's `BodyFallType` (`S+76`); the recordings hold 75 starts
(SPLC, not POST). 73 / 73 containers by type; voice gain recomp / ours p50 0.79.
- **Speech** (`world-speech.md` "Speech details resolved"): PEAK = azimuth head-shadow curves (6 / 6 recomp pairs on
the curves), Send A = out21 (pre-gain) and Send B = out15 (echo submix, not played), value 49 = phone ring →
64 (2 / 2 in 164620), value 29 = 1 s repeat with the game flag, Obj:Speech in0 / in1 / in4 by speaker id ranges,
first-free stream (95 / 109), constructor last = 68, main-cast mapping decoded (port needs the main-cast
index / decode). Open: the queue clock's unit, the echo submix, in2 / in3, the per-voice float, the near lines'
+0.6 dB.
- **NPC board slide** ported on static evidence (no local gate; `+780` per skater entry). Scripted background run (Mega-Park, 4 min standing, PLAYERPOST, 0 malformed): 12 instance-1 holds (78 s), no
NPC bail, so no slot-15 post by either instance: still unobserved.
- **NPC grain bed vs 180430** (filtered by owner): near band A gain 0.097 / 0.108, further bands louder in ours
(the local-skater distance is not joined); pitch matches.
- **Update (same day): echo submix and main cast ported** (`world-speech.md` "The echo send and the main cast"). The
pre-gain send (out21) feeds the echo submix, out15 goes to the env bus (corrects the line above); the voice float
is applied; the queue clock = visual game ticks. Main-cast channel: pro peds, NPC skater reactions / crash
(`NpcSkaterAudio::reactions`, `NpcSkaterReactionEvent`, mod event `reaction`), the message pairs; decode 6596
takes / 922 MB; all 82 recorded main-cast lines reachable through the ported words.
@@ -92,3 +92,14 @@ remove despawned skaters. Off switch `SKATE_AEMS_NPC_SKATERS=0`.
- The manager's gate (`[0x830670B8+20]` == 5, `sys+560` == 1) and the skater list order of the AI manager.
- The NPC's speech (`sub_824BF5F8`, SkaterSpeech manager `sub_824F7D10`: one record per skater, no distance gate
there) belongs to the speech stage.
## Board slide and the NPC bed vs the recomp (2026-10-03)
- **Board slide ported for the NPC instance:** `sub_824CB3C8` / `sub_824CB4C0` have no local test; `+780` comes from
the conditioner per skater entry and the bridge `sub_824B0DA8` copies it, so `NpcSkater` runs `BoardSlide` with
`NpcSkaterAudioState::loose_board` (`NpcSkaterAudio::loose_board`; ghosts take it per row from their log; mods
`loose_board`). A scripted background recomp run (Mega-Park, 4 min standing) saw no NPC bail and so no board slide post.
- **NPC grain bed vs 180430's NPC GREC rows** (owner `40C34020`, 227 k rows; a local research tool thins to 100 ms and
joins the NPC board by SKATEB speed; test `npc_bed_follows_the_recomp_rows`, 289 straight-roll rows): truck 0 A
record gain recomp / ours 0–10 m 0.097 / 0.108, 10–20 m 0.045 / 0.065, 20–30 m 0.005 / 0.014; A pitch 0.93–0.97
vs 0.96. Near band within 10 %; the far bands are louder in ours (the lookups use the distance to the local
skater, not joined: our sim keeps the local player at the camera).
@@ -91,3 +91,34 @@ Every B lookup reads Ctl 5.1, the ped's 3DObjPos.
- The `sub_824D8E60` flag.
- The 3 % of retail slots we do not produce (above).
- Instance assignment: nearest 15, provisional.
## Tazer and PedBodyFall ported (2026-10-03, doc 15 "Gaps closed")
- **SFXObj_Tazer** (object 5.3, vtable `0x822FCB60`, factory `sub_824F12D0`, 40 bytes): process `sub_824F1478`
(gate `[obj+16]+52`): while the ped audio state's byte `S+80` (= the manager's bit 17 of entry `+4`) is set and
no packet is held, post `c_tazer` (`sub_824B7950`, class descriptor `0x8302EEF8`, 9 words
`[32767, 0, 4096, 0, 25000, 0, 0, 0, G+4 byte]`, G = `*(0x83083C38)+0x2F070`, 0 in free skate); when it clears,
release. Update `sub_824F1560`: w1 raw0, w2 pitch1 (≤ 8192), w6 level3, w7 level2. The program owns a child post
(`c_tazer_grn_play`, op 38) and plays the burst while held: samples 8, 7, then shuffles of 0–6.
- `S+80` = tazing: the post comes 22 ms after the state graph's `TazeEntity` entry (PEDTIMER `TazerCycTime`
2.0 s, `pedestrian_wanttotaze.xml`), 3 / 3 zaps of 164620; bursts 2 / 3 last 2.1 / 2.0 s (the hold); burst 1
ended after 1.33 s (cause not traced).
- **Check** (`tazer_bursts_follow_the_recomp`, the export of a local research tool): ours 19 starts per 2 s hold vs the
recomp's 10 / 20 / 19 (49); gaps 192 / 192 / 128 / 96… vs 190 / 190 / 130 / 90–100, the 11th gap 128 vs 129 /
131; first-start gain recomp / ours 0.85 / 0.93 / 1.00 at the recorded geometry; order 8, 7, shuffles.
- **SFXObj_PedBodyFall** (object 5.2, vtable `0x822FCB18`, factory `sub_824F0880`): process `sub_824F0AB8`:
`S+76` (float, = entry `+16`) is the ped animation's **`BodyFallType`** channel (the string sits with
`RightToeDown` / `LeftToeDown` in the animation-event table at `0x820648F8`). On a change to a non-zero value one
Skate_Collisions sound starts through `sub_82497F48` (mono submix on eEQChain bus `[holder +140 field
B4C4F86A53963BA2]` = absent → 0, create): 8 → container 1184 (2 round-robin slots `+40/+44`, index `+60`),
9 → 948 (`+48/+52`, `+64`), other → 1183 (`+56`); containers from class `923CCB46EF5BF5BA` record
`DFEFC9212E0CBD2C` (holder `+124`). Update `sub_824F0E50`: per type its own volume / pitch output (8: out1 / out2,
9: out3 / out4, other: out5 / out6), raw0 azimuth, dt, env send level(7) (`sub_82498140`).
- **The recordings DO show it** (the earlier "no post" looked at POST lines; the starts are SPLC with chain
`82497FAC<824F0BE8|824F0D10|824F0E0C`): 75 starts in 6 sessions, the knock-down patterns 9, +0.10 other, +0.48 9,
+0.16 9 and 9, +0.13 8, +0.07 8, +0.10 other, +0.38 9 and 9, +0.15 other.
- **Check** (`body_falls_follow_the_recomp`, 73 starts with sample resolution): container 73 / 73 by type; the
recomp's sample is one the container plays in 57; voice gain recomp / ours p50 0.79 (p10 0.25, p90 1.25, n 40;
the knocked-down ped is guessed = the ped nearest the player).
- Engine: `PedAudio::{tazing, body_fall}`, messages `PedTazerEvent { seconds }` (default `ped_objects.tazer_seconds`
= TazerCycTime) and `PedBodyFallEvent { kind }` (queued, one console frame on, one off). Mod: ped option `tazing`,
events `tazer` / `body_fall`. Setup: `world_tuning.ped_objects` (ids, eq, tazer seconds from the xml).
@@ -232,3 +232,86 @@ Harness: `tests/world_sources.rs` `a_bumped_ped_warns_with_a_line_of_its_voice`.
3. The request queue and stream count for the living world (why lines overlap in the recomp).
4. Main cast / cameraman / announcer: they use the same library and already parse. Their managers differ (other vault
types, `sub_824AC560`).
## Speech details resolved (2026-10-03, doc 15 "Gaps closed")
- **Block layout** (PedestrianSpeech update `sub_824D9370`, block = obj+44; PlayerSpeech `sub_824DA300`, obj+48):
+4 speaker voice id (parsed from the clip name by `sub_824A89E8`), +8 main level, +12 the per-voice float
(S+152), +16 raw azimuth, +20 pitch, +24 HP (ped out13 / skater out8), +28 LP (out14 / out9), +32 second level
(out15… / out10…), +36/+40/+44 the PEAK curves' values, +48 a delay = min(S+148 × 1.0 [field BF48032DB145C5B4]
× 1/344, 0.15) s refreshed every 4 frames (field 510BAFA32A76B340), +69 far flag, +72 the stream the speaker
holds (−1 none), +76 speaking, +80 level 21 (ped) / 13 (skater), +84/+88 filters 22/23 (ped) / 14/15 (skater).
- **PEAK filter = head shadow by azimuth.** raw out0 folded (> 32767 → 65536 − raw), then three 8-point curves of
record `B29C3B2C13D96482` `default` (holder `*(0x830CFDA4)+44`): centre `2C166907CF51DB88` (600 → 4000 at the
side → 600), gain `EA2C18D9CE5CBA3A` (0.4 → 0.1), Q `CF8679F540B82B2B` (3). Recomp 163809: 6 / 6 PEAK (centre,
gain) pairs lie on the two curves at one azimuth (≤ 1 Hz); ours at the joined geometry 3 / 6 within 10 % (camera
orientation estimated). Ported: `SpeechVoiceTuning`, export `world_tuning.speech_voice`, mixer stream graph.
- **Two sends.** The stream voice graph (`sub_82C5C318`): Rsp0 → PI20 → Sen0 (Send A, pre-gain, env) → Gai0 →
HI20 → LI20 → Sen0 (Send B) → Pn21 → Sen0. Recomp modules `+0x570` (A) and `+0x7D0` (B). A = level 21 (ped) /
13 (skater): 17 of 29 lines within 25 % (out15 there: 11); B = out15: 18 of 27. Send B feeds the stream slot's
echo submix (`sub_82C5E2D8`: Sub0 → HI20 → Del0 → LI20 → Pn21 → Sen0 → the env bus; delay = block +48; filters
likely 22 / 23): **not ported** (Send B is computed, not played).
- **Value 49** = the phone: `sub_824D9C70` stops the old ring and starts CellPhone_Rings (Splice index 6) container 5
(class `C1831BDB6CB1B1EA` `cellphone` field `031EFDF991638985`); `sub_824D9AD8` (end of the update) follows it
with the main level / pitch / azimuth and, when it ends, requests value **64** (`4402_cell_greet`). Recomp
164620: SPLC 6/5 at 88.73 → 4402 HelCel 89.73; 234.38 → 235.57; the rings' records last 0.84–1.13 s.
- **Value 29**: while system byte `*(0x830CFDC4)+912` is set, obj+160 accumulates dt; at ≥ 1.0 (image constant) it
resets and the request repeats (the flag's meaning not traced: `LivingWorldAudio::photo_flag`).
- **Obj:Speech inputs** (SFXObj_Speech process `sub_824E2050`, every frame): in0 / in1 / in4 = 32767 while a playing
line's speaker (block +4) is 37–38 / 75–77 / 1–29; in2 = speech-system word `+0x1BD28` == 2; in3 = a block with
+60 == 0 and +93 set (neither traced). F45 (+200 mB on ped out2 / out3) is in0 → not regular peds: the ~+0.6 dB
of the recomp's near lines stays unexplained (E0's other terms A[Global.2], F[Global.28] / [108], A[Global.112]).
Ported: in0 / in1 / in4.
- **Which stream**: a new line takes the first free stream (k0 when both are free: 95 of 109 new lines in
163809 / 164620 / 180430). The interrupt reads record `channel × 2 + block +72` (the speaker's own stream;
−1 aliases the previous channel's second record).
- **Queue timeout unit**: the library's clock = the speech system's time callback `sub_824A4E90` =
`[[0x830CFD94]+16]` (installed by `sub_82C5EC20`); its unit was not settled from the code (no writer found).
- **Constructor** `sub_824D90D8`: obj+36 (last value) = 68, obj+40 = 8318, +156 = −1 (ported: `PedSpeech::default`).
- **Main-cast path (pros, S+116 == 0)**: `sub_824AC438`: 29 → event 141 (speaker word 136), 28 → 11 (when
`sub_82487ED0` is false and system +1196 == 0), 30 / 51 → stop the speaker's stream, then 6 or 115 (`rand() & 1`),
53 / 54 → 77, else nothing; request `sub_824AC560` (event id → index in a 72-entry table at `0x8224CB80`, vault
record at mgr + (index + 194) × 8, tuning bank "0" — exported) with words [S+100, S+104, flag 2/1 swapped, 0,
S+108, S+112]. **Not ported:** needs the main-cast speech index + decode (`maincastspeech.big`).
## The echo send and the main cast (2026-10-03, ported; corrects "Two sends" and "Queue timeout unit" above)
- **The stream system** (`sub_82C5CEF0`, every frame per speech stream): gain = block +8 / 32767 × block +12 (the
per-voice float `S+152`, `aud_characteristics` `2087A3290483BB4F`, 0.8–1.4); `Send(desc+32)` = block +32 (ped
out15 / skater out10) × the float, the post-filter send straight into the environment bus; `Send(desc+16)` =
block +80 (ped out21 / skater out13) × the float, the pre-gain send into the slot's echo submix. The echo's
HI20 = block +84 (ped filter 22 / skater 14), LI20 = block +88 (23 / 15), Del0 = block +48, posted only when it
changed; Pn21 and Sen0 keep their class defaults. **Correction:** the first decode had Send B (out15) feeding
the echo; the code says the pre-gain send (out21) does, and out15 goes to the environment bus.
- **Delay refresh** (`sub_824D9370`): a per-speaker countdown; at ≤ 0 the delay is recomputed from the camera
distance and the count reloads with field `510BAFA32A76B340` (4).
- **Ported:** `skate_audio::bus::speech_echo` (one graph per stream slot, channel × 2 + k; mono, the Pn21 / Sen0
routing as a unity tap), the mixer's stream path (PEAK → echo send → gain → HPF / LPF → env send), the voice
float, the delay countdown. Setup `world_tuning.speech_voice` carries `delay_factor` / `delay_frames`.
- **Recomp, re-verified** (39 lines, 163809 / 164620 / 180430, with the voice float): pre-gain send (`+0x570`) =
out21 within 25 % in 17 of 29 (out15: 10); env send (`+0x7D0`) = out15 in 19 of 27; gain median 0.988
(p10 0.55, p90 1.36); filters 4 of 5; PEAK on the curves 6 / 6.
- **Queue timeout unit (settled):** `[[0x830CFD94]+16]` is the function behind the `GetVisualGameTick` Lua binding
(`0x8283C2D8`): visual game ticks, one per rendered frame (the console's ~30 fps), the port's console frames.
- **Main-cast channel** (bank 0, channel 0; `maincastspeech.big`, 1283 clips, 73 events, tuning `speech_tuning["0"]`):
- who: a model with no living-world type bit and a cast bit / word (`6F2933E977CF40DD` = the pros' bit 1 << (n−1),
`14FD437D190677C8` = the special cast 30–38, `D6EA428C2B43E23A` = the word another pro's line names it by);
- words (`sub_824AC898`, block w0 = cast bit, w1 = cast word, w2 = 1 near / 2 far, w4 / w5 = the other skater's
cast bit / `D6EA` word): events 0 / 1 → [w1, w0, w2]; 247 → [w0, w9, w1]; 251 → [w0, w1, w10];
254 → [w1, w5, w0, w4]; 268 → [w0, w1, w11]; 271 / 274 / 275 → [w0, w1, w12]; 280 → [w0, w1, w13];
287 / 288 → [w1, w0, w4, w5]; else [w0, w1];
- NPC skater speech (`SFXObj_PlayerSpeech` non-local process `sub_824DA1B0`, inputs from the skater entry via
`sub_824B6E80`: `+102` = entry `+392` bit 4, `+103` = bit 3, `+104` = entry `+396` bit 10, `+131` = entry `+388`
bit 5). Living-world voices (`sub_824DAAC0`): `+102` / `+103` → 8204 in mode 55, 8239 in modes 19 / 20, else
8234 | (`rand()` & 1); `+104` → 8202; the chase flag (system `+1100` bit 6) → 8205 when nothing else; a request
only on change. Pros (`sub_824DA768`): `+102` → 1 (16 in modes 19 / 20 by the player) or 288 by a pro
(`+112`); `+103` → 1 / 288 by `+116`; `+104` → 0 / 287 by `+108`; requested every frame the byte holds. The
skater's crash (`sub_824DB688`, `+131` rising, latched in obj `+193`): message 8229 / 125;
- skater message pairs (`sub_824DAC00`, living-world / main-cast): bail grunt 8206 / 115, crash 8229 / 125,
impact 8233 / 6 and 8241 / 125 (`sub_824BD358`), hit reaction 8309 / 253 and race punch 8310 / 250
(`sub_824EFA60`), gesture 8291 / 247 (`sub_824F8778`).
- **Main-cast decode:** the free-roam events (101, 104, 130, 150, 151, 201, 202, 335, 336, 338, 344, 500, 501, 601,
903, 906, 913, 1014): 6596 takes, 3.0 h, 922 MB of 44.1 kHz PCM16 (opt-in with the living world's).
- **Recomp check:** 82 main-cast lines streamed in 11 sessions (READs of `maincastspeech.big`: 906 `AiSlam` ×45,
101 `pos`, 150 `pro_pos`, 104 `Slam`, 130 `col`, 201 `grunt`): all reachable through the ported words for their
speaker (76 of an event the port sends; 130 `_col` has no ported sender).
- **Not ported:** the repeat times of speaker slots 30 / 31 (record `+52` / `+56`); values 30 / 51 stopping the
speaker's stream first; the cameraman line of the crash; the game modes (free skate passes 0).