This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/tools/re-capture/ob_flag.py
Sylpheed RE agent 26421d5266 docs+tools: the per-entity OB flag is refuted, and the counter decrements when the player kills
With the pilot finally shooting, the experiment completed both halves. Sample A
at counter 12 over 120 entities gave 2 offsets where exactly 12 entities share a
value; the counter then went 12 -> 11 and NEITHER survived. So within +-0x400 of
an entity's position triple there is no 4-byte word whose shared-value population
tracks REMAINING OB.

The limits are recorded as part of the result, because they bound it: the test
asks which entities share an EXACT 32-bit value, so a single bit ORed into a word
that also carries health or a timer would never show up - a bit-level version of
the same differential is the follow-on. Anything outside the window, or on
entities that entities2 cannot see (it types by position CHANGING, so stationary
objectives are invisible), is untested too, and the populations differed a lot
between samples - 120 against 194.

Separately: REMAINING OB went 12 -> 11, the first decrement of this whole
investigation, while pilot.py logged 411 fire=1 samples and the HUD reached YOU
KILLED WARPLANES 0003. Stated carefully - it does NOT show the counter counts
kills, since an earlier run had the hostile population fall by a third with no
movement; it shows some kills close something the counter tracks.

Two robustness fixes: ob_flag retries an empty entity sample (one void run was
caused by exactly that), and Pad releases everything on interpreter exit - a
file-backed pad PERSISTS after its writer dies, so a tool killed mid-press would
leave a button held and the game would walk through menus on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-23 23:21:08 +00:00

184 lines
7.5 KiB
Python
Executable File

#!/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
# The counter is NOT at a fixed address across runs — see
# structures/mission-objective-counter.md. 0xbdb59668 is the value it takes in
# 3 of the 5 runs measured; when it is wrong this script refuses to run rather
# than reporting nonsense, and ob_hunt.py finds the run's own address in ~1 min
# of flight. Override with OB_VA.
VA = int(os.environ.get("OB_VA", "0xBDB59668"), 0)
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)
# RETRY an unreadable frame rather than counting it as a mismatch. ob_read
# returns None on a frame it cannot read - an explosion across the plate, a
# flash, the HUD momentarily gone - and treating that as "the address is
# wrong" aborted a perfectly good run once.
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:
print("could not read the HUD counter at all in 8 tries — is this "
"even in flight?", flush=True)
return 2
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
# RETRY an empty sample. entities2.moving() types entities by their position
# CHANGING between two reads, so a sample that lands on a load, a stall, or
# simply loses the race with another scanner comes back with nothing — and a
# zero-entity sample silently produces zero candidates and a void run. Seen
# once, with sample B finding 81 entities moments later.
for _ in range(6):
nA, entsA, gA = sample(w, defs, out, "A")
if len(entsA) >= 10:
break
print("[A] too few entities — retrying", flush=True)
time.sleep(5)
if len(entsA) < 10:
print("[A] never got a usable entity sample", flush=True)
return 4
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)
# WHO a candidate groups is a discriminator available without waiting for a
# transition: an OB flag should mark a subset of the hostiles, not a mix that
# includes the player and its wingmen. Printed for every candidate rather
# than filtered on, because "objectives are always hostile" is an assumption
# about the mission, not a measurement -- an escort objective would be
# friendly, and Stage 02 has one (the ACROPOLIS).
for (d, val), mem in sorted(candA.items()):
who = Counter(nm.replace("UN_", "") for nm in mem)
print(f" pos{d:+#07x} = {val.hex()} {dict(who)}", 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())