Implement native exit request control flow

This commit is contained in:
gamer147
2026-07-20 23:02:27 -04:00
parent b71328738d
commit b8cf21245a
6 changed files with 94 additions and 17 deletions

View File

@@ -278,18 +278,33 @@ been proven; only recording suppression is reset. Focused tests cover a three-fr
resolution to `SYSTEM4.BIN`. resolution to `SYSTEM4.BIN`.
**Frontend exit request is not a root reload (`0x1`, 2026-07-20).** Native **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++ `op_0x1_throw_exit_request@0x4162e0` constructs a four-byte value-one payload and throws it with
control exception (`DAT_005a9710`). Its only two corpus sites establish the intent: TITLE executes it after `ThrowInfo@0x5a9710`. The catchable-type metadata resolves that object to the named RTTI type
the fifth main-menu action's sound and delay, while SYSTEM4 executes it after reporting an invalid execution `Command_Exit_Exception` (`TypeDescriptor@0x5b13e8`), rather than an undifferentiated integer exception.
mode. The Windows/frontend catch policy—full exit versus returning to title—is outside this opcode handler Its only two corpus sites establish the intent: TITLE executes it after the fifth main-menu action's sound
and is not implemented in the Godot frontend yet. and delay, while SYSTEM4 executes it after reporting an invalid execution mode.
The catcher is the outer native message/scheduler loop now recovered as
`engine_main_tick_with_exception_policy@0x411840`. Its MSVC `FuncInfo@0x5a9750` has a typed catch entry for
`Command_Exit_Exception` at `catch_CommandExitException_set_exit_result@0x412648`. That funclet forces the
enclosing result to one and returns continuation `0x412961`, which performs loop teardown and returns to the
frontend. It never changes `frame_pc`. This matters because the dispatcher itself advances `frame_pc` only
after an opcode handler returns; `0x1` throws instead, so neither the dispatcher nor the catcher advances
past it. A distinct generic error-dialog policy at `0x412689` proves that fall-through is explicit: result
two retries the same instruction, result four adds the decoded instruction length before restarting the
loop, and other results exit. `Command_Exit_Exception` bypasses that policy entirely.
The Windows/frontend policy that follows the returned exit request—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 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 `DEBUG.BIN`; that code is unreachable in the native flow because the handler never returns. The port's
treats unknown `0x1` as a fall-through stub, so selecting the fifth TITLE action would expose that menu by former unknown-op fallback did expose that menu when selecting TITLE's fifth action, providing a useful
accident. This is a known discrepancy, not a legitimate route for validating `0x9`. End-to-end visual visual confirmation of the static mapping but not a legitimate retail route. Opcode `0x1` now propagates a
validation of the native `SYSTEM4 -> TITLE -> child -> 0x9 -> SYSTEM4 -> TITLE` history therefore remains process-exit request through hotspot callbacks and nested script frames and ends the VM session without
deferred until the frontend exit/return-to-title boundary or a natural game-over/completion route exists. executing the following bytecode. End-to-end visual validation of the native
`SYSTEM4 -> TITLE -> child -> 0x9 -> SYSTEM4 -> TITLE` history 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 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 viewer choices write `G[0]=1`, `G[0xaba5c]=-1`, `G[0x62ccf]=0`, and a raw script id into `G[0x699]`, then

View File

@@ -199,9 +199,9 @@ Operand 2 names the base cell itself: a global-bank operand produces a global re
## control ## control
### 0x1 `throw-exit-request` (throw-exit-request, argc 0) ### 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. - **summary:** () - raise Command_Exit_Exception with result value 1. The outer engine loop catches it, exits without advancing the VM PC, and returns control to the frontend. TITLE uses it for the fifth main-menu action; SYSTEM4 uses it after reporting an invalid execution mode.
- **grounding:** source=investigation, confidence=high - **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. - **evidence:** Ghidra /v2: op_0x1_throw_exit_request@0x4162e0 constructs a four-byte payload with value 1 and calls __CxxThrowException_8 with ThrowInfo 0x5a9710. Its CatchableTypeArray resolves to RTTI TypeDescriptor 0x5b13e8, `.?AVCommand_Exit_Exception@@`. The outer engine_main_tick_with_exception_policy@0x411840 has MSVC FuncInfo 0x5a9750; its typed catch entry maps Command_Exit_Exception to catch_CommandExitException_set_exit_result@0x412648, which forces result 1 and returns continuation 0x412961 for loop teardown/return. The dispatcher advances frame_pc only after a handler returns, and this catch never advances it. A separate generic error-dialog result-4 path at 0x412928 explicitly advances the PC, proving fall-through is opt-in and does not apply here. 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 is unreachable in the retail native path. The frontend full-exit/return-to-title prompt remains a separate unimplemented boundary.
### 0x3 `call-script` (call-script, argc 1) ### 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. - **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.

