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)