Implement ADV History interaction

This commit is contained in:
gamer147
2026-07-19 00:13:47 -04:00
parent 32a67b88de
commit 1fdedfb41f
7 changed files with 231 additions and 15 deletions

View File

@@ -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 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.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 The fourth port slice implements History's basic pointer interaction and clean return. Op `0x12e` treats its
through `0x1bd`, and the `0xd3/0xd4/0xd5` smooth-scroll callback scheduler. None changes backlog ownership or addressable operands as arrays in their actual VM domain (HISTORY's are local integers), scans from
requires choosing a save/profile backend. `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 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 Read-message Skip, then History after both the input layer and message-completion seam exist. Hide Window is

View File

@@ -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. - **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) ### 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 - **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) ### 0x19a `get-message-skip` (u00414E50, argc 1)
- **summary:** (out) - return the current all-message skip state set by op 0x88. - **summary:** (out) - return the current all-message skip state set by op 0x88.

View File

@@ -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 **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 using the existing callback layer. Then add stored voice replay (`0x1bd`); leave `0xd3/0xd4/0xd5` smooth
scroll interpolation as a separate fidelity slice. 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.

View File

@@ -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<bool> 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<Operand>()));
var script = ScriptAssembler.Assemble(Table, "HIT_RECT", ops, Array.Empty<string>());
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<StopAfterHistoryReturnsException>(() => 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
}
}

View File

@@ -25,6 +25,7 @@ public sealed class VirtualMachine
private readonly ITraceSink _sink; private readonly ITraceSink _sink;
private readonly object _interactiveLock = new(); private readonly object _interactiveLock = new();
private ExecFrame? _interactiveFrame; private ExecFrame? _interactiveFrame;
private ExecFrame? _rawInputFrame;
private int _pointerX = int.MinValue, _pointerY = int.MinValue; private int _pointerX = int.MinValue, _pointerY = int.MinValue;
private int _mouseButtonState; private int _mouseButtonState;
private int _heldInputCallbackMask; private int _heldInputCallbackMask;
@@ -48,6 +49,11 @@ public sealed class VirtualMachine
public long Steps { get; private set; } public long Steps { get; private set; }
public bool AutoMessageEnabled => _autoMessageEnabled; public bool AutoMessageEnabled => _autoMessageEnabled;
public bool MessageSkipEnabled => _messageSkipEnabled; public bool MessageSkipEnabled => _messageSkipEnabled;
/// <summary>True while a script-owned timed mouse/input callback loop (HISTORY/HIDEWIN family) owns input.</summary>
public bool IsRawInputCallbackActive
{
get { lock (_interactiveLock) return _rawInputFrame != null; }
}
public AdvTextHistory TextHistory { get; } public AdvTextHistory TextHistory { get; }
public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null, 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) private string FormatSwitchValue(Operand operand)
=> IsStr(operand) => IsStr(operand)
? ReadStr(operand) ? ReadStr(operand)
@@ -256,7 +284,12 @@ public sealed class VirtualMachine
private FrameOutcome RunFrame(ExecFrame frame, FrameCause cause, long callId = 0) private FrameOutcome RunFrame(ExecFrame frame, FrameCause cause, long callId = 0)
{ {
ExecFrame? previousInteractiveFrame; ExecFrame? previousInteractiveFrame;
lock (_interactiveLock) previousInteractiveFrame = _interactiveFrame; ExecFrame? previousRawInputFrame;
lock (_interactiveLock)
{
previousInteractiveFrame = _interactiveFrame;
previousRawInputFrame = _rawInputFrame;
}
var prev = _cur; _cur = frame; _depth++; var prev = _cur; _cur = frame; _depth++;
_sink.Emit(TraceEvent.FrameEnter(frame.Script.Name, _depth, cause, callId)); _sink.Emit(TraceEvent.FrameEnter(frame.Script.Name, _depth, cause, callId));
var outcome = FrameOutcome.RanOff; var outcome = FrameOutcome.RanOff;
@@ -280,6 +313,7 @@ public sealed class VirtualMachine
? previousInteractiveFrame : null; ? previousInteractiveFrame : null;
else if (ReferenceEquals(_interactiveFrame, frame)) else if (ReferenceEquals(_interactiveFrame, frame))
_interactiveFrame = null; _interactiveFrame = null;
if (ReferenceEquals(_rawInputFrame, frame)) _rawInputFrame = previousRawInputFrame;
} }
_cur = prev; _depth--; _cur = prev; _depth--;
return outcome; return outcome;
@@ -372,6 +406,37 @@ public sealed class VirtualMachine
WriteConsecutive(a[0], i, unchecked((int)_cur.Script.BodyDwords[offset + 1 + i])); WriteConsecutive(a[0], i, unchecked((int)_cur.Script.BodyDwords[offset + 1 + i]));
return pc + 1; 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": case "bit-set":
{ {
long bit = Read(a[1]); long bit = Read(a[1]);
@@ -577,6 +642,7 @@ public sealed class VirtualMachine
_cur.MouseCallbackIntervalMs = System.Math.Max(0, Read(a[0])); _cur.MouseCallbackIntervalMs = System.Math.Max(0, Read(a[0]));
_cur.MouseCallbackTarget = (int)Read(a[1]); _cur.MouseCallbackTarget = (int)Read(a[1]);
_cur.MouseCallbackNextAtMs = _host.InputClockMilliseconds + _cur.MouseCallbackIntervalMs; _cur.MouseCallbackNextAtMs = _host.InputClockMilliseconds + _cur.MouseCallbackIntervalMs;
lock (_interactiveLock) _rawInputFrame = _cur;
return pc + 1; return pc + 1;
case "get-input-type": case "get-input-type":
case "dispatch-mouse-callback": // 0xcd case "dispatch-mouse-callback": // 0xcd

View File

@@ -357,22 +357,24 @@ public partial class Main : Godot.Control
{ {
var p = ToNativeScreen(mb.Position); var p = ToNativeScreen(mb.Position);
bool advPageSuspended = _host.IsAdvPagePresentationSuspended; bool advPageSuspended = _host.IsAdvPagePresentationSuspended;
bool rawInputCallbackActive = _vm.IsRawInputCallbackActive;
_vm.UpdatePointer(p.X, p.Y); _vm.UpdatePointer(p.X, p.Y);
int nativeButtonBit = mb.ButtonIndex == MouseButton.Left ? 0x1 : 0x2; int nativeButtonBit = mb.ButtonIndex == MouseButton.Left ? 0x1 : 0x2;
_vm.UpdateMouseButtonState(nativeButtonBit, mb.Pressed); _vm.UpdateMouseButtonState(nativeButtonBit, mb.Pressed);
// AGE exposes the physical left button twice: raw mask 0x1 for the timed mouse callback, // 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 // and the configured primary action (default input callback index 4). Script-owned callback
// activation click's release arms the script; the next completed left click restores it. // loops consume both channels without releasing the enclosing ADV page wait.
if (mb.ButtonIndex == MouseButton.Left && advPageSuspended) if (mb.ButtonIndex == MouseButton.Left && (advPageSuspended || rawInputCallbackActive))
_vm.QueueInputCallback(mb.Pressed ? 4 : 10); _vm.QueueInputCallback(mb.Pressed ? 4 : 10);
if (mb.ButtonIndex == MouseButton.Left && mb.Pressed && _vm.TryActivatePointer(p.X, p.Y)) if (mb.ButtonIndex == MouseButton.Left && mb.Pressed && _vm.TryActivatePointer(p.X, p.Y))
{ {
GetViewport().SetInputAsHandled(); GetViewport().SetInputAsHandled();
return; return;
} }
// A left click owned by the yielded page must return through HIDEWIN's callback/coroutine // Modal callback scripts return through their own bytecode. Signaling the enclosing ADV wait
// path. Releasing the enclosing ADV wait here would also advance the restored dialogue page. // here would also advance the restored dialogue page after HISTORY/HIDEWIN exits.
if (mb.ButtonIndex == MouseButton.Left && mb.Pressed && !advPageSuspended) _host.SignalInput(); if (mb.ButtonIndex == MouseButton.Left && mb.Pressed
&& !advPageSuspended && !rawInputCallbackActive) _host.SignalInput();
return; return;
} }
UpdateAgeInputCallback(e, "ui_down", 0); UpdateAgeInputCallback(e, "ui_down", 0);
@@ -381,7 +383,8 @@ public partial class Main : Godot.Control
UpdateAgeInputCallback(e, "ui_right", 3); UpdateAgeInputCallback(e, "ui_right", 3);
UpdateAgeInputCallback(e, "ui_accept", 4); UpdateAgeInputCallback(e, "ui_accept", 4);
UpdateAgeInputCallback(e, "ui_cancel", 5); 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) private void UpdateAgeInputCallback(InputEvent e, StringName action, int index)

View File

@@ -2526,12 +2526,12 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "find-hit-rectangle" name = "find-hit-rectangle"
category = "input" 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 noop_headless = false
source = "investigation" source = "investigation"
confidence = "high" confidence = "high"
depends_on = [] 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]] [[opcode.semantics.args]]
i = 1 i = 1