View File

@@ -148,8 +148,9 @@ to SYSTEM4; op `0x9` performs a tested whole-stack reload of SYSTEM4; selected g
survive; and scene-owned presentation/input state is released. Manual validation of a natural gameplay 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 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 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 gameplay, game over, or completion. Do not use TITLE's post-`0x1` developer menu as evidence; native `0x1`
fallthrough as evidence; native `0x1` is non-returning. See `docs/engine-re.md`. is non-returning, and the port now propagates that exit request instead of falling through into the hidden
bytecode. See `docs/engine-re.md`.
**Godot debug scene launcher (2026-07-20; implemented and manually validated).** The first version **Godot debug scene launcher (2026-07-20; implemented and manually validated).** The first version
is deliberately narrower than arbitrary hot swapping: is deliberately narrower than arbitrary hot swapping:

View File

@@ -0,0 +1,53 @@
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class ExitRequestTests
{
private static Operand I(long value) => new(0, value);
private static Operand G(long address) => new(3, address);
[Fact]
public void ThrowExitRequestDoesNotFallThroughToFollowingBytecode()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var script = ScriptAssembler.Assemble(table, "TITLE.BIN", new List<(int, Operand[])>
{
(0x1, Array.Empty<Operand>()),
(0x55, new[] { G(0x7000), I(1) }),
}, Array.Empty<string>());
var vm = new VirtualMachine(script, table, new RecordingHost());
vm.Run();
Assert.Equal("exit-request", vm.HaltReason);
Assert.False(vm.Globals.ContainsKey(0x7000));
}
[Fact]
public void ThrowExitRequestEscapesNestedScriptFrames()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var root = ScriptAssembler.Assemble(table, "SYSTEM4.BIN", new List<(int, Operand[])>
{
(0x3, new[] { I(1) }),
(0x55, new[] { G(0x7001), I(1) }),
}, Array.Empty<string>());
var child = ScriptAssembler.Assemble(table, "TITLE.BIN", new List<(int, Operand[])>
{
(0x1, Array.Empty<Operand>()),
(0x55, new[] { G(0x7002), I(1) }),
}, Array.Empty<string>());
var vm = new VirtualMachine(root, table, new RecordingHost(), provider: new MapProvider(new()
{
[1] = child,
}));
vm.Run();
Assert.Equal("exit-request", vm.HaltReason);
Assert.False(vm.Globals.ContainsKey(0x7001));
Assert.False(vm.Globals.ContainsKey(0x7002));
}
}

View File

