re: find the menu screen id and cursor in guest memory
Menu navigation here has always been screenshot-driven, which is unusable under
--gpu=null -- the only configuration where the game does not hit the
software-rasterizer freeze. Without a memory signal, the one backend that runs
is the one that cannot be steered.
Snapshotting 0x82800000+3 MB at each menu of a rendered run and keeping the
4-byte words that differ between screens and hold small integers leaves exactly
four of 786 432. One has the property that matters -- it changes on a screen
transition and holds steady when only the highlight moves:
0x828A690C screen id 1 title, 3 main menu, 4 extras
0x828F38AC menu cursor (second copy at 0x828F38BC)
0x828F37B4 per-menu value
Verified on a fresh --gpu=null run with no display at all, driving the same keys
blind: title 1/2/12, main menu 3/4/45, after 4x down 3/12/45, extras 4/14/49 --
4/4 exact against the rendered run, across two runs and two GPU backends. That
is the check that matters, since this corpus has already had to mark one runtime
address run-dependent.
tools/re-capture/menu_state.py reads them; `menu_state.py watch` prints on
change.
Open: the rest of the sequence into a mission. Blind driving reached extras
(screen 4) and a further A did not move it, so MISSION SELECT needs a cursor
move first. Screen ids beyond 4 are unmapped, and the rendered run freezes on
entering that screen -- so map ids up to the freeze, then step blind past it.
This commit is contained in:
55
docs/re/menu-state-in-memory.md
Normal file
55
docs/re/menu-state-in-memory.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# The menu's screen id and cursor, in guest memory
|
||||||
|
|
||||||
|
**✅ Found and verified 2026-08-26.** Menu navigation in this corpus has always
|
||||||
|
been driven by screenshots. That is unusable under `--gpu=null`, which is the
|
||||||
|
only configuration where the game does not hit the software-rasterizer freeze
|
||||||
|
([`mission-freeze-heap-exhaustion.md`](mission-freeze-heap-exhaustion.md)) — so
|
||||||
|
without a memory signal, the one backend that runs is the one that cannot be
|
||||||
|
steered.
|
||||||
|
|
||||||
|
## The words
|
||||||
|
|
||||||
|
| address | meaning |
|
||||||
|
|---|---|
|
||||||
|
| **`0x828A690C`** | **screen id** — `1` title, `3` main menu, `4` extras |
|
||||||
|
| **`0x828F38AC`** | **menu cursor** (a second copy at `0x828F38BC`) |
|
||||||
|
| `0x828F37B4` | tracks the screen, distinct value per menu |
|
||||||
|
|
||||||
|
## How they were found
|
||||||
|
|
||||||
|
Snapshot `0x82800000` + 3 MB at each menu of a **rendered** run, then keep the
|
||||||
|
4-byte words that (a) differ between screens and (b) hold small integer values.
|
||||||
|
Of 786 432 words, exactly **four** qualified — and one of them,
|
||||||
|
`0x828A690C`, has the property that matters: it changes on a screen *transition*
|
||||||
|
and stays put when only the highlight moves.
|
||||||
|
|
||||||
|
screen: title -> main menu -> (4x down) -> extras
|
||||||
|
828a690c: 1 -> 3 -> 3 -> 4 <- screen id
|
||||||
|
828f38ac: 2 -> 4 -> 12 -> 14 <- cursor moves with the highlight
|
||||||
|
828f37b4: 12 -> 45 -> 45 -> 49
|
||||||
|
|
||||||
|
## ✅ Verified against a second run on a different GPU backend
|
||||||
|
|
||||||
|
The values were then read on a fresh `--gpu=null` run — **no display at all** —
|
||||||
|
while driving the same key sequence blind:
|
||||||
|
|
||||||
|
| step | rendered (lavapipe) | blind (`--gpu=null`) |
|
||||||
|
|---|---|---|
|
||||||
|
| title | 1 / 2 / 12 | **1 / 2 / 12** |
|
||||||
|
| main menu | 3 / 4 / 45 | **3 / 4 / 45** |
|
||||||
|
| after 4× down | 3 / 12 / 45 | **3 / 12 / 45** |
|
||||||
|
| extras | 4 / 14 / 49 | **4 / 14 / 49** |
|
||||||
|
|
||||||
|
**4 / 4 exact**, across two runs and two backends. That is the check that matters:
|
||||||
|
the words are not run-dependent, unlike the OB counter address this corpus had to
|
||||||
|
mark run-dependent earlier.
|
||||||
|
|
||||||
|
`tools/re-capture/menu_state.py` reads them (`menu_state.py watch` polls and
|
||||||
|
prints on change).
|
||||||
|
|
||||||
|
❔ Still open: the rest of the sequence into a **mission**. Blind driving reached
|
||||||
|
`extras` (screen 4) and a further Ⓐ did not move it — `4 / 14 / 49` twice — so
|
||||||
|
MISSION SELECT needs a cursor move first. The screen ids beyond 4 are unmapped;
|
||||||
|
the same snapshot-and-diff method extends to them, but it must be done on a
|
||||||
|
rendered run, and the rendered run **freezes on entering that screen**. The way
|
||||||
|
through is to map the ids up to the freeze and step blind past it.
|
||||||
61
tools/re-capture/menu_state.py
Executable file
61
tools/re-capture/menu_state.py
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read the guest's menu state — screen id and cursor — from memory.
|
||||||
|
|
||||||
|
Menu navigation in this corpus has always been driven by screenshots. That
|
||||||
|
breaks under `--gpu=null`, which is the only configuration where the game does
|
||||||
|
not hit the software-rasterizer freeze
|
||||||
|
(`docs/re/mission-freeze-heap-exhaustion.md`), so a memory-based signal is what
|
||||||
|
makes that backend usable as an oracle.
|
||||||
|
|
||||||
|
./menu_state.py # print screen/cursor once
|
||||||
|
./menu_state.py watch [n] # poll n times, one line per change
|
||||||
|
|
||||||
|
Found by snapshotting `0x82800000`+3 MB at each menu of a *rendered* run and
|
||||||
|
keeping the words that change on a screen transition but hold steady when only
|
||||||
|
the highlight moves.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import gmem # noqa: E402
|
||||||
|
import gworld # noqa: E402
|
||||||
|
|
||||||
|
SCREEN = 0x828A690C # 1 title, 3 main menu, 4 extras
|
||||||
|
CURSOR = 0x828F38AC # menu highlight; a second copy lives at 0x828F38BC
|
||||||
|
MISC = 0x828F37B4 # tracks the screen, distinct values per menu
|
||||||
|
|
||||||
|
SCREEN_NAMES = {0: "booting", 1: "title", 3: "main-menu", 4: "extras"}
|
||||||
|
|
||||||
|
|
||||||
|
def read(w, va):
|
||||||
|
return struct.unpack(">I", os.pread(w.fd, 4, gmem.va_to_off(va)))[0]
|
||||||
|
|
||||||
|
|
||||||
|
def state(w):
|
||||||
|
s, c, m = read(w, SCREEN), read(w, CURSOR), read(w, MISC)
|
||||||
|
return s, c, m, SCREEN_NAMES.get(s, f"unknown({s})")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
w = gworld.World()
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == "watch":
|
||||||
|
n = int(sys.argv[2]) if len(sys.argv) > 2 else 60
|
||||||
|
last = None
|
||||||
|
for _ in range(n):
|
||||||
|
cur = state(w)
|
||||||
|
if cur != last:
|
||||||
|
print(f"screen={cur[0]} ({cur[3]}) cursor={cur[1]} misc={cur[2]}",
|
||||||
|
flush=True)
|
||||||
|
last = cur
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
s, c, m, name = state(w)
|
||||||
|
print(f"screen={s} ({name}) cursor={c} misc={m}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user