Files
OpenMaidEngine/docs/global-memory-re.md
gamer147 a61c0c9abd feat(assets): solve asset resolution (SYS4INI per-scene section manifest)
resId -> files[section_base(scene) + resId]. SYS4INI's file list is
sectioned, one per scene (SCxxxx.BIN + its cross-archive asset manifest);
file_number is the index within the section. Unified for set-texture,
play-bgm, play-voice. Fully static/general -> no per-scene capture.

- tools/parse_sys4ini.py: SYS4INI (S4IC422, LZSS) -> build/asset-index.json
- tools/resolve_asset.py: sections + (scene,resId) resolver -> build/asset-sections.json
- validated: 97% structural, SC0000 17/17 vs Frida, 586/595 captured loads
- opcodes.toml: set-texture/create/draw-texture, play-bgm/voice enriched (frida-grounded)
- Frida tooling (capture_load_order all-archive, correlate_scope, ...) + vm0 --settex
- docs: asset-resolution-re (step2 SOLVED), global-memory-re (shelved), tools-reference

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:48:22 -04:00

8.7 KiB

Runtime Global Observation — RE state & starting point

Goal. Read the running game's live VM global variables by their bytecode address (e.g. G[0x62424], global-string 0x279). This is the VM-validation cornerstone: with it we can compare our VM's global state against the real game's at any point, verify effectful opcodes that have no machine oracle (battle math, flag logic, stat updates), and name the ~200 still-unclassified globals by watching them change. The roadmap flags it as the thing that replaces most Frida/Unicorn guesswork once the VM is validated-correct.

Status: SHELVED (2026-07-06), deliberately. We learned the shape of the problem but did not achieve a general "read any global by address" capability. This doc records what we tried, what we proved, the concrete landmarks found, and the right way to resume. It is not on the critical path for anything else, and — importantly — it does not crack the asset-resolution scope selector (see "What this does NOT solve").


The core finding: our flat-address model ≠ physical layout

Our VM models globals as one flat address space (G[addr]), and that is execution-correct (byte-exact against the dialogue-trace oracle). But it is an abstraction. Physically the game's state is structured and multi-store:

  • Multiple stores. VM int-globals, string-globals, and float-globals are almost certainly separate arrays (their address magnitudes differ wildly: global-string 0x279 = 633 vs global-int 0x62424 = 403,492). On top of that, values get copied into native C++ objects.
  • Structured records, not flat int32. Entity data (units, party) is stored as records with inline fixed-size string slots + mixed int fields + native heap pointers — not a packed int array. A base + addr*4 scan therefore finds nothing (confirmed: no stride in {2,4,8,12,16} reproduces a known consecutive-address *INIT value run).
  • Transient vs. stable globals. A global is a real variable, but how it's used varies:
    • Transient (e.g. G[0x62424], the CG resId): a scratch/argument register — mov G[0x62424] = resId; call load — set right before a call, overwritten right after. Never holds a stable value. Non-atomic value scans race against it and miss it.
    • Stable (e.g. global-string 0x279, the player name): persists across play. These are findable by value+stability scans; transient ones are not.
  • Multiple resId globals. Backgrounds vs. foreground portraits/sprites appear to use different resId globals / churn G[0x62424] between visible background changes (observed live).
  • Packed process. Main VM logic runs from a per-run heap r-x region (~30 MB, nonstable base) — so the interpreter can't be hooked at a fixed AGE.EXE+off. Archives are streamed via a heap block-cache through ReadFile (not memory-mapped). See docs/asset-resolution-re.md.

Why the naive scan failed. The first attempt scanned for a flat base+addr*4 int32 array using a signature of *INIT constants. It found 0 matches because (a) the layout isn't flat int32, and (b) *INIT entity data lives in structured records. The individual values exist in memory but only coincidentally adjacent.


