From c6b498598456ae079dd449a0af7927f2872e932d Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 21 Jul 2026 10:45:46 -0400 Subject: [PATCH] Implement held ADV fast-forward input --- docs/engine-re.md | 12 +++- docs/opcode-reference.md | 2 +- docs/phase-a-slice-plan.md | 16 +++++ engine/Age.Engine.Tests/InputBindingTests.cs | 53 +++++++++++++++++ engine/Age.Engine.Tests/TestSupport.cs | 14 ++++- engine/Age.Engine/Hosting/IHost.cs | 3 + engine/Age.Engine/Vm/VirtualMachine.cs | 20 +++++++ godot/GodotAdvHost.cs | 62 ++++++++++++++++---- vm-map/opcodes.toml | 2 +- 9 files changed, 167 insertions(+), 17 deletions(-) diff --git a/docs/engine-re.md b/docs/engine-re.md index b73aac9..8ddf714 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -2292,6 +2292,12 @@ poll/dispatch ops `0xff` and `0x100`. These calls are effectful engine configura `input_poll_joystick_action_bits@0x460380`. The first three logical actions are therefore not a generic Godot UI ordering; they are the native engine ABI established by the input manager and refined by scripts. +The same mask is also consumed directly by `adv_interpreter_tick@0x410fb0`. Logical action 6 is bit +`0x40`, the exact bit used by the transient ADV fast-forward/run-state path. There is no Ctrl-specific +branch: SYSTEM4's C and LeftCtrl mappings, the retained native Backspace default, and any joystick mapping +that emits action 6 all reach the same hold-to-fast-forward mechanism. Persistent op-`0x88` Skip injects +that same bit independently each tick. + The port now models this as one process-owned `InputBindings` service. It starts with the native seven keyboard defaults, the four configuration handlers mutate it, and `0xff` combines live keyboard-VK, left/right mouse, joystick-axis, and joystick-button state with the narrow logical injection used by tests. @@ -2300,8 +2306,10 @@ 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. -This remains frontend input configuration only: it needs no profile storage, boot seed, or game-specific -conditional. +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. --- diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index fd31355..096d1a0 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -824,7 +824,7 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** (out) - write 1 iff ADV message-skip run-state bit 0x08000000 is set, otherwise 0. - **grounding:** source=investigation, confidence=high - **depended on by:** 0x20c, 0x20d, 0x21c, 0x223 -- **evidence:** Ghidra handler 0x4272b0 reads ctx+0xa0ce4 bit 0x08000000 and vm_operand_write(1, 1|0). SC0000 label_1235a ORs it with op 0x1cc. +- **evidence:** Ghidra /v2: handler 0x4272b0 reads ctx+0xa0ce4 bit 0x08000000 and vm_operand_write(1, 1|0). adv_interpreter_tick@0x410fb0 first polls the configured logical-action mask; action 6 is bit 0x40 and therefore drives this transient run state from SYSTEM4's C/LeftCtrl bindings or the retained native Backspace default. Persistent op-0x88 state injects the same bit. SC0000 label_1235a ORs the result with op 0x1cc. ### 0x1ca `set-read-message-skip` (u0041B9B0, argc 1) - **summary:** (enabled) - set the engine setting `message:ReadTextSkip`, which skips only previously read text. diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 5576159..e112546 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -2190,3 +2190,19 @@ empty-mask dispatch, simultaneous/bounded actions, and native range failures. **Next:** manually exercise TITLE, ROOM, and ADV with keyboard, left/right mouse, and—if available—a controller. A clean check should confirm native direction order, Z/Enter/Space behavior, right-click action 7, and release re-arming before selecting another reached Phase-B cluster. + +### Slice B1 ADV held fast-forward implemented (2026-07-21) + +Manual validation found the general input bindings sound but LeftCtrl did not accelerate ADV. Native tracing +resolves the missing consumer: `adv_interpreter_tick@0x410fb0` polls the configured logical mask and uses +action 6's bit `0x40` for transient fast-forward. It does not test Ctrl directly, so SYSTEM4's C/LeftCtrl +bindings and the engine's retained Backspace default intentionally share the behavior. + +The VM now forwards held action 6 through an ADV-lifecycle-gated physical skip channel. Godot combines it +with, but stores it separately from, persistent op-`0x88` Skip; release therefore restores normal playback +only when the persistent channel is also off. The combined state reuses text completion, wait advance, +voice deferral/release, and skip pacing. Focused tests cover Backspace/C/Ctrl, activation on ADV resume, and +persistent-toggle survival across a physical press/release. + +**Next:** manually hold LeftCtrl through text reveal and multiple waits in SC0000, then confirm release +returns to normal cadence and that the on-screen Skip toggle remains active if enabled during the test. diff --git a/engine/Age.Engine.Tests/InputBindingTests.cs b/engine/Age.Engine.Tests/InputBindingTests.cs index 75161c0..a618adc 100644 --- a/engine/Age.Engine.Tests/InputBindingTests.cs +++ b/engine/Age.Engine.Tests/InputBindingTests.cs @@ -68,6 +68,59 @@ public class InputBindingTests Assert.Equal(7, bindings.MouseAction(1)); } + [Theory] + [InlineData(0x08)] // retained native Backspace default + [InlineData(0x11)] // SYSTEM4 LeftCtrl binding + [InlineData(0x43)] // SYSTEM4 C binding + public void LogicalActionSixDrivesHeldAdvFastForward(int virtualKey) + { + var script = ScriptAssembler.Assemble(Table, "ADV_FAST_FORWARD", new List<(int, Operand[])> + { + (0x19c, Array.Empty()), + (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(virtualKey, true); + Assert.False(host.PhysicalMessageSkip); // ADV lifecycle is not active yet. + + vm.Run(); + + Assert.True(host.PhysicalMessageSkip); + Assert.True(host.MessageSkip); + + vm.UpdateKeyboardVirtualKeyState(virtualKey, false); + + Assert.False(host.PhysicalMessageSkip); + Assert.False(host.MessageSkip); + } + + [Fact] + public void ReleasingPhysicalFastForwardDoesNotClearPersistentSkip() + { + var script = ScriptAssembler.Assemble(Table, "ADV_PERSISTENT_AND_HELD_SKIP", new List<(int, Operand[])> + { + (0x19c, Array.Empty()), + (0x88, new[] { I(1) }), + (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.Run(); + + vm.UpdateKeyboardVirtualKeyState(0x11, true); + vm.UpdateKeyboardVirtualKeyState(0x11, false); + + Assert.True(vm.MessageSkipEnabled); + Assert.False(host.PhysicalMessageSkip); + Assert.True(host.MessageSkip); + } + [Fact] public void EmptyPollDispatchesTheCallbackAtActionCount() { diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index dbe3d42..3d30cdb 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -13,6 +13,8 @@ internal class RecordingHost : IHost public int TransitionWaits; public int InputCallbackFrames; public bool MessageSkip; + public bool ScriptMessageSkip; + public bool PhysicalMessageSkip; public bool AdvReadSkip; public readonly List<(int Offset, string Text)> Lines = new(); public readonly List<(int Slot, int X, int Y)> TextCursors = new(); @@ -41,6 +43,7 @@ internal class RecordingHost : IHost public readonly List<(int First, int Count)> ReleasedSurfaceRanges = new(); public readonly List<(int Source, int Target, long Interval)> SurfaceCrossfades = new(); public readonly List MessageSkipChanges = new(); + public readonly List PhysicalMessageSkipChanges = new(); public readonly List CursorResources = new(); public readonly List AdvPagePresentationSuspended = new(); public int CursorClearCount; @@ -94,8 +97,15 @@ internal class RecordingHost : IHost public bool IsMessageSkipActive => MessageSkip; public void SetMessageSkipActive(bool active) { - MessageSkip = active; - MessageSkipChanges.Add(active); + ScriptMessageSkip = active; + MessageSkip = ScriptMessageSkip || PhysicalMessageSkip; + MessageSkipChanges.Add(MessageSkip); + } + public void SetPhysicalMessageSkipActive(bool active) + { + PhysicalMessageSkip = active; + MessageSkip = ScriptMessageSkip || PhysicalMessageSkip; + PhysicalMessageSkipChanges.Add(active); } public bool IsAdvReadSkipActive => AdvReadSkip; public void PresentFrame(GfxState gfx) diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index b2e1ec2..f9e7f0f 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -71,6 +71,9 @@ public interface IHost // Native 0x1c7/0x1cc query two distinct ADV skip channels. Headless and non-interactive // hosts default to normal playback; the Godot host supplies the live interactive values. void SetMessageSkipActive(bool active) { } + // Logical action 6 is the native hold-to-fast-forward channel. Keep it separate from the + // persistent op-0x88 channel so releasing the key cannot turn off the user's Skip toggle. + void SetPhysicalMessageSkipActive(bool active) { } bool IsMessageSkipActive => false; bool IsAdvReadSkipActive => false; // Normal playback reaches op 0x21c and parks until a queued 0x223 transition completes. The diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 18b9a2f..69a5bde 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -48,6 +48,7 @@ public sealed class VirtualMachine private bool _initialRootRun = true; private volatile bool _messageSkipEnabled; private volatile bool _messageSkipServiceActive; + private volatile bool _advSkipServiceEnabled; private AdvTextStyle _advTextStyle = AdvTextStyle.Default; private readonly Dictionary _valueSwitchTargets = new(StringComparer.Ordinal); public long CallScriptDispatches { get; private set; } @@ -157,6 +158,7 @@ public sealed class VirtualMachine public int UpdateKeyboardVirtualKeyState(int virtualKey, bool pressed) { InputBindings.UpdateKeyboardVirtualKey(virtualKey, pressed); + RefreshPhysicalMessageSkipState(); _host.WakeInputCallbackService(); return InputBindings.KeyboardAction(virtualKey); } @@ -164,6 +166,7 @@ public sealed class VirtualMachine public int UpdatePhysicalMouseButtonState(int physicalButton, bool pressed) { InputBindings.UpdateMouseButton(physicalButton, pressed); + RefreshPhysicalMessageSkipState(); _host.WakeInputCallbackService(); return InputBindings.MouseAction(physicalButton); } @@ -171,6 +174,7 @@ public sealed class VirtualMachine public int UpdateJoystickButtonState(int physicalButton, bool pressed) { InputBindings.UpdateJoystickButton(physicalButton, pressed); + RefreshPhysicalMessageSkipState(); _host.WakeInputCallbackService(); return InputBindings.JoystickButtonActionMask(physicalButton); } @@ -211,6 +215,16 @@ 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 + /// 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. + private void RefreshPhysicalMessageSkipState() + { + bool active = _advSkipServiceEnabled && (InputBindings.PollActionMask() & 0x40) != 0; + _host.SetPhysicalMessageSkipActive(active); + } + private static long Gi(Dictionary d, int k) => d.TryGetValue(k, out var v) ? v : 0; private long ReadGlobal(int k) => ExternalGlobals.TryGetValue(k, out var v) ? v : Gi(Globals, k); private static string Gs(Dictionary d, int k) => d.TryGetValue(k, out var v) ? v : ""; @@ -465,9 +479,11 @@ public sealed class VirtualMachine _autoVoicePending = false; _messageSkipEnabled = false; _messageSkipServiceActive = false; + _advSkipServiceEnabled = false; _advTextStyle = AdvTextStyle.Default; TextHistory.SetRecordingEnabled(true); _host.SetMessageSkipActive(false); + _host.SetPhysicalMessageSkipActive(false); _host.ResetSceneContext(); } @@ -1132,13 +1148,17 @@ 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 || _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 diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 81604a1..8ee0a2b 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -41,6 +41,9 @@ public sealed class GodotAdvHost : IHost private long _advTextStartedMs; private bool _advTextForceComplete; private readonly Dictionary _waitIndicators = new(); + private readonly object _messageSkipLock = new(); + private bool _scriptMessageSkipActive; + private bool _physicalMessageSkipActive; private volatile bool _messageSkipActive; private int _voiceBgmDuckControl; private (AudioPayload Audio, int PlaybackVariant)? _queuedSkippedVoice; @@ -405,19 +408,42 @@ public sealed class GodotAdvHost : IHost public bool IsMessageSkipActive => _messageSkipActive; public void SetMessageSkipActive(bool active) + => SetMessageSkipChannel(active, physical: false); + + public void SetPhysicalMessageSkipActive(bool active) + => SetMessageSkipChannel(active, physical: true); + + private void SetMessageSkipChannel(bool active, bool physical) { - _messageSkipActive = active; - _timeline?.State("message-skip", new() { ["enabled"] = active }); - if (active) + bool effective; + bool changed; + (AudioPayload Audio, int PlaybackVariant)? queued = null; + lock (_messageSkipLock) + { + if (physical) _physicalMessageSkipActive = active; + else _scriptMessageSkipActive = active; + effective = _scriptMessageSkipActive || _physicalMessageSkipActive; + changed = effective != _messageSkipActive; + _messageSkipActive = effective; + if (changed && !effective) + { + queued = _queuedSkippedVoice; + _queuedSkippedVoice = null; + } + } + if (!changed) return; + _timeline?.State("message-skip", new() + { + ["enabled"] = effective, + ["source"] = physical ? "logical-action-6" : "script", + }); + if (effective) { lock (_textLock) _advTextForceComplete = true; _frameSignal.Set(); _inputCallbackSignal.Set(); return; } - - var queued = _queuedSkippedVoice; - _queuedSkippedVoice = null; if (queued != null) DispatchVoice(queued.Value.Audio, queued.Value.PlaybackVariant); } @@ -628,8 +654,13 @@ public sealed class GodotAdvHost : IHost } while (_gate.Wait(0)) { } _inputCallbackSignal.WaitOne(0); - _messageSkipActive = false; - _queuedSkippedVoice = null; + lock (_messageSkipLock) + { + _scriptMessageSkipActive = false; + _physicalMessageSkipActive = false; + _messageSkipActive = false; + _queuedSkippedVoice = null; + } System.Threading.Volatile.Write(ref _voiceBgmDuckControl, 0); _advPagePresentationSuspended = false; _modalMovieCancelled = false; @@ -952,10 +983,19 @@ public sealed class GodotAdvHost : IHost _timeline?.Event("voice", new() { ["id"] = id, ["file"] = audio?.Name, ["playback_variant"] = playbackVariant }); if (audio == null) return; - if (_messageSkipActive) + bool queuedForSkip; + bool firstQueued = false; + lock (_messageSkipLock) + { + queuedForSkip = _messageSkipActive; + if (queuedForSkip) + { + firstQueued = _queuedSkippedVoice == null; + _queuedSkippedVoice = (audio, playbackVariant); + } + } + if (queuedForSkip) { - bool firstQueued = _queuedSkippedVoice == null; - _queuedSkippedVoice = (audio, playbackVariant); if (firstQueued) _main.CallDeferred("StopVoiceForMessageSkip"); return; } diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 58c9ee2..3519759 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -4135,7 +4135,7 @@ noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra handler 0x4272b0 reads ctx+0xa0ce4 bit 0x08000000 and vm_operand_write(1, 1|0). SC0000 label_1235a ORs it with op 0x1cc." +evidence = "Ghidra /v2: handler 0x4272b0 reads ctx+0xa0ce4 bit 0x08000000 and vm_operand_write(1, 1|0). adv_interpreter_tick@0x410fb0 first polls the configured logical-action mask; action 6 is bit 0x40 and therefore drives this transient run state from SYSTEM4's C/LeftCtrl bindings or the retained native Backspace default. Persistent op-0x88 state injects the same bit. SC0000 label_1235a ORs the result with op 0x1cc." [[opcode.semantics.args]] i = 1