docs+tools: a mission freeze that made an experiment lie, and the counter is not a class head-count

Three things from one Stage 02 run.

The address recurs a third time: HUD=4 RAM=4 at 0xbdb59668, so 3 of the 5 runs
measured put the counter exactly there.

The counter is NOT a live class head-count. With the counter at 4 the typed
entity list was 8 attackers, 7 friendly Delta Sabers, 7 turrets and the player -
no class has 4 members and no pair of them sums to 4. That sharpens the corpus's
existing "012 against 118 live ADAN" note from "not the hostile count" to "not
the count of any class this enumeration can see".

The flag experiment itself proves nothing, and why is the useful part. It found
20 offsets where exactly 4 of 23 entities agree, then reported "the counter never
moved" for 600 s. The guest had stopped advancing ten seconds into flight:
pilot.py logged 724 s of identical speed/yaw/pitch, and two screenshots six
seconds apart were byte-identical, max delta 0 over 863325 pixels - while
screen_id said "flight", the emulator burned 212% CPU and every liveness check
passed. So that was a fact about a dead world. Withdrawn along with it: the claim
in ob_session.sh that the counter climbs on its own in the first minutes, which
one advancing run supports and this one cannot.

frozen.py makes it a single call, checked in both directions (0 on the frozen
pair, 254 on two frames of a live run), and ob_hunt/ob_flag now say GUEST FROZEN
rather than waiting out their timeouts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
This commit is contained in:
Sylpheed RE agent
2026-08-23 19:10:41 +00:00
parent 2a844e6da9
commit fcd9fcecf8
5 changed files with 308 additions and 0 deletions

70
tools/re-capture/frozen.py Executable file
View File

@@ -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 <a.png> <b.png> -> 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)

143
tools/re-capture/ob_flag.py Executable file
View File

@@ -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 <out-dir> [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())

View File

@@ -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: