diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index 8dfe49d..17219d1 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -195,6 +195,9 @@ retained history rendering, metadata/voice lookup, and history backlog clearing. `engine/Age.Engine/Vm/VirtualMachine.AdvServices.cs` owns persistent/active message-skip control, read-skip settings and queries, auto-message state/timing, and per-message voice/skip reset opcode dispatch; shared state and refresh helpers remain in the VM coordinator because live text and input paths also consume them. +`engine/Age.Engine/Vm/VirtualMachine.Input.cs` owns blocking ADV waits, hotspot registration/arming, cursor +resources and virtual position, raw mouse/joystick callback registration and dispatch, action polling, and +physical-input mapping; public host-thread input entry points and shared synchronization remain in the coordinator. The disposable `build/page-map-.jsonl` files are produced by editor/development Godot runs and map runtime ADV page ordinals to their authoritative script offsets for `tools/locate_page.py`. Packaged exports diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index 6dd4095..0b985c4 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -681,6 +681,13 @@ do not mix mechanical moves with semantic changes. remain in the VM coordinator because live-text and input paths also consume them. Runtime validation remains green. + The tenth bounded `VirtualMachine.Step` extraction moved blocking ADV waits, hotspot registration/arming, + cursor resources and virtual position, raw mouse/joystick callback registration and dispatch, action polling, + and physical-input mapping into `engine/Age.Engine/Vm/VirtualMachine.Input.cs`. The top-level dispatcher + retains all labels and aliases at their existing positions and routes them through guarded `StepInput`; public + host-thread input entry points, shared synchronization, and callback service helpers remain in the coordinator. + Runtime validation remains green. + **Gate:** no externally visible behavior or command changes; generated artifacts are byte-identical where deterministic, and the corresponding engine, Python, Godot, and corpus validations remain green after each domain move. @@ -1054,7 +1061,8 @@ Continue step 2 of the **codebase consolidation** maintenance slice: behavior-ne by the tracked launcher and layered validation driver. With the planned `Main`, `GodotAdvHost`, and `GfxState` domains isolated and the audio, movie, surface/texture, retained-object, animation, presentation, ADV-text, and text-history `VirtualMachine.Step` families routed through domain handlers, with ADV skip/auto-message services -now isolated as well, extract the interactive input/hotspot/cursor callback opcode family next without replacing -the proven dispatcher or changing public types, commands, and generated output. +and interactive input/hotspot/cursor callbacks now isolated as well, extract the sleep/timed-callback sequencing +opcode family next without replacing the proven dispatcher or changing public types, commands, and generated +output. Concrete playthrough blockers may still preempt this bounded maintenance work; the consolidation effort does not replace Phase B gameplay validation or the open cross-platform gates. diff --git a/engine/Age.Engine/Vm/VirtualMachine.Input.cs b/engine/Age.Engine/Vm/VirtualMachine.Input.cs new file mode 100644 index 0000000..83b64e8 --- /dev/null +++ b/engine/Age.Engine/Vm/VirtualMachine.Input.cs @@ -0,0 +1,184 @@ +using Age.Engine.Hosting; +using Age.Engine.Model; + +namespace Age.Engine.Vm; + +public sealed partial class VirtualMachine +{ + private int StepInput(string label, IReadOnlyList a, int pc) + { + switch (label) + { + case "wait-for-input": + RefreshAdvReadSkipState(); + // Faithful headless: no player => halt here rather than plow past every prompt (see VmOptions). + if (_o.HaltAtWaitForInput) { HaltReason ??= "wait-for-input"; return HALT; } + // The native ADV chrome is a coroutine: after an earlier 0x93 cancellation its shared + // registration pass runs again before a stable message wait. Our blocking host models that + // scheduler boundary by re-arming the frame's retained definitions here. + bool wakeAtWait = false; + lock (_interactiveLock) + { + if (_cur.Hotspots.HasDefinitions && !_cur.Hotspots.Armed) + { + _interactiveFrame = _cur; + wakeAtWait = _cur.Hotspots.Arm(_pointerX, _pointerY); + } + } + if (wakeAtWait) _host.WakeInputCallbackService(); + _host.WaitForInput((int)Read(a[0]), ServiceHotspotCallback, + () => new AdvAutoWaitState(_autoMessageEnabled, _autoVoicePending, + _autoMessageTime0Ms, _autoMessageTime1Ms)); + _sharedProfile.ReadText.QueueMessage( + _cur.Script.PackedId, CurrentReadMessageIndex(), + _cur.Script.ReadMessageOffsets.Count); + return pc + 1; + case "u0041BEB0": + case "register-hotspot-callbacks": // 0x90: inclusive rect + enter/leave/activate local callbacks + lock (_interactiveLock) + _cur.Hotspots.Register((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]), + (int)Read(a[4]), (int)Read(a[5]), (int)Read(a[6])); + return pc + 1; + case "u00415040": + case "cancel-hotspot-wait": // 0x93 + lock (_interactiveLock) + { + _cur.Hotspots.Reset(); + if (ReferenceEquals(_interactiveFrame, _cur)) _interactiveFrame = null; + } + return pc + 1; + case "u00415090": + case "arm-hotspot-wait": // 0x94 + { + bool wake; + lock (_interactiveLock) + { + _interactiveFrame = _cur; + wake = _cur.Hotspots.Arm(_pointerX, _pointerY); + } + if (wake) _host.WakeInputCallbackService(); + return pc + 1; + } + case "u0041C150": + case "bind-hotspot-key": // 0x97: bind a configured logical action to this record + lock (_interactiveLock) + _cur.Hotspots.BindKey((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), + (int)Read(a[3]), (int)Read(a[4])); + return pc + 1; + case "u0041B210": + case "set-cursor-resource": // 0x86: raw indexed .CUR resource + _host.SetCursorResource(Read(a[0])); return pc + 1; + case "u00414D10": + case "clear-cursor-resource": // 0x87 + _host.ClearCursorResource(); return pc + 1; + case "mouse_callback": + case "register-mouse-callback": // 0xcc (poll interval ms, local target dword offset) + _cur.MouseCallbackIntervalMs = System.Math.Max(0, Read(a[0])); + _cur.MouseCallbackTarget = (int)Read(a[1]); + _cur.MouseCallbackNextAtMs = _host.InputClockMilliseconds + _cur.MouseCallbackIntervalMs; + lock (_interactiveLock) _rawInputFrame = _cur; + return pc + 1; + case "get-input-type": + case "dispatch-mouse-callback": // 0xcd + { + long now = _host.InputClockMilliseconds; + if (_cur.MouseCallbackTarget < 0 || now < _cur.MouseCallbackNextAtMs) return pc + 1; + _cur.MouseCallbackNextAtMs = now + _cur.MouseCallbackIntervalMs; + if (!_cur.Script.IndexByOffset.TryGetValue(_cur.MouseCallbackTarget, out int target)) + return pc + 1; + _cur.CallStack.Add(pc + 1); + return target; + } + case "joy_callback": + case "register-joy-callback": // 0xfb (input index, local target dword offset) + { + int index = (int)Read(a[0]); + if ((uint)index < 32) _cur.InputCallbackTargets[index] = (int)Read(a[1]); + return pc + 1; + } + case "u0041E360": + case "set-input-action-count": // 0xfe: actions [0,count), no-input callback at count + { + int count = unchecked((int)Read(a[0])); + if (!InputBindings.SetActionCount(count)) + { + HaltReason ??= $"input-action-count-out-of-range:{count}"; + return HALT; + } + return pc + 1; + } + case "u00415A10": + case "poll-joy-callback-input": // 0xff + _cur.PendingInputCallbackMask = InputBindings.PollActionMask() + | Volatile.Read(ref _heldInputCallbackMask) + | Interlocked.Exchange(ref _queuedInputCallbackMask, 0); + _cur.InputCallbackScanIndex = 0; + return pc + 1; + case "u00415A60": + case "dispatch-joy-callbacks": // 0x100 + { + int actionCount = InputBindings.ActionCount; + if (_cur.PendingInputCallbackMask == 0) + { + int idleTarget = _cur.InputCallbackTargets[actionCount]; + if (idleTarget < 0 || !_cur.Script.IndexByOffset.TryGetValue(idleTarget, out int target)) + return pc + 1; + _cur.CallStack.Add(pc + 1); + return target; + } + while (_cur.InputCallbackScanIndex < actionCount) + { + int index = _cur.InputCallbackScanIndex++; + if ((_cur.PendingInputCallbackMask & (1 << index)) == 0) continue; + int targetOffset = _cur.InputCallbackTargets[index]; + if (targetOffset < 0 || !_cur.Script.IndexByOffset.TryGetValue(targetOffset, out int target)) + continue; + // Resume on op 0x100 so another simultaneously active input can dispatch. + _cur.CallStack.Add(pc); + return target; + } + return pc + 1; + } + case "u0041E500": + case "map-joystick-button": // 0x107: button slot N emits action N+4 + InputBindings.MapJoystickButton(unchecked((int)Read(a[0])), unchecked((int)Read(a[1]))); + return pc + 1; + case "u00415E70": + case "get-mouse-button-state": // 0x108 + Write(a[0], Volatile.Read(ref _mouseButtonState)); return pc + 1; + case "u00415F10": + case "consume-mouse-wheel-delta": // 0x10d + Write(a[0], Interlocked.Exchange(ref _mouseWheelDelta, 0)); return pc + 1; + case "u00415EC0": + case "get-cursor-virtual": // 0x109 + { + int x, y; + lock (_interactiveLock) { x = _pointerX; y = _pointerY; } + Write(a[0], x == int.MinValue ? 0 : x); + Write(a[1], y == int.MinValue ? 0 : y); + return pc + 1; + } + case "u0041E540": + case "set-cursor-virtual": // 0x10a; retain the virtual position even without OS warping + UpdatePointer((int)Read(a[0]), (int)Read(a[1])); return pc + 1; + case "u0041E5A0": + case "map-mouse-button": // 0x10b: physical button -> slot, polled action is slot+4 + InputBindings.MapMouseButton(unchecked((int)Read(a[0])), unchecked((int)Read(a[1]))); + return pc + 1; + case "u0041E5E0": + case "map-keyboard-scancode": // 0x10c: logical action <- DIK translated through native VK table + { + int action = unchecked((int)Read(a[0])); + int dik = unchecked((int)Read(a[1])); + if (!InputBindings.MapKeyboardScanCode(action, dik)) + { + HaltReason ??= $"keyboard-action-out-of-range:{action}"; + return HALT; + } + return pc + 1; + } + default: + throw new InvalidOperationException($"Non-input opcode routed to input handler: {label}"); + } + } +} diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 4f05c21..dca387b 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -1978,173 +1978,47 @@ public sealed partial class VirtualMachine case "draw-formatted-integer": // 0x205 (surface slot, x, y, value, field width, flags) return StepAdvText(label, ins, pc); case "wait-for-input": - RefreshAdvReadSkipState(); - // Faithful headless: no player => halt here rather than plow past every prompt (see VmOptions). - if (_o.HaltAtWaitForInput) { HaltReason ??= "wait-for-input"; return HALT; } - // The native ADV chrome is a coroutine: after an earlier 0x93 cancellation its shared - // registration pass runs again before a stable message wait. Our blocking host models that - // scheduler boundary by re-arming the frame's retained definitions here. - bool wakeAtWait = false; - lock (_interactiveLock) - { - if (_cur.Hotspots.HasDefinitions && !_cur.Hotspots.Armed) - { - _interactiveFrame = _cur; - wakeAtWait = _cur.Hotspots.Arm(_pointerX, _pointerY); - } - } - if (wakeAtWait) _host.WakeInputCallbackService(); - _host.WaitForInput((int)Read(a[0]), ServiceHotspotCallback, - () => new AdvAutoWaitState(_autoMessageEnabled, _autoVoicePending, - _autoMessageTime0Ms, _autoMessageTime1Ms)); - _sharedProfile.ReadText.QueueMessage( - _cur.Script.PackedId, CurrentReadMessageIndex(), - _cur.Script.ReadMessageOffsets.Count); - return pc + 1; case "u0041BEB0": case "register-hotspot-callbacks": // 0x90: inclusive rect + enter/leave/activate local callbacks - lock (_interactiveLock) - _cur.Hotspots.Register((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]), - (int)Read(a[4]), (int)Read(a[5]), (int)Read(a[6])); - return pc + 1; case "u00415040": case "cancel-hotspot-wait": // 0x93 - lock (_interactiveLock) - { - _cur.Hotspots.Reset(); - if (ReferenceEquals(_interactiveFrame, _cur)) _interactiveFrame = null; - } - return pc + 1; case "u00415090": case "arm-hotspot-wait": // 0x94 - { - bool wake; - lock (_interactiveLock) - { - _interactiveFrame = _cur; - wake = _cur.Hotspots.Arm(_pointerX, _pointerY); - } - if (wake) _host.WakeInputCallbackService(); - return pc + 1; - } case "u0041C150": case "bind-hotspot-key": // 0x97: bind a configured logical action to this record - lock (_interactiveLock) - _cur.Hotspots.BindKey((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), - (int)Read(a[3]), (int)Read(a[4])); - return pc + 1; case "u0041B210": case "set-cursor-resource": // 0x86: raw indexed .CUR resource - _host.SetCursorResource(Read(a[0])); return pc + 1; case "u00414D10": case "clear-cursor-resource": // 0x87 - _host.ClearCursorResource(); return pc + 1; + return StepInput(label, a, pc); case "mouse_callback": case "register-mouse-callback": // 0xcc (poll interval ms, local target dword offset) - _cur.MouseCallbackIntervalMs = System.Math.Max(0, Read(a[0])); - _cur.MouseCallbackTarget = (int)Read(a[1]); - _cur.MouseCallbackNextAtMs = _host.InputClockMilliseconds + _cur.MouseCallbackIntervalMs; - lock (_interactiveLock) _rawInputFrame = _cur; - return pc + 1; case "get-input-type": case "dispatch-mouse-callback": // 0xcd - { - long now = _host.InputClockMilliseconds; - if (_cur.MouseCallbackTarget < 0 || now < _cur.MouseCallbackNextAtMs) return pc + 1; - _cur.MouseCallbackNextAtMs = now + _cur.MouseCallbackIntervalMs; - if (!_cur.Script.IndexByOffset.TryGetValue(_cur.MouseCallbackTarget, out int target)) - return pc + 1; - _cur.CallStack.Add(pc + 1); - return target; - } case "joy_callback": case "register-joy-callback": // 0xfb (input index, local target dword offset) - { - int index = (int)Read(a[0]); - if ((uint)index < 32) _cur.InputCallbackTargets[index] = (int)Read(a[1]); - return pc + 1; - } case "u0041E360": case "set-input-action-count": // 0xfe: actions [0,count), no-input callback at count - { - int count = unchecked((int)Read(a[0])); - if (!InputBindings.SetActionCount(count)) - { - HaltReason ??= $"input-action-count-out-of-range:{count}"; - return HALT; - } - return pc + 1; - } case "u00415A10": case "poll-joy-callback-input": // 0xff - _cur.PendingInputCallbackMask = InputBindings.PollActionMask() - | Volatile.Read(ref _heldInputCallbackMask) - | Interlocked.Exchange(ref _queuedInputCallbackMask, 0); - _cur.InputCallbackScanIndex = 0; - return pc + 1; case "u00415A60": case "dispatch-joy-callbacks": // 0x100 - { - int actionCount = InputBindings.ActionCount; - if (_cur.PendingInputCallbackMask == 0) - { - int idleTarget = _cur.InputCallbackTargets[actionCount]; - if (idleTarget < 0 || !_cur.Script.IndexByOffset.TryGetValue(idleTarget, out int target)) - return pc + 1; - _cur.CallStack.Add(pc + 1); - return target; - } - while (_cur.InputCallbackScanIndex < actionCount) - { - int index = _cur.InputCallbackScanIndex++; - if ((_cur.PendingInputCallbackMask & (1 << index)) == 0) continue; - int targetOffset = _cur.InputCallbackTargets[index]; - if (targetOffset < 0 || !_cur.Script.IndexByOffset.TryGetValue(targetOffset, out int target)) - continue; - // Resume on op 0x100 so another simultaneously active input can dispatch. - _cur.CallStack.Add(pc); - return target; - } - return pc + 1; - } + return StepInput(label, a, pc); case "u0041E500": case "map-joystick-button": // 0x107: button slot N emits action N+4 - InputBindings.MapJoystickButton(unchecked((int)Read(a[0])), unchecked((int)Read(a[1]))); - return pc + 1; case "u00415E70": case "get-mouse-button-state": // 0x108 - Write(a[0], Volatile.Read(ref _mouseButtonState)); return pc + 1; case "u00415F10": case "consume-mouse-wheel-delta": // 0x10d - Write(a[0], Interlocked.Exchange(ref _mouseWheelDelta, 0)); return pc + 1; case "u00415EC0": case "get-cursor-virtual": // 0x109 - { - int x, y; - lock (_interactiveLock) { x = _pointerX; y = _pointerY; } - Write(a[0], x == int.MinValue ? 0 : x); - Write(a[1], y == int.MinValue ? 0 : y); - return pc + 1; - } case "u0041E540": case "set-cursor-virtual": // 0x10a; retain the virtual position even without OS warping - UpdatePointer((int)Read(a[0]), (int)Read(a[1])); return pc + 1; case "u0041E5A0": case "map-mouse-button": // 0x10b: physical button -> slot, polled action is slot+4 - InputBindings.MapMouseButton(unchecked((int)Read(a[0])), unchecked((int)Read(a[1]))); - return pc + 1; case "u0041E5E0": case "map-keyboard-scancode": // 0x10c: logical action <- DIK translated through native VK table - { - int action = unchecked((int)Read(a[0])); - int dik = unchecked((int)Read(a[1])); - if (!InputBindings.MapKeyboardScanCode(action, dik)) - { - HaltReason ??= $"keyboard-action-out-of-range:{action}"; - return HALT; - } - return pc + 1; - } + return StepInput(label, a, pc); case "sleep": // 0xc8 (duration) — pause the host duration ms; headless hosts no-op (parity). Frame pacing. _host.Sleep(Read(a[0])); return pc + 1; case "u00425960":