From 7250de7feaf2bbb677b16e99d840bef6bd10eb22 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 20 Jul 2026 17:29:38 -0400 Subject: [PATCH] Document B0 natural boot control spine --- docs/engine-re.md | 37 +++++- docs/global-reference.md | 4 +- docs/phase-b-framework.md | 36 +++++- docs/scjump-progression.md | 12 +- docs/tools-reference.md | 10 ++ tools/frida/capture_script_loads.py | 190 ++++++++++++++++++++++++++++ vm-map/globals.toml | 44 +++---- 7 files changed, 297 insertions(+), 36 deletions(-) create mode 100644 tools/frida/capture_script_loads.py diff --git a/docs/engine-re.md b/docs/engine-re.md index f8216b5..845c511 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -233,10 +233,39 @@ the per-frame return stack (`[ctx+0x552e8]`/`[ctx+0x55248]`). The operand is a * 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). +**Port status:** the C# VM resolves both immediate and computed resource ids through `IScriptProvider`, +pushes an `ExecFrame`, runs the child, and resumes its caller. This is the same mechanism needed for the +natural root described below; an out-of-band scene-name registry is not required. + +#### Natural boot and New Game control spine (B0, 2026-07-20) + +SYSTEM4 is the long-lived script root and the game's actual scene coordinator. Its initial path establishes +the nine ADV layouts and system surfaces, chooses `LOADCONFIG.BIN` or `INITCONFIG.BIN`, calls `INIT2.BIN`, +optionally calls `LOGO.BIN` and `OP.BIN`, calls the one-op `INIT.BIN`, and enters `TITLE.BIN`. `INIT2` is +not a thin handle seed: it calls 23 data initializers in order (`EBINIT`, `CNINIT`, `ITINIT`, `SKINIT`, +`ILINIT`, `AFINIT`, `TRINIT`, `MAINIT`, `ALINIT`, `CDINIT2`, `MPINIT`, `LAINIT`, `OBINIT`, `STINIT2`, +`RTINIT`, `CGINIT`, `SPINIT`, `CTINIT`, `CVINIT`, `CIINIT`, `VIINIT`, `SCINIT`, `BTANINIT2`) and then +`TUNE.BIN`. The CLI `play --boot` nine-script list is therefore only a partial diagnostic approximation; +the Godot `--boot` path reaches the complete list indirectly because it executes `INIT2` with call-script +resolution enabled. + +An existing native operand trace identifies every observed heap codebase by a 100% match against its static +instruction-offset set. The captured New Game route is: + +`TITLE → GAMESTART → UNITECH → CALCARR → GAMESTART → TUNE → GAMESTART → TITLE → SYSTEM4 → SC0000`. + +The transition sites make the ownership explicit. `TITLE@0x31c` calls `GAMESTART`. The selected New Game +path calls `UNITECH@0xd47` (which calls `CALCARR@0x4ca`), later calls `TUNE@0x1338`, writes flow result +`G[0]=1` and SCJUMP decision `G[0x62ccf]=0`, and returns. SYSTEM4 resumes at `0x2b0`, prepares the ADV +scene boundary, resolves `G[0x87a57][G[0x62ccf]]` into next-script resource `G[0x699]`, falls back to raw +SYS4INI id `0x22` (`SC0000.BIN`) when the mapping is zero, and executes computed `call-script@0x477`. +Thus normal scenes remain nested script frames under SYSTEM4 and return to it; the port should keep one +VM/host session rooted at SYSTEM4 rather than replace top-level VMs based on a host-invented scene result. + +`tools/frida/capture_script_loads.py` hooks `script_frame_load_resource@0x40e980` and reads its third stack +argument (the raw packed resource id) for direct name resolution. It is attach-only: attempting to gate the +installed executable at process start with this loader hook produced Protection Error 45 and no records. +No protection bypass or executable patch is part of the investigation. --- diff --git a/docs/global-reference.md b/docs/global-reference.md index bdab8e5..25c83c0 100644 --- a/docs/global-reference.md +++ b/docs/global-reference.md @@ -7,6 +7,8 @@ | address | name | conf | source | usage | |---|---|---|---|---| +| `0x0` | system_flow_request | high | investigation | Return-mode request shared by TITLE/GAMESTART and SYSTEM4. The natural New Game path writes 1 immediately after GAMESTART's TUNE call; TITLE returns and SYSTEM4 routes value 1 into its ADV scene loop. Values 2/3/5 route to FORT/FIELD/CAMP; other writers use the same system-level request channel. | +| `0x699` | next_script_resource_id | high | investigation | SYSTEM4's computed child-script resource id. On the normal ADV path SYSTEM4 copies G[0x87a57][scjump_decision_out] here, substitutes raw id 0x22 (SC0000.BIN) when zero, executes call-script through this cell at offset 0x477, then clears it after the child returns. | | `0xa68` | — | med | auto-shape | TODO: confirm. Branch-read in 11 scenes / 12 scripts; compared against [0, 1]; writers=['SC0740.BIN', 'SC1580.BIN', 'SC1590.BIN']. | | `0x62ccc` | scjump_decision_out2 | low | inference | Adjacent to scjump_decision_out (0x62ccf) in the 0x62ccc-0x62ccf progression decision-output cluster; same 136-scene reach, written by CAMP/CLOSE/DEBUGADV. INFERENCE from adjacency — confirm meaning before relying on it. | | `0x62ccf` | scjump_decision_out | low | inference | One of SCJUMP's output/decision globals (progression state machine writes it). Related to chapter_mode. | @@ -342,8 +344,6 @@ | address | name | conf | source | usage | |---|---|---|---|---| -| `0x0` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 21 scripts; compared against [0, 1, 2, 3, 4, 5]; writers=['BTL.BIN', 'CAMP.BIN', 'DEBUGADV.BIN', 'DEBUGADV2.BIN']. | -| `0x699` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 4 scripts; compared against [0]; writers=['CAMP.BIN', 'EVOLVE.BIN', 'SALLY.BIN', 'SYSTEM4.BIN']. | | `0xa57` | lily_form_a | high | investigation | Lily current-form flag A. Exactly one of form A/B/C is 1; gates form-specific voiced dialogue (seeding 0xa57=1 -> SC0000 186->229 lines). Set externally (menu/save), no static writer. | | `0xa58` | lily_form_b | high | investigation | Lily current-form flag B. See lily_form_a. | | `0xa59` | lily_form_c | high | investigation | Lily current-form flag C. See lily_form_a. | diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 3c38287..7b0a856 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -67,22 +67,48 @@ Capture: Detailed progression semantics remain canonical in `docs/scjump-progression.md`; native loader findings belong in `docs/engine-re.md` and `docs/name-resolution.md`. +### Initial B0 result (2026-07-20) + +Static SYSTEM4/INIT2 control flow plus an existing native opcode trace establishes the first natural spine: + +`SYSTEM4 → config load/init → INIT2 (+23 nested data initializers, then TUNE) → optional LOGO/OP +→ INIT → TITLE → GAMESTART → UNITECH/CALCARR → TUNE → TITLE return → SYSTEM4 → SC0000`. + +SYSTEM4, not an opaque native dispatcher, is the long-lived scene coordinator. It maps the SCJUMP decision +through a global resource-id table, places the result in `G[0x699]`, and uses computed `call-script`; the +initial zero decision falls back to raw SYS4INI id `0x22`, `SC0000.BIN`. The current VM already supports +computed nested call-script frames. Consequently B1 should preserve one VM and host rooted at SYSTEM4, +letting script-owned setup/cleanup surround child scenes, rather than invent an out-of-band replacement +protocol. Full process-start observation remains useful for profile/default and retained host-state evidence, +but is no longer needed to guess the script coordinator architecture. + +The current headless C# runner already follows this root naturally: one SYSTEM4 run entered INITCONFIG, +INIT2 and all 23 of its data-initializer children, TUNE, INIT, and TITLE (28 nested script calls total), then +remained in TITLE's input-poll loop because the diagnostic host supplies no user input. Direct opcode +coverage is 100% for all 23 data initializers, CALCARR, and TUNE; the remaining direct coverage is SYSTEM4 +64/82, INIT2 9/12, TITLE 61/65, GAMESTART 43/47, and UNITECH 29/31. B0/B1 should therefore make the +SYSTEM4-rooted path visible and interactive in Godot, then investigate only the gaps actually reached on +that route instead of treating every static gap as a prerequisite. + ## Stage B1 — Persistent session and scene coordinator -Replace the single-root-scene assumption with an application-owned session that can run one script scene, -observe its terminal request, and start the next without discarding persistent state. +Replace the single-SC0000-root assumption with an application-owned session that runs SYSTEM4 as its root. +SYSTEM4's computed `call-script` is the authoritative scene coordinator: child scenes return to that frame, +while globals, the host, and intentional retained state remain owned by the same live VM session. Required responsibilities: - Own global integer/string banks and any proven external/profile state across scenes. -- Distinguish nested `call-script` frames from top-level scene replacement. +- Preserve the SYSTEM4 root while distinguishing ordinary child frames from scene-boundary children for + diagnostics and lifecycle assertions; do not perform host-driven top-level replacement. - Define scene-owned versus session-owned host state and tear each down at the correct boundary. - Preserve intentional system-owned surfaces, configuration, and audio while releasing scene-local state. - Expose deterministic transition evidence: outgoing scene, reason/decision, incoming scene, and state summary suitable for tests. -Completion evidence: a synthetic or small real sequence transitions between two top-level scripts while -preserving selected globals and correctly releasing scene-local presentation state. +Completion evidence: SYSTEM4 reaches a computed child script in one VM, the child returns to SYSTEM4, +selected globals and system-owned state survive, and script-owned boundary cleanup releases scene-local +presentation state. ## Stage B2 — Faithful full boot diff --git a/docs/scjump-progression.md b/docs/scjump-progression.md index d06859f..66c60e8 100644 --- a/docs/scjump-progression.md +++ b/docs/scjump-progression.md @@ -39,14 +39,20 @@ choices are not pure story flags. failures). This is the oracle that covers native-gated paths. Run: `py -3.11 -X utf8 tools/scjump_decode.py --verify`. -## The native decision→scene boundary (deferred) +## The decision→scene boundary (resolved 2026-07-20) A FIELD snippet does `lookup-array(ptr, 0x5f0ed, 0x62ccf)` then `u00428010(ptr)`, which the spec guessed was the scene resolver. **Correction (resolved 2026-07-20, via Ghidra):** `u00428010` (op `0x1a2`) snapshots that selected global integer array cell into the shared `SAVE.DAT` profile table, keyed by its resolved global-bank index. Paired op `0x1a3` restores a selected cell or zero. Its write of 3 at `ctx+0x53d88` is only the instruction length. So this is **profile persistence, not decision→scene -dispatch**; scene loading remains the separate call-script/progression mechanism. See `docs/engine-re.md` -for the verified pair, shared-save serialization path, and explicit port deferral boundary. +dispatch**. The separate boundary is now located in `SYSTEM4.BIN`: on the normal path it indexes the global +resource-id table rooted at `G[0x87a57]` by `scjump_decision_out` (`G[0x62ccf]`), copies the selected raw +SYS4INI id into `G[0x699]`, substitutes `0x22` (`SC0000.BIN`) when the result is zero, and executes +`call-script G[0x699]` at SYSTEM4 offset `0x477`. The called scene returns to the still-live SYSTEM4 frame. + +The captured initial New Game path sets decision zero in `GAMESTART.BIN`; SYSTEM4 consequently takes the +`0x22` fallback and enters SC0000. Later decisions use the same mapping/call boundary. See +`docs/engine-re.md` §Natural boot and New Game control spine for the native trace and complete boot chain. ## See also - `vm-map/globals.toml` — the named globals SCJUMP switches on (chapter_mode, progress counters, flags). diff --git a/docs/tools-reference.md b/docs/tools-reference.md index be0aadb..03ecbe7 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -201,6 +201,16 @@ branching/state can shift page ordinals between runs. Resolve a reported page wi *(Static disassembly of `build/engine-dump/range_00400000.bin` uses **capstone** — `py -3.11 -m pip install capstone`; VA `X` → file offset `X−0x400000`.)* +### B0 script-load capture + +`tools/frida/capture_script_loads.py` attaches at TITLE, hooks +`script_frame_load_resource@0x40e980`, and resolves each raw SYS4 resource id to a `.BIN` name and parent +frame in `build/script-loads.jsonl`. Launch the game normally, then run +`py -3.11 -u -X utf8 tools/frida/capture_script_loads.py [secs]` before selecting New Game. Use +`--analyze` to print an existing log and `--selftest` for its pure resolver checks. It is deliberately +attach-only: a process-start loader-hook trial triggered Protection Error 45, so the tool does not spawn +or bypass protection. + ## Native engine RE (Ghidra) | Tool | Purpose | Run | Reads → Writes | diff --git a/tools/frida/capture_script_loads.py b/tools/frida/capture_script_loads.py new file mode 100644 index 0000000..3115d37 --- /dev/null +++ b/tools/frida/capture_script_loads.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Capture native SYS4 script loads after attaching at the title screen. + +Hooks ``script_frame_load_resource@0x40e980`` after AGE.EXE unpacks. Its third +argument is the packed SYS4 resource id; base-table ids resolve directly through +``build/asset-index.json``. The resulting ordered log is the Phase-B0 answer key +for boot, title, New Game, and top-level scene handoffs. + + py -3.11 -u -X utf8 tools/frida/capture_script_loads.py 30 + py -3.11 -X utf8 tools/frida/capture_script_loads.py --analyze + +The probe is read-only. Launch the original normally, wait for TITLE, then +attach this probe before selecting New Game. A Frida-gated process-start trial +triggered the installed game's Protection Error 45, so this tool deliberately +does not offer spawn mode. +""" +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path + +TOOLS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(TOOLS)) +import paths # noqa: E402 + +OUT = paths.BUILD / "script-loads.jsonl" + +LOAD_OFF = 0x0E980 +IDX_OFF = 0x53D14 +RESOURCE_BASE = 0x53D64 +FRAME_STRIDE = 0x78 + +# push ebp; lea ebp,[esp-0x134]; sub esp,0x134 +SIG = "0x55,0x8d,0xac,0x24,0xcc,0xfe,0xff,0xff,0x81,0xec,0x34,0x01,0x00,0x00" + +JS = r""" +const LOAD_OFF=%d, IDX_OFF=%d, RESOURCE_BASE=%d, FRAME_STRIDE=%d; +const SIG=[%s]; +const mod=Process.getModuleByName('AGE.EXE'); +const started=Date.now(); +let installed=false, rows=[], total=0; +function flush(){ if(rows.length){ send({kind:'batch', rows:rows}); rows=[]; } } +function install(){ + if(installed) return true; + let ok=false; + try { + const b=new Uint8Array(mod.base.add(LOAD_OFF).readByteArray(SIG.length)); + ok=SIG.every((v,i)=>b[i]===v); + } catch(e) { ok=false; } + if(!ok) return false; + Interceptor.attach(mod.base.add(LOAD_OFF), { + onEnter(args){ + const ctx=this.context.ecx, sp=this.context.esp; + try { + const resource=sp.add(8).readU32(); + const depth=ctx.add(IDX_OFF).readS32(); + let parent=0xffffffff; + if(depth>0 && depth<64) + parent=ctx.add(RESOURCE_BASE+(depth-1)*FRAME_STRIDE).readU32(); + rows.push([Date.now()-started, depth, resource>>>0, parent>>>0]); + total++; + if(rows.length>=256) flush(); + } catch(e) {} + } + }); + installed=true; + send({kind:'ready', base:mod.base.toString()}); + return true; +} +if(!install()) { const timer=setInterval(()=>{ if(install()) clearInterval(timer); },1); } +setInterval(flush,100); +rpc.exports={ flush(){ flush(); return total; } }; +""" % (LOAD_OFF, IDX_OFF, RESOURCE_BASE, FRAME_STRIDE, SIG) + + +def resource_names(index_path: Path = paths.BUILD / "asset-index.json") -> dict[int, str]: + doc = json.loads(index_path.read_text(encoding="utf-8")) + return {int(row["raw_index"]): row["name"] for row in doc["files"]} + + +def resolve_resource(resource_id: int, names: dict[int, str]) -> str: + """Resolve base SYS4 ids; retain packed append ids without guessing a table.""" + if resource_id >> 24: + return f"packed:0x{resource_id:08x}" + return names.get(resource_id, f"unknown:0x{resource_id:x}") + + +def analyze(out_path: Path = OUT) -> int: + if not out_path.exists(): + print(f"[!] no capture: {out_path}") + return 1 + names = resource_names() + rows = [json.loads(line) for line in out_path.read_text(encoding="utf-8").splitlines() if line.strip()] + print(f"=== native SYS4 script loads ({len(rows)}) ===") + for i, row in enumerate(rows): + rid = int(row["resource_id"]) + parent = int(row["parent_resource_id"]) + parent_name = "root" if parent == 0xFFFFFFFF else resolve_resource(parent, names) + print(f"{i:3d} {row['elapsed_ms']:7d} ms depth={row['depth']:2d} " + f"0x{rid:04x} {resolve_resource(rid, names):<18} <- {parent_name}") + if rows and resolve_resource(int(rows[0]["resource_id"]), names).upper() != "SYSTEM4.BIN": + print("\n[note] The post-unpack hook did not observe the root SYSTEM4 load; " + "the list begins at the first later load it could intercept.") + return 0 + + +def capture(seconds: int) -> int: + import frida + + names = resource_names() + count = 0 + + try: + session = frida.attach("AGE.EXE") + except frida.ProcessNotFoundError: + print("[frida] AGE.EXE is not running; launch it normally and stop at TITLE first.") + return 2 + + OUT.parent.mkdir(parents=True, exist_ok=True) + log = OUT.open("w", encoding="utf-8") + + def on_message(message, data): + nonlocal count + if message.get("type") == "error": + print("[frida-error]", message.get("description")) + return + if message.get("type") != "send": + return + payload = message["payload"] + if payload.get("kind") == "ready": + print(f"[frida] script loader hook installed @ {payload['base']} + 0x{LOAD_OFF:x}") + return + if payload.get("kind") != "batch": + return + for elapsed, depth, resource, parent in payload["rows"]: + row = {"elapsed_ms": elapsed, "depth": depth, "resource_id": resource, + "parent_resource_id": parent} + log.write(json.dumps(row) + "\n") + count += 1 + print(f"LOAD depth={depth:2d} 0x{resource:04x} {resolve_resource(resource, names)}") + log.flush() + + script = session.create_script(JS) + script.on("message", on_message) + script.load() + print(f"[frida] attached; capture {seconds}s. Select New Game now.") + + try: + for _ in range(seconds): + time.sleep(1) + except KeyboardInterrupt: + print("[frida] stopped early.") + try: + script.exports_sync.flush() + time.sleep(0.2) + except Exception: + pass + try: + session.detach() + except Exception: + pass + log.close() + print(f"\n[capture] {count} script loads -> {OUT}") + return analyze() + + +def selftest() -> int: + names = {0: "SYSTEM4.BIN", 0x22: "SC0000.BIN"} + assert resolve_resource(0, names) == "SYSTEM4.BIN" + assert resolve_resource(0x22, names) == "SC0000.BIN" + assert resolve_resource(0x123, names) == "unknown:0x123" + assert resolve_resource(0x01000022, names) == "packed:0x01000022" + print("capture_script_loads selftest: OK") + return 0 + + +def main() -> int: + args = sys.argv[1:] + if "--selftest" in args: + return selftest() + if "--analyze" in args: + return analyze() + seconds = next((int(arg) for arg in args if arg.isdigit()), 60) + return capture(seconds) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vm-map/globals.toml b/vm-map/globals.toml index 067eabb..7e05f43 100644 --- a/vm-map/globals.toml +++ b/vm-map/globals.toml @@ -6,6 +6,28 @@ [meta] note = "Curated global addresses override the auto shape-inference map (build/global-var-map.json)." +[[global]] +address = "0x0" +name = "system_flow_request" +category = "choice-output" +type = "int" +value_domain = "0..5" +usage = "Return-mode request shared by TITLE/GAMESTART and SYSTEM4. The natural New Game path writes 1 immediately after GAMESTART's TUNE call; TITLE returns and SYSTEM4 routes value 1 into its ADV scene loop. Values 2/3/5 route to FORT/FIELD/CAMP; other writers use the same system-level request channel." +source = "investigation" +confidence = "high" +depends_on = ["0x699", "0x62ccf"] + +[[global]] +address = "0x699" +name = "next_script_resource_id" +category = "choice-output" +type = "int" +value_domain = "raw SYS4INI id or 0" +usage = "SYSTEM4's computed child-script resource id. On the normal ADV path SYSTEM4 copies G[0x87a57][scjump_decision_out] here, substitutes raw id 0x22 (SC0000.BIN) when zero, executes call-script through this cell at offset 0x477, then clears it after the child returns." +source = "investigation" +confidence = "high" +depends_on = ["0x62ccf"] + [[global]] address = "0x3234" name = "chapter_mode" @@ -160,28 +182,6 @@ source = "investigation" confidence = "med" depends_on = [] -[[global]] -address = "0x0" -name = "" -category = "story-flag" -type = "int" -value_domain = "one of {0, 1, 2, 3, 4, 5}" -usage = "TODO: confirm. Branch-read in 0 scenes / 21 scripts; compared against [0, 1, 2, 3, 4, 5]; writers=['BTL.BIN', 'CAMP.BIN', 'DEBUGADV.BIN', 'DEBUGADV2.BIN']." -source = "auto-shape" -confidence = "med" -depends_on = [] - -[[global]] -address = "0x699" -name = "" -category = "story-flag" -type = "int" -value_domain = "{0,1}" -usage = "TODO: confirm. Branch-read in 0 scenes / 4 scripts; compared against [0]; writers=['CAMP.BIN', 'EVOLVE.BIN', 'SALLY.BIN', 'SYSTEM4.BIN']." -source = "auto-shape" -confidence = "med" -depends_on = [] - [[global]] address = "0x6be" name = ""