From 7f8da4be711c8abd37c25881d2524d8db42eca6f Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 9 Jul 2026 09:19:36 -0400 Subject: [PATCH] plan: frida import-map slice (spec + implementation plan) Replaces the abandoned pe-sieve Task B: attach + scan the live process to map resolved import pointers (RVA->dll!Func) and label /v2. Recon-first hard gate; clean labels; read-only Frida. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-09-frida-import-map.md | 384 ++++++++++++++++++ .../2026-07-09-frida-import-map-design.md | 109 +++++ 2 files changed, 493 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-frida-import-map.md create mode 100644 docs/superpowers/specs/2026-07-09-frida-import-map-design.md diff --git a/docs/superpowers/plans/2026-07-09-frida-import-map.md b/docs/superpowers/plans/2026-07-09-frida-import-map.md new file mode 100644 index 0000000..94d2e9d --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-frida-import-map.md @@ -0,0 +1,384 @@ +# Frida Import-Map — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Name the dynamically-resolved Win32 APIs at their call sites in the `/v2` Ghidra image by mapping the packer's resolved import pointers (`RVA → dll!Func`) from the live process and applying clean labels. + +**Architecture:** One Frida tool (`tools/frida/map_imports.py`) attaches to the live game, builds `{runtime_addr → dll!Func}` from loaded-module exports, scans the `0x400000` module for aligned DWORDs matching those addresses, and (a) `--recon` reports clustering [the hard gate] or (b) writes `build/import-map.json`. A `run_script_inline` Java script then applies `imp__` labels to `/v2`. The scan/match/cluster logic is pure Python (unit-tested); the Frida attach is exercised live. + +**Tech Stack:** Python 3.11 (`py -3.11 -X utf8`), Frida 17.x (`tools/frida/` pattern — see `dump_engine.py`), ghidra-mcp `run_script_inline` (Java; `GHIDRA_MCP_ALLOW_SCRIPTS=1`). + +**Spec:** `docs/superpowers/specs/2026-07-09-frida-import-map-design.md`. + +## Global Constraints + +- Run Python as `py -3.11 -X utf8 tools/.py …` (utf8 mandatory on Windows). Frida scripts: `py -3.11 -u -X utf8 tools/frida/.py`. +- Tools import `tools/paths.py`; never hard-code paths. `build/` is disposable/gitignored. +- **ASLR safety:** build the export map and scan the module in the SAME live process; carry back only **RVA** (offset into the fixed `0x400000` main module). Never scan the old `/v2` dump for current-process pointer values. +- **Read-only Frida only** — `enumerateModules`/`enumerateExports` + memory reads (the safe plain-JS pattern; no spawn, no `GetProcAddress` hook, no patching). +- **Dump range:** the `/v2` image covers `0x400000`..`0x660000` (size `0x260000`). A match at RVA `≥ 0x260000` is heap/out-of-dump and NOT labelable. +- **Recon is a HARD GATE (Task 1):** if matches are not module-resident and in-range, STOP and document; do not build the labeler on heap-resident pointers. +- **Ghidra:** confirm the active program is `/v2/range_00400000.bin` (base `0x400000`, 4308 fns) before any script — the two-program gotcha (`docs/engine-re.md` runbook). End image mutation with `save_program`. +- Preserve annotations: never clobber an existing `USER_DEFINED` symbol. + +--- + +## Task 1: Recon probe — export map, module scan, clustering report (THE GATE) + +**Files:** +- Create: `tools/frida/map_imports.py` (Frida attach + export enum + scan + report; `--recon`) +- Create: `tools/frida/test_map_imports.py` (unit tests for the pure scan/match/cluster logic) +- Reference: `tools/frida/dump_engine.py` (attach + module-read boilerplate to mirror), `tools/frida/README.md` + +**Interfaces:** +- Produces (pure, importable): `build_export_index(exports) -> dict[int,str]`; `scan_pointer_matches(mem: bytes, base_va: int, index: dict[int,str]) -> list[tuple[int,int,str]]` returning `(rva, value, name)`; `cluster_runs(rvas: list[int], stride=4) -> list[tuple[int,int]]` returning `(start_rva, count)`. +- Produces (Task 2 consumes): the `--recon` verdict — whether matches are module-resident and in-range. + +- [ ] **Step 1: Write failing tests for the pure scan/cluster logic** + +```python +# tools/frida/test_map_imports.py (plain runner, project convention — no pytest) +import sys, struct +from map_imports import build_export_index, scan_pointer_matches, cluster_runs + +FAILS = [] +def check(c, m): + (FAILS.append(m) or print("FAIL:", m)) if not c else print("ok:", m) + +def test_export_index_canonicalizes(): + idx = build_export_index([ + {"address": 0x76d80e70, "name": "LoadLibraryA", "module": "kernel32.dll"}, + {"address": 0x76d7f7f0, "name": "GetProcAddress", "module": "kernel32.dll"}, + ]) + check(idx[0x76d80e70] == "kernel32.dll!LoadLibraryA", "index maps addr->dll!func") + +def test_scan_matches_little_endian_aligned(): + # base 0x400000; two import pointers at RVA 0x10 and 0x14, junk elsewhere. + idx = {0x76d80e70: "kernel32.dll!LoadLibraryA", 0x76d7f7f0: "kernel32.dll!GetProcAddress"} + mem = bytearray(0x20) + struct.pack_into(" no match + m = scan_pointer_matches(bytes(mem), 0x400000, idx) + check([(r, v) for r, v, _ in m] == [(0x10, 0x76d80e70), (0x14, 0x76d7f7f0)], + "scan finds aligned LE pointer matches, skips non-exports") + +def test_scan_ignores_unaligned(): + idx = {0x76d80e70: "kernel32.dll!LoadLibraryA"} + mem = bytearray(0x10) + struct.pack_into(" ignored + check(scan_pointer_matches(bytes(mem), 0x400000, idx) == [], "unaligned pointer ignored") + +def test_cluster_runs_groups_contiguous(): + # a run of 3 at 0x100/104/108, a singleton at 0x200 + check(cluster_runs([0x100, 0x104, 0x108, 0x200]) == [(0x100, 3), (0x200, 1)], + "cluster groups contiguous aligned runs, isolates singleton") + +def main(): + test_export_index_canonicalizes(); test_scan_matches_little_endian_aligned() + test_scan_ignores_unaligned(); test_cluster_runs_groups_contiguous() + print("FAILURES:", len(FAILS)); return 1 if FAILS else 0 + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `py -3.11 -X utf8 tools/frida/test_map_imports.py` +Expected: FAIL — `ModuleNotFoundError: No module named 'map_imports'`. + +- [ ] **Step 3: Implement the pure logic + Frida recon in `map_imports.py`** + +Pure functions (exact): + +```python +import struct + +def build_export_index(exports): + """[{address,name,module}] -> {int addr: 'module!name'} (first name wins per addr).""" + idx = {} + for e in exports: + a = int(e["address"]) + if a and a not in idx: + idx[a] = f"{e['module']}!{e['name']}" + return idx + +def scan_pointer_matches(mem, base_va, index): + """Aligned little-endian DWORD scan; return [(rva, value, name)] for values in index.""" + out = [] + n = len(mem) & ~3 + for off in range(0, n, 4): + v = struct.unpack_from(" [(start_rva, count)].""" + runs = [] + for r in sorted(rvas): + if runs and r == runs[-1][0] + runs[-1][1] * stride: + s, c = runs[-1]; runs[-1] = (s, c + 1) + else: + runs.append((r, 1)) + return runs +``` + +Frida driver (mirror `dump_engine.py`'s attach/rpc; JS enumerates exports + returns the module range bytes). Adapt names to the existing helper if `dump_engine.py` factors one out: + +```python +import sys, frida, json, paths +MODULE = "AGE.EXE"; BASE = 0x400000 + +_JS = r""" +rpc.exports = { + exports: function () { + var out = []; + Process.enumerateModules().forEach(function (m) { + m.enumerateExports().forEach(function (e) { + out.push({address: e.address.toString(), name: e.name, module: m.name}); + }); + }); + return out; + }, + modulemem: function (base, size) { + return Memory.readByteArray(ptr(base), size); + }, + modulesize: function (name) { + var m = Process.getModuleByName(name); return [m.base.toString(), m.size]; + } +}; +""" + +def attach(): + dev = frida.get_local_device() + procs = [p for p in dev.enumerate_processes() if p.name.lower().startswith("age")] + if not procs: + sys.exit("AGE.EXE not found — launch the game first.") + return dev.attach(procs[0].pid) + +def collect(): + session = attach() + script = session.create_script(_JS); script.load() + api = script.exports_sync + exports = [{"address": int(e["address"], 16), "name": e["name"], "module": e["module"]} + for e in api.exports()] + base_s, size = api.modulesize(MODULE) + mem = bytes(api.modulemem(int(base_s, 16), size)) + session.detach() + return exports, int(base_s, 16), mem + +def recon(): + exports, base, mem = collect() + idx = build_export_index(exports) + matches = scan_pointer_matches(mem, base, idx) + runs = cluster_runs([r for r, _, _ in matches]) + inrange = [r for r, _, _ in matches if r < 0x260000] + print(f"exports mapped={len(idx)} module bytes={len(mem)} matches={len(matches)} in-range(<0x260000)={len(inrange)}") + print(f"clusters(runs>=3)={[hex(s)+':'+str(c) for s,c in runs if c>=3]}") + print(f"singletons={sum(1 for _,c in runs if c==1)}") + verdict = "GATE-PASS: module-resident table present" if any(c>=3 for _,c in runs) and inrange else \ + "GATE-FAIL: no in-module import table — STOP, pointers may be heap-resident" + print(verdict) + +if __name__ == "__main__": + if "--recon" in sys.argv[1:]: + recon() + else: + from map_imports_full import build # Task 2 adds the writer; keep recon self-contained + build() +``` + +*(If `dump_engine.py` already exposes an attach/read helper, import and reuse it instead of duplicating `attach()`.)* + +- [ ] **Step 4: Run the unit tests to verify they pass** + +Run: `py -3.11 -X utf8 tools/frida/test_map_imports.py` +Expected: PASS (4 tests, `FAILURES: 0`). + +- [ ] **Step 5: Run the live recon against the running game (THE GATE)** + +Ensure AGE.EXE is running, then: +Run: `py -3.11 -u -X utf8 tools/frida/map_imports.py --recon` +Expected: a report line with `exports mapped=` (thousands), `matches=`, `in-range=`, `clusters(runs>=3)=`, and a `GATE-PASS`/`GATE-FAIL` verdict. +- **GATE-PASS** (a contiguous in-module run exists, matches in-range) → proceed to Task 2. +- **GATE-FAIL** (no in-module table; matches heap-resident/out-of-range) → **STOP.** Record the finding in `docs/engine-re.md` (the approach can't label the dump; a launch-keyed heap dump would be needed) and end the slice. Do not build Tasks 2–4. + +- [ ] **Step 6: Commit** + +```bash +git add tools/frida/map_imports.py tools/frida/test_map_imports.py +git commit -m "re(frida): import-map recon — export scan + clustering gate" +``` + +--- + +## Task 2: Full mapper — write `build/import-map.json` + +**Precondition:** Task 1 GATE-PASS. + +**Files:** +- Create: `tools/frida/map_imports_full.py` (the writer invoked by `map_imports.py` default mode) +- Modify: `tools/frida/map_imports.py` (default mode already calls `build()`) +- Test: extend `tools/frida/test_map_imports.py` + +**Interfaces:** +- Consumes: `scan_pointer_matches`, `cluster_runs` (Task 1). +- Produces: `select_table_matches(matches, runs, min_run=3) -> (table_map, singletons)` — split matches into those inside a contiguous run of ≥ `min_run` (auto-apply) vs isolated (review); and `build/import-map.json` (`{ "0xRVA": "dll!Func" }`), `build/import-map-singletons.json`. + +- [ ] **Step 1: Write the failing split test** + +```python +def test_select_table_matches_splits_runs_from_singletons(): + from map_imports_full import select_table_matches + matches = [(0x100,0,"a!f"),(0x104,0,"b!g"),(0x108,0,"c!h"),(0x200,0,"d!i")] + runs = [(0x100,3),(0x200,1)] + table, singles = select_table_matches(matches, runs, min_run=3) + # asserts table has the 3 run entries keyed by hex RVA, singles has the lone one + check(set(table) == {"0x100","0x104","0x108"} and set(singles) == {"0x200"}, + "run members auto-apply; singleton set aside") +``` +Add its call to `main()`. Run → FAIL (`select_table_matches` missing). + +- [ ] **Step 2: Implement `select_table_matches` + `build()` in `map_imports_full.py`** + +```python +import json, paths +from map_imports import scan_pointer_matches, cluster_runs # reuse +# collect() lives in map_imports; import it too +from map_imports import collect, build_export_index + +def select_table_matches(matches, runs, min_run=3): + run_rvas = set() + for start, count in runs: + if count >= min_run: + run_rvas.update(start + i*4 for i in range(count)) + table = {hex(r): n for r, _, n in matches if r in run_rvas} + singles = {hex(r): n for r, _, n in matches if r not in run_rvas} + return table, singles + +def build(): + exports, base, mem = collect() + idx = build_export_index(exports) + matches = scan_pointer_matches(mem, base, idx) + runs = cluster_runs([r for r, _, _ in matches]) + table, singles = select_table_matches(matches, runs) + (paths.BUILD / "import-map.json").write_text(json.dumps(table, indent=2)+"\n", encoding="utf-8") + (paths.BUILD / "import-map-singletons.json").write_text(json.dumps(singles, indent=2)+"\n", encoding="utf-8") + print(f"wrote {len(table)} table imports -> build/import-map.json; {len(singles)} singletons set aside") +``` + +- [ ] **Step 3: Run unit tests → PASS** + +Run: `py -3.11 -X utf8 tools/frida/test_map_imports.py` +Expected: PASS (5 tests). + +- [ ] **Step 4: Generate the real map (live) and sanity-check** + +Run: `py -3.11 -u -X utf8 tools/frida/map_imports.py` +Expected: `build/import-map.json` written with a sane count (dozens+); spot-check that recognizable APIs appear (e.g. a `kernel32.dll!ReadFile`, `winmm.dll!timeGetTime`, `d3d9.dll!*`). If the count is ~17 or clearly noise, revisit the run-selection `min_run` / range filter before proceeding. + +- [ ] **Step 5: Commit** + +```bash +git add tools/frida/map_imports_full.py tools/frida/test_map_imports.py +git commit -m "re(frida): write import-map.json (in-table matches; singletons set aside)" +``` + +--- + +## Task 3: Ghidra labeler — apply `imp__` labels to `/v2` + +**Precondition:** Task 2 produced `build/import-map.json`. + +**Files:** +- No new Python — driven via ghidra-mcp `run_script_inline` (Java), like Task A. + +- [ ] **Step 1: Confirm the correct program is active (two-program gotcha)** + +Via MCP `get_current_program_info` (or a probe script): assert base `0x400000` / 4308 functions. If not, `open_program /v2/range_00400000.bin` + `switch_program`. +Expected: active program path `/v2/range_00400000.bin`. + +- [ ] **Step 2: Dry-run report — how many labels, how many collide** + +`run_script_inline` (Java): read `build/import-map.json`, for each `RVA → name` compute `addr = 0x400000 + RVA`; report counts: would-create vs already-has-a-USER_DEFINED-symbol. Do not mutate. + +```java +import java.nio.file.*; import java.util.*; +import ghidra.program.model.symbol.*; +String txt = new String(Files.readAllBytes(Paths.get("S:/Game Hacking/Eushully/Himegari/age-reimpl/build/import-map.json"))); +java.util.regex.Matcher m = java.util.regex.Pattern + .compile("\"0x([0-9a-fA-F]+)\":\\s*\"([^\"]+)\"").matcher(txt); +int create=0, collide=0, total=0; +SymbolTable st = currentProgram.getSymbolTable(); +while (m.find()) { + total++; + long rva = Long.parseLong(m.group(1),16); + ghidra.program.model.address.Address a = toAddr(0x400000L + rva); + Symbol s = st.getPrimarySymbol(a); + if (s != null && s.getSource()==SourceType.USER_DEFINED) collide++; else create++; +} +println("total="+total+" wouldCreate="+create+" collideUserDefined="+collide); +``` +Expected: `wouldCreate` ≈ total; `collide` small (only if an import RVA overlaps an existing user symbol). + +- [ ] **Step 3: Apply the labels (mutate + save)** + +`run_script_inline` (Java) in one transaction: for each `RVA → "dll!Func"`, `sanitize` → `imp__` (dll without extension, non-alnum → `_`); skip if a `USER_DEFINED` symbol already exists at the addr; else `createLabel(addr, name, true)` (or `st.createLabel(addr, name, SourceType.USER_DEFINED)`). Print applied/skipped counts. Then `save_program`. + +```java +// name sanitize: "kernel32.dll!ReadFile" -> "imp_kernel32_ReadFile" +String raw = m.group(2); // dll!Func +String dll = raw.substring(0, raw.indexOf('!')).replaceAll("\\.[^.]*$",""); +String fn = raw.substring(raw.indexOf('!')+1); +String nm = ("imp_"+dll+"_"+fn).replaceAll("[^A-Za-z0-9_]","_"); +``` +Expected: applied ≈ `wouldCreate`; 0 clobbers; save succeeds. + +- [ ] **Step 4: Commit (docs only — image saved in Ghidra project)** + +*(No repo files change here; the Ghidra image lives in the project, not git. Proceed to Task 4 for the doc commit.)* + +--- + +## Task 4: Validate, document, record + +**Files:** +- Modify: `docs/engine-re.md` (mechanism + result under the IAT-reconstruction runbook entry) +- Modify: `docs/tools-reference.md` (add `map_imports.py`) +- Modify: `~/.claude/…/memory/himegari-port-status.md` (milestone) + +- [ ] **Step 1: Validate against known hand-identified APIs** + +Via MCP: decompile/inspect the call sites we already RE'd and confirm they now read named: +- `FUN_0044f390` / `FUN_0040e980` (resolver chain) → `CreateFileA` / `SetFilePointer` / `ReadFile`. +- `sleep_op_0xc8` → its `DAT_0056f3d4` timer source → a `timeGetTime`/`GetTickCount`-class import. +- A `d3d9` `Present` / `Direct3DCreate9` site. +Record which anchors resolved (need ≥2 of 3 for acceptance). + +- [ ] **Step 2: Update docs** + +`docs/engine-re.md`: in the runbook's "the right approach (queued, Frida-based)" note, replace "queued" with the result — mechanism confirmed, N labels applied, validated anchors, tool name. `docs/tools-reference.md`: add a `map_imports.py` row under "Runtime capture (Frida)". + +- [ ] **Step 3: Update the status memory** + +Add a milestone entry: Frida import-map DONE (or GATE-FAIL finding), N imports labeled, validated anchors, tool + map paths. + +- [ ] **Step 4: Commit** + +```bash +git add docs/engine-re.md docs/tools-reference.md +git commit -m "re(frida): import-map applied to /v2 — N Win32 APIs named at call sites; validated" +``` + +--- + +## Self-Review + +**Spec coverage:** recon gate → Task 1 (Steps 5–6, explicit STOP on GATE-FAIL); export map + scan → Task 1 Step 3; `import-map.json` + singleton split → Task 2; clean labels on `/v2` (no type archive) → Task 3; ASLR-safe (same-process map+scan, RVA-only) → Global Constraints + `collect()`; validation anchors → Task 4 Step 1; docs/memory → Task 4. All covered. + +**Placeholder scan:** the Frida `attach()`/JS is concrete but flagged "mirror/reuse `dump_engine.py`" — intentional (match the existing frida helper rather than duplicate); not a hidden TODO. No TBDs. + +**Type consistency:** `scan_pointer_matches` returns `(rva, value, name)` and every consumer (`recon`, `build`, `select_table_matches`) unpacks that triple; `cluster_runs` returns `(start_rva, count)` used identically in `recon`/`select_table_matches`; `build/import-map.json` is `{hex_rva: "dll!func"}` produced by `build()` and consumed by the Task 3 regex `"0x..":"..!.."`. Consistent. diff --git a/docs/superpowers/specs/2026-07-09-frida-import-map-design.md b/docs/superpowers/specs/2026-07-09-frida-import-map-design.md new file mode 100644 index 0000000..474f348 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-frida-import-map-design.md @@ -0,0 +1,109 @@ +# Frida import-map — naming dynamically-resolved Win32 APIs — design + +**Date:** 2026-07-09 +**Status:** approved (design), plan pending +**Replaces:** the abandoned pe-sieve Task B in +`docs/superpowers/plans/2026-07-09-re-tooling-handler-labels-and-iat.md`. +**Home in the canonical map:** results + the mapping mechanism land in `docs/engine-re.md` +(the runbook already carries the design sketch under "IAT reconstruction — tried, DOESN'T WORK → +the right approach"); the tools go in `docs/tools-reference.md`. This spec is the design record. + +## Motivation + +AGE.EXE ships with a **zeroed IAT** resolved via `GetProcAddress` at load (confirmed: `pack_check.py`, +and pe-sieve's `/imp` produced ~17 genuine + 300+ spurious entries — see the `engine-re.md` runbook). +So the game's hot Win32 APIs (`ReadFile`/`CreateFileA`/`SetFilePointer`/`timeGetTime`/d3d9 device +methods) show in Ghidra only as indirect calls through unnamed pointers, and we have been identifying +them by format-string and behaviour archaeology. This slice names them at their call sites in one pass — +the same high-leverage move as the Task A dispatch-handler labeling, for the Win32 surface. + +## Approach (decided in brainstorming) + +**Attach + scan, recon-first**, applying **clean labels** (no type archive): +- Build `{runtime_addr → "dll!Func"}` from the **live** process's loaded-module export tables. +- Scan the `0x400000` module's memory for aligned DWORDs whose value is in that set → those locations + hold resolved import pointers → record **`RVA → name`**. +- Apply `imp__` **labels** to the `/v2` Ghidra image at `0x400000 + RVA`. + +**Why ASLR-safe:** the export map and the module scan are done in the *same* live process, so the stored +pointer values and the export addresses are from one consistent address space. We carry back only the +**RVA** (offset into the fixed-base `0x400000` main module, which is not relocated), so the labels +computed live apply to the earlier dump — the code/table *locations* are identical across launches even +though the stored pointer *values* differ. + +**Why read-only / anti-tamper-safe:** only Frida `enumerateModules`/`enumerateExports` + memory reads — +the same plain-JS read pattern the existing capture scripts use. No spawn, no `GetProcAddress` hook, no +patching. (This is *not* the "live-debugger" case the user gated behind "probe carefully first.") + +## The one unknown, and the recon gate + +We do **not** yet know *where* the packer stores the resolved pointers. pe-sieve found genuine import +thunks at RVA `~0x202xxx` **inside the module** (`in_main:1`), which suggests a module-resident, +RVA-stable pointer table — exactly what this approach needs. But whether the `GetProcAddress`-resolved +hot APIs also land in-module, or in a heap block (non-stable base, **not** in the `/v2` dump), is unproven. + +**Recon is a HARD GATE.** Task 1 is read-only and only *reports*. It must show the matches cluster in a +**module-resident** region within the dump's range (RVA `< 0x260000`, the dump size) before any labeler +is built. If the pointers turn out to be heap-resident, we **stop and report that honestly** — the slice +cannot label the dump and would need a different mechanism (e.g. a heap dump keyed to the same launch). +No forcing. + +## Components + +### 1. `tools/frida/map_imports.py` — export map + module scan (Tasks 1 & 2) + +- Attach by PID (default: locate `AGE.EXE`, like `dump_engine.py`). +- Frida JS: `Process.enumerateModules()` → `Module.enumerateExports(m.name)` → `{addr → "dll!Func"}`. + Handle collisions/forwarders (multiple names per addr) by keeping a canonical name + noting alternates. +- Read the `0x400000` module range; scan 4-byte-aligned DWORDs; collect `(RVA, value, name)` for values + in the export set. +- **`--recon` (Task 1, report only):** print total exports mapped, total matches, and the **clustering**: + contiguous aligned runs (candidate import tables) vs isolated singletons; min/max RVA of matches and how + many fall within the dump range (`< 0x260000`). No file written. This is the gate. +- **default (Task 2):** write `build/import-map.json` = `{ "0xRVA": "dll!Func", ... }` restricted to matches + inside identified contiguous table region(s); isolated singletons written to a separate + `build/import-map-singletons.json` for review (not auto-applied — a lone match is more likely coincidental). + +### 2. Ghidra labeler (Task 3) — `run_script_inline` Java (reuse the Task A pattern) + +- Confirm the active program is `/v2/range_00400000.bin` (base `0x400000`, 4308 fns — the two-program + gotcha from Task A). +- Read `build/import-map.json`; for each `RVA → name`: `createLabel(toAddr(0x400000+RVA), + "imp_" + sanitize(dll) + "_" + func, SourceType.USER_DEFINED)`. Skip if a `USER_DEFINED` label already + exists there (idempotent, no clobber). One transaction; `save_program`. + +## Data flow + +live process ─▶ export map `{addr→name}` + module DWORD scan ─▶ `RVA→name` (`build/import-map.json`) +─▶ Ghidra `createLabel` on `/v2` ─▶ call sites read `CALL dword ptr [imp_kernel32_ReadFile]`. + +## Validation (Task 4) + +Cross-check against APIs already hand-identified, so the mapping is proven, not assumed: +- The `call-script` resolver chain (`FUN_0044f390` / `FUN_0040e980`) should now show `CreateFileA` / + `SetFilePointer` / `ReadFile` at its file-I/O call sites. +- The `sleep` timer source (`sleep_op_0xc8` → `DAT_0056f3d4`) should resolve to a `timeGetTime`/`GetTickCount`-class import. +- A `d3d9` `Present`/`Direct3DCreate9` site should be named. + +If those light up correctly, the mapping is real. Record the count of labels applied and the validated +anchors. Then update `docs/engine-re.md` (mechanism + result), `docs/tools-reference.md` (the new tool), +and the status memory. + +## Scope & boundaries + +- **In:** all loaded modules' exports (no curation); module-resident import pointers; clean labels. +- **Out (explicitly):** typed Win32 signatures / the type archive (labels only this pass — signatures are a + later layer); heap-resident pointer tables (out of reach of the dump — recon decides if this blocks us); + the `GetProcAddress`-hook/spawn variant (rejected: fragile, needs spawn); renaming the DLL functions + themselves (we label the *pointer slots*, not the targets, which aren't in the dump). +- **Regenerable:** `build/import-map.json` regenerates from a live run; disposable per project convention. + +## Acceptance criteria + +- Recon (Task 1) reports match clustering and the in-module/in-range verdict; **the gate is passed + explicitly** (matches are module-resident, within the dump) before Task 2/3 run — or the slice stops + here with a documented finding. +- `build/import-map.json` produced with a sane count (dozens+ of real imports, not ~17 and not 300+ noise). +- Ghidra labeler applies labels with 0 clobbers of existing `USER_DEFINED` symbols; `save_program` succeeds. +- ≥2 of the three validation anchors (resolver-chain file I/O, sleep timer, d3d9) read as named calls. +- Docs + memory updated.