feat: model ADV coroutines and retained effect teardown

This commit is contained in:
gamer147
2026-07-10 09:45:43 -04:00
parent 54bd9a7006
commit d9611a03b1
19 changed files with 474 additions and 222 deletions

View File

@@ -0,0 +1,114 @@
using System.Collections.Generic;
using System.Linq;
using Age.Engine.Diagnostics;
using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class CoroutineHostModelTests
{
private const int T_IMM = 0, T_STR = 2, T_GINT = 3, T_LINT = 9;
private const int SceneEntryGate = 0xaba5c;
private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson);
private static int Op(string label) => Table.ByLabel(label)!.Value;
private static Instruction Ins(int offset, int opcode, params Operand[] args) => new(offset, opcode, args);
private static Script Script(params Instruction[] instructions) => new()
{
Name = "COROUTINE-TEST",
Header = new ScriptHeader(0, 0, 0, 0, 0, 0),
Instructions = instructions,
IndexByOffset = instructions.Select((ins, i) => (ins.Offset, i)).ToDictionary(x => x.Offset, x => x.i),
Strings = new Dictionary<int, string> { [0x1000] = "LABEL", [0x1001] = "J" },
};
[Fact]
public void AdvLabeledYieldRunsSetupExactlyOnceThenUsesStructuralTerminal()
{
const int output = 0x6be, terminal = 0x6c3, setupCount = 0x7000, slot = 0x7001, observed = 0x7002;
var script = Script(
Ins(0x00, Op("eq"), new(T_LINT, 0), new(T_GINT, SceneEntryGate), new(T_IMM, 1)),
Ins(0x07, Op("jcc"), new(T_LINT, 0), new(T_IMM, 0x20), new(T_IMM, 0x80)),
Ins(0x20, 0x140, new(T_GINT, output), new(T_STR, 0x1000), new(T_STR, 0x1001), new(T_GINT, output)),
Ins(0x29, Op("mov"), new(T_GINT, terminal), new(T_IMM, 0x77)),
Ins(0x2e, Op("eq"), new(T_LINT, 1), new(T_GINT, terminal), new(T_GINT, output)),
Ins(0x35, Op("jcc"), new(T_LINT, 1), new(T_IMM, 0x60), new(T_IMM, 0x40)),
Ins(0x40, Op("add"), new(T_GINT, setupCount), new(T_GINT, setupCount), new(T_IMM, 1)),
Ins(0x47, Op("mov"), new(T_GINT, slot), new(T_IMM, 4)),
Ins(0x4c, Op("jmp"), new Operand(T_IMM, 0x20)),
Ins(0x60, Op("mov"), new(T_GINT, SceneEntryGate), new(T_IMM, 0)),
Ins(0x65, Op("jmp"), new Operand(T_IMM, 0x80)),
Ins(0x80, Op("mov"), new(T_GINT, observed), new(T_GINT, slot)),
Ins(0x85, Op("exit")));
var sink = new RecordingTraceSink { TracingSteps = true };
var vm = new VirtualMachine(script, Table, new CaptureHost(), sink: sink);
vm.Globals[output] = 0x77; // stale value from a previous scene: already equal to this scene's terminal
vm.Run();
Assert.Equal("exit", vm.HaltReason);
Assert.Equal(1, vm.Globals[setupCount]);
Assert.Equal(4, vm.Globals[observed]);
Assert.Equal(0, vm.Globals[SceneEntryGate]);
Assert.Equal(0x77, vm.Globals[output]);
Assert.Equal(2, sink.Events.Count(e => e.Kind == TraceEventKind.Step && e.Opcode == 0x140));
}
[Fact]
public void NonAdvLabeledServiceIsLeftStubbedAndDoesNotInjectSceneEntryGate()
{
var script = new Script
{
Name = "NON-ADV-140",
Header = new ScriptHeader(0, 0, 0, 0, 0, 0),
Instructions = new[]
{
Ins(0, 0x140, new(T_GINT, 0x699), new(T_STR, 0x2000), new(T_STR, 0x2001), new(T_GINT, 0x699)),
Ins(9, Op("exit")),
},
IndexByOffset = new Dictionary<int, int> { [0] = 0, [9] = 1 },
Strings = new Dictionary<int, string> { [0x2000] = "BIN", [0x2001] = "SC????.BIN" },
};
var sink = new RecordingTraceSink { TracingSteps = true };
var vm = new VirtualMachine(script, Table, new CaptureHost(), sink: sink);
vm.Globals[0x699] = 123;
vm.Run();
Assert.Equal(123, vm.Globals[0x699]);
Assert.False(vm.Globals.ContainsKey(SceneEntryGate));
Assert.Contains(sink.Events, e => e.Kind == TraceEventKind.Stub && e.Opcode == 0x140);
}
[Fact]
public void CoroutineHandlerOpsAreConsumedByTheHostSchedulerModel()
{
var script = Script(
Ins(0, 0x7b, new(T_IMM, 0x30), new(T_IMM, 0x40)),
Ins(5, 0x7c),
Ins(6, Op("exit")));
var sink = new RecordingTraceSink { TracingSteps = true };
var vm = new VirtualMachine(script, Table, new CaptureHost(), sink: sink);
vm.Run();
Assert.DoesNotContain(sink.Events, e => e.Kind == TraceEventKind.Stub && (e.Opcode == 0x7b || e.Opcode == 0x7c));
}
[Fact]
public void Sc0000EntryRunsSetupAndFillsDistinctTextureSlots()
{
var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], Table);
var sink = new RecordingTraceSink { TracingSteps = true };
var vm = new VirtualMachine(script, Table, new CaptureHost(),
new VmOptions(HaltAtWaitForInput: true), sink: sink);
vm.Run();
Assert.Equal("wait-for-input", vm.HaltReason);
Assert.Equal(0, vm.Globals[SceneEntryGate]);
Assert.Equal(new long[] { 4, 5, 6, 7, 8, 9, 10, 11 },
Enumerable.Range(0, 8).Select(i => vm.Globals[0x3239 + i * 3]).ToArray());
Assert.Equal(2, sink.Events.Count(e => e.Kind == TraceEventKind.Step && e.Opcode == 0x140));
}
}

