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:
44
tools/re-capture/escort_session.sh
Executable file
44
tools/re-capture/escort_session.sh
Executable 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"
|
||||
@@ -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 >/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
|
||||
|
||||
167
tools/re-capture/mission_state.py
Executable file
167
tools/re-capture/mission_state.py
Executable 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()
|
||||
@@ -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:)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user