105 lines
6.6 KiB
Markdown
105 lines
6.6 KiB
Markdown
# 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): `bin/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, anchored)
|
||
|
||
The interpreter dispatches each op via a per-context handler table, **fully anchored**:
|
||
|
||
> **`handler(op) = ctx[0x26c93 + op]`** (word index) **= `*(ctx + 0x9b24c + op*4)`**
|
||
> — `ctx` = the engine context (`esi` in handlers, thiscall; `param_1` in the decompile of the
|
||
> registration routine).
|
||
|
||
The registration routine **`FUN_00413860`** first fills `0x400` (1024) slots starting at
|
||
`ctx[0x26c93]` with a **default handler `FUN_004162b0`** (op 0's slot), then overrides specific
|
||
opcodes: `ctx[0x26c93 + op] = <handler_va>`. So **opcode = (word_index − 0x26c93)**. Cross-check:
|
||
`ctx[0x26e3f] = 0x427fb0` (byte offset `0x9b8fc`) → op `0x26e3f − 0x26c93 = 0x1ac`.
|
||
|
||
**Why this matters:** the Kelebek `u00XXXXXX` opcode names encode handler VAs from *Kelebek's* build,
|
||
which **drift** in ours. This table resolves the *real* handler for any opcode in our image — the
|
||
general fix for VA drift project-wide. To find op `N`'s handler: read `ctx[0x26c93 + N]` from the
|
||
`FUN_00413860` decompile (or `*(ctx + 0x9b24c + N*4)` at runtime).
|
||
|
||
**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); `FUN_00415f30(i)` =
|
||
a companion operand accessor.
|
||
|
||
---
|
||
|
||
## Findings
|
||
|
||
### op `0x1a2` (`u00428010`) is a GRAPHICS command-buffer op — NOT save, NOT decision→scene (2026-07-07)
|
||
|
||
The SCJUMP slice assumed `u00428010` resolved a decision value to a scene. **That premise is wrong**,
|
||
and pinning the *real* handler via the dispatch table above corrects two layers of confusion:
|
||
|
||
- **VA-drift trap:** Kelebek's `u00428010` = op `0x1a2`. But Kelebek's raw VA `0x428010`, in *our*
|
||
build, sits inside a *different* handler `0x427fb0`, which is **op `0x1ac`** (per the table:
|
||
`ctx[0x26e3f]=0x427fb0`). Op `0x1ac` is a **save-path** op — its handler formats
|
||
`%s\SAVE%2.2d.DAT` (format string `0x571e70`) and is multi-operand. Reading the raw VA gave the
|
||
wrong opcode.
|
||
- **Op `0x1a2`'s real handler = `FUN_0042d360`** (`= ctx[0x26c93+0x1a2] = ctx[0x26e35]`), argc 1. It:
|
||
sets the **current gfx-object cmd-type to 3** (`*(ctx+0x53d88 + ctx[0x53d14]*0x78) = 3`), fetches
|
||
operand 1, formats a key with `"%c%8.8x"` (format string `0x5714e0`) of `(3, operand)`, and calls
|
||
`FUN_0042cf70(key, &operand)`. This is a **graphics command-buffer registration op**, not save and
|
||
not scene-load.
|
||
- **Consequence — the decision→scene premise is discredited.** The FIELD snippet
|
||
`lookup(0x5f0ed, 0x62ccf); mov(ptr,1); lookup(0x5f0ed, 0x62ccf); u00428010(ptr)` (next op `0x21b`,
|
||
also gfx-family) is a **graphics/UI operation**, not scene sequencing. So `u00428010` does **not**
|
||
resolve decision→scene. **The real decision→scene mechanism is unidentified** — it belongs with the
|
||
call-script / script-load dispatch (`name-resolution.md §1`), the next target for this loop (now
|
||
armed with the dispatch table to resolve the call-script handler directly).
|
||
|
||
**Lesson:** never analyze a native op by its Kelebek `u00XXXXXX` VA directly — always resolve the real
|
||
handler through the dispatch table (`ctx[0x26c93 + op]`). The raw VA is off by whole functions.
|
||
|
||
---
|
||
|
||
## 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).
|