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:
BIN
docs/re/captures/stage02-inmission-freeze.png
Normal file
BIN
docs/re/captures/stage02-inmission-freeze.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 726 KiB |
83
docs/re/mission-freeze-and-ob-flag.md
Normal file
83
docs/re/mission-freeze-and-ob-flag.md
Normal file
@@ -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.
|
||||||
70
tools/re-capture/frozen.py
Executable file
70
tools/re-capture/frozen.py
Executable 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
143
tools/re-capture/ob_flag.py
Executable 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())
|
||||||
@@ -26,6 +26,7 @@ import sys
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import frozen # noqa: E402
|
||||||
import gmem # noqa: E402
|
import gmem # noqa: E402
|
||||||
import ob_read # noqa: E402
|
import ob_read # noqa: E402
|
||||||
import ob_scan # noqa: E402
|
import ob_scan # noqa: E402
|
||||||
@@ -66,11 +67,22 @@ def main():
|
|||||||
|
|
||||||
# --- filter on each following transition ---------------------------------
|
# --- filter on each following transition ---------------------------------
|
||||||
prev, done, results = v0, 0, []
|
prev, done, results = v0, 0, []
|
||||||
|
stuck = 0
|
||||||
while done < want and time.time() < deadline:
|
while done < want and time.time() < deadline:
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
v = hud(f"{out}/poll.png")
|
v = hud(f"{out}/poll.png")
|
||||||
if v is None or v == prev:
|
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
|
continue
|
||||||
|
stuck = 0
|
||||||
# Confirm the new value before spending a filter on it.
|
# Confirm the new value before spending a filter on it.
|
||||||
time.sleep(1.5)
|
time.sleep(1.5)
|
||||||
if hud(f"{out}/poll2.png") != v:
|
if hud(f"{out}/poll2.png") != v:
|
||||||
|
|||||||
Reference in New Issue
Block a user