View File

@@ -39,24 +39,24 @@ public class GfxCommandBufferTests
private static (int, Operand[]) Register(int handle) => (0x1a2, new[] { G(handle) });
[Fact]
public void QueryReturnsMinusOneUntilRegistered_ThenTheHandle()
public void QueryReturnsMinusOneUntilDrawBound_ThenSourceSlot()
{
// Native contract (docs/engine-re.md op 0x215/0x1a2): the query registry is populated ONLY by op 0x1a2
// (gfx-cmd-register). Giving a handle geometry via set-geom (0x217) must NOT register it — query stays -1
// so a CG handle takes label_12649's fresh branch. After 0x1a2, query returns the handle (native
// map[handle]=handle; small system handles double as their surface slot).
// Op 0x215 returns obj+4 from the retained gfx object. Geometry creates the object but leaves it unbound;
// op 0x1a2's descriptor registry is unrelated. Draw-texture binds the source slot returned by the query.
var t = T();
var scene = ScriptAssembler.Assemble(t, "GFX", new List<(int, Operand[])>
{
MovGI(1, 0xcb2a), MovGI(2, 0xd), MovGI(3, 0),
SetGeom3(1, 3, 3, 3), // 0xcb2a: geometry only, NOT registered
Register(2), // 0xd: op 0x1a2 registers it
Query(10, 1), Query(11, 2), Exit(),
MovGI(1, 0xcb2a), MovGI(2, 6), MovGI(3, 0), MovGI(4, 200),
SetGeom3(1, 3, 3, 3),
Register(1),
Query(10, 1),
(0x1fb, new[] { G(1), G(2), I(0), I(0), G(4), G(4), G(3), G(3) }),
Query(11, 1), Exit(),
}, System.Array.Empty<string>());
var vm = new VirtualMachine(scene, t, new RecordingHost());
vm.Run();
Assert.Equal(-1, vm.Globals[10]); // geometry-only CG handle -> -1 -> fresh branch (the bug fix)
Assert.Equal(0xd, vm.Globals[11]); // 0x1a2-registered handle -> its value (== handle)
Assert.Equal(-1, vm.Globals[10]);
Assert.Equal(6, vm.Globals[11]);
}
private static (int, Operand[]) BlitColor(int h, int x, int y, int alpha, int color)

View File

