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:
227
tools/re-capture/preset_messages.py
Normal file
227
tools/re-capture/preset_messages.py
Normal 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()
|
||||
Reference in New Issue
Block a user