re-capture: a dead display must not look like a playing movie

skip_intro.sh compared two screenshots to detect the intro movie. When the X
server died 3 min into a run, `screenshot` failed silently and left both PNGs
at their previous contents — two stale files, whose RMSE is a constant non-zero
number, i.e. exactly the signature of a changing screen. The loop then reported
"movie -> skip A" every 5 s for the whole 600 s timeout with no emulator and no
display alive, and the session wasted 10 minutes before saying BOOT FAILED.

Both waiters now verify, every iteration, that the screenshot was actually
written, that the display answers xdpyinfo, and that a non-zombie xenia_canary
exists — with distinct exit codes (3 display, 4 emulator, 5 capture) so the
session log names the cause instead of timing out.

Also adds mission_state.py + escort_session.sh: read pos+0x154 against each
definition's HP for EVERY entity, to test whether the hull anchor is a property
of the entity class rather than of the player object (the escort question).
This commit is contained in:
claude-re
2026-07-30 17:06:08 +00:00
parent 93d6534efe
commit 91fb022caf
5 changed files with 247 additions and 3 deletions

View File

@@ -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"

View File

@@ -35,7 +35,7 @@ cd /sylph-home/re
setsid nohup run-canary --audio --apu=sdl --log_mask=13 \ setsid nohup run-canary --audio --apu=sdl --log_mask=13 \
--logged_profile_slot_0_xuid=E0300000EFBEA3D4 </dev/null >/dev/null 2>&1 & --logged_profile_slot_0_xuid=E0300000EFBEA3D4 </dev/null >/dev/null 2>&1 &
sleep 5 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 sleep 14 # main menu is not input-ready before this
step down # NEW GAME -> LOAD GAME step down # NEW GAME -> LOAD GAME

167
tools/re-capture/mission_state.py Executable file
View File

@@ -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 <cfg> <secs> [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()

View File

@@ -3,16 +3,37 @@
# - movie playing (two grabs 0.6s apart differ a lot) -> tap A to skip # - 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 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. # 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 set -u
export HOME=/sylph-home/re 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 # 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. # detected as "already at the title". Blank it, and wait for the new window.
xsetroot -solid black 2>/dev/null || true 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} )) deadline=$(( SECONDS + ${1:-900} ))
while [ $SECONDS -lt $deadline ]; do 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/f1.png >/dev/null 2>&1; sleep 0.6
screenshot /tmp/f2.png >/dev/null 2>&1 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=$(compare -metric RMSE /tmp/f1.png /tmp/f2.png null: 2>&1 | sed 's/ .*//' | cut -d. -f1)
d=${d:-0} 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:) 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:)

View File

@@ -11,13 +11,25 @@
# block at the bottom of the screen, and no cutscene or menu has anything green # 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 # 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). # 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 set -u
export HOME=/sylph-home/re 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} )) DEADLINE=$(( SECONDS + ${1:-240} ))
PX=450; PY=640 # inside the SHIELD bar of the flight HUD PX=450; PY=640 # inside the SHIELD bar of the flight HUD
last_tap=0 last_tap=0
while [ $SECONDS -lt $DEADLINE ]; do 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 \ 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:) "%[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 if [ "${g:-0}" -gt 140 ] && [ $(( g - r )) -gt 60 ] && [ $(( g - b )) -gt 60 ]; then