Files
Syplheed-Reborn/tools/re-capture/entities2.py
Claude (auto-RE) 3d3d6726fa re(flight): START skips the briefing, and the player-entity lock is stage-specific
The scripted route now reaches FLIGHT unattended: boot -> main menu -> poke the
cleared-stage mask -> EXTRAS -> MISSION SELECT -> stage -> briefing -> READY ROOM
-> TAKE OFF -> flight, verified by a full HUD (TIME 00:52.54, speed 350, shields,
REMAINING OB 018) on stage 01.

Two things that cost a run each:

START skips the briefing; A does not. A pages through the brief, and ten A taps
still left the run sitting on a briefing screen -- twice, on two different
stages. One START press lands on the READY ROOM. fly_stage.sh now presses START.

entities2.py's `self` locked on `"Player" in name`, and that suffix is
STAGE-SPECIFIC: stage 02 fields UN_f002_TCAF_DeltaSaber_W_Player, but stage 01
fields UN_f001_TCAF_DeltaSaber_T with no suffix, so the filter found nothing
while the game was visibly flying and reporting 64 typed live entities. Falls
back to the craft class and prefers the instance that is actually moving (a
mission holds more than one). Recorded rather than worked around, because the
same assumption is embedded in several probes.
2026-08-13 21:45:54 +00:00

231 lines
9.3 KiB
Python

#!/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":
# The "_Player" suffix is STAGE-SPECIFIC. Stage 02 fields
# UN_f002_TCAF_DeltaSaber_W_Player, but stage 01 fields
# UN_f001_TCAF_DeltaSaber_T with no suffix at all, and this filter then
# found nothing while the game was visibly flying. Fall back to the
# player's craft class and prefer the instance that is actually moving —
# a mission holds more than one.
me = [e for e in ents if "Player" in e[1]]
if not me:
me = [e for e in ents if "DeltaSaber" in e[1] or "ArrowHead" in e[1]]
me.sort(key=lambda e: -e[3]) # fastest first: the flown one
if me:
print(f"# no *_Player entity; falling back to craft class -> {me[0][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()