# 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.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_.pak`: | name | role | |---|---| | `message\PresetMessage_.tbl` | the **rule** table — this document | | `message\PresetMessage__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_.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.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 `` 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_[_][_S-].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: _Fleet speaks (OP, ) TCAF_..Ship speaks (ADANPL, TCAF) `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.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.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-.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.