Files
skate-3-rust-engine/docs/hails-additions/audio-specs/world-speech.md
T
Hails 7ef35c45b0 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.
2026-10-03 21:17:18 -06:00

22 KiB
Raw Blame History

World speech (peds) — data, mechanism, port state (2026-10-03)

Port:

  • crates/skate-audio/src/world/speech.rs: the index;
  • speech_manager.rs: value → event, the gate, the request words;
  • speech_rules.rs: the .evt parser and the line and take choice;
  • peds.rs: PedSpeech.

Setup: tools/asset_pipeline/world_audio.py:

  • speech_index, with rules and each clip's id / history;
  • world_tuning → speech_tuning;
  • opt-in decode_speech.

Tests: the skate-audio world::speech_* unit tests, and tests/world_speech.rs on the dev install export.

Trace check: the local tool speech_takes.py <sessions…> (the take of each READ, the history rule, the sequence records). Dev install staging: stage_world_audio.py [--decode 501,104,205,101|all] (local tool). Harness: tests/world_sources.rs a_bumped_ped_warns_with_a_line_of_its_voice.

Data (data/audio/english/livingworldspeech.big, EB v3)

  • Clips. 3,011 .dat clips named <event>_<voice>[_<voice name>]_<line>.dat. Examples:
    • 501_59_busm1_Warn_n
    • 1901_53_Shout
    • 806_47_adtf2_Int_c14_tour
    • Voice ids run 41–96 (adtm1, adtf1/2/4, grn1/2, joc2/3, busm1–3, busw1–3, tenm1–3, stdf1, tenf2/3, secg1/2, torm1–3, torf1, bum1–3, sktm1/4/5, sktf1–3). Clips without a name in the file name use a generic voice (53, 91, …).
  • Archives inside (EB v3, read at boot):
    • livingworldhdr.big: one <clip>.hdr per clip. Layout: +2 u16 take count, +14 u16 offsets of each take's .sth row.
    • livingworldsth.big: one <clip>.sth per clip. 12-byte rows = u32 byte offset of the take in the .dat + the take's 8-byte EA SNR header.
  • Takes. All are codec 3 (EA-XMA2), mono, 36 kHz. A clip holds 1–48 takes, 20,096 in all and 14.3 h. Free-roam events come to about 9.3 h, about 2.4 GB as PCM16; events 101/104/205/501 alone are 518 MB.
  • Decoding. vgmstream decodes <take>.snr (the 8 header bytes) plus <take>.sns (the .dat slice) pairs.
  • Recomp reads. READ lines point at a take's offset inside the clip, not only the clip start (fixed in speech_nearfar.py). speech_takes.py maps each READ to its clip and take.
  • livingworld_Events.evt (114 KB): the speech library's event table, 81 events (501_warn = 0x2012, …).
    • Decoded: world_audio.parse_evt and speech_rules::EventTable::parse. All four banks parse: maincast bank 0, living world 1, announcer 3, cameraman 4.

    • Header:

      • +4: u32 offset of the name table (32-byte rows);
      • +8: bank; +9: sub-bank;
      • +0x10: u16 event count;
      • +0x18: u16 event offsets × 4.
    • Event:

      • u16 id, u16 queue timeout (60–180), u16 priority (500–550);
      • u8 record count;
      • u8 external conditions (0 in all banks);
      • u8 flags (high nibble = field count);
      • u8 probability % (100);
      • u8 flags2 (0), then one more byte;
      • u16 record offsets × 4;
      • 3-byte field descriptors FF <request word> 04.
    • Record:

      • weight code (0x39 = 4 × 25 everywhere in the living world);
      • probability (100);
      • clips << 2 | mode;
      • locals;
      • field count, then 3 pad bytes;
      • one byte per clip (offset × 4);
      • u32 field masks (0 = any);
      • 8-byte clip entries (u16 .hdr id; the rest is 0 in the living world).
    • Fields:

      • 1 = speaker type bit;
      • 2 = voice variant bit;
      • 3 depends on the event: the near/far flag, zombie (1901, 201, 606, 609), the conversation partner's type (806 / 807), or the conversation word (550–557);
      • 4 = zombie for 603.
    • Type bits (from the voices of their records):

      bit type voices
      0x1 adult m 41 / 42 / 43
      0x2 adult f 46 / 47 / 49
      0x4 granny 51 / 52
      0x8 jock 55 / 56
      0x10 teen f 72 / 73 / 74
      0x20 teen m 69 / 70 / 71
      0x40 security 75 / 76 / 77
      0x80 tourist m 82 / 83 / 84
      0x100 tourist f 85, 53
      0x200 skater m 89, 91, 92, 90
      0x400 skater f 94 / 95 / 96, 93
      0x1000 business m 59–61
      0x2000 business w 64–66
      0x4000 bum 87, 88, 86

      Variant bits: 1, 2, 4, 8 and 16.

    • 133 of the 3,348 living-world clip references have no clip (for example 603's tazer lines for some voices). Those records never play.

  • .hdr:
    • u16 id;
    • +2 flags (0 in all banks);
    • +3 take count;
    • +4 runtime;
    • +8 history length: the take count, or 0 for 685 clips (conversations 550–557, zombie lines, 497 Silence);
    • +9 / +10 size scale;
    • +16 u16 .sth row offsets;
    • then the history ring (cursor + entries, 0xFF = empty).

Mechanism (TU3)

  • State graphs. The ped state graphs (data/state/livingworldentities/pedestrian/aigraph/*.xml) send SendSpeechEvent speechevent=… speechvalue=N on state entry. The values are listed in speech::SPEECH_VALUES, for example 10 CollisionNearbyReaction (wanttoobserve), 25 StopCheer (slamreaction), 23 LongCheer (nearbyskatertrick), 20 Flee, 14 JoinChase, 17 AttemptTakeDown, 19 TakeDownSuccess, 12 ChaseResting, 66 EscapedEndChase.
    • The warn (11, pedestrian_dowarning.xml) and takedown success are commented out ("moved to the code").
    • The value lands in the ped audio state +136. Footstep packets read the same field (jump / collision).
  • SFXObj_PedestrianSpeech process sub_824D9908:
    1. When +136 changes, it builds a request to the speech manager *(0x830CFDDC): sub_824AB6C8 (or sub_824AC438 with a target).
    2. flag = 1 if +148 > +156 else 2.
    3. Values 7 / 8 are remapped to 30 after a 29, else 51.
    4. Value 49 goes to sub_824D9C70.
    5. Value 29 (photographer) repeats on a timer while a global flag is set.
    • Ported: PedSpeech::process (49 and the 29 timer are not).
  • Speech manager (Sk8::Audio::TheSpeechSystem, global *(0x830CFDDC)). Ported (speech_manager.rs).
    • Request block (sub_824D9908):
      • w0 = +96 type bit, w1 = +88 variant bit, w2 = flag, w3 = +92, w4 = +120, w5 = +124;
      • speaker slot = +84.
      • The living-world path needs +116 != 0. Otherwise sub_824AC438 sends main-cast events (pros; not ported).
    • Value → event (sub_824AB6C8): the table in event_for_value.
      • Coin flips use rand(); bums use rand() % 3; guards get radio lines.
      • Value 6 with +71 also sends main-cast event 0x8017 (not ported).
    • The request (sub_824ABA18), in order:
      1. Game-state preconditions.
      2. The vault tuning of the event (class Hash_9C1F48F5D637E275, SPCHType_1_EventID).
      3. Timers (sub_824A8C78): one per speaker slot × 81 events, f32 seconds, starting at 0.
        • The same event needs ≥ +24 s.
        • Any event needs ≥ +8 s, when +8 > 0.
        • Not-follow needs strictly more than its time. Ids 0 / 294 / 8318 / 33245 / 24752 are skipped.
      4. sub_824A75F0:
        • probability: rand() % 1000 × 0.1f < +20, or ≥ 99.9;
        • the player's speed × 3.6 within +32 / +36;
        • the challenge list;
        • the +40 / +44 timers;
        • the +49..51 game flags;
        • zombie mode needs +60.
      5. w9 = 2 in zombie mode (virtual [-620]+212 vfunc 156; the zomb_Shout records prove it).
      6. sub_824A73F0: interrupt by priority (not ported).
      7. sub_824ABD90: the request words of each event.
    • The timers restart when the line starts (sub_824A90B8 from sub_824A84B8).
  • Speech library (generic, sub_829717A0 …). Ported (speech_rules.rs).
    • sub_82971480: the event probability (fails when (draw>>16)·100>>16 > p), then the queue slot (not ported).
    • sub_82973CB8:
      • the weighted record order (sub_82972980): weight = 4^(b>>5)·(b&31), scale table at 0x82FDB3D8;
      • per record: a probability draw, the field match (sub_82973BD8) and the clips (sub_82972D70: at most 12; a missing clip fails the record).
    • Candidates per clip (sub_82972660): the takes not in the history ring; when every take is in it, the oldest one.
    • Pick (sub_82974220):
      • index = (draw>>16)·n>>16;
      • redrawn (at most 32 draws) while it is among the last min(n/2, 10) picks of the same header in a global 32-entry (index, header) ring;
      • if every draw hits, the one whose match is oldest.
    • sub_82973408: writes the history when the line starts.
    • Generator: add-with-carry at 0x82FDB3C0. Retail shares this state with the grain player's title generator.
    • The queue is not ported (sub_82971340 / sub_82971890 / sub_82971DA8):
      • 16 request slots and 8 streams;
      • highest priority wins, then the newest; older requests are dropped;
      • timeout = event +2, in a clock whose unit is not known.
      • The recomp plays several living-world lines at once (overlap.py (local script)), so the living-world channel is not exclusive. How is not resolved.

Reaction → event (measured; speech_reads.py, sessions 161849 / 163809 / 164620; the clip starts 0.03 s after)

reaction events (evidence)
bump → warn 501 Warn_n (M)
bump → warn + taze (female) 501 Warn_f (M); 330 / 331 tazer lines (name)
bump → flee 108 ChsFlee (M)
slam nearby 104 Slam_n (M)
collision nearby 205 SpecCol, 204 Gasp, 202 HImpRct (M)
trick nearby 101 SpecPos_n (M)
chase start 105 SpecChs bystanders (M), 603 chaser (name)
greet / returngreet / conversation 1901 Shout, 806 Int_c, 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 / _f lines: solved from the data. Request flag 1 (ped +148 > +156) means far.
    • The flag-1 records name 101_51_GenPos_Grn1_far / 104_51_grn1_Slam_far, and the flag-2 records …_near.
    • All 366 _f / _n records of 101 / 104 / 202 / 203 / 501 split the same way (tests/world_speech.rs).
    • Settled 2026-10-03 (world-audio-hookin-spec.md §7.3 G2): +148 = the ped's distance to the listener, +156 = aud_characteristics field A27215A909135B62 (20 m for peds, 30 for pros), so _f lines play beyond 20 m.
    • (Was:) what +148 / +156 hold (a distance and a threshold?) is not traced. speech_nearfar.py's distance join is still unreliable.
  • Take choice vs the recomp (speech_takes.py, 10 clean sessions; reads inside > 500 ms audio stalls left out):
    • 57 of 57 clips with 2+ plays follow the history rule, including a full cycle. A uniform pick would have repeated a take in about 13.6 of them.
    • 15 of 15 multi-clip lines are one record, in order.
    • The first takes of 806 lines are often 0 (6 of 13 in 164620). This may be chance, or a pairing we cannot see. Keep an eye on it.
  • Coverage. 40 voices have 501 and 104 lines, 39 have 205, 36 have 101 (harness print).

Playback (port)

  • speech::SPEECH_BANK (1 << 22) mixer bank. SpeechSlots maps (clip, take) → slot. Takes play as direct voices.
  • The harness renders a busm1 warn take (5.49 s, peak −9 dBFS at unity gain).
  • Level / pan: open (next step).
    • The stream request (sub_82973408 → sub_82C5F310) is 9 words: offset, size, channel, and the context (ped +64). It goes to a SpeechBank stream. The voice's level is set where those requests are used; that code is not found yet.
    • A lead: in 163809 a speech READ comes with a GAIN … 0.0 → 1.0 and a PITCH ratio of ~0.52–0.70 on one voice (0x40C43350).
    • The speech manager reads the PedestrianSpeech owner outputs itself, and which of its 24 outputs is not known (E0 out2 −1000 dB via B2 is the main volume candidate; out13/14 filters; out11/12 via B20 = 2–70 m camera distance).

Waiting on the engine

  • The ped system must provide:

    • speech values on state entry (PedState.speech_value);
    • the speaker fields (Speaker: slot, type bit, variant bit, partner type);
    • the near/far pair;
    • zombie mode;
    • the player's speed;
    • a manager clock.
  • The game host: world_sources logs requests (AUDIO_WORLD speech …) but plays nothing. To play, it needs:

    1. the index and rules from speech/livingworld.json → SpeechIndex::set_ids, EventTable, Library::new(ClipHeader…);
    2. world_tuning.speech_tuning["1"] → EventTuning;
    3. SpeechManager::request for each SpeechRequest;
    4. SpeechIndex::picks_to_lines → SpeechSlots with the decoded takes, played in sequence;
    5. the level mapping.

    The JSON → struct mapping is in tests/world_speech.rs load().

  • Maincast / cameraman / announcer speech (pro skater lines around the player, the cameraman's "own the spot"): same format, not indexed yet.

Next steps

  1. Level / pan: find the code that uses the SpeechBank stream requests (the 9-word requests sub_82C5F338 queues) and the speech voice's GAIN / SEND. Or hook it (category audio) and join it with the ped distance.
  2. What +148 / +156 are: find who writes the ped audio state, or add a first-pass hook on sub_824D9908 that logs both.
  3. The request queue and stream count for the living world (why lines overlap in the recomp).
  4. Main cast / cameraman / announcer: they use the same library and already parse. Their managers differ (other vault types, sub_824AC560).

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).