#!/usr/bin/env python3 """Mission state from guest RAM: every entity's hull, not just the player's. `own_state.py` found the player's hull by anchoring on a solved definition field: an undamaged craft carries its definition's own `HP` (+0x054), so the live counter is the copy of that number that *falls*. The result was `hull = position + 0x154`. The escort question needs the same number for **someone else's** ship. Stage 02 is lost when the ACROPOLIS sinks, not when the player dies, and the 240 s run in autopilot-memory-driven.md hit GAME OVER with our own hull untouched. So the claim to test here is that `+0x154` is a property of the *entity class*, not of the player object: every entity, hostile or friendly, fighter or capital ship, should hold its own definition's `HP` there at spawn and lose it when hit. That is falsifiable in one run: read `pos+0x154` and the definition `HP` for every entity in the scene and compare. If the anchor is class-wide, the ratio is 1.0 for everything undamaged and nothing else lines up by accident; if it is player-specific, most entities hold something unrelated. Sub-commands: scan [cfg] one table of every entity: hull vs definition HP watch [hz] [out] sample over time; report what took damage """ import json import math import os import struct import sys import time import numpy as np sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import gmem # noqa: E402 import navigator # noqa: E402 HULL_OFF = 0x154 # own_state.py, player craft DEF_HP = 0x054 # unit-struct-runtime.md, ✅ CONFIRMED DEF_SIZE_R = 0x50 def f32(fd, off): b = os.pread(fd, 4, off) if len(b) < 4: return float("nan") (v,) = struct.unpack(">f", b) return v class Mission: def __init__(self, cfg): self.W = navigator.World(cfg) self.def_hp = {} for va in self.W.defs: self.def_hp[va] = f32(self.W.fd, gmem.va_to_off(va) + DEF_HP) def snapshot(self): """[(off, name, faction, radius, pos, hull, hp_max)] for every entity.""" out = [] for off, va in self.W.ents: p = self.W.pos(off) if p is None: continue hull = f32(self.W.fd, off + HULL_OFF) nm = self.W.defs[va] out.append((off, nm, navigator.faction(nm), self.W.radius[va], p, hull, self.def_hp.get(va, float("nan")))) return out def fmt(e): off, nm, fac, r, p, hull, hp0 = e frac = hull / hp0 if hp0 and math.isfinite(hp0) and hp0 > 0 else float("nan") return (f"{gmem.primary_va(off):#010x} {nm[:34]:<34} {fac:<4} r={r:6.0f} " f"({p[0]:+8.0f},{p[1]:+8.0f},{p[2]:+8.0f}) hull={hull:9.1f} " f"HP={hp0:9.1f} frac={frac:6.3f}") def cmd_scan(cfg): m = Mission(cfg) m.W.scan() ents = m.snapshot() print(f"# {len(ents)} entities") ok = sum(1 for e in ents if math.isfinite(e[6]) and e[6] > 0 and abs(e[5] / e[6] - 1.0) < 1e-3) fin = sum(1 for e in ents if math.isfinite(e[6]) and e[6] > 0) print(f"# hull(+{HULL_OFF:#x}) == definition HP for {ok}/{fin} entities " f"whose definition has an HP") for e in sorted(ents, key=lambda e: -e[3]): print(" " + fmt(e)) def cmd_watch(cfg, secs, hz, out): m = Mission(cfg) m.W.scan() base = {e[0]: e for e in m.snapshot()} print(f"# watching {len(base)} entities for {secs:g}s at {hz:g}Hz", flush=True) fh = open(out, "w") if out else None t0 = time.time() last_scan = t0 last_seen = {} while time.time() - t0 < secs: t = time.time() if t - last_scan > 15.0: # new waves spawn; re-enumerate rarely m.W.scan() last_scan = t for e in m.snapshot(): base.setdefault(e[0], e) ents = m.snapshot() rec = {"t": round(t - t0, 2), "n": len(ents), "adan": sum(1 for e in ents if e[2] == "ADAN"), "tcaf": sum(1 for e in ents if e[2] == "TCAF"), "ents": []} for e in ents: off, nm, fac, r, p, hull, hp0 = e b = base.get(off) drop = (b[5] - hull) if b and math.isfinite(b[5]) else 0.0 big = r >= navigator.Navigator.BIG_RADIUS if big or drop > 0.5: rec["ents"].append({"va": gmem.primary_va(off), "nm": nm, "fac": fac, "r": round(r, 1), "hull": round(hull, 1), "hp0": round(hp0, 1), "drop": round(drop, 1), "pos": [round(float(c), 1) for c in p]}) if drop > 0.5 and abs(last_seen.get(off, 0.0) - hull) > 0.5: last_seen[off] = hull print(f"[{t-t0:6.1f}] HIT {nm[:30]:<30} {fac} " f"hull {hull:9.1f}/{hp0:9.1f} (-{drop:.0f})", flush=True) if fh: fh.write(json.dumps(rec) + "\n") fh.flush() time.sleep(max(0.0, 1.0 / hz - (time.time() - t))) if fh: fh.close() print("\n# damage summary") ents = {e[0]: e for e in m.snapshot()} for off, b in sorted(base.items(), key=lambda kv: -kv[1][3]): cur = ents.get(off) gone = cur is None hull = cur[5] if cur else float("nan") if not gone and abs(hull - b[5]) < 0.5 and b[3] < navigator.Navigator.BIG_RADIUS: continue print(f" {b[1][:34]:<34} {b[2]:<4} r={b[3]:6.0f} " f"{b[5]:9.1f} -> {hull:9.1f}" + (" GONE" if gone else "")) def main(): if len(sys.argv) < 3: sys.exit(__doc__) cmd = sys.argv[1] cfg = json.load(open(sys.argv[2])) if cmd == "scan": cmd_scan(cfg) elif cmd == "watch": cmd_watch(cfg, float(sys.argv[3]) if len(sys.argv) > 3 else 120.0, float(sys.argv[4]) if len(sys.argv) > 4 else 1.0, sys.argv[5] if len(sys.argv) > 5 else None) else: sys.exit(__doc__) if __name__ == "__main__": main()