re: the reactive-chatter rule table behind ORDOR_SQUADRON_EXTENDED

864 records = 144 rule tables per language pack x 6.  One schema for all
9216 event records; MessageCount*2 == positional count with zero mismatches.
Named 136/144 by two independent routes that agree as sets.  2388/2405
message ids join the settled sound-cue table.

Corrects squadron-orders.md: the executable misspells all four SQUADRON
entries of the 0x820AEEB0 enum as ORDOR_, and the disc data matches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
This commit is contained in:
Claude (auto)
2026-08-27 16:07:17 +00:00
parent d1368c12db
commit 2c2af7ad78
6 changed files with 783 additions and 5 deletions

View File

@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Regenerate docs/re/data/preset-messages.txt.
The reactive combat-chatter system: a per-character *rule* table
(`message\\PresetMessage_<who>[_<NN>][_S<stage>-<phase>].tbl`) holding 64 event
records plus a `Sperkers` speaker roster, paired with a `_msg.tbl` message table
in the layout cutscene-message-table.md already documents.
Run: python3 preset_messages.py > ../../docs/re/data/preset-messages.txt
"""
import sys, os, glob, collections
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from unit_substructures import pak_entries
import unitgroup as U
DAT = '/work/sylph_extract/dat'
PAK = os.path.join(DAT, 'GP_MAIN_GAME_E.pak')
MARKER = 'ORDOR_SQUADRON_EXTENDED'
STAGES = ['S%02d' % i for i in list(range(1, 17)) + list(range(18, 30))]
def idxd_entries(path):
out = {}
for h, s in pak_entries(path):
if s[:4] == b'IDXD':
out[h] = s
return out
def cue_names():
for h, s in pak_entries(os.path.join(DAT, 'tables.pak')):
if s[:4] != b'IDXD':
continue
try:
recs = U.parse(s)
except Exception:
continue
for r in recs:
if r['squadron'] == 'SOUNDS':
return {n for _, n, _ in r['fields'] if n is not None}
return set()
def to_cue(m):
"""sound-cue-table.md's rule: MSG_ becomes VOICE_, but a name that already
carries VOICE_ keeps the one it has rather than doubling it."""
if not m.startswith('MSG_'):
return m
rest = m[4:]
return rest if rest.startswith('VOICE_') else 'VOICE_' + rest
def main():
entries = idxd_entries(PAK)
rule = {}
for h, s in entries.items():
try:
recs = U.parse(s)
except Exception:
continue
if any(r['squadron'] == MARKER for r in recs):
rule[h] = recs
# how many language packs carry the same set
packs = sorted(glob.glob(os.path.join(DAT, 'GP_MAIN_GAME_?.pak')))
percopy = {}
for p in packs:
n = 0
for h, s in pak_entries(p):
if s[:4] == b'IDXD' and MARKER.encode() in s:
n += 1
percopy[os.path.basename(p)] = n
print('# Reactive combat-chatter rule tables (regenerated by preset_messages.py)')
print()
print('rule tables in GP_MAIN_GAME_E.pak : %d of %d IDXD entries' % (len(rule), len(entries)))
print('per language pack : %s' % percopy)
print('total across the disc : %d' % sum(percopy.values()))
# one record-name shape?
shapes = collections.Counter(tuple(r['squadron'] for r in recs) for recs in rule.values())
print('distinct record-name tuples : %d' % len(shapes))
shape = shapes.most_common(1)[0][0]
print('records per table : %d (64 events + Sperkers)' % len(shape))
# named-field schema, and the MessageCount identity
schemas = collections.Counter()
ok = bad = 0
msgs = set()
flags = collections.Counter()
perevent = collections.Counter()
for recs in rule.values():
for r in recs:
if r['squadron'] == 'Sperkers':
continue
names = tuple(n for _, n, _ in r['fields'] if n is not None)
schemas[names] += 1
pos = [v for _, n, v in r['fields'] if n is None]
try:
mc = int(U.named(r)['MessageCount'])
except Exception:
mc = -1
if mc * 2 == len(pos):
ok += 1
else:
bad += 1
if pos:
perevent[r['squadron']] += 1
for i, v in enumerate(pos):
if i % 2:
flags[v] += 1
else:
msgs.add(v)
print()
print('distinct named-field schemas : %d' % len(schemas))
for names, c in schemas.most_common():
print(' x%-6d %s' % (c, ', '.join(names)))
print('MessageCount*2 == positional count: %d/%d (mismatches %d)' % (ok, ok + bad, bad))
print('second-of-pair values : %s' % dict(flags))
print()
print('the 64 event records, and how many of the %d tables give each one lines:' % len(rule))
for name in sorted(n for n in shape if n != 'Sperkers'):
print(' %-32s %3d' % (name, perevent[name]))
# speakers
spk = collections.Counter()
per = collections.Counter()
for recs in rule.values():
for r in recs:
if r['squadron'] == 'Sperkers':
names = [n for _, n, _ in r['fields'] if n is not None]
per[len(names)] += 1
for n in names:
spk[n] += 1
print()
print('Sperkers roster: %d distinct speakers; sizes %s' % (
len(spk), dict(sorted(per.items()))))
for n, c in spk.most_common():
print(' %-28s %3d' % (n, c))
# the join into the sound-cue table
cues = cue_names()
hit = sorted(m for m in msgs if to_cue(m) in cues)
miss = sorted(m for m in msgs if to_cue(m) not in cues)
print()
print('distinct message ids : %d' % len(msgs))
print('resolved in tables.pak SOUNDS : %d (%.1f%%)' % (
len(hit), 100.0 * len(hit) / max(1, len(msgs))))
print('unresolved : %d' % len(miss))
fam = collections.Counter(m.split('_')[1] for m in miss if m.count('_') >= 2)
print(' by family: %s' % dict(fam))
for m in miss:
print(' %s' % m)
# naming, two independent routes
harvest = set()
for p in sorted(glob.glob(os.path.join(DAT, '**', '*.pak'), recursive=True)):
for h, s in pak_entries(p):
if s[:4] != b'IDXD':
continue
try:
recs = U.parse(s)
except Exception:
continue
for r in recs:
harvest.add(r['squadron'])
for _, n, v in r['fields']:
if n:
harvest.add(n)
harvest.add(v)
byharvest = {}
for c in harvest:
if c.startswith('PresetMessage_'):
byharvest.setdefault(U.name_hash('message\\' + c), 'message\\' + c)
declared = collections.Counter()
ums_found = []
for st in STAGES:
h = U.name_hash('message\\UnitMessageSet_%s.tbl' % st)
if h not in entries:
continue
ums_found.append(st)
for r in U.parse(entries[h]):
d = U.named(r)
for k in ('PresetMessage_Phase1', 'PresetMessage_Phase2', 'PresetMessage_Phase3'):
if d.get(k):
declared[d[k]] += 1
bydecl = {}
for v in declared:
bydecl.setdefault(U.name_hash('message\\' + v), 'message\\' + v)
A = {h: n for h, n in byharvest.items() if h in rule}
B = {h: n for h, n in bydecl.items() if h in rule}
print()
print('naming route 1 (any harvested PresetMessage_ string): %d/%d' % (len(A), len(rule)))
print('naming route 2 (UnitMessageSet_S* declarations) : %d/%d' % (len(B), len(rule)))
print('the two routes agree on the same set : %s' % (set(A) == set(B)))
print('UnitMessageSet_S* tables present: %d of %d (%s)' % (
len(ums_found), len(STAGES), ' '.join(ums_found)))
print('missing: %s' % ' '.join(s for s in STAGES if s not in ums_found))
print('distinct declared PresetMessage tables: %d' % len(declared))
# control: the same prefix also names a DIFFERENT shape
other = {h: n for h, n in byharvest.items() if h in entries and h not in rule}
print()
print('CONTROL — PresetMessage_* names that hit an IDXD entry but are NOT rule tables: %d' % len(other))
oshape = collections.Counter()
for h in other:
try:
recs = U.parse(entries[h])
except Exception:
continue
oshape[tuple(sorted({r['squadron'].rstrip('0123456789') for r in recs}))] += 1
print(' their record-name families: %s' % dict(oshape))
print()
print('rule tables, by name:')
for h in sorted(rule, key=lambda k: A.get(k, '~%#010x' % k)):
print(' %#010x %s' % (h, A.get(h, '<unnamed>')))
un = [h for h in rule if h not in A]
print()
print('unnamed rule tables: %d' % len(un))
if __name__ == '__main__':
main()