Resolve call-script dispatch: id = raw SYS4INI file index
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>
This commit is contained in:
@@ -94,11 +94,54 @@ handler through the dispatch table (`ctx[0x26c93 + op]`). The raw VA is off by w
|
||||
|
||||
---
|
||||
|
||||
### op `0x03` (`call-script`) is a raw index into the SYS4INI file table — SOLVED (2026-07-07)
|
||||
|
||||
The long-deferred `call-script <id>` registry (`name-resolution.md §1`) is cracked. Resolved through
|
||||
the dispatch table (op `0x03` → `ctx[0x26c93+3]` = **`FUN_0041bc90`**), then the loader/resolver chain:
|
||||
|
||||
- **`FUN_0041bc90`** (handler): fetches operand 1 (the id), bounds-checks call depth (≤ 0x26), pushes
|
||||
a script frame, and calls the loader.
|
||||
- **`FUN_0040e980`** (loader): opens the resource by id, reads the **0x20-byte SYS4 header**, checks
|
||||
magic, allocates per-frame code/local buffers from the header var-counts, reads the bytecode body,
|
||||
and pushes a script frame (**stride 0x1e = 30 dwords**, indexed by `ctx[0x14f45]`). Returns to the
|
||||
caller when the callee ends.
|
||||
- **`FUN_0044f390`** (resolver — the key): `record = [ctx+0x414] + id*0x50`. The record is exactly the
|
||||
**SYS4INI 80-byte layout** `{name[64], arc_id@0x40, file_number@0x44, offset@0x48, size@0x4c}`
|
||||
(count = `[ctx+0x40c]`, archive-name table = `[ctx+0x410]`). It tries a **loose override first**
|
||||
(`CreateFileA` on `record.name` → the mod/patch hook point), else opens archive
|
||||
`[record.arc_id*0x100 + ctx+0x410]`, `SetFilePointer` to `record.offset`, size = `record.size`.
|
||||
High-byte-tagged ids (`id & 0xff000000`) select an alternate pack via `[ctx+0x3028]` — **unused by
|
||||
the corpus** (0/297 ids carry a high byte).
|
||||
|
||||
**So `call-script <id>` = a direct RAW index into the SYS4INI global file table** — the same table
|
||||
`parse_sys4ini.py` reads, but indexed *without* skipping `@` placeholders (13208 records, 2
|
||||
placeholders). There is **no separate on-disk id→code registry**; SYS4INI *is* the registry, and we
|
||||
already had it. **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`,
|
||||
`0x329d→CALCREVISE`, `0x2add→CALCBTPARAM`), 0 out-of-range, 0 pack-branch. Tooling:
|
||||
`parse_sys4ini.py` emits `build/callscript-names.json` (id→name); `sys4load` annotates
|
||||
`call-script 0x1ab =ADDITEM.BIN`; the whole `build/disasm/*.asm` call graph now reads by name. See
|
||||
`name-resolution.md §1`.
|
||||
|
||||
**Companion — op `0x8f` (`call`) is INTRA-script, not cross-script.** Its handler **`FUN_0041fba0`**
|
||||
sets `[frame PC @+0x53d2c] = [frame codebase @+0x53d28] + operand*4` and pushes a return address on
|
||||
the per-frame return stack (`[ctx+0x552e8]`/`[ctx+0x55248]`). The operand is a **code offset within
|
||||
the current script** (matches header table **T3, tag 0x8F** = local call targets). So `0x8f` is a
|
||||
local JSR; only `0x03` loads another script.
|
||||
|
||||
**Follow-up (functional):** the C# VM still *stubs* `call-script`. With the id→resource mapping now
|
||||
known, it can be implemented for real (load the target `.BIN` from the archive via the SYS4INI record,
|
||||
push a frame, run, return) — the unlock for subroutine-using scripts and, via the same path,
|
||||
decision→scene (scenes are just `SCxxxx.BIN` records loaded by their SYS4INI index).
|
||||
|
||||
---
|
||||
|
||||
## Native walls backlog (targets for this loop)
|
||||
|
||||
- **decision→scene** — how `0x62ccf`/the decision actually selects the next `SCxxxx` (re-aimed away
|
||||
from `u00428010`; likely call-script-adjacent).
|
||||
- **call-script dispatch** — `call-script <id>` → engine entry point (`name-resolution.md §1`).
|
||||
- ~~**call-script dispatch**~~ — **SOLVED** (above): `call-script <id>` = raw SYS4INI file index.
|
||||
- **decision→scene** — how `0x62ccf`/the decision selects the next `SCxxxx`. Now narrower: scenes load
|
||||
via `call-script`/the same SYS4INI-index loader, so the open question is only where the decision
|
||||
value is turned into a scene *id* (a caller of SCJUMP; re-aimed away from `u00428010`).
|
||||
- **op `0x60`** (`u0041A270`) — the rand-like value gating 1732/1755 SCJUMP decisions.
|
||||
- **gfx command-buffer** — the `0x212–0x21a` positioned-object subsystem (`scjump`-unrelated; the
|
||||
rendering drift).
|
||||
|
||||
@@ -12,10 +12,25 @@ would make it read like source.
|
||||
|
||||
---
|
||||
|
||||
## #1 — `call-script` target resolution (naming the call graph)
|
||||
## #1 — `call-script` target resolution (naming the call graph) — ✅ SOLVED (2026-07-07)
|
||||
|
||||
**What it is.** `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
|
||||
**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):**
|
||||
@@ -40,16 +55,18 @@ on disk to read. Resolving it needs one of:
|
||||
- **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: deferred.** Not the quick win first assumed. Belongs with the engine/dispatch work
|
||||
(Phase 3), or a dedicated `SCJUMP.BIN` reverse. Until then `call-script` stays numeric.
|
||||
**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). The
|
||||
decision→scene hop is **native and still unidentified** — an earlier guess that op `u00428010`
|
||||
resolved it was **disproven via Ghidra** (that op is a graphics command-buffer op; see
|
||||
`docs/engine-re.md`). It's the same engine-level bucket as `call-script`; the Ghidra + MCP loop (and
|
||||
its recovered opcode-dispatch table) is now the tool to crack the `call-script`/script-load handler.
|
||||
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`).
|
||||
|
||||
---
|
||||
|
||||
@@ -172,7 +189,7 @@ 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 a name; #2 turns `C[unit][s] = E[unit][s]` into `unit.hp[s] =
|
||||
unit.maxHp[s]`. Priority reversal from the first guess: **#2 is the tractable readability lever
|
||||
now** (static handholds already half-built via the `*INIT` extraction); **#1 needs the engine**
|
||||
(dispatch reverse or Frida) and is deferred.
|
||||
`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.
|
||||
|
||||
@@ -27,6 +27,36 @@
|
||||
- **grounding:** source=investigation, confidence=high
|
||||
- **evidence:** By-ear confirmed (2026-07-06): SC0000 prologue voices play on their lines via Godot AudioStreamPlayer. Off-by-one disproven structurally: manifest interleaves graphics/voice (files[35]=EV049AA, [36]=MAN999, [37]=EV052CA, [38]=SYL0001), so files[base+id] lands voices on OGGs while files[base+id-1] would land them on .AGF graphics (silent) -- and they play, so the offset is exactly 0. Lily's lines are correctly form-gated (G[0xa57/0xa58/0xa59]) and stay silent when no form flag is seeded -- not a bug.
|
||||
|
||||
## control
|
||||
|
||||
### 0x3 `call-script` (call-script, argc 1)
|
||||
- **summary:** load & call another SYS4 script by id; id = RAW index into the SYS4INI file table (asset-index). Pushes a script frame; returns to caller when the callee ends.
|
||||
- **grounding:** source=investigation, confidence=high
|
||||
- **evidence:** native-RE (Ghidra): handler FUN_0041bc90 -> loader FUN_0040e980 -> resolver FUN_0044f390 indexes an 80-byte record table (base [ctx+0x414], count [ctx+0x40c]) at base+id*0x50 = the SYS4INI record layout {name[64],arc_id@0x40,file_number@0x44,offset@0x48,size@0x4c}. Confirmed statically: 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 pack-branch. See docs/engine-re.md + name-resolution.md #1.
|
||||
|
||||
op 0x03 (call-script, argc 1): `call-script <id>`. RESOLVED — the id is a direct RAW index into
|
||||
the SYS4INI global file table (the same table parse_sys4ini.py reads, but indexed WITHOUT skipping
|
||||
'@' placeholders; SYS4INI has 13208 records / 2 placeholders). No separate on-disk id->code registry
|
||||
exists; SYS4INI *is* the call-script registry.
|
||||
Native mechanism (dispatch table `handler(op)=ctx[0x26c93+op]`, op 0x03 -> FUN_0041bc90):
|
||||
1. FUN_0041bc90 fetches operand 1 (id), bounds-checks call depth (<=0x26), pushes a frame.
|
||||
2. FUN_0040e980 (loader): opens the resource by id, reads the 0x20-byte SYS4 header, checks magic,
|
||||
allocates per-frame code/local buffers from the header var-counts, reads the bytecode body,
|
||||
pushes a script frame (stride 0x1e = 30 dwords, indexed by ctx[0x14f45]).
|
||||
3. FUN_0044f390 (resolver): record = [ctx+0x414] + id*0x50. Tries a LOOSE OVERRIDE first
|
||||
(CreateFileA on record.name -> mod/patch hook point), else opens archive [record.arc_id*0x100 +
|
||||
ctx+0x410], SetFilePointer to record.offset, size = record.size.
|
||||
(High-byte-tagged ids `id & 0xff000000` select an alternate pack via [ctx+0x3028]; UNUSED by the
|
||||
corpus -- 0/297 ids have a high byte.)
|
||||
Companion op 0x8f `call` is INTRA-script (a local JSR), not cross-script -- see its entry.
|
||||
This also names the whole call graph statically (build/callscript-names.json).
|
||||
|
||||
|
||||
### 0x8f `call` (call, argc 1)
|
||||
- **summary:** intra-script subroutine call (local JSR): PC = frame.codebase + operand*4; pushes a return address on the per-frame return stack. NOT cross-script (that is call-script 0x03).
|
||||
- **grounding:** source=investigation, confidence=high
|
||||
- **evidence:** native-RE (Ghidra): handler FUN_0041fba0 (= ctx[0x26c93+0x8f]) sets [frame PC @+0x53d2c] = [frame codebase @+0x53d28] + operand*4 and pushes ((pc-base)>>2)+3 onto the per-frame return stack ([ctx+0x552e8]/[ctx+0x55248]). Target is a code OFFSET within the current script (matches header table T3 tag 0x8F = local call targets), confirming it is a local JSR, not a script load.
|
||||
|
||||
## draw
|
||||
|
||||
### 0x1a2 `gfx-cmd-register` (u00428010, argc 1)
|
||||
@@ -179,10 +209,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
|
||||
- **summary:** —
|
||||
- **grounding:** source=kelebek, confidence=med
|
||||
|
||||
### 0x3 `call-script` (call-script, argc 1)
|
||||
- **summary:** —
|
||||
- **grounding:** source=kelebek, confidence=med
|
||||
|
||||
### 0x5 `ret` (ret, argc 0)
|
||||
- **summary:** —
|
||||
- **grounding:** source=kelebek, confidence=med
|
||||
@@ -375,10 +401,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
|
||||
- **summary:** —
|
||||
- **grounding:** source=kelebek, confidence=med
|
||||
|
||||
### 0x8f `call` (call, argc 1)
|
||||
- **summary:** —
|
||||
- **grounding:** source=kelebek, confidence=med
|
||||
|
||||
### 0x93 `u00415040` (u00415040, argc 0)
|
||||
- **summary:** —
|
||||
- **grounding:** source=kelebek, confidence=low
|
||||
|
||||
@@ -95,6 +95,25 @@ outside scenes lives.
|
||||
|
||||
---
|
||||
|
||||
## Call graph — scripts are addressable by `call-script <id>` (2026-07-07)
|
||||
|
||||
`call-script <id>` (opcode 0x03) loads another script by a **raw index into the SYS4INI file table**
|
||||
(id = the entry's `raw_index` = its global position in SYS4INI). This is the resolved call-graph
|
||||
registry — there is no separate id→code table; SYS4INI is it. Mechanism: `engine-re.md` (op 0x03
|
||||
section); id→name single source: `build/callscript-names.json` (from `parse_sys4ini.py`); `sys4load`
|
||||
and the regenerated `build/disasm/*.asm` corpus now render targets by name
|
||||
(`call-script 0x1ab =ADDITEM.BIN`). **297 distinct scripts are called** across the corpus (3002 sites);
|
||||
the hottest are `HISTORY` (backlog), `MENU`, `HIDEWIN`, `BUNKI` (branch), `MES` (message), `ADDITEM`,
|
||||
`ADDEN`, `LOOK`, `RENDERMAP`. Scenes (`SCxxxx.BIN`) load through the *same* id-indexed loader.
|
||||
|
||||
**Living-reference decision:** no separate generated markdown call-script reference is kept. Unlike
|
||||
`opcode-reference.md` / `global-reference.md` (rendered from *curated* knowledge bases), the id→name
|
||||
mapping is purely mechanical (SYS4INI index → filename) with no semantics to curate — it already lives
|
||||
in the build artifact and in the named disasm corpus. Full call-graph edges (caller→callee counts) are
|
||||
derivable on demand from the corpus; materialize a doc only if a consumer needs it.
|
||||
|
||||
---
|
||||
|
||||
## Implications for the port
|
||||
|
||||
1. **Much more game logic lives in bytecode than expected.** Damage formulas
|
||||
|
||||
@@ -26,7 +26,7 @@ whenever a tool's inputs/outputs change.**
|
||||
|
||||
| Tool | Purpose | Run | Reads → Writes |
|
||||
|---|---|---|---|
|
||||
| `sys4load.py` | Loader + opcode-decoding disassembler for SYS4 `.BIN` scripts (the container-format core every other tool builds on). | `sys4load.py <file.BIN>` · `--summary` · `--strings` · `--json` · `sys4load.py <dir> --validate` (corpus check) | `.BIN` + `age_opcodes*.py` + `build/global-var-map.json` → stdout listing, or `build/scripts-json/` with `--json` |
|
||||
| `sys4load.py` | Loader + opcode-decoding disassembler for SYS4 `.BIN` scripts (the container-format core every other tool builds on). Annotates global operands (`build/globals.json`) and **`call-script` targets by name** (`build/callscript-names.json`, e.g. `call-script 0x1ab =ADDITEM.BIN`). | `sys4load.py <file.BIN>` · `--summary` · `--strings` · `--json` · `sys4load.py <dir> --validate` (corpus check) | `.BIN` + `age_opcodes*.py` + `build/globals.json` + `build/callscript-names.json` → stdout listing, or `build/scripts-json/` with `--json` |
|
||||
| `age_opcodes.py` | 548-entry Kelebek AGE opcode/arg-type table. **PRISTINE upstream data — never edit.** | *Imported.* | — |
|
||||
|
||||
## Opcode reference toolchain — single source of truth = `vm-map/opcodes.toml`
|
||||
@@ -90,7 +90,7 @@ plays `SC0000` from the real bytecode. User args (after `--`):
|
||||
|
||||
| Tool | Purpose | Run | Reads → Writes |
|
||||
|---|---|---|---|
|
||||
| `parse_sys4ini.py` | Parse `SYS4INI.BIN` (S4IC422, LZSS-compressed) into the authoritative asset index — name ↔ archive ↔ offset ↔ size for all DATA*.ALF (the `resId→file` answer key). | `parse_sys4ini.py [--check]` (`--check` validates vs `extracted/` + `.ALF` sizes) | `姫狩り…/SYS4INI.BIN` → `build/asset-index.json` |
|
||||
| `parse_sys4ini.py` | Parse `SYS4INI.BIN` (S4IC422, LZSS-compressed) into the authoritative asset index — name ↔ archive ↔ offset ↔ size for all DATA*.ALF (the `resId→file` answer key). Each entry carries `raw_index` (its 0-based position in the SYS4INI record table incl. `@` placeholders) = the engine's universal file id. Also emits the **`call-script <id> → name`** map (id = `raw_index`; see `engine-re.md`). | `parse_sys4ini.py [--check]` (`--check` validates vs `extracted/` + `.ALF` sizes) | `姫狩り…/SYS4INI.BIN` → `build/asset-index.json` + `build/callscript-names.json` |
|
||||
| `resolve_asset.py` | ★ **The static asset resolver.** SYS4INI is sectioned (one per scene: `SCxxxx.BIN` + its cross-archive manifest; `file_number` = index within section). Resolves `resId → files[section_base(scene) + resId]` for graphics AND audio, no capture. | `resolve_asset.py --build` · `resolve_asset.py <SCENE> [resId]` | `build/asset-index.json` → `build/asset-sections.json`; resolves any (scene, resId) |
|
||||
| `resolve_frida_reads.py` | Rescue noisy Frida archive-read offsets → asset names via the index (per-archive range search; drops 0x20000 paging reads); recovers the per-scene asset load order. | `resolve_frida_reads.py [reads.log] [-o out.json]` | `build/frida-reads.log` + `build/asset-index.json` → `build/frida-asset-loads.json` |
|
||||
| `convert_agf.py` | Convert AGF stills to BMP via `AGF2BMP2AGF.exe` (searches all `extracted/DATA*`). `--scene` batch-converts a scene's whole SYS4INI manifest — feeds the Godot render. | `convert_agf.py EV052CA.AGF …` · `convert_agf.py --scene SC0000` | `extracted/DATA*/*.AGF` → `build/textures/*.BMP` |
|
||||
|
||||
Reference in New Issue
Block a user