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
This commit is contained in:
Sylpheed RE agent
2026-08-24 00:41:47 +00:00
parent 6b3442ce8c
commit a27dddf93d
2 changed files with 65 additions and 7 deletions

View File

@@ -213,3 +213,36 @@ before the region summary existed, so "these regions are busy while frozen" has
nothing to be compared against. The regions may simply be the busiest regions in
the game at all times. That comparison is one run away and is the obvious next
measurement.
---
## 2026-08-24 — scoping the "which guest PC is spinning" experiment
The next step this file named turns out to be **more expensive than it looked**,
and the reason is worth writing down before someone starts it:
* 🔴 **Xenia's stack walker is a stub on POSIX.** `stack_walker_posix.cc` is
twenty lines: `StackWalker::Create` logs *"Stack walker unimplemented on
posix"* and returns `nullptr`. So `ThreadDebugInfo::guest_pc` — the field the
debugger would fill — is never populated on this platform, and the clean
in-emulator route does not exist.
* 🔴 **`PPCContext` carries no live PC either.** The guest PC lives in host
registers between block boundaries; there is no field to read.
* 🟡 **A reverse host→guest map is buildable but is a real feature.** The code
cache already learns the mapping when it emits code
(`X64CodeCache::OnCodePlaced(guest_address, function_info, …)`); a sorted
host-range → guest-function index would make a host RIP interpretable. That is
~50 lines *plus* a way to sample another thread's RIP, which on POSIX means
piggybacking on the signal machinery xenia already uses to suspend threads.
**The cheap route that exists instead: `gdb`.** It is installed
(`/usr/bin/gdb`), and `ptrace_scope` is **1**, so a debugger may only attach to
its own descendants — which means `gdb -p <pid>` on a running emulator will be
refused, but launching the emulator **under** gdb works. `run-canary` execs
`$XENIA_BIN`, so pointing `XENIA_BIN` at a small wrapper that `exec`s
`gdb --args <real binary> "$@"` keeps the lockfile and the flags intact and makes
gdb the parent. A `thread apply all bt` on a frozen run would not name JIT
frames, but it would immediately separate *"spinning inside guest JIT code"* from
*"spinning in a xenia loop"* — which is the fork this investigation is stuck on.
Not attempted here; recorded so the next pass starts from the right end.

View File

@@ -40,13 +40,30 @@ import gmem # noqa: E402
import gworld # noqa: E402
import ob_read # noqa: E402
VA = int(os.environ.get("OB_VA", "0xBDB59668"), 0)
# 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):
return struct.unpack(">I", os.pread(fd, 4, gmem.va_to_off(VA)))[0]
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):
@@ -92,18 +109,26 @@ def main():
w = gworld.World()
defs = entities2.definitions(w)
global VA
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",
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")