diff --git a/tools/re-capture/live_delta.py b/tools/re-capture/live_delta.py new file mode 100755 index 0000000..4550767 --- /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 7ca6c87..74f5d5a 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)