#!/usr/bin/env python3 """Type every live entity, and locate the player, via the definition pointer. A live entity object keeps a pointer to its parsed `.tbl` definition (vtable 0x820af844, addresses known) at a **fixed offset from its position triple**. Finding that offset once types every moving object in the scene at a stroke: enemies, friendlies and us, separated from the thousands of moving particles. The offset is *derived*, not assumed: for every moving triple, every nearby word is checked against the set of definition addresses, and the winning delta is the one that repeats across many independent entities. Sub-commands: delta find the (position -> definition pointer) offset list [delta] type every live entity and print it self [delta] the player's entity, its object window, and its orientation """ import math import os import struct import sys import time from collections import Counter import numpy as np sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import gmem # noqa: E402 import gworld # noqa: E402 def definitions(w): out = {} for off in w.scan_vtable(gworld.DEF_VTABLE): nm = w.name_of(off) if nm and nm.startswith("UN_"): va = gmem.primary_va(off) if va is not None: out[struct.pack(">I", va)] = nm return out # Entity objects live in one heap region; scanning only it turns a 250 MB # double-read into a few MB. That matters for more than speed: the full scan # competes with the emulator for every core under lavapipe, and a game that # does not advance a frame between the two samples has nothing "moving" in it. ENT_VA_LO, ENT_VA_HI = 0xBD000000, 0xBE000000 def moving(fd, size, dt=0.5, lo=1.0, hi=4000.0, va_range=(ENT_VA_LO, ENT_VA_HI)): def arrays(): if va_range: f0, f1 = gmem.va_to_off(va_range[0]), gmem.va_to_off(va_range[1]) else: f0, f1 = 0, size for a, b in gmem.extents(fd, size): a, b = max(a, f0), min(b, f1) n = (b - a) // 4 * 4 if n >= 64: yield a, np.frombuffer(os.pread(fd, n, a), dtype=">f4") s0 = dict(arrays()) t0 = time.time() time.sleep(dt) s1 = dict(arrays()) t1 = time.time() out = [] # Match extents by their file offset. Zipping the two lists positionally is # wrong: the game allocates between the samples, the sparse extent layout # shifts, and every subsequent pair misaligns -- which silently reports # almost nothing as moving. for o, a in s0.items(): b = s1.get(o) if b is None or len(a) != len(b): continue with np.errstate(invalid="ignore"): af = np.nan_to_num(a.astype(np.float64), nan=0, posinf=0, neginf=0) bf = np.nan_to_num(b.astype(np.float64), nan=0, posinf=0, neginf=0) d = bf - af idx = np.flatnonzero(np.abs(d) > 1e-4) cand = idx[np.isin(idx + 1, idx) & np.isin(idx + 2, idx)] for i in cand: v = np.array([d[i], d[i + 1], d[i + 2]]) sp = float(np.linalg.norm(v)) / (t1 - t0) if lo < sp < hi: out.append((o + int(i) * 4, tuple(af[i:i + 3]), sp)) return out def find_delta(fd, defs, movers, radius=0x400): votes = Counter() for off, pos, sp in movers[:4000]: lo = max(0, off - radius) blob = os.pread(fd, radius * 2, lo) for k in range(0, len(blob) - 3, 4): if blob[k:k + 4] in defs: votes[(lo + k) - off] += 1 return votes def typed(fd, defs, movers, delta): out = [] for off, pos, sp in movers: try: b = os.pread(fd, 4, off + delta) except OSError: continue nm = defs.get(b) if nm: out.append((off, nm, pos, sp)) return out def main(): cmd = sys.argv[1] if len(sys.argv) > 1 else "delta" w = gworld.World() fd, size = w.fd, w.size defs = definitions(w) print(f"# {len(defs)} unit definitions", flush=True) movers = moving(fd, size) print(f"# {len(movers)} moving triples", flush=True) if cmd == "delta": votes = find_delta(fd, defs, movers) print("# candidate (position -> definition pointer) deltas:") for d, n in votes.most_common(8): print(f" {d:+#08x} seen {n}") return delta = int(sys.argv[2], 0) if len(sys.argv) > 2 else 0x130 ents = typed(fd, defs, movers, delta) uniq = {} for off, nm, pos, sp in ents: uniq.setdefault((nm, tuple(round(c, 1) for c in pos)), (off, nm, pos, sp)) ents = list(uniq.values()) print(f"# {len(ents)} typed live entities (delta {delta:+#x})") if cmd == "list": c = Counter(nm for _, nm, _, _ in ents) for nm, k in c.most_common(): print(f" {k:4d} {nm}") for off, nm, pos, sp in sorted(ents, key=lambda e: e[1])[:60]: print(f" {gmem.primary_va(off):#010x} {nm:<40} " f"({pos[0]:+9.1f},{pos[1]:+9.1f},{pos[2]:+9.1f}) {sp:7.1f}/s") return if cmd == "self": me = [e for e in ents if "Player" in e[1]] if not me: sys.exit("player entity not found") off, nm, pos, sp = me[0] print(f"# player entity: pos va {gmem.primary_va(off):#010x} {nm}") print(f"# pos ({pos[0]:+.1f},{pos[1]:+.1f},{pos[2]:+.1f}) speed {sp:.1f}/s") # orientation inside the same object # The rotation is stored with a 16-byte row stride (a 4x4 transform # whose translation row is the position we already have), so a test for # nine *contiguous* floats structurally cannot find it. Test both. lo = max(0, off - 0x800) blob = os.pread(fd, 0x1000, lo) found = [] for k in range(0, len(blob) - 48, 4): for stride, tag in ((12, "3x3"), (16, "4x4")): try: rows = [np.array(struct.unpack_from(">3f", blob, k + stride * r)) for r in range(3)] except struct.error: continue M = np.array(rows) if not np.all(np.isfinite(M)) or np.max(np.abs(M)) > 1.001: continue if np.max(np.abs(M @ M.T - np.eye(3))) > 3e-3: continue if abs(np.linalg.det(M) - 1.0) > 1e-2: continue found.append(((lo + k) - off, M, stride)) break print(f"# orthonormal 3x3 blocks inside the player object: {len(found)}") for d, M, st in found[:6]: print(f" pos{d:+#07x} stride {st}: " + " ".join( "(" + ",".join(f"{v:+.3f}" for v in row) + ")" for row in M)) if len(sys.argv) > 3 and found: import json # Which of the orthonormal blocks is the CRAFT's attitude? The one # with a row along the direction of travel. Taking found[0] is what # produced a config with rot_delta -0x764 and a nonsense forward # axis: several blocks inside the object are orthonormal (bone or # camera frames), and only the craft's own has a row that tracks # where the craft is going. p0 = np.array(pos) time.sleep(0.35) p1 = np.array(struct.unpack(">3f", os.pread(fd, 12, off))) step = p1 - p0 if np.linalg.norm(step) < 1e-3: sys.exit("craft is not moving — cannot bind the forward axis") vdir = step / np.linalg.norm(step) best = None for d, M, st in found: cs = [float(M[r] @ vdir) for r in range(3)] r = int(np.argmax([abs(c) for c in cs])) if best is None or abs(cs[r]) > abs(best[3]): best = (d, M, st, cs[r], r) rot_delta, M, rot_stride, cos, row = best sign = 1 if cos > 0 else -1 print(f"# attitude block pos{rot_delta:+#07x} stride {rot_stride}: " f"forward = row {row} (sign {sign:+d}), cos={cos:+.3f}") if abs(cos) < 0.9: print("# WARNING: no block tracks the flight path (|cos| < 0.9)" " — the craft may be drifting hard; re-run while flying straight") cfg = {"def_delta": delta, "rot_delta": rot_delta, "rot_stride": rot_stride, "fwd_row": row, "fwd_sign": sign, "fwd_cos": round(cos, 4), "va_lo": ENT_VA_LO, "va_hi": ENT_VA_HI} json.dump(cfg, open(sys.argv[3], "w"), indent=1) print("# wrote " + sys.argv[3] + ": " + json.dumps(cfg)) return if __name__ == "__main__": main()