diff --git a/tools/re-capture/ob_sample.py b/tools/re-capture/ob_sample.py new file mode 100755 index 00000000..23024266 --- /dev/null +++ b/tools/re-capture/ob_sample.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Sample a candidate `REMAINING OB` address paired with a HUD screenshot. + +`structures/mission-objective-counter.md` found the counter at a guest VA in one +Stage 02 run and left cross-run stability untested. Testing it needs the RAM word +and the HUD digits from the SAME moment, which is the trap that already cost one +pass: the value moves between the memory read and the screenshot that is supposed +to confirm it. So every sample reads the word, takes the shot, and reads the word +AGAIN — a row whose two reads disagree is thrown away rather than believed. + +The HUD counter is cropped out of each shot (the region is fixed: the plate sits +in the top-right, x >= 0.72 W, 0.32 H .. 0.45 H) so the digits can be read without +squinting at a 1280x720 frame. + +Usage: ob_sample.py [interval_s] [count] +""" +import os +import struct +import subprocess +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import gmem # noqa: E402 + +CROP = (0.72, 0.32, 1.0, 0.45) # HUD "REMAINING OB" plate, fractions of W/H + + +def read_word(path, off): + try: + with open(path, "rb") as f: + f.seek(off) + b = f.read(4) + return struct.unpack(">I", b)[0] if len(b) == 4 else None + except OSError: + return None + + +def crop_hud(shot, out): + try: + from PIL import Image + except ImportError: + return False + im = Image.open(shot) + w, h = im.size + c = im.crop((int(CROP[0] * w), int(CROP[1] * h), int(CROP[2] * w), int(CROP[3] * h))) + c.resize((c.width * 2, c.height * 2)).save(out) + return True + + +def main(): + va = int(sys.argv[1], 0) + out_csv, shot_dir, tag = sys.argv[2], sys.argv[3], sys.argv[4] + interval = float(sys.argv[5]) if len(sys.argv) > 5 else 30.0 + count = int(sys.argv[6]) if len(sys.argv) > 6 else 8 + + off = gmem.va_to_off(va) + mem = gmem.mem_path() + os.makedirs(shot_dir, exist_ok=True) + t0 = time.time() + with open(out_csv, "w") as f: + f.write("t_s,ram_before,ram_after,stable,shot\n") + for i in range(count): + before = read_word(mem, off) + shot = os.path.join(shot_dir, f"{tag}-{i:02d}.png") + subprocess.run(["screenshot", shot], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + after = read_word(mem, off) + crop_hud(shot, os.path.join(shot_dir, f"{tag}-{i:02d}-ob.png")) + row = (f"{time.time()-t0:.1f},{before},{after}," + f"{int(before is not None and before == after)},{shot}") + f.write(row + "\n") + f.flush() + print(row, flush=True) + time.sleep(max(0.0, interval - (time.time() - t0) % interval)) + + +if __name__ == "__main__": + main() diff --git a/tools/re-capture/ob_session.sh b/tools/re-capture/ob_session.sh new file mode 100755 index 00000000..57555b10 --- /dev/null +++ b/tools/re-capture/ob_session.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Boot -> Stage 02 flight -> fly the survival pilot, sampling the candidate +# REMAINING OB address against the HUD the whole time. +# +# fly_session.sh with one addition: ob_sample.py runs alongside the pilot, so +# the RAM word and the HUD digits come from the same run rather than from two. +# The pilot is not optional decoration -- an unattended craft is dead in about a +# minute (upstream-baseline.md), and a dead craft produces no transitions, which +# is exactly the evidence this test needs. +# +# NOTE (2026-08-23): launch_mission.sh does not currently finish unattended on a +# freshly restored profile -- it reaches the READY ROOM and stops on BRIEFINGS +# (docs/re/dynamic-re-state-restore.md). Until that fixed `sleep 28` becomes a +# wait for the screen, this script inherits the same stop and the sampler never +# starts; drive the last two presses by hand, or fix the launcher first. +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:-240}" +TAG="${2:-ob}" +VA="${OB_VA:-0xbdb59668}" +SHOTS=/sylph-home/re/shots/$TAG +CFG=/tmp/nav-live.json + +"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; } +mkdir -p "$SHOTS" + +# Sample immediately -- the first reading is the cross-run test and must not +# depend on the pilot binding, which can fail on its own. +python3 "$SD/ob_sample.py" "$VA" "/tmp/$TAG-ob.csv" "$SHOTS" "$TAG" 25 \ + "$(python3 -c "import math;print(max(2,math.ceil($SECS/25)))")" & +SAMPLER=$! + +if python3 "$SD/entities2.py" self 0x130 "$CFG"; then + echo "--- config: $(cat "$CFG")" + python3 "$SD/pilot.py" "$CFG" "$SECS" || echo "PILOT EXITED $?" +else + echo "BIND FAILED -- sampling an unattended craft, expect a short run" +fi + +wait $SAMPLER 2>/dev/null +echo "--- samples: /tmp/$TAG-ob.csv" +cat "/tmp/$TAG-ob.csv" +echo "SESSION DONE"