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.
22 KiB
World speech (peds) — data, mechanism, port state (2026-10-03)
Port:
crates/skate-audio/src/world/speech.rs: the index;speech_manager.rs: value → event, the gate, the request words;speech_rules.rs: the.evtparser and the line and take choice;peds.rs:PedSpeech.
Setup: tools/asset_pipeline/world_audio.py:
speech_index, withrulesand each clip'sid/history;world_tuning→speech_tuning;- opt-in
decode_speech.
Tests: the skate-audio world::speech_* unit tests, and tests/world_speech.rs on the dev install export.
Trace check: the local tool speech_takes.py <sessions…> (the take of each READ, the history rule, the sequence
records).
Dev install staging: stage_world_audio.py [--decode 501,104,205,101|all] (local tool).
Harness: tests/world_sources.rs a_bumped_ped_warns_with_a_line_of_its_voice.
Data (data/audio/english/livingworldspeech.big, EB v3)
- Clips. 3,011
.datclips named<event>_<voice>[_<voice name>]_<line>.dat. Examples:501_59_busm1_Warn_n1901_53_Shout806_47_adtf2_Int_c14_tour- Voice ids run 41–96 (
adtm1,adtf1/2/4,grn1/2,joc2/3,busm1–3,busw1–3,tenm1–3,stdf1,tenf2/3,secg1/2,torm1–3,torf1,bum1–3,sktm1/4/5,sktf1–3). Clips without a name in the file name use a generic voice (53, 91, …).
- Archives inside (EB v3, read at boot):
livingworldhdr.big: one<clip>.hdrper clip. Layout: +2 u16 take count, +14 u16 offsets of each take's.sthrow.livingworldsth.big: one<clip>.sthper clip. 12-byte rows = u32 byte offset of the take in the.dat+ the take's 8-byte EA SNR header.
- Takes. All are codec 3 (EA-XMA2), mono, 36 kHz. A clip holds 1–48 takes, 20,096 in all and 14.3 h. Free-roam events come to about 9.3 h, about 2.4 GB as PCM16; events 101/104/205/501 alone are 518 MB.
- Decoding. vgmstream decodes
<take>.snr(the 8 header bytes) plus<take>.sns(the.datslice) pairs. - Recomp reads. READ lines point at a take's offset inside the clip, not only the clip start (fixed in
speech_nearfar.py).speech_takes.pymaps each READ to its clip and take. livingworld_Events.evt(114 KB): the speech library's event table, 81 events (501_warn= 0x2012, …).-
Decoded:
world_audio.parse_evtandspeech_rules::EventTable::parse. All four banks parse: maincast bank 0, living world 1, announcer 3, cameraman 4. -
Header:
- +4: u32 offset of the name table (32-byte rows);
- +8: bank; +9: sub-bank;
- +0x10: u16 event count;
- +0x18: u16 event offsets × 4.
-
Event:
- u16 id, u16 queue timeout (60–180), u16 priority (500–550);
- u8 record count;
- u8 external conditions (0 in all banks);
- u8 flags (high nibble = field count);
- u8 probability % (100);
- u8 flags2 (0), then one more byte;
- u16 record offsets × 4;
- 3-byte field descriptors
FF <request word> 04.
-
Record:
- weight code (0x39 = 4 × 25 everywhere in the living world);
- probability (100);
- clips << 2 | mode;
- locals;
- field count, then 3 pad bytes;
- one byte per clip (offset × 4);
- u32 field masks (0 = any);
- 8-byte clip entries (u16
.hdrid; the rest is 0 in the living world).
-
Fields:
- 1 = speaker type bit;
- 2 = voice variant bit;
- 3 depends on the event: the near/far flag, zombie (1901, 201, 606, 609), the conversation partner's type (806 / 807), or the conversation word (550–557);
- 4 = zombie for 603.
-
Type bits (from the voices of their records):
bit type voices 0x1 adult m 41 / 42 / 43 0x2 adult f 46 / 47 / 49 0x4 granny 51 / 52 0x8 jock 55 / 56 0x10 teen f 72 / 73 / 74 0x20 teen m 69 / 70 / 71 0x40 security 75 / 76 / 77 0x80 tourist m 82 / 83 / 84 0x100 tourist f 85, 53 0x200 skater m 89, 91, 92, 90 0x400 skater f 94 / 95 / 96, 93 0x1000 business m 59–61 0x2000 business w 64–66 0x4000 bum 87, 88, 86 Variant bits: 1, 2, 4, 8 and 16.
-
133 of the 3,348 living-world clip references have no clip (for example 603's tazer lines for some voices). Those records never play.
-
.hdr:- u16 id;
- +2 flags (0 in all banks);
- +3 take count;
- +4 runtime;
- +8 history length: the take count, or 0 for 685 clips (conversations 550–557, zombie lines, 497 Silence);
- +9 / +10 size scale;
- +16 u16
.sthrow offsets; - then the history ring (cursor + entries, 0xFF = empty).
Mechanism (TU3)
- State graphs. The ped state graphs (
data/state/livingworldentities/pedestrian/aigraph/*.xml) sendSendSpeechEvent speechevent=… speechvalue=Non state entry. The values are listed inspeech::SPEECH_VALUES, for example 10 CollisionNearbyReaction (wanttoobserve), 25 StopCheer (slamreaction), 23 LongCheer (nearbyskatertrick), 20 Flee, 14 JoinChase, 17 AttemptTakeDown, 19 TakeDownSuccess, 12 ChaseResting, 66 EscapedEndChase.- The warn (11,
pedestrian_dowarning.xml) and takedown success are commented out ("moved to the code"). - The value lands in the ped audio state +136. Footstep packets read the same field (jump / collision).
- The warn (11,
- SFXObj_PedestrianSpeech process
sub_824D9908:- When +136 changes, it builds a request to the speech manager
*(0x830CFDDC):sub_824AB6C8(orsub_824AC438with a target). - flag = 1 if +148 > +156 else 2.
- Values 7 / 8 are remapped to 30 after a 29, else 51.
- Value 49 goes to
sub_824D9C70. - Value 29 (photographer) repeats on a timer while a global flag is set.
- Ported:
PedSpeech::process(49 and the 29 timer are not).
- When +136 changes, it builds a request to the speech manager
- Speech manager (
Sk8::Audio::TheSpeechSystem, global*(0x830CFDDC)). Ported (speech_manager.rs).- Request block (
sub_824D9908):- w0 =
+96type bit, w1 =+88variant bit, w2 = flag, w3 =+92, w4 =+120, w5 =+124; - speaker slot =
+84. - The living-world path needs
+116 != 0. Otherwisesub_824AC438sends main-cast events (pros; not ported).
- w0 =
- Value → event (
sub_824AB6C8): the table inevent_for_value.- Coin flips use
rand(); bums userand() % 3; guards get radio lines. - Value 6 with
+71also sends main-cast event 0x8017 (not ported).
- Coin flips use
- The request (
sub_824ABA18), in order:- Game-state preconditions.
- The vault tuning of the event (class
Hash_9C1F48F5D637E275,SPCHType_1_EventID). - Timers (
sub_824A8C78): one per speaker slot × 81 events, f32 seconds, starting at 0.- The same event needs ≥
+24s. - Any event needs ≥
+8s, when+8> 0. - Not-follow needs strictly more than its time. Ids 0 / 294 / 8318 / 33245 / 24752 are skipped.
- The same event needs ≥
sub_824A75F0:- probability:
rand() % 1000 × 0.1f<+20, or ≥ 99.9; - the player's speed × 3.6 within
+32/+36; - the challenge list;
- the
+40/+44timers; - the
+49..51game flags; - zombie mode needs
+60.
- probability:
- w9 = 2 in zombie mode (virtual
[-620]+212vfunc 156; thezomb_Shoutrecords prove it). sub_824A73F0: interrupt by priority (not ported).sub_824ABD90: the request words of each event.
- The timers restart when the line starts (
sub_824A90B8fromsub_824A84B8).
- Request block (
- Speech library (generic,
sub_829717A0…). Ported (speech_rules.rs).sub_82971480: the event probability (fails when(draw>>16)·100>>16 > p), then the queue slot (not ported).sub_82973CB8:- the weighted record order (
sub_82972980): weight = 4^(b>>5)·(b&31), scale table at 0x82FDB3D8; - per record: a probability draw, the field match (
sub_82973BD8) and the clips (sub_82972D70: at most 12; a missing clip fails the record).
- the weighted record order (
- Candidates per clip (
sub_82972660): the takes not in the history ring; when every take is in it, the oldest one. - Pick (
sub_82974220):- index =
(draw>>16)·n>>16; - redrawn (at most 32 draws) while it is among the last min(n/2, 10) picks of the same header in a global 32-entry (index, header) ring;
- if every draw hits, the one whose match is oldest.
- index =
sub_82973408: writes the history when the line starts.- Generator: add-with-carry at 0x82FDB3C0. Retail shares this state with the grain player's title generator.
- The queue is not ported (
sub_82971340/sub_82971890/sub_82971DA8):- 16 request slots and 8 streams;
- highest priority wins, then the newest; older requests are dropped;
- timeout = event +2, in a clock whose unit is not known.
- The recomp plays several living-world lines at once (
overlap.py(local script)), so the living-world channel is not exclusive. How is not resolved.
Reaction → event (measured; speech_reads.py, sessions 161849 / 163809 / 164620; the clip starts 0.03 s after)
| reaction | events (evidence) |
|---|---|
| bump → warn | 501 Warn_n (M) |
| bump → warn + taze (female) | 501 Warn_f (M); 330 / 331 tazer lines (name) |
| bump → flee | 108 ChsFlee (M) |
| slam nearby | 104 Slam_n (M) |
| collision nearby | 205 SpecCol, 204 Gasp, 202 HImpRct (M) |
| trick nearby | 101 SpecPos_n (M) |
| chase start | 105 SpecChs bystanders (M), 603 chaser (name) |
| greet / returngreet / conversation | 1901 Shout, 806 Int_c, 807 Rct_c (M) |
| ambient (no reaction) | phone 4402 → 805 → 4405 with 497 Silence; bums 206 / 207; guard radio 102; pro lines from maincast (906/101/130) |
speech::REACTION_CUES holds this table, for logs and as evidence. The real choice is SpeechManager::request.
speech::choose (uniform) is only a fallback for hosts without the rules export.
_n/_flines: solved from the data. Request flag 1 (ped+148 > +156) means far.- The flag-1 records name
101_51_GenPos_Grn1_far/104_51_grn1_Slam_far, and the flag-2 records…_near. - All 366
_f/_nrecords of 101 / 104 / 202 / 203 / 501 split the same way (tests/world_speech.rs). - Settled 2026-10-03 (
world-audio-hookin-spec.md§7.3 G2):+148= the ped's distance to the listener,+156=aud_characteristicsfieldA27215A909135B62(20 m for peds, 30 for pros), so_flines play beyond 20 m. - (Was:) what
+148/+156hold (a distance and a threshold?) is not traced.speech_nearfar.py's distance join is still unreliable.
- The flag-1 records name
- Take choice vs the recomp (
speech_takes.py, 10 clean sessions; reads inside > 500 ms audio stalls left out):- 57 of 57 clips with 2+ plays follow the history rule, including a full cycle. A uniform pick would have repeated a take in about 13.6 of them.
- 15 of 15 multi-clip lines are one record, in order.
- The first takes of 806 lines are often 0 (6 of 13 in 164620). This may be chance, or a pairing we cannot see. Keep an eye on it.
- Coverage. 40 voices have 501 and 104 lines, 39 have 205, 36 have 101 (harness print).
Playback (port)
speech::SPEECH_BANK(1 << 22) mixer bank.SpeechSlotsmaps (clip, take) → slot. Takes play as direct voices.- The harness renders a busm1 warn take (5.49 s, peak −9 dBFS at unity gain).
- Level / pan: open (next step).
- The stream request (
sub_82973408→sub_82C5F310) is 9 words: offset, size, channel, and the context (ped+64). It goes to a SpeechBank stream. The voice's level is set where those requests are used; that code is not found yet. - A lead: in 163809 a speech READ comes with a
GAIN … 0.0 → 1.0and aPITCHratio of ~0.52–0.70 on one voice (0x40C43350). - The speech manager reads the PedestrianSpeech owner outputs itself, and which of its 24 outputs is not known (E0 out2 −1000 dB via B2 is the main volume candidate; out13/14 filters; out11/12 via B20 = 2–70 m camera distance).
- The stream request (
Waiting on the engine
-
The ped system must provide:
- speech values on state entry (
PedState.speech_value); - the speaker fields (
Speaker: slot, type bit, variant bit, partner type); - the near/far pair;
- zombie mode;
- the player's speed;
- a manager clock.
- speech values on state entry (
-
The game host: world_sources logs requests (
AUDIO_WORLD speech …) but plays nothing. To play, it needs:- the index and
rulesfromspeech/livingworld.json→SpeechIndex::set_ids,EventTable,Library::new(ClipHeader…); world_tuning.speech_tuning["1"]→EventTuning;SpeechManager::requestfor eachSpeechRequest;SpeechIndex::picks_to_lines→SpeechSlotswith the decoded takes, played in sequence;- the level mapping.
The JSON → struct mapping is in
tests/world_speech.rsload(). - the index and
-
Maincast / cameraman / announcer speech (pro skater lines around the player, the cameraman's "own the spot"): same format, not indexed yet.
Next steps
- Level / pan: find the code that uses the SpeechBank stream requests (the 9-word requests
sub_82C5F338queues) and the speech voice's GAIN / SEND. Or hook it (categoryaudio) and join it with the ped distance. - What
+148/+156are: find who writes the ped audio state, or add a first-pass hook onsub_824D9908that logs both. - The request queue and stream count for the living world (why lines overlap in the recomp).
- 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; PlayerSpeechsub_824DA300, obj+48): +4 speaker voice id (parsed from the clip name bysub_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
B29C3B2C13D96482default(holder*(0x830CFDA4)+44): centre2C166907CF51DB88(600 → 4000 at the side → 600), gainEA2C18D9CE5CBA3A(0.4 → 0.1), QCF8679F540B82B2B(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, exportworld_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_824D9C70stops the old ring and starts CellPhone_Rings (Splice index 6) container 5 (classC1831BDB6CB1B1EAcellphonefield031EFDF991638985);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)+912is 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 bysub_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 (whensub_82487ED0is false and system +1196 == 0), 30 / 51 → stop the speaker's stream, then 6 or 115 (rand() & 1), 53 / 54 → 77, else nothing; requestsub_824AC560(event id → index in a 72-entry table at0x8224CB80, 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 floatS+152,aud_characteristics2087A3290483BB4F, 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 field510BAFA32A76B340(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. Setupworld_tuning.speech_voicecarriesdelay_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 theGetVisualGameTickLua 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, tuningspeech_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 /D6EAword): 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_PlayerSpeechnon-local processsub_824DA1B0, inputs from the skater entry viasub_824B6E80:+102= entry+392bit 4,+103= bit 3,+104= entry+396bit 10,+131= entry+388bit 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+1100bit 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,+131rising, 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).
- who: a model with no living-world type bit and a cast bit / word (
- 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: 906AiSlam×45, 101pos, 150pro_pos, 104Slam, 130col, 201grunt): all reachable through the ported words for their speaker (76 of an event the port sends; 130_colhas 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).