From b8928ec7b8bbb203b7f7aad0be1d6e6e07e3757f Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 19 Jul 2026 21:38:34 -0400 Subject: [PATCH] Implement History mouse wheel navigation --- docs/engine-ctx-reference.md | 1 + docs/engine-re.md | 26 ++++++- docs/opcode-reference.md | 9 ++- docs/phase-a-slice-plan.md | 25 ++++++ .../HistoryInteractionOpsTests.cs | 76 +++++++++++++++++++ engine/Age.Engine/Vm/VirtualMachine.cs | 12 +++ godot/Main.cs | 12 +++ tools/age_opcodes_himegari.py | 1 + vm-map/engine-ctx.toml | 5 ++ vm-map/opcodes.toml | 14 ++-- 10 files changed, 169 insertions(+), 12 deletions(-) diff --git a/docs/engine-ctx-reference.md b/docs/engine-ctx-reference.md index 063f5e6..e5c1b81 100644 --- a/docs/engine-ctx-reference.md +++ b/docs/engine-ctx-reference.md @@ -11,6 +11,7 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | `0x410` | `archive_name_table` | `void*` | archive-name table base (arc_id*0x100 indexes it) | | `0x414` | `sys4ini_records` | `void*` | SYS4INI 80-byte record base {name[64],arc_id,file_number,offset,size}; record = base + id*0x50 | | `0x13dc` | `message_skip_enabled` | `int` | persistent all-message Skip flag; op 0x88 writes it and adv_interpreter_tick injects input bit 0x40 while nonzero | +| `0x1c34` | `mouse_wheel_delta` | `int` | signed WM_MOUSEWHEEL delta accumulated by age_main_window_proc; op 0x10d returns and clears it | | `0x3028` | `alt_pack_table` | `int` | call-script high-byte alternate pack table (unused by corpus) | | `0xb558` | `gfx_dirty_a` | `int` | gfx dirty flag (anim set raises) | | `0xb560` | `gfx_dirty_b` | `int` | gfx dirty flag | diff --git a/docs/engine-re.md b/docs/engine-re.md index b328f9d..563c9a7 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1445,7 +1445,31 @@ script frame, blocks only at these scheduler service boundaries using the host c after each local callback returns. Focused tests cover exact relative deadlines and the late catch-up choice. Smooth-scroll scheduling is now implemented without changing History backlog ownership or choosing a -save/profile backend. HISTORY's remaining static gaps are five supporting opcodes across seven instructions. +save/profile backend. + +#### Mouse-wheel accumulation and History navigation (2026-07-19) + +Opcode `0x10d` is the read half of AGE's generic Win32 mouse-wheel service. The newly recovered +`age_main_window_proc@0x486320` handles `WM_MOUSEWHEEL` (`0x20a`), sign-extends the high word of `wParam`, +and normally adds that value to `ctx+0x1c34`. In special run modes it instead maps the sign through the +configurable `set:WheelKeyUp`/`set:WheelKeyDown` actions, so the accumulator is specifically the ordinary +raw-wheel channel used by scripts such as HISTORY. + +`op_0x10d_consume_mouse_wheel_delta@0x428cf0` writes the accumulated signed value to operand 1 and clears +the field immediately. HISTORY polls it twice: once while establishing the callback loop to discard stale +input, then once per timed mouse callback. The script only tests zero and sign, converting a positive or +negative native delta into one call of its existing smooth-scroll path. + +The port follows that ownership directly: Godot converts wheel-up/down events to signed 120-unit native +deltas, the VM atomically accumulates them, and `0x10d` atomically reads and clears the total. This state is +runtime input only; it is neither a script seed nor profile/save data. A real SC0000-to-HISTORY regression +opens after eight retained messages and proves wheel-up selects older retained rows without releasing or +re-entering the enclosing ADV wait. + +HISTORY is now 74/78 distinct opcodes and 849/854 instructions handled or safe-noop. Its remaining static +gaps are four supporting opcodes across five instructions: text-style setter `0x8b` occurs twice, +`0x1ce`/`0x20a` form a sprite-animation service pair, and `0x1cb` reads the deliberately deferred +Read-message Skip profile setting. 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 e1e4a2e..5dfc295 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -594,6 +594,11 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **grounding:** source=investigation, confidence=high - **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. +### 0x10d `consume-mouse-wheel-delta` (u00415F10, argc 1) +- **summary:** (out) - return the accumulated signed mouse-wheel delta and clear it. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x10d_consume_mouse_wheel_delta@0x428cf0 copies ctx+0x1c34 to operand 1 and immediately clears the field. AGE's window procedure at 0x486925 handles WM_MOUSEWHEEL by sign-extending the high word of wParam and accumulating it at ctx+0x1c34; HISTORY.BIN polls this value and branches on its sign to select one scroll direction. + ### 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 after the incoming index for the next inclusive rectangle intersection, or return -1. - **grounding:** source=investigation, confidence=high @@ -878,10 +883,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=low -### 0x10d `u00415F10` (u00415F10, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x12c `lookup-array-2d` (lookup-array-2d, argc 5) - **summary:** — - **grounding:** source=kelebek, confidence=med diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index e303fd3..0096719 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -1874,3 +1874,28 @@ implementation slice from their actual responsibilities rather than treating the Validation: all 203 engine tests pass, opcode and EngineCtx tests/lints are clean, vm0 RECOVER passes, the Godot build has zero warnings, and threaded `SELFTEST OK`. + +### ADV History mouse-wheel navigation implemented (2026-07-19) + +The five-gap investigation separated independent responsibilities instead of grouping them by proximity. +Native `age_main_window_proc@0x486320` handles `WM_MOUSEWHEEL` by sign-extending its high-word delta and +accumulating it at `EngineCtx+0x1c34`; op `0x10d` returns that signed total and clears it. HISTORY first +uses the opcode to discard stale motion, then polls it from its timed mouse callback and feeds the sign into +the already implemented smooth-scroll route. + +Godot now translates wheel-up/down into signed 120-unit deltas, and the VM atomically accumulates and +consumes them. This extends the existing generic raw-input callback seam and introduces no script-specific +offsets, seeded state, or persistence choice. A focused regression proves accumulation/read-clear behavior; +a real SC0000-to-HISTORY regression opens after eight messages and proves wheel-up changes the retained row +selection without releasing the enclosing ADV wait. + +The canonical opcode and EngineCtx sources are regenerated, and `/v2` now names/comments the recovered main +window procedure and the specific `0x10d` handler. HISTORY is 74/78 distinct opcodes and 849/854 instructions +handled or safe-noop. Remaining: `0x8b` twice, paired sprite-animation service ops `0x1ce`/`0x20a`, and the +deferred Read-message Skip setting getter `0x1cb`. + +**Next:** reverse the common text-style block around `0x8b`; it is the smallest remaining non-persistence +gap. Keep `0x1cb` deferred, and treat `0x1ce`/`0x20a` together as their own sprite-animation slice. + +Validation: all 205 engine tests pass, opcode and EngineCtx tests/lints are clean, vm0 RECOVER passes, the +Godot build has zero warnings, and threaded `SELFTEST OK`. diff --git a/engine/Age.Engine.Tests/HistoryInteractionOpsTests.cs b/engine/Age.Engine.Tests/HistoryInteractionOpsTests.cs index 0f0a78d..23e4302 100644 --- a/engine/Age.Engine.Tests/HistoryInteractionOpsTests.cs +++ b/engine/Age.Engine.Tests/HistoryInteractionOpsTests.cs @@ -14,6 +14,7 @@ public class HistoryInteractionOpsTests private sealed class StopAfterHistoryReturnsException : Exception { } private sealed class StopAfterHistoryVoiceException : Exception { } + private sealed class StopAfterHistoryWheelException : Exception { } private static void SeedSystem4AdvLayouts(Sys4ScriptProvider scripts, AdvTextHistory history) => Assert.Equal(9, AdvTextLayoutBootstrap.ApplyLeadingDefinitionsAndResets( @@ -115,6 +116,81 @@ public class HistoryInteractionOpsTests } } + private sealed class Sc0000HistoryWheelHost : RecordingHost + { + public VirtualMachine Vm = null!; + private readonly Dictionary _before = new(); + private long _now; + private bool _wheelQueued; + public bool HistoryRowsChanged; + public override long InputClockMilliseconds => _now; + + public override void Sleep(long duration) + { + base.Sleep(duration); + _now += System.Math.Max(16, duration); + if (!Vm.IsRawInputCallbackActive || ActiveHistoryRenders.Count == 0) return; + if (!_wheelQueued) + { + foreach (var (slot, batch) in ActiveHistoryRenders) + _before[slot] = batch.FirstRecordIndex; + _wheelQueued = true; + Vm.QueueMouseWheelDelta(120); // native wheel-up sign: move toward older retained rows + return; + } + HistoryRowsChanged = ActiveHistoryRenders.Any(pair => + !_before.TryGetValue(pair.Key, out int first) || first != pair.Value.FirstRecordIndex); + if (HistoryRowsChanged) throw new StopAfterHistoryWheelException(); + } + + public override void WaitForInput(int layoutSlot, Func serviceInputCallback) + { + Waits++; + if (Waits < 8) return; + Vm.UpdatePointer(684, 572); + while (serviceInputCallback()) { } + Assert.True(Vm.TryActivatePointer(684, 572)); + while (serviceInputCallback()) { } + } + } + + [Fact] + public void MouseWheelDeltaAccumulatesAndIsClearedByOpcode10d() + { + var script = ScriptAssembler.Assemble(Table, "WHEEL_DELTA", + new List<(int, Operand[])> + { + (0x10d, new[] { G(0x100) }), + (0x10d, new[] { G(0x101) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var vm = new VirtualMachine(script, Table, new RecordingHost()); + vm.QueueMouseWheelDelta(120); + vm.QueueMouseWheelDelta(-360); + + vm.Run(); + + Assert.Equal(-240, vm.Globals[0x100]); + Assert.Equal(0, vm.Globals[0x101]); + } + + [Fact] + public void RealHistoryWheelUpNavigatesToOlderRetainedRows() + { + var scripts = Sys4ScriptProvider.Load(Table); + var host = new Sc0000HistoryWheelHost(); + var vm = new VirtualMachine(scripts.RequireByName("SC0000.BIN"), Table, host, + new VmOptions(MaxSteps: 2_000_000), scripts); + host.Vm = vm; + SeedSystem4AdvLayouts(scripts, vm.TextHistory); + vm.Globals[0x6c1] = 1; + + Assert.Throws(() => vm.Run()); + + Assert.True(host.HistoryRowsChanged); + Assert.Equal(8, host.Waits); // the eighth enclosing ADV wait was not released or re-entered + } + [Fact] public void RealHistoryRendersMultipleRowsAfterSeveralSc0000Messages() { diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 01ae6c1..ca91ec4 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -28,6 +28,7 @@ public sealed class VirtualMachine private ExecFrame? _rawInputFrame; private int _pointerX = int.MinValue, _pointerY = int.MinValue; private int _mouseButtonState; + private int _mouseWheelDelta; private int _heldInputCallbackMask; private int _queuedInputCallbackMask; private bool _autoMessageEnabled; @@ -95,6 +96,14 @@ public sealed class VirtualMachine /// Update one native mouse-button bit (left=0x1, right=0x2 in Himegari). public void UpdateMouseButtonState(int bit, bool pressed) => UpdateMaskBit(ref _mouseButtonState, bit, pressed); + /// Accumulate a signed native mouse-wheel delta until op 0x10d consumes it. + public void QueueMouseWheelDelta(int delta) + { + if (delta == 0) return; + Interlocked.Add(ref _mouseWheelDelta, delta); + _host.WakeInputCallbackService(); + } + /// Update one held AGE input-callback index used by ops 0xfb/0xff/0x100. public void UpdateInputCallbackState(int index, bool pressed) { @@ -725,6 +734,9 @@ public sealed class VirtualMachine case "u00415E70": case "get-mouse-button-state": // 0x108 Write(a[0], Volatile.Read(ref _mouseButtonState)); return pc + 1; + case "u00415F10": + case "consume-mouse-wheel-delta": // 0x10d + Write(a[0], Interlocked.Exchange(ref _mouseWheelDelta, 0)); return pc + 1; case "u00415EC0": case "get-cursor-virtual": // 0x109 { diff --git a/godot/Main.cs b/godot/Main.cs index 7564559..e99f8c2 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -359,6 +359,18 @@ public partial class Main : Godot.Control _vm.UpdatePointer(p.X, p.Y); return; } + if (e is InputEventMouseButton wheel + && wheel.Pressed + && (wheel.ButtonIndex == MouseButton.WheelUp || wheel.ButtonIndex == MouseButton.WheelDown)) + { + // WM_MOUSEWHEEL supplies signed multiples of WHEEL_DELTA (120). AGE accumulates that + // value until op 0x10d reads and clears it; HISTORY currently uses only its sign. + int direction = wheel.ButtonIndex == MouseButton.WheelUp ? 1 : -1; + int steps = System.Math.Max(1, (int)System.Math.Round(wheel.Factor)); + _vm.QueueMouseWheelDelta(direction * 120 * steps); + GetViewport().SetInputAsHandled(); + return; + } if (e is InputEventMouseButton mb && (mb.ButtonIndex == MouseButton.Left || mb.ButtonIndex == MouseButton.Right)) { diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index 080ef68..3c08988 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -30,6 +30,7 @@ INFERRED: dict[int, dict] = { 0x108: dict(name='get-mouse-button-state', category='input', noop=False, confidence='high', source='investigation', summary='(out) - return the current mouse-button state bitmask.'), 0x109: dict(name='get-cursor-virtual', category='input', noop=False, confidence='high', source='investigation', summary="(out_x)(out_y) - read the OS cursor and convert it into AGE's virtual-screen coordinates."), 0x10a: dict(name='set-cursor-virtual', category='input', noop=False, confidence='high', source='investigation', summary='(x)(y) - convert AGE virtual-screen coordinates to client/screen coordinates and move the OS cursor.'), + 0x10d: dict(name='consume-mouse-wheel-delta', category='input', noop=False, confidence='high', source='investigation', summary='(out) - return the accumulated signed mouse-wheel delta and clear it.'), 0x140: dict(name='coroutine-label-yield', category='control', noop=False, confidence='med', source='investigation', summary="(out)(name_str)(sub_str)(in) — scene-coroutine LOOP ITERATOR / labeled yield. Handler copies name/sub strings + the int operand and calls the NATIVE video/transition service (*DAT_005c6018)(8, ctx[0x54fe8], &{name,sub,in}); writes the returned PC-like value to operand 1. In SC0000 label_462 'ループ開始' (@0x46d): `out=G[0x6be]=LABEL('J',G[0x6be])`; loop runs the intro-setup body (incl. call label_125bd = slot-table fill G[0x3239..0x324e]=4..11) and jmps back until out==G[0x6c3] (a per-scene exit-PC immediate) → mov aba5c 0 → content. The gate G[0xaba5c]==1 that opens this loop is NATIVE scene-entry state (no script sets it to 1). DAT_005c6018 is runtime-resolved (all xrefs READ) = SAME class as the DirectDraw workers we don't model. PORT = HOST-MODEL IMPLEMENTED: synthesize the ADV scene-entry gate, run the LABEL/J setup body once, then return the structurally discovered per-scene terminal; do not emulate the video service. See engine-re.md §Scene-coroutine framework."), 0x199: dict(name='yield-adv-coroutine', category='control', noop=False, confidence='high', source='investigation', summary='Yield/re-enter the registered ADV coroutine handler. The fifth standard chrome button uses this transition to enter the HIDEWIN/window-hidden flow.'), 0x19a: dict(name='get-message-skip', category='input', noop=False, confidence='high', source='investigation', summary='(out) - return the current all-message skip state set by op 0x88.'), diff --git a/vm-map/engine-ctx.toml b/vm-map/engine-ctx.toml index 158b1eb..0a716fd 100644 --- a/vm-map/engine-ctx.toml +++ b/vm-map/engine-ctx.toml @@ -33,6 +33,11 @@ name = "message_skip_enabled" type = "int" note = "persistent all-message Skip flag; op 0x88 writes it and adv_interpreter_tick injects input bit 0x40 while nonzero" [[field]] +offset = 0x1c34 +name = "mouse_wheel_delta" +type = "int" +note = "signed WM_MOUSEWHEEL delta accumulated by age_main_window_proc; op 0x10d returns and clears it" +[[field]] offset = 0x3028 name = "alt_pack_table" type = "int" diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index e02eb15..cedbdd8 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -2463,18 +2463,18 @@ argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00415F10" -category = "unknown" -summary = "" +name = "consume-mouse-wheel-delta" +category = "input" +summary = "(out) - return the accumulated signed mouse-wheel delta and clear it." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x10d_consume_mouse_wheel_delta@0x428cf0 copies ctx+0x1c34 to operand 1 and immediately clears the field. AGE's window procedure at 0x486925 handles WM_MOUSEWHEEL by sign-extending the high word of wParam and accumulating it at ctx+0x1c34; HISTORY.BIN polls this value and branches on its sign to select one scroll direction." [[opcode.semantics.args]] i = 1 -role = "" +role = "accumulated signed delta out" observed_types = ["l-int"] [[opcode]]