Implement root reload and title debug launcher

This commit is contained in:
gamer147
2026-07-20 22:32:49 -04:00
parent a82f25230c
commit c84952e7d7
20 changed files with 1151 additions and 56 deletions

View File

@@ -0,0 +1,183 @@
using Age.Engine.Diagnostics;
using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class DebugSceneLaunchTests
{
private static Operand I(long value) => new(0, value);
private static Operand G(long address) => new(3, address);
private static (int, Operand[]) Call(Operand id) => (0x3, new[] { id });
private static (int, Operand[]) Mov(int address, long value) => (0x55, new[] { G(address), I(value) });
private static (int, Operand[]) Wait() => (0x72, new[] { I(0) });
private static (int, Operand[]) Sleep() => (0xc8, new[] { I(1) });
private static (int, Operand[]) Exit() => (0x2, Array.Empty<Operand>());
[Fact]
public async Task ParkedTitleFrameReturnsToCoordinatorWhichDispatchesSelectedScript()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var root = ScriptAssembler.Assemble(table, "SYSTEM4.BIN", new List<(int, Operand[])>
{
Call(I(1)), Call(G(0x699)), Mov(0x7102, 1), Exit(),
}, Array.Empty<string>());
var title = ScriptAssembler.Assemble(table, "TITLE.BIN", new List<(int, Operand[])>
{
Wait(), Mov(0x7100, 1), Exit(),
}, Array.Empty<string>());
var selected = ScriptAssembler.Assemble(table, "DEBUG.BIN", new List<(int, Operand[])>
{
Mov(0x7101, 1), Exit(),
}, Array.Empty<string>());
var host = new BlockingWaitHost();
var trace = new RecordingTraceSink();
var vm = new VirtualMachine(root, table, host, provider: new MapProvider(new()
{
[1] = title,
[2] = selected,
}), sink: trace);
Assert.False(vm.TryRequestDebugFrameReturn(0, new Dictionary<int, long>()));
Task run = Task.Run(() => vm.Run());
await host.WaitEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
var parked = Assert.IsType<DebugFrameSnapshot>(vm.DebugFrame);
Assert.Equal("TITLE.BIN", parked.CurrentScript);
Assert.Equal(new[] { "SYSTEM4.BIN", "TITLE.BIN" }, parked.CallStack);
Assert.True(vm.TryRequestDebugFrameReturn(parked.FrameId, new Dictionary<int, long>
{
[0] = 1,
[0xaba5c] = -1,
[0x62ccf] = 0,
[0x699] = 2,
}));
Assert.False(vm.TryRequestDebugFrameReturn(parked.FrameId, new Dictionary<int, long>()));
host.SignalInput();
await run.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(0, vm.Globals.GetValueOrDefault(0x7100));
Assert.Equal(1, vm.Globals[0x7101]);
Assert.Equal(1, vm.Globals[0x7102]);
Assert.Null(vm.DebugFrame);
Assert.Contains(trace.Events, e => e.Kind == TraceEventKind.FrameExit
&& e.Name == "TITLE.BIN" && e.Text == "DebugReturned");
Assert.Contains(trace.Events, e => e.Kind == TraceEventKind.FrameEnter
&& e.Name == "DEBUG.BIN" && e.Cause == FrameCause.CallScript);
}
[Fact]
public async Task SelectedScriptExitScriptStillPerformsWholeStackRootReload()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var initialRoot = ScriptAssembler.Assemble(table, "SYSTEM4.BIN", new List<(int, Operand[])>
{
Call(I(1)), Call(G(0x699)), Mov(0x7200, 1), Exit(),
}, Array.Empty<string>());
var title = ScriptAssembler.Assemble(table, "TITLE.BIN", new List<(int, Operand[])>
{
Wait(), Exit(),
}, Array.Empty<string>());
var selected = ScriptAssembler.Assemble(table, "DEBUG.BIN", new List<(int, Operand[])>
{
(0x9, Array.Empty<Operand>()),
}, Array.Empty<string>());
var reloadedRoot = ScriptAssembler.Assemble(table, "SYSTEM4.BIN", new List<(int, Operand[])>
{
Mov(0x7201, 1), Exit(),
}, Array.Empty<string>());
var host = new BlockingWaitHost();
var trace = new RecordingTraceSink();
var vm = new VirtualMachine(initialRoot, table, host, provider: new MapProvider(new()
{
[0] = reloadedRoot,
[1] = title,
[2] = selected,
}), sink: trace);
Task run = Task.Run(() => vm.Run());
await host.WaitEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
var frame = Assert.IsType<DebugFrameSnapshot>(vm.DebugFrame);
Assert.True(vm.TryRequestDebugFrameReturn(frame.FrameId,
new Dictionary<int, long> { [0x699] = 2 }));
host.SignalInput();
await run.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(0, vm.Globals.GetValueOrDefault(0x7200));
Assert.Equal(1, vm.Globals[0x7201]);
Assert.Equal(1, host.SceneContextResets);
Assert.Contains(trace.Events, e => e.Kind == TraceEventKind.FrameEnter
&& e.Name == "SYSTEM4.BIN" && e.Cause == FrameCause.RootReload);
}
[Fact]
public async Task PollingTitleReturnsAtOpcodeBoundaryWithoutAdvInputWait()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var root = ScriptAssembler.Assemble(table, "SYSTEM4.BIN", new List<(int, Operand[])>
{
Call(I(1)), Call(G(0x699)), Mov(0x7302, 1), Exit(),
}, Array.Empty<string>());
var title = ScriptAssembler.Assemble(table, "TITLE.BIN", new List<(int, Operand[])>
{
Sleep(), Mov(0x7300, 1), Exit(),
}, Array.Empty<string>());
var selected = ScriptAssembler.Assemble(table, "DEBUG.BIN", new List<(int, Operand[])>
{
Mov(0x7301, 1), Exit(),
}, Array.Empty<string>());
var host = new BlockingSleepHost();
var vm = new VirtualMachine(root, table, host, provider: new MapProvider(new()
{
[1] = title,
[2] = selected,
}));
Task run = Task.Run(() => vm.Run());
await host.SleepEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
var frame = Assert.IsType<DebugFrameSnapshot>(vm.DebugFrame);
Assert.Equal(new[] { "SYSTEM4.BIN", "TITLE.BIN" }, frame.CallStack);
Assert.True(vm.TryRequestDebugFrameReturn(frame.FrameId,
new Dictionary<int, long> { [0x699] = 2 }));
host.CompleteSleep();
await run.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(0, vm.Globals.GetValueOrDefault(0x7300));
Assert.Equal(1, vm.Globals[0x7301]);
Assert.Equal(1, vm.Globals[0x7302]);
}
private sealed class BlockingWaitHost : RecordingHost
{
private readonly SemaphoreSlim _gate = new(0, 1);
public TaskCompletionSource WaitEntered { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public override void WaitForInput(int layoutSlot, Func<bool> serviceInputCallback,
Func<AdvAutoWaitState> autoWaitState)
{
Waits++;
WaitEntered.TrySetResult();
if (!_gate.Wait(TimeSpan.FromSeconds(5))) throw new TimeoutException("test input wait timed out");
}
public void SignalInput() => _gate.Release();
}
private sealed class BlockingSleepHost : RecordingHost
{
private readonly SemaphoreSlim _gate = new(0, 1);
public TaskCompletionSource SleepEntered { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public override void Sleep(long duration)
{
SleptDurations.Add(duration);
SleepEntered.TrySetResult();
if (!_gate.Wait(TimeSpan.FromSeconds(5))) throw new TimeoutException("test sleep timed out");
}
public void CompleteSleep() => _gate.Release();
}
}

View File

@@ -17,14 +17,19 @@ public class MovieOpcodeTests
(0x130, new[] { new Operand(3, 0x100) }),
(0x9, System.Array.Empty<Operand>()),
}, System.Array.Empty<string>());
var vm = new VirtualMachine(root, table, new RecordingHost());
var reloadedRoot = ScriptAssembler.Assemble(table, "SYSTEM4", new List<(int, Operand[])>
{
(0x130, new[] { new Operand(3, 0x101) }),
(0x2, System.Array.Empty<Operand>()),
}, System.Array.Empty<string>());
var host = new RecordingHost();
var vm = new VirtualMachine(root, table, host,
provider: new MapProvider(new Dictionary<long, Script> { [0] = reloadedRoot }));
vm.Run();
Assert.Equal(1, vm.Globals[0x100]);
vm.Globals[0x100] = -1;
vm.Run();
Assert.Equal(0, vm.Globals[0x100]);
Assert.Equal(0, vm.Globals[0x101]);
Assert.Equal(1, host.SceneContextResets);
}
[Fact]

