From dde015f7e3936c31bb4b8fdb6cb4a44d1f179e56 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 7 Jul 2026 11:29:02 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20native-engine=20RE=20=E2=80=94=20Ghidra?= =?UTF-8?q?+MCP=20loop=20+=20opcode=20dispatch=20table=20+=20u00428010=20c?= =?UTF-8?q?orrection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/PROJECT-STRUCTURE.md | 3 +- docs/engine-re.md | 102 +++++++++++++++++++++++++++++++++++++ docs/scjump-progression.md | 11 ++-- 3 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 docs/engine-re.md diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index 5c2bc1f..71a331a 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -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/ .asm — human-readable disassembly, one per script diff --git a/docs/engine-re.md b/docs/engine-re.md new file mode 100644 index 0000000..d24a4ea --- /dev/null +++ b/docs/engine-re.md @@ -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 /imp 3 /dmode 3 /dir ` (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], `. 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 ` → 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). diff --git a/docs/scjump-progression.md b/docs/scjump-progression.md index 07a0e3c..930800d 100644 --- a/docs/scjump-progression.md +++ b/docs/scjump-progression.md @@ -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).