diff --git a/docs/re/structures/mission-objective-counter.md b/docs/re/structures/mission-objective-counter.md new file mode 100644 index 00000000..8355dcce --- /dev/null +++ b/docs/re/structures/mission-objective-counter.md @@ -0,0 +1,60 @@ +# `REMAINING OB` — the mission's own objective counter, in RAM + +**Status:** ✅ `CONFIRMED` for one Stage 02 run: a **big-endian u32** whose value +is exactly the HUD's `REMAINING OB`, verified across two transitions it was not +selected by. 🟡 the address itself is from one run — **cross-run stability is +untested**. ❔ what it counts, and whether objective-marked entities carry a flag. + +## Why it matters + +[`autopilot-memory-driven.md`](../autopilot-memory-driven.md) ranks this its +problem #2: a pilot that flies well but ignores the objective cannot finish a +mission. Its 300 s run took no damage, killed one fighter, and watched +`REMAINING OB` **rise** from 004 to 011 as waves spawned. Reading the counter is +what turns "shoot whatever is nearest" into "shoot what closes the mission". + +## The find + +Guest VA **`0xbdb59668`**, big-endian u32, in a Stage 02 run on the +[upstream baseline](../upstream-baseline.md). + +| time | RAM `0xbdb59668` | HUD | +|---|---|---| +| selected on | 18 → 24 | 018 → 024 | +| t+40 s | 24 | 024 | +| t+75 s | **23** | **023** | +| t+110 s | **22** | **022** | +| t+145 s | 22 | 022 | +| t+180 s | 22 | 022 | + +The two middle rows are the ones that matter: the candidate was filtered on the +18→24 transition, and it then tracked 24→23→22 on its own. + +## Method, and the two traps in it + +`tools/re-capture/ob_scan.py`. Scan one snapshot for the current value, then +filter that candidate set against **live** `/dev/shm/xenia_memory_*` at the next +distinct value. Only the first pass needs the 4.8 GB copy. + +**🔴 Filter on a change, not a repeat.** The first attempt scanned at 19, filtered +at 19 again, then at 18 — and left **zero** survivors. The value moves between +the memory copy and the screenshot that reads it, so "still 19" is not reliable. +Scanning 18 and filtering on 24 gave exactly one candidate on the first try. + +**🔴 Verify across a transition you did not select on.** An earlier differential +over 19→18 also gave exactly one candidate, `0xbc22e83c` — and it was **wrong**: +read live it held 26 while the HUD showed 017. It was an unrelated counter that +happened to step 19→18 in the same window. One matching transition is not +evidence; the same offset tracking a *later*, unselected change is. + +## What is not settled + +* 🟡 **Cross-run stability.** `0xbdb59668` is from a single run. The corpus's + other runtime finds are re-scanned per run, so the durable result here is the + **method**, not the number. Testing whether the address repeats costs one boot. +* ❔ **What it counts.** It rose 18 → 24 while waves spawned and fell as things + died, so it is objective targets remaining, not kills. Whether every `OB`-badged + entity is one of them, and whether that badge is a flag in the entity object, is + the natural follow-on — and it is what the pilot actually needs to *choose* + targets rather than merely know how many are left. +* ❔ Whether the same address holds for other stages. diff --git a/tools/re-capture/ob_scan.py b/tools/re-capture/ob_scan.py new file mode 100644 index 00000000..8cf5bc91 --- /dev/null +++ b/tools/re-capture/ob_scan.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Value-scan guest memory for a changing counter — used to find REMAINING OB. + +The mission's objective counter is on the HUD, so it is in RAM, and finding it +turns "shoot whatever is nearest" into "shoot what closes the mission" +(autopilot-memory-driven.md problem #2). It was found with this, in three passes: + + ob_scan.py scan # every aligned BE u32 == value + ob_scan.py filter # keep those now equal to v + ob_scan.py show # offsets as guest VAs + +Only the FIRST pass needs a 4.8 GB snapshot; every later pass reads just the +candidate offsets, so it can run straight against /dev/shm/xenia_memory_*. + +Two traps this encodes, both paid for: + +* **Filter on a CHANGE, not on a repeat.** A three-snapshot filter that required + 19 -> 19 -> 18 left zero survivors, because the value moved between the memory + copy and the screenshot that read it. Scanning one value and filtering on the + NEXT distinct value found it immediately. +* **Verify across a LATER change.** The first single candidate this produced + (0xbc22e83c) matched the transition it was filtered on and was still WRONG — + read live it held 26 while the HUD showed 017. A candidate is only believable + once it tracks a transition it was not selected by. +""" +import os, struct, sys +sys.path.insert(0, "/work/Syplheed-Reborn/tools/re-capture") +import gmem + +def scan(path, val): + """Every 4-byte-aligned offset whose BE u32 == val.""" + out = [] + pat = struct.pack(">I", val) + size = os.path.getsize(path) + with open(path, "rb") as f: + for start, end in gmem.extents(f.fileno(), size): + pos = start & ~3 + while pos < end: + f.seek(pos) + buf = f.read(min(1 << 24, end - pos)) + if not buf: break + i = buf.find(pat) + while i != -1: + if (pos + i) % 4 == 0: + out.append(pos + i) + i = buf.find(pat, i + 1) + pos += len(buf) + return out + +def read_at(path, offs, ): + vals = {} + with open(path, "rb") as f: + for o in offs: + f.seek(o); b = f.read(4) + if len(b) == 4: + vals[o] = struct.unpack(">I", b)[0] + return vals + +if __name__ == "__main__": + cmd = sys.argv[1] + if cmd == "scan": + offs = scan(sys.argv[2], int(sys.argv[3])) + print(len(offs)) + with open(sys.argv[4], "w") as f: + for o in offs: f.write(f"{o}\n") + elif cmd == "filter": + offs = [int(l) for l in open(sys.argv[3])] + want = int(sys.argv[4]) + vals = read_at(sys.argv[2], offs) + keep = [o for o, v in vals.items() if v == want] + print(len(keep)) + with open(sys.argv[5], "w") as f: + for o in keep: f.write(f"{o}\n") + elif cmd == "show": + for l in open(sys.argv[2]): + o = int(l) + va = gmem.primary_va(o) + print(f"off {o:#013x} va {va:#010x}" if va else f"off {o:#013x}")