Files
Sylpheed/tools/re-capture/ob_bitflag.py
Sylpheed RE agent a27dddf93d docs+tools: scope the guest-PC experiment properly, and let ob_bitflag find the counter itself
The freeze's named next step is more expensive than it looked, and the reasons
are recorded before anyone starts it: xenia's stack walker is a TWENTY-LINE STUB
on POSIX (Create logs "unimplemented" and returns nullptr), so
ThreadDebugInfo::guest_pc is never filled here; PPCContext carries no live PC
either. A reverse host->guest map is buildable - the code cache already learns
the mapping in OnCodePlaced - but that plus a way to sample another thread's RIP
is a real emulator feature, not a patch.

The cheap route that does exist: gdb is installed and ptrace_scope is 1, so
attaching to a running emulator is refused but launching it UNDER gdb is not -
run-canary execs $XENIA_BIN, so a wrapper that execs "gdb --args <real binary>"
keeps the lockfile and flags and makes gdb the parent. That would separate
"spinning in guest JIT code" from "spinning in a xenia loop", which is the fork
this is stuck on. Not attempted yet.

ob_bitflag now locates the counter itself from the three addresses measured so
far, instead of refusing when the default one is wrong. That costs no
transitions, and with roughly half of all runs ending early, transitions are the
scarce resource.

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

200 lines
7.5 KiB
Python
Executable File

#!/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 <out-dir> [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
# The counter's address is per-run, but not arbitrary: every run measured so far
# has put it in a narrow band, and three exact addresses have now been confirmed
# or strongly implicated. Trying the known ones against the HUD costs nothing and
# no transitions, where ob_hunt.py needs two — and with roughly half of all runs
# ending early, transitions are the scarce resource.
KNOWN_VAS = [0xBDB59668, 0xBDB49668, 0xBDB58668]
VA = int(os.environ["OB_VA"], 0) if "OB_VA" in os.environ else None
LO, HI = -0x400, 0xC00 # window around the position triple
DELTA = 0x130
def counter(fd, va=None):
return struct.unpack(">I", os.pread(fd, 4, gmem.va_to_off(va or VA)))[0]
def locate(fd, hud_value):
"""Pick whichever known address currently equals the HUD, or None."""
for va in ([VA] if VA else KNOWN_VAS):
try:
if counter(fd, va) == hud_value:
return va
except Exception:
continue
return None
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)
global VA
v = None
for _ in range(8):
v = hud(f"{out}/a.png")
if v is not None:
break
time.sleep(3)
if v is None:
print("could not read the HUD counter at all — is this in flight?",
flush=True)
return 2
found = locate(w.fd, v)
if found is None:
tried = " ".join(f"{a:#x}" for a in ([VA] if VA else KNOWN_VAS))
print(f"HUD={v} but none of the known addresses holds it ({tried}) — "
f"re-scan with ob_hunt.py and pass OB_VA", flush=True)
return 2
VA = found
n0 = counter(w.fd)
print(f"HUD={v} RAM={n0} at {VA:#x}", flush=True)
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())