The 13 tables that voice every player-facing event are all PresetMessage_Katana_*, the PLAYER's own sets -- unit-group-table.md already names the link (DeltaSaber_T_Player carries msg=MessageSet_Katana). Exact partition: 13 voice all 14 events, 131 voice none, 0 voice some, and no non-Katana table voices any of them. CharacterKATANA is the only speaker exclusive to the set. Corrects preset-message-rules.md, which called those the wingman tables. The wingman roster is owned by isl-condition-builtins.md (UNITS: Bird1-Sandra ... Rhino2-Katana, Rhino3-Ellen) and needed no experiment. Residual: Katana_09_S10-1 and Katana_14_S16-2 voice none of the 14. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
592 lines
29 KiB
Markdown
592 lines
29 KiB
Markdown
# The reactive-chatter rule table
|
||
|
||
**✅ Settled 2026-08-27** (static; regenerator
|
||
`tools/re-capture/preset_messages.py`, artefact
|
||
[`data/preset-messages.txt`](../data/preset-messages.txt)).
|
||
|
||
The corpus already knew *that* `PresetMessage_*` is reactive combat chatter —
|
||
[`ixud-localised-text.md`](ixud-localised-text.md) names the IXUD family
|
||
("the bulk: reactive combat chatter keyed by speaker class") and
|
||
[`mission-phase-deployment.md`](../mission-phase-deployment.md) found the link
|
||
that reaches it (`message\UnitMessageSet_S<NN>.tbl` gives every crew a
|
||
`PresetMessage_Phase1/2/3`). What neither says is **what is inside the table** —
|
||
which events exist, what governs when a line fires, and how a line reaches a
|
||
recording. That is this document.
|
||
|
||
## Two files per set
|
||
|
||
A chatter set is a **pair** of IDXD objects in each `dat/GP_MAIN_GAME_<lang>.pak`:
|
||
|
||
| name | role |
|
||
|---|---|
|
||
| `message\PresetMessage_<who>.tbl` | the **rule** table — this document |
|
||
| `message\PresetMessage_<who>_msg.tbl` | the **message** table |
|
||
|
||
The `_msg` half is *not* new: it is exactly the record layout
|
||
[`cutscene-message-table.md`](cutscene-message-table.md) settled for
|
||
`MSG_DEMO_*` — a `Generic` count record plus `Message_NNN` records with `ID` /
|
||
`PageCount` and 9 positional fields per page. What that document does not say is
|
||
that the same layout carries **137 more objects per language pack** for
|
||
in-mission chatter, against the 32 cutscene ones it counted. The control run
|
||
confirms the split cleanly: of the 273 `PresetMessage_*` names that hit an IDXD
|
||
entry, **136 are rule tables and 137 are `Generic`+`Message_NNN` message
|
||
tables** (3 of those are `Generic`-only, i.e. empty).
|
||
|
||
## The rule table
|
||
|
||
**144 per language pack, 864 across the six `GP_MAIN_GAME_<lang>.pak`** — one
|
||
identical count in each, and `ORDOR_SQUADRON_EXTENDED` (the misspelling is the
|
||
game's, see below) is the largest single record name on the disc for exactly
|
||
that reason.
|
||
|
||
Every one of the 144 has the **same 65 records**: 64 event records plus a
|
||
`Sperkers` (sic) roster. One record-name tuple, no variants.
|
||
|
||
### Event records
|
||
|
||
All 9 216 event records (144 × 64) share **one** named-field schema, with no
|
||
exceptions:
|
||
|
||
```
|
||
EffectiveTime Interval Priority Probability MessageCount
|
||
IntervalFluctuation Pattern
|
||
```
|
||
|
||
After the seven named fields come the record's positional payload —
|
||
[a record's unnamed positional fields are its payload] — in **pairs**:
|
||
|
||
(message id, Yes/No) e.g. ('MSG_BIRD_020', 'No')
|
||
|
||
`MessageCount` is the number of pairs, and that identity is **exact**:
|
||
`MessageCount · 2 == positional-field count` in **9 216 of 9 216** records, zero
|
||
mismatches. That is what pins the pairing. The second element of each pair is
|
||
`No` 8 940 times and `Yes` 388 times; its meaning is not settled.
|
||
|
||
### The 64 events
|
||
|
||
The full census with per-event table counts is in the artefact. The shape of
|
||
that census is itself informative:
|
||
|
||
* **Player-facing events are voiced only by the PLAYER's own tables** — see
|
||
"the player-facing partition" below. Every `LOCKON_1..12`, `LOCKON_UP`,
|
||
`LOCKON_DOWN`, `PLAYER_HP_LESS_10/30/50` and `PLAYER_AMMO_LESS_00/10/40`
|
||
record carries lines in **exactly 13** of the 144 tables, the same 13 as
|
||
`ORDER_WINGMAN_{FORMATION,ATTACK,COVER,EXTENDED}`. (❌ An earlier revision of
|
||
this document called those "the wingman tables". They are not.)
|
||
* The squad/squadron order events carry lines in **7** tables each.
|
||
* Generic combat events are near-universal: `DAMAGED_BY_PLAYER_GREATER_50` 128,
|
||
`DAMAGED` 124, `HP_LESS_10` and `HP_LESS_50` 121 each.
|
||
* **Five events are declared everywhere and populated nowhere** —
|
||
`DAMAGED_BY_PLAYER_50_30`, `HP_LESS_30`, `RETURNED`, `TALK`, `TM_TO_PLAYER`
|
||
have `MessageCount 0` in all 144 tables. They are slots the shipped data never
|
||
uses.
|
||
|
||
Note the near-miss pairs that are *not* redundant: `HP_LESS_*` (the speaker's
|
||
own hull) versus `PLAYER_HP_LESS_*`, and `AVOIDED_BY_PLAYER` versus
|
||
`AVOIDED_PLAYER_ATTACK`. The `_PLAYER` half of each pair is the smaller
|
||
population.
|
||
|
||
### `Sperkers`
|
||
|
||
The last record is a roster of speaker names as **field names** with empty
|
||
values — `CharacterELLEN`, `CharacterADANCAMAEL`, … **49 distinct speakers**
|
||
across the 144 tables. Most tables name one speaker (84 of 144); the rest name
|
||
2–11, and three name none.
|
||
|
||
## The join to audio is the settled cue table
|
||
|
||
The pair's first element is a message id, and it resolves through the cue index
|
||
[`sound-cue-table.md`](sound-cue-table.md) already documents — `MSG_` becomes
|
||
`VOICE_`, then `SOUNDS` in `dat/tables.pak` gives the numeric id and `FILES` the
|
||
`.slb` bank.
|
||
|
||
**2 388 of 2 405 distinct message ids resolve (99.3 %).** The 17 that do not
|
||
are characterised, not swept up: 8 are ordinary four-letter-family ids
|
||
(`MSG_ADAN_569/668/788/789`, `MSG_ADPL_045/046/047`, `MSG_RHIN_341`) and 9 are
|
||
`MSG_VOICE_*`, seven of them the consecutive run `MSG_VOICE_D_443`…`_449`. A
|
||
consecutive run with no recording reads as content cut late. All 17 are listed
|
||
in the artefact.
|
||
|
||
⚠️ **The wrinkle in that doc is real and I walked into it.** Rewriting `MSG_` to
|
||
`VOICE_` unconditionally gives `VOICE_VOICE_D_443`, which resolved only 2 353 of
|
||
2 405 and would have been written up as "52 lines with no recording", with 44 of
|
||
them a naming artefact. `sound-cue-table.md` states the exception explicitly —
|
||
a name that already carries `VOICE_` keeps the one it has — and applying it
|
||
moves the count to 2 388. Recorded here because the same trap is one line of
|
||
code away in any port.
|
||
|
||
## Naming — five routes, and all 144 land
|
||
|
||
The rule tables are pak entries keyed by `name_hash`, so they had to be named
|
||
before any of this could be read. Five routes, each an independent transform:
|
||
|
||
| route | source | named |
|
||
|---|---|---|
|
||
| 1 | any `PresetMessage_*` string appearing in any IDXD object, under `message\` | 136/144 |
|
||
| 2 | the `PresetMessage_Phase1/2/3` values of the 22 `UnitMessageSet_S<NN>.tbl` | 136/144 |
|
||
| 3 | strip `_msg` from a **message**-table name to get its rule table's name | 137/144 |
|
||
| 4 | predicted from the `Sperkers` roster (below) | 6/144 |
|
||
| 5 | sweep of the naming grammar over the 86 `<who>` tokens routes 1–4 expose | 1/144 |
|
||
| | **union** | **144/144** |
|
||
|
||
Routes 1 and 2 name **the same set** (checked as a set identity, not a matching
|
||
count). Route 3 is a strict superset of route 1 and hits **no** entry that is not
|
||
a rule table. Every one of the 144 has its `_msg` companion present.
|
||
|
||
Names are of the form `PresetMessage_<who>[_<NN>][_S<stage>-<phase>].tbl`, e.g.
|
||
`PresetMessage_Ellen_03_S03-3.tbl`, `PresetMessage_ADAN_Camael.tbl` — a set is
|
||
scoped to a character *and* a stage-phase, which is what makes 144 of them.
|
||
|
||
### ✅ The roster predicts the name
|
||
|
||
Route 4 is the interesting one, because the prediction came first. Among the
|
||
tables already named, the two-speaker ones expose a transparent rule:
|
||
|
||
<FACTION>_Fleet<X><Y> speaks (<FACTION>OP<Y>, <FACTION><X>)
|
||
TCAF_..Ship<X> speaks (ADANPL<X>, TCAF<X>)
|
||
|
||
`TCAF_Fleet{AA,AB,BA,CA,CB}` and `TCAF_17thFleet{AA,AB,BA}` and
|
||
`TCAF_17thFleetShipC` are named; **six gaps in those series** were predicted from
|
||
the six unnamed tables' rosters and then hashed:
|
||
|
||
| roster | predicted name | result |
|
||
|---|---|---|
|
||
| `TCAFOPB`, `TCAFB` | `TCAF_FleetBB` | ✅ `0x59d48b31` |
|
||
| `TCAFOPB`, `TCAFB` | `TCAF_17thFleetBB` | ✅ `0x9d29575d` |
|
||
| `TCAFOPA`, `TCAFC` | `TCAF_17thFleetCA` | ✅ `0x9d4c5353` |
|
||
| `TCAFOPB`, `TCAFC` | `TCAF_17thFleetCB` | ✅ `0x9e527c53` |
|
||
| `ADANPLA`, `TCAFA` | `TCAF_17thFleetShipA` | ✅ `0xee14c77c` |
|
||
| `ADANPLB`, `TCAFB` | `TCAF_17thFleetShipB` | ✅ `0xef1af07c` |
|
||
|
||
**6 of 6.** The control matters as much as the hits: four same-shaped names the
|
||
series does *not* contain — `TCAF_FleetCC`, `TCAF_17thFleetCC`, `TCAF_ShipA_01`,
|
||
`TCAF_17thFleetShipC_01` — score **0 of 4**, so this is not "any plausible name
|
||
hits an unnamed slot".
|
||
|
||
### 🔴 Refuted: the undeclared tables are not tutorial chatter
|
||
|
||
The eight tables that neither route 1 nor route 2 names are undeclared because
|
||
**nothing on the disc declares them** — no `UnitMessageSet` entry, and their own
|
||
names appear as strings nowhere. `UnitMessageSet_S<NN>.tbl` exists for 22 of the
|
||
28 stages and the six missing are exactly **S18–S23, the tutorials**, so
|
||
"the undeclared eight are tutorial chatter" was the obvious reading. It is
|
||
**wrong**: the eight are `PresetMessage_Katana_14_S16-2` and
|
||
`PresetMessage_Margras_04_S14-1` — both **story** stages — plus the six TCAF
|
||
fleet/ship tables above, which carry no stage tag at all. Not one is a tutorial.
|
||
|
||
What that leaves is a different and smaller claim: the tutorials ship **no**
|
||
`UnitMessageSet`, and no rule table is tagged `_S18`…`_S23`, so on this evidence
|
||
the six tutorial missions have no reactive chatter of their own.
|
||
|
||
## ✅ Correction to `squadron-orders.md`
|
||
|
||
That document renders the voice-line enum at `0x820AEEB0` as
|
||
`ORDER_{WINGMAN,SQUAD,SQUADRON}_{FORMATION,ATTACK,COVER,EXTENDED}`. The
|
||
executable's own strings are **not** uniform: the WINGMAN and SQUAD names are
|
||
`ORDER_`, and all four SQUADRON names are misspelled **`ORDOR_`**. The disc data
|
||
carries the identical misspelling, which is how the two sides were joined.
|
||
|
||
That document's 🔴 warning — that this enum is a UI/comms axis and is **not** the
|
||
order-class enum — is **confirmed and now explained**: all twelve names are
|
||
record names in this chatter rule table, i.e. they are the twelve *events*
|
||
"a squadron order was issued", each with its own lines, priority and interval.
|
||
They are triggers for barks, not order types.
|
||
|
||
## ✅ The seven fields, read off the engine's own loader
|
||
|
||
**Settled 2026-08-27** from `sub_82213980`, the one function in the executable
|
||
that references all seven field-name strings (they sit contiguously at
|
||
`0x820A5794`…`0x820A5800`; the query is the string-xref join, and the seven
|
||
xrefs land in that single function). It walks the rule table's records through
|
||
`sub_82448AA0` / `sub_824482D0` / `sub_82448BC8` and builds a **40-byte** runtime
|
||
object per event. The disc-side value census in the artefact agrees with it at
|
||
every point.
|
||
|
||
| field | disc values (all 9 216 records) | what the loader does |
|
||
|---|---|---|
|
||
| `EffectiveTime` | `0` 8566, `3` 453, `5` 187, `10` 10 | `× 60` → `[obj+28]` |
|
||
| `Interval` | `0` 6820, `3` 821, `4` 1, `5` 894, `30` 182, `120` 498 | `× 60` → `[obj+20]` |
|
||
| `IntervalFluctuation` | `0` 8403, `2` 813 | `× 60` → `[obj+24]` |
|
||
| `Priority` | `1`…`7`, `9` (no `8`); `1` is 7 738 | byte → `[obj+17]` |
|
||
| `Probability` | `0` 6788, then `1`,`2`,`3`,`5`,`10`,`20`,`25`,`30`,`40`,`50`,`60`,`100` | byte → `[obj+16]`; **`0` makes the loader skip the record entirely** |
|
||
| `Pattern` | `Sound` 8395, `Window` 821 | string → small enum → `[obj+18]` |
|
||
| `MessageCount` | `0`…`26` | pair count; **clamped to 32** |
|
||
|
||
✅ **The three time fields are SECONDS, converted to 60 Hz frames.** The loader
|
||
emits `mulli rN, rN, 60` for `Interval`, `IntervalFluctuation` and
|
||
`EffectiveTime` and for nothing else. Every disc value is a small integer
|
||
(`120` s = two minutes is the largest), which is what makes seconds the only
|
||
reading that survives.
|
||
|
||
✅ **`Pattern` is a delivery channel, and the enum has two dead arms.** The
|
||
loader `strcmp`s the value against `"Log"` → 2, `"Window"` → 3, `"Demo"` → 4,
|
||
defaulting to **1**. The disc only ever says `Sound` (→ default 1) or `Window`
|
||
(→ 3); **`Log` and `Demo` are declared in code and never used in data**. Do not
|
||
conflate this with the radio *delivery category* (`None`/`Emergency`/`Killed`/
|
||
`Noise`) that `mission-phase-deployment.md` found in `ScriptMessage_S02_msg.tbl`
|
||
— different table, different axis. (`Killed` does sit at `0x820A5784`,
|
||
immediately before this string block, which is exactly the kind of adjacency
|
||
that invites the mistake.)
|
||
|
||
✅ **The `Yes`/`No` element is a 32-bit mask, and that is why `MessageCount` is
|
||
capped at 32.** The loader runs two passes over the positional payload:
|
||
`sub_82448BC8(rec, 2·i)` collects the **even** slots (the message ids) and
|
||
`sub_82448BC8(rec, 2·i+1)` the **odd** ones, `strcmp`s each against `"Yes"`
|
||
(`0x820A57A4`), and sets `1 << i` in a `u32` stored at `[obj+32]`. The clamp and
|
||
the mask width are the same constant. **Confirmation from the data:** the largest
|
||
`MessageCount` on the disc is **26**, no record exceeds the clamp, and no
|
||
record's `Yes` count ever exceeds its `MessageCount`. This also independently
|
||
re-derives the pair layout from the executable side, having first been inferred
|
||
from `MessageCount · 2 == positional count`.
|
||
|
||
### The 40-byte runtime object
|
||
|
||
```
|
||
+16 u8 Probability (per cent)
|
||
+17 u8 Priority (1..9)
|
||
+18 u8 Pattern (1 = Sound, 3 = Window; 2 = Log and 4 = Demo unused)
|
||
+20 u32 Interval * 60 (frames)
|
||
+24 u32 IntervalFluctuation * 60
|
||
+28 u32 EffectiveTime * 60
|
||
+32 u32 the Yes bitmask
|
||
+36 u32 zero at construction
|
||
```
|
||
|
||
`[obj+16]`'s byte is also the loader's gate: it is read with `sub_824482D0` and
|
||
sign-extended, and a **zero `Probability` skips the record before the object is
|
||
ever allocated**.
|
||
|
||
### Residual, characterised
|
||
|
||
Cross-tabbing `MessageCount == 0` against `Probability == 0` over all 9 216
|
||
records: **6 786** are zero on both (the event is simply not used by that
|
||
speaker), **2 417** are set on both, **11** carry a `Probability` but no lines,
|
||
and **2** carry lines with `Probability 0` — those two ship dialogue the loader
|
||
can never reach, because it skips the record first. Thirteen dead records out of
|
||
9 216.
|
||
|
||
### 🟡 What this does *not* settle
|
||
|
||
All seven fields and the `Yes`/`No` flag are settled above; the static side of
|
||
this system is complete. What is **not** established: the numeric effect of the
|
||
ducking level at `node[+20]` (its mechanism is clear, its attenuation is not),
|
||
what the state values other than `0x10`/`0x20` mean, and whether
|
||
`sub_822168E8`/`sub_82217980` do anything beyond stop/resume. None of these
|
||
change the data model a port needs.
|
||
|
||
🔴 One handle was tried and refuted: searching the whole image for functions
|
||
that touch `+16`/`+17`/`+18`/`+20`/`+28`/`+32`/`+36` returns dozens of unrelated
|
||
matches, because those offsets recur across unrelated layouts. **Offset shape is
|
||
not an identifier** — the call graph is. The bounded version of the same search,
|
||
restricted to this compilation unit *after* the call graph had established which
|
||
functions matter, is sound and is what pinned `+17`/`+18`. `Pattern`'s two live values
|
||
are named, but that `Window` means an on-screen text window is a reading of the
|
||
name, not something measured.
|
||
|
||
## ✅ How the three phase tables are merged — and what that costs
|
||
|
||
**Settled 2026-08-27.** `sub_82215A58` is the `UnitMessageSet` reader: it
|
||
references exactly `CrewCount`, `PresetMessage_Phase1`, `PresetMessage_Phase2`
|
||
and `PresetMessage_Phase3`, and it reaches the loader from **one** call site
|
||
(`0x82215D98`). So a crew's three phase tables are loaded into **one** map, not
|
||
three. The map is keyed by a hash of the *event record's name*
|
||
(`sub_82455C78`, the rolling-accumulator / character-sum-top-byte shape
|
||
[`idxd-tag-hash.md`](idxd-tag-hash.md) documents; which member of that family
|
||
this entry point is was not pinned here, and the finding does not depend on it).
|
||
|
||
Because all 64 event names recur in every phase table, **every merge collides**.
|
||
`sub_82213980`'s insert therefore has a reconciliation arm, and
|
||
`sub_82213840` — a function called from nowhere else — is its comparator.
|
||
|
||
### `sub_82213840` compares six of the eight fields
|
||
|
||
It walks the two message-id vectors elementwise, then compares `+16`
|
||
(`Probability`), `+17` (`Priority`), `+18` (`Pattern`), `+20` (`Interval`),
|
||
`+24` (`IntervalFluctuation`) and `+28` (`EffectiveTime`), and returns a bool.
|
||
It does **not** look at `+32` (the `Yes` mask) or `+36`.
|
||
|
||
On a colliding key the loader compares, and then — whatever the answer —
|
||
**frees the newcomer and keeps the incumbent**. A mismatch additionally clears
|
||
the byte the loader returns (`lbz r3, 80(r31)` at the epilogue); the caller
|
||
turns that into a flag at `0x82215D9C`…`0x82215DDC` and propagates it.
|
||
|
||
### What the disc actually does with that
|
||
|
||
Measured over the 22 `UnitMessageSet_S<NN>.tbl` (numbers in the artefact):
|
||
|
||
| | |
|
||
|---|---|
|
||
| `UnitMessageSet` records naming ≥ 2 phase tables | **298** |
|
||
| duplicate-key insertions to reconcile | **26 432** |
|
||
| identical on the compared fields | **26 208** (99.15 %) |
|
||
| **different** — loader clears its flag | **224** |
|
||
| compared fields equal but `Yes` mask differs | **0** |
|
||
|
||
Of the 224, **189 differ only in the message list** — per-phase dialogue
|
||
variants for the same event and the same timings. The other 35 also move
|
||
`Probability`/`Priority`/`Pattern`/`Interval`.
|
||
|
||
✅ **The phase tables are not additive.** For a given crew and event, phase 1's
|
||
rule wins and the later phases' are discarded — so **224 authored variants never
|
||
play**. A port that merges them additively, or that lets a later phase override,
|
||
will not behave like the game.
|
||
|
||
✅ **The comparator's blind spot is never exercised.** There is no record pair
|
||
that agrees on the six compared fields and disagrees on the `Yes` mask, so the
|
||
fact that `+32` is excluded from the comparison changes nothing on the shipped
|
||
disc. Worth recording precisely because it is the kind of gap that looks like a
|
||
latent bug until it is counted.
|
||
|
||
## ✅ Who fires a bark, and the first half of the policy
|
||
|
||
**Settled 2026-08-27**, reached through the **call graph**, not through offset
|
||
shape (that route is refuted — see the note below).
|
||
|
||
### The event names are executable strings, so the firing sites name themselves
|
||
|
||
**36 of the 64 event names ship as strings** in `0x820AEEC8`…`0x820B2160`, and the
|
||
functions referencing them are the sites that raise each event:
|
||
|
||
| site | events it raises | reading |
|
||
|---|---|---|
|
||
| `sub_823800A8` | 17 — `DAMAGED*`, `HP_LESS_*`, `KILLED*`, `KILL_UNIT`, `LOST_{BRIDGE,SHIELD,THRUSTER,TURRET}_BY_PLAYER`, `SHIELD_DOWNED*`, `ATTACK_SUCCEEDED` | damage / destruction |
|
||
| `sub_82320B48` | 15 — all twelve `ORDER_`/`ORDOR_` names plus `COUNTER`, `COUNTER_TO_PLAYER`, `FOUND_PLAYER` | squadron orders |
|
||
| `sub_8237B020` | 9 — `PLAYER_HP_LESS_*`, `KILL_PLAYER`, `DAMAGED_TO_PLAYER`, `AVOIDED_BY_PLAYER`, `DAMAGED`, `KILLED`, `SHIELD_DOWNED` | player state |
|
||
| `sub_823B1438` | `PLAYER_AMMO_LESS_10/40` | ammunition |
|
||
| `sub_82381B10` | `AVOID_ATTACK`, `AVOIDED_PLAYER_ATTACK` | evasion |
|
||
| `sub_82398CC0` / `sub_823969D0` / `sub_82362F18` | `ATTACK_IS_AVOIDED` / `EXTENDED` / `FOUND_PLAYER` | one each |
|
||
|
||
[`squadron-orders.md`](squadron-orders.md) already names `sub_82320B48` as the
|
||
selector for the twelve-string enum and warns it is a comms axis. **What it does
|
||
not say** is that the function is one of eight *bark firing sites* that share a
|
||
single entry point, and that it also raises three non-order events.
|
||
|
||
### `sub_8220FA98` — the shared entry, and the `Probability` roll
|
||
|
||
All eight sites call it, and nothing else in the file does more. It takes a lock
|
||
around `this+4`, bails unless the enable byte at `[this+8843]` is set, then:
|
||
|
||
1. looks the **speaker** up in the map at `[this+8820]`;
|
||
2. indexes a vector inside that entry by `[this+8816] − 1` (a byte; the natural
|
||
reading is the phase, but that is a reading, not measured);
|
||
3. looks the **event name** up in *that* entry's map → the 40-byte rule object;
|
||
4. rolls: `f0 = rand01 · 100.0`, and **if `f0 > [rule+16]` it returns without
|
||
firing.**
|
||
|
||
✅ **`Probability` is a straight per-occurrence percentage.** The scaling
|
||
constant at `0x820856F8` is exactly **100.0**, read from the image — which is
|
||
what turns the disc census (`1,2,3,5,10,20,25,30,40,50,60,100`) from "looks like
|
||
per cent" into per cent. `sub_8220D970` builds the uniform: two `rand()` calls
|
||
folded into 16 bits, converted to float.
|
||
|
||
### `sub_82210670` — pick a line
|
||
|
||
Called only when the roll passes. In order:
|
||
|
||
1. **Duplicate suppression.** It walks the list at `[this+8736]` for a node whose
|
||
`+8` and `+12` match this (speaker, rule) pair; if one is pending it returns
|
||
immediately. The same bark cannot be queued twice.
|
||
2. **Eligibility.** `MessageCount` is recomputed from the vector
|
||
(`([rule+8] − [rule+4]) >> 2`), and a message is eligible only if its bit is
|
||
**clear** in the mask at **`[rule+36]`** — the word the loader zeroes at
|
||
construction. So `+36` is a runtime *exclusion* mask over the message list;
|
||
"already spoken" is the obvious reading, and the write site was not found this
|
||
iteration.
|
||
3. **Uniform pick.** `f0 = eligible_count · rand01`, then walk the eligible bits
|
||
subtracting `1.0` (the constant at `0x8208583C`) until the counter runs out or
|
||
`f0` drops to `0.0` (`0x8209FD28`). That index is the chosen line.
|
||
4. **Hand-off.** It reads `[rule+18]` `Pattern`, `[rule+17]` `Priority` and
|
||
`[rule+28]` `EffectiveTime`, and passes them with the chosen message to
|
||
`sub_822109B0`.
|
||
|
||
✅ **A measured negative worth having:** the pick path reads `+17`, `+18`, `+28`
|
||
and `+36` — and **not** `+20` (`Interval`), `+24` (`IntervalFluctuation`) or
|
||
`+32` (the disc `Yes` mask). Those three are consumed somewhere after the
|
||
hand-off, not on the way in. Within the whole chatter compilation unit
|
||
(`0x8220C000`…`0x82216000`), `+17` and `+18` are read by exactly two functions —
|
||
`sub_82213840` (the comparator) and `sub_82210670` — which is what makes this
|
||
attribution safe where a binary-wide offset search is not.
|
||
|
||
## ✅ The queue, `Priority`, and what `Yes`/`No` actually means
|
||
|
||
**Settled 2026-08-27** from `sub_822109B0` (enqueue) and `sub_8220FC50` (the
|
||
per-frame tick, called from `sub_821AB650` and `sub_821B5FB8`).
|
||
|
||
### The pending queue
|
||
|
||
`sub_822109B0` allocates from a fixed pool: **68-byte nodes starting at
|
||
`this+32`**, with `[this+8740]` as the live count and `[this+8736]` as the list
|
||
head. **If the count is already 128 it returns 0 and the bark is dropped.**
|
||
|
||
A slot is free iff its byte at `+23` is zero — and `+23` holds **`Pattern`**,
|
||
which is never 0 for a loaded rule (the enum is 1…4). The presentation field
|
||
doubles as the occupancy flag.
|
||
|
||
| node | content |
|
||
|---|---|
|
||
| `+4` | the chosen message id |
|
||
| `+8` | the speaker entry |
|
||
| `+12` | the rule object |
|
||
| `+16` | `EffectiveTime` (frames) |
|
||
| `+20`, `+21` | zero at enqueue |
|
||
| `+22` | `Priority` |
|
||
| `+23` | `Pattern` — also the in-use flag |
|
||
| `+24` | state byte, zero at enqueue |
|
||
| `+25` | the chosen message **index** |
|
||
|
||
### `Priority` orders the list, and its fast path is dead
|
||
|
||
Insertion compares `Priority` against **10**. At or above 10 the node is pushed
|
||
straight onto the front with no ordering; below 10 it is spliced ahead of the
|
||
first node with a strictly smaller `Priority`, leaving the list **descending by
|
||
`Priority`**. **No shipped record reaches 10** — the disc's range is 1…9 — so the
|
||
front-push arm never runs on the retail data, and every bark takes the ordered
|
||
path.
|
||
|
||
`sub_82210AF0` (called only from the tick) walks the list and returns the largest
|
||
`[node+20]` among pending nodes whose `Priority` is **higher** than the one being
|
||
considered and whose `[node+24]` is not `0x10` — a preemption / ducking query
|
||
against higher-priority speech already queued.
|
||
|
||
### ✅ `Yes`/`No` is a one-shot flag — the `+32`/`+36` pair closes
|
||
|
||
The tick ends a line with exactly this, at `0x82210160`:
|
||
|
||
```
|
||
r10 = node[+25] ; the index of the line just spoken
|
||
r9 = rule[+32] ; the Yes mask, straight off the disc
|
||
r8 = rule[+36] ; the used mask, zeroed by the loader
|
||
rule[+36] = ((1 << r10) & r9) | r8
|
||
```
|
||
|
||
The spoken line's bit enters the **used** mask **only if its `Yes` bit is set**.
|
||
Since `sub_82210670` treats a message as eligible only when its bit in `+36` is
|
||
clear, the meaning is exact:
|
||
|
||
* **`Yes` → the line is one-shot.** Once spoken it is permanently removed from
|
||
that rule's pool.
|
||
* **`No` → the line repeats.** Its bit never enters `+36`.
|
||
|
||
That is what the census counts: **8 940 `No` and 388 `Yes`** — 388 lines across
|
||
the disc are authored to be heard once. It also closes the earlier open question
|
||
"what reads `+32`": nothing until a line finishes, and then only to gate this
|
||
one AND.
|
||
|
||
The tick also branches on `Pattern < 3` (using `this+8776` on the taken side), so
|
||
`Window` (3) is on a different presentation path from `Sound` (1) — consistent
|
||
with the two values the disc ships.
|
||
|
||
## ✅ `Interval` and `IntervalFluctuation` — the cooldown, and the system closes
|
||
|
||
**Settled 2026-08-27** from `sub_82210C38`, the retire-a-line routine (called
|
||
from the tick `sub_8220FC50` and from `sub_8220FB90`). It is the only place in
|
||
the chatter code that reads the rule's `+20` and `+24` as **words**, which is
|
||
what distinguishes them from the byte fields at the same offsets in the queue
|
||
node.
|
||
|
||
When a line finishes:
|
||
|
||
```
|
||
rule = node[+12]
|
||
if rule:
|
||
f1 = rand01() ; sub_8220D970
|
||
f0 = (float)rule[+24] ; IntervalFluctuation, frames
|
||
f13 = (float)rule[+20] ; Interval, frames
|
||
node[+16] = (int)(f0 * f1 + f13) ; fmadds
|
||
else:
|
||
node[+16] = 0
|
||
node[+24] = 0x20 ; state: cooling down
|
||
```
|
||
|
||
✅ **`Interval` is a floor and `IntervalFluctuation` is uniform additive jitter**:
|
||
the cooldown is `Interval + IntervalFluctuation · rand01` frames, i.e. uniform
|
||
over `[Interval, Interval + IntervalFluctuation)`. On the disc that is
|
||
`Interval ∈ {0,3,4,5,30,120}` seconds with a jitter of `0` or up to `+2` seconds
|
||
(813 records carry `IntervalFluctuation 2`).
|
||
|
||
✅ **The cooldown is per (speaker, event), and the queue node *is* the timer.**
|
||
`node[+16]` — the field that held `EffectiveTime` while the line was playing — is
|
||
reused for the cooldown. In state `0x20` the tick decrements it each frame and,
|
||
only when it reaches zero, unlinks the node, clears `[node+23]` to free the pool
|
||
slot and decrements the count:
|
||
|
||
```
|
||
node[+16] -= elapsed
|
||
if node[+16] > 0: keep waiting
|
||
else: unlink; node[+23] = 0; [this+8740]--
|
||
```
|
||
|
||
That closes the loop with the duplicate check in `sub_82210670`, which refuses to
|
||
fire when a node with the same `(speaker, rule)` is already in the list. **The
|
||
"already queued" bail and the cooldown are the same mechanism**: a retired line
|
||
keeps its node alive for `Interval + jitter` frames, and for exactly that long the
|
||
same speaker cannot repeat the same event. Nothing per-rule or global is
|
||
involved — the rule object itself holds no timestamp.
|
||
|
||
⚠️ **Port note:** the lingering node still occupies one of the **128** pool slots.
|
||
A long `Interval` (120 s = 7 200 frames) parks a slot for two minutes.
|
||
|
||
The same routine also stops the presentation — `sub_822168E8` on `this+8776` for
|
||
`Pattern` 1, or on `node+28` when `Pattern ≥ 3` — and, when `[node+20] == 1`,
|
||
sweeps the pending list calling `sub_82217980(…, 0)` on every node whose state is
|
||
not `0x10`. Together with `sub_82210AF0` (which returns the largest `[node+20]`
|
||
among *higher*-priority pending nodes) that makes `+20` a ducking level: raised
|
||
while a higher-priority line plays, released when it retires. The exact
|
||
attenuation is not established here.
|
||
|
||
## ✅ The player-facing partition — and a correction
|
||
|
||
**Settled 2026-08-27.** The 13 tables that voice every player-facing event are
|
||
**all `PresetMessage_Katana_NN_S<stage>-<phase>.tbl`** — the player character's
|
||
own chatter sets.
|
||
|
||
| | |
|
||
|---|---|
|
||
| tables voicing **all 14** player-facing events | **13** |
|
||
| tables voicing **none** | **131** |
|
||
| tables voicing **some** | **0** |
|
||
|
||
Zero partials: the split is exact, not a tendency. And it is not merely that few
|
||
others voice them — **none of the 129 non-Katana tables voices *any* of the 14**.
|
||
|
||
There are **15** Katana tables in total; the two that voice none are
|
||
`Katana_09_S10-1` and `Katana_14_S16-2`, and they are all-or-nothing like
|
||
everything else. Why those two stage-phases opt out is not established.
|
||
|
||
Of the 15 speakers named across the 13, **`CharacterKATANA` is the only one
|
||
exclusive to them**; the other 14 all appear in other tables too.
|
||
|
||
[`unit-group-table.md`](unit-group-table.md) supplies the link that makes this
|
||
make sense — the player's own flight in Stage 02 reads
|
||
|
||
```
|
||
unit=UN_f001_TCAF_DeltaSaber_T_Player msg=MessageSet_Katana id=Character_Player_Test
|
||
unit=UN_f001_TCAF_DeltaSaber_T msg=MessageSet_Ellen id=ELLEN
|
||
```
|
||
|
||
so `MessageSet_Katana` is the message set attached to the **player craft**.
|
||
`LOCKON_*` is the player's own lock-on callout, `PLAYER_AMMO_LESS_*` the player's
|
||
own ammunition, and `ORDER_WINGMAN_*` the player *issuing* an order — all of
|
||
which belong on the player's set and nowhere else.
|
||
|
||
### ❌ Correction, and the doc that already owned the question
|
||
|
||
The question this iteration set out to answer — "which of the 49 speakers are
|
||
wingmen" — was **already answered elsewhere and did not need this experiment**.
|
||
[`isl-condition-builtins.md`](isl-condition-builtins.md) owns it: the `UNITS`
|
||
record in `GP_HANGAR_ARSENAL.pak` is seven fields pairing a craft slot with a
|
||
pilot — `Bird1-Sandra`, `Bird2-Billy`, `Bird3-Antonius`, `Bird4-Carl`,
|
||
`Rhino1-Raymond`, `Rhino2-Katana`, `Rhino3-Ellen` — and it names that **the
|
||
wingman roster**. [`hangar-loadout-system.md`](hangar-loadout-system.md) owns the
|
||
`PlayerSET_*` rows that go with it.
|
||
|
||
So the earlier phrasing here was wrong twice over: the 13 are the **player's**
|
||
tables, not wingman tables, and the wingman roster is a separate, already-settled
|
||
list in which `Rhino2-Katana` is the player's own slot.
|
||
|
||
## 🟡 Not settled
|
||
|
||
* The *policy* the seven fields drive — see "what this does not settle" above.
|
||
* Why `Katana_09_S10-1` and `Katana_14_S16-2` opt out of the player-facing
|
||
events entirely.
|