From 21e6556a109ea7dac0d411d90cf55be4cecd61fc Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Sun, 23 Aug 2026 18:19:29 +0000 Subject: [PATCH] tools: read REMAINING OB off the HUD, so the scan can follow the counter Both hand attempts at this address managed exactly one filter and then lost the counter, because every reading of the HUD costs a human round trip. ob_read.py does it by template correlation over three fixed digit cells, and ob_hunt.py uses that to run the whole method unattended: confirm the HUD on both sides of the 0.9 s scan, then filter on each following transition, labelling the first as the one it selected on and the rest as verification. Two things measured rather than assumed: * Normalisation is the point, not a nicety. The plate is translucent, so an explosion turns it orange - on the 008 reference frame a cyan-stroke mask finds 6 of ~70 stroke columns and would silently read nothing. Per-cell mean/std normalisation reads it correctly at 0.843. * The accept rule is two-sided because the margin is narrow: over 12 cells from four frames of known value the correct digit scores 0.843..1.000 and the best WRONG digit reaches 0.789 (0 and 8 are similar outlines). So a floor of 0.80 AND a 0.05 margin over the runner-up, against a smallest observed correct margin of 0.093. A bare threshold fitted between those two numbers would be fitted to twelve samples. Templates exist for 0 1 2 4 8 - the digits actually seen. Anything else reads as "?" and callers must treat a "?" as unknown, never as a value. Rejects both negatives tested: GAME OVER scores 0.05-0.18, the main menu 0.07-0.14. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE --- tools/re-capture/ob_hunt.py | 99 +++++++++++++++++++++++++++++++++++++ tools/re-capture/ob_read.py | 85 +++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100755 tools/re-capture/ob_hunt.py create mode 100755 tools/re-capture/ob_read.py diff --git a/tools/re-capture/ob_hunt.py b/tools/re-capture/ob_hunt.py new file mode 100755 index 00000000..9cff02d1 --- /dev/null +++ b/tools/re-capture/ob_hunt.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Find `REMAINING OB` in RAM by value-scanning against the HUD, automatically. + +The method is `ob_scan.py`'s, and the two traps it records are what this +automates away, because both were paid twice by hand: + +* **Confirm the HUD on BOTH sides of the scan.** The scan itself takes ~0.9 s, + but the counter climbs 004 -> 008 -> 012 within the first minutes of Stage 02, + and a scan taken across a step selects nothing. A pass whose before- and + after-readings disagree is thrown away and retried rather than used. +* **Verify across a transition you did NOT select on.** The first filter only + narrows; the SECOND one is the evidence. Two hand attempts each managed one + filter and then lost the counter — once because the pilot stopped killing + anything, once because the mission ended. + +The HUD is read by `ob_read.py`, which returns `?` rather than a wrong digit, so +an unreadable frame (explosion, GAME OVER) stalls the hunt instead of poisoning +it. + +Usage: ob_hunt.py [transitions] [timeout_s] +""" +import json +import os +import subprocess +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import gmem # noqa: E402 +import ob_read # noqa: E402 +import ob_scan # noqa: E402 + + +def hud(shot): + subprocess.run(["screenshot", shot], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + txt, _ = ob_read.read(shot) + return int(txt) if txt.isdigit() else None + + +def main(): + out = sys.argv[1] + want = int(sys.argv[2]) if len(sys.argv) > 2 else 2 + deadline = time.time() + (float(sys.argv[3]) if len(sys.argv) > 3 else 900) + os.makedirs(out, exist_ok=True) + mem = gmem.mem_path() + log = lambda *a: print(*a, flush=True) + + # --- the scan, with the HUD confirmed on both sides ----------------------- + cands = None + while cands is None and time.time() < deadline: + v0 = hud(f"{out}/scan-before.png") + if v0 is None: + time.sleep(3) + continue + offs = ob_scan.scan(mem, v0) + v1 = hud(f"{out}/scan-after.png") + if v1 != v0: + log(f"scan at {v0} discarded: HUD moved to {v1} across it") + continue + cands = offs + log(f"scanned at {v0:03d}: {len(cands)} candidates") + if cands is None: + log("never got a clean scan") + return 1 + + # --- filter on each following transition --------------------------------- + prev, done, results = v0, 0, [] + while done < want and time.time() < deadline: + time.sleep(5) + v = hud(f"{out}/poll.png") + if v is None or v == prev: + continue + # Confirm the new value before spending a filter on it. + time.sleep(1.5) + if hud(f"{out}/poll2.png") != v: + continue + vals = ob_scan.read_at(mem, cands) + cands = [o for o, x in vals.items() if x == v] + done += 1 + kind = "SELECTED ON" if done == 1 else "verification (unselected)" + log(f"{prev:03d} -> {v:03d} [{kind}] {len(cands)} survive") + results.append({"from": prev, "to": v, "survivors": len(cands)}) + prev = v + if not cands: + log("no survivors — the scan value or a reading was wrong") + break + + payload = {"scan_value": v0, "transitions": results, + "survivors": [{"off": o, "va": gmem.primary_va(o)} for o in sorted(cands)]} + json.dump(payload, open(f"{out}/hunt.json", "w"), indent=1) + for o in sorted(cands): + log(f" va {gmem.primary_va(o):#x}") + log(f"wrote {out}/hunt.json") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/re-capture/ob_read.py b/tools/re-capture/ob_read.py new file mode 100755 index 00000000..c9b28955 --- /dev/null +++ b/tools/re-capture/ob_read.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Read the HUD's `REMAINING OB` counter off a screenshot. + +Finding the counter in RAM needs the HUD value at the moment of every scan and +every filter, and the corpus's rule needs it again at a *later* transition. Doing +that by eye costs a round trip per reading, which is why the last two attempts +each managed one filter and then missed the next step of the counter. + +Three digits, fixed cells, matched against templates by **normalised** cross- +correlation. The normalisation is the point rather than a nicety: the plate is +translucent, so an explosion turns the whole thing red and a nearby tracer washes +it out. A hue mask keyed on the digits' cyan silently fails on exactly those +frames — measured: the `008` reference frame has an orange flash across it and a +cyan mask finds 6 of the ~70 stroke columns. Subtracting each cell's own mean and +dividing by its own standard deviation makes the match indifferent to both. + +The cells and the template strip were measured from the cyan-stroke column +profile of clean frames: strokes at x 1109-1132, 1139-1162, 1169-1191 and rows +236-269, so the cells are those boxes with a small margin. + +**Templates exist for 0 1 2 4 8 only** — those are the digits the counter has +actually shown in captured frames. Anything else reads as `?`, and a reading with +a `?` in it is not a number: callers must treat it as "unknown", never as a +value. Add a digit by pasting a new 29x39 cell into `ob_digits.png` and extending +DIGITS; the file is a plain grey strip in DIGITS order. + +Usage: ob_read.py [--json] -> "004" | "0?2" | "none" +""" +import json +import os +import sys + +import numpy as np +from PIL import Image + +CELLS = [(1106, 233, 1135, 272), (1136, 233, 1165, 272), (1166, 233, 1195, 272)] +DIGITS = "01248" +STRIP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ob_digits.png") +# MEASURED over 12 cells from four frames whose value is known by eye, rather +# than guessed: the correct digit scores 0.843 .. 1.000 and the best WRONG digit +# scores up to 0.789 — these are outline glyphs on a translucent plate, so 0 and +# 8 look a lot alike to a correlator. A bare threshold between 0.789 and 0.843 +# would be fitted to twelve samples, so accept on TWO conditions instead: a floor +# well below the worst correct match, AND a margin over the runner-up. The +# smallest correct-to-runner-up margin observed is 0.093. +THRESHOLD = 0.80 +MARGIN = 0.05 + + +def _norm(a): + a = a.astype(np.float64) + s = a.std() + return (a - a.mean()) / s if s > 1e-6 else a * 0.0 + + +def _templates(): + strip = np.asarray(Image.open(STRIP).convert("L")) + w = strip.shape[1] // len(DIGITS) + return {d: _norm(strip[:, i * w:(i + 1) * w]) for i, d in enumerate(DIGITS)} + + +def read(path): + """Return (text, per-digit best scores).""" + im = Image.open(path).convert("L") + tmpl = _templates() + out, scores = "", [] + for box in CELLS: + c = _norm(np.asarray(im.crop(box))) + ranked = sorted(((float((c * t).mean()), d) for d, t in tmpl.items() + if t.shape == c.shape), reverse=True) + best, bd = ranked[0] + runner = ranked[1][0] if len(ranked) > 1 else -2.0 + scores.append((round(best, 3), round(best - runner, 3))) + out += bd if best >= THRESHOLD and best - runner >= MARGIN else "?" + return out, scores + + +if __name__ == "__main__": + txt, sc = read(sys.argv[1]) + if "--json" in sys.argv: + print(json.dumps({"text": txt, "scores": sc, + "value": int(txt) if txt.isdigit() else None})) + else: + print(f"{txt} scores={sc}") + sys.exit(0 if txt.isdigit() else 1)