From eb7fbecd40569b8e09e241137b287f3ef0c59198 Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Sun, 23 Aug 2026 20:53:07 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE --- tools/re-capture/live_delta.py | 78 ++++++++++++++++++++++++++++++++++ tools/re-capture/ob_flag.py | 17 +++++++- 2 files changed, 93 insertions(+), 2 deletions(-) create mode 100755 tools/re-capture/live_delta.py diff --git a/tools/re-capture/live_delta.py b/tools/re-capture/live_delta.py new file mode 100755 index 00000000..45507672 --- /dev/null +++ b/tools/re-capture/live_delta.py @@ -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()) diff --git a/tools/re-capture/ob_flag.py b/tools/re-capture/ob_flag.py index 7ca6c874..74f5d5a5 100755 --- a/tools/re-capture/ob_flag.py +++ b/tools/re-capture/ob_flag.py @@ -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)