View File

@@ -0,0 +1,115 @@
using Age.Engine.Diagnostics;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class RootReloadTests
{
private static Operand I(long value) => new(0, value);
private static Operand G(long address) => new(3, address);
[Fact]
public void ExitScriptDiscardsEveryCallerAndReloadsRawScriptZero()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var system4 = ScriptAssembler.Assemble(table, "SYSTEM4", new List<(int, Operand[])>
{
(0x1b6, new[] { G(0x220) }),
(0x19a, new[] { G(0x221) }),
(0x55, new[] { G(0x202), I(0x99) }),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
var deepest = ScriptAssembler.Assemble(table, "DEEPEST", new List<(int, Operand[])>
{
(0x55, new[] { G(0x203), I(3) }),
(0x9, Array.Empty<Operand>()),
(0x55, new[] { G(0x204), I(4) }),
}, Array.Empty<string>());
var child = ScriptAssembler.Assemble(table, "CHILD", new List<(int, Operand[])>
{
(0x3, new[] { I(2) }),
(0x55, new[] { G(0x205), I(5) }),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
var initial = ScriptAssembler.Assemble(table, "INITIAL", new List<(int, Operand[])>
{
(0x1b7, new[] { I(1) }),
(0x88, new[] { I(1) }),
(0x55, new[] { G(0x200), I(1) }),
(0x3, new[] { I(1) }),
(0x55, new[] { G(0x201), I(2) }),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
var provider = new MapProvider(new Dictionary<long, Script>
{
[0] = system4,
[1] = child,
[2] = deepest,
});
var host = new RecordingHost();
var trace = new RecordingTraceSink();
var vm = new VirtualMachine(initial, table, host, provider: provider, sink: trace);
vm.Globals[0x2ff] = 0x1234;
vm.ExternalGlobals[7] = 0x5678;
vm.Gfx.SetSurface(5, 0x33, 0);
vm.Gfx.BindDraw(0x100, 5, 0, 0, 1, 1, 0, 0);
vm.TextHistory.DefineLayout(1, 10, 10, 0, 0);
vm.TextHistory.AppendText(1, 0, "preserved", AdvTextStyle.Default);
vm.TextHistory.SetRecordingEnabled(false);
vm.Run();
Assert.Equal("exit", vm.HaltReason);
Assert.Equal(1, vm.Globals[0x200]);
Assert.Equal(3, vm.Globals[0x203]);
Assert.Equal(0x99, vm.Globals[0x202]);
Assert.False(vm.Globals.ContainsKey(0x201));
Assert.False(vm.Globals.ContainsKey(0x204));
Assert.False(vm.Globals.ContainsKey(0x205));
Assert.Equal(0x1234, vm.Globals[0x2ff]);
Assert.Equal(0x5678, vm.ExternalGlobals[7]);
Assert.Equal(0, vm.Globals[0x220]);
Assert.Equal(0, vm.Globals[0x221]);
Assert.Empty(vm.Gfx.SnapshotVisibleObjects());
Assert.Single(vm.TextHistory.Records, r => r.Text == "preserved");
Assert.False(vm.TextHistory.RecordingSuppressed);
Assert.Equal(1, host.SceneContextResets);
Assert.False(host.MessageSkip);
var rootEnter = Assert.Single(trace.Events,
e => e.Kind == TraceEventKind.FrameEnter && e.Name == "SYSTEM4");
Assert.Equal(FrameCause.RootReload, rootEnter.Cause);
Assert.Equal(3, trace.Events.Count(e =>
e.Kind == TraceEventKind.FrameExit && e.Text == "RootReload"));
}
[Fact]
public void HimegariRawScriptZeroIsSystem4()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var scripts = Sys4ScriptProvider.Load(table);
Assert.Equal("SYSTEM4.BIN", scripts.GetById(0)?.Name);
}
[Fact]
public void ExitScriptResetsSceneBeforeAnUnresolvedRootLoadFails()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var script = ScriptAssembler.Assemble(table, "NO_ROOT", new List<(int, Operand[])>
{
(0x9, Array.Empty<Operand>()),
}, Array.Empty<string>());
var host = new RecordingHost();
var vm = new VirtualMachine(script, table, host);
vm.Gfx.SetSurface(5, 0x33, 0);
vm.Gfx.BindDraw(0x100, 5, 0, 0, 1, 1, 0, 0);
vm.Run();
Assert.Equal("root-reload-unresolved:0x0", vm.HaltReason);
Assert.Equal(1, host.SceneContextResets);
Assert.Empty(vm.Gfx.SnapshotVisibleObjects());
}
}

View File

@@ -1,4 +1,5 @@
using Age.Engine.Sys4;
using Age.Engine.Diagnostics;
using Xunit;
public class Sys4ScriptProviderTests
@@ -46,4 +47,44 @@ public class Sys4ScriptProviderTests
Assert.Null(provider.GetByName("../FIELD.BIN"));
Assert.Equal(481, provider.ScriptNames.Count);
}
[Fact]
public void DebugCatalogPreservesPackedIdentityAcrossBaseAndAppendPacks()
{
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
var entries = DebugSceneCatalog.Build(catalog);
Assert.Equal(catalog.EnumerateScripts().Count, entries.Count);
Assert.Equal(481, entries.Count(entry => entry.PackId == 0));
Assert.Contains(entries, entry => entry.Name == "$1$SC1260.BIN"
&& entry.PackedId == (0x01000000L | (uint)entry.RawIndex)
&& entry.Kind == DebugScriptKind.Scenario);
Assert.All(entries, entry => Assert.Same(
catalog.ResolvePacked(entry.PackedId),
catalog.AppendPacks.GetValueOrDefault(entry.PackId, catalog).ResolveRaw(entry.RawIndex)));
Assert.Equal(entries.Count, entries.Select(entry => entry.PackedId).Distinct().Count());
}
[Fact]
public void DebugCatalogFiltersByProfileCategoryNameAndPackedId()
{
var entries = DebugSceneCatalog.Build(Sys4AssetCatalog.Load(Paths.Sys4Ini));
var scenarios = DebugSceneCatalog.Filter(entries, DebugScriptFilter.Scenario, "");
Assert.NotEmpty(scenarios);
Assert.All(scenarios, entry => Assert.Equal(DebugScriptKind.Scenario, entry.Kind));
Assert.Contains(scenarios, entry => entry.Name.Equals("SC0000.BIN", StringComparison.OrdinalIgnoreCase));
var debug = DebugSceneCatalog.Filter(entries, DebugScriptFilter.Debug, "DEBUG.BIN");
var exact = Assert.Single(debug);
Assert.Equal("DEBUG.BIN", exact.Name);
Assert.True(exact.Launchable);
Assert.Equal(exact, Assert.Single(DebugSceneCatalog.Filter(entries, DebugScriptFilter.All,
$"0x{exact.PackedId:x}")));
Assert.Equal(exact, Assert.Single(DebugSceneCatalog.Filter(entries, DebugScriptFilter.All,
exact.PackedId.ToString())));
Assert.False(entries.Single(entry => entry.Name == "SYSTEM4.BIN").Launchable);
Assert.False(entries.Single(entry => entry.Name == "TITLE.BIN").Launchable);
}
}

View File

@@ -43,6 +43,7 @@ internal class RecordingHost : IHost
public readonly List<long> CursorResources = new();
public readonly List<bool> AdvPagePresentationSuspended = new();
public int CursorClearCount;
public int SceneContextResets;
public void ShowText(int offset, string text) => Lines.Add((offset, text));
public void SetAdvTextCursor(int layoutSlot, int x, int y) => TextCursors.Add((layoutSlot, x, y));
public void DrawStringToSurface(int surfaceSlot, int x, int y, string text)
@@ -88,6 +89,7 @@ internal class RecordingHost : IHost
public void ClearCursorResource() => CursorClearCount++;
public virtual void Sleep(long duration) => SleptDurations.Add(duration);
public virtual void FrameYield() { }
public void ResetSceneContext() => SceneContextResets++;
public bool IsMessageSkipActive => MessageSkip;
public void SetMessageSkipActive(bool active)
{