diff --git a/docs/re/captures/ob-counter-neighbourhood-stage02.json b/docs/re/captures/ob-counter-neighbourhood-stage02.json new file mode 100644 index 00000000..02ad8b4c --- /dev/null +++ b/docs/re/captures/ob-counter-neighbourhood-stage02.json @@ -0,0 +1,63 @@ +{ + "counter_va": 3182794344, + "n0": 4, + "n1": 8, + "hud_b": 8, + "noisy_words": 0, + "moved": [ + { + "va": 3182794044, + "delta_from_counter": -300, + "before": 872415232, + "after": 939524096 + }, + { + "va": 3182794060, + "delta_from_counter": -284, + "before": 872415232, + "after": 939524096 + }, + { + "va": 3182794076, + "delta_from_counter": -268, + "before": 3165464588, + "after": 3165463948 + }, + { + "va": 3182794108, + "delta_from_counter": -236, + "before": 3165463948, + "after": 3165464588 + }, + { + "va": 3182794344, + "delta_from_counter": 0, + "before": 4, + "after": 8 + }, + { + "va": 3182794444, + "delta_from_counter": 100, + "before": 872415232, + "after": 939524096 + }, + { + "va": 3182794460, + "delta_from_counter": 116, + "before": 872415232, + "after": 939524096 + }, + { + "va": 3182794476, + "delta_from_counter": 132, + "before": 3165466508, + "after": 3165465868 + }, + { + "va": 3182794508, + "delta_from_counter": 164, + "before": 3165465868, + "after": 3165466508 + } + ] +} \ No newline at end of file diff --git a/docs/re/structures/mission-objective-counter.md b/docs/re/structures/mission-objective-counter.md index 2aa42fcf..3f194416 100644 --- a/docs/re/structures/mission-objective-counter.md +++ b/docs/re/structures/mission-objective-counter.md @@ -198,3 +198,45 @@ not able to look often enough. **Still open, and untouched:** ❔ what the counter counts, and whether an `OB`-badged entity carries a flag in its entity object. That is the part the autopilot actually needs, and knowing the address is only its precondition. + +## ✅ 2026-08-24 — what is AROUND the counter: its own rendered digits + +With the per-entity searches refuted at both word and bit level +(`../mission-freeze-and-ob-flag.md`), the next question was what object owns the +counter. It sits at `0xbdb59668`, inside the entity-heap window, so the answer is +readable directly: sample ±0x200 around it across one transition and keep the +words that move **with** it +([`ob_neighbourhood.py`](../../tools/re-capture/ob_neighbourhood.py), +[`captures/ob-counter-neighbourhood-stage02.json`](../captures/ob-counter-neighbourhood-stage02.json)). + +Control first: over an 8-second interval while the counter sat still, **0 of the +256 words in the window changed**. So the window is quiet, and anything that +moves on the step is a real neighbour rather than noise. + +On `4 → 8`, **nine** words moved — the counter and eight others, in two kinds: + +| offset from the counter | before → after | what it is | +|---|---|---| +| `-0x12c`, `-0x11c`, `+0x64`, `+0x74` | `0x34000000 → 0x38000000` | **ASCII `'4'` → `'8'`**, NUL-padded | +| `-0x10c`, `-0xec`, `+0x84`, `+0xa4` | `0xbcad258c ↔ 0xbcad280c`, `0xbcad2d0c ↔ 0xbcad2f8c` | guest pointers into `0xbcad2xxx` | + +Read live a moment later with the HUD showing `008`, all four character slots +hold `'8'`. So the neighbourhood is the **HUD's rendered text for this counter** — +the digit as a character in four places, each with a pointer that swaps as the +digit changes (a glyph or sprite record). + +### 🟡 Which reframes what the address IS + +This file has called `0xbdb59668` "the mission's own objective counter". The +evidence now says something narrower: it is **the HUD counter widget's value**, +sitting beside the characters it renders. + +🔴 **But there is no separate mission-side copy that moves with it**, and that was +already measured: `ob_hunt.py` scans *all* of guest memory and requires a +candidate to match across two transitions, and it left **exactly one** address. +Whatever the mission script keeps internally either does not change on the same +step or is not a plain big-endian word. + +**Next:** follow one of the `0xbcad2xxx` pointers — if they resolve to glyph or +sprite records, the widget is confirmed and the search for a mission-side count +moves to the objects those digits are *fed from*. diff --git a/tools/re-capture/ob_neighbourhood.py b/tools/re-capture/ob_neighbourhood.py new file mode 100755 index 00000000..fa842f3c --- /dev/null +++ b/tools/re-capture/ob_neighbourhood.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Which words NEXT TO `REMAINING OB` move with it? + +Both the word-level and the bit-level per-entity searches are refuted +(`mission-freeze-and-ob-flag.md`): nothing in an entity object tracks the +counter. So the counter belongs to something else — and it sits at `0xbdb59668`, +inside the entity heap window, which means the object that owns it is right +there to be read. + +This samples a window around the counter across one of its transitions and +reports the words that changed **with** it. A mission-script object should have +neighbours that move together: a total, a wave index, a timer. + +The discipline is the same as everywhere else here: the counter's own address is +confirmed against the HUD first, and a word is only interesting if it changes on +the same step the counter does — a word that changes every sample is noise. + +Usage: ob_neighbourhood.py [radius_bytes] [timeout_s] +""" +import json +import os +import struct +import subprocess +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import gmem # noqa: E402 +import ob_read # noqa: E402 + +KNOWN_VAS = [0xBDB59668, 0xBDB49668, 0xBDB58668] + + +def hud(shot): + subprocess.run(["screenshot", shot], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + txt, _ = ob_read.read(shot) + return int(txt) if txt.isdigit() else None + + +def main(): + out = sys.argv[1] + radius = int(sys.argv[2], 0) if len(sys.argv) > 2 else 0x200 + deadline = time.time() + (float(sys.argv[3]) if len(sys.argv) > 3 else 420) + fd = os.open(gmem.mem_path(), os.O_RDONLY) + + v = None + for _ in range(8): + v = hud("/tmp/obn.png") + if v is not None: + break + time.sleep(3) + va = next((a for a in KNOWN_VAS + if struct.unpack(">I", os.pread(fd, 4, gmem.va_to_off(a)))[0] == v), None) + if v is None or va is None: + print(f"HUD={v}: no known address holds it — re-scan with ob_hunt.py") + return 2 + base = gmem.va_to_off(va) - radius + print(f"counter {v} at {va:#x}; window {va - radius:#x}..{va + radius:#x}", + flush=True) + + def window(): + b = os.pread(fd, radius * 2, base) + return [struct.unpack_from(">I", b, i)[0] for i in range(0, len(b), 4)] + + a = window() + n0 = a[radius // 4] + # A word that changes on EVERY sample is noise, not a neighbour: take a + # mid-sample while the counter is still n0 and discard anything that moved. + time.sleep(8) + mid = window() + noisy = {i for i, (x, y) in enumerate(zip(a, mid)) if x != y} + print(f"words that move even while the counter is still {n0}: {len(noisy)}", + flush=True) + + while time.time() < deadline: + time.sleep(4) + cur = window() + if cur[radius // 4] != n0: + break + else: + print("counter never moved") + return 1 + n1 = cur[radius // 4] + vb = hud("/tmp/obn2.png") + print(f"counter {n0} -> {n1} (HUD {vb})", flush=True) + + moved = [i for i, (x, y) in enumerate(zip(mid, cur)) if x != y and i not in noisy] + rows = [{"va": va - radius + i * 4, "delta_from_counter": (i * 4) - radius, + "before": mid[i], "after": cur[i]} for i in moved] + print(f"words that moved WITH the counter (excluding the noisy ones): {len(rows)}") + for r in rows[:40]: + print(f" {r['va']:#010x} counter{r['delta_from_counter']:+#07x} " + f"{r['before']:#010x} -> {r['after']:#010x}" + f" ({r['before']} -> {r['after']})") + json.dump({"counter_va": va, "n0": n0, "n1": n1, "hud_b": vb, + "noisy_words": len(noisy), "moved": rows}, open(out, "w"), indent=1) + print(f"wrote {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())