Add hidden title debug mode and pace menu polls

This commit is contained in:
gamer147
2026-07-20 23:38:19 -04:00
parent 19b7b57edf
commit 2fa42b085a
10 changed files with 67 additions and 11 deletions

View File

@@ -302,7 +302,10 @@ TITLE happens to contain a developer menu immediately after its `0x1`, including
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
executing the following bytecode. An explicit Godot `--native-debug-menu` diagnostic can deliberately
restore the old fall-through for developer archaeology; it maps to `VmOptions.IgnoreExitRequests`, defaults
off, and is not part of the native compatibility path. Because it changes the opcode globally, it also
suppresses SYSTEM4's invalid-execution-mode exit site during that run. 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.
@@ -778,6 +781,11 @@ native presentation trace proved that back-to-back retained mutations execute wi
first published at the following `0x20c`/`0x21c`, sleep, or stable input boundary. An earlier claim that the
opening was generally sleep-paced was inherited without execution evidence and is superseded.
The native timer arm clamps every duration to at least one millisecond. This is semantically important for
menu scripts: ROOM's steady input loop uses `sleep 0` as a one-engine-tick yield. The Godot host formerly
allowed a zero deadline, turning that loop into a free-running burst that exhausted the VM step guard and
showed `-end-`; it now applies the native minimum after the debug speed multiplier.
**Related — `present-frame` (op `0x20c`):** dispatch `param_1[0x26e9f] = gfx_op_0x20c_present_frame`
`gfx_render_frame`@`0x4820b0` (buffer flip). The port treats `0x20c` as an explicit retained-state publication
boundary in the interactive host while keeping it `noop_headless=true`; the Kelebek label `u00416200` was

View File

@@ -273,7 +273,7 @@ Implemented as a whole-stack root-reload boundary in the persistent VM. A reques
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra: dispatch ctx[0x26c93+0xc8]=0x420ec0; sleep_op_0xc8 + sleep_timer_arm decoded/annotated 2026-07-08. docs/engine-re.md sleep section.
Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_timer_arm @0x44cff0 at ctx+0x5f304 = active flag + start tick + duration) that the engine main loop polls, resuming the script when elapsed. Operand UNIT = MILLISECONDS (start = ms tick source DAT_0056f3d4, timeGetTime/GetTickCount class). duration<10 fast-paths via [0x56f0b8]; all real scene sleeps (100/750/1000) are >=10. The handler also records its generic 3-dword instruction length and runs anti-tamper checks, neither needed host-side. Port equivalent: the Godot host parks the VM thread for duration ms while the presentation compositor continues. Sleep is one proven presentation-capable service boundary; ordinary AE setup runs burst-fast to 0x21c and is not paced per opcode. Headless hosts no-op it (parity).
Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_timer_arm @0x44cff0 at ctx+0x5f304 = active flag + start tick + duration) that the engine main loop polls, resuming the script when elapsed. Operand UNIT = MILLISECONDS (start = ms tick source DAT_0056f3d4, timeGetTime/GetTickCount class), and the native arm helper clamps duration to a minimum of 1 ms. ROOM's input loop deliberately uses sleep(0), making it a one-engine-tick yield rather than a no-op. The handler also records its generic 3-dword instruction length and runs anti-tamper checks, neither needed host-side. Port equivalent: the Godot host parks the VM thread for max(1, scaled duration) ms while the presentation compositor continues. Sleep is one proven presentation-capable service boundary; ordinary AE setup runs burst-fast to 0x21c and is not paced per opcode. Headless hosts no-op it (parity).
### 0xd3 `begin-timed-callback-sequence` (u00425960, argc 0)
- **summary:** Clear and initialize the current script frame's timed local-callback sequence.

View File

