Files
OpenMaidEngine/docs/engine-re.md
gamer147 0ae4f607c0 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>
2026-07-07 11:29:02 -04:00

103 lines
5.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `0x2120x21a` positioned-object subsystem (`scjump`-unrelated; the
rendering drift).