Native-RE (Ghidra) cracked call-script <id> (opcode 0x03): its handler FUN_0041bc90 -> loader FUN_0040e980 -> resolver FUN_0044f390 indexes an 80-byte record table at base + id*0x50 == the SYS4INI record layout. So `call-script <id>` is a direct RAW index into the SYS4INI global file table (the asset index we already parse) -- there is no separate on-disk id->code registry. This resolves name-resolution.md #1, statically, no Frida. Confirmed: all 297 distinct corpus call-script ids resolve to a .BIN script with a semantically-exact name (0x1ab->ADDITEM, 0x2ae7->MES, 0x143->BUNKI, 0x329d->CALCREVISE), 0 out-of-range, 0 alternate-pack. Companion op 0x8f `call` is an intra-script JSR (FUN_0041fba0), not cross-script. - parse_sys4ini.py: preserve `raw_index` per entry (= the engine file id; index the RAW records incl. '@' placeholders) + emit build/callscript-names.json (id->name). - sys4load.py: annotate `call-script 0x1ab =ADDITEM.BIN`. - opcodes.toml 0x03/0x8f refined (source=investigation, confidence high, handler VAs) + rebuilt opcode-reference.md. - docs: engine-re.md (op 0x03 section + backlog re-aimed), name-resolution.md #1 (SOLVED), script-inventory.md (call graph + living-reference decision), tools-reference.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
13 KiB
Name resolution — recovering what the compiler stripped
The disassembler reads the SYS4 bytecode's operations and control flow cleanly (see any
build/disasm/*.asm). What it can't show is the two kinds of names the AGE compiler
discarded: which function a call targets (#1) and what a global variable means (#2).
Both are data-labeling problems, not decoding problems. This note records what each is, what
we found, and how tractable it is.
Motivating example: RECOVER.BIN translates to correct pseudocode today, but reads as
call-script 0x329d (#1) and C[unit][s] = E[unit][s] over raw addresses (#2). Naming those
would make it read like source.
#1 — call-script target resolution (naming the call graph) — ✅ SOLVED (2026-07-07)
RESOLVED via native-RE. call-script <id> is a direct RAW index into the SYS4INI file table —
the very asset index we already parsed. No hidden engine registry: SYS4INI is the registry. Cracked
by decompiling the handler chain in Ghidra (op 0x03 → FUN_0041bc90 → loader FUN_0040e980 →
resolver FUN_0044f390, which does record = table_base + id*0x50 over the 80-byte SYS4INI records).
Statically confirmed: all 297/297 distinct corpus call-script ids resolve to a .BIN script with
a semantically-exact name (0x1ab→ADDITEM, 0x2ae7→MES, 0x143→BUNKI), 0 out-of-range. Full
mechanism in engine-re.md (“op 0x03 (call-script)…”). Tooling: parse_sys4ini.py →
build/callscript-names.json (id→name); sys4load renders call-script 0x1ab =ADDITEM.BIN; the
build/disasm/*.asm call graph now reads by name. The one caveat: index the RAW SYS4INI records
(including the 2 @ placeholders) — asset-index.json carries each entry's raw_index (= the id)
for exactly this. Remaining (functional, not naming): the C# VM still stubs call-script
execution; implementing it (load .BIN by id, push frame, run, return) is the follow-up. The original
analysis (kept below for provenance) had concluded this was engine-level and deferred — it was, and
the Ghidra loop is what resolved it.
What it is (original framing). call-script N (Kelebek opcode 0x03) carries a bare number —
0x329d, 0x2ade — the id of an engine entry point. To render call RECOVER instead of
call-script 0x329d you need a table id → (script, entry).
Findings (inspected 2026-07-06):
SYSTEM4.BINis not an index — it's a small SYS4 script (375 instrs) titled "SYSTEM4 INIT", the engine boot/init routine (ADV mode, fonts, error text).SYS4INI.BIN(S4IC422) is the ALF asset index — archive filenames for extraction (SYSTEM4.BIN,M002.OGG,EV049A.AGF…), not a script-call registry.- The ids are large and sparse (
0x329d= 12,957 ≫ 481 scripts), so the number is an index into a global entry-point registry the engine builds, not a script-file index. - Even Kelebek's reference decompiler leaves these numeric (its comment only says "param = SYSTEM4.bin index"). So this is genuinely unresolved upstream, not merely unfinished.
Why it's engine-level (harder than a file lookup). There is no id → name table sitting
on disk to read. Resolving it needs one of:
DecodeRULED OUT as the registry (recon 2026-07-06).SCJUMP.BINSCJUMP.BIN(29,796 instrs) is a progression state machine, not an id→code table: it switches onglobal 0x3234(mode 1–9) then nestedeq/ne/and/jccon flags, ending inmovs to output globals. It decides what comes next via state; it barely usescall-script. Useful for game-flow logic, not for resolvingcall-scriptids. So the id→code registry is genuinely engine-level.- Watch the engine resolve one (Frida) — breakpoint the
call-scripthandler in the running game, logid → resolved address/script. Ground truth; Phase-3 (live-tools) work. - Find the registration path — if a boot script assigns ids to entry points, extract it statically (SYSTEM4.BIN is far too small to hold ~13k, so it's cumulative or lives in AGE.EXE).
Status: ✅ SOLVED (see the banner at the top of this section). It did belong with the engine/dispatch work — the Ghidra + MCP loop resolved it via the opcode-dispatch table.
Update (2026-07-07): SCJUMP's decision logic is now decoded — (chapter_mode, guards) → decision value — see docs/scjump-progression.md and tools/scjump_decode.py. That confirmed
SCJUMP is not the call-script registry (it produces a decision value, not a script id). Then the
Ghidra + MCP loop cracked call-script itself (the SOLVED banner above): via the opcode-dispatch
table it walked the handler → loader → resolver and found the id is a raw SYS4INI file index. What
remains of the earlier decision→scene question is now narrow: scenes are SCxxxx.BIN records loaded
through the same id-indexed loader, so the only open piece is where the SCJUMP decision value
becomes a scene id (a caller of SCJUMP). The u00428010 guess for that hop was disproven via Ghidra
(it's a graphics command-buffer op; see docs/engine-re.md).
#2 — The global-variable map (naming the data)
What it is. The VM has one flat global memory bank; the bytecode addresses it by raw
offset (global-int 0x152616, global-int 0x52383). Each offset is a specific piece of game
state (a unit's HP, the current-unit index, a stat table). The map we want is
offset → (name, type, structure).
Why it's opaque. No symbol table exists anywhere; meaning lives in how AGE.EXE and the scripts use each global. Nothing declares "0x152616 is the current unit."
Why a big chunk is recoverable statically (the tractable one). Unlike #1, #2 has strong free handholds — several of which we've already built:
- The
*INITscripts are the writers, and we already extracted them.EBINIT/ITINIT/SKINIT/CGINIT/MPINITpopulate global arrays with names and data (build/data/*.json). The base addressEBINITwrites 277 unit names into is the unit-name table. Each JSON'sname_array_base,desc_array_bases, andfield_columnsare literally global addresses we can label by which table wrote them. - Strings anchor the string side for free.
set-stringwrites skill names toglobal-string 0x23a3…→ that array is the skill-name table.*MEStables likewise. - Access shape reveals structure without names. A global read as
base[unit*stride + col]exposes a per-unit record and its width (RECOVER showed 14-, 3-, 30-column tables). A global used as the loop-invariant row index everywhere (0x152616) is a "current X" pointer. Constants-compared → mode/flag; only-incremented → counter. - Frida for the ambiguous ones (heavy, ground truth). Do a known action in-game (take damage, gain a level), watch which global changes → definitive labels. Reserve for leftovers.
Feasibility. A partial map — enough to make most gameplay scripts readable — is achievable now, statically, from methods 1–3. A complete map needs Frida for the tail. It's incremental: label the ~dozen hottest globals first (biggest readability payoff), grow the rest on demand.
Partial map — BUILT (v1, 2026-07-06). tools/global_map.py → build/global-var-map.json
(all evidence) + build/global-var-map.md (labelled subset). It ingests build/data/*.json
(name/desc/field bases), scans the 481-script corpus for each global's access shape
(2D-table base + stride, 1D-array base, row-index, scalar), and ranks "current entity" index
pointers by purity. First result: 16,354 of 49,435 distinct globals labelled —
| kind | count | example |
|---|---|---|
| string tables (names/descs/messages) | 3,199 | 0x23a3 = skill-name table |
| per-entity data-field arrays (from *INIT) | 12,700 | dense = shared fields, ? = sparse per-entity |
| row-major record tables (from access shape) | 122 | 0x52383 = record-table[stride 30] |
| 1D arrays | 307 | |
| index / "current entity" pointers | 26 | 0x152616 (purity 0.51), 0xeff75 (0.95) |
Validated against RECOVER: the map independently reproduces its hand-traced layout —
0x4e11b→stride 14, 0x52383→stride 30, 0xaacb4→1D array, 0x152616→current-entity index.
Wired into the disassembler. sys4load annotates global operands with the map's high/medium
-confidence labels (low-confidence tail omitted for readability), e.g. RECOVER now renders
lookup-array-2d p0 (global-int 0x4e11b =rec[s14]) (global-int 0x152616 =current-entity-index?) ….
Labels are prefixed = to mark them as inferred aliases. Regenerate the .asm corpus with
tools/extract_phase2.py after refreshing the map. Turn it off by deleting/renaming
build/global-var-map.json (the loader degrades gracefully).
Confidence is marked per entry; labels ending ? are low-confidence guesses.
The curated registry — vm-map/globals.toml (2026-07-07)
The v1 auto map (build/global-var-map.json) infers shapes but cannot recover branch-flag
meaning — and is sometimes wrong (it labels 0xa57, the Lily form-A story flag, as a
"string-table"). The curated registry fixes this, modelled exactly on vm-map/opcodes.toml:
vm-map/globals.toml— the only hand-edited source. One[[global]]per known address:name,category(story-flag/index-pointer/data-table/string-table/ui-toggle/choice-output/counter/unknown),type,value_domain,usage, and provenance (source/confidence/depends_on).tools/globals_build.py --buildmerges curated entries over the auto map →build/globals.json(machine) +docs/global-reference.md(generated human view).--lintchecks vocabulary, the auto-shape≠high rule, and danglingdepends_on.sys4loadreadsbuild/globals.jsonfor operand labels (curated names win, shown asname(category); the auto tail is kept only at high/med confidence). Regenerate the.asmcorpus withtools/extract_phase2.pyto pick up new labels.
Story-state flags (the first populated category)
Story flags are scalar globals that ADV/progression logic branches on (chapter, character
forms, choices, routes) — a category the auto shape map never enumerated. tools/story_flags.py
is a 100% static miner: it flags a global as a candidate when it feeds a comparison (eq/ne/
lt/lte/gr/gre), a logical (and/or), or a jcc condition, and is not a genuine table/
index in the shape map. Per candidate it records compared-against constants (→ value domain), the
writer set (progression-written but scene-read = strong story flag), total- and scene-reach, and
near-universal (ADV-chrome) status → an auto category + confidence. Output:
build/story-flags-candidates.json (review surface: 1261 branch-read globals, 205 story-flag
candidates); --bootstrap seeds high-signal skeletons (med-confidence, non-chrome) into
globals.toml for human naming. Dynamic confirmation of a flag's reach stays separate —
Age.Cli sweep 0xADDR=VAL.
Reading the catalog: reach_scenes > 0 = the flag changes SC/SP scene dialogue directly (e.g.
0xa57 Lily form, scene-reach 78). reach_scenes = 0 with progression writers = a
progression/menu-layer flag read by the game-flow scripts, not scenes (e.g. 0x3234 chapter, read
by SCJUMP/FIELD). Known/named anchors: 0x3234 chapter_mode (enum 1..9), 0x3231
game_mode (adjacent mode selector), 0xa57/8/9 Lily forms A/B/C (boolean, externally set),
0x62ccf/0x62ccc SCJUMP decision outputs, 0x6642c route_branch (BUNKI = 分岐 writer),
0x6c9–0x6cd UI toggles. Config/settings globals written by CONFIG/INITCONFIG (scene-reach 0)
are not story flags — the miner over-tags them; they are recategorized unknown when curated.
Future step — growing the map (planned, not yet done)
The v1 map labels shapes and tables; the next increments add meaning, cheapest first:
- Fold in the
*MESmessage-table writers (ITMES,SKMES,VIMES, …) and any otherset-string/copy-to-globalwriters not covered by the*INITset — pure static win, extends the string/data labels. (Also: most name-table bases are read rarely — reads likely go through*MES/an indirection; tracing that would connect names to their readers.) - Label 2D record tables by their readers — cross-reference which scripts read each
rec[sN]table and infer purpose from context (e.g. RECOVER's 30-wide tables ↔ a status/recovery system). Static, medium effort. - Name which stat each field is (Frida). The one step needing live tools: change a
known value in-game (take damage, gain XP), watch which global moves → definitive
field@X = "HP". Reserve for the fields that matter; this is the last mile.
Re-run tools/global_map.py after each increment; sys4load picks up the new labels
automatically (it reads build/global-var-map.json at load).
How the two relate
#1 names functions (the call graph); #2 names data (game state). In RECOVER, #1 turns
call-script 0x329d into CALCREVISE.BIN; #2 turns C[unit][s] = E[unit][s] into unit.hp[s] = unit.maxHp[s]. Both are now largely in hand: #1 is SOLVED (the SYS4INI-index dispatch reverse —
turned out to need the engine, and the Ghidra loop delivered it), and #2 has a partial static map
(the *INIT handholds) that grows on demand.