Implement ADV system menu input routing
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user