feat(formats,viewer): movie subtitles, voice decode, and the movie manifest
The movie cutscene subtitle + voice pipeline, driven by the ADVERTISE_MOVIE manifest (the authoritative movie -> subtitle -> voice index). Also flushes several sessions of local WIP (async viewer loading, grouped-pool XBG7/hero-ship decode, drawlog tooling). See docs/HANDOFF-movie-voice-subtitles-2026-07-19.md. Subtitles (movie_subtitle.rs): - Full movie->track->text chain; join multi-line captions sharing one timing (fixes S13A dropped "Look at it father" line); overlap-safe active_cues(); Latin-1 accents preserved. Voice (slb.rs): XACT .slb -> XMA1 RIFF; take the FIRST sub-wave bounded by its declared data size (fixes S10-S16 alternate-take garble); list_voice_clips. Manifest (movie_manifest.rs): parse ADVERTISE_MOVIE (0x5B983A08) for the real movie->voice binding (not always VOICE_<movie>; e.g. hokyu -> VOICE_D_* in etc\). Resolve the token's sound.pak path via sounds.tbl. DIRECT bindings only — the demo-id shared-clip fallback for unbound hokyu movies was verified WRONG in-game and reverted (unbound hokyu stay unvoiced; correct join key is an OPEN problem). Viewer: manifest-driven voice (movie player toggle + solo button), standalone "Voice Lines" browser, stacked caption overlay. Tests: 46 formats-lib + 11 viewer-lib + movie_manifest/movie_subtitle/slb disc tests; full workspace green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
126
tools/analyze_drawlog_wvp.py
Normal file
126
tools/analyze_drawlog_wvp.py
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Recover each ship part's runtime WORLD transform from a Canary draw log.
|
||||
|
||||
The draw logger (branch capture-drawlog) now dumps, per distinct draw:
|
||||
- the first ~8 object-space vertex positions (pre-transform), and
|
||||
- the vertex-shader float constants c0..c31 (which hold the transform matrices).
|
||||
|
||||
The game bakes each part's WORLD matrix into the constants, so the shader's
|
||||
World*View*Proj (WVP) for a part is VP * part_World. The body (bdy_01) is drawn
|
||||
with an identity world, so body_WVP = VP. Hence for any part drawn in the SAME
|
||||
frame (the ship's parts always are):
|
||||
|
||||
part_World = inv(body_WVP) * part_WVP (View/Proj cancel)
|
||||
|
||||
We don't know a-priori which 4 consecutive constants form the WVP, so we try every
|
||||
block c[i..i+3] and keep the one for which inv(body_WVP)*part_WVP is a RIGID
|
||||
transform (orthonormal 3x3 + translation) for the parts — that uniquely identifies
|
||||
the matrix block. The translation column of each part's world matrix is the
|
||||
placement we want (esp. the ±X nacelle offset the static XBG7 graph lacks).
|
||||
|
||||
Usage: analyze_drawlog_wvp.py xenia_re_draws.log
|
||||
Identify the body draw via --body-indices (the draw with the most indices) or by
|
||||
matching object-space positions to our decoded sub-meshes.
|
||||
"""
|
||||
import sys, re
|
||||
import numpy as np
|
||||
|
||||
def parse(path):
|
||||
draws = []
|
||||
cur = None
|
||||
consts = {}
|
||||
mode = None
|
||||
for line in open(path):
|
||||
if line.startswith("DRAW "):
|
||||
if cur is not None:
|
||||
cur["consts"] = consts
|
||||
draws.append(cur)
|
||||
cur = {"header": line.strip(), "positions": [], "textures": [],
|
||||
"size_words": 0}
|
||||
consts = {}
|
||||
mode = None
|
||||
m = re.search(r"index\[base=0x([0-9A-Fa-f]+) count=(\d+)", line)
|
||||
cur["idx_base"] = int(m.group(1), 16) if m else None
|
||||
cur["idx_count"] = int(m.group(2)) if m else None
|
||||
m = re.search(r"indices=(\d+)", line)
|
||||
cur["indices"] = int(m.group(1)) if m else None
|
||||
elif cur is not None and line.strip().startswith("stream "):
|
||||
# Largest vertex buffer (size_words) = the hull (body, identity world).
|
||||
m = re.search(r"size_words=(\d+)", line)
|
||||
if m:
|
||||
cur["size_words"] = max(cur["size_words"], int(m.group(1)))
|
||||
elif cur is None:
|
||||
continue
|
||||
elif line.strip().startswith("positions:"):
|
||||
cur["positions"] = [tuple(map(float, t.split(",")))
|
||||
for t in re.findall(r"\(([^)]+)\)", line)]
|
||||
elif line.strip().startswith("textures:"):
|
||||
cur["textures"] = re.findall(r"base=0x([0-9A-Fa-f]+)", line)
|
||||
elif line.strip().startswith("vsconst"):
|
||||
mode = "vsconst"
|
||||
elif mode == "vsconst":
|
||||
m = re.match(r"\s*c(\d+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)", line)
|
||||
if m:
|
||||
consts[int(m.group(1))] = np.array([float(m.group(i)) for i in range(2, 6)])
|
||||
else:
|
||||
mode = None
|
||||
if cur is not None:
|
||||
cur["consts"] = consts
|
||||
draws.append(cur)
|
||||
return draws
|
||||
|
||||
def wvp_from(consts, i):
|
||||
"""4x4 from constants c[i..i+3] as rows."""
|
||||
if not all(k in consts for k in (i, i+1, i+2, i+3)):
|
||||
return None
|
||||
return np.array([consts[i], consts[i+1], consts[i+2], consts[i+3]])
|
||||
|
||||
def is_rigid(M):
|
||||
"""3x3 upper-left orthonormal (rotation/reflection), within tolerance."""
|
||||
R = M[:3, :3]
|
||||
if not np.all(np.isfinite(R)):
|
||||
return False
|
||||
G = R @ R.T
|
||||
return np.allclose(G, np.eye(3), atol=1e-2) or np.allclose(G / (np.trace(G)/3), np.eye(3), atol=1e-2)
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__); return
|
||||
draws = parse(sys.argv[1])
|
||||
print(f"parsed {len(draws)} draws")
|
||||
# Body = the draw with the largest vertex buffer (the ~10891-vert hull, drawn
|
||||
# with an identity world). idx_count is unreliable (the hull's 9 material
|
||||
# groups dedup to the first group's partial count).
|
||||
body = max(draws, key=lambda d: d.get("size_words") or 0)
|
||||
print(f"body draw: {body['header'][:90]} size_words={body['size_words']}")
|
||||
# Try every constant block; report the one giving rigid part transforms.
|
||||
for i in range(0, 29):
|
||||
bwvp = wvp_from(body["consts"], i)
|
||||
if bwvp is None:
|
||||
continue
|
||||
try:
|
||||
binv = np.linalg.inv(bwvp)
|
||||
except np.linalg.LinAlgError:
|
||||
continue
|
||||
worlds = []
|
||||
rigid = 0
|
||||
for d in draws:
|
||||
pw = wvp_from(d["consts"], i)
|
||||
if pw is None:
|
||||
continue
|
||||
W = binv @ pw # column-vector convention; may need transpose
|
||||
worlds.append((d, W))
|
||||
if is_rigid(W) or is_rigid(W.T):
|
||||
rigid += 1
|
||||
if rigid >= max(2, len(worlds)//2):
|
||||
print(f"\n=== WVP block c{i}..c{i+3}: {rigid}/{len(worlds)} rigid ===")
|
||||
for d, W in worlds:
|
||||
# translation is the last column (or last row, convention-dependent)
|
||||
tcol = W[:3, 3]
|
||||
trow = W[3, :3]
|
||||
print(f" idx={d['idx_count']:>6} base={d['idx_base'] and hex(d['idx_base'])}"
|
||||
f" t_col=({tcol[0]:+.2f},{tcol[1]:+.2f},{tcol[2]:+.2f})"
|
||||
f" t_row=({trow[0]:+.2f},{trow[1]:+.2f},{trow[2]:+.2f})")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
88
tools/extract_movie_subtitles.py
Normal file
88
tools/extract_movie_subtitles.py
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/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="/home/fabi/RE Project Sylpheed/sylph_extract"
|
||||
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))
|
||||
Reference in New Issue
Block a user