What we tried (chronological, with outcomes)

  1. Flat-int32 signature scantools/frida/find_globals_base.py (+ build/globals-signature.json, 15,481 distinctive (addr,value) pairs from *INIT mov (global-int A) IMM; 215-dword contiguous anchor @0x631a9). Result: 0 hits at any anchor length, in the opening AND in the first dungeon (ruling out load-timing). Diagnostic: the individual values occur (coincidentally); no stride in {2,4,8,12,16} reproduces the consecutive-address sequence ⇒ not flat int32.
  2. "Lily" (player name) anchor — searched for the entered name. Found as ASCII Lily\0. But the context proved it's the wrong anchor: every copy is embedded in a native unit-record (inline string slot + stats + heap pointers), i.e. downstream copies, not the VM string-global store.
  3. Differential value scan on G[0x62424]tools/frida/find_global_by_sequence.py. Self-driven by the ReadFile→file_number(=resId) signal: scan for resId at load 1, keep those that become the next resId, etc. Converged to 3 addresses that tracked 35→37→39→43→46 perfectly — but all were STACK slots (region 0x18f000, self-referential + 0x76xxxxxx return addresses); their values are garbage between loads. They're the per-call argument copies, not the global.
  4. Stability filter (re-read survivors ~1.4 s later, during the pause) — proved the point: 2084 locations held resId 35 stably, but 0 of them became 37 ⇒ G[0x62424] is transient, never stable at a value, so neither value-scans nor stability-filters can pin it.

Concrete landmarks (for a future run — but note ASLR: these are per-process)

  • Player name = global-string 0x279; default "リリィ" (set in INPUTNAME.BIN via set-string (global-string 0x279), then the input op 0x1aa/u00425920 writes the entered name to 0x279, then copied to local-string 0x135). Stable global — the best future anchor.
  • Native unit records holding the name: ASCII Lily\0 in 16-byte-ish slots, followed by int stats (level/…, e.g. 0c 0f 04 0f 13 1f) and native pointers; arrays with stride ~0x1c. Region example 0x6e9c000 +0x82000 (rw-, no module). These are native objects, not the VM store.
  • Transient-arg stack region where load-arg resId copies appear: ~0x18f000 +0x11000 (68 KB rw-).
  • Large heap regions (candidate VM stores): 51.8 MB @ 0x2f64000, plus ~10/9.6/8/6 MB regions.
  • Unpacked code (packer): 30.3 MB r-x @ 0x62411000 (main VM logic; nonstable base per run).
  • Element size / mapping: unknown; it is not uniform base+addr*4 int32.

Tools built (kept for resumption)

All under tools/frida/ (see tools/frida/README.md and docs/tools-reference.md):

  • find_globals_base.py--build-sig builds build/globals-signature.json; scan mode does the flat-int32 signature scan (robust prefix ladder + verify). Currently finds nothing → layout isn't flat.
  • find_global_by_sequence.py — differential resId scan with stability filter. Finds stack proxies; transient globals elude it.
  • capture_load_order.py, locate_resource_load.py, capture_resid_args.py — asset-resolution captures (context: how the loader/decoder chain was found).

What this does NOT solve (avoid the trap we fell into)

Reading G[0x62424] live would not reveal the asset-resolution scope selector (why resId 37 → EV052CA and not one of the other 8 fn-37 files). The resId is already free from the ReadFile file_number; the scope is separate native scene state. So global observation and the scope selector are independent problems — don't chase globals expecting to crack resolution.


Heuristic value-scans only ever find specific, stable globals one at a time; they give no general address → memory mapping. A guaranteed, general capability needs the interpreter's address-resolution logic. Recommended order:

  1. Anchor on a STABLE global, not a transient one. Best: global-string 0x279 (the name). Find its VM-store copy (distinguish from native unit copies: the VM store won't be wrapped in heap pointers). A distinctive name makes the value scan collapse fast.
  2. Find the interpreter's global-access function. Set a hardware/MemoryAccessMonitor watchpoint on that stable global's physical location, trigger a bytecode read (e.g. open a menu that draws the name), and backtrace into the heap interpreter. That function's address computation is the bytecode-address → physical mapping — read it rather than guessing the layout.
  3. Generalize + verify. Derive the mapping (likely per-store / per-region), build read_global(addr), and verify against our VM's known state (e.g. read a stable flag whose value our VM predicts).
  4. Then build a runtime global-watch tool for VM validation and effectful-op naming.

Alternative bootstraps if watchpoints are awkward: snapshot-diff at quiescent points (change a known stable global via gameplay, diff memory); or a hardware write watchpoint on a stable global's location to catch the interpreter's write path.

Bottom line for the next session: the pieces (signature, tools, landmarks, the name anchor) are in place. Resume from a stable anchor and target the interpreter, not heuristic scans of transient globals — and only when runtime observation is actually the priority (it isn't blocking other work).