@@ -354,8 +354,9 @@ public sealed class VirtualMachine
: unchecked((int)Read(operand)).ToString(System.Globalization.CultureInfo.InvariantCulture); : unchecked((int)Read(operand)).ToString(System.Globalization.CultureInfo.InvariantCulture);
private sealed class RootReloadRequestedException : Exception { } private sealed class RootReloadRequestedException : Exception { }
private sealed class ProcessExitRequestedException : Exception { }
private sealed record DebugFrameReturnRequest(ExecFrame Frame, IReadOnlyDictionary<int, long> GlobalWrites); private sealed record DebugFrameReturnRequest(ExecFrame Frame, IReadOnlyDictionary<int, long> GlobalWrites);
private enum FrameOutcome { Returned, DebugReturned, RootReload, Halted, RanOff } private enum FrameOutcome { Returned, DebugReturned, RootReload, ExitRequested, Halted, RanOff }
public void Run(int entryOffset = 0) public void Run(int entryOffset = 0)
{ {
@@ -388,6 +389,7 @@ public sealed class VirtualMachine
} }
if (outcome == FrameOutcome.RanOff) HaltReason ??= "pc-out-of-range"; if (outcome == FrameOutcome.RanOff) HaltReason ??= "pc-out-of-range";
else if (outcome is FrameOutcome.Returned or FrameOutcome.DebugReturned) HaltReason ??= "exit"; else if (outcome is FrameOutcome.Returned or FrameOutcome.DebugReturned) HaltReason ??= "exit";
else if (outcome == FrameOutcome.ExitRequested) HaltReason ??= "exit-request";
// Halted: HaltReason already set by the halting op. // Halted: HaltReason already set by the halting op.
break; break;
} }
@@ -468,6 +470,7 @@ public sealed class VirtualMachine
} }
} }
catch (RootReloadRequestedException) { outcome = FrameOutcome.RootReload; } catch (RootReloadRequestedException) { outcome = FrameOutcome.RootReload; }
catch (ProcessExitRequestedException) { outcome = FrameOutcome.ExitRequested; }
_sink.Emit(TraceEvent.FrameExit(frame.Script.Name, _depth, outcome.ToString())); _sink.Emit(TraceEvent.FrameExit(frame.Script.Name, _depth, outcome.ToString()));
return outcome; return outcome;
} }
@@ -720,6 +723,10 @@ public sealed class VirtualMachine
Write(a[0], visits == 0 ? (terminal == 0 ? 1 : 0) : terminal); Write(a[0], visits == 0 ? (terminal == 0 ? 1 : 0) : terminal);
return pc + 1; return pc + 1;
} }
case "throw-exit-request":
// Native op 0x1 throws Command_Exit_Exception through callbacks and nested script
// frames. The outer engine loop catches it and exits without advancing frame_pc.
throw new ProcessExitRequestedException();
case "exit": return FRAME_RETURN; case "exit": return FRAME_RETURN;
case "exit-script": case "exit-script":
// Native op 0x9 clears the process-initial flag, disposes every active script frame, // Native op 0x9 clears the process-initial flag, disposes every active script frame,
@@ -743,6 +750,7 @@ public sealed class VirtualMachine
var outcome = RunFrame(new ExecFrame(child, entry), FrameCause.CallScript, id); var outcome = RunFrame(new ExecFrame(child, entry), FrameCause.CallScript, id);
if (outcome == FrameOutcome.Halted) return HALT; // propagate whole-VM halt up if (outcome == FrameOutcome.Halted) return HALT; // propagate whole-VM halt up
if (outcome == FrameOutcome.RootReload) return ROOT_RELOAD; // discard every caller frame if (outcome == FrameOutcome.RootReload) return ROOT_RELOAD; // discard every caller frame
if (outcome == FrameOutcome.ExitRequested) throw new ProcessExitRequestedException();
return pc + 1; // Returned / RanOff: resume caller return pc + 1; // Returned / RanOff: resume caller
} }
case "show-text": case "show-text":

View File

@@ -46,12 +46,12 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "throw-exit-request" name = "throw-exit-request"
category = "control" 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." summary = "() - raise Command_Exit_Exception with result value 1. The outer engine loop catches it, exits without advancing the VM PC, and returns control to the frontend. TITLE uses it for the fifth main-menu action; SYSTEM4 uses it after reporting an invalid execution mode."
noop_headless = false noop_headless = false
source = "investigation" source = "investigation"
confidence = "high" confidence = "high"
depends_on = [] depends_on = []
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." evidence = "Ghidra /v2: op_0x1_throw_exit_request@0x4162e0 constructs a four-byte payload with value 1 and calls __CxxThrowException_8 with ThrowInfo 0x5a9710. Its CatchableTypeArray resolves to RTTI TypeDescriptor 0x5b13e8, `.?AVCommand_Exit_Exception@@`. The outer engine_main_tick_with_exception_policy@0x411840 has MSVC FuncInfo 0x5a9750; its typed catch entry maps Command_Exit_Exception to catch_CommandExitException_set_exit_result@0x412648, which forces result 1 and returns continuation 0x412961 for loop teardown/return. The dispatcher advances frame_pc only after a handler returns, and this catch never advances it. A separate generic error-dialog result-4 path at 0x412928 explicitly advances the PC, proving fall-through is opt-in and does not apply here. 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 is unreachable in the retail native path. The frontend full-exit/return-to-title prompt remains a separate unimplemented boundary."
[[opcode]] [[opcode]]
op = 0x2 op = 0x2