Files
Sylpheed/tools/extract_movie_subtitles.py
sim e909c7c133 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

89 lines
3.5 KiB
Python

#!/usr/bin/env python3
import struct, zlib, re, os
MOD=0x00FFF9D7; RECIP=0x80031493
def rol(v,n,b=32):
v&=(1<<b)-1; return ((v<<n)|(v>>(b-n)))&((1<<b)-1)
def nh(name):
bs=bytearray(name.encode('latin-1'))
for i,b in enumerate(bs):
if 0x41<=b<=0x5A: bs[i]=b+0x20
a=0;bb=0
for byte in bs:
c=(byte if byte<0x80 else byte-0x100)&0xFFFFFFFF
a=((rol(a,8)&0xFFFFFF00)+c)&0xFFFFFFFF
bb=(bb+c)&0xFFFFFFFF
hi=((a*RECIP)>>32)&0xFFFFFFFF; q=rol(hi,9)&0x1FF
a=(a-(q*MOD))&0xFFFFFFFF
return (((bb<<24)&0xFF000000)|(a&0xFFFFFF))&0xFFFFFFFF
EX = os.environ.get("SYLPHEED_DISC") or exit("set SYLPHEED_DISC to the extracted disc (the directory holding dat/)")
def load_pak(base):
"""base like dat/movie/eng ; returns list of (hash,off,size), data bytes"""
pak=open(f"{EX}/{base}.pak","rb").read()
n=struct.unpack(">I",pak[4:8])[0]
es=[struct.unpack(">III",pak[16+12*i:28+12*i]) for i in range(n)]
p00=f"{EX}/{base}.p00"
data=open(p00,"rb").read() if os.path.exists(p00) else pak
return es, data
def unz(blob):
if blob[:2]==b'Z1':
zi=blob.find(b'\x78\xda');
if zi<0: zi=blob.find(b'\x78\x9c')
return zlib.decompress(blob[zi:])
return blob
# --- verify the track key scheme ---
es,data=load_pak("dat/movie/eng")
keys={h for h,_,_ in es}
for mv,exp in [("S00A",0x6F2D9663),("hokyu_DS_s02A",0x3662B1F8),("RT01C_1",0x756F69FB),("S16A",0xEEB85377)]:
k=nh(f"subtitle_{mv}.tbl")
print(f"subtitle_{mv}.tbl -> {k:08X} expect {exp:08X} inpak={k in keys} {'OK' if k==exp else 'MISMATCH'}")
# --- build MSG_DEMO text table from GP_MAIN_GAME_E ---
es2,data2=load_pak("dat/GP_MAIN_GAME_E")
text={} # 'MSG_DEMO_ddd_ppp_ll' -> string
keyrx=re.compile(r'^MSG_DEMO_\d+(_\d+)*$')
for h,o,s in es2:
dec=unz(data2[o:o+s])
if dec[:4]!=b'IXUD': continue
# walk null-terminated utf-16le strings in the whole block
toks=re.findall(rb'(?:[\x20-\x7e]\x00){2,}', dec)
toks=[t.decode('utf-16-le') for t in toks]
# pair: text followed by its key
for i in range(len(toks)-1):
if keyrx.match(toks[i+1]) and not keyrx.match(toks[i]):
text[toks[i+1]]=toks[i]
print(f"\nMSG_DEMO text lines loaded: {len(text)}")
# --- render a movie's subtitle transcript ---
def track_for(mv):
k=nh(f"subtitle_{mv}.tbl")
for h,o,s in es:
if h==k: return unz(data[o:o+s])
return None
# index text by integer demo id -> ordered list of lines
bydemo={}
for k,v in text.items():
m=re.match(r'MSG_DEMO_(\d+)_',k)
if m: bydemo.setdefault(int(m.group(1)),[]).append((k,v))
for d in bydemo: bydemo[d].sort()
def transcript(mv):
dec=track_for(mv)
if not dec: return f"[no track for {mv}]"
toks=re.findall(rb'(?:[\x20-\x7e]\x00){2,}', dec)
toks=[t.decode('utf-16-le') for t in toks]
out=[f"=== {mv}.wmv ==="]
# collect all MSG_DEMO ids and timecodes in order; pair positionally
seq=[('id',int(re.match(r'MSG_DEMO_(\d+)$',t).group(1))) if re.match(r'MSG_DEMO_(\d+)$',t)
else ('tc',t) if re.match(r'\d\d:\d\d\.\d\d$',t) else ('x',t) for t in toks]
ids=[v for k,v in seq if k=='id']; tcs=[v for k,v in seq if k=='tc']
for j,demo in enumerate(ids):
tc=tcs[j] if j<len(tcs) else "--:--.--"
lines=[v for _,v in bydemo.get(demo,[])]
out.append(f" {tc} [{demo:3d}] "+(" ".join(lines) if lines else "(no text on disc)"))
return "\n".join(out)
for mv in ["S00A","S16A","hokyu_DS_s02A"]:
print(); print(transcript(mv))