tools: diff live guest memory against itself, and stop aborting on an unreadable HUD frame

live_delta.py exists for the freeze: a frozen run is guest code spinning, and
spinning code waits on guest memory, so the words still changing while frozen
should be the loop's own bookkeeping and whatever it polls. Measured control on a
RUNNING game, so the frozen number has something to be compared against:
1863296 words change in 4 s, 2.08% of the 357.7 MB of data extents. The sparse
4.6 GB costs nothing - the walk is over SEEK_DATA extents.

The freeze did not reproduce this run, so the frozen half of that measurement is
still owed.

ob_flag.py: an unreadable HUD frame is now retried rather than counted as a
mismatch. ob_read returns None on a frame it cannot read - an explosion across
the plate, a flash - and the sanity check treated that as "the address is wrong"
and aborted a perfectly good run.

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-23 20:53:07 +00:00
parent 99be872d1c
commit eb7fbecd40
2 changed files with 93 additions and 2 deletions

78
tools/re-capture/live_delta.py Executable file
View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Which guest words are still changing while the game is frozen?
`mission-freeze-resume-spin.md` establishes that a frozen run is guest code
SPINNING — the main thread stays in state `R` and gains ~40 % of a core over ten
seconds while making not one kernel call. Spinning code is waiting for something
in guest memory to change, and its own bookkeeping is the only thing still
moving. So diff guest RAM against itself across a few seconds: while the game
runs this is hopeless (everything moves), but while it is frozen the survivors
should be a handful of words — the loop's counter, and whatever it polls.
The comparison is over the file's *data extents* only (`SEEK_DATA`), so the 4.6 GB
of sparse holes cost nothing, and the first snapshot is kept in memory per extent.
Usage: live_delta.py [gap_s] [max_report] [--range LO HI]
"""
import os
import sys
import time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gmem # noqa: E402
def snapshot(fd, size, lo=None, hi=None):
out = []
f0 = gmem.va_to_off(lo) if lo is not None else 0
f1 = gmem.va_to_off(hi) if hi is not None else size
for a, b in gmem.extents(fd, size):
a, b = max(a, f0), min(b, f1)
n = (b - a) // 4 * 4
if n >= 4:
out.append((a, np.frombuffer(os.pread(fd, n, a), dtype=">u4")))
return out
def main():
gap = float(sys.argv[1]) if len(sys.argv) > 1 else 6.0
top = int(sys.argv[2]) if len(sys.argv) > 2 else 60
lo = hi = None
if "--range" in sys.argv:
i = sys.argv.index("--range")
lo, hi = int(sys.argv[i + 1], 0), int(sys.argv[i + 2], 0)
path = gmem.mem_path()
fd = os.open(path, os.O_RDONLY)
size = os.path.getsize(path)
a = snapshot(fd, size, lo, hi)
total = sum(len(x[1]) for x in a) * 4
print(f"# {path}: {len(a)} extents, {total/1e6:.1f} MB of data; "
f"waiting {gap}s", flush=True)
time.sleep(gap)
b = dict((off, arr) for off, arr in snapshot(fd, size, lo, hi))
changed = []
for off, arr0 in a:
arr1 = b.get(off)
if arr1 is None or len(arr1) != len(arr0):
continue
idx = np.flatnonzero(arr0 != arr1)
for i in idx:
changed.append((off + int(i) * 4, int(arr0[i]), int(arr1[i])))
print(f"# {len(changed)} words changed in {gap}s "
f"({len(changed)*4/max(total,1)*100:.4f} % of the data)", flush=True)
for o, v0, v1 in changed[:top]:
va = gmem.primary_va(o)
d = v1 - v0
print(f" va {va:#010x} {v0:#010x} -> {v1:#010x}"
f"{f' ({v0} -> {v1}, {d:+d})' if abs(v0) < 1 << 28 and abs(v1) < 1 << 28 else ''}")
if len(changed) > top:
print(f" ... and {len(changed)-top} more")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -89,9 +89,22 @@ def main():
w = gworld.World()
defs = entities2.definitions(w)
v = hud(f"{out}/a.png")
n0 = counter(w.fd)
# 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)