Implement ADV system menu input routing

This commit is contained in:
gamer147
2026-07-21 11:16:50 -04:00
parent f0e31343a0
commit 12e5073918
8 changed files with 213 additions and 18 deletions

View File

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

View File

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

View File

@@ -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<string>());
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()
{

View File

@@ -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<bool> 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<bool> 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<DebugFrameSnapshot>(Vm.DebugFrame);
Assert.True(Vm.TryRequestDebugFrameReturn(frame.FrameId, new Dictionary<int, long>()));
_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<Operand>()),
(0x72, new[] { I(1) }),
(0x2, Array.Empty<Operand>()),
(0x55, new[] { G(0x110), I(1) }),
(0x5, Array.Empty<Operand>()),
}, Array.Empty<string>());
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<StopAtFirstWaitException>(() => 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()
{

View File

@@ -121,6 +121,8 @@ public sealed class GfxState
private readonly Dictionary<long, long> _fieldTable = new(); // ctx+0x46d14 (0x216); no family writer -> default 0
public long CurrentObject { get; private set; }
/// <summary>EngineCtx+0x14e08, selected by op 0x80 and used by op 0x1d9 when its slot is zero.</summary>
public int DefaultObjectSlot { get; private set; }
/// <summary>The D3D render target selected by op 0x20d. -1 denotes the main backbuffer.</summary>
public int CurrentRenderTargetSlot { get; private set; } = -1;
@@ -148,6 +150,11 @@ public sealed class GfxState
}
}
public void SetDefaultObjectSlot(int slot)
{
lock (_lock) DefaultObjectSlot = slot;
}
/// <summary>Op 0x21d: clone the native 0x2d4-byte retained-object record from source to destination.</summary>
public bool CloneObject(long sourceHandle, long destinationHandle)
{

View File

@@ -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);
}
/// <summary>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.</summary>
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.

View File

@@ -135,6 +135,17 @@ public sealed class VirtualMachine
return consumed;
}
/// <summary>Queue the first armed hotspot callback bound by op 0x97 to any action in
/// <paramref name="actionMask"/>. True means the logical input was consumed.</summary>
public bool TryActivateInputActions(int actionMask)
{
bool consumed;
lock (_interactiveLock)
consumed = _interactiveFrame?.Hotspots.ActivateBoundActions(actionMask) == true;
if (consumed) _host.WakeInputCallbackService();
return consumed;
}
/// <summary>Update one native mouse-button bit (left=0x1, right=0x2 in Himegari).</summary>
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)

View File

@@ -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)
{