Implement root reload and title debug launcher
This commit is contained in:
@@ -101,7 +101,8 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
|
||||
│ └── Age.Engine/Sys4/ runtime catalog parser, loose-first bounded ALF asset store,
|
||||
│ script provider, AGF/LZSS and Windows CUR decoders, and resource facade
|
||||
├── tools/frida/ runtime-capture + engine-dump scripts (see tools/frida/README.md)
|
||||
└── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine)
|
||||
└── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine),
|
||||
including the TITLE-only F4 debug scene launcher
|
||||
```
|
||||
|
||||
The disposable `build/page-map-<SCENE>.jsonl` files are produced by normal Godot runs and map runtime ADV
|
||||
|
||||
@@ -253,10 +253,55 @@ in one VM; Godot `--boot` remains only for an explicit direct-scene diagnostic s
|
||||
deterministic first-process-run behavior, not a profile/save decision. `SYSTEM4@0x29a` calls op `0x130` and
|
||||
executes `LOGO.BIN@0x2a4` then `OP.BIN@0x2a7` when its result is nonzero. Native
|
||||
`op_0x130_get_initial_root_run@0x4295b0` returns `EngineCtx+0x54ff0`; context construction initializes that
|
||||
field to one, while `op_0x9_handler` is its only later writer and clears it before resetting engine state and
|
||||
reloading root script id zero. The port now owns the same flag in the persistent VM: it begins at one,
|
||||
`0x130` writes it, and the existing op-`0x9` lifecycle boundary clears it. It is not a boot seed or profile
|
||||
value. Full native whole-stack root reload remains part of the broader scene-coordinator work.
|
||||
field to one, while `op_0x9_reset_scene_and_reload_root` is its only later writer and clears it before
|
||||
resetting engine state and reloading root script id zero. The port now owns the same flag in the persistent
|
||||
VM: it begins at one,
|
||||
`0x130` writes it, and op `0x9` clears it as part of the whole-stack root reload described below. It is not
|
||||
a boot seed or profile value.
|
||||
|
||||
**Whole-stack root reload (`0x9`, 2026-07-20).** Native
|
||||
`op_0x9_reset_scene_and_reload_root@0x418f50` is not an ordinary child-script return. It clears the
|
||||
initial-root flag, calls `script_frame_dispose@0x40e610` across all 40 interpreter slots, cancels timed and
|
||||
input-callback state, invokes `scene_context_init_reset@0x40b3b0`, resets hotspot/input services, and finally
|
||||
loads raw script resource zero with `script_frame_load_resource(..., 0)`. Raw SYS4INI index zero is
|
||||
`SYSTEM4.BIN` in Himegari.
|
||||
|
||||
The scene reset owns interpreter run state, ADV input/skip/auto state, retained gfx objects and command
|
||||
queues, text/render buffers, and—on the normal fresh-session path—the 1000 ordinary surface/movie slots.
|
||||
It does not clear the global VM bank or engine configuration. The port now mirrors that boundary: an `0x9`
|
||||
request propagates through every nested `call-script` frame without executing any caller continuation,
|
||||
clears scene-owned VM/Godot presentation and input state, cancels deferred SFX starts without unloading or
|
||||
stopping active channels/BGM, preserves global/external-global banks and process-owned configuration/caches,
|
||||
then begins raw script zero at offset zero in the same VM session.
|
||||
The retained ADV history backlog remains intact for now because its lifetime across this reset has not yet
|
||||
been proven; only recording suppression is reset. Focused tests cover a three-frame unwind and raw-zero
|
||||
resolution to `SYSTEM4.BIN`.
|
||||
|
||||
**Frontend exit request is not a root reload (`0x1`, 2026-07-20).** Native
|
||||
`op_0x1_throw_exit_request@0x4162e0` constructs reason value one and raises the engine's non-returning C++
|
||||
control exception (`DAT_005a9710`). Its only two corpus sites establish the intent: TITLE executes it after
|
||||
the fifth main-menu action's sound and delay, while SYSTEM4 executes it after reporting an invalid execution
|
||||
mode. The Windows/frontend catch policy—full exit versus returning to title—is outside this opcode handler
|
||||
and is not implemented in the Godot frontend yet.
|
||||
|
||||
TITLE happens to contain a developer menu immediately after its `0x1`, including a `call-script` to
|
||||
`DEBUG.BIN`; that code is unreachable in the native flow because the handler never returns. The port still
|
||||
treats unknown `0x1` as a fall-through stub, so selecting the fifth TITLE action would expose that menu by
|
||||
accident. This is a known discrepancy, not a legitimate route for validating `0x9`. End-to-end visual
|
||||
validation of the native `SYSTEM4 -> TITLE -> child -> 0x9 -> SYSTEM4 -> TITLE` history therefore remains
|
||||
deferred until the frontend exit/return-to-title boundary or a natural game-over/completion route exists.
|
||||
|
||||
The unreachable developer menu nevertheless records the game's intended debug-scene handoff. Its two ADV
|
||||
viewer choices write `G[0]=1`, `G[0xaba5c]=-1`, `G[0x62ccf]=0`, and a raw script id into `G[0x699]`, then
|
||||
return TITLE with local result one. TITLE's outer loop performs its normal ADV input/skip exit pair and
|
||||
returns to SYSTEM4. SYSTEM4 resumes at `0x2b0`; the nonzero `G[0xaba5c]` suppresses SCJUMP remapping, so the
|
||||
coordinator keeps the requested `G[0x699]`, performs its normal scene-entry setup, and calls that script at
|
||||
`0x477`. Other developer choices directly call utility scripts such as `DEBUG.BIN` from TITLE instead.
|
||||
|
||||
This also confirms three distinct cleanup owners around a debug launch: the selected script's own terminal
|
||||
subroutines, SYSTEM4's ordinary post-child cleanup (including all ten SFX channels and retained scene
|
||||
objects), and op `0x9`'s whole-stack scene reset when that opcode is actually executed. An arbitrary VM
|
||||
script replacement would bypass the first two and is not equivalent to native scene dispatch.
|
||||
|
||||
Both child scripts create/draw 800x600 surface 42 and call op `0x20f`; their resource ids are universal raw
|
||||
SYS4INI indexes `0x335f`/`LOGO.AGF` and `0x3364`/`OP.AGF`. `ED.BIN` is the only other corpus user, with
|
||||
|
||||
@@ -198,6 +198,11 @@ Operand 2 names the base cell itself: a global-bank operand produces a global re
|
||||
|
||||
## control
|
||||
|
||||
### 0x1 `throw-exit-request` (throw-exit-request, argc 0)
|
||||
- **summary:** () - raise the engine's non-returning exit/fatal-abort control exception with reason value 1. TITLE uses it for the fifth main-menu action; SYSTEM4 uses it after reporting an invalid execution mode.
|
||||
- **grounding:** source=investigation, confidence=high
|
||||
- **evidence:** Ghidra /v2: op_0x1_throw_exit_request@0x4162e0 constructs local value 1 and calls __CxxThrowException_8 with type descriptor DAT_005a9710; the handler is non-returning. Corpus has exactly two sites: TITLE@0x393 after the fifth menu action's sound/sleep, and SYSTEM4@0x5b9 after printing 'invalid execution mode'. TITLE bytecode following 0x1 builds a developer debug menu and can only be reached when a port incorrectly treats 0x1 as a fall-through stub. The frontend catch/prompt policy remains a separate unimplemented boundary.
|
||||
|
||||
### 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
|
||||
@@ -222,11 +227,11 @@ This also names the whole call graph statically (build/callscript-names.json).
|
||||
|
||||
|
||||
### 0x9 `exit-script` (exit-script, argc 0)
|
||||
- **summary:** () - terminate the active script lifecycle and return to root script id 0. Before the native engine resets/reloads the root, it clears the initial-root-run flag queried by op 0x130 so LOGO/OP are not replayed.
|
||||
- **summary:** () - discard the complete active script stack, reset scene-owned engine services, and load raw script resource 0 as the new root. The global VM banks and process-owned configuration survive; the initial-root-run flag queried by op 0x130 is cleared so LOGO/OP are not replayed.
|
||||
- **grounding:** source=investigation, confidence=high
|
||||
- **evidence:** Ghidra /v2: op_0x9_handler@0x418f50 stores zero to EngineCtx+0x54ff0, calls scene_context_init_reset, and loads root script resource id 0. Corpus sites are terminal scene/control exits rather than ordinary local returns.
|
||||
- **evidence:** Ghidra /v2: op_0x9_reset_scene_and_reload_root@0x418f50 clears EngineCtx+0x54ff0, disposes all 40 interpreter-frame slots with script_frame_dispose@0x40e610, aborts timed/input callback state, calls scene_context_init_reset@0x40b3b0, resets hotspot/input services, optionally releases AutoFreeTex surfaces, then calls script_frame_load_resource(...,0). The scene reset clears interpreter/ADV/input/retained-gfx state and normally releases the 1000 surface/movie slots; it does not clear the VM global bank or engine configuration. Corpus sites are terminal scene/control exits rather than ordinary local returns.
|
||||
|
||||
The port retains its earlier frame/session-boundary representation of root return, but now performs the proven process-lifecycle side effect by clearing its VM-owned initial-root-run flag. A faithful whole-stack root reload remains part of the broader persistent scene-coordinator work, not the startup-movie slice.
|
||||
Implemented as a whole-stack root-reload boundary in the persistent VM. A request propagates through every nested call-script frame without resuming caller instructions, clears VM/host scene presentation and input state, cancels deferred SFX starts while preserving active/process-owned audio, preserves globals/external globals and process-owned host configuration/caches, then resolves raw resource 0 through the script provider and starts it at offset zero. The retained history backlog is deliberately preserved pending a separate proof of its native lifetime; recording suppression is reset.
|
||||
|
||||
### 0x7b `coroutine-save-yield-handlers` (u0041ADB0, argc 2)
|
||||
- **summary:** (handler1_pc)(handler2_pc) — scene-coroutine: save the two per-frame yield/resume handler PCs. Native writes op1→ctx[0x6da88+idx*4], op2→ctx[0x6db28+idx*4] (idx=ctx[0x53d14] script-context index); its generic handler prologue records the 5-dword instruction length. SC0000 0x79: `0x7b label_3c9 label_41e` registers the ADV per-frame render→poll→yield handlers. Part of the scene-coroutine framework (see engine-re.md §Scene-coroutine framework); pairs with 0x7c (resume) + 0x140 (loop iterator).
|
||||
@@ -293,7 +298,7 @@ Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_t
|
||||
### 0x130 `get-initial-root-run` (get-initial-root-run, argc 1)
|
||||
- **summary:** (out) - write the engine's initial-root-run flag. It is initialized to 1 when the AGE context is constructed and cleared by op 0x9 before that opcode resets state and reloads root script id 0. SYSTEM4 uses the value to call LOGO.BIN and OP.BIN only on the process's initial root run.
|
||||
- **grounding:** source=investigation, confidence=high
|
||||
- **evidence:** Ghidra /v2: op_0x130_get_initial_root_run@0x4295b0 copies EngineCtx+0x54ff0 to operand 1. FUN_00413860 initializes +0x54ff0 to 1 at 0x413cc7/0x413d15; op_0x9_handler@0x418f50 is its only later writer and clears it before scene_context_init_reset plus script_frame_load_resource(...,0). Corpus: sole site SYSTEM4@0x29a branches to LOGO.BIN then OP.BIN only when the returned value is nonzero.
|
||||
- **evidence:** Ghidra /v2: op_0x130_get_initial_root_run@0x4295b0 copies EngineCtx+0x54ff0 to operand 1. FUN_00413860 initializes +0x54ff0 to 1 at 0x413cc7/0x413d15; op_0x9_reset_scene_and_reload_root@0x418f50 is its only later writer and clears it before scene_context_init_reset plus script_frame_load_resource(...,0). Corpus: sole site SYSTEM4@0x29a branches to LOGO.BIN then OP.BIN only when the returned value is nonzero.
|
||||
|
||||
Implemented as process-lifecycle state owned by the persistent VM: it begins at one and op 0x9 clears it. It is not a script global, save/profile value, command-line seed, or script-name special case.
|
||||
|
||||
@@ -796,10 +801,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
|
||||
|
||||
## unknown
|
||||
|
||||
### 0x1 `u004149C0` (u004149C0, argc 0)
|
||||
- **summary:** —
|
||||
- **grounding:** source=kelebek, confidence=low
|
||||
|
||||
### 0x2 `exit` (exit, argc 0)
|
||||
- **summary:** —
|
||||
- **grounding:** source=kelebek, confidence=med
|
||||
|
||||
@@ -112,8 +112,8 @@ that is one at context construction and cleared only when op `0x9` resets/reload
|
||||
SYSTEM4 calls `LOGO.BIN` and `OP.BIN` only while that flag is nonzero. The former stubbed-zero output
|
||||
explained the direct jump to TITLE. LOGO and OP then use the modal movie op
|
||||
`0x20f` with raw catalog movies `0x335f`/`LOGO.AGF` and `0x3364`/`OP.AGF`; existing `0x236` is the distinct
|
||||
non-modal, scene-local movie-to-surface path. The VM now models the initial-root flag and clears it at its
|
||||
existing op-`0x9` lifecycle boundary. Godot resolves a typed raw MPEG asset, reuses the asynchronous decoder
|
||||
non-modal, scene-local movie-to-surface path. The VM now models the initial-root flag and clears it at the
|
||||
op-`0x9` whole-stack root-reload boundary. Godot resolves a typed raw MPEG asset, reuses the asynchronous decoder
|
||||
and retained compositor, and parks the VM until EOF or mouse/Accept/Cancel input. Focused natural-boot tests
|
||||
prove `LOGO -> OP -> INIT -> TITLE` ordering and exact movie operands. MPEG audio remains explicitly deferred
|
||||
until the decoder abstraction has an engine-owned synchronized audio/volume contract.
|
||||
@@ -124,6 +124,15 @@ Replace the single-SC0000-root assumption with an application-owned session that
|
||||
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.
|
||||
|
||||
**Root-reload boundary implemented (2026-07-20).** Ordinary op `0x2` child exits still return to their
|
||||
calling SYSTEM4 frame. Op `0x9` is the distinct native reset path: it discards the complete active script
|
||||
stack, clears scene-owned graphics/input/ADV state, cancels deferred SFX starts while preserving active
|
||||
audio, preserves global banks and process-owned host state, and starts raw script resource zero
|
||||
(`SYSTEM4.BIN`) at offset zero. The implementation propagates the boundary
|
||||
through nested calls without running any caller continuation and records the new root frame with
|
||||
`FrameCause.RootReload`. Native RE and the one intentional history-lifetime exception are documented in
|
||||
`docs/engine-re.md`; the history backlog remains preserved until its ownership is proven rather than guessed.
|
||||
|
||||
Required responsibilities:
|
||||
|
||||
- Own global integer/string banks and any proven external/profile state across scenes.
|
||||
@@ -134,9 +143,97 @@ Required responsibilities:
|
||||
- Expose deterministic transition evidence: outgoing scene, reason/decision, incoming scene, and state
|
||||
summary suitable for tests.
|
||||
|
||||
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.
|
||||
Completion evidence now present: SYSTEM4 reaches computed child scripts in one VM; ordinary children return
|
||||
to SYSTEM4; op `0x9` performs a tested whole-stack reload of SYSTEM4; selected globals and process-owned state
|
||||
survive; and scene-owned presentation/input state is released. Manual validation of a natural gameplay
|
||||
route through the first `0x9` remains deferred: Himegari's readily accessible return-to-title choice belongs
|
||||
to the still-unimplemented frontend exit-request policy, while the other known natural paths require later
|
||||
gameplay, game over, or completion. Do not use TITLE's currently exposed post-`0x1` developer-menu
|
||||
fallthrough as evidence; native `0x1` is non-returning. See `docs/engine-re.md`.
|
||||
|
||||
**Godot debug scene launcher (2026-07-20; implemented and manually validated).** The first version
|
||||
is deliberately narrower than arbitrary hot swapping:
|
||||
|
||||
- Expose an F4-style Godot overlay only while `TITLE.BIN` is the persistent VM's active SYSTEM4 child.
|
||||
- Resolve the chosen `.BIN` through the existing SYS4 catalog, then ask the VM to return the current TITLE
|
||||
child frame with the game-authored coordinator writes (`G[0]=1`, `G[0xaba5c]=-1`, `G[0x62ccf]=0`, and
|
||||
selected packed id in `G[0x699]`) applied on the VM thread.
|
||||
- Let SYSTEM4 resume at `0x2b0` and execute its real entry wrapper and computed `call-script`; do not replace
|
||||
the VM root or call the selected scene directly from Godot.
|
||||
- Disable switching while another scene is active. That scene must reach its own terminal cleanup and then
|
||||
either return through SYSTEM4's post-child cleanup or execute its genuine op `0x9`. A separate clean
|
||||
relaunch remains the escape hatch for a stuck/incomplete scene.
|
||||
|
||||
The runtime now has a generic debug-only "return this exact active child frame with queued global writes"
|
||||
request, thread-safe frame-generation/stack reporting, and the distinct `DebugReturned` trace outcome. TITLE
|
||||
does not park in ADV op `0x72`: its visible menu continuously polls input and executes a 1 ms op-`0xc8` sleep
|
||||
at `TITLE@0xe5`. The request therefore targets the observed active frame generation and is consumed by the
|
||||
VM thread at its next completed opcode boundary, before another TITLE opcode can execute. `SignalInput` is
|
||||
used only if the target happens to be in a real ADV wait, avoiding a stale signal that could advance the
|
||||
selected child. Synthetic coordinator tests cover both an ADV wait and TITLE's sleep/poll shape, selected-
|
||||
child dispatch, ordinary SYSTEM4 continuation, stale/ineligible request rejection, and selected-child
|
||||
op-`0x9` whole-stack propagation.
|
||||
|
||||
This launcher would provide the real visible TITLE→selected scene sequence and preserve the coordinator
|
||||
boundary, but it cannot manufacture valid late-game state. The current direct harness and opcode coverage
|
||||
suggest early ADV scenes and `DEBUG.BIN` are plausible targets; later scenarios, GAMECLEAR, battle/map, and
|
||||
profile-dependent scripts may still require progression data or missing opcodes. A startup-only/direct-scene
|
||||
selector is cheaper, but it is merely a UI for `--scene ... --boot` and provides no transition-lifecycle
|
||||
evidence. An unrestricted in-process switch would additionally require VM cancellation, task joining,
|
||||
movie/audio disposal, locator/trace regeneration, and an explicit global-state policy, so it is not a quick
|
||||
or trustworthy first version.
|
||||
|
||||
**Menu population and selection contract.** The runtime SYS4 catalog—not `build/` inventory—is the
|
||||
source of truth. Himegari currently has 481 unique base-catalog `.BIN` records: 136 `SC####`, 164 `SP*`, 8
|
||||
`DEBUG*`, 29 initializer-named scripts, and 144 other named scripts. Each menu row keeps the packed resource
|
||||
id as its identity and carries display name, pack selector, raw index, archive, size, and category; names are
|
||||
labels rather than keys so future append-pack collisions remain representable. Population should enumerate
|
||||
base `Catalog.Files` plus every mounted append catalog, exclude placeholders/non-BIN records, and compute
|
||||
`packed_id = (pack_id << 24) | raw_index` without parsing all scripts up front. The selected script is decoded
|
||||
and validated only when Launch is pressed; an unsupported decode reports an error and leaves TITLE running.
|
||||
The currently mounted append pack contributes 39 additional `.BIN` records, so the shipped launcher smoke
|
||||
test sees 520 distinct packed script ids.
|
||||
|
||||
The initial UI groups entries rather than implying every BIN is a standalone scene:
|
||||
|
||||
- **Scenario:** `SC####.BIN`, naturally sorted by number.
|
||||
- **Secondary/event:** `SP*.BIN`, naturally sorted by name and suffix.
|
||||
- **Debug:** `DEBUG*.BIN`.
|
||||
- **Other/expert:** every remaining script; the separate **All** filter includes every category. Initializers,
|
||||
callbacks, data routines, and modal UI scripts may require caller-owned state and may immediately return
|
||||
or corrupt the live session.
|
||||
|
||||
`SYSTEM4.BIN` and `TITLE.BIN` are not launchable in the first version; recursively dispatching either through
|
||||
SYSTEM4's child slot is not a scene test. Search is case-insensitive over name and hexadecimal/decimal packed
|
||||
id. The detail pane shows name, category, packed/raw id, archive, size, and the fixed warning that launch uses
|
||||
the current live global/profile state. Compatibility or opcode-gap badges are deferred until coverage logic
|
||||
has an engine-owned runtime API; the menu must not parse generated Markdown or call Python tooling.
|
||||
|
||||
The implementation should leave one explicit extension point for future test sequences:
|
||||
`DebugLaunchPreset(label, packed_script_id, extra_global_writes, note)`. Catalog rows use only the four
|
||||
coordinator writes above; profile-authored presets may later add proven story/progression globals without
|
||||
turning the menu into a free-form state editor or save backend. Arbitrary PC/offset jumps are out of scope.
|
||||
|
||||
**Implementation order.** (1) Add catalog script-entry enumeration with packed ids and unit coverage for
|
||||
base/append mounts, placeholders, duplicate names across packs, and category/sort/filter behavior. (2) Add a
|
||||
generic VM debug request targeted at an exact active frame generation; it applies an immutable set of global
|
||||
writes on the VM thread and returns that child at the next opcode boundary. Test SYSTEM4→TITLE→selected child,
|
||||
ordinary child return/cleanup continuation, op-`0x9` propagation, TITLE's sleep/poll loop, and stale/ineligible
|
||||
request rejection. (3) Add the Godot F4 overlay (`PopupPanel`, search/category controls, `ItemList`, detail
|
||||
pane, Launch/Cancel), consume all overlay input, and enable Launch only for the active `SYSTEM4 > TITLE`
|
||||
stack. (4) Add a Godot smoke test for catalog population and request wiring, then
|
||||
manually validate `TITLE -> DEBUG -> 0x9 -> SYSTEM4 -> TITLE` before expanding the selectable categories or
|
||||
adding presets.
|
||||
|
||||
Steps 1–4 are complete. F4 opens the Godot `PopupPanel` only for the exact active
|
||||
`SYSTEM4.BIN > TITLE.BIN` stack; search, category filters, packed-id metadata, guarded Launch, and Cancel are
|
||||
live. `SYSTEM4.BIN` and `TITLE.BIN` remain visible but unlaunchable. While the panel is open, AGE gameplay
|
||||
input is not forwarded. Launch reparses the selected packed id before queuing any writes. The threaded Godot
|
||||
selftest constructs the catalog and panel and currently reports 520 unique packed scripts. Manual validation
|
||||
confirmed `TITLE -> F4 -> DEBUG.BIN`: its four scripted ADV pages at `0xc7`, `0x110`, `0x17b`, and `0x1ed`
|
||||
were presented, its terminal op `0x9` at `0x1fb` ran, and SYSTEM4 reconstructed the visible TITLE menu. No
|
||||
launcher/session-lifecycle discrepancy was observed. DEBUG-specific content oddities are not acceptance
|
||||
failures for this developer route and remain out of scope unless they reproduce in a normal game script.
|
||||
|
||||
## Stage B2 — Faithful full boot
|
||||
|
||||
|
||||
@@ -162,6 +162,16 @@ render texture ops (no GPU context) — run windowed for real scenes. User args
|
||||
`--scene SC0000 --boot --shot-sequence ... --gfx-log ...` to distinguish control-flow stalls from retained-object/compositor
|
||||
failures at an exact bytecode boundary. Relative output paths are project-relative (`godot/`).
|
||||
|
||||
**Godot debug scene launcher:** press **F4** while the natural boot is showing TITLE. TITLE's visible menu is
|
||||
a live 1 ms sleep/input-poll loop rather than an ADV `wait-for-input`; the launcher identifies that exact
|
||||
active child frame and returns it cooperatively at the next completed opcode boundary. The overlay enumerates
|
||||
all base and mounted-append `.BIN` records by packed id, with All/SC/SP/Debug/Other filters, name or exact
|
||||
hexadecimal/decimal id search, and archive/raw-id details. `SYSTEM4.BIN` and `TITLE.BIN` are intentionally
|
||||
unlaunchable. Launch is accepted only for the exact `SYSTEM4.BIN > TITLE.BIN` wait stack; it returns TITLE
|
||||
with the coordinator globals queued on the VM thread, then lets SYSTEM4 perform its normal computed child
|
||||
call. F4 outside TITLE prints an unavailable reason and changes no state. Cancel or Escape closes the panel.
|
||||
The launcher does not seed story/profile state, jump to byte offsets, or force-switch an active child scene.
|
||||
|
||||
**Godot page locator:** every normal run recreates `build/page-map-<SCENE>.jsonl`, adding one record per
|
||||
`wait-for-input` with the run-relative page, page-start location, canonical wait script/offset, last
|
||||
show-text instruction and string offsets, text, and nested call stack. Use `--page-map <jsonl>` to override
|
||||
|
||||
183
engine/Age.Engine.Tests/DebugSceneLaunchTests.cs
Normal file
183
engine/Age.Engine.Tests/DebugSceneLaunchTests.cs
Normal file
@@ -0,0 +1,183 @@
|
||||
using Age.Engine.Diagnostics;
|
||||
using Age.Engine.Hosting;
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Sys4;
|
||||
using Age.Engine.Vm;
|
||||
using Xunit;
|
||||
|
||||
public class DebugSceneLaunchTests
|
||||
{
|
||||
private static Operand I(long value) => new(0, value);
|
||||
private static Operand G(long address) => new(3, address);
|
||||
private static (int, Operand[]) Call(Operand id) => (0x3, new[] { id });
|
||||
private static (int, Operand[]) Mov(int address, long value) => (0x55, new[] { G(address), I(value) });
|
||||
private static (int, Operand[]) Wait() => (0x72, new[] { I(0) });
|
||||
private static (int, Operand[]) Sleep() => (0xc8, new[] { I(1) });
|
||||
private static (int, Operand[]) Exit() => (0x2, Array.Empty<Operand>());
|
||||
|
||||
[Fact]
|
||||
public async Task ParkedTitleFrameReturnsToCoordinatorWhichDispatchesSelectedScript()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
var root = ScriptAssembler.Assemble(table, "SYSTEM4.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
Call(I(1)), Call(G(0x699)), Mov(0x7102, 1), Exit(),
|
||||
}, Array.Empty<string>());
|
||||
var title = ScriptAssembler.Assemble(table, "TITLE.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
Wait(), Mov(0x7100, 1), Exit(),
|
||||
}, Array.Empty<string>());
|
||||
var selected = ScriptAssembler.Assemble(table, "DEBUG.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
Mov(0x7101, 1), Exit(),
|
||||
}, Array.Empty<string>());
|
||||
var host = new BlockingWaitHost();
|
||||
var trace = new RecordingTraceSink();
|
||||
var vm = new VirtualMachine(root, table, host, provider: new MapProvider(new()
|
||||
{
|
||||
[1] = title,
|
||||
[2] = selected,
|
||||
}), sink: trace);
|
||||
|
||||
Assert.False(vm.TryRequestDebugFrameReturn(0, new Dictionary<int, long>()));
|
||||
Task run = Task.Run(() => vm.Run());
|
||||
await host.WaitEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
var parked = Assert.IsType<DebugFrameSnapshot>(vm.DebugFrame);
|
||||
Assert.Equal("TITLE.BIN", parked.CurrentScript);
|
||||
Assert.Equal(new[] { "SYSTEM4.BIN", "TITLE.BIN" }, parked.CallStack);
|
||||
Assert.True(vm.TryRequestDebugFrameReturn(parked.FrameId, new Dictionary<int, long>
|
||||
{
|
||||
[0] = 1,
|
||||
[0xaba5c] = -1,
|
||||
[0x62ccf] = 0,
|
||||
[0x699] = 2,
|
||||
}));
|
||||
Assert.False(vm.TryRequestDebugFrameReturn(parked.FrameId, new Dictionary<int, long>()));
|
||||
host.SignalInput();
|
||||
await run.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(0, vm.Globals.GetValueOrDefault(0x7100));
|
||||
Assert.Equal(1, vm.Globals[0x7101]);
|
||||
Assert.Equal(1, vm.Globals[0x7102]);
|
||||
Assert.Null(vm.DebugFrame);
|
||||
Assert.Contains(trace.Events, e => e.Kind == TraceEventKind.FrameExit
|
||||
&& e.Name == "TITLE.BIN" && e.Text == "DebugReturned");
|
||||
Assert.Contains(trace.Events, e => e.Kind == TraceEventKind.FrameEnter
|
||||
&& e.Name == "DEBUG.BIN" && e.Cause == FrameCause.CallScript);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectedScriptExitScriptStillPerformsWholeStackRootReload()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
var initialRoot = ScriptAssembler.Assemble(table, "SYSTEM4.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
Call(I(1)), Call(G(0x699)), Mov(0x7200, 1), Exit(),
|
||||
}, Array.Empty<string>());
|
||||
var title = ScriptAssembler.Assemble(table, "TITLE.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
Wait(), Exit(),
|
||||
}, Array.Empty<string>());
|
||||
var selected = ScriptAssembler.Assemble(table, "DEBUG.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
(0x9, Array.Empty<Operand>()),
|
||||
}, Array.Empty<string>());
|
||||
var reloadedRoot = ScriptAssembler.Assemble(table, "SYSTEM4.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
Mov(0x7201, 1), Exit(),
|
||||
}, Array.Empty<string>());
|
||||
var host = new BlockingWaitHost();
|
||||
var trace = new RecordingTraceSink();
|
||||
var vm = new VirtualMachine(initialRoot, table, host, provider: new MapProvider(new()
|
||||
{
|
||||
[0] = reloadedRoot,
|
||||
[1] = title,
|
||||
[2] = selected,
|
||||
}), sink: trace);
|
||||
|
||||
Task run = Task.Run(() => vm.Run());
|
||||
await host.WaitEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
var frame = Assert.IsType<DebugFrameSnapshot>(vm.DebugFrame);
|
||||
Assert.True(vm.TryRequestDebugFrameReturn(frame.FrameId,
|
||||
new Dictionary<int, long> { [0x699] = 2 }));
|
||||
host.SignalInput();
|
||||
await run.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(0, vm.Globals.GetValueOrDefault(0x7200));
|
||||
Assert.Equal(1, vm.Globals[0x7201]);
|
||||
Assert.Equal(1, host.SceneContextResets);
|
||||
Assert.Contains(trace.Events, e => e.Kind == TraceEventKind.FrameEnter
|
||||
&& e.Name == "SYSTEM4.BIN" && e.Cause == FrameCause.RootReload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PollingTitleReturnsAtOpcodeBoundaryWithoutAdvInputWait()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
var root = ScriptAssembler.Assemble(table, "SYSTEM4.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
Call(I(1)), Call(G(0x699)), Mov(0x7302, 1), Exit(),
|
||||
}, Array.Empty<string>());
|
||||
var title = ScriptAssembler.Assemble(table, "TITLE.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
Sleep(), Mov(0x7300, 1), Exit(),
|
||||
}, Array.Empty<string>());
|
||||
var selected = ScriptAssembler.Assemble(table, "DEBUG.BIN", new List<(int, Operand[])>
|
||||
{
|
||||
Mov(0x7301, 1), Exit(),
|
||||
}, Array.Empty<string>());
|
||||
var host = new BlockingSleepHost();
|
||||
var vm = new VirtualMachine(root, table, host, provider: new MapProvider(new()
|
||||
{
|
||||
[1] = title,
|
||||
[2] = selected,
|
||||
}));
|
||||
|
||||
Task run = Task.Run(() => vm.Run());
|
||||
await host.SleepEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
var frame = Assert.IsType<DebugFrameSnapshot>(vm.DebugFrame);
|
||||
Assert.Equal(new[] { "SYSTEM4.BIN", "TITLE.BIN" }, frame.CallStack);
|
||||
Assert.True(vm.TryRequestDebugFrameReturn(frame.FrameId,
|
||||
new Dictionary<int, long> { [0x699] = 2 }));
|
||||
host.CompleteSleep();
|
||||
await run.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(0, vm.Globals.GetValueOrDefault(0x7300));
|
||||
Assert.Equal(1, vm.Globals[0x7301]);
|
||||
Assert.Equal(1, vm.Globals[0x7302]);
|
||||
}
|
||||
|
||||
private sealed class BlockingWaitHost : RecordingHost
|
||||
{
|
||||
private readonly SemaphoreSlim _gate = new(0, 1);
|
||||
public TaskCompletionSource WaitEntered { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public override void WaitForInput(int layoutSlot, Func<bool> serviceInputCallback,
|
||||
Func<AdvAutoWaitState> autoWaitState)
|
||||
{
|
||||
Waits++;
|
||||
WaitEntered.TrySetResult();
|
||||
if (!_gate.Wait(TimeSpan.FromSeconds(5))) throw new TimeoutException("test input wait timed out");
|
||||
}
|
||||
|
||||
public void SignalInput() => _gate.Release();
|
||||
}
|
||||
|
||||
private sealed class BlockingSleepHost : RecordingHost
|
||||
{
|
||||
private readonly SemaphoreSlim _gate = new(0, 1);
|
||||
public TaskCompletionSource SleepEntered { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public override void Sleep(long duration)
|
||||
{
|
||||
SleptDurations.Add(duration);
|
||||
SleepEntered.TrySetResult();
|
||||
if (!_gate.Wait(TimeSpan.FromSeconds(5))) throw new TimeoutException("test sleep timed out");
|
||||
}
|
||||
|
||||
public void CompleteSleep() => _gate.Release();
|
||||
}
|
||||
}
|
||||
@@ -17,14 +17,19 @@ public class MovieOpcodeTests
|
||||
(0x130, new[] { new Operand(3, 0x100) }),
|
||||
(0x9, System.Array.Empty<Operand>()),
|
||||
}, System.Array.Empty<string>());
|
||||
var vm = new VirtualMachine(root, table, new RecordingHost());
|
||||
var reloadedRoot = ScriptAssembler.Assemble(table, "SYSTEM4", new List<(int, Operand[])>
|
||||
{
|
||||
(0x130, new[] { new Operand(3, 0x101) }),
|
||||
(0x2, System.Array.Empty<Operand>()),
|
||||
}, System.Array.Empty<string>());
|
||||
var host = new RecordingHost();
|
||||
var vm = new VirtualMachine(root, table, host,
|
||||
provider: new MapProvider(new Dictionary<long, Script> { [0] = reloadedRoot }));
|
||||
|
||||
vm.Run();
|
||||
Assert.Equal(1, vm.Globals[0x100]);
|
||||
|
||||
vm.Globals[0x100] = -1;
|
||||
vm.Run();
|
||||
Assert.Equal(0, vm.Globals[0x100]);
|
||||
Assert.Equal(0, vm.Globals[0x101]);
|
||||
Assert.Equal(1, host.SceneContextResets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
115
engine/Age.Engine.Tests/RootReloadTests.cs
Normal file
115
engine/Age.Engine.Tests/RootReloadTests.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using Age.Engine.Diagnostics;
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Sys4;
|
||||
using Age.Engine.Vm;
|
||||
using Xunit;
|
||||
|
||||
public class RootReloadTests
|
||||
{
|
||||
private static Operand I(long value) => new(0, value);
|
||||
private static Operand G(long address) => new(3, address);
|
||||
|
||||
[Fact]
|
||||
public void ExitScriptDiscardsEveryCallerAndReloadsRawScriptZero()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
var system4 = ScriptAssembler.Assemble(table, "SYSTEM4", new List<(int, Operand[])>
|
||||
{
|
||||
(0x1b6, new[] { G(0x220) }),
|
||||
(0x19a, new[] { G(0x221) }),
|
||||
(0x55, new[] { G(0x202), I(0x99) }),
|
||||
(0x2, Array.Empty<Operand>()),
|
||||
}, Array.Empty<string>());
|
||||
var deepest = ScriptAssembler.Assemble(table, "DEEPEST", new List<(int, Operand[])>
|
||||
{
|
||||
(0x55, new[] { G(0x203), I(3) }),
|
||||
(0x9, Array.Empty<Operand>()),
|
||||
(0x55, new[] { G(0x204), I(4) }),
|
||||
}, Array.Empty<string>());
|
||||
var child = ScriptAssembler.Assemble(table, "CHILD", new List<(int, Operand[])>
|
||||
{
|
||||
(0x3, new[] { I(2) }),
|
||||
(0x55, new[] { G(0x205), I(5) }),
|
||||
(0x2, Array.Empty<Operand>()),
|
||||
}, Array.Empty<string>());
|
||||
var initial = ScriptAssembler.Assemble(table, "INITIAL", new List<(int, Operand[])>
|
||||
{
|
||||
(0x1b7, new[] { I(1) }),
|
||||
(0x88, new[] { I(1) }),
|
||||
(0x55, new[] { G(0x200), I(1) }),
|
||||
(0x3, new[] { I(1) }),
|
||||
(0x55, new[] { G(0x201), I(2) }),
|
||||
(0x2, Array.Empty<Operand>()),
|
||||
}, Array.Empty<string>());
|
||||
var provider = new MapProvider(new Dictionary<long, Script>
|
||||
{
|
||||
[0] = system4,
|
||||
[1] = child,
|
||||
[2] = deepest,
|
||||
});
|
||||
var host = new RecordingHost();
|
||||
var trace = new RecordingTraceSink();
|
||||
var vm = new VirtualMachine(initial, table, host, provider: provider, sink: trace);
|
||||
vm.Globals[0x2ff] = 0x1234;
|
||||
vm.ExternalGlobals[7] = 0x5678;
|
||||
vm.Gfx.SetSurface(5, 0x33, 0);
|
||||
vm.Gfx.BindDraw(0x100, 5, 0, 0, 1, 1, 0, 0);
|
||||
vm.TextHistory.DefineLayout(1, 10, 10, 0, 0);
|
||||
vm.TextHistory.AppendText(1, 0, "preserved", AdvTextStyle.Default);
|
||||
vm.TextHistory.SetRecordingEnabled(false);
|
||||
|
||||
vm.Run();
|
||||
|
||||
Assert.Equal("exit", vm.HaltReason);
|
||||
Assert.Equal(1, vm.Globals[0x200]);
|
||||
Assert.Equal(3, vm.Globals[0x203]);
|
||||
Assert.Equal(0x99, vm.Globals[0x202]);
|
||||
Assert.False(vm.Globals.ContainsKey(0x201));
|
||||
Assert.False(vm.Globals.ContainsKey(0x204));
|
||||
Assert.False(vm.Globals.ContainsKey(0x205));
|
||||
Assert.Equal(0x1234, vm.Globals[0x2ff]);
|
||||
Assert.Equal(0x5678, vm.ExternalGlobals[7]);
|
||||
Assert.Equal(0, vm.Globals[0x220]);
|
||||
Assert.Equal(0, vm.Globals[0x221]);
|
||||
Assert.Empty(vm.Gfx.SnapshotVisibleObjects());
|
||||
Assert.Single(vm.TextHistory.Records, r => r.Text == "preserved");
|
||||
Assert.False(vm.TextHistory.RecordingSuppressed);
|
||||
Assert.Equal(1, host.SceneContextResets);
|
||||
Assert.False(host.MessageSkip);
|
||||
|
||||
var rootEnter = Assert.Single(trace.Events,
|
||||
e => e.Kind == TraceEventKind.FrameEnter && e.Name == "SYSTEM4");
|
||||
Assert.Equal(FrameCause.RootReload, rootEnter.Cause);
|
||||
Assert.Equal(3, trace.Events.Count(e =>
|
||||
e.Kind == TraceEventKind.FrameExit && e.Text == "RootReload"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HimegariRawScriptZeroIsSystem4()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
var scripts = Sys4ScriptProvider.Load(table);
|
||||
|
||||
Assert.Equal("SYSTEM4.BIN", scripts.GetById(0)?.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExitScriptResetsSceneBeforeAnUnresolvedRootLoadFails()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
var script = ScriptAssembler.Assemble(table, "NO_ROOT", new List<(int, Operand[])>
|
||||
{
|
||||
(0x9, Array.Empty<Operand>()),
|
||||
}, Array.Empty<string>());
|
||||
var host = new RecordingHost();
|
||||
var vm = new VirtualMachine(script, table, host);
|
||||
vm.Gfx.SetSurface(5, 0x33, 0);
|
||||
vm.Gfx.BindDraw(0x100, 5, 0, 0, 1, 1, 0, 0);
|
||||
|
||||
vm.Run();
|
||||
|
||||
Assert.Equal("root-reload-unresolved:0x0", vm.HaltReason);
|
||||
Assert.Equal(1, host.SceneContextResets);
|
||||
Assert.Empty(vm.Gfx.SnapshotVisibleObjects());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Age.Engine.Sys4;
|
||||
using Age.Engine.Diagnostics;
|
||||
using Xunit;
|
||||
|
||||
public class Sys4ScriptProviderTests
|
||||
@@ -46,4 +47,44 @@ public class Sys4ScriptProviderTests
|
||||
Assert.Null(provider.GetByName("../FIELD.BIN"));
|
||||
Assert.Equal(481, provider.ScriptNames.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DebugCatalogPreservesPackedIdentityAcrossBaseAndAppendPacks()
|
||||
{
|
||||
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
|
||||
var entries = DebugSceneCatalog.Build(catalog);
|
||||
|
||||
Assert.Equal(catalog.EnumerateScripts().Count, entries.Count);
|
||||
Assert.Equal(481, entries.Count(entry => entry.PackId == 0));
|
||||
Assert.Contains(entries, entry => entry.Name == "$1$SC1260.BIN"
|
||||
&& entry.PackedId == (0x01000000L | (uint)entry.RawIndex)
|
||||
&& entry.Kind == DebugScriptKind.Scenario);
|
||||
Assert.All(entries, entry => Assert.Same(
|
||||
catalog.ResolvePacked(entry.PackedId),
|
||||
catalog.AppendPacks.GetValueOrDefault(entry.PackId, catalog).ResolveRaw(entry.RawIndex)));
|
||||
Assert.Equal(entries.Count, entries.Select(entry => entry.PackedId).Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DebugCatalogFiltersByProfileCategoryNameAndPackedId()
|
||||
{
|
||||
var entries = DebugSceneCatalog.Build(Sys4AssetCatalog.Load(Paths.Sys4Ini));
|
||||
|
||||
var scenarios = DebugSceneCatalog.Filter(entries, DebugScriptFilter.Scenario, "");
|
||||
Assert.NotEmpty(scenarios);
|
||||
Assert.All(scenarios, entry => Assert.Equal(DebugScriptKind.Scenario, entry.Kind));
|
||||
Assert.Contains(scenarios, entry => entry.Name.Equals("SC0000.BIN", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var debug = DebugSceneCatalog.Filter(entries, DebugScriptFilter.Debug, "DEBUG.BIN");
|
||||
var exact = Assert.Single(debug);
|
||||
Assert.Equal("DEBUG.BIN", exact.Name);
|
||||
Assert.True(exact.Launchable);
|
||||
Assert.Equal(exact, Assert.Single(DebugSceneCatalog.Filter(entries, DebugScriptFilter.All,
|
||||
$"0x{exact.PackedId:x}")));
|
||||
Assert.Equal(exact, Assert.Single(DebugSceneCatalog.Filter(entries, DebugScriptFilter.All,
|
||||
exact.PackedId.ToString())));
|
||||
|
||||
Assert.False(entries.Single(entry => entry.Name == "SYSTEM4.BIN").Launchable);
|
||||
Assert.False(entries.Single(entry => entry.Name == "TITLE.BIN").Launchable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ internal class RecordingHost : IHost
|
||||
public readonly List<long> CursorResources = new();
|
||||
public readonly List<bool> AdvPagePresentationSuspended = new();
|
||||
public int CursorClearCount;
|
||||
public int SceneContextResets;
|
||||
public void ShowText(int offset, string text) => Lines.Add((offset, text));
|
||||
public void SetAdvTextCursor(int layoutSlot, int x, int y) => TextCursors.Add((layoutSlot, x, y));
|
||||
public void DrawStringToSurface(int surfaceSlot, int x, int y, string text)
|
||||
@@ -88,6 +89,7 @@ internal class RecordingHost : IHost
|
||||
public void ClearCursorResource() => CursorClearCount++;
|
||||
public virtual void Sleep(long duration) => SleptDurations.Add(duration);
|
||||
public virtual void FrameYield() { }
|
||||
public void ResetSceneContext() => SceneContextResets++;
|
||||
public bool IsMessageSkipActive => MessageSkip;
|
||||
public void SetMessageSkipActive(bool active)
|
||||
{
|
||||
|
||||
135
engine/Age.Engine/Diagnostics/DebugSceneCatalog.cs
Normal file
135
engine/Age.Engine/Diagnostics/DebugSceneCatalog.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Age.Engine.Sys4;
|
||||
|
||||
namespace Age.Engine.Diagnostics;
|
||||
|
||||
public enum DebugScriptKind { Scenario, SecondaryEvent, Debug, Other }
|
||||
public enum DebugScriptFilter { All, Scenario, SecondaryEvent, Debug, Other }
|
||||
|
||||
/// <summary>One script shown by the developer scene launcher. PackedId, rather than Name, is its identity.</summary>
|
||||
public sealed record DebugSceneEntry(
|
||||
long PackedId,
|
||||
string Name,
|
||||
string Archive,
|
||||
long Size,
|
||||
int PackId,
|
||||
int RawIndex,
|
||||
DebugScriptKind Kind,
|
||||
bool Launchable);
|
||||
|
||||
/// <summary>Future profile-owned extension for a proven launch state; catalog rows use no extra writes.</summary>
|
||||
public sealed record DebugLaunchPreset(
|
||||
string Label,
|
||||
long PackedScriptId,
|
||||
IReadOnlyDictionary<int, long> ExtraGlobalWrites,
|
||||
string Note);
|
||||
|
||||
/// <summary>Pure catalog/filter model shared by the Godot developer UI and unit tests.</summary>
|
||||
public static partial class DebugSceneCatalog
|
||||
{
|
||||
public static IReadOnlyList<DebugSceneEntry> Build(Sys4AssetCatalog catalog)
|
||||
=> catalog.EnumerateScripts()
|
||||
.Select(item =>
|
||||
{
|
||||
string logicalName = StripAppendPrefix(item.Asset.Name);
|
||||
return new DebugSceneEntry(
|
||||
item.PackedId,
|
||||
item.Asset.Name,
|
||||
item.Asset.Archive,
|
||||
item.Asset.Size,
|
||||
item.Asset.PackId,
|
||||
item.Asset.RawIndex,
|
||||
Classify(logicalName),
|
||||
!logicalName.Equals("SYSTEM4.BIN", StringComparison.OrdinalIgnoreCase)
|
||||
&& !logicalName.Equals("TITLE.BIN", StringComparison.OrdinalIgnoreCase));
|
||||
})
|
||||
.OrderBy(entry => KindRank(entry.Kind))
|
||||
.ThenBy(entry => entry.Name, NaturalNameComparer.Instance)
|
||||
.ThenBy(entry => entry.PackedId)
|
||||
.ToArray();
|
||||
|
||||
public static IReadOnlyList<DebugSceneEntry> Filter(
|
||||
IEnumerable<DebugSceneEntry> entries, DebugScriptFilter filter, string? query)
|
||||
{
|
||||
string needle = (query ?? "").Trim();
|
||||
return entries.Where(entry => MatchesFilter(entry, filter) && MatchesQuery(entry, needle)).ToArray();
|
||||
}
|
||||
|
||||
public static DebugScriptKind Classify(string name)
|
||||
{
|
||||
string logicalName = StripAppendPrefix(Path.GetFileName(name));
|
||||
if (ScenarioName().IsMatch(logicalName)) return DebugScriptKind.Scenario;
|
||||
if (logicalName.StartsWith("SP", StringComparison.OrdinalIgnoreCase))
|
||||
return DebugScriptKind.SecondaryEvent;
|
||||
if (logicalName.StartsWith("DEBUG", StringComparison.OrdinalIgnoreCase))
|
||||
return DebugScriptKind.Debug;
|
||||
return DebugScriptKind.Other;
|
||||
}
|
||||
|
||||
private static bool MatchesFilter(DebugSceneEntry entry, DebugScriptFilter filter)
|
||||
=> filter == DebugScriptFilter.All || (int)entry.Kind == (int)filter - 1;
|
||||
|
||||
private static bool MatchesQuery(DebugSceneEntry entry, string query)
|
||||
{
|
||||
if (query.Length == 0) return true;
|
||||
if (entry.Name.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (query.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||
&& long.TryParse(query.AsSpan(2), NumberStyles.AllowHexSpecifier,
|
||||
CultureInfo.InvariantCulture, out long hex))
|
||||
return entry.PackedId == hex;
|
||||
return long.TryParse(query, NumberStyles.Integer, CultureInfo.InvariantCulture, out long dec)
|
||||
&& entry.PackedId == dec;
|
||||
}
|
||||
|
||||
private static string StripAppendPrefix(string name) => AppendPrefix().Replace(name, "", 1);
|
||||
private static int KindRank(DebugScriptKind kind) => kind switch
|
||||
{
|
||||
DebugScriptKind.Scenario => 0,
|
||||
DebugScriptKind.SecondaryEvent => 1,
|
||||
DebugScriptKind.Debug => 2,
|
||||
_ => 3,
|
||||
};
|
||||
|
||||
[GeneratedRegex(@"^SC\d{4}\.BIN$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ScenarioName();
|
||||
|
||||
[GeneratedRegex(@"^\$\d+\$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex AppendPrefix();
|
||||
|
||||
private sealed class NaturalNameComparer : IComparer<string>
|
||||
{
|
||||
public static NaturalNameComparer Instance { get; } = new();
|
||||
|
||||
public int Compare(string? left, string? right)
|
||||
{
|
||||
left ??= "";
|
||||
right ??= "";
|
||||
int li = 0, ri = 0;
|
||||
while (li < left.Length && ri < right.Length)
|
||||
{
|
||||
if (char.IsDigit(left[li]) && char.IsDigit(right[ri]))
|
||||
{
|
||||
int lstart = li, rstart = ri;
|
||||
while (li < left.Length && char.IsDigit(left[li])) li++;
|
||||
while (ri < right.Length && char.IsDigit(right[ri])) ri++;
|
||||
ReadOnlySpan<char> ln = left.AsSpan(lstart, li - lstart).TrimStart('0');
|
||||
ReadOnlySpan<char> rn = right.AsSpan(rstart, ri - rstart).TrimStart('0');
|
||||
int length = ln.Length.CompareTo(rn.Length);
|
||||
if (length != 0) return length;
|
||||
int numeric = ln.CompareTo(rn, StringComparison.Ordinal);
|
||||
if (numeric != 0) return numeric;
|
||||
int padded = (li - lstart).CompareTo(ri - rstart);
|
||||
if (padded != 0) return padded;
|
||||
continue;
|
||||
}
|
||||
|
||||
int character = char.ToUpperInvariant(left[li]).CompareTo(char.ToUpperInvariant(right[ri]));
|
||||
if (character != 0) return character;
|
||||
li++;
|
||||
ri++;
|
||||
}
|
||||
return (left.Length - li).CompareTo(right.Length - ri);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ using Age.Engine.Model;
|
||||
namespace Age.Engine.Diagnostics;
|
||||
|
||||
public enum TraceEventKind { Step, FrameEnter, FrameExit, CallScript, Stub, Halt }
|
||||
public enum FrameCause { TopScene, CallScript }
|
||||
public enum FrameCause { TopScene, CallScript, RootReload }
|
||||
|
||||
/// <summary>An engine diagnostic fact. A <c>readonly struct</c> with a Kind discriminator and a shared
|
||||
/// field set — no per-event heap allocation. Only the fields relevant to a Kind are populated; the
|
||||
|
||||
@@ -65,6 +65,9 @@ public interface IHost
|
||||
void ClearCursorResource() { }
|
||||
void Sleep(long duration);
|
||||
void FrameYield();
|
||||
// Native op 0x9 resets scene-owned host services before reloading root script resource 0.
|
||||
// Global banks, engine configuration, decoded-asset caches, and persistent profile state survive.
|
||||
void ResetSceneContext() { }
|
||||
// Native 0x1c7/0x1cc query two distinct ADV skip channels. Headless and non-interactive
|
||||
// hosts default to normal playback; the Godot host supplies the live interactive values.
|
||||
void SetMessageSkipActive(bool active) { }
|
||||
|
||||
@@ -237,6 +237,24 @@ public sealed class GfxState
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Native scene_context_init_reset ownership boundary used by opcode 0x9: discard
|
||||
/// retained objects, command/query state, surfaces, transitions, render-target selection, and the
|
||||
/// scene animation clock while leaving VM globals and decoded host assets outside this model.</summary>
|
||||
public void ResetSceneContext()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_objects.Clear();
|
||||
_fieldTable.Clear();
|
||||
_surfaces.Clear();
|
||||
_surfaceTransitions.Clear();
|
||||
CurrentObject = 0;
|
||||
CurrentRenderTargetSlot = -1;
|
||||
AnimClockDurationTicks = 0;
|
||||
AnimClockGeneration++;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly object _lock = new();
|
||||
|
||||
// ---- surfaces (image buffers per slot): ctx+0x52bd4[slot], from create/set-texture ----
|
||||
|
||||
@@ -16,6 +16,9 @@ public sealed record AssetEntry(
|
||||
bool IsPlaceholder = false,
|
||||
int PackId = 0);
|
||||
|
||||
/// <summary>A real catalog entry paired with the packed resource id AGE uses at runtime.</summary>
|
||||
public sealed record PackedAssetEntry(long PackedId, AssetEntry Asset);
|
||||
|
||||
/// <summary>Runtime parser and lookup views for a base S4IC SYS4INI catalog and its S4AC append mounts.</summary>
|
||||
public sealed class Sys4AssetCatalog
|
||||
{
|
||||
@@ -172,6 +175,25 @@ public sealed class Sys4AssetCatalog
|
||||
.Where(f => f.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(f => f.Name.ToUpperInvariant()).ToArray();
|
||||
|
||||
/// <summary>Enumerate every script in native packed-id order, including mounted append packs.
|
||||
/// Placeholder slots and non-script assets are excluded without collapsing raw indices.</summary>
|
||||
public IReadOnlyList<PackedAssetEntry> EnumerateScripts()
|
||||
{
|
||||
var scripts = new List<PackedAssetEntry>();
|
||||
AddScripts(this, scripts);
|
||||
foreach (var append in _appendPacks.OrderBy(pair => pair.Key).Select(pair => pair.Value))
|
||||
AddScripts(append, scripts);
|
||||
return scripts;
|
||||
}
|
||||
|
||||
private static void AddScripts(Sys4AssetCatalog catalog, List<PackedAssetEntry> scripts)
|
||||
{
|
||||
long selector = (long)catalog.PackId << 24;
|
||||
foreach (var entry in catalog.Files)
|
||||
if (entry.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase))
|
||||
scripts.Add(new PackedAssetEntry(selector | (uint)entry.RawIndex, entry));
|
||||
}
|
||||
|
||||
private static Dictionary<string, (int Start, int End)> BuildSceneRanges(IReadOnlyList<AssetEntry> files)
|
||||
{
|
||||
var ranges = new Dictionary<string, (int Start, int End)>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -3,12 +3,16 @@ using Age.Engine.Hosting;
|
||||
using Age.Engine.Model;
|
||||
namespace Age.Engine.Vm;
|
||||
|
||||
/// <summary>A stable identity/snapshot of the exact script frame currently executing.</summary>
|
||||
public sealed record DebugFrameSnapshot(long FrameId, string CurrentScript, IReadOnlyList<string> CallStack);
|
||||
|
||||
public sealed class VirtualMachine
|
||||
{
|
||||
private const long NoJump = 0xFFFFFFFF;
|
||||
private const int HALT = int.MinValue;
|
||||
private const int FRAME_RETURN = int.MinValue + 1;
|
||||
private const int HOTSPOT_RETURN = int.MinValue + 2;
|
||||
private const int ROOT_RELOAD = int.MinValue + 3;
|
||||
private const int SceneEntryCoroutineGate = 0xaba5c;
|
||||
private const int T_IMM = 0, T_STR = 2, T_GINT = 3, T_GFLOAT = 4, T_GSTR = 5, T_GPTR = 6,
|
||||
T_GSTRPTR = 8, T_LINT = 9, T_LFLOAT = 10, T_LSTR = 11, T_LPTR = 12,
|
||||
@@ -24,6 +28,12 @@ public sealed class VirtualMachine
|
||||
private int _depth;
|
||||
private readonly ITraceSink _sink;
|
||||
private readonly object _interactiveLock = new();
|
||||
private readonly object _debugControlLock = new();
|
||||
private readonly List<string> _activeFrameNames = new();
|
||||
private ExecFrame? _debugActiveFrame;
|
||||
private long _debugActiveFrameId;
|
||||
private long _debugNextFrameId;
|
||||
private DebugFrameReturnRequest? _debugFrameReturnRequest;
|
||||
private ExecFrame? _interactiveFrame;
|
||||
private ExecFrame? _rawInputFrame;
|
||||
private int _pointerX = int.MinValue, _pointerY = int.MinValue;
|
||||
@@ -59,12 +69,40 @@ public sealed class VirtualMachine
|
||||
}
|
||||
public AdvTextHistory TextHistory { get; }
|
||||
|
||||
/// <summary>The currently executing recursive script frame and stack, or null outside VM execution.</summary>
|
||||
public DebugFrameSnapshot? DebugFrame
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_debugControlLock)
|
||||
return _debugActiveFrame == null
|
||||
? null
|
||||
: new DebugFrameSnapshot(_debugActiveFrameId, _debugActiveFrame.Script.Name,
|
||||
_activeFrameNames.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null,
|
||||
IScriptProvider? provider = null, ITraceSink? sink = null,
|
||||
AdvTextHistory? textHistory = null)
|
||||
{ _s = s; _t = t; _host = host; _o = o ?? new VmOptions(); _provider = provider;
|
||||
_sink = sink ?? NullTraceSink.Instance; TextHistory = textHistory ?? new AdvTextHistory(); }
|
||||
|
||||
/// <summary>Queue global writes and return only the identified active frame at its next opcode boundary.
|
||||
/// Writes are copied here and applied by the VM thread before another opcode executes.</summary>
|
||||
public bool TryRequestDebugFrameReturn(long frameId, IReadOnlyDictionary<int, long> globalWrites)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(globalWrites);
|
||||
lock (_debugControlLock)
|
||||
{
|
||||
if (_debugActiveFrame == null || _debugActiveFrameId != frameId
|
||||
|| _debugFrameReturnRequest != null) return false;
|
||||
_debugFrameReturnRequest = new DebugFrameReturnRequest(
|
||||
_debugActiveFrame, new Dictionary<int, long>(globalWrites));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Update the native 800x600 cursor coordinate without advancing the current ADV page.</summary>
|
||||
public void UpdatePointer(int x, int y)
|
||||
{
|
||||
@@ -315,7 +353,9 @@ public sealed class VirtualMachine
|
||||
? ReadStr(operand)
|
||||
: unchecked((int)Read(operand)).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
private enum FrameOutcome { Returned, Halted, RanOff }
|
||||
private sealed class RootReloadRequestedException : Exception { }
|
||||
private sealed record DebugFrameReturnRequest(ExecFrame Frame, IReadOnlyDictionary<int, long> GlobalWrites);
|
||||
private enum FrameOutcome { Returned, DebugReturned, RootReload, Halted, RanOff }
|
||||
|
||||
public void Run(int entryOffset = 0)
|
||||
{
|
||||
@@ -324,14 +364,65 @@ public sealed class VirtualMachine
|
||||
if (entryOffset == 0 && _s.Instructions.Any(ins => IsAdvLabeledYield(_s, ins)))
|
||||
Globals[SceneEntryCoroutineGate] = 1;
|
||||
|
||||
var top = new ExecFrame(_s, _s.IndexByOffset.TryGetValue(entryOffset, out var idx) ? idx : 0);
|
||||
var outcome = RunFrame(top, FrameCause.TopScene);
|
||||
if (outcome == FrameOutcome.RanOff) HaltReason ??= "pc-out-of-range";
|
||||
else if (outcome == FrameOutcome.Returned) HaltReason ??= "exit";
|
||||
// Halted: HaltReason already set by the halting op.
|
||||
Script root = _s;
|
||||
int rootEntry = root.IndexByOffset.TryGetValue(entryOffset, out var idx) ? idx : 0;
|
||||
FrameCause cause = FrameCause.TopScene;
|
||||
while (true)
|
||||
{
|
||||
var outcome = RunFrame(new ExecFrame(root, rootEntry), cause);
|
||||
if (outcome == FrameOutcome.RootReload)
|
||||
{
|
||||
// Native 0x9 performs the scene reset before attempting the resource-0 load. Keep
|
||||
// that ordering even when a diagnostic provider cannot resolve the root script.
|
||||
ResetSceneContextForRootReload();
|
||||
var reloaded = _provider?.GetById(0);
|
||||
if (reloaded == null)
|
||||
{
|
||||
HaltReason ??= "root-reload-unresolved:0x0";
|
||||
break;
|
||||
}
|
||||
root = reloaded;
|
||||
rootEntry = root.IndexByOffset.TryGetValue(0, out int ri) ? ri : 0;
|
||||
cause = FrameCause.RootReload;
|
||||
continue;
|
||||
}
|
||||
if (outcome == FrameOutcome.RanOff) HaltReason ??= "pc-out-of-range";
|
||||
else if (outcome is FrameOutcome.Returned or FrameOutcome.DebugReturned) HaltReason ??= "exit";
|
||||
// Halted: HaltReason already set by the halting op.
|
||||
break;
|
||||
}
|
||||
_sink.Emit(TraceEvent.Halt(HaltReason ?? "unknown", Steps));
|
||||
}
|
||||
|
||||
private void ResetSceneContextForRootReload()
|
||||
{
|
||||
Gfx.ResetSceneContext();
|
||||
_valueSwitchTargets.Clear();
|
||||
lock (_interactiveLock)
|
||||
{
|
||||
_interactiveFrame = null;
|
||||
_rawInputFrame = null;
|
||||
_mouseButtonState = 0;
|
||||
_mouseWheelDelta = 0;
|
||||
_heldInputCallbackMask = 0;
|
||||
_queuedInputCallbackMask = 0;
|
||||
}
|
||||
lock (_debugControlLock)
|
||||
{
|
||||
_debugActiveFrame = null;
|
||||
_debugActiveFrameId = 0;
|
||||
_debugFrameReturnRequest = null;
|
||||
}
|
||||
_autoMessageEnabled = false;
|
||||
_autoVoicePending = false;
|
||||
_messageSkipEnabled = false;
|
||||
_messageSkipServiceActive = false;
|
||||
_advTextStyle = AdvTextStyle.Default;
|
||||
TextHistory.SetRecordingEnabled(true);
|
||||
_host.SetMessageSkipActive(false);
|
||||
_host.ResetSceneContext();
|
||||
}
|
||||
|
||||
private FrameOutcome RunFrame(ExecFrame frame, FrameCause cause, long callId = 0)
|
||||
{
|
||||
ExecFrame? previousInteractiveFrame;
|
||||
@@ -342,6 +433,16 @@ public sealed class VirtualMachine
|
||||
previousRawInputFrame = _rawInputFrame;
|
||||
}
|
||||
var prev = _cur; _cur = frame; _depth++;
|
||||
ExecFrame? previousDebugActiveFrame;
|
||||
long previousDebugActiveFrameId;
|
||||
lock (_debugControlLock)
|
||||
{
|
||||
previousDebugActiveFrame = _debugActiveFrame;
|
||||
previousDebugActiveFrameId = _debugActiveFrameId;
|
||||
_debugActiveFrame = frame;
|
||||
_debugActiveFrameId = ++_debugNextFrameId;
|
||||
_activeFrameNames.Add(frame.Script.Name);
|
||||
}
|
||||
bool hostContextEntered = false;
|
||||
try
|
||||
{
|
||||
@@ -350,22 +451,35 @@ public sealed class VirtualMachine
|
||||
_sink.Emit(TraceEvent.FrameEnter(frame.Script.Name, _depth, cause, callId));
|
||||
var outcome = FrameOutcome.RanOff;
|
||||
int pc = frame.Pc;
|
||||
while (pc >= 0 && pc < frame.Script.Instructions.Count)
|
||||
try
|
||||
{
|
||||
if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; outcome = FrameOutcome.Halted; break; }
|
||||
Steps++;
|
||||
if (_sink.TracingSteps) _sink.Emit(TraceEvent.Step(pc, frame.Script.Instructions[pc], _depth));
|
||||
int next = Step(frame.Script.Instructions[pc], pc);
|
||||
_host.FrameYield();
|
||||
if (next == FRAME_RETURN) { outcome = FrameOutcome.Returned; break; }
|
||||
if (next == HALT) { outcome = FrameOutcome.Halted; break; }
|
||||
pc = next;
|
||||
while (pc >= 0 && pc < frame.Script.Instructions.Count)
|
||||
{
|
||||
if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; outcome = FrameOutcome.Halted; break; }
|
||||
Steps++;
|
||||
if (_sink.TracingSteps) _sink.Emit(TraceEvent.Step(pc, frame.Script.Instructions[pc], _depth));
|
||||
int next = Step(frame.Script.Instructions[pc], pc);
|
||||
_host.FrameYield();
|
||||
if (next == FRAME_RETURN) { outcome = FrameOutcome.Returned; break; }
|
||||
if (next == ROOT_RELOAD) { outcome = FrameOutcome.RootReload; break; }
|
||||
if (next == HALT) { outcome = FrameOutcome.Halted; break; }
|
||||
if (TryConsumeDebugFrameReturn(frame)) { outcome = FrameOutcome.DebugReturned; break; }
|
||||
pc = next;
|
||||
}
|
||||
}
|
||||
catch (RootReloadRequestedException) { outcome = FrameOutcome.RootReload; }
|
||||
_sink.Emit(TraceEvent.FrameExit(frame.Script.Name, _depth, outcome.ToString()));
|
||||
return outcome;
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_debugControlLock)
|
||||
{
|
||||
if (ReferenceEquals(_debugFrameReturnRequest?.Frame, frame)) _debugFrameReturnRequest = null;
|
||||
if (_activeFrameNames.Count > 0) _activeFrameNames.RemoveAt(_activeFrameNames.Count - 1);
|
||||
_debugActiveFrame = previousDebugActiveFrame;
|
||||
_debugActiveFrameId = previousDebugActiveFrameId;
|
||||
}
|
||||
lock (_interactiveLock)
|
||||
{
|
||||
if (cause == FrameCause.CallScript)
|
||||
@@ -386,6 +500,20 @@ public sealed class VirtualMachine
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryConsumeDebugFrameReturn(ExecFrame frame)
|
||||
{
|
||||
if (Volatile.Read(ref _debugFrameReturnRequest) is not { } pending
|
||||
|| !ReferenceEquals(pending.Frame, frame)) return false;
|
||||
lock (_debugControlLock)
|
||||
{
|
||||
if (!ReferenceEquals(_debugFrameReturnRequest?.Frame, frame)) return false;
|
||||
foreach (var (address, value) in _debugFrameReturnRequest.GlobalWrites)
|
||||
Globals[address] = value;
|
||||
_debugFrameReturnRequest = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private bool ServiceHotspotCallback()
|
||||
{
|
||||
int target;
|
||||
@@ -403,6 +531,7 @@ public sealed class VirtualMachine
|
||||
if (_sink.TracingSteps) _sink.Emit(TraceEvent.Step(pc, _cur.Script.Instructions[pc], _depth));
|
||||
int next = Step(_cur.Script.Instructions[pc], pc);
|
||||
_host.FrameYield();
|
||||
if (next == ROOT_RELOAD) throw new RootReloadRequestedException();
|
||||
if (next == HOTSPOT_RETURN || next == FRAME_RETURN) break;
|
||||
if (next == HALT) break;
|
||||
pc = next;
|
||||
@@ -593,11 +722,10 @@ public sealed class VirtualMachine
|
||||
}
|
||||
case "exit": return FRAME_RETURN;
|
||||
case "exit-script":
|
||||
// Native op 0x9 clears the process-initial root flag before returning control to
|
||||
// the root-script loader. Root reload itself remains represented by the port's
|
||||
// existing frame/session boundary; retaining the flag here prevents LOGO/OP replay.
|
||||
// Native op 0x9 clears the process-initial flag, disposes every active script frame,
|
||||
// resets scene-owned services, and loads raw script resource 0 as the new root.
|
||||
_initialRootRun = false;
|
||||
return FRAME_RETURN;
|
||||
return ROOT_RELOAD;
|
||||
case "call-script":
|
||||
{
|
||||
long id = a.Count > 0 ? Read(a[0]) : 0;
|
||||
@@ -614,6 +742,7 @@ public sealed class VirtualMachine
|
||||
var entry = child.IndexByOffset.TryGetValue(0, out var ci) ? ci : 0;
|
||||
var outcome = RunFrame(new ExecFrame(child, entry), FrameCause.CallScript, id);
|
||||
if (outcome == FrameOutcome.Halted) return HALT; // propagate whole-VM halt up
|
||||
if (outcome == FrameOutcome.RootReload) return ROOT_RELOAD; // discard every caller frame
|
||||
return pc + 1; // Returned / RanOff: resume caller
|
||||
}
|
||||
case "show-text":
|
||||
|
||||
127
godot/DebugSceneLauncher.cs
Normal file
127
godot/DebugSceneLauncher.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Age.Engine.Diagnostics;
|
||||
using Godot;
|
||||
|
||||
/// <summary>Godot-only developer overlay. Runtime transition policy remains in Main/VirtualMachine.</summary>
|
||||
public partial class DebugSceneLauncher : PopupPanel
|
||||
{
|
||||
private readonly LineEdit _search = new() { PlaceholderText = "Name or exact packed id (0x...)" };
|
||||
private readonly OptionButton _category = new();
|
||||
private readonly ItemList _list = new() { SelectMode = ItemList.SelectModeEnum.Single };
|
||||
private readonly Label _details = new() { AutowrapMode = TextServer.AutowrapMode.WordSmart };
|
||||
private readonly Label _status = new() { AutowrapMode = TextServer.AutowrapMode.WordSmart };
|
||||
private readonly Button _launch = new() { Text = "Launch", Disabled = true };
|
||||
private IReadOnlyList<DebugSceneEntry> _all = Array.Empty<DebugSceneEntry>();
|
||||
private IReadOnlyList<DebugSceneEntry> _visible = Array.Empty<DebugSceneEntry>();
|
||||
private DebugSceneEntry? _selected;
|
||||
private string _currentContext = "";
|
||||
|
||||
public event Action<DebugSceneEntry>? LaunchRequested;
|
||||
|
||||
public DebugSceneLauncher()
|
||||
{
|
||||
Title = "AGE Debug Scene Launcher";
|
||||
Exclusive = true;
|
||||
|
||||
var margin = new MarginContainer();
|
||||
margin.AddThemeConstantOverride("margin_left", 14);
|
||||
margin.AddThemeConstantOverride("margin_top", 14);
|
||||
margin.AddThemeConstantOverride("margin_right", 14);
|
||||
margin.AddThemeConstantOverride("margin_bottom", 14);
|
||||
AddChild(margin);
|
||||
margin.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
|
||||
|
||||
var column = new VBoxContainer();
|
||||
margin.AddChild(column);
|
||||
|
||||
var heading = new Label { Text = "Launch a packed SYS4 script through SYSTEM4" };
|
||||
heading.AddThemeFontSizeOverride("font_size", 18);
|
||||
column.AddChild(heading);
|
||||
|
||||
var filters = new HBoxContainer();
|
||||
column.AddChild(filters);
|
||||
_category.AddItem("All");
|
||||
_category.AddItem("Scenario (SC)");
|
||||
_category.AddItem("Secondary / Event (SP)");
|
||||
_category.AddItem("Debug");
|
||||
_category.AddItem("Other / Expert");
|
||||
_category.CustomMinimumSize = new Vector2(190, 0);
|
||||
filters.AddChild(_category);
|
||||
_search.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
filters.AddChild(_search);
|
||||
|
||||
_list.CustomMinimumSize = new Vector2(0, 290);
|
||||
_list.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||
column.AddChild(_list);
|
||||
|
||||
_details.CustomMinimumSize = new Vector2(0, 76);
|
||||
column.AddChild(_details);
|
||||
_status.CustomMinimumSize = new Vector2(0, 34);
|
||||
column.AddChild(_status);
|
||||
|
||||
var actions = new HBoxContainer { Alignment = BoxContainer.AlignmentMode.End };
|
||||
column.AddChild(actions);
|
||||
var cancel = new Button { Text = "Cancel" };
|
||||
actions.AddChild(cancel);
|
||||
actions.AddChild(_launch);
|
||||
|
||||
_search.TextChanged += _ => Refresh();
|
||||
_category.ItemSelected += _ => Refresh();
|
||||
_list.ItemSelected += SelectEntry;
|
||||
_list.ItemActivated += SelectAndLaunch;
|
||||
cancel.Pressed += Hide;
|
||||
_launch.Pressed += RequestLaunch;
|
||||
}
|
||||
|
||||
public void Open(IReadOnlyList<DebugSceneEntry> entries, string currentContext)
|
||||
{
|
||||
_all = entries;
|
||||
_currentContext = currentContext;
|
||||
_status.Text = "";
|
||||
Refresh();
|
||||
PopupCentered(new Vector2I(700, 540));
|
||||
_search.GrabFocus();
|
||||
}
|
||||
|
||||
public void SetStatus(string message) => _status.Text = message;
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
var filter = (DebugScriptFilter)_category.Selected;
|
||||
_visible = DebugSceneCatalog.Filter(_all, filter, _search.Text);
|
||||
_list.Clear();
|
||||
foreach (var entry in _visible)
|
||||
_list.AddItem($"{entry.Name} 0x{entry.PackedId:x8}");
|
||||
_selected = null;
|
||||
_launch.Disabled = true;
|
||||
_details.Text = $"{_visible.Count} scripts shown. Current: {_currentContext}";
|
||||
}
|
||||
|
||||
private void SelectEntry(long index)
|
||||
{
|
||||
if (index < 0 || index >= _visible.Count) return;
|
||||
_selected = _visible[(int)index];
|
||||
_launch.Disabled = !_selected.Launchable;
|
||||
string guard = _selected.Launchable
|
||||
? "Launch returns TITLE to SYSTEM4, which performs the actual script dispatch. " +
|
||||
"Current live globals/profile state is retained; no story state is synthesized."
|
||||
: "Protected coordinator/root script; direct launch is disabled.";
|
||||
_details.Text =
|
||||
$"{_selected.Name} [{_selected.Kind}]\n" +
|
||||
$"packed=0x{_selected.PackedId:x8} ({_selected.PackedId}) " +
|
||||
$"pack={_selected.PackId} raw=0x{_selected.RawIndex:x} " +
|
||||
$"archive={_selected.Archive} size={_selected.Size:N0}\n{guard}";
|
||||
}
|
||||
|
||||
private void SelectAndLaunch(long index)
|
||||
{
|
||||
SelectEntry(index);
|
||||
RequestLaunch();
|
||||
}
|
||||
|
||||
private void RequestLaunch()
|
||||
{
|
||||
if (_selected is { Launchable: true } selected) LaunchRequested?.Invoke(selected);
|
||||
}
|
||||
}
|
||||
@@ -491,6 +491,44 @@ public sealed class GodotAdvHost : IHost
|
||||
_frameSignal.Set();
|
||||
}
|
||||
|
||||
public void ResetSceneContext()
|
||||
{
|
||||
// scene_context_init_reset releases ordinary surface/movie bindings but keeps decoded asset
|
||||
// caches and process-owned audio/configuration available to the reloaded SYSTEM4 root.
|
||||
ReleaseSurfaceRange(0, 1000);
|
||||
lock (_textLock)
|
||||
{
|
||||
_surfaceText.Clear();
|
||||
_surfaceResources.Clear();
|
||||
_historyText.Clear();
|
||||
_advText = "";
|
||||
_advTextX = 100;
|
||||
_advTextY = 47;
|
||||
_advTextForceComplete = false;
|
||||
_waitIndicators.Clear();
|
||||
_activeWaitLayout = 0;
|
||||
_waitIndicatorEnabled = false;
|
||||
}
|
||||
while (_gate.Wait(0)) { }
|
||||
_inputCallbackSignal.WaitOne(0);
|
||||
_messageSkipActive = false;
|
||||
_queuedSkippedVoice = null;
|
||||
System.Threading.Volatile.Write(ref _voiceBgmDuckControl, 0);
|
||||
_advPagePresentationSuspended = false;
|
||||
_modalMovieCancelled = false;
|
||||
_modalMovieWaiting = false;
|
||||
_foregroundGfx = null;
|
||||
IsWaiting = false;
|
||||
IsTransitionWaiting = false;
|
||||
IsSleeping = false;
|
||||
IsTextRevealing = false;
|
||||
System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, -1);
|
||||
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
|
||||
_main.CallDeferred("CancelScheduledSoundEffectStarts");
|
||||
_main.CallDeferred("ClearPage");
|
||||
_timeline?.State("scene-context-reset", new());
|
||||
}
|
||||
|
||||
// Main thread, once per rendered frame: releases a VM thread parked in Sleep or a presentation/input wait.
|
||||
public void PulseFrame() => _frameSignal.Set();
|
||||
|
||||
|
||||
127
godot/Main.cs
127
godot/Main.cs
@@ -1,8 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Godot;
|
||||
using Age.Engine.Diagnostics;
|
||||
using Age.Engine.Hosting;
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Sys4;
|
||||
@@ -45,6 +47,9 @@ public partial class Main : Godot.Control
|
||||
private readonly int[] _sfxGenerations = new int[10];
|
||||
private VirtualMachine _vm = null!;
|
||||
private GodotAdvHost _host = null!;
|
||||
private Sys4ScriptProvider? _scripts;
|
||||
private DebugSceneLauncher? _debugSceneLauncher;
|
||||
private IReadOnlyList<DebugSceneEntry> _debugSceneEntries = System.Array.Empty<DebugSceneEntry>();
|
||||
private readonly Age.Engine.Hosting.FrameClock _clock = new();
|
||||
private readonly System.Collections.Generic.Dictionary<long, MovieRuntime> _movies = new();
|
||||
private readonly System.Collections.Generic.HashSet<long> _movieFrameSeen = new();
|
||||
@@ -200,6 +205,7 @@ public partial class Main : Godot.Control
|
||||
Sys4ScriptProvider? scripts = null;
|
||||
if (_selftest) (script, provider) = BuildSelfTestScene(table);
|
||||
else { scripts = Sys4ScriptProvider.Load(table); script = scripts.RequireByName(scene + ".BIN"); provider = scripts; }
|
||||
_scripts = scripts;
|
||||
bool directSceneHarness = !_selftest
|
||||
&& !scene.Equals("SYSTEM4", System.StringComparison.OrdinalIgnoreCase);
|
||||
if (_timelineLogPath != null) _timeline = new GodotTimelineLog(_timelineLogPath);
|
||||
@@ -218,6 +224,13 @@ public partial class Main : Godot.Control
|
||||
if (histFile != null) { _hist = new Age.Engine.Diagnostics.HistogramTraceSink();
|
||||
sink = new Age.Engine.Diagnostics.CompositeTraceSink(_trace, _hist); }
|
||||
_vm = new VirtualMachine(script, table, _host, new VmOptions(MaxSteps: 20_000_000), provider, sink);
|
||||
if (scripts != null)
|
||||
{
|
||||
_debugSceneEntries = DebugSceneCatalog.Build(scripts.Catalog);
|
||||
_debugSceneLauncher = new DebugSceneLauncher();
|
||||
_debugSceneLauncher.LaunchRequested += LaunchDebugScene;
|
||||
AddChild(_debugSceneLauncher);
|
||||
}
|
||||
// SYSTEM4.BIN defines these nine shared ADV text layouts before dispatching any scene. The
|
||||
// single-scene harness starts after that prefix, so carry forward its exact script-owned state
|
||||
// alongside the inherited SO000/SO001 state below. Full Phase-B SYSTEM4 replay will replace this
|
||||
@@ -359,6 +372,21 @@ public partial class Main : Godot.Control
|
||||
_locatorHud.Text = _locator.CurrentDisplay + " · copied";
|
||||
return;
|
||||
}
|
||||
if (e is InputEventKey debugKey && debugKey.Pressed && !debugKey.Echo && debugKey.Keycode == Key.F4)
|
||||
{
|
||||
ToggleDebugSceneLauncher();
|
||||
GetViewport().SetInputAsHandled();
|
||||
return;
|
||||
}
|
||||
if (_debugSceneLauncher?.Visible == true)
|
||||
{
|
||||
if (e is InputEventKey escape && escape.Pressed && !escape.Echo && escape.Keycode == Key.Escape)
|
||||
{
|
||||
_debugSceneLauncher.Hide();
|
||||
GetViewport().SetInputAsHandled();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e is InputEventMouseMotion motion)
|
||||
{
|
||||
var p = ToNativeScreen(motion.Position);
|
||||
@@ -435,6 +463,82 @@ public partial class Main : Godot.Control
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleDebugSceneLauncher()
|
||||
{
|
||||
if (_debugSceneLauncher == null) return;
|
||||
if (_debugSceneLauncher.Visible)
|
||||
{
|
||||
_debugSceneLauncher.Hide();
|
||||
return;
|
||||
}
|
||||
if (!TryGetTitleDebugFrame(out var frame, out string reason))
|
||||
{
|
||||
_status.Text = reason;
|
||||
GD.Print($"[debug-launcher] unavailable: {reason}");
|
||||
return;
|
||||
}
|
||||
_debugSceneLauncher.Open(_debugSceneEntries, string.Join(" > ", frame.CallStack));
|
||||
}
|
||||
|
||||
private void LaunchDebugScene(DebugSceneEntry entry)
|
||||
{
|
||||
if (_debugSceneLauncher == null || _scripts == null) return;
|
||||
if (!TryGetTitleDebugFrame(out var frame, out string reason))
|
||||
{
|
||||
_debugSceneLauncher.SetStatus(reason);
|
||||
return;
|
||||
}
|
||||
if (!entry.Launchable || _scripts.GetById(entry.PackedId) == null)
|
||||
{
|
||||
_debugSceneLauncher.SetStatus("The selected packed script could not be parsed; no state was changed.");
|
||||
return;
|
||||
}
|
||||
|
||||
var coordinatorWrites = new Dictionary<int, long>
|
||||
{
|
||||
[0] = 1,
|
||||
[0xaba5c] = -1,
|
||||
[0x62ccf] = 0,
|
||||
[0x699] = entry.PackedId,
|
||||
};
|
||||
if (!_vm.TryRequestDebugFrameReturn(frame.FrameId, coordinatorWrites))
|
||||
{
|
||||
_debugSceneLauncher.SetStatus("TITLE changed frames before launch; reopen the launcher and try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
_timeline?.Event("debug-scene-launch-request", new()
|
||||
{
|
||||
["script"] = entry.Name,
|
||||
["packed_id"] = entry.PackedId,
|
||||
});
|
||||
GD.Print($"[debug-launcher] SYSTEM4 dispatch requested: {entry.Name} (0x{entry.PackedId:x8})");
|
||||
_debugSceneLauncher.Hide();
|
||||
// ADV waits need an explicit wake; TITLE's actual menu is a 1 ms sleep/poll loop and will consume
|
||||
// the request at its next opcode boundary without leaving a stale input signal for the child scene.
|
||||
if (_host.IsWaiting) _host.SignalInput();
|
||||
}
|
||||
|
||||
private bool TryGetTitleDebugFrame(out DebugFrameSnapshot frame, out string reason)
|
||||
{
|
||||
frame = _vm.DebugFrame!;
|
||||
if (_done || frame == null)
|
||||
{
|
||||
reason = "Available only while TITLE is the active SYSTEM4 child.";
|
||||
return false;
|
||||
}
|
||||
if (frame.CallStack.Count != 2
|
||||
|| !frame.CallStack[0].Equals("SYSTEM4.BIN", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| !frame.CallStack[1].Equals("TITLE.BIN", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| !frame.CurrentScript.Equals("TITLE.BIN", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
reason = "Refused: the active stack is not SYSTEM4 > TITLE.";
|
||||
return false;
|
||||
}
|
||||
reason = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
private (int X, int Y) ToNativeScreen(Vector2 position)
|
||||
{
|
||||
Vector2 size = GetViewportRect().Size;
|
||||
@@ -973,6 +1077,14 @@ public partial class Main : Godot.Control
|
||||
GetTree().CreateTimer(realDelaySeconds).Timeout += StartIfCurrent;
|
||||
}
|
||||
|
||||
public void CancelScheduledSoundEffectStarts()
|
||||
{
|
||||
// Native scene_context_init_reset calls sfx_clear_scheduled_starts. Generation invalidation
|
||||
// cancels the timer callbacks without stopping active sounds or unloading their channel streams.
|
||||
for (int channel = 0; channel < _sfxGenerations.Length; channel++)
|
||||
_sfxGenerations[channel]++;
|
||||
}
|
||||
|
||||
public void ReleaseSoundEffect(int channel)
|
||||
{
|
||||
if ((uint)channel >= (uint)_sfx.Length) return;
|
||||
@@ -1068,8 +1180,19 @@ public partial class Main : Godot.Control
|
||||
var actual = _host.Captured.ConvertAll(c => c.Offset);
|
||||
bool ok = actual.Count == expected.Count;
|
||||
for (int i = 0; ok && i < actual.Count; i++) ok = actual[i] == expected[i];
|
||||
if (ok) GD.Print($"SELFTEST OK: threaded host matches headless ({actual.Count} lines, full handling)");
|
||||
else GD.Print($"SELFTEST FAIL: threaded={actual.Count} vs headless={expected.Count}");
|
||||
var debugEntries = DebugSceneCatalog.Build(Sys4AssetCatalog.Load(Paths.Sys4Ini));
|
||||
bool launcherOk = debugEntries.Any(entry => entry.Name == "DEBUG.BIN" && entry.Launchable)
|
||||
&& debugEntries.Select(entry => entry.PackedId).Distinct().Count() == debugEntries.Count;
|
||||
var launcherSmoke = new DebugSceneLauncher();
|
||||
AddChild(launcherSmoke);
|
||||
launcherSmoke.Open(debugEntries, "SYSTEM4.BIN > TITLE.BIN");
|
||||
launcherSmoke.Hide();
|
||||
launcherSmoke.QueueFree();
|
||||
ok &= launcherOk;
|
||||
if (ok) GD.Print($"SELFTEST OK: threaded host matches headless ({actual.Count} lines, full handling); " +
|
||||
$"debug launcher catalog/UI smoke ({debugEntries.Count} packed scripts)");
|
||||
else GD.Print($"SELFTEST FAIL: threaded={actual.Count} vs headless={expected.Count}; " +
|
||||
$"debug-launcher={launcherOk}");
|
||||
GetTree().Quit(ok ? 0 : 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,19 +39,19 @@ opcodes_used_by_himegari = 248
|
||||
|
||||
[[opcode]]
|
||||
op = 0x1
|
||||
label = "u004149C0"
|
||||
label = "throw-exit-request"
|
||||
argc = 0
|
||||
abi_source = "kelebek+decode-validated"
|
||||
|
||||
[opcode.semantics]
|
||||
name = "u004149C0"
|
||||
category = "unknown"
|
||||
summary = ""
|
||||
name = "throw-exit-request"
|
||||
category = "control"
|
||||
summary = "() - raise the engine's non-returning exit/fatal-abort control exception with reason value 1. TITLE uses it for the fifth main-menu action; SYSTEM4 uses it after reporting an invalid execution mode."
|
||||
noop_headless = false
|
||||
source = "kelebek"
|
||||
confidence = "low"
|
||||
source = "investigation"
|
||||
confidence = "high"
|
||||
depends_on = []
|
||||
evidence = ""
|
||||
evidence = "Ghidra /v2: op_0x1_throw_exit_request@0x4162e0 constructs local value 1 and calls __CxxThrowException_8 with type descriptor DAT_005a9710; the handler is non-returning. Corpus has exactly two sites: TITLE@0x393 after the fifth menu action's sound/sleep, and SYSTEM4@0x5b9 after printing 'invalid execution mode'. TITLE bytecode following 0x1 builds a developer debug menu and can only be reached when a port incorrectly treats 0x1 as a fall-through stub. The frontend catch/prompt policy remains a separate unimplemented boundary."
|
||||
|
||||
[[opcode]]
|
||||
op = 0x2
|
||||
@@ -181,13 +181,13 @@ abi_source = "kelebek+decode-validated"
|
||||
[opcode.semantics]
|
||||
name = "exit-script"
|
||||
category = "control"
|
||||
summary = "() - terminate the active script lifecycle and return to root script id 0. Before the native engine resets/reloads the root, it clears the initial-root-run flag queried by op 0x130 so LOGO/OP are not replayed."
|
||||
summary = "() - discard the complete active script stack, reset scene-owned engine services, and load raw script resource 0 as the new root. The global VM banks and process-owned configuration survive; the initial-root-run flag queried by op 0x130 is cleared so LOGO/OP are not replayed."
|
||||
noop_headless = false
|
||||
source = "investigation"
|
||||
confidence = "high"
|
||||
depends_on = []
|
||||
evidence = "Ghidra /v2: op_0x9_handler@0x418f50 stores zero to EngineCtx+0x54ff0, calls scene_context_init_reset, and loads root script resource id 0. Corpus sites are terminal scene/control exits rather than ordinary local returns."
|
||||
details = "The port retains its earlier frame/session-boundary representation of root return, but now performs the proven process-lifecycle side effect by clearing its VM-owned initial-root-run flag. A faithful whole-stack root reload remains part of the broader persistent scene-coordinator work, not the startup-movie slice."
|
||||
evidence = "Ghidra /v2: op_0x9_reset_scene_and_reload_root@0x418f50 clears EngineCtx+0x54ff0, disposes all 40 interpreter-frame slots with script_frame_dispose@0x40e610, aborts timed/input callback state, calls scene_context_init_reset@0x40b3b0, resets hotspot/input services, optionally releases AutoFreeTex surfaces, then calls script_frame_load_resource(...,0). The scene reset clears interpreter/ADV/input/retained-gfx state and normally releases the 1000 surface/movie slots; it does not clear the VM global bank or engine configuration. Corpus sites are terminal scene/control exits rather than ordinary local returns."
|
||||
details = "Implemented as a whole-stack root-reload boundary in the persistent VM. A request propagates through every nested call-script frame without resuming caller instructions, clears VM/host scene presentation and input state, cancels deferred SFX starts while preserving active/process-owned audio, preserves globals/external globals and process-owned host configuration/caches, then resolves raw resource 0 through the script provider and starts it at offset zero. The retained history backlog is deliberately preserved pending a separate proof of its native lifetime; recording suppression is reset."
|
||||
|
||||
[[opcode]]
|
||||
op = 0x21
|
||||
@@ -2625,7 +2625,7 @@ noop_headless = false
|
||||
source = "investigation"
|
||||
confidence = "high"
|
||||
depends_on = []
|
||||
evidence = "Ghidra /v2: op_0x130_get_initial_root_run@0x4295b0 copies EngineCtx+0x54ff0 to operand 1. FUN_00413860 initializes +0x54ff0 to 1 at 0x413cc7/0x413d15; op_0x9_handler@0x418f50 is its only later writer and clears it before scene_context_init_reset plus script_frame_load_resource(...,0). Corpus: sole site SYSTEM4@0x29a branches to LOGO.BIN then OP.BIN only when the returned value is nonzero."
|
||||
evidence = "Ghidra /v2: op_0x130_get_initial_root_run@0x4295b0 copies EngineCtx+0x54ff0 to operand 1. FUN_00413860 initializes +0x54ff0 to 1 at 0x413cc7/0x413d15; op_0x9_reset_scene_and_reload_root@0x418f50 is its only later writer and clears it before scene_context_init_reset plus script_frame_load_resource(...,0). Corpus: sole site SYSTEM4@0x29a branches to LOGO.BIN then OP.BIN only when the returned value is nonzero."
|
||||
details = "Implemented as process-lifecycle state owned by the persistent VM: it begins at one and op 0x9 clears it. It is not a script global, save/profile value, command-line seed, or script-name special case."
|
||||
|
||||
[[opcode.semantics.args]]
|
||||
|
||||
Reference in New Issue
Block a user