Implement native exit request control flow
This commit is contained in:
@@ -278,18 +278,33 @@ been proven; only recording suppression is reset. Focused tests cover a three-fr
|
||||
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.
|
||||
`op_0x1_throw_exit_request@0x4162e0` constructs a four-byte value-one payload and throws it with
|
||||
`ThrowInfo@0x5a9710`. The catchable-type metadata resolves that object to the named RTTI type
|
||||
`Command_Exit_Exception` (`TypeDescriptor@0x5b13e8`), rather than an undifferentiated integer exception.
|
||||
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 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
|
||||
`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.
|
||||
`DEBUG.BIN`; that code is unreachable in the native flow because the handler never returns. The port's
|
||||
former unknown-op fallback did expose that menu when selecting TITLE's fifth action, providing a useful
|
||||
visual confirmation of the static mapping but not a legitimate retail route. Opcode `0x1` now propagates a
|
||||
process-exit request through hotspot callbacks and nested script frames and ends the VM session without
|
||||
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
|
||||
viewer choices write `G[0]=1`, `G[0xaba5c]=-1`, `G[0x62ccf]=0`, and a raw script id into `G[0x699]`, then
|
||||
|
||||
@@ -199,9 +199,9 @@ 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.
|
||||
- **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
|
||||
- **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)
|
||||
- **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.
|
||||
|
||||
@@ -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
|
||||
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`.
|
||||
gameplay, game over, or completion. Do not use TITLE's post-`0x1` developer menu as evidence; native `0x1`
|
||||
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
|
||||
is deliberately narrower than arbitrary hot swapping:
|
||||
|
||||
53
engine/Age.Engine.Tests/ExitRequestTests.cs
Normal file
53
engine/Age.Engine.Tests/ExitRequestTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -354,8 +354,9 @@ public sealed class VirtualMachine
|
||||
: unchecked((int)Read(operand)).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
private sealed class RootReloadRequestedException : Exception { }
|
||||
private sealed class ProcessExitRequestedException : Exception { }
|
||||
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)
|
||||
{
|
||||
@@ -388,6 +389,7 @@ public sealed class VirtualMachine
|
||||
}
|
||||
if (outcome == FrameOutcome.RanOff) HaltReason ??= "pc-out-of-range";
|
||||
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.
|
||||
break;
|
||||
}
|
||||
@@ -468,6 +470,7 @@ public sealed class VirtualMachine
|
||||
}
|
||||
}
|
||||
catch (RootReloadRequestedException) { outcome = FrameOutcome.RootReload; }
|
||||
catch (ProcessExitRequestedException) { outcome = FrameOutcome.ExitRequested; }
|
||||
_sink.Emit(TraceEvent.FrameExit(frame.Script.Name, _depth, outcome.ToString()));
|
||||
return outcome;
|
||||
}
|
||||
@@ -720,6 +723,10 @@ public sealed class VirtualMachine
|
||||
Write(a[0], visits == 0 ? (terminal == 0 ? 1 : 0) : terminal);
|
||||
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-script":
|
||||
// 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);
|
||||
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.ExitRequested) throw new ProcessExitRequestedException();
|
||||
return pc + 1; // Returned / RanOff: resume caller
|
||||
}
|
||||
case "show-text":
|
||||
|
||||
@@ -46,12 +46,12 @@ abi_source = "kelebek+decode-validated"
|
||||
[opcode.semantics]
|
||||
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."
|
||||
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
|
||||
source = "investigation"
|
||||
confidence = "high"
|
||||
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]]
|
||||
op = 0x2
|
||||
|
||||
Reference in New Issue
Block a user