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) 6ee041c1bf re: the chatter phase merge is not additive; 224 authored variants never play
sub_82215A58 reads CrewCount + PresetMessage_Phase1/2/3 and reaches the loader
from ONE call site, so the three phase tables fold into one map keyed by the
event record name.  Every merge collides; sub_82213840 reconciles on the
message list plus +16/+17/+18/+20/+24/+28 (NOT the +32 Yes mask), and the
incumbent always wins.  Measured: 26432 collisions, 26208 identical, 224
different (189 differ only in the message list), 0 mask-only differences.

Refuted handle: intersecting functions by the object's offsets finds dozens of
unrelated layouts -- offset shape is not an identifier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-27 16:50:42 +00:00

338 lines
17 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 wingman tables.** 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}`.
* 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
The loader and the merge tell us units, storage and duplicate handling — not
the *firing* policy. `Priority`'s comparison rule, whether `Interval` is a
cooldown per event or per speaker, and what `IntervalFluctuation` randomises
against all live in the consumer of the map, reachable via
`sub_82215A58` ← `sub_82214050`. 🔴 One handle was tried and refuted: searching
for functions that touch `+16`/`+17`/`+18`/`+20`/`+28`/`+32`/`+36` returns
dozens of unrelated matches, because those offsets recur across unrelated object
layouts. Offset shape is not an identifier. `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.
## 🟡 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.