This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/docs/re/structures/preset-message-rules.md
Claude (auto) 36f97c6322 re: the player-facing chatter partition; correcting the wingman claim
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
2026-08-27 17:42:28 +00:00

592 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
211, 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 14 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 **S18S23, 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.