From 2fa42b085a5fbde9e83f658cee0b474f9c02c72a Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 20 Jul 2026 23:38:19 -0400 Subject: [PATCH] Add hidden title debug mode and pace menu polls --- docs/engine-re.md | 10 +++++++++- docs/opcode-reference.md | 2 +- docs/phase-b-framework.md | 10 +++++++++- docs/tools-reference.md | 8 +++++++- engine/Age.Engine.Tests/ExitRequestTests.cs | 19 +++++++++++++++++++ engine/Age.Engine/Vm/VirtualMachine.cs | 1 + engine/Age.Engine/Vm/VmOptions.cs | 4 +++- godot/GodotAdvHost.cs | 7 ++++++- godot/Main.cs | 15 +++++++++++---- vm-map/opcodes.toml | 2 +- 10 files changed, 67 insertions(+), 11 deletions(-) diff --git a/docs/engine-re.md b/docs/engine-re.md index cf02a0d..dc9c6b7 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -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 diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index ace6507..9ceec10 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -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. diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 6cd3836..d8308ad 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -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. diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 932d9d8..5617b09 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -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 ` — 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 `, 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 [--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 [--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 ` — 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. diff --git a/engine/Age.Engine.Tests/ExitRequestTests.cs b/engine/Age.Engine.Tests/ExitRequestTests.cs index faa550c..d4c06ba 100644 --- a/engine/Age.Engine.Tests/ExitRequestTests.cs +++ b/engine/Age.Engine.Tests/ExitRequestTests.cs @@ -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()), + (0x55, new[] { G(0x7000), I(1) }), + (0x2, Array.Empty()), + }, Array.Empty()); + 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]); + } } diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 8c22910..8c866c6 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -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(); diff --git a/engine/Age.Engine/Vm/VmOptions.cs b/engine/Age.Engine/Vm/VmOptions.cs index 7ce9fc1..7d8a200 100644 --- a/engine/Age.Engine/Vm/VmOptions.cs +++ b/engine/Age.Engine/Vm/VmOptions.cs @@ -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. +/// Debug-only divergence: treat op 0x1 as a no-op so unreachable +/// post-exit bytecode can be explored. Leave false for native-faithful execution. public sealed record VmOptions(int EmitCap = 2, long MaxSteps = 2_000_000, int CallDepthCap = 64, - bool HaltAtWaitForInput = false); + bool HaltAtWaitForInput = false, bool IgnoreExitRequests = false); diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 19a6305..2172f66 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -547,9 +547,14 @@ public sealed class GodotAdvHost : IHost public double SleepScale = 1.0; // --sleep-scale : 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 diff --git a/godot/Main.cs b/godot/Main.cs index 6bb92b2..7935c39 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -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 : 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); } diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 9d88d0d..2835d41 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -2047,7 +2047,7 @@ abi_source = "kelebek+decode-validated" name = "sleep" category = "control" summary = "Pause the current script for 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"