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) <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-09 09:19:36 -04:00
parent 263699ef92
commit 48dbb7c128
2 changed files with 493 additions and 0 deletions

View File

@@ -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_<dll>_<func>` 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/<name>.py …` (utf8 mandatory on Windows). Frida scripts: `py -3.11 -u -X utf8 tools/frida/<name>.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("<I", mem, 0x10, 0x76d80e70)
struct.pack_into("<I", mem, 0x14, 0x76d7f7f0)
struct.pack_into("<I", mem, 0x18, 0x12345678) # not an export -> 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("<I", mem, 0x2, 0x76d80e70) # unaligned -> 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("<I", mem, off)[0]
name = index.get(v)
if name is not None:
out.append((off, v, name))
return out
def cluster_runs(rvas, stride=4):
"""Group sorted RVAs into contiguous aligned runs -> [(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 24.
- [ ] **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_<dll>_<func>` 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>_<func>` (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 56, 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.

View File

@@ -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_<dll>_<func>` **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.