diff --git a/docs/engine-re.md b/docs/engine-re.md index b61b09c..41689ca 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -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 ` 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 ` = 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 ` → engine entry point (`name-resolution.md §1`). +- ~~**call-script dispatch**~~ — **SOLVED** (above): `call-script ` = 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). diff --git a/docs/name-resolution.md b/docs/name-resolution.md index b0ddbb7..3be9d75 100644 --- a/docs/name-resolution.md +++ b/docs/name-resolution.md @@ -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 ` 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. diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index fd5a7d2..014d8be 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -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 `. 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 diff --git a/docs/script-inventory.md b/docs/script-inventory.md index 09ef826..3ba34e8 100644 --- a/docs/script-inventory.md +++ b/docs/script-inventory.md @@ -95,6 +95,25 @@ outside scenes lives. --- +## Call graph — scripts are addressable by `call-script ` (2026-07-07) + +`call-script ` (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 diff --git a/docs/tools-reference.md b/docs/tools-reference.md index b0ee476..1387747 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -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 ` · `--summary` · `--strings` · `--json` · `sys4load.py --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 ` · `--summary` · `--strings` · `--json` · `sys4load.py --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 → 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 [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` | diff --git a/tools/parse_sys4ini.py b/tools/parse_sys4ini.py index b9f4ab0..995efda 100644 --- a/tools/parse_sys4ini.py +++ b/tools/parse_sys4ini.py @@ -115,13 +115,18 @@ def parse(path: Path) -> dict: f"have {len(blob)} decompressed bytes)") files = [] - for _ in range(file_count): + for i in range(file_count): name_b, arc_id, file_number, offset, size = struct.unpack_from(FILE_ENTRY_FMT, blob, p) p += FILE_ENTRY_LEN name = _cstr(name_b) if name == "@" or not name: continue files.append({ + # raw_index = the record's 0-based position in the SYS4INI table INCLUDING '@' + # placeholders. This is the engine's universal file id: `call-script ` and every + # script/asset load index by it (engine FUN_0044f390: record = base + id*0x50). It is + # NOT the same as this entry's position in `files` (which omits placeholders). + "raw_index": i, "name": name, "archive": archives[arc_id] if 0 <= arc_id < arc_count else None, "arc_id": arc_id, @@ -215,6 +220,15 @@ def main() -> int: out.write_text(json.dumps(index, ensure_ascii=False, indent=1), encoding="utf-8") print(f"-> {out.relative_to(paths.REPO)}") + # call-script id -> name map (raw_index keyed). `call-script ` (opcode 0x03) is a direct + # raw index into the SYS4INI file table, so this IS the call-graph name registry that + # name-resolution.md #1 needed (confirmed via native-RE, see docs/engine-re.md). sys4load reads + # it to annotate `call-script 0x1ab =ADDITEM`. + cs = {str(f["raw_index"]): f["name"] for f in index["files"]} + cs_out = paths.BUILD / "callscript-names.json" + cs_out.write_text(json.dumps(cs, ensure_ascii=False, indent=0), encoding="utf-8") + print(f"-> {cs_out.relative_to(paths.REPO)} ({len(cs)} ids)") + if do_check: print("--- validation ---") return 1 if check(index) else 0 diff --git a/tools/sys4load.py b/tools/sys4load.py index 01a7ad9..b893dc1 100644 --- a/tools/sys4load.py +++ b/tools/sys4load.py @@ -80,6 +80,21 @@ def _load_global_labels() -> dict: GLOBAL_LABELS = _load_global_labels() +def _load_callscript_names() -> dict: + """id -> script name. `call-script ` (op 0x03) is a raw index into the SYS4INI file + table; build/callscript-names.json maps every id to its script name (see docs/engine-re.md).""" + try: + p = Path(__file__).resolve().parent.parent / "build" / "callscript-names.json" + data = json.loads(p.read_text(encoding="utf-8")) + except Exception: + return {} + return {int(k): v for k, v in data.items()} + + +CALLSCRIPT_NAMES = _load_callscript_names() +CALLSCRIPT_OP = 0x03 + + def display_label(op: int) -> str: """Rendered mnemonic: Kelebek name if it has one, else the inferred name, else u00….""" lbl = OPCODES.get(op, (f"?{op:x}", 0))[0] @@ -368,6 +383,8 @@ def _fmt_operand(op: int, arg_index: int, atype: int, aval: int, strings: dict) tlabel = ARG_TYPES.get(atype) if atype == 0 or tlabel is None: # immediate / unknown-tag: raw value if atype == 0: + if op == CALLSCRIPT_OP and aval in CALLSCRIPT_NAMES: + return f"{aval:#x} ={CALLSCRIPT_NAMES[aval]}" # call-script target script name return f"{aval:#x}" return f"" if tlabel == "float": diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index e1b3e2b..8369add 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -77,17 +77,36 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "call-script" -category = "unknown" -summary = "" +category = "control" +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." noop_headless = false -source = "kelebek" -confidence = "med" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +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." +confirm_by = "" +details = """ +op 0x03 (call-script, argc 1): `call-script `. 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). +""" [[opcode.semantics.args]] i = 1 -role = "" +role = "script id = raw SYS4INI file index" observed_types = ["imm", "g-int", "l-ptr"] [[opcode]] @@ -1428,17 +1447,18 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "call" -category = "unknown" -summary = "" +category = "control" +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)." noop_headless = false -source = "kelebek" -confidence = "med" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +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." +confirm_by = "" [[opcode.semantics.args]] i = 1 -role = "" +role = "local code target (word offset within current script)" observed_types = ["imm", "g-int"] [[opcode]]