Files
Sylpheed/tools/re-capture/isl_dialogue.py
Sylpheed RE agent 66c3551a95 re: caption pages are utterances, and the dialogue tool was truncating 356 names
Measured the <id>_<page>_<line> key structure: a page is one subtitle box of
3 or 4 wrapped lines, and successive pages are successive utterances by
possibly different speakers. 452 of 4091 ids span more than one page, up to 8.

That refutes the isl_dialogue.py committed two commits ago, which read only
page 000 -- 356 of the 1338 script message names are multi-page, so a quarter
of its output was truncated to the opening utterance. Tool now walks pages
until one is empty; Stage 02 sample regenerated (43 of 213 calls multi-page).

The 2683/2683 resolution figure is unaffected: it counted ids that have text,
and every id does have a page 000. What was wrong was the rendered text.
2026-08-26 03:11:38 +00:00

164 lines
5.1 KiB
Python

#!/usr/bin/env python3
"""Render a mission script's dialogue: every `request_script_message` in order,
with the caption text it plays.
isl_dialogue.py <StageNN.ssb> [GP_MAIN_GAME_E.pak]
Built-in 64 takes a symbol-table-1 (type 6) message name; the caption text lives
in the pack's IXUD blocks under `<name>_<page>_<line>`. All 2683 call sites
across the 28 stages resolve, so this is a total mapping rather than a sample. 356 of the 1338 distinct
message names span more than one page; every page is printed.
"""
import collections
import struct
import sys
import isl
NO_NAME = 0xFFFFFFFF
# A caption id is keyed `<id>_<page>_<line>`. A page is one subtitle box of up
# to four wrapped lines; successive pages are successive utterances. Measured
# maxima on the English pack: 8 pages, 4 lines.
MAX_PAGES = 16
MAX_LINES = 8
def be32(b, o):
return struct.unpack_from('>I', b, o)[0]
def wstr(b, pool, off):
base = pool + 2 * off
e = base
while b[e:e + 2] != b'\0\0':
e += 2
return ''.join(chr(c) for c in struct.unpack('>%dH' % ((e - base) // 2), b[base:e]))
def captions(pak_entries):
"""`MSG_*` key -> text, read from the IXUD record/field table."""
out = {}
for b in pak_entries.values():
if b[:4] != b'IXUD':
continue
n = be32(b, 4)
o = 8 + 16 * n
m = be32(b, o)
o += 4
fields = [struct.unpack_from('>III', b, o + 12 * i) for i in range(m)]
o += 12 * m
pool = o + 4
for (_k, no, vo) in fields:
if no == NO_NAME:
continue
try:
key, val = wstr(b, pool, no), wstr(b, pool, vo)
except Exception:
continue
if key.startswith('MSG_') and val.strip():
out.setdefault(key, val)
return out
def walk(b, base, nxt, sink):
staged, pending, off = {}, None, base
while off + 4 <= nxt:
w = be32(b, off)
op, ln = w & 0xFF, (w >> 8) & 0xFF
k1, k0 = (w >> 24) & 0xFF, (w >> 16) & 0xFF
if ln < 4 or ln % 4:
return
ws = [be32(b, off + i) for i in range(4, max(ln, 4), 4) if off + i + 4 <= len(b)]
if op in (0, 1) and len(ws) >= 2:
if k0 == 2 and k1 == 1:
pending = ws[1]
elif k0 == 3 and k1 == 2 and pending is not None:
staged[ws[0]] = pending
elif k0 == 3 and k1 == 1:
staged[ws[0]] = ws[1]
if op == 19 and ws:
sink(off, ws[0], dict(staged))
staged = {}
off += ln
def main():
ssb = sys.argv[1]
pak = sys.argv[2] if len(sys.argv) > 2 else \
'/work/sylph_extract/dat/GP_MAIN_GAME_E.pak'
sys.path.insert(0, sys.path[0])
from unitgroup import read_entry # noqa: F401 (kept for parity)
import zlib
import os
def pak_entries(path):
d = open(path, 'rb').read()
assert d[:4] == b'IPFB'
segs, base, i = b'', path[:-4], 0
while os.path.exists('%s.p%02d' % (base, i)):
segs += open('%s.p%02d' % (base, i), 'rb').read()
i += 1
out = {}
for k in range(be32(d, 4)):
h, off, cs = struct.unpack_from('>III', d, 0x10 + 12 * k)
raw = segs[off:off + cs]
if raw[:2] == b'Z1':
raw = zlib.decompress(raw[10:])
out[h] = raw
return out
cap = captions(pak_entries(pak))
b = isl.load(ssb)
s1 = isl.symbols(b, 1)
end = be32(b, 0x0C)
lines = []
def sink(off, bid, st):
if bid != 64:
return
v = st.get(0)
if not isinstance(v, int) or v not in s1:
return
name = s1[v][1]
pages = []
for page in range(MAX_PAGES):
box = [cap[k] for k in
('%s_%03d_%02d' % (name, page, i) for i in range(MAX_LINES))
if k in cap]
if not box:
break
pages.append(' '.join(box))
lines.append((off, name, pages))
bases = isl.phase_bases(b) + [end]
for i in range(len(bases) - 1):
walk(b, bases[i], bases[i + 1], sink)
name = ssb.replace('\\', '/').split('/')[-1]
print('# %s — dialogue, in script order' % name)
print()
print('Generated by `tools/re-capture/isl_dialogue.py`. Each line is one')
print('`request_script_message` (built-in 64) and the caption it plays.')
print()
multi = sum(1 for _, _, p in lines if len(p) > 1)
print('%d message calls, %d with text, %d spanning more than one page.'
% (len(lines), sum(1 for _, _, p in lines if p), multi))
print()
print('A page is one subtitle box (measured maximum 4 wrapped lines).')
print('Successive pages are successive utterances and may be different')
print('speakers, so a multi-page id is a whole exchange, not one line.')
print()
seen = set()
for off, nm, pages in lines:
if off in seen:
continue
seen.add(off)
print('%06X %-22s %s' % (off, nm, pages[0] if pages else ''))
for extra in pages[1:]:
print('%6s %-22s %s' % ('', '', extra))
if __name__ == '__main__':
main()