docs: native-engine RE — Ghidra+MCP loop + opcode dispatch table + u00428010 correction
Ghidra+MCP workflow validated. Found the opcode->handler dispatch table (*(ctx+0x9b8f4+op*4), registered by FUN_00413860) — the general fix for Kelebek VA drift. Corrected: u00428010 (op 0x1a2) is a save/resource op, NOT decision->scene; the SCJUMP consumer persists the visited-decision flag. New doc docs/engine-re.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -73,7 +73,8 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
|
||||
│ ├── script-inventory.md what the 481 scripts are
|
||||
│ ├── opcode-reference.md GENERATED from opcodes.toml (human-readable opcode reference)
|
||||
│ ├── global-reference.md GENERATED from globals.toml (human-readable global registry)
|
||||
│ └── scjump-progression.md SCJUMP progression decode + native decision→scene boundary
|
||||
│ ├── scjump-progression.md SCJUMP progression decode + native decision→scene boundary
|
||||
│ └── engine-re.md native-engine RE (Ghidra+MCP): dispatch table, decompiled ops
|
||||
│
|
||||
├── build/ DERIVED (our-work-side) — generated by tools/; disposable
|
||||
│ ├── disasm/ <NAME>.asm — human-readable disassembly, one per script
|
||||
|
||||
102
docs/engine-re.md
Normal file
102
docs/engine-re.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Native-engine reverse engineering (Ghidra + MCP)
|
||||
|
||||
Static RE of the **unpacked** `AGE.EXE` engine image, driving Ghidra 12.1.2 via the
|
||||
bethington/ghidra-mcp bridge. This is the home for decompiled native-op findings — the class of logic
|
||||
the scripts call but that lives compiled in the engine (decision→scene, call-script dispatch, op 0x60,
|
||||
the gfx command-buffer). Opcode semantics recovered here also flow into `vm-map/opcodes.toml`.
|
||||
|
||||
Related: `docs/scjump-progression.md` (the SCJUMP decoder that hit this wall), `name-resolution.md §1`
|
||||
(call-script), `vm-mapping-plan.md` appendix (why the exe is packed + the runtime-dump route).
|
||||
|
||||
---
|
||||
|
||||
## Runbook — the Ghidra + MCP loop
|
||||
|
||||
**One-time setup (done 2026-07-07):**
|
||||
- **MCP server:** bethington/ghidra-mcp, cloned to `S:\Game Hacking\ghidra-mcp`. We used the **prebuilt
|
||||
extension** `GhidraMCP-5.14.2.zip` (installed in Ghidra via File > Install Extensions) — this skips
|
||||
the Maven/Java-21 build. The Python **bridge** runs from a venv (`.venv`, Python 3.11, `pip install .`);
|
||||
no `uv` needed. Registered in Claude Code via `.mcp.json` at the workspace root:
|
||||
`{"mcpServers":{"ghidra":{"command":"S:\\Game Hacking\\ghidra-mcp\\.venv\\Scripts\\bridge-mcp-ghidra.exe","args":["--transport","stdio"]}}}`.
|
||||
- **In Ghidra:** enable the GhidraMCP plugin (File > Configure) and **Tools > GhidraMCP > Start MCP
|
||||
Server** (serves `http://127.0.0.1:8089/`). The bridge talks to that; Claude reaches the bridge over stdio.
|
||||
|
||||
**Loading the engine image (IMPORTANT — the language gotcha):**
|
||||
- Import `age-reimpl/build/engine-dump/range_00400000.bin` (the module dump: 2,490,368 bytes, the full
|
||||
0x400000 module image; VA→file offset = `VA − 0x400000`).
|
||||
- **Format = Raw Binary, Language = `x86:LE:32:default`, Image Base = `0x400000`.** Ghidra's language
|
||||
picker offers `x86:LE:32:System Management Mode` as the "closest" match — **do NOT use it.** SMM is a
|
||||
16-bit *segmented* (segment:offset) variant for BIOS/SMRAM; it mis-decodes flat 32-bit code (it loaded
|
||||
with addresses like `0000:0000`/`0025:ffff` and produced **0 functions**). The plain `default` variant
|
||||
is correct and yielded **2,721 functions**.
|
||||
- We drove the (re)import over MCP: `import_file(language="x86:LE:32:default", compiler_spec="windows",
|
||||
auto_analyze=false)` → `set_image_base(0x400000)` **before** analysis (so absolute-address refs resolve)
|
||||
→ `run_analysis`.
|
||||
- **Load sanity check (AGF-decoder landmark):** at VA `0x474f23`, `CMP word ptr [ESI + 0x4], 0x4d42`
|
||||
(the `BM`/BMP-magic check) confirms the image is correctly based + decoded.
|
||||
- Escalation (unused so far): `tools/pe-sieve32.exe /pid <PID> /imp 3 /dmode 3 /dir <out>` (run from
|
||||
**PowerShell**, not Git Bash — it mangles `/flags`) rebuilds the IAT into a clean PE. Only needed if
|
||||
raw-dump analysis is inadequate; it was fine for reading logic, so we stayed on the raw dump.
|
||||
|
||||
---
|
||||
|
||||
## Master key — the opcode→handler dispatch table (2026-07-07)
|
||||
|
||||
The interpreter dispatches each op via a per-context handler table:
|
||||
|
||||
> **`handler(op) = *(ctx + 0x9b8f4 + op*4)`** — `ctx` = the engine context (`esi` in handlers, thiscall).
|
||||
|
||||
The table is populated by the registration routine **`FUN_00413860`** as a long run of
|
||||
`MOV dword ptr [ESI + 0x9b8f4 + op*4], <handler_va>`. Sample (from ~`0x414756`):
|
||||
|
||||
| slot offset | handler VA |
|
||||
|---|---|
|
||||
| `0x9b8f4` | `0x42bd90` |
|
||||
| `0x9b8f8` | `0x427ed0` |
|
||||
| `0x9b8fc` | `0x427fb0` (the save handler below) |
|
||||
| `0x9b900` | `0x416b70` |
|
||||
| `0x9b904` | `0x428100` |
|
||||
| `0x9b908` | `0x428240` |
|
||||
|
||||
**Why this matters:** the Kelebek `u00XXXXXX` opcode names encode handler VAs from *Kelebek's* build,
|
||||
which **drift** in our build (confirmed below). This table resolves the *real* handler for any opcode
|
||||
in our image — the general fix for VA drift across the whole project. (Anchoring the slot↔opcode index
|
||||
is the immediate next step: read the dispatch site's exact base, then `slot = base + op*4`.)
|
||||
|
||||
**Other confirmed engine-context offsets** (`ctx`/`esi`): `+0x53d14` = current gfx-object index;
|
||||
`+0x53d88` = per-object cmd-type table (stride `0x78` = 120 bytes); operand-fetch helper = `call
|
||||
0x41b940` (thiscall, `ecx=ctx`, arg = operand index → returns the operand value).
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### op `0x1a2` (`u00428010`) is a SAVE/resource-file op — NOT decision→scene (2026-07-07)
|
||||
|
||||
The SCJUMP slice assumed `u00428010` resolved a decision value to a scene. **That premise is wrong:**
|
||||
|
||||
- Kelebek's `u00428010` = **opcode `0x1a2`, argc 1**. In *our* build, VA `0x428010` is **inside a
|
||||
different function** (`0x427fb0`) — Kelebek-VA drift.
|
||||
- `0x427fb0` is a genuine interpreter handler (uses `ctx+0x53d14`, the `ctx+0x53d88` cmd-type table,
|
||||
and the `0x41b940` operand-fetch). It builds file paths from the format string at `0x571e70` =
|
||||
**`%s\SAVE%2.2d.DAT`** (and a second at `0x571e84`) via an sprintf-style `0x407770`. It is
|
||||
**multi-operand** (fetches operands 2 and 3) — inconsistent with the argc-1 op `0x1a2`, reconfirming
|
||||
the drift.
|
||||
- **Conclusion:** op `0x1a2`/`u00428010` is a **save/resource-file** op (matches its earlier tentative
|
||||
"resource" tag). So the SCJUMP consumer pattern `lookup(0x5f0ed, decision); mov(ptr,1);
|
||||
u00428010(ptr)` is **persisting the "visited-decision" flag into the save data**, *not* loading a
|
||||
scene. The real decision→scene resolution is a **different** mechanism — most likely the same native
|
||||
boundary as call-script/script-load (next investigation).
|
||||
|
||||
*(To pin op `0x1a2`'s exact handler in our build: index the dispatch table above by `0x1a2`.)*
|
||||
|
||||
---
|
||||
|
||||
## Native walls backlog (targets for this loop)
|
||||
|
||||
- **decision→scene** — how `0x62ccf`/the decision actually selects the next `SCxxxx` (re-aimed away
|
||||
from `u00428010`; likely call-script-adjacent).
|
||||
- **call-script dispatch** — `call-script <id>` → engine entry point (`name-resolution.md §1`).
|
||||
- **op `0x60`** (`u0041A270`) — the rand-like value gating 1732/1755 SCJUMP decisions.
|
||||
- **gfx command-buffer** — the `0x212–0x21a` positioned-object subsystem (`scjump`-unrelated; the
|
||||
rendering drift).
|
||||
@@ -40,10 +40,13 @@ choices are not pure story flags.
|
||||
Run: `py -3.11 -X utf8 tools/scjump_decode.py --verify`.
|
||||
|
||||
## The native decision→scene boundary (deferred)
|
||||
The decision value → actual `SCxxxx.BIN` is resolved by the **native op `u00428010`** (consumers do
|
||||
`lookup-array(ptr, 0x5f0ed, 0x62ccf)` then `u00428010(ptr)`). That mapping is compiled into `AGE.EXE`,
|
||||
not present in any script — so it is engine-level, the same bucket as the `call-script` dispatch (see
|
||||
`name-resolution.md §1`). Cracking it needs the engine dump / Frida and is a separate slice.
|
||||
Consumers do `lookup-array(ptr, 0x5f0ed, 0x62ccf)` then `u00428010(ptr)`. **Correction (2026-07-07,
|
||||
via Ghidra):** `u00428010` (op `0x1a2`) is **not** the scene resolver — it's a **save/resource-file
|
||||
op** (its handler formats `%s\SAVE%2.2d.DAT`), so that pattern **persists the "visited-decision" flag
|
||||
into the save**, not loads a scene. See `docs/engine-re.md`. The actual decision→scene resolution is a
|
||||
different, still-native mechanism — most likely call-script/script-load-adjacent (`name-resolution.md
|
||||
§1`). Cracking it uses the engine-dump + Ghidra loop (the opcode-dispatch table found in
|
||||
`engine-re.md` is the key); still a separate slice.
|
||||
|
||||
## See also
|
||||
- `vm-map/globals.toml` — the named globals SCJUMP switches on (chapter_mode, progress counters, flags).
|
||||
|
||||
Reference in New Issue
Block a user