diff --git a/docs/engine-re.md b/docs/engine-re.md index c8439a6..c432144 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -2727,6 +2727,25 @@ deferred ReadTextDB work should remain a seam rather than a storage dependency. names/comments the cursor, callback dispatch, retained-history navigation/render/metadata, and history-voice opcode paths. +### Menu default cursor auto-move (2026-08-16) + +The menu option stored in `G[0x3238]` is `menu_cursor_auto_move_enabled`. `INITCONFIG.BIN` defaults it to +one and `CONFIG.BIN` writes the paired enabled/disabled values. At `BUNKI.BIN@0x2b6` and +`SBUNKI.BIN@0x289`, an enabled value makes the script compute a point in the default choice row and invoke +`set-cursor-virtual` (`0x10a`); the disabled path instead samples the existing physical position through +`get-cursor-virtual` (`0x109`). `BUNKIMOVE` and `SBUNKIMOVE` use the same toggle while animating the menu. +The highlighted default row and the physical pointer are therefore two consequences of the same script +path, not one frontend-owned selection operation. + +The `/v2` handler `op_0x10a_set_cursor_virtual@0x421590` converts the client origin to screen space, +applies active VirtualFullScreen scale/letterbox geometry, and calls USER32 `SetCursorPos`. The port now +preserves its immediate virtual-pointer update and also forwards a cursor-warp request to the interactive +host. Godot marshals that request onto its main thread, inversely maps the 800x600 AGE coordinate into the +current viewport, and calls `Viewport.WarpMouse`. Godot supports that operation on the port's Windows, +macOS, and Linux desktop targets; it is documented as a no-op on Android, iOS, and Web, where the VM's +logical position still remains correct. A synthetic opcode regression covers both the VM position and host +request. The Ghidra handler annotation was updated and the `/v2` program saved on 2026-08-16. + ### ADV Hide Window implementation (2026-07-18) The x=772 callback now follows the original control flow rather than a Godot-only visibility shortcut. diff --git a/docs/global-reference.md b/docs/global-reference.md index eea3075..526daf1 100644 --- a/docs/global-reference.md +++ b/docs/global-reference.md @@ -3730,6 +3730,7 @@ | `0x6cb` | adv_hover_message_skip | high | investigation | Pointer-hover flag for the standard ADV all-message Skip button at (728,572). See adv_hover_history. | | `0x6cc` | adv_hover_read_message_skip | high | investigation | Pointer-hover flag for the standard ADV read-message-only Skip button at (750,572). See adv_hover_history. | | `0x6cd` | adv_hover_hide_window | high | investigation | Pointer-hover flag for the standard ADV Hide-window button at (772,572). See adv_hover_history. | +| `0x3238` | menu_cursor_auto_move_enabled | high | investigation | Persistent game option controlling whether a newly opened BUNKI/SBUNKI choice menu moves the physical cursor to its default row. INITCONFIG defaults it to 1; CONFIG writes 1/0 for the paired choices. BUNKI@0x2b6 and SBUNKI@0x289 call set-cursor-virtual when enabled and sample the existing cursor with get-cursor-virtual when disabled; BUNKIMOVE/SBUNKIMOVE use the same toggle during menu entrance motion. Not a scene story flag. | | `0x62425` | adv_hide_window_enabled | high | investigation | Native ADV-scheduler permission for the standard Hide Window action. After op 0x199 enters the registered yield-A handler, every standard ADV scene calls HIDEWIN.BIN only while this value is nonzero. No script writes it and the complete boot-to-SC0000 VM-write capture does not contain it, so it is native-owned inherited state rather than saved-game or script boot data. The Godot scene bootstrap mirrors the original enabled value 1. | | `0x15a096` | information_message_handled | high | investigation | INFOMES clears this before walking handler rows and stops when it becomes one. CIMES, EIMES, and VIMES set it after rendering a matching character, enemy, or glossary message, implementing a first-handler-wins extension chain. | @@ -3737,7 +3738,6 @@ | address | name | conf | source | usage | |---|---|---|---|---| -| `0x3238` | — | low | inference | Config/settings global (CONFIG.BIN/INITCONFIG.BIN writer, scene-reach 0) — NOT a scene story flag; miner over-tagged it. Branch-read in 5 scripts; compared against [1]. | | `0x3301` | — | low | inference | Config/settings global (INITCONFIG.BIN writer, scene-reach 0) — NOT a scene story flag; miner over-tagged it. Branch-read in 6 scripts; compared against [1, 2]. | | `0x3303` | — | low | inference | Config/settings global (CONFIG.BIN/INITCONFIG.BIN writer, scene-reach 0) — NOT a scene story flag; miner over-tagged it. Branch-read in 8 scripts; compared against [1]. | | `0x3304` | — | low | inference | Config/settings global (CONFIG.BIN/INITCONFIG.BIN writer, scene-reach 0) — NOT a scene story flag; miner over-tagged it. Branch-read in 13 scripts; compared against [1]. | diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index d54a124..6994322 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -1282,7 +1282,7 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " ### 0x10a `set-cursor-virtual` (u0041E540, argc 2) - **summary:** (x)(y) - convert AGE virtual-screen coordinates to client/screen coordinates and move the OS cursor. - **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 USER32 SetCursorPos. BUNKI@0x2b6 and SBUNKI@0x289 test G[0x3238], compute the default choice point, and call 0x10a when menu cursor auto-move is enabled; their disabled branches retain the physical position through 0x109. SC0000 also alternates the cursor by one vertical pixel after state-changing ADV button clicks so hover re-enters cleanly. ### 0x10b `map-mouse-button` (u0041E5A0, argc 2) - **summary:** (button_slot)(physical_button) - map a physical mouse button to a logical button slot; polling emits the slot as logical action slot+4. diff --git a/engine/Age.Engine.Tests/InputBindingTests.cs b/engine/Age.Engine.Tests/InputBindingTests.cs index b026674..0c1c35d 100644 --- a/engine/Age.Engine.Tests/InputBindingTests.cs +++ b/engine/Age.Engine.Tests/InputBindingTests.cs @@ -47,6 +47,25 @@ public class InputBindingTests Assert.NotEqual(0, vm.InputBindings.JoystickButtonActionMask(3) & (1 << 4)); } + [Fact] + public void SetCursorVirtualUpdatesVmCoordinatesAndRequestsAHostWarp() + { + var script = ScriptAssembler.Assemble(Table, "CURSOR_WARP", new List<(int, Operand[])> + { + (0x10a, new[] { I(321), I(234) }), + (0x109, new[] { G(0x710), G(0x711) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var host = new RecordingHost(); + var vm = new VirtualMachine(script, Table, host); + + vm.Run(); + + Assert.Equal((321, 234), Assert.Single(host.CursorWarps)); + Assert.Equal(321, vm.Globals.GetValueOrDefault(0x710)); + Assert.Equal(234, vm.Globals.GetValueOrDefault(0x711)); + } + [Fact] [Trait("Category", "Workspace")] public void System4BootstrapReplaysAllSixteenInputConfigurationCalls() diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index a0d5005..c7af693 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -76,6 +76,7 @@ internal class RecordingHost : IHost public readonly List FullwidthTextEdits = new(); public System.Func? OnFullwidthTextEdit; public readonly List CursorResources = new(); + public readonly List<(int X, int Y)> CursorWarps = new(); public readonly List AdvPagePresentationSuspended = new(); public readonly List<(AdvLiveTextRun Run, int GlyphDelayMilliseconds)> LiveTextRuns = new(); public readonly Dictionary SurfacePixels = new(); @@ -176,6 +177,7 @@ internal class RecordingHost : IHost public virtual long InputClockMilliseconds => Environment.TickCount64; public void SetCursorResource(long resourceId) => CursorResources.Add(resourceId); public void ClearCursorResource() => CursorClearCount++; + public void WarpCursor(int virtualX, int virtualY) => CursorWarps.Add((virtualX, virtualY)); public virtual void Sleep(long duration) => SleptDurations.Add(duration); public virtual void WaitForTimedCallbackDeadline(long duration) { diff --git a/engine/Age.Engine/Hosting/IInputHost.cs b/engine/Age.Engine/Hosting/IInputHost.cs index db03e0f..5056a98 100644 --- a/engine/Age.Engine/Hosting/IInputHost.cs +++ b/engine/Age.Engine/Hosting/IInputHost.cs @@ -32,6 +32,7 @@ public interface IInputHost void SetCursorResource(long resourceId) { } void ClearCursorResource() { } + void WarpCursor(int virtualX, int virtualY) { } // Native 0x1c7/0x1cc query two distinct ADV skip channels. Headless and non-interactive // hosts default to normal playback; the Godot host supplies the live interactive values. diff --git a/engine/Age.Engine/Vm/VirtualMachine.Input.cs b/engine/Age.Engine/Vm/VirtualMachine.Input.cs index 83b64e8..8a8fa80 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.Input.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.Input.cs @@ -159,8 +159,14 @@ public sealed partial class VirtualMachine return pc + 1; } case "u0041E540": - case "set-cursor-virtual": // 0x10a; retain the virtual position even without OS warping - UpdatePointer((int)Read(a[0]), (int)Read(a[1])); return pc + 1; + case "set-cursor-virtual": // 0x10a: update AGE state immediately, then request the native warp + { + int x = (int)Read(a[0]); + int y = (int)Read(a[1]); + UpdatePointer(x, y); + _host.WarpCursor(x, y); + return pc + 1; + } case "u0041E5A0": case "map-mouse-button": // 0x10b: physical button -> slot, polled action is slot+4 InputBindings.MapMouseButton(unchecked((int)Read(a[0])), unchecked((int)Read(a[1]))); diff --git a/godot/GodotAdvHost.PresentationInput.cs b/godot/GodotAdvHost.PresentationInput.cs index 291eed3..ed77584 100644 --- a/godot/GodotAdvHost.PresentationInput.cs +++ b/godot/GodotAdvHost.PresentationInput.cs @@ -482,6 +482,13 @@ public sealed partial class GodotAdvHost _timeline?.Event("cursor-clear", new()); } + public void WarpCursor(int virtualX, int virtualY) + { + // Script execution is on the VM thread; Godot input/display APIs belong to the main thread. + _main.CallDeferred("WarpAgeCursor", virtualX, virtualY); + _timeline?.Event("cursor-warp", new() { ["x"] = virtualX, ["y"] = virtualY }); + } + public void WaitForForegroundTransition(GfxState gfx) { _foregroundTransitionWaitBypassed = false; diff --git a/godot/Main.Input.cs b/godot/Main.Input.cs index 6f6f07f..62744ae 100644 --- a/godot/Main.Input.cs +++ b/godot/Main.Input.cs @@ -256,6 +256,17 @@ public partial class Main (int)System.Math.Floor(position.Y * _screenHeight / size.Y)); } + public void WarpAgeCursor(int virtualX, int virtualY) + { + // Viewport.WarpMouse expects viewport coordinates. This is the inverse of ToNativeScreen and + // lets Godot account for the platform window origin before moving the desktop pointer. + Vector2 size = GetViewport().GetVisibleRect().Size; + if (size.X <= 0 || size.Y <= 0 || _screenWidth <= 0 || _screenHeight <= 0) return; + GetViewport().WarpMouse(new Vector2( + virtualX * size.X / _screenWidth, + virtualY * size.Y / _screenHeight)); + } + public void SetAgeCursor(byte[] rgba, int width, int height, int hotspotX, int hotspotY) { var image = Image.CreateFromData(width, height, false, Image.Format.Rgba8, rgba); diff --git a/vm-map/globals.toml b/vm-map/globals.toml index 70a48d7..541da1b 100644 --- a/vm-map/globals.toml +++ b/vm-map/globals.toml @@ -2419,13 +2419,13 @@ depends_on = ["0x3234"] [[global]] address = "0x3238" -name = "" -category = "unknown" +name = "menu_cursor_auto_move_enabled" +category = "ui-toggle" type = "int" value_domain = "{0,1}" -usage = "Config/settings global (CONFIG.BIN/INITCONFIG.BIN writer, scene-reach 0) — NOT a scene story flag; miner over-tagged it. Branch-read in 5 scripts; compared against [1]." -source = "inference" -confidence = "low" +usage = "Persistent game option controlling whether a newly opened BUNKI/SBUNKI choice menu moves the physical cursor to its default row. INITCONFIG defaults it to 1; CONFIG writes 1/0 for the paired choices. BUNKI@0x2b6 and SBUNKI@0x289 call set-cursor-virtual when enabled and sample the existing cursor with get-cursor-virtual when disabled; BUNKIMOVE/SBUNKIMOVE use the same toggle during menu entrance motion. Not a scene story flag." +source = "investigation" +confidence = "high" depends_on = [] [[global]] diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 56472cb..eea3775 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -2426,7 +2426,7 @@ noop_headless = false source = "investigation" confidence = "high" depends_on = [] -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 USER32 SetCursorPos. BUNKI@0x2b6 and SBUNKI@0x289 test G[0x3238], compute the default choice point, and call 0x10a when menu cursor auto-move is enabled; their disabled branches retain the physical position through 0x109. SC0000 also alternates the cursor by one vertical pixel after state-changing ADV button clicks so hover re-enters cleanly." [[opcode.semantics.args]] i = 1