From 79b8d53fcc6cd94af5d1daedc5208f31098b36ca Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 29 Jul 2026 14:45:37 -0400 Subject: [PATCH] Implement system menu show delay opcodes --- docs/engine-ctx-reference.md | 1 + docs/engine-re.md | 41 +++++++++++ docs/opcode-reference.md | 23 +++--- docs/phase-b-framework.md | 34 +++++++++ .../SystemMenuShowDelayOpcodeTests.cs | 71 +++++++++++++++++++ engine/Age.Engine/Vm/VirtualMachine.cs | 13 ++++ vm-map/engine-ctx.toml | 5 ++ vm-map/opcodes.toml | 38 +++++----- 8 files changed, 201 insertions(+), 25 deletions(-) create mode 100644 engine/Age.Engine.Tests/SystemMenuShowDelayOpcodeTests.cs diff --git a/docs/engine-ctx-reference.md b/docs/engine-ctx-reference.md index d13472e..dd93f11 100644 --- a/docs/engine-ctx-reference.md +++ b/docs/engine-ctx-reference.md @@ -61,6 +61,7 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | `0x55100` | `adv_skip_service_enabled` | `int` | ADV presentation lifecycle gate: op 0x19b clears it while preserving the persistent Skip toggle; op 0x19c sets it before recomputing active message/read skip | | `0x55104` | `auto_message_enabled` | `int` | ADV Auto mode flag; op 0x1b6 reads, op 0x1b7 writes, adv_input_service_poll consumes | | `0x55110` | `text_history_recording_suppressed` | `uint` | high bit suppresses ADV retained-history index/text/metadata/voice recording; op 0x1bb writes 0x80000000 or zero, and HISTORY.BIN brackets itself with disable/enable | +| `0x5511c` | `system_menu_show_delay_ms` | `uint` | op 0x149 replaces the TIMER_SHOWMENU top-edge dwell threshold; reset defaults to 0 and SYSTEM4 sets 1000; op 0x148 reads it | | `0x55120` | `anti_tamper_a` | `int` | anti-tamper checksum operand | | `0x55124` | `anti_tamper_b` | `int` | anti-tamper checksum operand | | `0x5512c` | `anti_tamper_fp` | `int` | anti-tamper (import fn ptr / result) | diff --git a/docs/engine-re.md b/docs/engine-re.md index 0b5dc91..69b941c 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -933,6 +933,47 @@ an `IHost` service: the Godot frontend has no AGERC/native application menu to g as a game-input or persistence switch would broaden its native effect. `SystemMenuActionsEnabled` exposes the retained value for a future equivalent frontend menu. +### System-menu top-edge reveal delay — opcodes `0x148` / `0x149` (2026-07-29) + +`op_0x149_set_system_menu_show_delay@0x422050` replaces the complete dword at +`EngineCtx.system_menu_show_delay_ms` (`ctx+0x5511c`); the paired +`op_0x148_get_system_menu_show_delay@0x429c70` writes it to its output operand. Scene reset initializes +the field to zero. Himegari never calls the getter and has one setter site: SYSTEM4 writes 1000 +milliseconds immediately before the unrelated `0x248(128)` tiled-surface configuration. + +The only non-opcode consumer is `system_menu_top_edge_dwell_timer_tick@0x486280`, reached from the main +Win32 window procedure's `WM_TIMER` id 1 and repeated top-edge `WM_MOUSEMOVE` path. A pointer at client +y=0 or y=1 starts a 100-millisecond polling timer and captures `timeGetTime`; leaving those two rows +clears the timestamp and kills the timer. The tick reveals the menu only when: + +- unsigned elapsed time is strictly greater than `system_menu_show_delay_ms`; +- neither the left nor right mouse button is held; +- `display:ScreenMode` is windowed; and +- AGERC host command `-1` reports no active host UI. + +The unsigned comparison preserves the setter's complete dword: a negative script value becomes a very +long effective delay rather than being clamped. The independent Alt-key path calls the same reveal worker +immediately and does not consult this field. + +`system_menu_strip_show@0x407bd0` prepares/suspends presentation, marks `TIMER_SHOWMENU` active, and calls +AGERC host command 4. In the shipped DLL, `agerc_system_menu_strip_dialog_proc@0x10003c20` owns that +modeless top strip: it copies the AGE window's title and icon, sizes itself to the screen width and one +menu-bar height, refreshes the settings/save action state described for opcode `0x142`, and dispatches its +menu commands. `agerc_system_menu_strip_hide@0x10002a40` hides the retained window and clears AGERC bit +`0x10`, allowing a later dwell to reveal it again. + +The port has no equivalent host application-menu strip, so this is an isolated scene-context state slice: +retain the unsigned value, reset it to zero on root scene reload, expose the exact getter, and leave game +input, the script-owned right-click menu, and settings persistence unchanged. The handlers, timer/reveal +workers, EngineCtx field, AGERC dispatcher/dialog/hide workers, and key globals are named/commented in the +saved Ghidra programs. + +The VM implementation retains the field as a `uint`, sign-extends the getter's dword into the existing +script integer-cell representation, and restores zero in the opcode-`0x9` scene reset. It exposes +`SystemMenuShowDelayMilliseconds` for a future frontend consumer but deliberately makes no `IHost` call. +Focused coverage includes SYSTEM4's 1000-millisecond setting/getter pair, a high-bit dword round trip, and +root-reload lifetime. + The same route exposed an independent publication-atomicity requirement. Each tile step reaches `FIELD@0x4eb2 -> 0x9225`, which calls `DRAWMAP`, `DRAWOBJ`, and `DRAWMINIMAP`. `DRAWMAP` first erases its old terrain handle ranges and then rebuilds them; `DRAWMINIMAP` recreates mutable surfaces `0x42..0x44` diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 94b233c..f24c19e 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -1281,6 +1281,20 @@ LineEdit, keeps it open on either native validation error, and resumes the worke or cancel. Accept replaces operand 1 and cancel leaves it untouched; operand 2 is never modified. +### 0x148 `get-system-menu-show-delay` (get-system-menu-show-delay, argc 1) +- **summary:** (out) - return the current unsigned TIMER_SHOWMENU top-edge dwell threshold paired with opcode 0x149. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x148_get_system_menu_show_delay@0x429c70 writes EngineCtx+0x5511c to operand 1. Scene reset initializes the field to 0, opcode 0x149 replaces it, and the windowed top-edge TIMER_SHOWMENU path consumes it as an unsigned millisecond threshold. This getter is not observed in Himegari's corpus. + +Port status (2026-07-29): implemented with opcode 0x149. The getter writes the retained dword through the VM's signed integer-cell representation so high-bit values preserve the same native 32 bits. + +### 0x149 `set-system-menu-show-delay` (set-system-menu-show-delay, argc 1) +- **summary:** (milliseconds) - replace the unsigned dwell threshold before pointer hover at the top two client rows reveals AGERC's modeless system-menu strip. Scene reset defaults to 0; SYSTEM4 sets 1000. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x149_set_system_menu_show_delay@0x422050 directly stores operand 1 at EngineCtx+0x5511c; scene_context_init_reset writes 0 and paired op_0x148_get_system_menu_show_delay@0x429c70 reads it. system_menu_top_edge_dwell_timer_tick@0x486280 compares the field unsigned against timeGetTime elapsed while WM_MOUSEMOVE is in client y=0..1, then requires no left/right button, windowed ScreenMode, and g_pfnAgercHostCallback(-1)==0 before system_menu_strip_show@0x407bd0 invokes AGERC command 4. The handler uses the complete dword. SYSTEM4's sole site sets 1000 immediately before the unrelated tiled-surface edge setting. + +Port status (2026-07-29): implemented as unsigned scene-context state. The setter preserves all 32 operand bits, root scene reload restores zero, and the current Godot frontend deliberately adds no host effect because it has no AGERC application-menu strip. + ### 0x19a `get-message-skip` (u00414E50, argc 1) - **summary:** (out) - return the current all-message skip state set by op 0x88. - **grounding:** source=investigation, confidence=high @@ -1998,15 +2012,6 @@ Port status (2026-07-24): implemented through the same profile-lifetime setting - **grounding:** source=kelebek, confidence=low - **evidence:** Not observed in Himegari's script corpus; ABI label/argc come from Kelebek's AGE table. -### 0x148 `u004160A0` (u004160A0, argc 1) -- **summary:** Broader AGE-catalog compatibility stub; the port currently traces and skips it. -- **grounding:** source=kelebek, confidence=low -- **evidence:** Not observed in Himegari's script corpus; ABI label/argc come from Kelebek's AGE table. - -### 0x149 `u0041FCE0` (u0041FCE0, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x14a `u0041FD10` (u0041FD10, argc 7) - **summary:** Broader AGE-catalog compatibility stub; the port currently traces and skips it. - **grounding:** source=kelebek, confidence=low diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 563a472..8e4deb9 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -1011,6 +1011,40 @@ zero-warning Godot build, clean diff checking, and the Himegari-targeted threade **NEXT:** rerank the final four gaps and investigate SYSTEM4's singleton `0x149`; keep developer-only `0x24d` for its dedicated movie-mask compositor slice. +**SYSTEM-MENU SHOW DELAY `0x149` INVESTIGATION COMPLETE (2026-07-29):** the opcode replaces the unsigned +millisecond threshold at `EngineCtx.system_menu_show_delay_ms`; scene reset defaults it to zero and +SYSTEM4's sole call sets 1000. The exact, unobserved paired getter is opcode `0x148`. + +AGE's Win32 window procedure starts a 100-millisecond timer while the pointer dwells in the top two client +rows. Once unsigned elapsed time is strictly greater than the configured threshold, with no mouse button +held, windowed ScreenMode, and no active AGERC host UI, it reveals AGERC's modeless full-width system-menu +strip. Alt reveals the same strip immediately and bypasses the threshold. This is the same settings/save +menu whose actions opcode `0x142` guards against CONFIG reentrancy, but it is unrelated to the adjacent +`0x248` graphics configuration. + +The current Godot frontend has no equivalent application-menu strip. The bounded implementation is exact +state parity: retain the full unsigned dword, reset it to zero with scene context, implement the paired +getter, and expose it for a future host strip without changing game input, the script-authored right-click +menu, or persistence. This investigation changes metadata only, so the inventory remains 4 opcodes / +5 instructions. The `/v2` and `/support/AGERC.DLL` handlers, reveal/timer/dialog paths, typed EngineCtx +field, and key globals are annotated and saved. + +**NEXT:** implement the exact `0x148`/`0x149` state pair. Afterwards rerank `0x137`, `0x241`, and the two +developer-only `0x24d` sites. + +**SYSTEM-MENU SHOW DELAY `0x148`/`0x149` IMPLEMENTED (2026-07-29):** the VM now retains the complete +unsigned threshold, returns the same native dword through signed script integer cells, and restores zero +on root scene-context reload. SYSTEM4's 1000-millisecond setup is handled without inventing an `IHost` +effect for an AGERC strip the Godot frontend does not have. + +Three focused regressions cover the exact SYSTEM4 setter/getter sequence, high-bit dword preservation, and +root-reload lifetime. The shipped setter site is closed; the unobserved getter is also available for +broader AGE compatibility. The remaining effectful inventory is 3 opcodes / 4 instructions. Validation +passes 501/501 engine tests, opcode and EngineCtx build/lint, a zero-warning Godot build, clean diff +checking, and the Himegari-targeted threaded `SELFTEST OK`. + +**NEXT:** rerank `0x137`, `0x241`, and developer-only `0x24d` ×2 before choosing the next slice. + ## Later Phase B breadth **INIT data-semantics side track started (2026-07-22).** Before naming more gameplay state, the static diff --git a/engine/Age.Engine.Tests/SystemMenuShowDelayOpcodeTests.cs b/engine/Age.Engine.Tests/SystemMenuShowDelayOpcodeTests.cs new file mode 100644 index 0000000..7859367 --- /dev/null +++ b/engine/Age.Engine.Tests/SystemMenuShowDelayOpcodeTests.cs @@ -0,0 +1,71 @@ +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; + +public class SystemMenuShowDelayOpcodeTests +{ + private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson); + private static Operand I(long value) => new(0, value); + private static Operand G(long address) => new(3, address); + private static (int, Operand[]) Exit() => (0x2, Array.Empty()); + + [Fact] + public void System4DelayRoundTripsThroughThePairedGetter() + { + Script system4 = ScriptAssembler.Assemble(Table, "SYSTEM4.BIN", + [ + (0x149, [I(1000)]), + (0x148, [G(0x100)]), + Exit(), + ], []); + var vm = new VirtualMachine(system4, Table, new RecordingHost()); + + Assert.Equal(0u, vm.SystemMenuShowDelayMilliseconds); + + vm.Run(); + + Assert.Equal(1000u, vm.SystemMenuShowDelayMilliseconds); + Assert.Equal(1000, vm.Globals[0x100]); + } + + [Fact] + public void SetterAndGetterPreserveTheCompleteNativeDword() + { + Script system4 = ScriptAssembler.Assemble(Table, "SYSTEM4.BIN", + [ + (0x149, [I(0x80000001L)]), + (0x148, [G(0x100)]), + Exit(), + ], []); + var vm = new VirtualMachine(system4, Table, new RecordingHost()); + + vm.Run(); + + Assert.Equal(0x80000001u, vm.SystemMenuShowDelayMilliseconds); + Assert.Equal(unchecked((int)0x80000001u), vm.Globals[0x100]); + } + + [Fact] + public void RootSceneReloadRestoresTheZeroDefault() + { + Script reloadedSystem4 = ScriptAssembler.Assemble(Table, "SYSTEM4.BIN", + [ + (0x148, [G(0x100)]), + Exit(), + ], []); + Script initial = ScriptAssembler.Assemble(Table, "INITIAL", + [ + (0x149, [I(1000)]), + (0x9, Array.Empty()), + ], []); + var provider = new MapProvider(new Dictionary { [0] = reloadedSystem4 }); + var host = new RecordingHost(); + var vm = new VirtualMachine(initial, Table, host, provider: provider); + + vm.Run(); + + Assert.Equal(0u, vm.SystemMenuShowDelayMilliseconds); + Assert.Equal(0, vm.Globals[0x100]); + Assert.Equal(1, host.SceneContextResets); + } +} diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 5bfad7b..2a48759 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -91,6 +91,9 @@ public sealed class VirtualMachine // EngineCtx +0xa0d10: AGERC queries this through IAGEService to gray its native // settings/save menu actions while CONFIG owns the scripted settings screen. private int _systemMenuActionsEnabled = 1; + // EngineCtx +0x5511c: unsigned TIMER_SHOWMENU dwell threshold. Native op 0x148 + // returns the same dword through the VM's signed integer-cell representation. + private uint _systemMenuShowDelayMilliseconds; private readonly Dictionary _valueSwitchTargets = new(StringComparer.Ordinal); // Native EngineCtx owns 11 lazily allocated integer FIFOs at +0x55130. ATSEEK/MVSEEK use // slot zero as their packed-coordinate flood-fill worklist; op 0x132 replaces a slot. @@ -115,6 +118,7 @@ public sealed class VirtualMachine public bool AutoMessageEnabled => _autoMessageEnabled; public bool MessageSkipEnabled => _messageSkipEnabled; public int SystemMenuActionsEnabled => _systemMenuActionsEnabled; + public uint SystemMenuShowDelayMilliseconds => _systemMenuShowDelayMilliseconds; public string PendingDiagnosticText => _diagnosticOutput.PendingText; /// /// Zero-based active-frame cutoff selected by opcode 0x1ad, or null when no surviving marker @@ -786,6 +790,7 @@ public sealed class VirtualMachine _advReadSkipState = false; _advTextStyle = AdvTextStyle.Default; _systemMenuActionsEnabled = 1; + _systemMenuShowDelayMilliseconds = 0; TextHistory.SetRecordingEnabled(true); _host.SetMessageSkipActive(false); _host.SetPhysicalMessageSkipActive(false); @@ -2343,6 +2348,14 @@ public sealed class VirtualMachine case "u0041FB10": // pre-reference compatibility _systemMenuActionsEnabled = unchecked((int)Read(a[0])); return pc + 1; + case "get-system-menu-show-delay": // 0x148: paired TIMER_SHOWMENU getter + case "u004160A0": // pre-reference compatibility + Write(a[0], unchecked((int)_systemMenuShowDelayMilliseconds)); + return pc + 1; + case "set-system-menu-show-delay": // 0x149: top-edge dwell threshold in milliseconds + case "u0041FCE0": // pre-reference compatibility + _systemMenuShowDelayMilliseconds = unchecked((uint)Read(a[0])); + return pc + 1; case "get-message-glyph-delay": // 0x7f case "u00414C60": Write(a[0], _messageGlyphDelayMilliseconds); return pc + 1; diff --git a/vm-map/engine-ctx.toml b/vm-map/engine-ctx.toml index a4dd887..fdd6b43 100644 --- a/vm-map/engine-ctx.toml +++ b/vm-map/engine-ctx.toml @@ -283,6 +283,11 @@ name = "text_history_recording_suppressed" type = "uint" note = "high bit suppresses ADV retained-history index/text/metadata/voice recording; op 0x1bb writes 0x80000000 or zero, and HISTORY.BIN brackets itself with disable/enable" [[field]] +offset = 0x5511c +name = "system_menu_show_delay_ms" +type = "uint" +note = "op 0x149 replaces the TIMER_SHOWMENU top-edge dwell threshold; reset defaults to 0 and SYSTEM4 sets 1000; op 0x148 reads it" +[[field]] offset = 0x55120 name = "anti_tamper_a" type = "int" diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index a5ad8c8..4542c98 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -3054,23 +3054,24 @@ observed_types = ["l-str"] [[opcode]] op = 0x149 -label = "u0041FCE0" +label = "set-system-menu-show-delay" argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041FCE0" -category = "unknown" -summary = "" +name = "set-system-menu-show-delay" +category = "input" +summary = "(milliseconds) - replace the unsigned dwell threshold before pointer hover at the top two client rows reveals AGERC's modeless system-menu strip. Scene reset defaults to 0; SYSTEM4 sets 1000." +details = "Port status (2026-07-29): implemented as unsigned scene-context state. The setter preserves all 32 operand bits, root scene reload restores zero, and the current Godot frontend deliberately adds no host effect because it has no AGERC application-menu strip." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x149_set_system_menu_show_delay@0x422050 directly stores operand 1 at EngineCtx+0x5511c; scene_context_init_reset writes 0 and paired op_0x148_get_system_menu_show_delay@0x429c70 reads it. system_menu_top_edge_dwell_timer_tick@0x486280 compares the field unsigned against timeGetTime elapsed while WM_MOUSEMOVE is in client y=0..1, then requires no left/right button, windowed ScreenMode, and g_pfnAgercHostCallback(-1)==0 before system_menu_strip_show@0x407bd0 invokes AGERC command 4. The handler uses the complete dword. SYSTEM4's sole site sets 1000 immediately before the unrelated tiled-surface edge setting." [[opcode.semantics.args]] i = 1 -role = "" +role = "unsigned top-edge system-menu dwell threshold in milliseconds" observed_types = ["imm"] [[opcode]] @@ -8831,20 +8832,25 @@ evidence = "Not observed in Himegari's script corpus; ABI label/argc come from K [[opcode]] op = 0x148 -label = "u004160A0" +label = "get-system-menu-show-delay" argc = 1 observed_in_himegari = false -abi_source = "kelebek" +abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u004160A0" -category = "unknown" -summary = "Broader AGE-catalog compatibility stub; the port currently traces and skips it." +name = "get-system-menu-show-delay" +category = "input" +summary = "(out) - return the current unsigned TIMER_SHOWMENU top-edge dwell threshold paired with opcode 0x149." +details = "Port status (2026-07-29): implemented with opcode 0x149. The getter writes the retained dword through the VM's signed integer-cell representation so high-bit values preserve the same native 32 bits." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "Not observed in Himegari's script corpus; ABI label/argc come from Kelebek's AGE table." +evidence = "Ghidra /v2: op_0x148_get_system_menu_show_delay@0x429c70 writes EngineCtx+0x5511c to operand 1. Scene reset initializes the field to 0, opcode 0x149 replaces it, and the windowed top-edge TIMER_SHOWMENU path consumes it as an unsigned millisecond threshold. This getter is not observed in Himegari's corpus." + +[[opcode.semantics.args]] +i = 1 +role = "out: unsigned top-edge system-menu dwell threshold in milliseconds" [[opcode]] op = 0x14a