Implement held ADV fast-forward input

This commit is contained in:
gamer147
2026-07-21 10:45:46 -04:00
parent f840e356cf
commit c6b4985984
9 changed files with 167 additions and 17 deletions

View File

@@ -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 `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. 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 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, 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. 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 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 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. 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 The live action-6 state now also feeds the ADV fast-forward host channel while the ADV lifecycle service is
conditional. 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.
--- ---

View File

@@ -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. - **summary:** (out) - write 1 iff ADV message-skip run-state bit 0x08000000 is set, otherwise 0.
- **grounding:** source=investigation, confidence=high - **grounding:** source=investigation, confidence=high
- **depended on by:** 0x20c, 0x20d, 0x21c, 0x223 - **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) ### 0x1ca `set-read-message-skip` (u0041B9B0, argc 1)
- **summary:** (enabled) - set the engine setting `message:ReadTextSkip`, which skips only previously read text. - **summary:** (enabled) - set the engine setting `message:ReadTextSkip`, which skips only previously read text.

View File

@@ -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 **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 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. 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.

View File

@@ -68,6 +68,59 @@ public class InputBindingTests
Assert.Equal(7, bindings.MouseAction(1)); 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<Operand>()),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
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<Operand>()),
(0x88, new[] { I(1) }),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
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] [Fact]
public void EmptyPollDispatchesTheCallbackAtActionCount() public void EmptyPollDispatchesTheCallbackAtActionCount()
{ {

View File

@@ -13,6 +13,8 @@ internal class RecordingHost : IHost
public int TransitionWaits; public int TransitionWaits;
public int InputCallbackFrames; public int InputCallbackFrames;
public bool MessageSkip; public bool MessageSkip;
public bool ScriptMessageSkip;
public bool PhysicalMessageSkip;
public bool AdvReadSkip; public bool AdvReadSkip;
public readonly List<(int Offset, string Text)> Lines = new(); public readonly List<(int Offset, string Text)> Lines = new();
public readonly List<(int Slot, int X, int Y)> TextCursors = 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 First, int Count)> ReleasedSurfaceRanges = new();
public readonly List<(int Source, int Target, long Interval)> SurfaceCrossfades = new(); public readonly List<(int Source, int Target, long Interval)> SurfaceCrossfades = new();
public readonly List<bool> MessageSkipChanges = new(); public readonly List<bool> MessageSkipChanges = new();
public readonly List<bool> PhysicalMessageSkipChanges = new();
public readonly List<long> CursorResources = new(); public readonly List<long> CursorResources = new();
public readonly List<bool> AdvPagePresentationSuspended = new(); public readonly List<bool> AdvPagePresentationSuspended = new();
public int CursorClearCount; public int CursorClearCount;
@@ -94,8 +97,15 @@ internal class RecordingHost : IHost
public bool IsMessageSkipActive => MessageSkip; public bool IsMessageSkipActive => MessageSkip;
public void SetMessageSkipActive(bool active) public void SetMessageSkipActive(bool active)
{ {
MessageSkip = active; ScriptMessageSkip = active;
MessageSkipChanges.Add(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 bool IsAdvReadSkipActive => AdvReadSkip;
public void PresentFrame(GfxState gfx) public void PresentFrame(GfxState gfx)

View File

@@ -71,6 +71,9 @@ public interface IHost
// Native 0x1c7/0x1cc query two distinct ADV skip channels. Headless and non-interactive // 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. // hosts default to normal playback; the Godot host supplies the live interactive values.
void SetMessageSkipActive(bool active) { } 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 IsMessageSkipActive => false;
bool IsAdvReadSkipActive => false; bool IsAdvReadSkipActive => false;
// Normal playback reaches op 0x21c and parks until a queued 0x223 transition completes. The // Normal playback reaches op 0x21c and parks until a queued 0x223 transition completes. The

View File

@@ -48,6 +48,7 @@ public sealed class VirtualMachine
private bool _initialRootRun = true; private bool _initialRootRun = true;
private volatile bool _messageSkipEnabled; private volatile bool _messageSkipEnabled;
private volatile bool _messageSkipServiceActive; private volatile bool _messageSkipServiceActive;
private volatile bool _advSkipServiceEnabled;
private AdvTextStyle _advTextStyle = AdvTextStyle.Default; private AdvTextStyle _advTextStyle = AdvTextStyle.Default;
private readonly Dictionary<string, int> _valueSwitchTargets = new(StringComparer.Ordinal); private readonly Dictionary<string, int> _valueSwitchTargets = new(StringComparer.Ordinal);
public long CallScriptDispatches { get; private set; } public long CallScriptDispatches { get; private set; }
@@ -157,6 +158,7 @@ public sealed class VirtualMachine
public int UpdateKeyboardVirtualKeyState(int virtualKey, bool pressed) public int UpdateKeyboardVirtualKeyState(int virtualKey, bool pressed)
{ {
InputBindings.UpdateKeyboardVirtualKey(virtualKey, pressed); InputBindings.UpdateKeyboardVirtualKey(virtualKey, pressed);
RefreshPhysicalMessageSkipState();
_host.WakeInputCallbackService(); _host.WakeInputCallbackService();
return InputBindings.KeyboardAction(virtualKey); return InputBindings.KeyboardAction(virtualKey);
} }
@@ -164,6 +166,7 @@ public sealed class VirtualMachine
public int UpdatePhysicalMouseButtonState(int physicalButton, bool pressed) public int UpdatePhysicalMouseButtonState(int physicalButton, bool pressed)
{ {
InputBindings.UpdateMouseButton(physicalButton, pressed); InputBindings.UpdateMouseButton(physicalButton, pressed);
RefreshPhysicalMessageSkipState();
_host.WakeInputCallbackService(); _host.WakeInputCallbackService();
return InputBindings.MouseAction(physicalButton); return InputBindings.MouseAction(physicalButton);
} }
@@ -171,6 +174,7 @@ public sealed class VirtualMachine
public int UpdateJoystickButtonState(int physicalButton, bool pressed) public int UpdateJoystickButtonState(int physicalButton, bool pressed)
{ {
InputBindings.UpdateJoystickButton(physicalButton, pressed); InputBindings.UpdateJoystickButton(physicalButton, pressed);
RefreshPhysicalMessageSkipState();
_host.WakeInputCallbackService(); _host.WakeInputCallbackService();
return InputBindings.JoystickButtonActionMask(physicalButton); return InputBindings.JoystickButtonActionMask(physicalButton);
} }
@@ -211,6 +215,16 @@ public sealed class VirtualMachine
} while (Interlocked.CompareExchange(ref field, after, before) != before); } while (Interlocked.CompareExchange(ref field, after, before) != before);
} }
/// <summary>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.</summary>
private void RefreshPhysicalMessageSkipState()
{
bool active = _advSkipServiceEnabled && (InputBindings.PollActionMask() & 0x40) != 0;
_host.SetPhysicalMessageSkipActive(active);
}
private static long Gi(Dictionary<int, long> d, int k) => d.TryGetValue(k, out var v) ? v : 0; private static long Gi(Dictionary<int, long> 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 long ReadGlobal(int k) => ExternalGlobals.TryGetValue(k, out var v) ? v : Gi(Globals, k);
private static string Gs(Dictionary<int, string> d, int k) => d.TryGetValue(k, out var v) ? v : ""; private static string Gs(Dictionary<int, string> d, int k) => d.TryGetValue(k, out var v) ? v : "";
@@ -465,9 +479,11 @@ public sealed class VirtualMachine
_autoVoicePending = false; _autoVoicePending = false;
_messageSkipEnabled = false; _messageSkipEnabled = false;
_messageSkipServiceActive = false; _messageSkipServiceActive = false;
_advSkipServiceEnabled = false;
_advTextStyle = AdvTextStyle.Default; _advTextStyle = AdvTextStyle.Default;
TextHistory.SetRecordingEnabled(true); TextHistory.SetRecordingEnabled(true);
_host.SetMessageSkipActive(false); _host.SetMessageSkipActive(false);
_host.SetPhysicalMessageSkipActive(false);
_host.ResetSceneContext(); _host.ResetSceneContext();
} }
@@ -1132,13 +1148,17 @@ public sealed class VirtualMachine
Write(a[0], _messageSkipEnabled ? 1 : 0); return pc + 1; Write(a[0], _messageSkipEnabled ? 1 : 0); return pc + 1;
case "u00414E80": case "u00414E80":
case "suspend-adv-skip-service": // 0x19b: preserve the toggle while leaving ADV presentation case "suspend-adv-skip-service": // 0x19b: preserve the toggle while leaving ADV presentation
_advSkipServiceEnabled = false;
_messageSkipServiceActive = false; _messageSkipServiceActive = false;
_host.SetMessageSkipActive(false); _host.SetMessageSkipActive(false);
_host.SetPhysicalMessageSkipActive(false);
return pc + 1; return pc + 1;
case "u00414EC0": case "u00414EC0":
case "resume-adv-skip-service": // 0x19c: recompute active fast-forward on ADV entry case "resume-adv-skip-service": // 0x19c: recompute active fast-forward on ADV entry
_advSkipServiceEnabled = true;
_messageSkipServiceActive = _messageSkipEnabled || _host.IsAdvReadSkipActive; _messageSkipServiceActive = _messageSkipEnabled || _host.IsAdvReadSkipActive;
_host.SetMessageSkipActive(_messageSkipServiceActive); _host.SetMessageSkipActive(_messageSkipServiceActive);
RefreshPhysicalMessageSkipState();
return pc + 1; return pc + 1;
case "get-message-skip": // 0x1c7: persistent Skip or host-supplied Ctrl fast-forward 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 // The native per-op tick continually re-arms the transient run-state bit while the

View File

@@ -41,6 +41,9 @@ public sealed class GodotAdvHost : IHost
private long _advTextStartedMs; private long _advTextStartedMs;
private bool _advTextForceComplete; private bool _advTextForceComplete;
private readonly Dictionary<int, AdvWaitIndicatorConfig> _waitIndicators = new(); private readonly Dictionary<int, AdvWaitIndicatorConfig> _waitIndicators = new();
private readonly object _messageSkipLock = new();
private bool _scriptMessageSkipActive;
private bool _physicalMessageSkipActive;
private volatile bool _messageSkipActive; private volatile bool _messageSkipActive;
private int _voiceBgmDuckControl; private int _voiceBgmDuckControl;
private (AudioPayload Audio, int PlaybackVariant)? _queuedSkippedVoice; private (AudioPayload Audio, int PlaybackVariant)? _queuedSkippedVoice;
@@ -405,19 +408,42 @@ public sealed class GodotAdvHost : IHost
public bool IsMessageSkipActive => _messageSkipActive; public bool IsMessageSkipActive => _messageSkipActive;
public void SetMessageSkipActive(bool active) 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; bool effective;
_timeline?.State("message-skip", new() { ["enabled"] = active }); bool changed;
if (active) (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; lock (_textLock) _advTextForceComplete = true;
_frameSignal.Set(); _frameSignal.Set();
_inputCallbackSignal.Set(); _inputCallbackSignal.Set();
return; return;
} }
var queued = _queuedSkippedVoice;
_queuedSkippedVoice = null;
if (queued != null) DispatchVoice(queued.Value.Audio, queued.Value.PlaybackVariant); if (queued != null) DispatchVoice(queued.Value.Audio, queued.Value.PlaybackVariant);
} }
@@ -628,8 +654,13 @@ public sealed class GodotAdvHost : IHost
} }
while (_gate.Wait(0)) { } while (_gate.Wait(0)) { }
_inputCallbackSignal.WaitOne(0); _inputCallbackSignal.WaitOne(0);
_messageSkipActive = false; lock (_messageSkipLock)
_queuedSkippedVoice = null; {
_scriptMessageSkipActive = false;
_physicalMessageSkipActive = false;
_messageSkipActive = false;
_queuedSkippedVoice = null;
}
System.Threading.Volatile.Write(ref _voiceBgmDuckControl, 0); System.Threading.Volatile.Write(ref _voiceBgmDuckControl, 0);
_advPagePresentationSuspended = false; _advPagePresentationSuspended = false;
_modalMovieCancelled = false; _modalMovieCancelled = false;
@@ -952,10 +983,19 @@ public sealed class GodotAdvHost : IHost
_timeline?.Event("voice", new() { ["id"] = id, ["file"] = audio?.Name, _timeline?.Event("voice", new() { ["id"] = id, ["file"] = audio?.Name,
["playback_variant"] = playbackVariant }); ["playback_variant"] = playbackVariant });
if (audio == null) return; 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"); if (firstQueued) _main.CallDeferred("StopVoiceForMessageSkip");
return; return;
} }

View File

@@ -4135,7 +4135,7 @@ noop_headless = false
source = "investigation" source = "investigation"
confidence = "high" confidence = "high"
depends_on = [] 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]] [[opcode.semantics.args]]
i = 1 i = 1