diff --git a/tools/re-capture/escort_session.sh b/tools/re-capture/escort_session.sh new file mode 100755 index 0000000..b15fa81 --- /dev/null +++ b/tools/re-capture/escort_session.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# One background task: boot -> Stage 02 -> fly the pilot while a second reader +# records EVERY entity's hull, so the escort question ("who is losing, and how +# fast") is answered from memory instead of from the HUD. +# +# Same one-task rule as fly_session.sh: the display and the emulator both die on +# their own in this container, so nothing may depend on surviving between tool +# calls. +set -u +export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98 +export PYTHONPATH=/sylph-home/.local/lib/python3.12/site-packages +SD="$(cd "$(dirname "$0")" && pwd)" +SECS="${1:-300}" +TAG="${2:-escort}" +SHOTS=/sylph-home/re/shots +CFG=/tmp/nav-live.json + +"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; } +python3 "$SD/entities2.py" self 0x130 "$CFG" || { echo "BIND FAILED"; exit 1; } +echo "--- config: $(cat "$CFG")" + +echo "=== initial entity table ===" +python3 "$SD/mission_state.py" scan "$CFG" + +# The HUD's REMAINING OB counter has no known address yet, so the correlation +# material is a screenshot every 20 s stamped against the same clock as the +# memory samples. +( for i in $(seq 1 20); do + printf '%s SHOT %02d\n' "$(date +%s.%N)" "$i" >> "/tmp/$TAG-shots.log" + screenshot "$SHOTS/$TAG-$i.png" >/dev/null 2>&1 + sleep 20 + done ) & +SHOTTER=$! + +date +%s.%N > "/tmp/$TAG-t0" +python3 "$SD/mission_state.py" watch "$CFG" "$SECS" 1 "/tmp/$TAG-mission.jsonl" \ + > "/tmp/$TAG-mission.log" 2>&1 & +WATCHER=$! + +python3 "$SD/pilot.py" "$CFG" "$SECS" > "/tmp/$TAG-pilot.log" 2>&1 +wait $WATCHER 2>/dev/null +kill $SHOTTER 2>/dev/null +screenshot "$SHOTS/$TAG-end.png" >/dev/null 2>&1 +echo "SESSION DONE" diff --git a/tools/re-capture/launch_mission.sh b/tools/re-capture/launch_mission.sh index 52cfd5d..4126967 100755 --- a/tools/re-capture/launch_mission.sh +++ b/tools/re-capture/launch_mission.sh @@ -35,7 +35,7 @@ cd /sylph-home/re setsid nohup run-canary --audio --apu=sdl --log_mask=13 \ --logged_profile_slot_0_xuid=E0300000EFBEA3D4 /dev/null 2>&1 & sleep 5 -"$SD/skip_intro.sh" 600 || { echo "BOOT FAILED"; exit 1; } +"$SD/skip_intro.sh" 600 || { echo "BOOT FAILED (skip_intro exit $?)"; exit 1; } sleep 14 # main menu is not input-ready before this step down # NEW GAME -> LOAD GAME diff --git a/tools/re-capture/mission_state.py b/tools/re-capture/mission_state.py new file mode 100755 index 0000000..3935ff3 --- /dev/null +++ b/tools/re-capture/mission_state.py @@ -0,0 +1,167 @@ +#!/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() diff --git a/tools/re-capture/skip_intro.sh b/tools/re-capture/skip_intro.sh index 8eaa3f1..c4548ce 100755 --- a/tools/re-capture/skip_intro.sh +++ b/tools/re-capture/skip_intro.sh @@ -3,16 +3,37 @@ # - movie playing (two grabs 0.6s apart differ a lot) -> tap A to skip # - static screen -> if the green "PRESS (A) BUTTON" glyph is there, tap A and stop # Static logo screens are left alone, so a stray tap can never land on NEW GAME. +# +# LIVENESS IS CHECKED EVERY ITERATION, and that is not a nicety. When the +# display died 3 minutes into a run, `screenshot` started failing silently and +# left /tmp/f1.png and /tmp/f2.png at their last contents — two *stale* files, +# which compare to a constant non-zero RMSE, which reads exactly like "the +# screen is changing a lot". So the loop reported "movie -> skip A" every 5 s +# for the full 600 s timeout with no emulator and no X server running. A dead +# display and a playing movie must never be able to look the same. set -u export HOME=/sylph-home/re +DISP="${DISPLAY:-:98}" +alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $1}'; } # The X root keeps the DEAD session's last frame, so a fresh launch would be # detected as "already at the title". Blank it, and wait for the new window. xsetroot -solid black 2>/dev/null || true -until xdotool search --name "Xenia-canary" >/dev/null 2>&1; do sleep 1; done +until xdotool search --name "Xenia-canary" >/dev/null 2>&1; do + xdpyinfo -display "$DISP" >/dev/null 2>&1 || { echo "DISPLAY LOST at ${SECONDS}s (before the window appeared)"; exit 3; } + [ -n "$(alive)" ] || { echo "EMULATOR GONE at ${SECONDS}s (before the window appeared)"; exit 4; } + sleep 1 +done deadline=$(( SECONDS + ${1:-900} )) while [ $SECONDS -lt $deadline ]; do + rm -f /tmp/f1.png /tmp/f2.png screenshot /tmp/f1.png >/dev/null 2>&1; sleep 0.6 screenshot /tmp/f2.png >/dev/null 2>&1 + if [ ! -s /tmp/f1.png ] || [ ! -s /tmp/f2.png ]; then + xdpyinfo -display "$DISP" >/dev/null 2>&1 \ + || { echo "DISPLAY LOST at ${SECONDS}s"; exit 3; } + echo "SCREENSHOT FAILED at ${SECONDS}s with the display up"; exit 5 + fi + [ -n "$(alive)" ] || { echo "EMULATOR GONE at ${SECONDS}s"; exit 4; } d=$(compare -metric RMSE /tmp/f1.png /tmp/f2.png null: 2>&1 | sed 's/ .*//' | cut -d. -f1) d=${d:-0} read -r r g b < <(convert /tmp/f2.png -format "%[fx:int(255*p{625,618}.r)] %[fx:int(255*p{625,618}.g)] %[fx:int(255*p{625,618}.b)]" info:) diff --git a/tools/re-capture/wait_flight.sh b/tools/re-capture/wait_flight.sh index 85fd9a2..ab1b951 100755 --- a/tools/re-capture/wait_flight.sh +++ b/tools/re-capture/wait_flight.sh @@ -11,13 +11,25 @@ # block at the bottom of the screen, and no cutscene or menu has anything green # there. So poll that pixel, and tap A every few seconds until it appears (which # dismisses the objective card whenever it happens to be up). +# +# Same liveness rule as skip_intro.sh, for the same reason: a failed screenshot +# leaves r/g/b unset, they default to 0, the green test fails, and waiting for +# the HUD becomes indistinguishable from waiting for a dead emulator. set -u export HOME=/sylph-home/re +DISP="${DISPLAY:-:98}" +alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $1}'; } DEADLINE=$(( SECONDS + ${1:-240} )) PX=450; PY=640 # inside the SHIELD bar of the flight HUD last_tap=0 while [ $SECONDS -lt $DEADLINE ]; do - screenshot /tmp/wf.png >/dev/null 2>&1 || { sleep 2; continue; } + rm -f /tmp/wf.png + if ! screenshot /tmp/wf.png >/dev/null 2>&1 || [ ! -s /tmp/wf.png ]; then + xdpyinfo -display "$DISP" >/dev/null 2>&1 \ + || { echo "DISPLAY LOST at ${SECONDS}s"; exit 3; } + sleep 2; continue + fi + [ -n "$(alive)" ] || { echo "EMULATOR GONE at ${SECONDS}s"; exit 4; } read -r r g b < <(convert /tmp/wf.png -format \ "%[fx:int(255*p{$PX,$PY}.r)] %[fx:int(255*p{$PX,$PY}.g)] %[fx:int(255*p{$PX,$PY}.b)]" info:) if [ "${g:-0}" -gt 140 ] && [ $(( g - r )) -gt 60 ] && [ $(( g - b )) -gt 60 ]; then