diff --git a/docs/re/captures/stage02-inmission-freeze.png b/docs/re/captures/stage02-inmission-freeze.png new file mode 100644 index 00000000..89a1fa85 Binary files /dev/null and b/docs/re/captures/stage02-inmission-freeze.png differ diff --git a/docs/re/mission-freeze-and-ob-flag.md b/docs/re/mission-freeze-and-ob-flag.md new file mode 100644 index 00000000..d07391fc --- /dev/null +++ b/docs/re/mission-freeze-and-ob-flag.md @@ -0,0 +1,83 @@ +# An in-mission freeze, and the first cut at what `REMAINING OB` counts + +**2026-08-23.** One Stage 02 run, aimed at the ❔ that +[`structures/mission-objective-counter.md`](structures/mission-objective-counter.md) +leaves open — *what* the counter counts. It produced one clean refutation, one +new confirmation, and one experiment that proves nothing, for a reason worth +having a tool for. + +## ✅ The address recurs a third time + +`HUD=4 RAM=4` at `0xbdb59668`, read before anything else was attempted. That is +the **third** run putting the counter at that exact address (runs 1, 4, 5 of the +five measured), against runs 2 and 3 where it read a hard 0. The rule in the +structure file — *try it, check it against the HUD, re-scan when it reads 0* — +survives another test. + +## 🔴 The counter is not a live class head-count + +With the counter at **4**, the typed live entity list was: + +| n | class | +|---|---| +| 8 | `UN_e010_ADAN_Attacker_S` | +| 7 | `UN_f001_TCAF_DeltaSaber_T` | +| 7 | `UN_e007_ADAN_Turret` | +| 1 | `UN_f001_TCAF_DeltaSaber_T_Player` | + +**No class has 4 members**, and no sum of two of them does either. This is a +sharper version of the corpus's existing note (`012` on the HUD against 118 live +ADAN): it is not merely "not the hostile count", it is not the count of *any* +class the enumeration can see. So the counter is either a subset marked some +other way — the `OB` badge — or a number the mission script keeps of its own. + +## 🔴 The flag experiment proves nothing — the guest had frozen + +The plan was the same shape that settled the address: find every 4-byte offset +around an entity where exactly *N* entities agree, then require the survivors to +agree exactly *N′* times after the counter moves. The first half ran — +**20 candidate offsets** where exactly 4 of the 23 entities share a value — and +then the counter sat at 4 for the whole 600 s window and the run reported +"counter never moved". + +**That reading was wrong, and the correction is the useful part.** The guest had +stopped advancing about **ten seconds into flight**: + +* `pilot.py` logged 5 910 samples over 734 s and every one after t=10.6 s carried + the identical speed, yaw and pitch — **724 s of frozen state**. +* Two screenshots six seconds apart were **byte-identical**: max difference 0 + over 863 325 pixels ([`captures/stage02-inmission-freeze.png`](captures/stage02-inmission-freeze.png)). +* Everything that is supposed to notice, did not: `screen_id.py` still said + `flight`, the emulator was alive at **212 % CPU**, and the process liveness + check every script uses was satisfied. + +So "the counter never moved" was a fact about a dead world, not about the game. +🔴 **Withdrawn with it:** the claim written into `ob_session.sh` that the counter +"climbs on its own as waves spawn in the first minutes" — in a run that *is* +advancing it did (004 → 008 → 012 twice), but this run cannot support the general +statement and the two facts had been about to be conflated. + +**The freeze is intermittent, not universal:** the run an hour earlier stepped +the counter twice and had `pilot.py` in `DEFEND` at t=629 s with changing state. + +### [`frozen.py`](../../tools/re-capture/frozen.py) + +Two screenshots, a gap, exact comparison — no tolerance, because a stopped +simulation is *identical*, not merely similar. Checked in both directions: 0 on +the frozen pair, `max_pixel_delta=254` on two frames of a live one. `ob_hunt.py` +and `ob_flag.py` now consult it after a minute of no movement and say +`GUEST FROZEN` instead of quietly waiting out their timeout. + +## What is still open + +* ❔ **The flag itself.** 20 candidate offsets exist but have never been put to + the second test. Repeat `ob_flag.py` on a run that is actually advancing; it + now aborts early instead of wasting the window if it is not. +* ❔ **Whether the enumeration can even see the objectives.** `entities2.typed` + finds entities by their position *changing*, so a stationary objective is + invisible to it. Stage 02's objective is "shoot down all invading enemy + fighters", which move — but a null result would not rule out a flag on objects + this method never enumerates, and that limit is now written into the tool. +* ❔ **What causes the freeze.** Not investigated here at all; this run only + establishes that it happens, that it can happen within seconds of the HUD + appearing, and how to detect it in one call. diff --git a/tools/re-capture/frozen.py b/tools/re-capture/frozen.py new file mode 100755 index 00000000..960de536 --- /dev/null +++ b/tools/re-capture/frozen.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Is the guest still ANIMATING, or has the mission frozen? + +Measured 2026-08-23: a Stage 02 run entered flight, drew a correct HUD, and then +stopped advancing about ten seconds later. `screen_id.py` still said `flight`, +the emulator still burned 212 % CPU, `pilot.py` still logged 5 900 samples, and +every one of them carried the same speed, yaw and pitch — 724 s of identical +state. Two screenshots six seconds apart were **byte-identical**, max difference +0 over 863 325 pixels. + +That is worth its own test because of what it costs when missed: an experiment +that waits for `REMAINING OB` to change will wait out its whole timeout and then +report "the counter never moved", which reads as a fact about the game and is +really a fact about a dead world. Nothing else in the toolkit notices — the +screen classifier, the liveness check and the CPU are all happy. + +A frozen frame is EXACTLY identical, not merely similar: this is a stopped +simulation, not a still scene, so no tolerance is needed and none is used. The +one thing that must be excluded is a screenshot that failed. + +Usage: frozen.py [gap_s] -> prints frozen|animating, exit 0 if FROZEN + frozen.py --pair -> same test on two saved frames, so the + NEGATIVE side can be checked without + a live game to be un-frozen in +""" +import subprocess +import sys +import time + +from PIL import Image, ImageChops + + +def grab(path): + subprocess.run(["screenshot", path], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + return Image.open(path).convert("RGB") + + +def frozen(gap=6.0): + a = grab("/tmp/frz-a.png") + time.sleep(gap) + b = grab("/tmp/frz-b.png") + if a.size != b.size: + return None, -1 + bbox = ImageChops.difference(a, b).getbbox() + px = list(ImageChops.difference(a.convert("L"), b.convert("L")).getdata()) + return (bbox is None), max(px) + + +def compare(pa, pb): + a, b = Image.open(pa).convert("RGB"), Image.open(pb).convert("RGB") + if a.size != b.size: + return None, -1 + px = list(ImageChops.difference(a.convert("L"), b.convert("L")).getdata()) + return (ImageChops.difference(a, b).getbbox() is None), max(px) + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "--pair": + f, mx = compare(sys.argv[2], sys.argv[3]) + gap = "pair" + else: + gap = float(sys.argv[1]) if len(sys.argv) > 1 else 6.0 + f, mx = frozen(gap) + if f is None: + print("unknown (screenshot failed)") + sys.exit(2) + suffix = gap if gap == "pair" else f"{gap}s" + print(f"{'frozen' if f else 'animating'} max_pixel_delta={mx} gap={suffix}") + sys.exit(0 if f else 1) diff --git a/tools/re-capture/ob_flag.py b/tools/re-capture/ob_flag.py new file mode 100755 index 00000000..125229c8 --- /dev/null +++ b/tools/re-capture/ob_flag.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Does an `OB`-badged entity carry a flag, and is `REMAINING OB` its count? + +`mission-objective-counter.md` has the counter's address; what it *counts* is the +part the autopilot needs, because "shoot what closes the mission" requires +picking the right target, not knowing how many are left. + +Two questions, in order of how cheaply they can be killed: + +1. **Is the counter just a per-class head-count?** Print the class histogram + beside the counter. The corpus already suspects not (012 on the HUD against + 118 live ADAN), and one run settles it for every class at once. +2. **Is there a per-entity flag whose set-cardinality is the counter?** For every + 4-byte offset in a window around each entity, count how many entities share + each value. An offset where exactly N entities agree, with N the counter, is a + candidate — and there will be many by chance, so the answer is the SECOND + sample: after the counter moves to N', the same (offset, value) must be shared + by exactly N' entities. That is the same "verify across a transition you did + not select on" rule the address itself had to pass. + +🔴 Known limit, stated because it bounds the conclusion: `entities2.typed` +enumerates entities by their position triple CHANGING between two samples, so a +stationary objective is invisible to it. Stage 02's objective is "shoot down all +invading enemy fighters", which move — but a null result here does not rule out a +flag on objects this enumeration never sees. + +Usage: ob_flag.py [timeout_s] +""" +import json +import os +import struct +import subprocess +import sys +import time +from collections import Counter, defaultdict + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import entities2 # noqa: E402 +import frozen # noqa: E402 +import gmem # noqa: E402 +import gworld # noqa: E402 +import ob_read # noqa: E402 + +VA = 0xBDB59668 +RADIUS = 0x400 +DELTA = 0x130 + + +def counter(fd): + return struct.unpack(">I", os.pread(fd, 4, gmem.va_to_off(VA)))[0] + + +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 sample(w, defs, out, tag): + """(counter, entity list, {(offset, value): [entity positions]}).""" + fd = w.fd + n = counter(fd) + movers = entities2.moving(fd, w.size) + ents = entities2.typed(fd, defs, movers, DELTA) + uniq = {} + for off, nm, pos, sp in ents: + uniq.setdefault((nm, tuple(round(c, 1) for c in pos)), (off, nm)) + ents = list(uniq.values()) + groups = defaultdict(list) + for off, nm in ents: + lo = off - RADIUS + blob = os.pread(fd, RADIUS * 2, lo) + for k in range(0, len(blob) - 3, 4): + groups[(lo + k - off, blob[k:k + 4])].append(nm) + print(f"[{tag}] counter={n} entities={len(ents)}", flush=True) + return n, ents, groups + + +def main(): + out = sys.argv[1] + deadline = time.time() + (float(sys.argv[2]) if len(sys.argv) > 2 else 600) + os.makedirs(out, exist_ok=True) + w = gworld.World() + defs = entities2.definitions(w) + + v = hud(f"{out}/a.png") + n0 = counter(w.fd) + print(f"HUD={v} RAM={n0}", flush=True) + if v != n0: + print("HUD and RAM disagree — wrong address for this run; re-scan with " + "ob_hunt.py before trusting anything below", flush=True) + return 2 + + nA, entsA, gA = sample(w, defs, out, "A") + hist = Counter(nm for _, nm in entsA) + print(f"[A] class histogram vs counter {nA}:", flush=True) + for nm, k in hist.most_common(12): + print(f" {k:4d} {nm}", flush=True) + exact = [nm for nm, k in hist.items() if k == nA] + print(f"[A] classes whose head-count equals the counter: {exact or 'NONE'}", + flush=True) + + candA = {k: v for k, v in gA.items() if len(v) == nA} + print(f"[A] offsets where exactly {nA} entities agree: {len(candA)}", flush=True) + + stuck = 0 + while time.time() < deadline: + time.sleep(5) + if counter(w.fd) != nA: + break + stuck += 1 + if stuck % 12 == 0 and frozen.frozen(6.0)[0]: + print("GUEST FROZEN — the world stopped advancing, so the counter " + "was never going to move; this run proves nothing", flush=True) + return 3 + nB = counter(w.fd) + if nB == nA: + print("counter never moved — no verification possible", flush=True) + return 1 + vb = hud(f"{out}/b.png") + print(f"counter {nA} -> {nB} (HUD {vb})", flush=True) + + _, entsB, gB = sample(w, defs, out, "B") + survivors = {k: (len(gA[k]), len(gB.get(k, []))) for k in candA + if len(gB.get(k, [])) == nB} + print(f"[B] of {len(candA)} candidates, {len(survivors)} still hold " + f"exactly {nB}", flush=True) + rows = [{"delta": d, "value": val.hex(), "a": a, "b": b} + for (d, val), (a, b) in sorted(survivors.items())] + json.dump({"nA": nA, "nB": nB, "hud_a": v, "hud_b": vb, + "entities_a": len(entsA), "entities_b": len(entsB), + "class_matches": exact, "candidates_a": len(candA), + "survivors": rows}, open(f"{out}/flag.json", "w"), indent=1) + for r in rows[:40]: + print(f" pos{r['delta']:+#07x} = {r['value']} {r['a']} -> {r['b']}", + flush=True) + print(f"wrote {out}/flag.json", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/re-capture/ob_hunt.py b/tools/re-capture/ob_hunt.py index 9cff02d1..e6168a4d 100755 --- a/tools/re-capture/ob_hunt.py +++ b/tools/re-capture/ob_hunt.py @@ -26,6 +26,7 @@ import sys import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import frozen # noqa: E402 import gmem # noqa: E402 import ob_read # noqa: E402 import ob_scan # noqa: E402 @@ -66,11 +67,22 @@ def main(): # --- filter on each following transition --------------------------------- prev, done, results = v0, 0, [] + stuck = 0 while done < want and time.time() < deadline: time.sleep(5) v = hud(f"{out}/poll.png") if v is None or v == prev: + # "the counter never moved" is a claim about the GAME; a frozen + # guest makes it a claim about a dead world instead, and the run + # then burns its whole timeout saying nothing. Measured once: the + # world stopped ~10 s into flight and everything downstream -- + # screen_id, the liveness check, the CPU -- stayed happy. + stuck += 1 + if stuck % 12 == 0 and frozen.frozen(6.0)[0]: + log("GUEST FROZEN — the world stopped advancing; abandoning") + break continue + stuck = 0 # Confirm the new value before spending a filter on it. time.sleep(1.5) if hud(f"{out}/poll2.png") != v: