tools+docs: find REMAINING OB in guest RAM, verified across unselected transitions

The mission's objective counter is a big-endian u32 at guest VA 0xbdb59668 in a
Stage 02 run on the upstream baseline. It was selected on an 18->24 transition
and then tracked 24->23->22 against the HUD on its own - four readings, two
changes it was not filtered on.

That last point is the whole discipline here, because the first attempt failed it.
An earlier differential over 19->18 also produced exactly one candidate,
0xbc22e83c, which matched the transition it was selected on and was still wrong:
read live it held 26 while the HUD showed 017. One matching transition is not
evidence.

A second trap is recorded too: a three-snapshot filter requiring 19 -> 19 -> 18
left ZERO survivors, because the value moves between the memory copy and the
screenshot that reads it. Filtering on the next DISTINCT value instead found the
counter on the first try.

ob_scan.py carries the method: scan one snapshot, then filter the candidate set
against live /dev/shm/xenia_memory_* at each new value, so only the first pass
needs a 4.8 GB copy.

Stated plainly as unsettled: the ADDRESS is from one run and cross-run stability
is untested, so the durable result is the method rather than the number. And what
the counter counts - whether every OB-badged entity is one of them, and whether
that badge is a flag in the entity object - is the follow-on the pilot actually
needs to CHOOSE targets rather than just know how many remain.
This commit is contained in:
Sylpheed RE agent
2026-08-19 19:01:24 +00:00
parent 59f22d364d
commit c2404bc051
2 changed files with 138 additions and 0 deletions

View File

@@ -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.

View File

@@ -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 <snapshot> <value> <out> # every aligned BE u32 == value
ob_scan.py filter <live-or-snap> <in> <v> <out> # keep those now equal to v
ob_scan.py show <candidates> # 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}")