Implement system menu show delay opcodes

This commit is contained in:
gamer147
2026-07-29 14:45:37 -04:00
parent 2065a1c504
commit 2993962307
8 changed files with 201 additions and 25 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Operand>());
[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<Operand>()),
], []);
var provider = new MapProvider(new Dictionary<long, Script> { [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);
}
}

View File

@@ -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<string, int> _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;
/// <summary>
/// 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;

View File

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

View File

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