Document B0 natural boot control spine

This commit is contained in:
gamer147
2026-07-20 17:29:38 -04:00
parent 745e6e18b5
commit 7250de7fea
7 changed files with 297 additions and 36 deletions

View File

@@ -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.
---

View File

@@ -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. |

View File

@@ -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

View File

@@ -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).

View File

@@ -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 `X0x400000`.)*
### 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 |

View File

@@ -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())

View File

@@ -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 = ""