Files
Sylpheed/tools/re-capture/isl_dialogue.py
sim ac0ad579fd chore: retire the last dead paths and names from the consolidation
Nothing here changes what a tool computes; it changes where tools look.

- tools/re-capture: 33 censuses globbed /work/sylph_extract, a path that has
  existed nowhere since /work became a clone, so they matched nothing and
  printed empty results. They now resolve the disc through a new disc.py
  from $SYLPHEED_DISC and exit loudly without it (the #44 fix, generalised).
  Nine scripts that imported siblings from the retired Reborn checkout or an
  old session scratchpad now import from their own directory. unitgroup.py
  only needs the variable when --pak is not given.
- sylpheed-xex: the loader only ever uses the XEX2 retail key. The dead
  devkit key and a doc comment claiming a devkit fallback that does not
  exist are gone; Project Sylpheed is a retail XEX2, so no XEX1 key either.
- sylpheed-viewer: real_font_rasterizes looked for /tmp/sylph_extract and so
  always skipped. It reads $SYLPHEED_DISC now, and passes against the disc.
- Comments and docs that named xenia-rs, the Reborn repository or /work/*.pe
  as places to look now name sylpheed.db, Canary's ppc_context.h and the
  flat .pe; docs/re/README.md no longer says the native Canary build does not
  run.

Historical records keep their original paths: findings that were measured
against /work/xenia-rs/sylpheed.db still say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:30:28 +02:00

165 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
from disc import disc_root
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 \
disc_root() + '/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()