Implement History mouse wheel navigation

This commit is contained in:
gamer147
2026-07-19 21:38:34 -04:00
parent 5ba16505d6
commit b8928ec7b8
10 changed files with 169 additions and 12 deletions

View File

@@ -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) | | `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 | | `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 | | `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) | | `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) | | `0xb558` | `gfx_dirty_a` | `int` | gfx dirty flag (anim set raises) |
| `0xb560` | `gfx_dirty_b` | `int` | gfx dirty flag | | `0xb560` | `gfx_dirty_b` | `int` | gfx dirty flag |

View File

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

@@ -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 - **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. - **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) ### 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. - **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
@@ -878,10 +883,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **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) ### 0x12c `lookup-array-2d` (lookup-array-2d, argc 5)
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=med - **grounding:** source=kelebek, confidence=med

View File

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

View File

@@ -14,6 +14,7 @@ public class HistoryInteractionOpsTests
private sealed class StopAfterHistoryReturnsException : Exception { } private sealed class StopAfterHistoryReturnsException : Exception { }
private sealed class StopAfterHistoryVoiceException : Exception { } private sealed class StopAfterHistoryVoiceException : Exception { }
private sealed class StopAfterHistoryWheelException : Exception { }
private static void SeedSystem4AdvLayouts(Sys4ScriptProvider scripts, AdvTextHistory history) private static void SeedSystem4AdvLayouts(Sys4ScriptProvider scripts, AdvTextHistory history)
=> Assert.Equal(9, AdvTextLayoutBootstrap.ApplyLeadingDefinitionsAndResets( => Assert.Equal(9, AdvTextLayoutBootstrap.ApplyLeadingDefinitionsAndResets(
@@ -115,6 +116,81 @@ public class HistoryInteractionOpsTests
} }
} }
private sealed class Sc0000HistoryWheelHost : RecordingHost
{
public VirtualMachine Vm = null!;
private readonly Dictionary<int, int> _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<bool> 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<Operand>()),
}, Array.Empty<string>());
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<StopAfterHistoryWheelException>(() => vm.Run());
Assert.True(host.HistoryRowsChanged);
Assert.Equal(8, host.Waits); // the eighth enclosing ADV wait was not released or re-entered
}
[Fact] [Fact]
public void RealHistoryRendersMultipleRowsAfterSeveralSc0000Messages() public void RealHistoryRendersMultipleRowsAfterSeveralSc0000Messages()
{ {

View File

@@ -28,6 +28,7 @@ public sealed class VirtualMachine
private ExecFrame? _rawInputFrame; 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 _mouseWheelDelta;
private int _heldInputCallbackMask; private int _heldInputCallbackMask;
private int _queuedInputCallbackMask; private int _queuedInputCallbackMask;
private bool _autoMessageEnabled; private bool _autoMessageEnabled;
@@ -95,6 +96,14 @@ public sealed class VirtualMachine
/// <summary>Update one native mouse-button bit (left=0x1, right=0x2 in Himegari).</summary> /// <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); public void UpdateMouseButtonState(int bit, bool pressed) => UpdateMaskBit(ref _mouseButtonState, bit, pressed);
/// <summary>Accumulate a signed native mouse-wheel delta until op 0x10d consumes it.</summary>
public void QueueMouseWheelDelta(int delta)
{
if (delta == 0) return;
Interlocked.Add(ref _mouseWheelDelta, delta);
_host.WakeInputCallbackService();
}
/// <summary>Update one held AGE input-callback index used by ops 0xfb/0xff/0x100.</summary> /// <summary>Update one held AGE input-callback index used by ops 0xfb/0xff/0x100.</summary>
public void UpdateInputCallbackState(int index, bool pressed) public void UpdateInputCallbackState(int index, bool pressed)
{ {
@@ -725,6 +734,9 @@ public sealed class VirtualMachine
case "u00415E70": case "u00415E70":
case "get-mouse-button-state": // 0x108 case "get-mouse-button-state": // 0x108
Write(a[0], Volatile.Read(ref _mouseButtonState)); return pc + 1; 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 "u00415EC0":
case "get-cursor-virtual": // 0x109 case "get-cursor-virtual": // 0x109
{ {

View File

@@ -359,6 +359,18 @@ public partial class Main : Godot.Control
_vm.UpdatePointer(p.X, p.Y); _vm.UpdatePointer(p.X, p.Y);
return; 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 if (e is InputEventMouseButton mb
&& (mb.ButtonIndex == MouseButton.Left || mb.ButtonIndex == MouseButton.Right)) && (mb.ButtonIndex == MouseButton.Left || mb.ButtonIndex == MouseButton.Right))
{ {

View File

@@ -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.'), 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."), 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.'), 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."), 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.'), 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.'), 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.'),

View File

@@ -33,6 +33,11 @@ name = "message_skip_enabled"
type = "int" type = "int"
note = "persistent all-message Skip flag; op 0x88 writes it and adv_interpreter_tick injects input bit 0x40 while nonzero" note = "persistent all-message Skip flag; op 0x88 writes it and adv_interpreter_tick injects input bit 0x40 while nonzero"
[[field]] [[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 offset = 0x3028
name = "alt_pack_table" name = "alt_pack_table"
type = "int" type = "int"

View File

@@ -2463,18 +2463,18 @@ argc = 1
abi_source = "kelebek+decode-validated" abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "u00415F10" name = "consume-mouse-wheel-delta"
category = "unknown" category = "input"
summary = "" summary = "(out) - return the accumulated signed mouse-wheel delta and clear it."
noop_headless = false noop_headless = false
source = "kelebek" source = "investigation"
confidence = "low" confidence = "high"
depends_on = [] 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]] [[opcode.semantics.args]]
i = 1 i = 1
role = "" role = "accumulated signed delta out"
observed_types = ["l-int"] observed_types = ["l-int"]
[[opcode]] [[opcode]]