From 12e5073918a5f596473b38bdcefbc0be3208dedf Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 21 Jul 2026 11:16:50 -0400 Subject: [PATCH] Implement ADV system menu input routing --- docs/engine-re.md | 22 ++-- docs/phase-a-slice-plan.md | 14 +++ .../Age.Engine.Tests/GfxLifecycleOpsTests.cs | 18 +++ engine/Age.Engine.Tests/HotspotInputTests.cs | 109 ++++++++++++++++++ engine/Age.Engine/Model/GfxState.cs | 7 ++ engine/Age.Engine/Vm/HotspotRegistry.cs | 26 ++++- engine/Age.Engine/Vm/VirtualMachine.cs | 16 ++- godot/Main.cs | 19 ++- 8 files changed, 213 insertions(+), 18 deletions(-) diff --git a/docs/engine-re.md b/docs/engine-re.md index a96c491..5bbffbd 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -2322,15 +2322,16 @@ the parent ADV controls and redraws the retained page. Opcode `0x97` binds actio Native `adv_input_service_poll@0x411230` polls the configured logical-action mask first, then calls `input_hotspot_poll_bound_action_callback@0x403fb0`. The helper scans armed records in registration order; for each nonnegative op-`0x97` action index it tests `mask & (1 << action)` and returns the record's ordinary -activation callback PC. This is the missing port seam. `HotspotRegistry.BindKey` already retains the action -on the matching record, but no code consumes `Entry.InputBit`; Godot currently routes only pointer-left -activation and action-4/5 page advance. Right mouse therefore reaches `InputBindings` as action 7 but never -queues the MENU callback. +activation callback PC. This identified the former port seam: `HotspotRegistry.BindKey` retained the action +on the matching record, but no code consumed `Entry.InputBit`; Godot routed only pointer-left activation and +action-4/5 page advance. Right mouse therefore reached `InputBindings` as action 7 without queuing MENU. -The first implementation slice is narrow and engine-generic: expose bound-action activation on the armed -hotspot registry, route pressed keyboard/mouse/joystick logical-action masks through it before ordinary page -advance, wake the existing callback service, and reuse the same consume/rearm behavior as pointer activation. -No MENU-specific branch belongs in Godot or the VM. +The implemented bridge is engine-generic. `HotspotRegistry.ActivateBoundActions` scans armed records in +registration order, consumes the first matching logical-action binding through the ordinary activation +target, and shares pointer activation's disarm/rearm lifecycle. Godot routes pressed keyboard, mouse, and +joystick logical-action masks through the VM before ordinary page advance and wakes the existing callback +service. There is no MENU-specific branch in Godot or the VM. A real SC0000 regression proves action 7 +enters the release `MENU.BIN` and restores the parent ADV hotspot registry after controlled return. The reached script path is promising but should be validated incrementally. `MENU.BIN` is 48/49 opcodes handled and its only static gap is op `0x80`; `INFO.BIN`, which selects character/enemy/voice/affinity/item @@ -2342,8 +2343,9 @@ the system menu. The sole `MENU.BIN` gap is now decoded: `op_0x80_set_default_gfx_object_slot@0x41ed40` stores operand 1 at EngineCtx `+0x14e08`; `op_0x1d9_handler@0x420a30` substitutes that selected slot only when its explicit object-slot operand is zero. MENU-family scripts select slots 7/8/9 on entry and restore slot 1 during -teardown. This selector should join the input bridge implementation for state correctness, though the -surveyed MENU/INFO scripts do not themselves call op `0x1d9`, so it is not the cause of the missing launch. +teardown. The port now retains this engine-owned selector in `GfxState.DefaultObjectSlot`; MENU's entry path +selects slot 8 in the regression. The surveyed MENU/INFO scripts do not themselves call op `0x1d9`, so this +state was not the cause of the former missing launch. --- diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 3c38ae7..983d96c 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -2224,3 +2224,17 @@ follow-up discrepancies reached through manual use. SAVE/CONFIG remain separate, action masks through it before page advance, and implement op `0x80`'s engine-owned selector. Add a focused synthetic bound-action test plus an SC0000 action-7 regression that proves the real `MENU.BIN` frame is entered and the parent ADV wait is restored after return. + +**Implemented:** the armed hotspot registry now scans op-`0x97` bindings in native registration order and +feeds matches through the existing activation callback lifecycle. Godot sends pressed keyboard, mouse, and +joystick action masks through that bridge before ADV page advance, so right mouse and X both reach the +script-owned MENU callback without a game-specific shortcut. Op `0x80` retains the selected default graphics +object slot. Focused coverage proves single-consumption/rearm behavior, the selector contract, and actual +SC0000 action-7 entry into release `MENU.BIN` followed by restoration of the parent ADV hotspots. Validation: +255 engine tests, zero-warning Godot build, and threaded selftest. + +**Next:** manually exercise right-click and X from an SC0000 ADV wait, verify the MENU shell can be navigated +and closed without losing the underlying page, then classify only the secondary submenu gaps actually reached. + +**Manual validation:** passed on 2026-07-21. The system menu opens from ADV and returns successfully through +the implemented script-owned action path; no underlying-page restoration discrepancy was reported. diff --git a/engine/Age.Engine.Tests/GfxLifecycleOpsTests.cs b/engine/Age.Engine.Tests/GfxLifecycleOpsTests.cs index 0002165..bb3a08d 100644 --- a/engine/Age.Engine.Tests/GfxLifecycleOpsTests.cs +++ b/engine/Age.Engine.Tests/GfxLifecycleOpsTests.cs @@ -55,6 +55,24 @@ public class GfxLifecycleOpsTests Assert.Equal(-1, vm.Gfx.CurrentRenderTargetSlot); } + [Fact] + public void DefaultGraphicsObjectSlotOpcodeRetainsLatestSelection() + { + var table = T(); + var scene = ScriptAssembler.Assemble(table, "GFX-DEFAULT-SLOT", new List<(int, Operand[])> + { + (0x80, new[] { I(7) }), + (0x80, new[] { I(1) }), + Exit(), + }, System.Array.Empty()); + var vm = new VirtualMachine(scene, table, new RecordingHost()); + + vm.Run(); + + Assert.Equal(1, vm.Gfx.DefaultObjectSlot); + Assert.Equal("exit", vm.HaltReason); + } + [Fact] public void BulkReleaseDropsOnlyTransientSurfaceRange() { diff --git a/engine/Age.Engine.Tests/HotspotInputTests.cs b/engine/Age.Engine.Tests/HotspotInputTests.cs index f77ecf3..fb84734 100644 --- a/engine/Age.Engine.Tests/HotspotInputTests.cs +++ b/engine/Age.Engine.Tests/HotspotInputTests.cs @@ -39,6 +39,64 @@ public class HotspotInputTests } } + private sealed class BoundActionHost : RecordingHost + { + public VirtualMachine Vm = null!; + public bool Consumed; + public bool DuplicateConsumed; + + public override void WaitForInput(int layoutSlot, Func serviceInputCallback) + { + Waits++; + Assert.False(Vm.TryActivateInputActions(1 << 6)); + Consumed = Vm.TryActivateInputActions(1 << 7); + DuplicateConsumed = Vm.TryActivateInputActions(1 << 7); + while (serviceInputCallback()) { } + } + } + + private sealed class Sc0000MenuHost : RecordingHost + { + public VirtualMachine Vm = null!; + public bool MenuActionConsumed; + public bool ParentHotspotsRearmed; + + public override void WaitForInput(int layoutSlot, Func serviceInputCallback) + { + MenuActionConsumed = Vm.TryActivateInputActions(1 << 7); + while (serviceInputCallback()) { } + // Consuming the same binding again without advancing the page proves the parent registry + // was restored after MENU returned. Do not service it: this test ends at that boundary. + ParentHotspotsRearmed = Vm.TryActivateInputActions(1 << 7); + throw new StopAtFirstWaitException(); + } + } + + private sealed class ReturnMenuOnEntrySink : Age.Engine.Diagnostics.ITraceSink + { + public VirtualMachine Vm = null!; + public bool MenuEntered; + private bool _returnRequested; + public bool TracingSteps => true; + + public void Emit(in Age.Engine.Diagnostics.TraceEvent e) + { + if (e.Kind == Age.Engine.Diagnostics.TraceEventKind.FrameEnter + && string.Equals(e.Name, "MENU.BIN", StringComparison.OrdinalIgnoreCase)) + { + MenuEntered = true; + return; + } + // Request the controlled return when MENU reaches its slot selector. Step events are emitted + // before execution, so op 0x80 still runs and the request is consumed at its next boundary. + if (!MenuEntered || _returnRequested + || e.Kind != Age.Engine.Diagnostics.TraceEventKind.Step || e.Opcode != 0x80) return; + var frame = Assert.IsType(Vm.DebugFrame); + Assert.True(Vm.TryRequestDebugFrameReturn(frame.FrameId, new Dictionary())); + _returnRequested = true; + } + } + private sealed class Sc0000HoverHost : RecordingHost { public VirtualMachine Vm = null!; @@ -168,6 +226,34 @@ public class HotspotInputTests Assert.False(vm.TryActivatePointer(30, 40)); // the script frame has exited, so no hotspot remains active } + [Fact] + public void ArmedHotspot_DispatchesBoundLogicalActionThroughOrdinaryActivationCallback() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + const int activateTarget = 31; + var script = ScriptAssembler.Assemble(table, "HOTSPOT_ACTION", new List<(int, Operand[])> + { + (0x90, new[] { I(10), I(20), I(20), I(20), I(-1), I(-1), I(activateTarget) }), + (0x97, new[] { I(10), I(20), I(20), I(20), I(7) }), + (0x94, Array.Empty()), + (0x72, new[] { I(1) }), + (0x2, Array.Empty()), + (0x55, new[] { G(0x110), I(1) }), + (0x5, Array.Empty()), + }, Array.Empty()); + var host = new BoundActionHost(); + var vm = new VirtualMachine(script, table, host); + host.Vm = vm; + + vm.Run(); + + Assert.True(host.Consumed); + Assert.False(host.DuplicateConsumed); + Assert.Equal(1, vm.Globals.GetValueOrDefault(0x110)); + Assert.Equal(1, host.Waits); + Assert.Equal("exit", vm.HaltReason); + } + [Fact] public void Sc0000AdvChromeBootstrap_VisitsAllFiveVisibleButtonRegistrations() { @@ -410,6 +496,29 @@ public class HotspotInputTests Assert.Equal(new[] { true, false }, host.AdvPagePresentationSuspended); } + [Fact] + public void Sc0000ActionSeven_EntersRealMenuAndRestoresParentAdvHotspots() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var scripts = Sys4ScriptProvider.Load(table); + var scene = scripts.RequireByName("SC0000.BIN"); + var host = new Sc0000MenuHost(); + var sink = new ReturnMenuOnEntrySink(); + var vm = new VirtualMachine(scene, table, host, new VmOptions(MaxSteps: 1_000_000), scripts, sink); + host.Vm = vm; + sink.Vm = vm; + InputBindingBootstrap.Apply(scripts.RequireByName("SYSTEM4.BIN"), vm.InputBindings); + vm.Globals[0x6c1] = 1; + vm.Globals[0x62425] = 1; + + Assert.Throws(() => vm.Run()); + + Assert.True(host.MenuActionConsumed); + Assert.True(sink.MenuEntered); + Assert.Equal(8, vm.Gfx.DefaultObjectSlot); // MENU's entry selector ran before the controlled return. + Assert.True(host.ParentHotspotsRearmed); + } + [Fact] public void MessageSkipState_ReachesHostBeforeFollowingOpcodeCadenceYields() { diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index d94c275..549164a 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -121,6 +121,8 @@ public sealed class GfxState private readonly Dictionary _fieldTable = new(); // ctx+0x46d14 (0x216); no family writer -> default 0 public long CurrentObject { get; private set; } + /// EngineCtx+0x14e08, selected by op 0x80 and used by op 0x1d9 when its slot is zero. + public int DefaultObjectSlot { get; private set; } /// The D3D render target selected by op 0x20d. -1 denotes the main backbuffer. public int CurrentRenderTargetSlot { get; private set; } = -1; @@ -148,6 +150,11 @@ public sealed class GfxState } } + public void SetDefaultObjectSlot(int slot) + { + lock (_lock) DefaultObjectSlot = slot; + } + /// Op 0x21d: clone the native 0x2d4-byte retained-object record from source to destination. public bool CloneObject(long sourceHandle, long destinationHandle) { diff --git a/engine/Age.Engine/Vm/HotspotRegistry.cs b/engine/Age.Engine/Vm/HotspotRegistry.cs index 102c0e7..87e032d 100644 --- a/engine/Age.Engine/Vm/HotspotRegistry.cs +++ b/engine/Age.Engine/Vm/HotspotRegistry.cs @@ -7,7 +7,7 @@ internal sealed class HotspotRegistry { public int Left, Top, Right, Bottom; public int EnterTarget, LeaveTarget, ActivateTarget; - public int? InputBit; + public int? LogicalAction; public bool Contains(int x, int y) => x >= Left && x <= Right && y >= Top && y <= Bottom; @@ -36,12 +36,12 @@ internal sealed class HotspotRegistry }); } - public void BindKey(int x, int y, int width, int height, int inputBit) + public void BindKey(int x, int y, int width, int height, int logicalAction) { int right = x + width, bottom = y + height; var entry = _entries.FirstOrDefault(e => e.Left == x && e.Top == y && e.Right == right && e.Bottom == bottom); - if (entry != null) entry.InputBit = inputBit; + if (entry != null) entry.LogicalAction = logicalAction; } public bool Arm(int pointerX, int pointerY) @@ -69,7 +69,25 @@ internal sealed class HotspotRegistry int hit = FindHit(x, y); if (hit < 0) return false; - int target = _entries[hit].ActivateTarget; + return ConsumeActivation(_entries[hit].ActivateTarget); + } + + /// Activate the first armed record whose op-0x97 logical action is present in the + /// current input mask. Native scans records in registration order before pointer activation. + public bool ActivateBoundActions(int actionMask) + { + if (!Armed || actionMask == 0) return false; + for (int i = 0; i < _entries.Count; i++) + { + int? action = _entries[i].LogicalAction; + if (action is >= 0 and < 32 && (actionMask & (1 << action.Value)) != 0) + return ConsumeActivation(_entries[i].ActivateTarget); + } + return false; + } + + private bool ConsumeActivation(int target) + { // Native consumes the active input registration before dispatch. Its ADV scheduler revisits the // shared registration routine afterward; retain the definitions so the blocking host can model // that revisit without advancing the enclosing dialogue page. diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 69a5bde..ebadb4c 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -135,6 +135,17 @@ public sealed class VirtualMachine return consumed; } + /// Queue the first armed hotspot callback bound by op 0x97 to any action in + /// . True means the logical input was consumed. + public bool TryActivateInputActions(int actionMask) + { + bool consumed; + lock (_interactiveLock) + consumed = _interactiveFrame?.Hotspots.ActivateBoundActions(actionMask) == true; + if (consumed) _host.WakeInputCallbackService(); + return consumed; + } + /// Update one native mouse-button bit (left=0x1, right=0x2 in Himegari). public void UpdateMouseButtonState(int bit, bool pressed) => UpdateMaskBit(ref _mouseButtonState, bit, pressed); @@ -962,7 +973,7 @@ public sealed class VirtualMachine return pc + 1; } case "u0041C150": - case "bind-hotspot-key": // 0x97: keyboard/pad routing is a later host-input slice + 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])); @@ -1398,6 +1409,9 @@ public sealed class VirtualMachine Gfx.GetOrCreate(Read(a[0])).V18 = (Read(a[1]), Read(a[2]), Read(a[3])); return pc + 1; case "set-gfx-geom3-b": // 0x219 (handle)(a)(b)(c) -> V24 Gfx.GetOrCreate(Read(a[0])).V24 = (Read(a[1]), Read(a[2]), Read(a[3])); return pc + 1; + case "u0041AF00": // 0x80: default object slot substituted by native op 0x1d9 + case "set-default-gfx-object-slot": + Gfx.SetDefaultObjectSlot((int)Read(a[0])); return pc + 1; // ---- SC0000 anim/transform/spritesheet cluster (docs/engine-re.md ยง"SC0000 anim ... cluster") ---- case "u00421DD0": // 0x22f set-position: (handle)(op2)(x)(y)(z) -> base position (direct set) diff --git a/godot/Main.cs b/godot/Main.cs index a634073..fbe78d2 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -423,7 +423,7 @@ public partial class Main : Godot.Control int nativeButtonBit = mb.ButtonIndex == MouseButton.Left ? 0x1 : 0x2; int physicalButton = mb.ButtonIndex == MouseButton.Left ? 0 : 1; _vm.UpdateMouseButtonState(nativeButtonBit, mb.Pressed); - _vm.UpdatePhysicalMouseButtonState(physicalButton, mb.Pressed); + int action = _vm.UpdatePhysicalMouseButtonState(physicalButton, mb.Pressed); if (mb.Pressed && _host.IsModalMovieWaiting) { _host.SignalInput(); @@ -432,6 +432,11 @@ public partial class Main : Godot.Control } // AGE exposes mouse buttons twice: op 0x108 reads the raw bitmask while op 0xff translates // the held physical button through the script-configured logical action map. + if (mb.Pressed && action >= 0 && _vm.TryActivateInputActions(1 << action)) + { + GetViewport().SetInputAsHandled(); + return; + } if (mb.ButtonIndex == MouseButton.Left && mb.Pressed && _vm.TryActivatePointer(p.X, p.Y)) { GetViewport().SetInputAsHandled(); @@ -447,7 +452,11 @@ public partial class Main : Godot.Control && Win32VirtualKeyTranslator.TryTranslate(gameplayKey, out int virtualKey)) { int action = _vm.UpdateKeyboardVirtualKeyState(virtualKey, gameplayKey.Pressed); - if (gameplayKey.Pressed && IsAdvanceAction(action)) + if (gameplayKey.Pressed && action >= 0 && _vm.TryActivateInputActions(1 << action)) + { + GetViewport().SetInputAsHandled(); + } + else if (gameplayKey.Pressed && IsAdvanceAction(action)) { if (_host.IsModalMovieWaiting) { @@ -462,7 +471,11 @@ public partial class Main : Godot.Control if (e is InputEventJoypadButton joyButton) { int actionMask = _vm.UpdateJoystickButtonState((int)joyButton.ButtonIndex, joyButton.Pressed); - if (joyButton.Pressed && HasAdvanceAction(actionMask)) + if (joyButton.Pressed && _vm.TryActivateInputActions(actionMask)) + { + GetViewport().SetInputAsHandled(); + } + else if (joyButton.Pressed && HasAdvanceAction(actionMask)) { if (_host.IsModalMovieWaiting) {