@@ -150,7 +150,8 @@ route through the first `0x9` remains deferred: Himegari's readily accessible re
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 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`.
bytecode. The opt-in `--native-debug-menu` diagnostic deliberately restores fall-through for exploring that
retained developer UI, but does not qualify as native lifecycle validation. 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:
@@ -236,6 +237,13 @@ were presented, its terminal op `0x9` at `0x1fb` ran, and SYSTEM4 reconstructed
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.
**TITLE Extra Room entry (`ROOM.BIN`, 2026-07-20).** The title dispatch itself is correct. The first manual
entry exposed a scheduler discrepancy: ROOM's stable input poll uses `sleep 0`, which native `0xc8` clamps
to a one-millisecond timer. Godot previously treated it as a zero-duration no-op, letting the script consume
the 20-million-step guard and display `-end-`. The interactive host now preserves the native one-tick yield;
Godot also prints the VM halt reason and step count before its generic end marker so future terminations are
distinguishable. Manual ROOM presentation and its genuine op-`0x9` return to TITLE remain to be rechecked.
## Stage B2 — Faithful full boot
Replace `--boot`'s diagnostic seeding and separately injected inherited surfaces with normal boot execution.

View File

@@ -139,13 +139,19 @@ at 2.5M lines). All observe-only → parity preserved; all on `run`/`play`/`swee
Plays the real bytecode with call-script execution on (subroutines run live). A no-argument launch starts
the persistent `SYSTEM4.BIN` root and reaches TITLE naturally. The local `run-godot.ps1`/`.cmd` launchers
make that route explicit with `--scene SYSTEM4` and pass neither `--boot` nor SC0000 seeds. `--headless` can't
render texture ops (no GPU context) — run windowed for real scenes. User args (after `--`):
render texture ops (no GPU context) — run windowed for real scenes. On VM termination the console prints
the exact halt reason and step count before the generic on-screen `-end-` marker. User args (after `--`):
- `--scene <NAME>` — override the default `SYSTEM4` root with a direct diagnostic scene, e.g. `--scene SC0240` (executes 29 nested subroutines).
- `--selftest` — headless; runs a **synthesized** scene through the thread/suspend/`CallDeferred` plumbing and asserts it matches a live headless run (full handling; no vm0/frozen golden). Exits.
- `--seed 0xADDR=VAL` (repeatable) — seed initial global state, e.g. `--seed 0xa57=1` unlocks Lily's form-A voiced dialogue.
- `--boot` — direct-scene diagnostic only: with `--scene <non-SYSTEM4>`, run the old
`INITCONFIG/INIT2/INIT` state prefix before that isolated scene. The normal SYSTEM4-rooted launch neither
needs nor applies it. e.g. `godot --path godot -- --scene SC0000 --boot`.
- `--native-debug-menu` — intentionally treat exit-request opcode `0x1` as a no-op, exposing TITLE's
otherwise unreachable shipped developer menu. This is a non-native debug divergence and also suppresses
SYSTEM4's invalid-execution-mode exit request for that run. Direct Godot and PowerShell launches leave it
disabled unless explicitly requested. The local `run-godot.cmd` convenience launcher enables it by
default; use `run-godot.ps1` without `-NativeDebugMenu` for a native-faithful launch.
- `--shot <png> [--shot-page N]` — capture page N to a PNG then quit (dev screenshot). At scene end it also prints the call-scripts executed as nested frames.
- `--shot-sequence <dir> [--frames N]` — dump one PNG per rendered frame (`frame_0000.png…`, default N=180 ≈ 3s @60fps) then quit, auto-advancing past input waits. Verifies time-based retained effects and publication boundaries as distinct frames, which a single `--shot` cannot. CPU/IO-heavy by design (a PNG every frame); a dev diagnostic, not a normal run. e.g. `godot --path godot -- --scene SC0000 --boot --shot-sequence out/seq --frames 300`.
- `--sleep-scale <f>` — multiply every explicit `sleep` (op 0xc8) duration by `f` (default 1.0). This stretches only script-authored sleep holds; it does not slow ordinary opcode bursts or replace `0x20c`/`0x21c` presentation pacing. Debug-only; leave at 1.0 for real playback.

View File

@@ -50,4 +50,23 @@ public class ExitRequestTests
Assert.False(vm.Globals.ContainsKey(0x7001));
Assert.False(vm.Globals.ContainsKey(0x7002));
}
[Fact]
public void DebugIgnoreExitRequestFallsThroughToFollowingBytecode()
{
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) }),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
var vm = new VirtualMachine(script, table, new RecordingHost(),
new VmOptions(IgnoreExitRequests: true));
vm.Run();
Assert.Equal("exit", vm.HaltReason);
Assert.Equal(1, vm.Globals[0x7000]);
}
}

View File

@@ -724,6 +724,7 @@ public sealed class VirtualMachine
return pc + 1;
}
case "throw-exit-request":
if (_o.IgnoreExitRequests) return pc + 1;
// 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();

View File

@@ -6,5 +6,7 @@ namespace Age.Engine.Vm;
/// through every prompt into code no real playthrough reaches (which is what made a headless SC0000 spin
/// 493k× in the name-entry poll loop). Leave false for interactive frontends that really block on input
/// (Godot) and for the dialogue-coverage sweep that deliberately walks every page.</param>
/// <param name="IgnoreExitRequests">Debug-only divergence: treat op 0x1 as a no-op so unreachable
/// post-exit bytecode can be explored. Leave false for native-faithful execution.</param>
public sealed record VmOptions(int EmitCap = 2, long MaxSteps = 2_000_000, int CallDepthCap = 64,
bool HaltAtWaitForInput = false);
bool HaltAtWaitForInput = false, bool IgnoreExitRequests = false);

View File

