diff --git a/docs/engine-re.md b/docs/engine-re.md index 6c33fba..dac2d10 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1685,6 +1685,12 @@ at `+0x55180`. Himegari's queue and stack sites use only id 0. Its sole `0x137` `CALLBACK_LOAD@0xf2`, immediately before the helper that rebuilds the eight retained ADV layers; no Himegari script calls `0x138` or `0x139`. +The portable VM implements the complete family as eleven isolated logical stacks. All slots exist at +scene entry, root reload reconstructs them empty, and `0x137` replaces only the selected slot. This keeps +the handler-admitted id-10 ABI without allowing it to overwrite numeric-glyph state. Empty `0x139` +preserves `out_value` while writing success zero, because reproducing the native leaked `EngineCtx *` +would expose meaningless host-dependent data rather than a script-observable engine value. + `op_0x241_play_movie_to_surface_at_position@0x4247e0` is an exact five-operand extension of `0x236`: `(movie_resource, surface_slot, movie_and_sound_flags, start_delay_ms, position_ms)`. diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 78b09ce..d0fc260 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -371,7 +371,7 @@ Implemented as FIFO TryDequeue: nonempty writes success=1 plus the signed dword; - **depended on by:** 0x138, 0x139 - **evidence:** Ghidra /v2: op_0x137_reset_int_stack@0x421940 destroys the pointer at EngineCtx+0x55158+id*4, allocates 0x14 bytes, and calls int_stack_construct@0x407410. The constructor allocates 0x100 dwords, records a 0x100-dword growth quantum, and initializes top=-1. scene_context_init_reset@0x40b560 constructs exactly ten entries [0,10), while the handler checks only id<=10; id 10 therefore lands at +0x55180, the numeric-glyph-style table. Corpus: CALLBACK_LOAD@0xf2 is the sole site and resets stack 0 before rebuilding the eight retained ADV layers. -AGE owns ten independent stack pointers at EngineCtx+0x55158 and reconstructs all ten on every scene-context reset. The handler's unsigned range check accidentally admits id 10 as well as 0..9; that address aliases the first dword of the numeric-glyph-style table rather than an eleventh stack. Himegari uses only id 0. As with the port's queue and numeric-style families, a portable implementation should expose the handler-admitted id 10 as safe independent logical state rather than reproduce native adjacent-memory corruption. +AGE owns ten independent stack pointers at EngineCtx+0x55158 and reconstructs all ten on every scene-context reset. The handler's unsigned range check accidentally admits id 10 as well as 0..9; that address aliases the first dword of the numeric-glyph-style table rather than an eleventh stack. Himegari uses only id 0. Port status (2026-07-29): implemented with eleven safely isolated handler-addressable stacks, each initially available with native-equivalent 0x100-dword starting capacity and reconstructed empty on root scene reload. Reset replaces only the selected logical stack; invalid ids halt diagnostically. ### 0x138 `push-int-stack` (push-int-stack, argc 2) - **summary:** (stack_id, value) - push one signed integer onto the selected native LIFO, growing its dword buffer by 0x100 entries when full. @@ -380,13 +380,15 @@ AGE owns ten independent stack pointers at EngineCtx+0x55158 and reconstructs al - **depended on by:** 0x139 - **evidence:** Ghidra /v2: op_0x138_push_int_stack@0x421a40 admits stack ids <=10 and calls int_stack_push@0x408860. The helper grows capacity by the stored 0x100-dword quantum when top+2 reaches capacity, increments top, and stores the value. The ten-slot/id-10 alias caveat is documented on op 0x137. No Himegari script calls this opcode. +Port status (2026-07-29): implemented over the scene-context stack registry. All signed dword values round-trip through the VM's integer-cell representation, and handler-addressable slot 10 remains independent host state. + ### 0x139 `try-pop-int-stack` (try-pop-int-stack, argc 3) - **summary:** (stack_id, out_success, out_value) - pop the most recently pushed integer and write success=1, or write success=0 when empty. - **grounding:** source=investigation, confidence=high - **depends on:** 0x137, 0x138 - **evidence:** Ghidra /v2: op_0x139_try_pop_int_stack@0x4297a0 reads top at stack+0x10; when top>=0 it reads data[top] and decrements top, then writes success and value. When top<0, the local holding the value was never replaced from the incoming EngineCtx pointer, yet operand 3 is still written. No Himegari script calls this opcode. -On success native writes the removed value. On empty, the release handler still writes its reused `this`/EngineCtx local into out_value, so that cell is implementation garbage and must not be consumed unless out_success is nonzero. A portable compatibility implementation should preserve out_value on failure rather than expose a host pointer. The ten-slot/id-10 alias caveat is documented on op 0x137. +On success native writes the removed value. On empty, the release handler still writes its reused `this`/EngineCtx local into out_value, so that cell is implementation garbage and must not be consumed unless out_success is nonzero. Port status (2026-07-29): implemented as LIFO TryPop; success writes 1 and the signed dword, while empty writes 0 and deliberately preserves out_value rather than exposing a host pointer. The ten-slot/id-10 alias caveat is documented on op 0x137. ### 0x191 `absolute-value` (absolute-value, argc 2) - **summary:** Write the signed 32-bit absolute value of operand 2 to operand 1. diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 5c9db2f..18db46a 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -1075,6 +1075,23 @@ fields are saved. **NEXT:** implement the coherent `0x137`-`0x139` integer-stack family, then `0x241`, then the DEBUG-only `0x24d` compositor slice. +**INTEGER STACK `0x137`-`0x139` IMPLEMENTED (2026-07-29):** the VM now owns eleven safely isolated, +handler-addressable LIFO slots. They are constructed at VM scene entry, reconstructed empty on whole-root +scene reload, and individually replaced by `0x137`; `0x138` pushes signed dwords and `0x139` pops in LIFO +order. Empty pop writes success zero while preserving the prior value destination instead of leaking +native's reused internal EngineCtx pointer. Slot 10 is intentionally independent logical state rather than +AGE's adjacent numeric-glyph-table corruption. + +Three focused regressions cover independent LIFO ordering and signed values, reset plus empty-output +behavior, safe slot 10, pre-reset availability, and root-reload lifetime. `CALLBACK_LOAD`'s shipped +`0x137` is closed, while unobserved `0x138`/`0x139` are implemented for wider AGE compatibility. The +remaining effectful inventory is 2 opcodes / 3 instructions: singleton `0x241` and DEBUG-only `0x24d` x2. +Validation passes 504/504 engine tests, opcode/global/EngineCtx build and lint suites, the zero-warning +Godot build, clean diff checking, and the Himegari-targeted threaded `SELFTEST OK`. + +**NEXT:** implement positioned movie playback `0x241` with synchronized FFmpeg initial seek, then finish +the DEBUG-only `0x24d` green-channel movie-mask compositor. + ## 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/IntegerStackOpsTests.cs b/engine/Age.Engine.Tests/IntegerStackOpsTests.cs new file mode 100644 index 0000000..a6e9390 --- /dev/null +++ b/engine/Age.Engine.Tests/IntegerStackOpsTests.cs @@ -0,0 +1,104 @@ +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; + +public class IntegerStackOpsTests +{ + 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 Operand L(long address) => new(9, address); + private static (int, Operand[]) Exit() => (0x2, Array.Empty()); + + [Fact] + public void IntegerStacksAreIndependentLifosAndResetDiscardsPendingValues() + { + Script script = ScriptAssembler.Assemble(Table, "INT_STACK", + [ + (0x138, [I(0), I(11)]), + (0x138, [I(0), I(-22)]), + (0x138, [I(1), I(33)]), + + (0x139, [I(0), L(0), L(1)]), + (0x55, [G(0x100), L(0)]), + (0x55, [G(0x101), L(1)]), + (0x139, [I(0), L(0), L(1)]), + (0x55, [G(0x102), L(0)]), + (0x55, [G(0x103), L(1)]), + (0x139, [I(1), L(0), L(1)]), + (0x55, [G(0x104), L(0)]), + (0x55, [G(0x105), L(1)]), + + (0x55, [L(1), I(777)]), + (0x139, [I(0), L(0), L(1)]), + (0x55, [G(0x106), L(0)]), + (0x55, [G(0x107), L(1)]), + (0x138, [I(1), I(44)]), + (0x137, [I(1)]), + (0x139, [I(1), L(0), L(1)]), + (0x55, [G(0x108), L(0)]), + Exit(), + ], []); + var vm = new VirtualMachine(script, Table, new RecordingHost()); + + vm.Run(); + + Assert.Equal(1, vm.Globals[0x100]); + Assert.Equal(-22, vm.Globals[0x101]); + Assert.Equal(1, vm.Globals[0x102]); + Assert.Equal(11, vm.Globals[0x103]); + Assert.Equal(1, vm.Globals[0x104]); + Assert.Equal(33, vm.Globals[0x105]); + Assert.Equal(0, vm.Globals[0x106]); + Assert.Equal(777, vm.Globals[0x107]); + Assert.Equal(0, vm.Globals[0x108]); + } + + [Fact] + public void HandlerAddressableSlotTenIsSafeIndependentState() + { + Script script = ScriptAssembler.Assemble(Table, "INT_STACK_SLOT_TEN", + [ + (0x138, [I(0), I(10)]), + (0x138, [I(10), I(1010)]), + (0x139, [I(10), G(0x100), G(0x101)]), + (0x139, [I(0), G(0x102), G(0x103)]), + Exit(), + ], []); + var vm = new VirtualMachine(script, Table, new RecordingHost()); + + vm.Run(); + + Assert.Equal(1, vm.Globals[0x100]); + Assert.Equal(1010, vm.Globals[0x101]); + Assert.Equal(1, vm.Globals[0x102]); + Assert.Equal(10, vm.Globals[0x103]); + } + + [Fact] + public void RootSceneReloadReconstructsEmptyStacks() + { + Script reloadedSystem4 = ScriptAssembler.Assemble(Table, "SYSTEM4.BIN", + [ + (0x55, [L(1), I(777)]), + (0x139, [I(0), L(0), L(1)]), + (0x55, [G(0x100), L(0)]), + (0x55, [G(0x101), L(1)]), + Exit(), + ], []); + Script initial = ScriptAssembler.Assemble(Table, "INITIAL", + [ + (0x138, [I(0), I(123)]), + (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(0, vm.Globals[0x100]); + Assert.Equal(777, vm.Globals[0x101]); + Assert.Equal(1, host.SceneContextResets); + } +} diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 2f48145..30c5d6f 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -95,9 +95,12 @@ public sealed class VirtualMachine // 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. + // Eleven safely isolated handler-addressable FIFO slots. Native physically owns ten at + // +0x55130; its admitted id 10 aliases stack slot zero. ATSEEK/MVSEEK use FIFO slot zero. private readonly Queue?[] _intQueues = new Queue?[11]; + // Eleven safely isolated handler-addressable LIFO slots. Native constructs ten at +0x55158 + // on every scene reset; its admitted id 10 aliases numeric-glyph-style storage. + private readonly Stack[] _intStacks = CreateIntegerStacks(); // Opcodes 0x06/0x08 load scripts into numbered EngineCtx frame slots and invoke them later. // Unlike ordinary call-script frames, native non-adjacent slots survive return with locals intact. private readonly Dictionary _preloadedScriptSlots = new(); @@ -766,6 +769,7 @@ public sealed class VirtualMachine Gfx.ResetSceneContext(); _valueSwitchTargets.Clear(); _preloadedScriptSlots.Clear(); + ResetIntegerStacks(); lock (_interactiveLock) { _interactiveFrame = null; @@ -797,6 +801,20 @@ public sealed class VirtualMachine _host.ResetSceneContext(); } + private static Stack[] CreateIntegerStacks() + { + var stacks = new Stack[11]; + for (int i = 0; i < stacks.Length; i++) + stacks[i] = new Stack(0x100); + return stacks; + } + + private void ResetIntegerStacks() + { + for (int i = 0; i < _intStacks.Length; i++) + _intStacks[i] = new Stack(0x100); + } + private void RestartBgm(long requestedTrackId, int startMode) { if (requestedTrackId != 0) @@ -1672,6 +1690,52 @@ public sealed class VirtualMachine } return pc + 1; } + case "u0041F1C0": + case "reset-int-stack": // 0x137 (stack_id) + { + int stackId = unchecked((int)Read(a[0])); + if ((uint)stackId >= (uint)_intStacks.Length) + { + HaltReason ??= $"int-stack-id-out-of-range:{stackId}"; + return HALT; + } + _intStacks[stackId] = new Stack(0x100); + return pc + 1; + } + case "u0041F2B0": + case "push-int-stack": // 0x138 (stack_id, value) + { + int stackId = unchecked((int)Read(a[0])); + if ((uint)stackId >= (uint)_intStacks.Length) + { + HaltReason ??= $"int-stack-id-out-of-range:{stackId}"; + return HALT; + } + _intStacks[stackId].Push(unchecked((int)Read(a[1]))); + return pc + 1; + } + case "u0041F310": + case "try-pop-int-stack": // 0x139 (stack_id, out_success, out_value) + { + int stackId = unchecked((int)Read(a[0])); + if ((uint)stackId >= (uint)_intStacks.Length) + { + HaltReason ??= $"int-stack-id-out-of-range:{stackId}"; + return HALT; + } + if (_intStacks[stackId].TryPop(out int value)) + { + Write(a[1], 1); + Write(a[2], value); + } + else + { + // Native writes success=0 and leaks an internal EngineCtx pointer through + // operand 3. Preserve the destination instead of exposing host garbage. + Write(a[1], 0); + } + return pc + 1; + } case "bit-set": { long bit = Read(a[1]); diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 1d37787..6781c84 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -2816,7 +2816,7 @@ abi_source = "kelebek+decode-validated" name = "reset-int-stack" category = "compute" summary = "(stack_id) - destroy the selected native integer LIFO and replace it with an empty stack." -details = "AGE owns ten independent stack pointers at EngineCtx+0x55158 and reconstructs all ten on every scene-context reset. The handler's unsigned range check accidentally admits id 10 as well as 0..9; that address aliases the first dword of the numeric-glyph-style table rather than an eleventh stack. Himegari uses only id 0. As with the port's queue and numeric-style families, a portable implementation should expose the handler-admitted id 10 as safe independent logical state rather than reproduce native adjacent-memory corruption." +details = "AGE owns ten independent stack pointers at EngineCtx+0x55158 and reconstructs all ten on every scene-context reset. The handler's unsigned range check accidentally admits id 10 as well as 0..9; that address aliases the first dword of the numeric-glyph-style table rather than an eleventh stack. Himegari uses only id 0. Port status (2026-07-29): implemented with eleven safely isolated handler-addressable stacks, each initially available with native-equivalent 0x100-dword starting capacity and reconstructed empty on root scene reload. Reset replaces only the selected logical stack; invalid ids halt diagnostically." noop_headless = false source = "investigation" confidence = "high" @@ -8691,6 +8691,7 @@ abi_source = "kelebek" name = "push-int-stack" category = "compute" summary = "(stack_id, value) - push one signed integer onto the selected native LIFO, growing its dword buffer by 0x100 entries when full." +details = "Port status (2026-07-29): implemented over the scene-context stack registry. All signed dword values round-trip through the VM's integer-cell representation, and handler-addressable slot 10 remains independent host state." noop_headless = false source = "investigation" confidence = "high" @@ -8718,7 +8719,7 @@ abi_source = "kelebek" name = "try-pop-int-stack" category = "compute" summary = "(stack_id, out_success, out_value) - pop the most recently pushed integer and write success=1, or write success=0 when empty." -details = "On success native writes the removed value. On empty, the release handler still writes its reused `this`/EngineCtx local into out_value, so that cell is implementation garbage and must not be consumed unless out_success is nonzero. A portable compatibility implementation should preserve out_value on failure rather than expose a host pointer. The ten-slot/id-10 alias caveat is documented on op 0x137." +details = "On success native writes the removed value. On empty, the release handler still writes its reused `this`/EngineCtx local into out_value, so that cell is implementation garbage and must not be consumed unless out_success is nonzero. Port status (2026-07-29): implemented as LIFO TryPop; success writes 1 and the signed dword, while empty writes 0 and deliberately preserves out_value rather than exposing a host pointer. The ten-slot/id-10 alias caveat is documented on op 0x137." noop_headless = false source = "investigation" confidence = "high"