@@ -4,19 +4,20 @@ using Xunit;
public class GfxStateTests
{
[Fact]
public void QueryRegistryIsPopulatedOnlyByRegister_NotByGeometryOps()
public void QueryReturnsBoundSourceSlot_NotOperandRegistryValue()
{
// Native contract (docs/engine-re.md op 0x215/0x1a2): the op-0x215 query registry is populated ONLY by
// op 0x1a2 (gfx-cmd-register). Merely giving a handle geometry (GetOrCreate, as the set-geom ops do)
// must NOT make query-gfx-object return a slot for it — otherwise a CG handle (never 0x1a2-registered)
// wrongly takes label_12649's existing branch and collapses off-screen.
// Native op 0x215 queries the retained-object map and returns obj+4, the source slot set by draw-texture.
// Geometry alone creates an object but leaves obj+4 at -1. Op 0x1a2 is a separate descriptor registry.
var g = new GfxState();
g.GetOrCreate(0xcb2a).V18 = (400, 600, 0); // geometry only, like the fresh CG-load branch
Assert.Equal(-1, g.QuerySlot(0xcb2a)); // NOT registered => -1 => fresh branch (correct)
g.GetOrCreate(0xcb2a).V18 = (400, 600, 0);
Assert.Equal(-1, g.QuerySlot(0xcb2a));
g.Register(0xd); // op 0x1a2 registers a small system/UI handle
Assert.Equal(0xd, g.QuerySlot(0xd)); // native map[handle]=handle; the value doubles as its slot
Assert.Equal(-1, g.QuerySlot(0x9999)); // unknown -> -1 (matches native 0xffffffff)
g.Register(0xcb2a);
Assert.Equal(-1, g.QuerySlot(0xcb2a));
g.BindDraw(0xcb2a, 6, 0, 0, 200, 200, 10, 20);
Assert.Equal(6, g.QuerySlot(0xcb2a));
Assert.Equal(-1, g.QuerySlot(0x9999));
}
[Fact]
@@ -31,12 +32,12 @@ public class GfxStateTests
}
[Fact]
public void ReleaseRemovesTheHandleFromTheQueryRegistry()
public void ReleaseRemovesTheRetainedObject()
{
var g = new GfxState();
g.Register(0x10);
Assert.Equal(0x10, g.QuerySlot(0x10));
g.Release(0x10); // op 0x1fa / 0x1f7 tear down the registration too
g.BindDraw(0x10, 4, 0, 0, 10, 10, 0, 0);
Assert.Equal(4, g.QuerySlot(0x10));
g.Release(0x10);
Assert.Equal(-1, g.QuerySlot(0x10));
}
@@ -48,7 +49,8 @@ public class GfxStateTests
public void EraseRangeRemovesHandlesInRange()
{
var g = new GfxState();
g.Register(0x10); g.Register(0x11); g.Register(0x12); g.Register(0x20);
g.BindDraw(0x10, 1, 0, 0, 1, 1, 0, 0); g.BindDraw(0x11, 2, 0, 0, 1, 1, 0, 0);
g.BindDraw(0x12, 3, 0, 0, 1, 1, 0, 0); g.BindDraw(0x20, 4, 0, 0, 1, 1, 0, 0);
g.EraseRange(0x10, 3); // count>1 → erase [0x10, 0x13)
Assert.Equal(-1, g.QuerySlot(0x10));
Assert.Equal(-1, g.QuerySlot(0x12));
@@ -59,7 +61,7 @@ public class GfxStateTests
public void EraseRangeCountLeOneErasesSingleHandle()
{
var g = new GfxState();
g.Register(0x10); g.Register(0x11);
g.BindDraw(0x10, 1, 0, 0, 1, 1, 0, 0); g.BindDraw(0x11, 2, 0, 0, 1, 1, 0, 0);
g.EraseRange(0x10, 1); // count<=1 → single handle
Assert.Equal(-1, g.QuerySlot(0x10));
Assert.NotEqual(-1, g.QuerySlot(0x11));
@@ -100,4 +102,20 @@ public class GfxStateTests
g.EraseRange(0x10, 1);
Assert.Empty(g.SnapshotVisibleObjects()); // erased => gone from the registry => not composited
}
[Fact]
public void Sc0000EffectCleanupQueryEnablesObjectEraseAndSurfaceRelease()
{
var g = new GfxState();
g.SetSurface(6, 0x37, 0);
g.BindDraw(0xcb8e, 6, 0, 0, 200, 200, 300, 180);
int slot = g.QuerySlot(0xcb8e); // mirrors post-effect cleanup at SC0000 0x3321
Assert.Equal(6, slot);
g.EraseRange(0xcb8e, 10); // op 0x1f7
g.ClearSurface(slot); // op 0x1fa
Assert.Empty(g.SnapshotVisibleObjects());
Assert.Equal(-1, g.QuerySlot(0xcb8e));
}
}

View File

@@ -24,15 +24,27 @@ public class TextureOpsTests
}
[Fact]
public void SC0000FiresTextureOpsWithSlot0FullScreenSlideshow()
public void SC0000FiresTextureOpsWithAssignedFullScreenSlot()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var provider = Sys4ScriptProvider.Load(table);
var session = new GameSession();
foreach (var name in new[] { "INITCONFIG.BIN", "INIT2.BIN", "INIT.BIN" })
session.RunScene(Sys4Loader.Load(Paths.Scripts()[name], table), table, new CaptureHost(), provider: provider);
var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table);
var host = new RecHost();
new VirtualMachine(script, table, host).Run();
var vm = new VirtualMachine(script, table, host, new VmOptions(MaxSteps: 20_000_000), provider);
foreach (var kv in session.Globals) vm.Globals[kv.Key] = kv.Value;
foreach (var kv in session.GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value;
vm.Run();
Assert.True(host.Creates > 0, "create-texture should fire");
// The intro loads a sequence of full-screen images into slot 0; res 0x23 is the first bg.
Assert.Contains(host.Sets, s => s.resId == 0x23 && s.slot == 0);
Assert.Contains(host.Draws, d => d.slot == 0 && d.w == 0x320 && d.h == 0x258);
// Boot + coroutine setup fill the handle/slot tables, so the first bg uses its assigned slot.
Assert.Contains(host.Sets, s => s.resId == 0x23 && s.slot == 5);
Assert.Contains(host.Draws, d => d.slot == 5 && d.w == 0x320 && d.h == 0x258);
// AE001H is the ritual/magic-circle sheet. At the post-effect transition SC0000 explicitly queries its
// retained object, erases the object group, and releases the returned slot; it must not survive the scene.
Assert.DoesNotContain(vm.Gfx.SnapshotVisibleObjects(), o => o.SurfaceResId == 0x37);
}
}