diff --git a/docs/re/mission-freeze-and-ob-flag.md b/docs/re/mission-freeze-and-ob-flag.md index cda46f5..7768061 100644 --- a/docs/re/mission-freeze-and-ob-flag.md +++ b/docs/re/mission-freeze-and-ob-flag.md @@ -208,3 +208,46 @@ by position changing, so a sample that lands on a load or loses the race with another scanner returns nothing — and a zero-entity sample silently yields zero candidates and a run that proves nothing. Seen exactly that way once, with the following sample finding 81 entities moments later. + +--- + +## 2026-08-24 — the bit-level test exists; three runs, no verification, and one hole closed + +[`ob_bitflag.py`](../../tools/re-capture/ob_bitflag.py) is the follow-on the +word-level refutation named: for every 4-byte offset in the window (now +`-0x400 … +0xC00`) and every one of its 32 bits, count how many entities have it +**set**, keep the `(offset, bit)` pairs whose count is exactly the counter, and +require the survivors to match again after the counter moves. Both polarities are +counted, because an objective could equally be marked by a bit that is *clear* on +it. + +It ran three times and **verified nothing**. What each attempt cost is the useful +part: + +| run | sample A | outcome | +|---|---|---| +| 1 | counter 4, 29 entities → **187** set-polarity + **33** clear-polarity candidates | "counter never moved" for the whole 700 s window — **and the mission had ended in GAME OVER partway through** | +| 2 | — | HUD unreadable: still the GAME OVER screen | +| 3 (fresh boot) | — | the counter was at a **different address**; the guard refused to run. `ob_hunt` got one filter (`008 → 012`, 4 survivors including `0xbdb58668`) and then the guest froze | + +### 🔴 The hole run 1 exposed: "animating" is not "still in a mission" + +`frozen.py` asks whether the guest is advancing. The **GAME OVER screen animates +perfectly happily** — mean colour (114, 22, 63), the magenta plate — so every +liveness check passed while the mission was over, and the tool reported *"the +counter never moved"*, which reads as a fact about the game and was a fact about +a finished one. + +Closed: `frozen.in_flight()` classifies the screen with `screen_id.py`, and +`ob_hunt.py` / `ob_flag.py` / `ob_bitflag.py` now abort with **`NO LONGER IN +FLIGHT`** instead of waiting out their windows. That is the second time a +confident negative in this investigation turned out to be about a dead world — +the first was the freeze — and the pattern is worth naming: *before believing +"X never happened", show that the thing that would produce X was still running.* + +### Attrition is now the dominant cost + +Of the Stage 02 runs that have reached flight, roughly half end early — a freeze, +or a GAME OVER when the escorted ACROPOLIS or the craft is lost — and the scan +needs the run to survive **two** counter transitions. Nothing here fixes that; it +is why three attempts produced one filter between them. diff --git a/tools/re-capture/frozen.py b/tools/re-capture/frozen.py index 960de53..f34da3c 100755 --- a/tools/re-capture/frozen.py +++ b/tools/re-capture/frozen.py @@ -55,6 +55,28 @@ def compare(pa, pb): return (ImageChops.difference(a, b).getbbox() is None), max(px) +def in_flight(shot="/tmp/frz-flight.png"): + """Is the flight HUD still on screen? + + `frozen()` answers "is the guest animating", and that is NOT the same + question: the GAME OVER screen animates happily, and a run that ends there + passes every liveness check while the mission is over. One bit-level + differential spent its whole 700 s window reporting "the counter never + moved" about a world that had stopped being a mission — mean colour + (114, 22, 63), the magenta GAME OVER plate, not flight. + + Classified by `screen_id.py`'s whole-image statistics, for the reasons its + own header gives. + """ + import subprocess as _sp + import os as _os + _sp.run(["screenshot", shot], stdout=_sp.DEVNULL, stderr=_sp.DEVNULL) + here = _os.path.dirname(_os.path.abspath(__file__)) + out = _sp.run(["python3", _os.path.join(here, "screen_id.py"), shot], + capture_output=True, text=True).stdout.split() + return bool(out) and out[0] == "flight" + + if __name__ == "__main__": if len(sys.argv) > 1 and sys.argv[1] == "--pair": f, mx = compare(sys.argv[2], sys.argv[3]) diff --git a/tools/re-capture/ob_bitflag.py b/tools/re-capture/ob_bitflag.py new file mode 100755 index 0000000..20d197d --- /dev/null +++ b/tools/re-capture/ob_bitflag.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Is `REMAINING OB` the population of a per-entity *bit*? + +[`ob_flag.py`](ob_flag.py) refuted the word-level version: no 4-byte word near an +entity's position holds a common value on exactly the objective entities, and its +two candidates both died on the `12 → 11` transition. But that test asks which +entities share an **exact 32-bit value**, and it names its own blind spot — a +single bit ORed into a word that also carries health, a timer or a state machine +would never produce a shared value and could not be seen. + +This is that test at bit granularity: for every 4-byte offset in the window and +every one of its 32 bits, count how many entities have the bit SET, keep the +(offset, bit) pairs whose count is exactly the counter, and then require the +survivors to match again after the counter moves — the same "verify across a +transition you did not select on" rule, because there will be thousands of +coincidences at N alone. + +Both polarities are counted: an objective might be marked by a bit that is set on +it, or by one that is CLEAR on it and set on everything else. + +🔴 Same limits as the word-level test, and they still bound the answer: +`entities2.typed` types entities by their position CHANGING, so a stationary +objective is invisible; and anything outside the window is untested. + +Usage: ob_bitflag.py [timeout_s] +""" +import json +import os +import struct +import subprocess +import sys +import time + +import numpy as np + +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 = int(os.environ.get("OB_VA", "0xBDB59668"), 0) +LO, HI = -0x400, 0xC00 # window around the position triple +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, tag): + """(counter, entity names, words[n_entities, n_words] as uint32).""" + 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()) + rows, names = [], [] + for off, nm in ents: + blob = os.pread(fd, HI - LO, off + LO) + if len(blob) != HI - LO: + continue + rows.append(np.frombuffer(blob, dtype=">u4")) + names.append(nm) + print(f"[{tag}] counter={n} entities={len(rows)}", flush=True) + return n, names, (np.array(rows, dtype=np.uint32) if rows else np.zeros((0, 0), np.uint32)) + + +def bit_counts(words): + """counts[bit, word] = how many entities have that bit set.""" + out = np.zeros((32, words.shape[1]), dtype=np.int32) + for b in range(32): + out[b] = ((words >> np.uint32(b)) & np.uint32(1)).sum(axis=0) + return out + + +def main(): + out = sys.argv[1] + deadline = time.time() + (float(sys.argv[2]) if len(sys.argv) > 2 else 700) + os.makedirs(out, exist_ok=True) + w = gworld.World() + defs = entities2.definitions(w) + + v = None + for _ in range(8): + v = hud(f"{out}/a.png") + n0 = counter(w.fd) + if v is not None: + break + time.sleep(3) + print(f"HUD={v} RAM={n0}", flush=True) + if v is None or v != n0: + print("HUD and RAM disagree (or unreadable) — re-scan with ob_hunt.py", + flush=True) + return 2 + + for _ in range(6): + nA, namesA, wordsA = sample(w, defs, "A") + if len(namesA) >= 10: + break + print("[A] too few entities — retrying", flush=True) + time.sleep(5) + if len(namesA) < 10: + print("[A] never got a usable entity sample", flush=True) + return 4 + + cA = bit_counts(wordsA) + nA_ents = len(namesA) + setA = np.argwhere(cA == nA) # bit set on exactly N entities + clrA = np.argwhere(cA == nA_ents - nA) # bit CLEAR on exactly N + print(f"[A] (offset,bit) pairs set on exactly {nA}: {len(setA)}; " + f"clear on exactly {nA}: {len(clrA)}", flush=True) + + stuck = 0 + while time.time() < deadline: + time.sleep(5) + if counter(w.fd) != nA: + break + stuck += 1 + if stuck % 12 == 0: + # Two different ways the wait can be pointless, and only one of them + # is a freeze: a run that ends in GAME OVER keeps ANIMATING, so + # frozen() is happy while the mission is over. One window was spent + # reporting "the counter never moved" about exactly that. + if frozen.frozen(6.0)[0]: + print("GUEST FROZEN — the counter was never going to move", + flush=True) + return 3 + if not frozen.in_flight(): + print("NO LONGER IN FLIGHT — the mission ended; this window " + "proves nothing", flush=True) + return 5 + 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) + + nB2, namesB, wordsB = sample(w, defs, "B") + cB = bit_counts(wordsB) + nB_ents = len(namesB) + surv_set = [(int(b), int(o)) for b, o in setA if cB[b, o] == nB2] + surv_clr = [(int(b), int(o)) for b, o in clrA if cB[b, o] == nB_ents - nB2] + print(f"[B] survivors — set-polarity {len(surv_set)}, " + f"clear-polarity {len(surv_clr)}", flush=True) + rows = ([{"polarity": "set", "offset": LO + o * 4, "bit": b} for b, o in surv_set] + + [{"polarity": "clear", "offset": LO + o * 4, "bit": b} for b, o in surv_clr]) + json.dump({"nA": nA, "nB": nB2, "hud_a": v, "hud_b": vb, + "entities_a": nA_ents, "entities_b": nB_ents, + "candidates_a": {"set": len(setA), "clear": len(clrA)}, + "survivors": rows}, open(f"{out}/bitflag.json", "w"), indent=1) + for r in rows[:40]: + print(f" {r['polarity']:5} pos{r['offset']:+#07x} bit {r['bit']:2d}", + flush=True) + if len(rows) > 40: + print(f" ... and {len(rows)-40} more (see bitflag.json)", flush=True) + print(f"wrote {out}/bitflag.json", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/re-capture/ob_flag.py b/tools/re-capture/ob_flag.py index fd3d241..e03d7be 100755 --- a/tools/re-capture/ob_flag.py +++ b/tools/re-capture/ob_flag.py @@ -150,10 +150,16 @@ def main(): 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 + if stuck % 12 == 0: + if 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 + if not frozen.in_flight(): + print("NO LONGER IN FLIGHT — the mission ended; this window " + "proves nothing", flush=True) + return 5 nB = counter(w.fd) if nB == nA: print("counter never moved — no verification possible", flush=True) diff --git a/tools/re-capture/ob_hunt.py b/tools/re-capture/ob_hunt.py index d5bde9d..c22c4ff 100755 --- a/tools/re-capture/ob_hunt.py +++ b/tools/re-capture/ob_hunt.py @@ -78,9 +78,13 @@ def main(): # 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 + if stuck % 12 == 0: + if frozen.frozen(6.0)[0]: + log("GUEST FROZEN — the world stopped advancing; abandoning") + break + if not frozen.in_flight(): + log("NO LONGER IN FLIGHT — the mission ended; abandoning") + break continue stuck = 0 # Confirm the new value before spending a filter on it.