The one function referencing all seven field-name strings is the rule table's loader. Interval / IntervalFluctuation / EffectiveTime are SECONDS, emitted as *60 frame counts; Probability is a percentage and zero skips the record; Pattern is a 4-arm enum of which only Sound and Window ship; the Yes/No pair element is a u32 bitmask, which is why MessageCount is clamped to 32 (max on disc is 26). 40-byte object layout recorded. 13 dead records characterised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
14 KiB
The reactive-chatter rule table
✅ Settled 2026-08-27 (static; regenerator
tools/re-capture/preset_messages.py, artefact
data/preset-messages.txt).
The corpus already knew that PresetMessage_* is reactive combat chatter —
ixud-localised-text.md names the IXUD family
("the bulk: reactive combat chatter keyed by speaker class") and
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 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 wingman tables. Every
LOCKON_1..12,LOCKON_UP,LOCKON_DOWN,PLAYER_HP_LESS_10/30/50andPLAYER_AMMO_LESS_00/10/40record carries lines in exactly 13 of the 144 tables — the same 13 asORDER_WINGMAN_{FORMATION,ATTACK,COVER,EXTENDED}. - The squad/squadron order events carry lines in 7 tables each.
- Generic combat events are near-universal:
DAMAGED_BY_PLAYER_GREATER_50128,DAMAGED124,HP_LESS_10andHP_LESS_50121 each. - Five events are declared everywhere and populated nowhere —
DAMAGED_BY_PLAYER_50_30,HP_LESS_30,RETURNED,TALK,TM_TO_PLAYERhaveMessageCount 0in 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 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 strcmps 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, strcmps 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
The loader tells us the units and the storage, not the policy. Priority's
comparison rule, whether Interval is a cooldown per event or per speaker, and
what IntervalFluctuation randomises against are all in the consumer of the
40-byte object, which this iteration did not read. 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.
🟡 Not settled
- The policy the seven fields drive — see "what this does not settle" above.
- Which of the 49 speakers is a wingman — the "13 tables" partition above is a count, not a verified roster.