#!/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()