From 6df1db08398b04dadba0a7ce0890d3a003cb6666 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 6 Jul 2026 23:21:08 -0400 Subject: [PATCH 1/7] docs(opcodes): 0x215 = native graphics-object query (root cause of gfx drift) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigation upgrade: 0x215 queries the native object manager by element handle-id (0x62455[idx]) and returns the object's slot/status; sign-tested to drive label_12649's slot-select. Stubbing it collapses all draws onto slot 0 → the anchor-preserve geometry reads foreign textures → cumulative bg/sprite drift. Keystone for the graphics object-manager; exact return via unicorn (handler @0x421160). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/opcode-reference.md | 10 +++++----- tools/age_opcodes_himegari.py | 2 +- vm-map/opcodes.toml | 14 +++++++------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 1817b1a..5e36dd7 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -34,11 +34,6 @@ - **grounding:** source=inference, confidence=low - **evidence:** confirm via frida -### 0x215 `count?` (u00421160, argc 2) -- **summary:** 2 args -> writes global then result tested >0 (gre/lt) — count/search-returns-index helper -- **grounding:** source=inference, confidence=med -- **evidence:** confirm via unicorn - ## draw ### 0x1f7 `ui-elem?` (u00420270, argc 2) @@ -86,6 +81,11 @@ - **grounding:** source=inference, confidence=med - **evidence:** SC0000 label_12649: set-texture(resId,slot) then 0x208(slot)->w,h feeds w/2 horizontal-center + foot-anchor subtraction into draw-texture dst; stubbing yields 0x0 sizes / off-center draws +### 0x215 `query-gfx-object?` (u00421160, argc 2) +- **summary:** 0x215 (out)(handle_id) — queries the native graphics-object manager by element handle-id (the value in 0x62455[idx], often +1/+2 for a sub-element); writes the object's slot/status into `out`, sign-tested (gre/lt 0) to drive label_12649's slot-select branch and set the working slot G[0x62452]. KEYSTONE for per-object slot selection — stubbing it collapses every draw onto slot 0, so the anchor-preserve geometry reads foreign-sized textures → cumulative bg/sprite drift (see docs/phase-a-slice-plan.md A2b-Geometry). Reads native object-manager state (NOT VM-computable). Exact return semantics: RE via unicorn (native handler @0x421160). +- **grounding:** source=investigation, confidence=med +- **evidence:** SC0000 label_12649 (0x12670) + label_123ef (0x12419/0x12450): called with 0x62455[idx] handle-ids (±offset); result gre/lt 0 branches slot-select and feeds ui-elem?(0x1f7)/set-texture slot. Record table 0x3239 (label_125bd @0x0050f) assigns per-object slots 4..13. Handles are the 0xcf08/0xe678/0xd6d8 element-id family. + ### 0x217 `gfx-geom?` (u004211E0, argc 4) - **summary:** 4 global-ints; part of a 0x217/0x218/0x21a geometry chain - **grounding:** source=inference, confidence=low diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index 9c886cf..e8e7696 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -21,7 +21,7 @@ INFERRED: dict[int, dict] = { 0x1ff: dict(name='draw?', category='draw', noop=False, confidence='low', source='inference', summary='4 args (global+imms); follows 0x217, then call'), 0x202: dict(name='draw-blit?', category='draw', noop=False, confidence='med', source='inference', summary='5 args (coords/sizes); preceded by coord arithmetic, near draw ops'), 0x203: dict(name='draw?', category='draw', noop=False, confidence='med', source='inference', summary='4 args; chains with 0x202/draw-texture'), - 0x215: dict(name='count?', category='compute', noop=False, confidence='med', source='inference', summary='2 args -> writes global then result tested >0 (gre/lt) — count/search-returns-index helper'), + 0x215: dict(name='query-gfx-object?', category='draw', noop=False, confidence='med', source='investigation', summary="0x215 (out)(handle_id) — queries the native graphics-object manager by element handle-id (the value in 0x62455[idx], often +1/+2 for a sub-element); writes the object's slot/status into `out`, sign-tested (gre/lt 0) to drive label_12649's slot-select branch and set the working slot G[0x62452]. KEYSTONE for per-object slot selection — stubbing it collapses every draw onto slot 0, so the anchor-preserve geometry reads foreign-sized textures → cumulative bg/sprite drift (see docs/phase-a-slice-plan.md A2b-Geometry). Reads native object-manager state (NOT VM-computable). Exact return semantics: RE via unicorn (native handler @0x421160)."), 0x217: dict(name='gfx-geom?', category='draw', noop=False, confidence='low', source='inference', summary='4 global-ints; part of a 0x217/0x218/0x21a geometry chain'), 0x218: dict(name='gfx-geom?', category='draw', noop=False, confidence='low', source='inference', summary='4 global-ints; chains with 0x21a/0x217'), 0x21a: dict(name='gfx-geom?', category='draw', noop=False, confidence='low', source='inference', summary='4 global-ints; chains with 0x218/0x217'), diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 3ebe58b..928eaf4 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -5227,23 +5227,23 @@ argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "count?" -category = "compute" -summary = "2 args -> writes global then result tested >0 (gre/lt) — count/search-returns-index helper" +name = "query-gfx-object?" +category = "draw" +summary = "0x215 (out)(handle_id) — queries the native graphics-object manager by element handle-id (the value in 0x62455[idx], often +1/+2 for a sub-element); writes the object's slot/status into `out`, sign-tested (gre/lt 0) to drive label_12649's slot-select branch and set the working slot G[0x62452]. KEYSTONE for per-object slot selection — stubbing it collapses every draw onto slot 0, so the anchor-preserve geometry reads foreign-sized textures → cumulative bg/sprite drift (see docs/phase-a-slice-plan.md A2b-Geometry). Reads native object-manager state (NOT VM-computable). Exact return semantics: RE via unicorn (native handler @0x421160)." noop_headless = false -source = "inference" +source = "investigation" confidence = "med" depends_on = [] -evidence = "confirm via unicorn" +evidence = "SC0000 label_12649 (0x12670) + label_123ef (0x12419/0x12450): called with 0x62455[idx] handle-ids (±offset); result gre/lt 0 branches slot-select and feeds ui-elem?(0x1f7)/set-texture slot. Record table 0x3239 (label_125bd @0x0050f) assigns per-object slots 4..13. Handles are the 0xcf08/0xe678/0xd6d8 element-id family." [[opcode.semantics.args]] i = 1 -role = "" +role = "out_slot_status" observed_types = ["g-int", "l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "handle_id" observed_types = ["imm", "g-int", "l-int", "l-ptr"] [[opcode]] From 447649d381b738eb2575eee29ec7e7c719758d80 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 6 Jul 2026 23:29:37 -0400 Subject: [PATCH 2/7] docs: SYS4AB.BIN = XOR-0xFF(AGE.EXE), same packed binary (dead end for static RE) Decrypted SYS4AB: 0x2c header (S4AB magic + 0x10E000 size + 8-byte key) then XOR-0xFF payload == AGE.EXE byte-for-byte. Not a patched/unpacked VM. Real handler code exists only unpacked in the runtime heap (0x62411000). Corrects the 'likely the patched VM' assumption. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/vm-mapping-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/vm-mapping-plan.md b/docs/vm-mapping-plan.md index 3d65131..6a9a887 100644 --- a/docs/vm-mapping-plan.md +++ b/docs/vm-mapping-plan.md @@ -148,6 +148,6 @@ Unicorn is a bare CPU emulator (no OS). It is the **better** tool for the *pure- ## Appendix — `AGE.EXE` is packed (relevant to Phase 3.4, and as the image source for 3.2) -Verified this session (`tools/pack_check.py`): 32-bit PE, code sections at max entropy (8.00), blank section names, IAT RVA 0, no plaintext anchors. `SYS4AB.BIN` (magic `S4AB`, entropy 7.94) is a second encrypted engine image whose header stores `AGE.EXE`'s exact size (`0x0010E000`) — likely the patched VM the loader maps. +Verified this session (`tools/pack_check.py`): 32-bit PE, code sections at max entropy (8.00), blank section names, IAT RVA 0, no plaintext anchors. **`SYS4AB.BIN` (magic `S4AB`) is a dead end for static analysis — it decrypts to `AGE.EXE` byte-for-byte** (2026-07-06): 0x2c-byte header (`"S4AB"` + version + `0x0010E000` size dword ×3 + an 8-byte key/hash field), then the payload is a trivial **XOR-`0xFF`** of the *same packed* `AGE.EXE` (`bytes(x^0xFF for x in payload) == AGE.EXE`, exact). So it is **not** a patched/unpacked VM — both on-disk engine images are the identical packed binary, and VA `0x421160` (any handler) is entropy-8.00 garbage in both. The real handler code exists **only unpacked in the runtime heap** (`~30 MB r-x @ 0x62411000`, nonstable base per run — see `docs/global-memory-re.md`). Static Unicorn/Ghidra therefore requires a **runtime dump** of that region, or hook it live. Static analysis therefore requires a **runtime dump first** — you're dumping for *analysis* not redistribution, so don't chase OEP: launch to the title screen (Japanese locale required), then dump the decrypted image and load it in Ghidra. Tools: **PE-sieve** (CLI, agent-drivable: `pe-sieve.exe /pid /imp 3`) or **x32dbg + Scylla + ScyllaHide** (GUI, handles the anti-debug). Validate the dump by confirming `SYS4422`/`.BIN`/`DATA1` now appear in plaintext. **But prefer Frida dynamic hooking (Phase 3.1) — it avoids the unpack entirely.** From fdd5bd061359f1e8acfe00baa56509fb82a9fa10 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 6 Jul 2026 23:33:02 -0400 Subject: [PATCH 3/7] =?UTF-8?q?feat(frida):=20dump=5Fengine.py=20=E2=80=94?= =?UTF-8?q?=20dump=20unpacked=20engine=20code=20from=20live=20process?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both on-disk engine images are the same packed binary, so native handler code exists only unpacked in memory. dump_engine attaches, enumerates ranges, and dumps the AGE.EXE module + large r-x heap regions (chunked) to build/engine-dump/ for offline disassembly (locate 0x215 @ VA 0x421160 via the dispatch table). Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/frida/README.md | 8 +++ tools/frida/dump_engine.py | 142 +++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 tools/frida/dump_engine.py diff --git a/tools/frida/README.md b/tools/frida/README.md index 819b678..3da5d14 100644 --- a/tools/frida/README.md +++ b/tools/frida/README.md @@ -21,6 +21,14 @@ Prereq: `py -3.11 -m pip install frida` (core only — `frida-tools` CLI is not ## Tools +- **`dump_engine.py`** — ★ dumps the **unpacked engine code** from the live process for offline static + RE (native op handlers). Both on-disk images are the same packed binary (`SYS4AB.BIN` = XOR-0xFF of + `AGE.EXE`), so the real handler code exists only in memory: the `AGE.EXE` module (some code unpacked + in-place, e.g. the AGF decoder `+0x74f1f`) plus the main VM interpreter in a large per-run heap `r-x` + region (~30 MB, nonstable base). Attach → it enumerates ranges, dumps the module image + every r-x + range ≥ 1 MB (chunked) → `build/engine-dump/{manifest.json,range_.bin}`, and prints the + landmark bytes at `AGE.EXE+0x74f1f` to validate. Then disassemble (capstone) and locate a handler + (e.g. `0x215` @ Kelebek VA `0x421160`) via the opcode dispatch table. Attach by pid. - **`capture_load_order.py`** — ★ the working asset-resolution capture. Hooks `ReadFile` on `DATA2.ALF`; each asset load starts with header reads **at its exact archive offset**, so exact-start reads give the clean per-asset **load order** (→ names via `build/asset-index.json`). `--analyze` diff --git a/tools/frida/dump_engine.py b/tools/frida/dump_engine.py new file mode 100644 index 0000000..a683754 --- /dev/null +++ b/tools/frida/dump_engine.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Dump the UNPACKED engine code from the live game so we can statically disassemble native op +handlers (docs/vm-mapping-plan.md appendix; docs/global-memory-re.md). + +Why: both on-disk engine images are the SAME packed binary — SYS4AB.BIN decrypts (XOR-0xFF) to +AGE.EXE byte-for-byte, and AGE.EXE's code sections are entropy-8.00 packed garbage. The real handler +code exists only after the packer runs, in memory: AGE.EXE has some unpacked code in-place (e.g. the +AGF decoder at AGE.EXE+0x74f1f) AND the main VM interpreter runs from a large per-run heap r-x region +(~30 MB, nonstable base). So to RE a handler (e.g. 0x215 @ Kelebek VA 0x421160) we dump these regions +live, then disassemble offline and locate the handler via the opcode dispatch table. + +Flow: launch the game (via `AGE Patch.exe`, JP locale) to the title -> attach -> this dumps -> exit. +Output: build/engine-dump/manifest.json + range_.bin per dumped region (build/ is gitignored). + +Usage: py -3.11 -u -X utf8 tools/frida/dump_engine.py [pid|AGE.EXE] +""" +import json +import sys +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +OUTDIR = REPO / "build" / "engine-dump" + +# Dump every r-x range (code), plus the AGE.EXE module image in full. rw- ranges are only listed in +# the manifest (native object-manager state; snapshot later if a handler needs it). CHUNK keeps each +# frida message small; 2 MB is comfortably under the default limits. +CHUNK = 2 * 1024 * 1024 + +JS = r""" +const CHUNK = %d; + +function ranges(prot){ return Process.enumerateRanges(prot).map(r => ({ + base: r.base.toString(), size: r.size, prot: r.protection, + file: r.file ? r.file.path : null })); } + +function dump(baseStr, size, tag){ + const base = ptr(baseStr); + for (let off = 0; off < size; off += CHUNK){ + const n = Math.min(CHUNK, size - off); + let buf; + try { buf = Memory.readByteArray(base.add(off), n); } + catch(e){ send({kind:'gap', base:baseStr, off:off, n:n, err:''+e}); continue; } + send({kind:'chunk', base:baseStr, off:off, n:n, tag:tag}, buf); + } + send({kind:'done', base:baseStr, size:size, tag:tag}); +} + +const mod = Process.getModuleByName('AGE.EXE'); +const rx = ranges('r-x'); +const rw = ranges('rw-'); + +// landmark check: the AGF decoder is documented at AGE.EXE+0x74f1f (stable, unpacked in-place) +let landmark = null; +try { landmark = Memory.readByteArray(mod.base.add(0x74f1f), 16); } catch(e){} + +send({kind:'manifest', + age_base: mod.base.toString(), age_size: mod.size, + rx: rx, rw: rw}, landmark); + +// dump targets: the AGE.EXE module image, plus every r-x range >= 1 MB (the unpacked heap code) +dump(mod.base.toString(), mod.size, 'age-module'); +for (const r of rx){ + if (r.base === mod.base.toString()) continue; // module already covered + if (r.size >= 1*1024*1024) dump(r.base, r.size, 'rx-heap'); +} +send({kind:'alldone'}); +""" + + +def capture(proc): + import frida + OUTDIR.mkdir(parents=True, exist_ok=True) + files = {} # base -> open file handle + manifest = {} + done = {"flag": False} + + def fh(base): + if base not in files: + files[base] = open(OUTDIR / f"range_{int(base, 16):08x}.bin", "wb") + return files[base] + + def on_message(msg, data): + if msg.get("type") == "error": + print("[frida-error]", msg.get("description")); return + if msg.get("type") != "send": + return + pl = msg["payload"] + k = pl.get("kind") + if k == "manifest": + manifest.update(pl) + lm = data.hex(" ") if data else "(unreadable)" + print(f"[frida] AGE.EXE base={pl['age_base']} size=0x{pl['age_size']:x}") + print(f"[frida] landmark AGE.EXE+0x74f1f = {lm}") + print(f"[frida] r-x ranges: {len(pl['rx'])} rw- ranges: {len(pl['rw'])}") + for r in pl["rx"]: + mark = " <-- dump" if (r["size"] >= 1 << 20 or r["base"] == pl["age_base"]) else "" + print(f" r-x {r['base']} size=0x{r['size']:x} {r.get('file') or ''}{mark}") + elif k == "chunk": + f = fh(pl["base"]); f.seek(pl["off"]); f.write(data) + elif k == "gap": + print(f" [gap] {pl['base']}+0x{pl['off']:x} n=0x{pl['n']:x} {pl['err']}") + elif k == "done": + print(f"[frida] dumped {pl['tag']} {pl['base']} (0x{pl['size']:x} bytes)") + elif k == "alldone": + done["flag"] = True + + target = int(proc) if str(proc).isdigit() else proc + try: + session = frida.attach(target) + except (frida.ProcessNotFoundError, frida.ServerNotRunningError): + procs = frida.get_local_device().enumerate_processes() + hits = [(p.pid, p.name) for p in procs if "age" in p.name.lower()] + print("[frida] AGE.EXE not found. Running AGE-like processes:", hits or "(none)") + print(" Launch the game (AGE Patch.exe, JP locale) to the title, then re-run.") + return 2 + + script = session.create_script(JS % CHUNK) + script.on("message", on_message) + script.load() + print(f"[frida] attached to {proc}; dumping engine code -> {OUTDIR}") + try: + for _ in range(600): # up to ~60 s; exits early on alldone + if done["flag"]: + break + time.sleep(0.1) + except KeyboardInterrupt: + pass + for f in files.values(): + f.close() + if manifest: + (OUTDIR / "manifest.json").write_text( + json.dumps({k: v for k, v in manifest.items() if k != "type"}, + ensure_ascii=False, indent=1), encoding="utf-8") + print(f"\n[frida] wrote {len(files)} range file(s) + manifest.json to {OUTDIR}") + print(" Next: disassemble (capstone) and locate the 0x215 handler via the dispatch table.") + return 0 if done["flag"] else 1 + + +if __name__ == "__main__": + proc = next((a for a in sys.argv[1:] if not a.startswith("-")), "AGE.EXE") + sys.exit(capture(proc)) From d85a6003cc205c676c18152c3a7520ddedb1e997 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 6 Jul 2026 23:43:54 -0400 Subject: [PATCH 4/7] =?UTF-8?q?fix(frida):=20dump=5Fengine=20=E2=80=94=20u?= =?UTF-8?q?se=20ptr.readByteArray=20(frida=2017),=20skip=20system=20DLLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dump validated: AGE.EXE is unpacked in-place at 0x400000 in memory (AGF-decoder landmark @0x474f1f reads real code with the 'BM' 0x4D42 check). Kelebek handler VAs map directly (VA-0x400000 = file offset). Handler ABI: thiscall (esi=engine context), operands fetched via call 0x41b940, per-object command-type table at [esi+idx*120+0x53d88]. 0x215 is part of a native gfx command-buffer manager. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/frida/dump_engine.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tools/frida/dump_engine.py b/tools/frida/dump_engine.py index a683754..9ea5c95 100644 --- a/tools/frida/dump_engine.py +++ b/tools/frida/dump_engine.py @@ -39,8 +39,9 @@ function dump(baseStr, size, tag){ for (let off = 0; off < size; off += CHUNK){ const n = Math.min(CHUNK, size - off); let buf; - try { buf = Memory.readByteArray(base.add(off), n); } + try { buf = base.add(off).readByteArray(n); } // frida 17: method on the pointer catch(e){ send({kind:'gap', base:baseStr, off:off, n:n, err:''+e}); continue; } + if (buf === null){ send({kind:'gap', base:baseStr, off:off, n:n, err:'null'}); continue; } send({kind:'chunk', base:baseStr, off:off, n:n, tag:tag}, buf); } send({kind:'done', base:baseStr, size:size, tag:tag}); @@ -52,16 +53,18 @@ const rw = ranges('rw-'); // landmark check: the AGF decoder is documented at AGE.EXE+0x74f1f (stable, unpacked in-place) let landmark = null; -try { landmark = Memory.readByteArray(mod.base.add(0x74f1f), 16); } catch(e){} +try { landmark = mod.base.add(0x74f1f).readByteArray(16); } catch(e){} send({kind:'manifest', age_base: mod.base.toString(), age_size: mod.size, rx: rx, rw: rw}, landmark); -// dump targets: the AGE.EXE module image, plus every r-x range >= 1 MB (the unpacked heap code) +// dump targets: the AGE.EXE module image, plus every ANONYMOUS r-x range >= 1 MB (the unpacked heap +// code). Skip file-backed r-x ranges — those are Windows system DLLs, not the engine. dump(mod.base.toString(), mod.size, 'age-module'); for (const r of rx){ if (r.base === mod.base.toString()) continue; // module already covered + if (r.file) continue; // skip system DLLs if (r.size >= 1*1024*1024) dump(r.base, r.size, 'rx-heap'); } send({kind:'alldone'}); From eee070722abea7f949b840c621052daa9357419a Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 6 Jul 2026 23:51:18 -0400 Subject: [PATCH 5/7] =?UTF-8?q?feat(frida):=20probe=5Fhandlers=20=E2=80=94?= =?UTF-8?q?=20confirms=20interpreter=20runs=20from=20module=200x400000?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit operand-fetch (call 0x41b940) fires ~8500/sec => the VM interpreter executes from the in-place unpacked module at 0x400000 (NOT the heap copy) => handlers are hookable by dump address. gfx-family(0x212-0x215)=0 at the title (no CG commands until a scene runs). Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/frida/probe_handlers.py | 74 +++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tools/frida/probe_handlers.py diff --git a/tools/frida/probe_handlers.py b/tools/frida/probe_handlers.py new file mode 100644 index 0000000..2a29eed --- /dev/null +++ b/tools/frida/probe_handlers.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Probe: does the engine execute opcode handlers from the in-place module image (0x400000) or the +per-run heap copy (0x62411000)? Determines where to hook for the gfx-object-manager RE. + +Hooks (module-relative offsets): + +0x74f1f AGF decoder (KNOWN stable control — fires on every AGF/texture load) + +0x1b940 operand-fetch helper (call 0x41b940 in handlers — fires per operand) + +0x21090 the 0x212..0x215 gfx-command family entry +Reports per-hook fire counts each second. Drive the game (start a new game / click the opening, +which loads CGs) to generate graphics activity. + +Usage: py -3.11 -u -X utf8 tools/frida/probe_handlers.py [pid|AGE.EXE] +""" +import sys +import time + +JS = r""" +const mod = Process.getModuleByName('AGE.EXE'); +const targets = { 'agf-decoder@+74f1f': 0x74f1f, 'operand-fetch@+1b940': 0x1b940, 'gfx-family@+21090': 0x21090 }; +const counts = {}; +for (const [name, off] of Object.entries(targets)){ + counts[name] = 0; + try { + Interceptor.attach(mod.base.add(off), { + onEnter(args){ counts[name]++; } + }); + send({kind:'hooked', name:name, addr: mod.base.add(off).toString()}); + } catch(e){ send({kind:'hookfail', name:name, err:''+e}); } +} +setInterval(() => send({kind:'counts', counts: counts}), 1000); +send({kind:'ready', base: mod.base.toString()}); +""" + + +def main(): + import frida + proc = next((a for a in sys.argv[1:] if not a.startswith("-")), "AGE.EXE") + + def on_message(msg, data): + if msg.get("type") == "error": + print("[frida-error]", msg.get("description")); return + if msg.get("type") != "send": + return + pl = msg["payload"] + k = pl.get("kind") + if k == "ready": + print(f"[frida] AGE.EXE base={pl['base']} — drive the game (new game / click opening) now.") + elif k == "hooked": + print(f"[frida] hooked {pl['name']} @ {pl['addr']}") + elif k == "hookfail": + print(f"[frida] HOOK FAILED {pl['name']}: {pl['err']}") + elif k == "counts": + print(" " + " ".join(f"{n}={c}" for n, c in pl["counts"].items())) + + target = int(proc) if str(proc).isdigit() else proc + try: + session = frida.attach(target) + except (frida.ProcessNotFoundError, frida.ServerNotRunningError): + hits = [(p.pid, p.name) for p in frida.get_local_device().enumerate_processes() + if p.name.upper().startswith("AGE")] + print("[frida] AGE.EXE not found. AGE* processes:", hits or "(none)") + return 2 + script = session.create_script(JS) + script.on("message", on_message) + script.load() + try: + time.sleep(30) + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 896bea977b4fab135da6686409f5769f2c07c079 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 7 Jul 2026 00:04:49 -0400 Subject: [PATCH 6/7] =?UTF-8?q?feat(frida):=20capture=5Fgfx=5Fobjects=20+?= =?UTF-8?q?=20finding=20=E2=80=94=20drift=20is=20state-divergence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live capture (esi=engine ctx via operand-fetch, poll object-record array [esi+0x53d64] stride 120). KEY FINDING: through the full real opening, the record array holds only 3 persistent UI objects — NO CG objects. The real game does NOT draw opening CGs via the 0x212-0x21a positioned-object path our headless VM uses; with state it takes a different (direct) branch. So the bg/sprite drift is a STATE-DIVERGENCE artifact of the unseeded headless VM, not a missing native op — the fix is the Phase B state/choices flow (makes label_12649 take the if-branch). Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/frida/capture_gfx_objects.py | 107 +++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tools/frida/capture_gfx_objects.py diff --git a/tools/frida/capture_gfx_objects.py b/tools/frida/capture_gfx_objects.py new file mode 100644 index 0000000..ac2f7a4 --- /dev/null +++ b/tools/frida/capture_gfx_objects.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Capture the native graphics object-manager state, to RE the slot/geometry the engine assigns each +object (the ground truth behind the drift; docs/phase-a-slice-plan.md A2b). + +Root cause recap: our VM stubs 0x215 (native gfx-object query) so every draw collapses onto slot 0. +The real per-object slot/geometry lives in a native object-record array inside the engine context. + +Robust anchoring: individual gfx handler entry addresses (Kelebek) are approximate (mid-instruction), +so hooking them is unreliable. Instead we hook the ONE confirmed-firing address — the operand-fetch +helper at module +0x1b940 (thiscall; ecx = the engine context 'esi') — grab the context pointer once, +then POLL the object-record array directly: [esi + 0x53d64], stride 120 (0x78) bytes/object, command- +type field at record+0x24, current index at [esi + 0x53d14]. No dependence on fragile handler offsets. + +Decode offline + correlate with `Age.Cli gfx SC0000.BIN` (objects load EV049AA/EV052*/BG030A/... with +slots 4..13 per the 0x3239 record table). Poll snapshots over time show how records change per scene. + +Flow: attach while a scene is up (or replay the opening) -> polls ~2/s -> Ctrl-C -> build/gfx-objects.jsonl. +Usage: py -3.11 -u -X utf8 tools/frida/capture_gfx_objects.py [pid|AGE.EXE] [seconds] +""" +import json +import sys +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +OUT = REPO / "build" / "gfx-objects.jsonl" + +OPFETCH_OFF = 0x1b940 # operand-fetch helper (call 0x41b940) — confirmed firing; ecx = context +REC_BASE = 0x53d64 # object-record array offset within the engine context (esi) +REC_STRIDE = 120 # bytes per object record +IDX_OFF = 0x53d14 # current object index +N_RECORDS = 20 # poll the first N object records + +JS = r""" +const mod = Process.getModuleByName('AGE.EXE'); +const OPFETCH = mod.base.add(%d); +const REC_BASE = %d, REC_STRIDE = %d, IDX_OFF = %d, N = %d; +let ctx = null; +const h = Interceptor.attach(OPFETCH, { + onEnter(args){ + if (ctx === null){ ctx = this.context.ecx; send({kind:'ctx', esi: ctx.toString()}); } + } +}); +function poll(){ + if (ctx === null){ send({kind:'wait'}); return; } + let idx = -1, recs = null; + try { idx = ctx.add(IDX_OFF).readS32(); } catch(e){} + try { recs = ctx.add(REC_BASE).readByteArray(N * REC_STRIDE); } catch(e){} + send({kind:'snap', idx:idx}, recs); +} +setInterval(poll, 450); +send({kind:'ready', base: mod.base.toString(), opfetch: OPFETCH.toString()}); +""" + + +def main(): + import frida + args = [a for a in sys.argv[1:] if not a.startswith("-")] + proc = args[0] if args else "AGE.EXE" + secs = int(args[1]) if len(args) > 1 else 20 + OUT.parent.mkdir(parents=True, exist_ok=True) + log = open(OUT, "w", encoding="utf-8") + st = {"n": 0, "ctx": None} + + def on_message(msg, data): + if msg.get("type") == "error": + print("[frida-error]", msg.get("description")); return + if msg.get("type") != "send": + return + pl = msg["payload"] + k = pl.get("kind") + if k == "ready": + print(f"[frida] hooked operand-fetch @ {pl['opfetch']}; grabbing context + polling records.") + elif k == "ctx": + st["ctx"] = pl["esi"]; print(f"[frida] engine context esi = {pl['esi']}") + elif k == "wait": + pass + elif k == "snap": + rec = {"idx": pl["idx"], "esi": st["ctx"], "records_hex": data.hex() if data else None} + log.write(json.dumps(rec) + "\n"); log.flush() + st["n"] += 1 + # quick view: current idx + that record's first 40 bytes + if data and 0 <= pl["idx"] < N_RECORDS: + r = data[pl["idx"]*REC_STRIDE:(pl["idx"]+1)*REC_STRIDE] + print(f" snap#{st['n']} idx={pl['idx']} rec[:40]={r[:40].hex(' ')}") + else: + print(f" snap#{st['n']} idx={pl['idx']} (no rec)") + + target = int(proc) if str(proc).isdigit() else proc + try: + session = frida.attach(target) + except (frida.ProcessNotFoundError, frida.ServerNotRunningError): + hits = [(p.pid, p.name) for p in frida.get_local_device().enumerate_processes() + if p.name.upper().startswith("AGE")] + print("[frida] AGE.EXE not found. AGE* processes:", hits or "(none)") + return 2 + script = session.create_script(JS % (OPFETCH_OFF, REC_BASE, REC_STRIDE, IDX_OFF, N_RECORDS)) + script.on("message", on_message) + script.load() + print(f"[frida] attached to {proc}; polling {secs}s -> {OUT}") + time.sleep(secs) + print(f"\n[frida] done. {st['n']} snapshots -> {OUT}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 31a5423ad2cb58a18098d8d625c90bb43188eac6 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 7 Jul 2026 00:10:45 -0400 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20update=20living=20refs=20=E2=80=94?= =?UTF-8?q?=20engine-dump=20tooling,=20gfx=20drift=20=3D=20state-divergenc?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools-reference: dump_engine/probe_handlers/capture_gfx_objects + SYS4AB note. phase-a-slice-plan: post-opening drift RESOLVED as a state-divergence artifact (fix = Phase B state flow, not a native-op subsystem). PROJECT-STRUCTURE: build/{textures,engine-dump}, engine/, tools/frida/. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/PROJECT-STRUCTURE.md | 7 ++++++- docs/phase-a-slice-plan.md | 32 ++++++++++++++++++++++---------- docs/tools-reference.md | 7 ++++++- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index fedab33..248ee30 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -75,11 +75,16 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings) │ │ └── strings.jsonl every string, tagged by source opcode │ ├── data/ parsed data tables (*INIT → JSON) │ ├── scripts-json/ machine-readable full dumps (on demand via --json) + │ ├── textures/ AGF→BMP stills (convert_agf.py) — feeds the Godot render + │ ├── engine-dump/ UNPACKED engine dump (frida/dump_engine.py): range_.bin + manifest.json + │ ├── asset-index.json, asset-sections.json asset resolver data (parse_sys4ini / resolve_asset) │ ├── global-var-map.{json,md} partial global-variable name map │ ├── opcodes.json GENERATED from opcodes.toml (machine view for the C# VM) │ └── manifest.json, opcode-coverage.md (opcode-coverage.md GENERATED from opcodes.toml) │ - └── godot/ DELIVERABLE — the Godot/C# engine project (built in Phase A2+) + ├── engine/ DELIVERABLE — the .NET VM core (AgeEngine.sln: Age.Engine / Age.Cli / tests) + ├── tools/frida/ runtime-capture + engine-dump scripts (see tools/frida/README.md) + └── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine) ``` ## Conventions diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 8bba758..640a87a 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -268,16 +268,28 @@ measured `0×0`, and the anchor-preserve math (`base' = center − (w_new/2, h_n corruption. Fix: seed `_slotDims[0] = (800,600)` (and record `create-texture(w,h)` dims) so the first CG's anchor stays an identity. This is the faithful stand-in for the skipped boot-time primary-surface creation. -**DEFERRED (next chunk) — the sprite/background anchor-record subsystem.** Everything blits through slot 0 -as an immediate-mode canvas; the anchor-preserve base globals **accumulate drift** across textures of -*different* sizes (same-size 800×600 CGs stay put; the first `BG*` 800×500 / `AE*` 800×800 / sprite starts -a drift that accumulates — `BG030A→(300,500)`, next→`(450,100)`, →`(800,350)`…, marching bottom-right). -The real engine doesn't drift because it stores each element's geometry in a **per-object record** via -`0x217/0x218/0x21a` (currently no-op) and restores it (the `0x12683` if-branch reading the `0x3239` record -table). Implementing that store/restore (+ the record layout, likely Frida-confirmed) is the fix for -backgrounds **and** sprites together. Fades/alpha (`AE*`, `0x202/0x203`) and green chromakey remain -deferred as before (the compositor is built to accept alpha later). Also out: true multi-surface (dest -handle is collapsed onto the screen). The full-screen opening path is unaffected by any of these. +**Post-opening bg/sprite drift — RESOLVED as a STATE-DIVERGENCE artifact, NOT a missing native op +(2026-07-06/07).** Symptom: everything blits through slot 0 as an immediate-mode canvas; the anchor-preserve +base globals **accumulate drift** across differently-sized textures (`BG030A→(300,500)`, next→`(450,100)`, +→`(800,350)`… marching bottom-right). We reverse-engineered the whole chain (systematic-debugging): +1. Root cause traced to **`0x215` = native graphics-object query** (opcodes.toml `query-gfx-object?`), which we + stub → `label_12649` takes the wrong branch → all draws collapse onto slot 0 → anchor-preserve reads foreign + textures → drift. +2. **Engine now statically analyzable (major, general unlock):** `SYS4AB.BIN` = `XOR-0xFF(AGE.EXE)` (dead end), + but `AGE.EXE` unpacks **in-place at 0x400000** in the live process → `tools/frida/dump_engine.py` → + `build/engine-dump/` (validated via AGF-decoder landmark; interpreter confirmed to run from the module, so + handlers are hookable). Handler ABI + object-record layout (`[esi+0x53d64]`, 120B/rec, cmd-type at rec+0x24) + decoded. See `docs/vm-mapping-plan.md` appendix + `tools/frida/README.md`. +3. **Live capture verdict (the resolution):** `tools/frida/capture_gfx_objects.py` polled the object-record + array through the **real** opening — it held only **3 persistent UI objects, ZERO CG objects**. So the real + game does **not** draw the opening CGs via the `0x212–0x21a` positioned-object path our headless VM uses; + with proper state it takes a different (direct) branch that we already render correctly. **⇒ the drift is + downstream of our unseeded headless VM taking `label_12649`'s else-branch (compute-from-drifting-base) where + the real game hits the if-branch (stored/record geometry). The fix is the Phase B state/choices-flow work, + not a separate native-op subsystem.** Seeding real per-scene/object state makes `label_12649` branch right. +Fades/alpha (`AE*`, `0x202/0x203`) + green chromakey + true multi-surface remain deferred; the compositor is +built to accept alpha later. The full-screen opening path is correct and unaffected. **Native gfx-op modeling +is only needed for scenes that genuinely use runtime-positioned sprites — revisit later with the dump in hand.** --- diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 9c339f0..5724b18 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -78,10 +78,15 @@ All opcode knowledge (ABI, semantics, provenance, `depends_on`) is hand-edited * | `tools/frida/capture_resid_args.py` | Phase-2 probe: dumps the decoder's args / context / caller frame (established the loader carries only offsets, not names). | `py -3.11 -u -X utf8 tools/frida/capture_resid_args.py [pid]` · `--analyze` | running game → `build/frida-resid-args.jsonl` | | `tools/frida/find_globals_base.py` | Runtime-global RE (SHELVED — see `docs/global-memory-re.md`): flat-int32 signature scan for the VM global array. Finds nothing → layout isn't flat. | `--build-sig` · `py -3.11 -u -X utf8 tools/frida/find_globals_base.py [pid]` | `*INIT` → `build/globals-signature.json`; scans running game | | `tools/frida/find_global_by_sequence.py` | Runtime-global RE (SHELVED): differential resId value-scan + stability filter. Finds stack proxies; proved `G[0x62424]` is a transient arg-register. | `py -3.11 -u -X utf8 tools/frida/find_global_by_sequence.py [pid]` | running game + index → stdout | +| `tools/frida/dump_engine.py` | ★ **Dump the UNPACKED engine code** from the live process for offline static RE (native handlers). `AGE.EXE` unpacks in-place at `0x400000`; Kelebek VAs map `VA−0x400000` = file-off. Validated via the AGF-decoder landmark `+0x74f1f`. | `py -3.11 -u -X utf8 tools/frida/dump_engine.py [pid]` | running game → `build/engine-dump/{manifest.json,range_.bin}` | +| `tools/frida/probe_handlers.py` | Probe which region the interpreter executes from (module vs heap). Confirmed: **operand-fetch `+0x1b940` fires ~8500/s ⇒ interpreter runs from the module `0x400000`** (handlers hookable by dump address). | `py -3.11 -u -X utf8 tools/frida/probe_handlers.py [pid]` | running game → stdout (per-hook fire counts) | +| `tools/frida/capture_gfx_objects.py` | Capture the native gfx object-manager state: grab engine ctx (`esi` via operand-fetch `ecx`), poll the object-record array `[esi+0x53d64]` (20×120B; `field[0]=0xffffffff`=free, cmd-type at rec+0x24). **Finding: the real opening uses ZERO CG records ⇒ the bg/sprite drift is a state-divergence artifact, not a missing op.** | `py -3.11 -u -X utf8 tools/frida/capture_gfx_objects.py [pid] [secs]` | running game → `build/gfx-objects.jsonl` | + +*(Static disassembly of `build/engine-dump/range_00400000.bin` uses **capstone** — `py -3.11 -m pip install capstone`; VA `X` → file offset `X−0x400000`.)* ## Historical / one-off | Tool | Purpose | |---|---| | `probe_*.py` (`probe_header`, `probe_leads`, `probe_refs`, `probe_tables`, `probe_tags`, `probe_types`, `probe_xref`) | Container/opcode format-RE probes used to reverse the format originally. Kept for reproducibility; not part of the normal workflow. | -| `pack_check.py` | Checks whether `AGE.EXE` is packed (it is). No longer a blocker — we run our own VM. | +| `pack_check.py` | Checks whether `AGE.EXE` is packed (it is: entropy-8 code sections, zeroed IAT). `SYS4AB.BIN` is NOT a separate image — it's `XOR-0xFF(AGE.EXE)` byte-for-byte (0x2c header + XOR payload). The unpacked engine exists only in memory → dump it with `frida/dump_engine.py`. |