diff --git a/docs/engine-re.md b/docs/engine-re.md index 09a7149..19c29f8 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1350,9 +1350,24 @@ source rows therefore retain five independent speaker names, and compositor sour each one into its bound object. A real-script regression retains SC0000 page one, runs unmodified `HISTORY.BIN`, observes a non-empty rendered row, and reaches its `(0,60000)` presentation call. -History's remaining work is interaction fidelity: `0x12e` rectangle hover/hit selection, stored voice replay -through `0x1bd`, and the `0xd3/0xd4/0xd5` smooth-scroll callback scheduler. None changes backlog ownership or -requires choosing a save/profile backend. +The fourth port slice implements History's basic pointer interaction and clean return. Op `0x12e` treats its +addressable operands as arrays in their actual VM domain (HISTORY's are local integers), scans from +`incoming_index+1`, and tests inclusive intersection. Rectangle fields are `[left,right,top,bottom]`; each +candidate's x/y offset is subtracted from the pointer before comparison with the reference rectangle. +HISTORY's decoded arrays contain fourteen candidates: scrollbar/control regions, bottom-right close region +8, and five 650x130 text rows at indices 9..13. Moving the pointer updates the selected index and executes the +script's existing redraw, while left-button release on region 8 sets the script's exit state and runs its +normal surface/layout/recording cleanup. + +The host input bridge now distinguishes an enclosing ADV page wait from a script-owned timed raw-input loop. +Registration through op `0xcc` marks the current frame as the raw-input owner until that frame returns; +Godot routes physical button state and configured callback indices to it without signaling the parked ADV +page. This is frame-scoped callback state, not a HISTORY name/offset special case, and also matches the +existing HIDEWIN scheduler family. A real regression activates x=684 in SC0000, runs unmodified HISTORY, +selects/closes region 8, observes retained text, and returns to the same single page wait. + +History's remaining work is stored voice replay through `0x1bd` and the `0xd3/0xd4/0xd5` smooth-scroll +callback scheduler. None changes backlog ownership or requires choosing a save/profile backend. The original dependency order was **Hide Window first** to establish reusable callback/coroutine input, then Read-message Skip, then History after both the input layer and message-completion seam exist. Hide Window is diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 192a177..d3b2bc6 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -573,9 +573,9 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **evidence:** Ghidra /v2: op_0x10a_set_cursor_virtual@0x421590 maps virtual coordinates through the active VirtualFullScreen geometry and calls SetCursorPos. SC0000 alternates the cursor by one vertical pixel after state-changing ADV button clicks so the hover state re-enters cleanly. ### 0x12e `find-hit-rectangle` (find-hit-rectangle, argc 8) -- **summary:** (index_inout)(reference_rect)(pointer_x)(pointer_y)(rect_array)(x_offsets)(y_offsets)(count) - scan forward through encoded rectangles and return the next hit index, or -1. +- **summary:** (index_inout)(reference_rect)(pointer_x)(pointer_y)(rect_array)(x_offsets)(y_offsets)(count) - scan after the incoming index for the next inclusive rectangle intersection, or return -1. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra /v2: op_0x12e_find_hit_rectangle@0x428ff0 resolves the reference rectangle and three array operands as VM pointers, decodes rectangle/offset values with anti_tamper_a, scans candidates after the incoming index, applies per-entry x/y offsets to the pointer coordinates, performs rectangle overlap containment tests, and writes the matched array index or -1 to operand 1. HISTORY.BIN uses it for mouse hover selection over its literal-initialized row rectangles. +- **evidence:** Ghidra /v2: op_0x12e_find_hit_rectangle@0x428ff0 resolves the reference rectangle and three array operands as VM pointers, decodes rectangle/offset values with anti_tamper_a, and starts at incoming_index+1. Each candidate is [left,right,top,bottom]; the worker subtracts its per-entry x/y offsets from the pointer, inclusively intersects it with the reference rectangle, and writes the matched array index or -1 to operand 1. HISTORY.BIN uses decoded local arrays for scrollbar/control regions, close region 8, and visible text rows 9..13. ### 0x19a `get-message-skip` (u00414E50, argc 1) - **summary:** (out) - return the current all-message skip state set by op 0x88. diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index f008b29..4a81fd2 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -1752,3 +1752,26 @@ opcodes and 840/854 instructions handled or safe-noop; its ten remaining effectf **Next:** implement the basic History interaction slice around rectangle hit/hover (`0x12e`) and clean exit using the existing callback layer. Then add stored voice replay (`0x1bd`); leave `0xd3/0xd4/0xd5` smooth scroll interpolation as a separate fidelity slice. + +### ADV History basic interaction implemented (2026-07-19) + +The fourth bounded slice is complete. Op `0x12e` now performs its native forward scan over addressable VM +arrays, including HISTORY's local rectangle and offset tables. It begins after the incoming index, applies +per-candidate offsets, and inclusively intersects `[left,right,top,bottom]` candidates with the supplied +reference rectangle. The shipped arrays resolve to scrollbar/control regions, bottom-right close candidate +8, and five 650x130 dialogue rows at candidates 9..13; the script's existing redraw owns hover visuals. + +Raw input ownership is now frame-scoped. Registering a timed mouse callback through `0xcc` marks that script +frame as the input owner until return. Godot continues updating raw mouse bits and input callback indices, +but does not release the enclosing ADV page semaphore while that modal loop is active. This closes a real +scheduler hole without a HISTORY filename, scene offset, or synthetic button path. + +Two regressions cover inclusive edges/forward scan and the complete live route. The latter reaches SC0000's +first wait, activates the real x=684 History hotspot, lets unmodified `HISTORY.BIN` render retained text, +presses/releases its real close region, and proves the script returns with recording restored while the same +single ADV wait remains parked. Validation: engine 196/196, zero-warning Godot build, threaded `SELFTEST OK`. +HISTORY is now 69/78 distinct opcodes and 841/854 instructions handled or safe-noop; its nine remaining +effectful gaps total 13 instructions. + +**Next:** implement stored History voice replay (`0x1bd`) against the existing voice host path. Then take +`0xd3/0xd4/0xd5` smooth-scroll interpolation as a separate scheduler/fidelity slice. diff --git a/engine/Age.Engine.Tests/HistoryInteractionOpsTests.cs b/engine/Age.Engine.Tests/HistoryInteractionOpsTests.cs new file mode 100644 index 0000000..8976303 --- /dev/null +++ b/engine/Age.Engine.Tests/HistoryInteractionOpsTests.cs @@ -0,0 +1,109 @@ +using Age.Engine.Hosting; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; + +public class HistoryInteractionOpsTests +{ + private const int T_IMM = 0, T_GINT = 3, T_LINT = 9; + private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson); + private static Operand I(long value) => new(T_IMM, value); + private static Operand G(int address) => new(T_GINT, address); + private static Operand L(int address) => new(T_LINT, address); + + private sealed class StopAfterHistoryReturnsException : Exception { } + + private sealed class Sc0000HistoryCloseHost : RecordingHost + { + public VirtualMachine Vm = null!; + private long _now; + private int _modalSleeps; + public bool HistoryReturned; + public bool SawRenderedText; + public override long InputClockMilliseconds => _now; + + public override void Sleep(long duration) + { + base.Sleep(duration); + _now += System.Math.Max(16, duration); + if (!Vm.IsRawInputCallbackActive) return; + _modalSleeps++; + if (_modalSleeps == 1) + { + Vm.UpdatePointer(790, 570); // HISTORY candidate 8: visible bottom-right close region + Vm.UpdateMouseButtonState(0x1, true); + Vm.QueueInputCallback(4); + } + else if (_modalSleeps == 3) + { + Vm.UpdateMouseButtonState(0x1, false); + Vm.QueueInputCallback(10); + } + } + + public override void WaitForInput(int layoutSlot, Func serviceInputCallback) + { + Waits++; + Vm.UpdatePointer(684, 572); + while (serviceInputCallback()) { } + Assert.True(Vm.TryActivatePointer(684, 572)); + while (serviceInputCallback()) { } + HistoryReturned = !Vm.IsRawInputCallbackActive && !Vm.TextHistory.RecordingSuppressed; + SawRenderedText = HistoryRenders.Any(render => render.Text.Length > 0); + throw new StopAfterHistoryReturnsException(); + } + } + + [Fact] + public void FindHitRectangleScansAfterTheIncomingIndexWithInclusiveEdges() + { + var ops = new List<(int, Operand[])>(); + void Set(int address, long value) => ops.Add((0x55, new[] { L(address), I(value) })); + + for (int i = 0; i < 4; i++) Set(i, 0); // point-sized reference rectangle + long[][] rectangles = + { + new long[] { 0, 10, 0, 10 }, + new long[] { 0, 20, 0, 20 }, + new long[] { 0, 20, 0, 20 }, + }; + for (int rectangle = 0; rectangle < rectangles.Length; rectangle++) + for (int field = 0; field < 4; field++) Set(100 + rectangle * 4 + field, rectangles[rectangle][field]); + foreach (var (address, value) in new[] + { + (200, 100L), (201, 200L), (202, 300L), + (210, 100L), (211, 200L), (212, 300L), + }) Set(address, value); + Set(50, 0); // skip candidate 0 and begin at candidate 1 + ops.Add((0x12e, new[] { L(50), L(0), I(220), I(220), L(100), L(200), L(210), I(3) })); + ops.Add((0x55, new[] { G(0x100), L(50) })); + Set(50, 1); + ops.Add((0x12e, new[] { L(50), L(0), I(321), I(320), L(100), L(200), L(210), I(3) })); + ops.Add((0x55, new[] { G(0x101), L(50) })); + ops.Add((0x2, Array.Empty())); + var script = ScriptAssembler.Assemble(Table, "HIT_RECT", ops, Array.Empty()); + var vm = new VirtualMachine(script, Table, new RecordingHost()); + + vm.Run(); + + Assert.Equal(1, vm.Globals[0x100]); // (220,220) is on candidate 1's inclusive edge + Assert.Equal(-1, vm.Globals[0x101]); + } + + [Fact] + public void RealSc0000HistoryButtonRendersAndClosesWithoutAdvancingThePageWait() + { + var scripts = Sys4ScriptProvider.Load(Table); + var host = new Sc0000HistoryCloseHost(); + var vm = new VirtualMachine(scripts.RequireByName("SC0000.BIN"), Table, host, + new VmOptions(MaxSteps: 2_000_000), scripts); + host.Vm = vm; + vm.Globals[0x6c1] = 1; + + Assert.Throws(() => vm.Run()); + + Assert.True(host.SawRenderedText); + Assert.True(host.HistoryReturned); + Assert.Equal(1, host.Waits); // the enclosing ADV page was never released or re-entered + } +} diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index d30111d..b9caf8a 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -25,6 +25,7 @@ public sealed class VirtualMachine private readonly ITraceSink _sink; private readonly object _interactiveLock = new(); private ExecFrame? _interactiveFrame; + private ExecFrame? _rawInputFrame; private int _pointerX = int.MinValue, _pointerY = int.MinValue; private int _mouseButtonState; private int _heldInputCallbackMask; @@ -48,6 +49,11 @@ public sealed class VirtualMachine public long Steps { get; private set; } public bool AutoMessageEnabled => _autoMessageEnabled; public bool MessageSkipEnabled => _messageSkipEnabled; + /// True while a script-owned timed mouse/input callback loop (HISTORY/HIDEWIN family) owns input. + public bool IsRawInputCallbackActive + { + get { lock (_interactiveLock) return _rawInputFrame != null; } + } public AdvTextHistory TextHistory { get; } public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null, @@ -231,6 +237,28 @@ public sealed class VirtualMachine } } + private long ReadAddressedCell(Operand operand, int offset) + { + return operand.Type switch + { + T_LINT => Gi(_cur.Locals.I, checked((int)operand.Value + offset)), + T_LFLOAT => Gi(_cur.Locals.F, checked((int)operand.Value + offset)), + T_GINT or T_GFLOAT => ReadGlobal(checked((int)operand.Value + offset)), + T_LPTR => Gi(Globals, checked((int)Gi(_cur.Locals.P, (int)operand.Value) + offset)), + T_GPTR => Gi(Globals, checked((int)Gi(Globals, (int)operand.Value) + offset)), + _ => Gi(Globals, checked((int)operand.Value + offset)), + }; + } + + private (bool IsLocal, int Address) AddressedCellIdentity(Operand operand, int offset) + => operand.Type switch + { + T_LINT or T_LFLOAT => (true, checked((int)operand.Value + offset)), + T_LPTR => (false, checked((int)Gi(_cur.Locals.P, (int)operand.Value) + offset)), + T_GPTR => (false, checked((int)Gi(Globals, (int)operand.Value) + offset)), + _ => (false, checked((int)operand.Value + offset)), + }; + private string FormatSwitchValue(Operand operand) => IsStr(operand) ? ReadStr(operand) @@ -256,7 +284,12 @@ public sealed class VirtualMachine private FrameOutcome RunFrame(ExecFrame frame, FrameCause cause, long callId = 0) { ExecFrame? previousInteractiveFrame; - lock (_interactiveLock) previousInteractiveFrame = _interactiveFrame; + ExecFrame? previousRawInputFrame; + lock (_interactiveLock) + { + previousInteractiveFrame = _interactiveFrame; + previousRawInputFrame = _rawInputFrame; + } var prev = _cur; _cur = frame; _depth++; _sink.Emit(TraceEvent.FrameEnter(frame.Script.Name, _depth, cause, callId)); var outcome = FrameOutcome.RanOff; @@ -280,6 +313,7 @@ public sealed class VirtualMachine ? previousInteractiveFrame : null; else if (ReferenceEquals(_interactiveFrame, frame)) _interactiveFrame = null; + if (ReferenceEquals(_rawInputFrame, frame)) _rawInputFrame = previousRawInputFrame; } _cur = prev; _depth--; return outcome; @@ -372,6 +406,37 @@ public sealed class VirtualMachine WriteConsecutive(a[0], i, unchecked((int)_cur.Script.BodyDwords[offset + 1 + i])); return pc + 1; } + case "find-hit-rectangle": // 0x12e: inclusive rectangle intersection over addressed arrays + case "u0041E940": + { + int previous = (int)Read(a[0]); + int count = System.Math.Max(0, (int)Read(a[7])); + long refLeft = ReadAddressedCell(a[1], 0); + long refRight = ReadAddressedCell(a[1], 1); + long refTop = ReadAddressedCell(a[1], 2); + long refBottom = ReadAddressedCell(a[1], 3); + int match = -1; + for (int index = previous + 1; index < count; index++) + { + long x = Read(a[2]) - ReadAddressedCell(a[5], index); + long y = Read(a[3]) - ReadAddressedCell(a[6], index); + long left = ReadAddressedCell(a[4], index * 4); + long right = ReadAddressedCell(a[4], index * 4 + 1); + long top = ReadAddressedCell(a[4], index * 4 + 2); + long bottom = ReadAddressedCell(a[4], index * 4 + 3); + bool isReferenceRectangle = AddressedCellIdentity(a[1], 0) + == AddressedCellIdentity(a[4], index * 4); + if (!isReferenceRectangle + && x + refLeft <= right && x + refRight >= left + && y + refTop <= bottom && y + refBottom >= top) + { + match = index; + break; + } + } + Write(a[0], match); + return pc + 1; + } case "bit-set": { long bit = Read(a[1]); @@ -577,6 +642,7 @@ public sealed class VirtualMachine _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 diff --git a/godot/Main.cs b/godot/Main.cs index d8ddfa0..d4c8eb4 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -357,22 +357,24 @@ public partial class Main : Godot.Control { var p = ToNativeScreen(mb.Position); bool advPageSuspended = _host.IsAdvPagePresentationSuspended; + bool rawInputCallbackActive = _vm.IsRawInputCallbackActive; _vm.UpdatePointer(p.X, p.Y); int nativeButtonBit = mb.ButtonIndex == MouseButton.Left ? 0x1 : 0x2; _vm.UpdateMouseButtonState(nativeButtonBit, mb.Pressed); // AGE exposes the physical left button twice: raw mask 0x1 for the timed mouse callback, - // and the configured primary action (default input callback index 4). During HIDEWIN the - // activation click's release arms the script; the next completed left click restores it. - if (mb.ButtonIndex == MouseButton.Left && advPageSuspended) + // and the configured primary action (default input callback index 4). Script-owned callback + // loops consume both channels without releasing the enclosing ADV page wait. + if (mb.ButtonIndex == MouseButton.Left && (advPageSuspended || rawInputCallbackActive)) _vm.QueueInputCallback(mb.Pressed ? 4 : 10); if (mb.ButtonIndex == MouseButton.Left && mb.Pressed && _vm.TryActivatePointer(p.X, p.Y)) { GetViewport().SetInputAsHandled(); return; } - // A left click owned by the yielded page must return through HIDEWIN's callback/coroutine - // path. Releasing the enclosing ADV wait here would also advance the restored dialogue page. - if (mb.ButtonIndex == MouseButton.Left && mb.Pressed && !advPageSuspended) _host.SignalInput(); + // Modal callback scripts return through their own bytecode. Signaling the enclosing ADV wait + // here would also advance the restored dialogue page after HISTORY/HIDEWIN exits. + if (mb.ButtonIndex == MouseButton.Left && mb.Pressed + && !advPageSuspended && !rawInputCallbackActive) _host.SignalInput(); return; } UpdateAgeInputCallback(e, "ui_down", 0); @@ -381,7 +383,8 @@ public partial class Main : Godot.Control UpdateAgeInputCallback(e, "ui_right", 3); UpdateAgeInputCallback(e, "ui_accept", 4); UpdateAgeInputCallback(e, "ui_cancel", 5); - if (e.IsActionPressed("ui_accept")) _host.SignalInput(); + if (e.IsActionPressed("ui_accept") + && !_host.IsAdvPagePresentationSuspended && !_vm.IsRawInputCallbackActive) _host.SignalInput(); } private void UpdateAgeInputCallback(InputEvent e, StringName action, int index) diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 36688e5..f8d43fb 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -2526,12 +2526,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "find-hit-rectangle" category = "input" -summary = "(index_inout)(reference_rect)(pointer_x)(pointer_y)(rect_array)(x_offsets)(y_offsets)(count) - scan forward through encoded rectangles and return the next hit index, or -1." +summary = "(index_inout)(reference_rect)(pointer_x)(pointer_y)(rect_array)(x_offsets)(y_offsets)(count) - scan after the incoming index for the next inclusive rectangle intersection, or return -1." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra /v2: op_0x12e_find_hit_rectangle@0x428ff0 resolves the reference rectangle and three array operands as VM pointers, decodes rectangle/offset values with anti_tamper_a, scans candidates after the incoming index, applies per-entry x/y offsets to the pointer coordinates, performs rectangle overlap containment tests, and writes the matched array index or -1 to operand 1. HISTORY.BIN uses it for mouse hover selection over its literal-initialized row rectangles." +evidence = "Ghidra /v2: op_0x12e_find_hit_rectangle@0x428ff0 resolves the reference rectangle and three array operands as VM pointers, decodes rectangle/offset values with anti_tamper_a, and starts at incoming_index+1. Each candidate is [left,right,top,bottom]; the worker subtracts its per-entry x/y offsets from the pointer, inclusively intersects it with the reference rectangle, and writes the matched array index or -1 to operand 1. HISTORY.BIN uses decoded local arrays for scrollbar/control regions, close region 8, and visible text rows 9..13." [[opcode.semantics.args]] i = 1