@@ -547,9 +547,14 @@ public sealed class GodotAdvHost : IHost
public double SleepScale = 1.0; // --sleep-scale <f>: debug multiplier for explicit op-0xc8 holds only
// Wait on the unified FrameClock timebase (not Thread.Sleep) so the Speed multiplier scales
// sleeps together with retained presentation clocks. Main._Process advances the clock + pulses each frame.
internal static long NormalizeSleepMilliseconds(long duration, double scale)
=> (long)System.Math.Clamp(duration * scale, 1, 60_000);
public void Sleep(long duration)
{
long ms = (long)System.Math.Clamp(duration * SleepScale, 0, 60_000); // cap so a pathological script can't hang the window
// Native sleep_timer_arm clamps the duration to at least 1 ms. In menu poll loops, sleep(0)
// therefore yields to the next engine tick instead of becoming a free-running no-op.
long ms = NormalizeSleepMilliseconds(duration, SleepScale);
long deadline = _clock.NowMs + ms;
_timeline?.State("sleep", new() { ["duration_ms"] = ms, ["deadline_ms"] = deadline });
// A sleep is a service boundary: make preceding retained writes visible once even when no animation

View File

@@ -159,6 +159,9 @@ public partial class Main : Godot.Control
var userArgs = OS.GetCmdlineUserArgs();
_selftest = System.Array.IndexOf(userArgs, "--selftest") >= 0;
bool boot = System.Array.IndexOf(userArgs, "--boot") >= 0; // diagnostic prefix for direct-scene runs
bool nativeDebugMenu = System.Array.IndexOf(userArgs, "--native-debug-menu") >= 0;
if (nativeDebugMenu)
GD.Print("[debug] native exit requests disabled; post-0x1 bytecode may execute");
string scene = "SYSTEM4"; // natural persistent root; --scene keeps direct diagnostics
var seeds = new List<(int Addr, long Val)>(); // --seed 0xADDR=VAL (repeatable) — initial global state
double sleepScale = 1.0; // --sleep-scale <f>: scale explicit op-0xc8 holds
@@ -223,7 +226,8 @@ public partial class Main : Godot.Control
Age.Engine.Diagnostics.ITraceSink sink = _trace;
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);
_vm = new VirtualMachine(script, table, _host,
new VmOptions(MaxSteps: 20_000_000, IgnoreExitRequests: nativeDebugMenu), provider, sink);
if (scripts != null)
{
_debugSceneEntries = DebugSceneCatalog.Build(scripts.Catalog);
@@ -348,6 +352,7 @@ public partial class Main : Godot.Control
{
_ended = true;
DumpHistogram();
GD.Print($"[vm] ended: {_vm!.HaltReason ?? "unknown"} after {_vm.Steps} steps");
ReportSubroutines();
ShowEnd();
if (_selftest) RunSelfTest();
@@ -1188,11 +1193,13 @@ public partial class Main : Godot.Control
launcherSmoke.Open(debugEntries, "SYSTEM4.BIN > TITLE.BIN");
launcherSmoke.Hide();
launcherSmoke.QueueFree();
ok &= launcherOk;
bool sleepMinimumOk = GodotAdvHost.NormalizeSleepMilliseconds(0, 1.0) == 1
&& GodotAdvHost.NormalizeSleepMilliseconds(100, 1.0) == 100;
ok &= launcherOk && sleepMinimumOk;
if (ok) GD.Print($"SELFTEST OK: threaded host matches headless ({actual.Count} lines, full handling); " +
$"debug launcher catalog/UI smoke ({debugEntries.Count} packed scripts)");
$"debug launcher catalog/UI smoke ({debugEntries.Count} packed scripts); sleep-min=1ms");
else GD.Print($"SELFTEST FAIL: threaded={actual.Count} vs headless={expected.Count}; " +
$"debug-launcher={launcherOk}");
$"debug-launcher={launcherOk}; sleep-min={sleepMinimumOk}");
GetTree().Quit(ok ? 0 : 1);
}

View File

@@ -2047,7 +2047,7 @@ abi_source = "kelebek+decode-validated"
name = "sleep"
category = "control"
summary = "Pause the current script for <duration> milliseconds while retained presentation continues."
details = "Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_timer_arm @0x44cff0 at ctx+0x5f304 = active flag + start tick + duration) that the engine main loop polls, resuming the script when elapsed. Operand UNIT = MILLISECONDS (start = ms tick source DAT_0056f3d4, timeGetTime/GetTickCount class). duration<10 fast-paths via [0x56f0b8]; all real scene sleeps (100/750/1000) are >=10. The handler also records its generic 3-dword instruction length and runs anti-tamper checks, neither needed host-side. Port equivalent: the Godot host parks the VM thread for duration ms while the presentation compositor continues. Sleep is one proven presentation-capable service boundary; ordinary AE setup runs burst-fast to 0x21c and is not paced per opcode. Headless hosts no-op it (parity)."
details = "Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_timer_arm @0x44cff0 at ctx+0x5f304 = active flag + start tick + duration) that the engine main loop polls, resuming the script when elapsed. Operand UNIT = MILLISECONDS (start = ms tick source DAT_0056f3d4, timeGetTime/GetTickCount class), and the native arm helper clamps duration to a minimum of 1 ms. ROOM's input loop deliberately uses sleep(0), making it a one-engine-tick yield rather than a no-op. The handler also records its generic 3-dword instruction length and runs anti-tamper checks, neither needed host-side. Port equivalent: the Godot host parks the VM thread for max(1, scaled duration) ms while the presentation compositor continues. Sleep is one proven presentation-capable service boundary; ordinary AE setup runs burst-fast to 0x21c and is not paced per opcode. Headless hosts no-op it (parity)."
noop_headless = false
source = "investigation"
confidence = "high"