Implement integer stack opcodes

This commit is contained in:
gamer147
2026-07-29 15:18:07 -04:00
parent 682f65318b
commit 71ae1b7c5b
6 changed files with 200 additions and 6 deletions

View File

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

View File

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

View File

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

View File

@@ -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<Operand>());
[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<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(0, vm.Globals[0x100]);
Assert.Equal(777, vm.Globals[0x101]);
Assert.Equal(1, host.SceneContextResets);
}
}

View File

@@ -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<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.
// 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<int>?[] _intQueues = new Queue<int>?[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<int>[] _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<int, PreloadedScriptSlot> _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<int>[] CreateIntegerStacks()
{
var stacks = new Stack<int>[11];
for (int i = 0; i < stacks.Length; i++)
stacks[i] = new Stack<int>(0x100);
return stacks;
}
private void ResetIntegerStacks()
{
for (int i = 0; i < _intStacks.Length; i++)
_intStacks[i] = new Stack<int>(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<int>(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]);

View File

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