diff --git a/docs/engine-re.md b/docs/engine-re.md index 4230116..6a880bf 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -2013,15 +2013,13 @@ creating a synthetic pointer click. Focused tests cover `0x88` enable/disable, ` `0x101`, retained host state, and the actual x=728 callback. The new EngineCtx fields are applied to `/v2`; the affected handlers, interpreter tick, and settings-default initializer are named/commented and saved. -**Manual pacing correction.** The first port build released skipped text/waits correctly but then let the -background VM free-run to the next non-skipped service boundary. That produced whole-scene bursts separated -by explicit sleeps: visibly an immediate jump, a slow point, then another immediate jump. Native -`adv_interpreter_tick` still dispatches exactly one opcode per engine tick while persistent Skip removes the -ordinary waits. `GodotAdvHost.FrameYield` now consumes one rendered-frame pulse per opcode only while -message Skip is active. Normal opcode bursts retain the existing run-to-service-boundary model; Skip gains -the missing native governor and remains fast without teleporting between blocking points. A regression proves -op `0x88` state reaches the host before the following cadence yields. Validation is engine 168/168, -zero-warning Godot build, and threaded `SELFTEST OK`. +**Skip pacing regression corrected (2026-07-29).** A later port change incorrectly treated +`adv_interpreter_tick`'s one-op dispatch as one rendered frame per opcode only while Skip was active. +That contradicted the synchronized native trace above: the outer service calls the dispatcher in fast bursts, +and Ctrl measured about 7,738 operand fetches/sec rather than one per display frame. The artificial +`GodotAdvHost.FrameYield` wait made the setup/cleanup bytecode between skipped fades take seconds even though +the fades themselves correctly jumped to their endpoints. `FrameYield` is nonblocking again; only explicit +presentation, sleep, and input services own wall-clock time. **ADV entry/exit lifecycle.** The script comments `savemesskip` and `loadmesskip` describe a temporary suspension boundary, not a second saved preference. `op_0x19b_suspend_adv_skip_service@0x416560` clears @@ -3454,6 +3452,13 @@ before `gfx_render_frame`. This is the generalized native CG-fade click path. It color/alpha and matrix channels serviced by `0x21c`, as well as queued type-0 surface commands; it is separate from the legacy run-state-bit-8 fade service. +The ordering is a lifecycle contract, not incidental: native clears run-state `0x400` before calling +`gfx_request_force_complete_and_reset_anim_clock`. Service-flags bit 1 can make that worker a no-op, and +detached or movie-backed presentation can remain active, but none of those states re-park the interpreter. +The port must therefore track “the `0x400` wait was bypassed” separately from “all timed presentation is +inactive.” Deriving the wait solely from the latter produces an invisible-duration stall: ordinary fade +channels reach their endpoint, while an excluded movie/protected channel keeps the VM blocked. + Corpus evidence is unusually sharp: all 303 calls pass an immediate flag, with zero used 302 times and one used once. SC0000's two CG-loader sites (`0x12723`, `0x13310`) write zero. `BTL.BIN@0x2b4d` writes one on an animated battle object after its texture/movie or sprite-cell setup, matching the nonblocking background @@ -3679,10 +3684,12 @@ uses callback slot `count` only for an empty mask. Godot translates layout-indep native Win32 VK namespace and sends physical mouse/standard joy events through the service; it no longer assigns Godot's `ui_*` actions directly to AGE indices. Direct-scene diagnostics replay the same 16 immediate SYSTEM4 configuration calls through `InputBindingBootstrap`, while natural boot executes the real opcodes. -The live action-6 state now also feeds the ADV fast-forward host channel while the ADV lifecycle service is -enabled. That transient channel is kept separate from persistent op-`0x88` Skip, so releasing the held key -cannot clear the user's toggle; both channels share the existing text completion, wait advance, voice -deferral, and skip cadence. This needs no profile storage, boot seed, or game-specific conditional. +The live action-6 state now also feeds the ADV fast-forward host channel independently of the +op-`0x19b`/`0x19c` lifecycle gate. Ghidra shows `adv_interpreter_tick` polling the physical action mask before +dispatch on every tick and testing bit `0x40` without consulting `adv_skip_service_enabled`; `0x19b` clears +persistent/read-driven active Skip but cannot release a physically held key. The transient physical channel +remains separate from persistent op-`0x88` Skip, so releasing the key cannot clear the user's toggle. Both +channels share text completion, wait advance, and voice deferral. ### ADV right-click/X system-menu path (2026-07-21) diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index dfc3381..81bf5aa 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -734,7 +734,7 @@ Port status (2026-07-24): the VM refreshes this state from message:ReadTextSkip - **depended on by:** 0x223 - **evidence:** Ghidra handler 0x417520 records the 1-dword instruction length and ORs ctx+0xa0ce4 with 0x400. engine_main_tick_with_exception_policy@0x411840 branch 0x4123d1 polls gfx_animation_service_poll; its active branch reads EffectSkipOnClick@0x570f60, rejects gfx_animation_service_flags bit 0, polls/consumes action-4 bit 0x10, clears run-state 0x400, calls gfx_request_force_complete_and_reset_anim_clock@0x4076c0, then gfx_render_frame. capture_presentation_trace.py: after 0x125a6 render, 0xcb8e/0xcb98 bind and 0xd5a/0xd63/0xd73/0xd8a mode+targets execute without render; repeated gfx_render_frame begins only at 0x21c. 2026-07-10, click lifecycle refined 2026-07-29. -SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit 0x400 parks the interpreter while gfx_render_frame repeatedly samples finite one-shot object channels and queued surface commands; op 0x224 follows after dirty state clears. When system:EffectSkipOnClick is enabled and animation-service flag bit 0 is clear, logical action 4 clears the service wait, requests the same forced completion as 0x243, renders the terminal retained frame, and consumes the trigger. That request completes ordinary finite color/scale/rotation/translation channels and type-0 surface commands together; op-0x242-detached channels, ambient cycles, and movie masks are excluded. Native trace proves AE001D bind, mode-1 0x203, and 0x202 targets complete in one 5 ms batch with no render, then first compose here. +SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit 0x400 parks the interpreter while gfx_render_frame repeatedly samples finite one-shot object channels and queued surface commands; op 0x224 follows after dirty state clears. When system:EffectSkipOnClick is enabled and animation-service flag bit 0 is clear, logical action 4 is consumed and run-state 0x400 is cleared before the engine requests the same forced completion as 0x243 and renders once. Wait release is independent of endpoint completion: service-flags bit 1 can suppress the force worker, while op-0x242-detached channels, ambient cycles, and movie-backed presentation can continue asynchronously after interpreter resume. With ordinary flags, the request completes finite color/scale/rotation/translation channels and type-0 surface commands together. Native trace proves AE001D bind, mode-1 0x203, and 0x202 targets complete in one 5 ms batch with no render, then first compose here. ## draw @@ -1341,12 +1341,14 @@ Port status (2026-07-29): implemented as unsigned scene-context state. The sette - **evidence:** Ghidra /v2: op_0x19a_get_message_skip@0x4271b0 writes ctx+0x550fc to operand 1. The shared ADV redraw routine uses it to select SO001's active Message-skip icon at x=728. ### 0x19b `suspend-adv-skip-service` (u00414E80, argc 0) -- **summary:** () - suspend active ADV fast-forward while preserving the user's persistent all-message Skip toggle. +- **summary:** () - suspend persistent/read-driven ADV fast-forward while preserving the user's all-message Skip toggle; physically held logical action 6 is independent. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra /v2: op_0x19b_suspend_adv_skip_service@0x416560 clears run-state bit 0x08000000, ctx+0x13dc active message skip, ctx+0x55100 ADV skip-service enable, and the click-cancel phase, but deliberately leaves ctx+0x550fc persistent all-message Skip unchanged. Corpus comments call it `savemesskip`; ADV-end/branch and CALLBACK_LOAD paths use it before leaving ADV presentation. +- **evidence:** Ghidra /v2: op_0x19b_suspend_adv_skip_service@0x416560 clears run-state bit 0x08000000, ctx+0x13dc active message skip, ctx+0x55100 ADV skip-service enable, and the click-cancel phase, but deliberately leaves ctx+0x550fc persistent all-message Skip unchanged. It does not mutate the input manager; adv_interpreter_tick@0x410fb0 polls logical action 6 (mask bit 0x40) independently of ctx+0x55100. Corpus comments call the opcode `savemesskip`; ADV-end/branch and CALLBACK_LOAD paths use it before leaving ADV presentation. + +This lifecycle operation affects persistent/read-driven active Skip only. Physical logical action 6 is process input polled independently by adv_interpreter_tick and remains active while held. ### 0x19c `resume-adv-skip-service` (u00414EC0, argc 0) -- **summary:** () - enable the ADV skip service and recompute active fast-forward from persistent all-message Skip or the live read-skip channel. +- **summary:** () - enable the persistent/read ADV skip service and recompute active fast-forward from all-message Skip or the live read-skip channel; physical action 6 remains independent. - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x19c_resume_adv_skip_service@0x4165a0 sets ctx+0x55100, clears the click-cancel phase, and normally activates run-state bit 0x08000000 when ctx+0x550fc persistent all-message Skip or ctx+0x6dbd4 adv_read_skip_state is nonzero. Otherwise it clears ctx+0x13dc and the run-state bit. A separate startup fast-forward guard at ctx+0x6f86c suppresses reactivation while active. Corpus comments call it `loadmesskip`; ADV setup, branch, and CALLBACK_LOAD paths pair it with op 0x19b. diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index f43cae0..0f73018 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -874,6 +874,42 @@ activating Ctrl/Skip during the wait uses the same endpoint path. Ambient loops, and movie masks remain unaffected. Focused exclusions and endpoint regressions pass in the 523-test engine suite, and the Godot build is warning-free. +**Retained transition wait ownership corrected (2026-07-29):** playthrough testing with Skip held exposed +that endpoint completion alone was insufficient. Skip could force an ordinary fade invisible, yet the host +continued waiting on the broad presentation predicate until a movie-backed/protected channel or the original +duration ended. Native clears run-state `0x400` before requesting endpoint completion, so interpreter resume +is independent of whether excluded presentation remains active. The host now records that explicit service +bypass, exits the transition wait immediately when the action is accepted, and reapplies an already-active +Skip state at every new retained-transition boundary. Focused coverage distinguishes service-flags bit 0 +(reject the click) from bit 1 (accept resume but suppress endpoint forcing) and proves movie masks continue +asynchronously after wait bypass. A direct `SC0000@0xc01` runtime trace—the page before the ritual CG +sequence—shows its four click-skipped retained waits completing in 14, 14, 14, and 34 ms despite scripted +durations of 150/890/300 ms; the later 1.587-second interval is the next page's ordinary glyph reveal at +`SC0000@0xe04`, not an invisible transition wait. + +**Held-Skip pacing corrected (2026-07-29):** comparing held logical action 6 against the click trace at +`SC0000@0xc01` exposed a separate delay outside the transition service. The Godot host was waiting for one +rendered-frame pulse after every VM opcode while Skip was active, stretching hundreds of burst-fast CG +setup/cleanup instructions into an invisible multi-second hold. Native live evidence instead measures roughly +7,738 operand fetches/sec under Ctrl, and Ghidra shows the physical action-6 mask polled independently of the +`0x19b`/`0x19c` persistent-Skip lifecycle. Per-op Skip pacing is removed, and a held Ctrl/C/Backspace channel +now survives `0x19b`; presentation, sleep, and input boundaries remain the only wall-clock owners. The +post-fix held-action trace reaches page 14 from `SC0000@0xc01` in 83 ms and takes the native +`0x243`+`0x20c` endpoint branch at every intervening transition. The engine suite passes 525/525. + +**Nested ADV input ownership corrected (2026-07-29):** the first-dungeon boss event reaches ordinary +dialogue at `SC0600@0x2343` and parks at `wait-for-input@0x234b`, but FIELD's registered timed mouse +callback remains stored in its suspended parent script frame. The frontend incorrectly treated any stored +raw callback as current modal input ownership. It consequently hid the wait indicator and withheld normal +mouse/keyboard/joy advance signals from the nested SC0600 page, producing a softlock immediately before +combat. + +Raw callback ownership is now frame-relative: it is active only while its owning script frame is the +currently executing frame. The dormant FIELD registration survives the nested call and becomes active +again when FIELD resumes, while SC0600 retains its ordinary ADV marker and input path. A synthetic +parent-callback → child-dialogue regression preserves both halves of that lifetime. The engine suite passes +526/526, the Godot build has zero warnings, and the Himegari-targeted threaded selftest passes. + **Cyclic reset implemented (2026-07-29):** `0x230(handle)` now gets or creates the retained object, disables the four looping channels represented by the compositor, and clears the complete native start/period block—including the preserved raw state for the currently unmodeled second cyclic matrix. diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 2560966..1551794 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -362,6 +362,9 @@ use the last `input-wait` event in `timeline.jsonl` as the authoritative manual and `colorProgress`, synchronized with the same frame/clock in `--timeline-log`. Parent directories are created automatically. - `--transition-click-ms ` — diagnostic-only input injector: after a foreground transition has been active for `n` virtual milliseconds, send one click through the real input lifecycle. The click completes/consumes the transition and does not advance a stable page. Use with `--timeline-log`, `--gfx-log`, and windowed `--shot-sequence`; omit for normal play. +- `--hold-message-skip` — diagnostic-only held-input injector: hold Ctrl through the VM's real keyboard + binding path, producing native logical action 6 continuously. Use it to compare persistent fast-forward + against `--transition-click-ms` at the same page without relying on synthetic host state. - `--timeline-log ` — diagnostic-only synchronized event stream for a real Godot run. Records every executed script byte offset/opcode, script-frame entry/exit with depth/cause/outcome, resolved call-script ids/names, unknown-fallback `stub` events, virtual time/frame, VM state changes (`running`, `sleep`, `input-wait`, diff --git a/engine/Age.Engine.Tests/ForegroundTransitionTests.cs b/engine/Age.Engine.Tests/ForegroundTransitionTests.cs index d754ffd..a96778e 100644 --- a/engine/Age.Engine.Tests/ForegroundTransitionTests.cs +++ b/engine/Age.Engine.Tests/ForegroundTransitionTests.cs @@ -111,7 +111,8 @@ public class ForegroundTransitionTests gfx.QueueMovieMaskTransition(new MovieMaskTransitionRequest( 11, 45, 10, 1, -184, 0, 800, 600, 0, 0x325e, 0, 1000)); - Assert.Equal(3, gfx.CompleteClickSkippableTimedPresentation(200)); + Assert.True(gfx.TryCompleteClickSkippableTimedPresentation(200, out int completed)); + Assert.Equal(3, completed); Assert.Equal(1.0, gfx.SnapshotForegroundTransitions(200).Single().Progress); Assert.False(gfx.TryGet(100)!.OneShotColorEnabled); @@ -123,9 +124,8 @@ public class ForegroundTransitionTests [Theory] [InlineData(1)] - [InlineData(2)] [InlineData(3)] - public void AnimationServiceFlagsSuppressClickCompletion(long flags) + public void AnimationServiceFlagBit0RejectsClickCompletion(long flags) { var gfx = new GfxState(); gfx.SetSurface(4, 0x25, -1); @@ -133,7 +133,35 @@ public class ForegroundTransitionTests gfx.SetAnimatedObjectColorResolved(100, 0, 1000, 0, 0xffffff); gfx.SetAnimationServiceFlags(flags); - Assert.Equal(0, gfx.CompleteClickSkippableTimedPresentation(100)); + Assert.False(gfx.TryCompleteClickSkippableTimedPresentation(100, out int completed)); + Assert.Equal(0, completed); Assert.True(gfx.TryGet(100)!.OneShotColorEnabled); } + + [Fact] + public void AnimationServiceFlagBit1AcceptsWaitBypassWithoutForcingEndpoint() + { + var gfx = new GfxState(); + gfx.SetSurface(4, 0x25, -1); + gfx.BindDraw(100, 4, 0, 0, 800, 600, 0, 0); + gfx.SetAnimatedObjectColorResolved(100, 0, 1000, 0, 0xffffff); + gfx.SetAnimationServiceFlags(2); + + Assert.True(gfx.TryCompleteClickSkippableTimedPresentation(100, out int completed)); + Assert.Equal(0, completed); + Assert.True(gfx.TryGet(100)!.OneShotColorEnabled); + } + + [Fact] + public void MovieMaskAcceptsWaitBypassAndContinuesAsynchronously() + { + var gfx = new GfxState(); + gfx.QueueMovieMaskTransition(new MovieMaskTransitionRequest( + 11, 45, 10, 1, -184, 0, 800, 600, 0, 0x325e, 0, 1000)); + + Assert.True(gfx.TryCompleteClickSkippableTimedPresentation(100, out int completed)); + Assert.Equal(0, completed); + Assert.True(gfx.HasActiveTimedPresentation(100)); + Assert.False(gfx.SnapshotMovieMaskTransitions().Single().Completed); + } } diff --git a/engine/Age.Engine.Tests/InputBindingTests.cs b/engine/Age.Engine.Tests/InputBindingTests.cs index a618adc..dd5e5a4 100644 --- a/engine/Age.Engine.Tests/InputBindingTests.cs +++ b/engine/Age.Engine.Tests/InputBindingTests.cs @@ -85,7 +85,7 @@ public class InputBindingTests Sys4ScriptProvider.Load(Table).RequireByName("SYSTEM4.BIN"), vm.InputBindings); vm.UpdateKeyboardVirtualKeyState(virtualKey, true); - Assert.False(host.PhysicalMessageSkip); // ADV lifecycle is not active yet. + Assert.True(host.PhysicalMessageSkip); vm.Run(); @@ -98,6 +98,29 @@ public class InputBindingTests Assert.False(host.MessageSkip); } + [Fact] + public void HeldLogicalActionSixSurvivesPersistentSkipLifecycleSuspension() + { + var script = ScriptAssembler.Assemble(Table, "HELD_SKIP_LIFECYCLE", new List<(int, Operand[])> + { + (0x19c, Array.Empty()), + (0x19b, Array.Empty()), + (0x1c7, new[] { G(0x140) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var host = new RecordingHost(); + var vm = new VirtualMachine(script, Table, host); + InputBindingBootstrap.Apply( + Sys4ScriptProvider.Load(Table).RequireByName("SYSTEM4.BIN"), vm.InputBindings); + + vm.UpdateKeyboardVirtualKeyState(0x11, true); + vm.Run(); + + Assert.True(host.PhysicalMessageSkip); + Assert.True(host.MessageSkip); + Assert.Equal(1, vm.Globals.GetValueOrDefault(0x140)); + } + [Fact] public void ReleasingPhysicalFastForwardDoesNotClearPersistentSkip() { diff --git a/engine/Age.Engine.Tests/WaitForInputTests.cs b/engine/Age.Engine.Tests/WaitForInputTests.cs index 9292962..00fbaa7 100644 --- a/engine/Age.Engine.Tests/WaitForInputTests.cs +++ b/engine/Age.Engine.Tests/WaitForInputTests.cs @@ -6,6 +6,28 @@ using Xunit; public class WaitForInputTests { + private sealed class NestedWaitHost : RecordingHost + { + public VirtualMachine Vm = null!; + public bool SawDormantParentCallback; + public bool SawParentCallbackRestored; + + public override void WaitForInput(int layoutSlot, System.Func serviceInputCallback) + { + Waits++; + SawDormantParentCallback = + Vm.RawInputCallbackScriptName == "PARENT" + && !Vm.IsRawInputCallbackActive; + } + + public override void Sleep(long duration) + { + SawParentCallbackRestored = + Vm.RawInputCallbackScriptName == "PARENT" + && Vm.IsRawInputCallbackActive; + } + } + // wait-for-input (0x72) fires per page. Synthesize a two-page scene and assert it fires exactly // twice — full handling, no dependency on a real scene's (stubbed) line count. [Fact] @@ -28,4 +50,35 @@ public class WaitForInputTests Assert.Equal(new[] { "page one", "page two" }, vm.Emitted.Select(e => e.Text).ToArray()); Assert.Equal("exit", vm.HaltReason); } + + [Fact] + public void DormantParentRawInputCallbackDoesNotOwnNestedDialogueWait() + { + var t = OpcodeTableJson.Load(Paths.OpcodesJson); + var child = ScriptAssembler.Assemble(t, "CHILD", new List<(int, Operand[])> + { + (0x6e, new[] { new Operand(2, 0), new Operand(0, 0) }), + (0x72, new[] { new Operand(0, 0) }), + (0x2, System.Array.Empty()), + }, new[] { "nested dialogue" }); + var parent = ScriptAssembler.Assemble(t, "PARENT", new List<(int, Operand[])> + { + // FIELD-style timed mouse callback remains registered while call-script enters an ADV scene. + (0xcc, new[] { new Operand(0, 50), new Operand(0, 0xffff_ffff) }), + (0x3, new[] { new Operand(0, 5) }), + (0xc8, new[] { new Operand(0, 0) }), + (0x2, System.Array.Empty()), + }, System.Array.Empty()); + var host = new NestedWaitHost(); + var vm = new VirtualMachine(parent, t, host, provider: new MapProvider(new() { [5] = child })); + host.Vm = vm; + + vm.Run(); + + Assert.True(host.SawDormantParentCallback); + Assert.True(host.SawParentCallbackRestored); + Assert.Equal(1, host.Waits); + Assert.False(vm.IsRawInputCallbackActive); + Assert.Equal("exit", vm.HaltReason); + } } diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index 750ba7d..45774b9 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -934,38 +934,41 @@ public sealed class GfxState (o.RotationEnabled && o.RotationPeriodMs > 0))); } - /// Force the finite retained presentation serviced by native run-state bit 0x400 to its - /// endpoint. This is the EffectSkipOnClick path: type-0 surface commands and ordinary finite object - /// channels complete together, while movie masks, ambient cycles, and op-0x242-detached channels remain - /// active. Op 0x24e bit 0 disables click skipping; bit 1 suppresses the shared force-complete request. - public int CompleteClickSkippableTimedPresentation(long nowMs) + /// Consume the native run-state-0x400 EffectSkipOnClick action and, when permitted, force its + /// finite retained presentation to the endpoint. Bit 0 of op 0x24e rejects the action entirely. Bit 1 + /// still allows the service wait to end but suppresses the shared force-complete request. Movie masks, + /// ambient cycles, and op-0x242-detached channels continue asynchronously after the service resumes. + public bool TryCompleteClickSkippableTimedPresentation(long nowMs, out int completed) { lock (_lock) { - if ((AnimationServiceFlags & 3) != 0) return 0; + completed = 0; + if ((AnimationServiceFlags & 1) != 0) return false; - int completed = 0; - foreach (var t in _surfaceTransitions.Values) + if ((AnimationServiceFlags & 2) == 0) { - if (t.Forced || TransitionProgress(t, nowMs) >= 1.0) continue; - t.Forced = true; - completed++; - } + foreach (var t in _surfaceTransitions.Values) + { + if (t.Forced || TransitionProgress(t, nowMs) >= 1.0) continue; + t.Forced = true; + completed++; + } - completed += CountOneShotChannels(_rangeTransform); - foreach (var o in _objects.Values) - { - if ((o.OneShotAnimationControlFlags & 1) != 0) continue; - completed += CountOneShotChannels(o); - } - ForceCompleteOneShotChannels(); + completed += CountOneShotChannels(_rangeTransform); + foreach (var o in _objects.Values) + { + if ((o.OneShotAnimationControlFlags & 1) != 0) continue; + completed += CountOneShotChannels(o); + } + ForceCompleteOneShotChannels(); - if (AnimClockDurationTicks != 0) completed++; - AnimClockDurationTicks = 0; - AnimClockGeneration++; + if (AnimClockDurationTicks != 0) completed++; + AnimClockDurationTicks = 0; + AnimClockGeneration++; + } if (completed > 0) MarkRetainedMutation(); - return completed; + return true; } } diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 00d39bd..622f3db 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -83,7 +83,6 @@ public sealed class VirtualMachine private bool _sharedProfileShutdownHandled; private volatile bool _messageSkipEnabled; private volatile bool _messageSkipServiceActive; - private volatile bool _advSkipServiceEnabled; private bool _advReadSkipState; private AdvTextStyle _advTextStyle = AdvTextStyle.Default; private int _messageWindowAlphaSetting; @@ -138,10 +137,18 @@ public sealed class VirtualMachine } } } - /// True while a script-owned timed mouse/input callback loop (HISTORY/HIDEWIN family) owns input. + /// True while the currently executing script frame owns a timed raw mouse/input callback + /// loop (HISTORY/HIDEWIN/FIELD family). A registered callback in a suspended parent frame is dormant + /// while a nested ADV scene owns the VM and must not suppress that child's ordinary page input. public bool IsRawInputCallbackActive { - get { lock (_interactiveLock) return _rawInputFrame != null; } + get + { + ExecFrame? rawInputFrame; + lock (_interactiveLock) rawInputFrame = _rawInputFrame; + if (rawInputFrame == null) return false; + lock (_debugControlLock) return ReferenceEquals(rawInputFrame, _debugActiveFrame); + } } public string? RawInputCallbackScriptName { @@ -357,13 +364,13 @@ public sealed class VirtualMachine } while (Interlocked.CompareExchange(ref field, after, before) != before); } - /// Mirror adv_interpreter_tick's bit-0x40 path. The bit is logical action 6 from the + /// Mirror adv_interpreter_tick's live bit-0x40 poll. The bit is logical action 6 from the /// process-owned binding map, not a hardcoded Ctrl test; Himegari also binds C and retains the - /// engine's default Backspace binding. The presentation lifecycle gate prevents a held action - /// from leaking into non-ADV script execution. + /// engine's default Backspace binding. Native polls this physical channel independently of the + /// op-0x19b/0x19c lifecycle used by persistent/read-message Skip. private void RefreshPhysicalMessageSkipState() { - bool active = _advSkipServiceEnabled && (InputBindings.PollActionMask() & 0x40) != 0; + bool active = (InputBindings.PollActionMask() & 0x40) != 0; _host.SetPhysicalMessageSkipActive(active); } @@ -790,7 +797,6 @@ public sealed class VirtualMachine _autoVoicePending = false; _messageSkipEnabled = false; _messageSkipServiceActive = false; - _advSkipServiceEnabled = false; _advReadSkipState = false; _advTextStyle = AdvTextStyle.Default; _systemMenuActionsEnabled = 1; @@ -799,6 +805,7 @@ public sealed class VirtualMachine _host.SetMessageSkipActive(false); _host.SetPhysicalMessageSkipActive(false); _host.ResetSceneContext(); + RefreshPhysicalMessageSkipState(); } private static Stack[] CreateIntegerStacks() @@ -2276,22 +2283,19 @@ public sealed class VirtualMachine Write(a[0], _messageSkipEnabled ? 1 : 0); return pc + 1; case "u00414E80": case "suspend-adv-skip-service": // 0x19b: preserve the toggle while leaving ADV presentation - _advSkipServiceEnabled = false; _messageSkipServiceActive = false; _host.SetMessageSkipActive(false); - _host.SetPhysicalMessageSkipActive(false); return pc + 1; case "u00414EC0": case "resume-adv-skip-service": // 0x19c: recompute active fast-forward on ADV entry - _advSkipServiceEnabled = true; _messageSkipServiceActive = _messageSkipEnabled || _advReadSkipState || _host.IsAdvReadSkipActive; _host.SetMessageSkipActive(_messageSkipServiceActive); RefreshPhysicalMessageSkipState(); return pc + 1; case "get-message-skip": // 0x1c7: persistent Skip or host-supplied Ctrl fast-forward - // The native per-op tick continually re-arms the transient run-state bit while the - // ADV service is enabled. The host channel also carries physical fast-forward input. + // Native persistent state and the independently polled physical action-6 channel both + // re-arm the transient run-state bit consumed by this query. Write(a[0], _messageSkipServiceActive || _host.IsMessageSkipActive ? 1 : 0); return pc + 1; case "get-adv-read-skip-state": // 0x1cc: per-message read/click skip service state case "get-adv-service-state": // compatibility with pre-recovery generated tables diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index ee43e4c..f853829 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -94,6 +94,7 @@ public sealed class GodotAdvHost : IHost private volatile bool _modalMovieWaiting; private volatile bool _modalMovieCancelled; private GfxState? _foregroundGfx; + private volatile bool _foregroundTransitionWaitBypassed; private readonly object _screenTransitionLock = new(); private readonly Dictionary> _renderTargetSnapshots = new(); private readonly object _backbufferRangeLock = new(); @@ -662,13 +663,16 @@ public sealed class GodotAdvHost : IHost { if (IsTransitionWaiting && _foregroundGfx != null) { - int completed = _foregroundGfx.CompleteClickSkippableTimedPresentation(_clock.NowMs); - if (completed > 0) + bool accepted = _foregroundGfx.TryCompleteClickSkippableTimedPresentation( + _clock.NowMs, out int completed); + if (accepted) { - _timeline?.State("transition-forced-complete", new() + _foregroundTransitionWaitBypassed = true; + _timeline?.State("transition-skip-accepted", new() { - ["count"] = completed, + ["endpoints_completed"] = completed, ["source"] = source, + ["wait_bypassed"] = true, }); _frameSignal.Set(); return true; @@ -781,6 +785,7 @@ public sealed class GodotAdvHost : IHost public void WaitForForegroundTransition(GfxState gfx) { + _foregroundTransitionWaitBypassed = false; int started = gfx.StartForegroundTransitions(_clock.NowMs); bool hasActivePresentation = gfx.HasActiveTimedPresentation(_clock.NowMs) || HasActiveMoviePresentation(); @@ -790,6 +795,10 @@ public sealed class GodotAdvHost : IHost System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, _clock.NowMs); IsTransitionWaiting = true; _timeline?.State("transition-start", new() { ["count"] = started }); + // Skip may already have been active before this service boundary. Native ADV script usually + // selects 0x243+0x20c in that case, but applying it here also covers transition helpers that + // enter run-state 0x400 without repeating the script-side query. + if (_messageSkipActive) TryForceActiveTransition("message-skip"); } bool scriptSuspended = SuspendScriptForPresentation(); try @@ -797,7 +806,9 @@ public sealed class GodotAdvHost : IHost RequestSynchronizedPresentation(); if (!hasActivePresentation) return; int lastBucket = -1; - while ((gfx.HasActiveTimedPresentation(_clock.NowMs) || HasActiveMoviePresentation()) && !_stopping) + while (!_foregroundTransitionWaitBypassed + && (gfx.HasActiveTimedPresentation(_clock.NowMs) || HasActiveMoviePresentation()) + && !_stopping) { var active = gfx.SnapshotForegroundTransitions(_clock.NowMs); int bucket = active.Count == 0 ? 100 : (int)System.Math.Floor(active[0].Progress * 10); @@ -812,8 +823,8 @@ public sealed class GodotAdvHost : IHost } _frameSignal.WaitOne(50); } - // The active query becomes false at the exact transition/movie endpoint. Publish that terminal sample - // once so the last visible frame cannot remain fractionally incomplete. + // Publish the natural terminal sample or the forced service-resume sample once before the + // following script burst mutates/releases its retained inputs. RequestSynchronizedPresentation(); } finally @@ -821,6 +832,7 @@ public sealed class GodotAdvHost : IHost ResumeScriptAfterPresentation(scriptSuspended); } IsTransitionWaiting = false; + _foregroundTransitionWaitBypassed = false; System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, -1); _foregroundGfx = null; _timeline?.State("running", new() { ["transition_complete"] = true }); @@ -1102,13 +1114,10 @@ public sealed class GodotAdvHost : IHost _frameSignal.Set(); } - // Ordinary opcode bursts run to the next service boundary without frame pacing. Persistent message - // Skip removes most of those boundaries, but native adv_interpreter_tick still executes one opcode per - // engine tick; retain that cadence here so Skip advances quickly instead of free-running whole scenes. - public void FrameYield() - { - if (_messageSkipActive && !_stopping) _frameSignal.WaitOne(50); - } + // Native dispatch remains burst-fast between explicit presentation/sleep/input services even when + // message Skip removes those services. Per-op frame pacing turns ordinary skipped setup/cleanup bursts + // into multi-second invisible stalls. + public void FrameYield() { } // op 0xc8: block the VM background thread while the main-thread compositor keeps presenting retained state. // Time-based sibling of WaitForInput's suspend. The native op arms a non-blocking main-loop-polled timer; diff --git a/godot/Main.cs b/godot/Main.cs index 5d1c4af..b0d6196 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -265,6 +265,7 @@ public partial class Main : Godot.Control double sleepScale = 1.0; // --sleep-scale : scale explicit op-0xc8 holds double speed = 1.0; // --speed : sleeps + retained presentation clocks long transitionClickMs = -1; // --transition-click-ms : force active transitions after n virtual ms + bool holdMessageSkip = false; // --hold-message-skip: hold native logical action 6 for diagnostics string? histFile = null; // --trace-histogram : op/call-site execution counts of the REAL run string? pageMapPath = null; // --page-map : override default build/page-map-SCxxxx.jsonl for (int i = 0; i < userArgs.Length; i++) @@ -289,6 +290,7 @@ public partial class Main : Godot.Control if (userArgs[i] == "--sleep-scale" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out sleepScale); if (userArgs[i] == "--speed" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out speed); if (userArgs[i] == "--transition-click-ms" && i + 1 < userArgs.Length) long.TryParse(userArgs[i + 1], out transitionClickMs); + if (userArgs[i] == "--hold-message-skip") holdMessageSkip = true; if (userArgs[i] == "--trace-histogram" && i + 1 < userArgs.Length) histFile = userArgs[i + 1]; if (userArgs[i] == "--page-map" && i + 1 < userArgs.Length) pageMapPath = userArgs[i + 1]; if (userArgs[i] == "--locator-hud") _locatorHudVisible = true; @@ -457,6 +459,8 @@ public partial class Main : Godot.Control if (!_selftest && scene.Equals("SC0000", System.StringComparison.OrdinalIgnoreCase)) _vm.ExternalGlobals[0x6242d] = 4; foreach (var (addr, val) in seeds) _vm.Globals[addr] = val; // --seed overrides boot state + if (holdMessageSkip) + _vm.UpdateKeyboardVirtualKeyState(0x11, true); // Ctrl; bindings resolve it to logical action 6. _vmTask = Task.Run(() => { try { _vm.Run(); } diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index 7d4600d..e12e5da 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -50,8 +50,8 @@ INFERRED: dict[int, dict] = { 0x143: dict(name='run-mounted-append-autoruns', category='control', noop=False, confidence='high', source='investigation', summary='() - enqueue record zero from every mounted nonzero AAI selector in ascending selector order, then execute those packed scripts serially before resuming the caller.'), 0x199: dict(name='yield-adv-coroutine', category='control', noop=False, confidence='high', source='investigation', summary='Yield/re-enter the registered ADV coroutine handler. The fifth standard chrome button uses this transition to enter the HIDEWIN/window-hidden flow.'), 0x19a: dict(name='get-message-skip', category='input', noop=False, confidence='high', source='investigation', summary='(out) - return the current all-message skip state set by op 0x88.'), - 0x19b: dict(name='suspend-adv-skip-service', category='input', noop=False, confidence='high', source='investigation', summary="() - suspend active ADV fast-forward while preserving the user's persistent all-message Skip toggle."), - 0x19c: dict(name='resume-adv-skip-service', category='input', noop=False, confidence='high', source='investigation', summary='() - enable the ADV skip service and recompute active fast-forward from persistent all-message Skip or the live read-skip channel.'), + 0x19b: dict(name='suspend-adv-skip-service', category='input', noop=False, confidence='high', source='investigation', summary="() - suspend persistent/read-driven ADV fast-forward while preserving the user's all-message Skip toggle; physically held logical action 6 is independent."), + 0x19c: dict(name='resume-adv-skip-service', category='input', noop=False, confidence='high', source='investigation', summary='() - enable the persistent/read ADV skip service and recompute active fast-forward from all-message Skip or the live read-skip channel; physical action 6 remains independent.'), 0x1a6: dict(name='half-byte-string-length', category='compute', noop=False, confidence='high', source='investigation', summary="Write half the resolved string's byte length, using integer truncation."), 0x1a8: dict(name='instruction-marker-noop', category='marker', noop=True, confidence='high', source='investigation', summary="Zero-operand structural marker. The native shared 0xaf/0x1a8 handler only records this instruction's one-dword length and returns."), 0x1b2: dict(name='append-diagnostic-value', category='control', noop=False, confidence='high', source='investigation', summary="Format operand 1 with AGE's generic operand-to-string conversion and append its exact bytes to the context diagnostic accumulator. This does not present or clear the accumulated text."), diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 616bcf8..642b233 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -3350,12 +3350,13 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "suspend-adv-skip-service" category = "input" -summary = "() - suspend active ADV fast-forward while preserving the user's persistent all-message Skip toggle." +summary = "() - suspend persistent/read-driven ADV fast-forward while preserving the user's all-message Skip toggle; physically held logical action 6 is independent." +details = "This lifecycle operation affects persistent/read-driven active Skip only. Physical logical action 6 is process input polled independently by adv_interpreter_tick and remains active while held." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra /v2: op_0x19b_suspend_adv_skip_service@0x416560 clears run-state bit 0x08000000, ctx+0x13dc active message skip, ctx+0x55100 ADV skip-service enable, and the click-cancel phase, but deliberately leaves ctx+0x550fc persistent all-message Skip unchanged. Corpus comments call it `savemesskip`; ADV-end/branch and CALLBACK_LOAD paths use it before leaving ADV presentation." +evidence = "Ghidra /v2: op_0x19b_suspend_adv_skip_service@0x416560 clears run-state bit 0x08000000, ctx+0x13dc active message skip, ctx+0x55100 ADV skip-service enable, and the click-cancel phase, but deliberately leaves ctx+0x550fc persistent all-message Skip unchanged. It does not mutate the input manager; adv_interpreter_tick@0x410fb0 polls logical action 6 (mask bit 0x40) independently of ctx+0x55100. Corpus comments call the opcode `savemesskip`; ADV-end/branch and CALLBACK_LOAD paths use it before leaving ADV presentation." [[opcode]] op = 0x19c @@ -3366,7 +3367,7 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "resume-adv-skip-service" category = "input" -summary = "() - enable the ADV skip service and recompute active fast-forward from persistent all-message Skip or the live read-skip channel." +summary = "() - enable the persistent/read ADV skip service and recompute active fast-forward from all-message Skip or the live read-skip channel; physical action 6 remains independent." noop_headless = false source = "investigation" confidence = "high" @@ -5588,7 +5589,7 @@ abi_source = "kelebek+decode-validated" name = "mark-frame-yield" category = "control" summary = "Set native run-state bit 0x400; in normal ADV playback this is the retained-presentation render/wait/resume boundary." -details = "SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit 0x400 parks the interpreter while gfx_render_frame repeatedly samples finite one-shot object channels and queued surface commands; op 0x224 follows after dirty state clears. When system:EffectSkipOnClick is enabled and animation-service flag bit 0 is clear, logical action 4 clears the service wait, requests the same forced completion as 0x243, renders the terminal retained frame, and consumes the trigger. That request completes ordinary finite color/scale/rotation/translation channels and type-0 surface commands together; op-0x242-detached channels, ambient cycles, and movie masks are excluded. Native trace proves AE001D bind, mode-1 0x203, and 0x202 targets complete in one 5 ms batch with no render, then first compose here." +details = "SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit 0x400 parks the interpreter while gfx_render_frame repeatedly samples finite one-shot object channels and queued surface commands; op 0x224 follows after dirty state clears. When system:EffectSkipOnClick is enabled and animation-service flag bit 0 is clear, logical action 4 is consumed and run-state 0x400 is cleared before the engine requests the same forced completion as 0x243 and renders once. Wait release is independent of endpoint completion: service-flags bit 1 can suppress the force worker, while op-0x242-detached channels, ambient cycles, and movie-backed presentation can continue asynchronously after interpreter resume. With ordinary flags, the request completes finite color/scale/rotation/translation channels and type-0 surface commands together. Native trace proves AE001D bind, mode-1 0x203, and 0x202 targets complete in one 5 ms batch with no render, then first compose here." noop_headless = false source = "investigation" confidence = "high"