diff --git a/docs/engine-ctx-reference.md b/docs/engine-ctx-reference.md index c9c359e..d13472e 100644 --- a/docs/engine-ctx-reference.md +++ b/docs/engine-ctx-reference.md @@ -123,3 +123,4 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | `0xa0cc8` | `screen_bpp` | `int` | screen bpp (8) | | `0xa0ce4` | `run_state_flags` | `uint` | interpreter run-state flags (bit1 sleeping; 0x8000000 skip/fast-forward) | | `0xa0ce8` | `timed_callback_abort_signal` | `int` | scheduler-polled abort/input signal cleared when op 0xd5 starts; redirects to abort PC when configured | +| `0xa0d10` | `system_menu_actions_enabled` | `int` | op 0x142 replaces this AGERC/IAGEService reentrancy guard; scene reset defaults it to 1, while CONFIG brackets its scripted settings UI with 0 then 1 | diff --git a/docs/engine-re.md b/docs/engine-re.md index 6ee39f5..0b5dc91 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -903,6 +903,36 @@ backing's earlier one-shot-color provenance, so classifying only animated or loo rebuilt message window opaque. Post-bind static alpha now controls this fresh backing as well; repeated CONFIG changes therefore take effect immediately while the enclosing ADV wait remains active. +### Native system-menu reentrancy guard — opcode `0x142` (2026-07-29) + +`op_0x142_set_system_menu_actions_enabled@0x421fe0` is a direct one-operand setter for +`EngineCtx.system_menu_actions_enabled` (`ctx+0xa0d10`). `scene_context_init_reset@0x40b3b0` initializes +the field to one. CONFIG.BIN contains both shipped calls: `0x142(0)` is its first instruction, before any +settings data or UI is built, and `0x142(1)` runs after its last optional callback and immediately before +retained-object/surface/SFX teardown and exit. + +The field is not consumed by the interpreter, renderer, gameplay logic, or persistence. AGE exposes it +through `AGE:IAGEService` virtual method `iage_service_query_system_menu_enabled@0x45f7c0` (vtable offset +`+0xb4`), which returns whether the field is nonzero. The shipped AGERC.DLL obtains that interface through +`GetClassObject("AGE:IAGEService")`. Its `agerc_refresh_system_menu_state@0x10002e10` calls the virtual +getter after refreshing the native menu's settings. A zero result applies `MF_GRAYED` through +`EnableMenuItem` to command ids `0x9c63`, `0x9c64`, `0x9c65`, `0x9c68`, `0x9c6c`, `0x9c6e`, and +`0x9c6f`; those entries launch settings/save-related callbacks and modal services that could otherwise +reenter configuration while CONFIG already owns the scripted settings UI. + +The opcode is therefore a host-menu availability guard, not a configuration-persistence switch or input +lock for the game itself. A portable frontend without AGE's native AGERC menu can retain the flag for +lifetime/state parity and gate any future equivalent menu, with no immediate visual or headless effect. +It is an isolated implementation slice and has no dependency on the remaining opcode gaps. The handler, +IAGEService getter, AGERC menu refresher, and typed EngineCtx field are annotated in the saved Ghidra +programs. + +The port retains the complete signed dword on `VirtualMachine`, initializes it to one, replaces it on each +`0x142` call, and restores one during opcode-`0x9` root scene-context reset. It deliberately does not call +an `IHost` service: the Godot frontend has no AGERC/native application menu to gray, and treating the flag +as a game-input or persistence switch would broaden its native effect. `SystemMenuActionsEnabled` exposes +the retained value for a future equivalent frontend menu. + 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 4181686..94b233c 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -1258,6 +1258,13 @@ 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_0x12e_find_hit_rectangle@0x428ff0 resolves the reference rectangle and three array operands as VM pointers, decodes rectangle/offset values with anti_tamper_a, and starts at incoming_index+1. Each candidate is [left,right,top,bottom]; the worker subtracts its per-entry x/y offsets from the pointer, inclusively intersects it with the reference rectangle, and writes the matched array index or -1 to operand 1. HISTORY.BIN uses decoded local arrays for scrollbar/control regions, close region 8, and visible text rows 9..13. +### 0x142 `set-system-menu-enabled` (set-system-menu-enabled, argc 1) +- **summary:** (enabled) - replace the EngineCtx flag exposed through IAGEService to gate reentrant native AGERC system-menu actions. Scene reset defaults it to 1; CONFIG writes 0 on entry and 1 immediately before teardown and exit. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x142_set_system_menu_actions_enabled@0x421fe0 directly stores operand 1 at EngineCtx+0xa0d10. scene_context_init_reset initializes the field to 1. iage_service_query_system_menu_enabled@0x45f7c0 exposes it as a boolean through the AGE:IAGEService vtable at +0xb4. Shipped /support/AGERC.DLL's agerc_refresh_system_menu_state@0x10002e10 calls that method; zero invokes EnableMenuItem(...,MF_GRAYED) for command ids 0x9c63/64/65/68/6c/6e/6f. CONFIG.BIN's only sites bracket its complete scripted settings UI with 0 then 1. + +Port status (2026-07-29): implemented as an EngineCtx-lifetime signed-dword field on VirtualMachine. Root scene reload restores one. The current Godot frontend has no AGERC native system menu, so the value is retained for exact state parity and a future equivalent menu consumer without affecting game input or persisted settings. + ### 0x144 `edit-fullwidth-string-dialog` (edit-fullwidth-string-dialog, argc 2) - **summary:** (result_inout)(initial_text) - synchronously open AGERC's modal full-width text editor. Accept writes at most eight valid CP932 double-byte characters to operand 1; cancel leaves operand 1 unchanged. Operand 2 supplies the initial edit text and is preserved. - **grounding:** source=investigation, confidence=high @@ -1976,10 +1983,6 @@ Port status (2026-07-24): implemented through the same profile-lifetime setting - **summary:** — - **grounding:** source=kelebek, confidence=med -### 0x142 `u0041FB10` (u0041FB10, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x145 `u00416040` (u00416040, argc 1) - **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 c95ad1f..563a472 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -982,6 +982,35 @@ build, clean diff checking, and the Himegari-targeted threaded `SELFTEST OK`. **NEXT:** investigate CONFIG's two-site `0x142`; keep developer-only `0x24d` for a later dedicated movie-mask compositor slice. +**SYSTEM-MENU GUARD `0x142` INVESTIGATION COMPLETE (2026-07-29):** the opcode directly replaces +`EngineCtx.system_menu_actions_enabled`. Scene reset defaults it to one. CONFIG's first instruction writes +zero; its second and final call restores one immediately before UI/surface/SFX teardown and exit. + +AGE exposes the field through `AGE:IAGEService` virtual offset `+0xb4`. The shipped AGERC menu refresher +calls it and, when false, grays seven settings/save-related native menu actions so they cannot reenter +configuration while CONFIG owns the scripted settings screen. No interpreter, renderer, gameplay, or save +consumer reads the field. The port has no AGERC native menu, so the exact implementation is a small +EngineCtx-lifetime boolean retained for parity and available to gate any future host equivalent; it should +not block game input or change persisted options. The remaining inventory stays 5 opcodes / 7 instructions +until implementation. + +**NEXT:** implement `0x142` as the isolated system-menu availability guard. Afterwards rerank the four +remaining opcodes, with SYSTEM4 singleton `0x149` the likely next native investigation. + +**SYSTEM-MENU GUARD `0x142` IMPLEMENTED (2026-07-29):** the VM now retains AGE's complete signed-dword +field, starts it at one, replaces it on every call, and restores one on root scene-context reload. +CONFIG's exact `0 -> 1` bracket is handled without routing the flag into game input, options persistence, +or a synthetic host service. The public retained value is available if a future frontend grows an +equivalent application menu. + +Three focused regressions cover the native default and signed replacement, CONFIG's exact pair, and +root-reload lifetime. Both shipped sites are closed, reducing the remaining effectful inventory to +4 opcodes / 5 instructions. Validation passes 498/498 engine tests, opcode and EngineCtx build/lint, a +zero-warning Godot build, clean diff checking, and the Himegari-targeted threaded `SELFTEST OK`. + +**NEXT:** rerank the final four gaps and investigate SYSTEM4's singleton `0x149`; keep developer-only +`0x24d` for its dedicated movie-mask compositor 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/SystemMenuGuardOpcodeTests.cs b/engine/Age.Engine.Tests/SystemMenuGuardOpcodeTests.cs new file mode 100644 index 0000000..d02a076 --- /dev/null +++ b/engine/Age.Engine.Tests/SystemMenuGuardOpcodeTests.cs @@ -0,0 +1,66 @@ +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; + +public class SystemMenuGuardOpcodeTests +{ + private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson); + private static Operand I(long value) => new(0, value); + private static (int, Operand[]) Exit() => (0x2, Array.Empty()); + + [Fact] + public void SetterDefaultsEnabledAndReplacesTheCompleteNativeDword() + { + Script config = ScriptAssembler.Assemble(Table, "CONFIG.BIN", + [ + (0x142, [I(0)]), + (0x142, [I(-7)]), + Exit(), + ], []); + var vm = new VirtualMachine(config, Table, new RecordingHost()); + + Assert.Equal(1, vm.SystemMenuActionsEnabled); + + vm.Run(); + + Assert.Equal(-7, vm.SystemMenuActionsEnabled); + } + + [Fact] + public void ConfigEntryAndExitPairRestoresNativeMenuActions() + { + Script config = ScriptAssembler.Assemble(Table, "CONFIG.BIN", + [ + (0x142, [I(0)]), + (0x142, [I(1)]), + Exit(), + ], []); + var vm = new VirtualMachine(config, Table, new RecordingHost()); + + vm.Run(); + + Assert.Equal(1, vm.SystemMenuActionsEnabled); + } + + [Fact] + public void RootSceneReloadRestoresTheNativeDefault() + { + Script system4 = ScriptAssembler.Assemble(Table, "SYSTEM4.BIN", + [ + Exit(), + ], []); + Script config = ScriptAssembler.Assemble(Table, "CONFIG.BIN", + [ + (0x142, [I(0)]), + (0x9, Array.Empty()), + ], []); + var provider = new MapProvider(new Dictionary { [0] = system4 }); + var host = new RecordingHost(); + var vm = new VirtualMachine(config, Table, host, provider: provider); + + vm.Run(); + + Assert.Equal(1, vm.SystemMenuActionsEnabled); + Assert.Equal(1, host.SceneContextResets); + } +} diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 3f1c4e5..5bfad7b 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -88,6 +88,9 @@ public sealed class VirtualMachine private AdvTextStyle _advTextStyle = AdvTextStyle.Default; private int _messageWindowAlphaSetting; private int _messageGlyphDelayMilliseconds; + // 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; 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. @@ -111,6 +114,7 @@ public sealed class VirtualMachine public long Steps { get; private set; } public bool AutoMessageEnabled => _autoMessageEnabled; public bool MessageSkipEnabled => _messageSkipEnabled; + public int SystemMenuActionsEnabled => _systemMenuActionsEnabled; public string PendingDiagnosticText => _diagnosticOutput.PendingText; /// /// Zero-based active-frame cutoff selected by opcode 0x1ad, or null when no surviving marker @@ -781,6 +785,7 @@ public sealed class VirtualMachine _advSkipServiceEnabled = false; _advReadSkipState = false; _advTextStyle = AdvTextStyle.Default; + _systemMenuActionsEnabled = 1; TextHistory.SetRecordingEnabled(true); _host.SetMessageSkipActive(false); _host.SetPhysicalMessageSkipActive(false); @@ -2334,6 +2339,10 @@ public sealed class VirtualMachine _messageWindowAlphaSetting = (int)Read(a[0]); _host.SetMessageWindowAlphaSetting(_messageWindowAlphaSetting); return pc + 1; + case "set-system-menu-enabled": // 0x142: native AGERC menu reentrancy guard + case "u0041FB10": // pre-reference compatibility + _systemMenuActionsEnabled = unchecked((int)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 26664f2..a4dd887 100644 --- a/vm-map/engine-ctx.toml +++ b/vm-map/engine-ctx.toml @@ -592,3 +592,8 @@ offset = 0xa0ce8 name = "timed_callback_abort_signal" type = "int" note = "scheduler-polled abort/input signal cleared when op 0xd5 starts; redirects to abort PC when configured" +[[field]] +offset = 0xa0d10 +name = "system_menu_actions_enabled" +type = "int" +note = "op 0x142 replaces this AGERC/IAGEService reentrancy guard; scene reset defaults it to 1, while CONFIG brackets its scripted settings UI with 0 then 1" diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 8b77347..a5ad8c8 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -2964,23 +2964,24 @@ observed_types = ["l-int"] [[opcode]] op = 0x142 -label = "u0041FB10" +label = "set-system-menu-enabled" argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u0041FB10" -category = "unknown" -summary = "" +name = "set-system-menu-enabled" +category = "input" +summary = "(enabled) - replace the EngineCtx flag exposed through IAGEService to gate reentrant native AGERC system-menu actions. Scene reset defaults it to 1; CONFIG writes 0 on entry and 1 immediately before teardown and exit." +details = "Port status (2026-07-29): implemented as an EngineCtx-lifetime signed-dword field on VirtualMachine. Root scene reload restores one. The current Godot frontend has no AGERC native system menu, so the value is retained for exact state parity and a future equivalent menu consumer without affecting game input or persisted settings." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x142_set_system_menu_actions_enabled@0x421fe0 directly stores operand 1 at EngineCtx+0xa0d10. scene_context_init_reset initializes the field to 1. iage_service_query_system_menu_enabled@0x45f7c0 exposes it as a boolean through the AGE:IAGEService vtable at +0xb4. Shipped /support/AGERC.DLL's agerc_refresh_system_menu_state@0x10002e10 calls that method; zero invokes EnableMenuItem(...,MF_GRAYED) for command ids 0x9c63/64/65/68/6c/6e/6f. CONFIG.BIN's only sites bracket its complete scripted settings UI with 0 then 1." [[opcode.semantics.args]] i = 1 -role = "" +role = "native system-menu actions enabled (zero disables; nonzero enables)" observed_types = ["imm"] [[opcode]]