using System; using System.Collections.Generic; using System.Linq; using Age.Engine.Hosting; using Age.Engine.Model; using Age.Engine.Sys4; using Age.Engine.Vm; using Xunit; public class HotspotInputTests { private sealed class StopAtFirstWaitException : Exception { } private sealed class InteractiveHost : RecordingHost { public VirtualMachine Vm = null!; public bool ClickConsumed; public bool SecondClickConsumed; public override void WaitForInput(int layoutSlot, Func serviceInputCallback) { Waits++; // Width/height are added to x/y, so the native rectangle includes (30,40). Vm.UpdatePointer(30, 40); while (serviceInputCallback()) { } Assert.Equal(1, Vm.Globals.GetValueOrDefault(0x100)); Vm.UpdatePointer(31, 40); while (serviceInputCallback()) { } Assert.Equal(1, Vm.Globals.GetValueOrDefault(0x101)); Vm.UpdatePointer(30, 40); while (serviceInputCallback()) { } ClickConsumed = Vm.TryActivatePointer(30, 40); while (serviceInputCallback()) { } SecondClickConsumed = Vm.TryActivatePointer(30, 40); while (serviceInputCallback()) { } } } private sealed class Sc0000HoverHost : RecordingHost { public VirtualMachine Vm = null!; public bool SawHistoryHover; public override void WaitForInput(int layoutSlot, Func serviceInputCallback) { Vm.UpdatePointer(684, 572); while (serviceInputCallback()) { } SawHistoryHover = Vm.Globals.GetValueOrDefault(0x6c9) == 1; throw new StopAtFirstWaitException(); } } private sealed class Sc0000MessageSkipHost : RecordingHost { public VirtualMachine Vm = null!; public override void WaitForInput(int layoutSlot, Func serviceInputCallback, Func autoWaitState) { Vm.UpdatePointer(728, 572); while (serviceInputCallback()) { } Assert.True(Vm.TryActivatePointer(728, 572)); while (serviceInputCallback()) { } throw new StopAtFirstWaitException(); } } private sealed class AutoStateHost : RecordingHost { public AdvAutoWaitState State; public override void WaitForInput(int layoutSlot, Func serviceInputCallback, Func autoWaitState) { while (serviceInputCallback()) { } State = autoWaitState(); Waits++; } } private sealed class MessageSkipCadenceHost : RecordingHost { public int ActiveSkipYields; public override void FrameYield() { if (MessageSkip) ActiveSkipYields++; } } private sealed class Sc0000HideWindowHost : RecordingHost { public VirtualMachine Vm = null!; private long _now; public int HideLoopSleeps; public bool HideReturned; public override long InputClockMilliseconds => _now; public override void Sleep(long duration) { base.Sleep(duration); _now += System.Math.Max(16, duration); if (duration <= 1 && ++HideLoopSleeps == 2) { Vm.UpdateMouseButtonState(0x1, false); // release the x=772 activation click Vm.QueueInputCallback(10); // common release callback arms HIDEWIN input } else if (duration <= 1 && HideLoopSleeps == 3) { Vm.UpdateMouseButtonState(0x1, true); // next primary click is generic action index 4 Vm.QueueInputCallback(4); } else if (duration <= 1 && HideLoopSleeps == 4) { Vm.UpdateMouseButtonState(0x1, false); Vm.QueueInputCallback(10); } } public override void WaitForInput(int layoutSlot, Func serviceInputCallback) { Vm.UpdatePointer(772, 572); while (serviceInputCallback()) { } Vm.UpdateMouseButtonState(0x1, true); Assert.True(Vm.TryActivatePointer(772, 572)); while (serviceInputCallback()) { } HideReturned = true; throw new StopAtFirstWaitException(); } } [Fact] public void ArmedHotspot_DispatchesHoverAndConsumesActivationWithoutAdvancingPage() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); // Main code ends at dword 20. Activation also exercises an ordinary local call beneath the // VM's temporary callback return sentinel. const int enterTarget = 20, leaveTarget = 26, activateTarget = 32; var script = ScriptAssembler.Assemble(table, "HOTSPOT", new List<(int, Operand[])> { (0x90, new[] { I(10), I(20), I(20), I(20), I(enterTarget), I(leaveTarget), I(activateTarget) }), (0x94, Array.Empty()), (0x72, new[] { I(1) }), (0x2, Array.Empty()), (0x55, new[] { G(0x100), I(1) }), (0x5, Array.Empty()), (0x55, new[] { G(0x101), I(1) }), (0x5, Array.Empty()), (0x8f, new[] { I(36) }), (0x5, Array.Empty()), (0x1b7, new[] { I(1) }), (0x5, Array.Empty()), }, Array.Empty()); var host = new InteractiveHost(); var vm = new VirtualMachine(script, table, host); host.Vm = vm; vm.Run(); Assert.True(host.ClickConsumed); Assert.True(host.SecondClickConsumed); Assert.True(vm.AutoMessageEnabled); Assert.Equal(1, host.Waits); Assert.Equal(7, host.InputCallbackFrames); Assert.Equal("exit", vm.HaltReason); Assert.False(vm.TryActivatePointer(30, 40)); // the script frame has exited, so no hotspot remains active } [Fact] public void Sc0000AdvChromeBootstrap_VisitsAllFiveVisibleButtonRegistrations() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table); var trace = new RecordingTraceSink { TracingSteps = true }; var vm = new VirtualMachine(script, table, new RecordingHost(), new VmOptions(HaltAtWaitForInput: true), sink: trace); vm.Globals[0x6c1] = 1; // inherited SYSTEM4 adv_chrome_enabled state seeded by Godot Main vm.Run(); Assert.Equal(new[] { 0x94, 0xa3, 0xb2, 0xc1, 0xd0 }, trace.Events .Where(e => e.Kind == Age.Engine.Diagnostics.TraceEventKind.Step && e.Opcode == 0x90 && e.Ins!.Offset < 0xdf) .Select(e => e.Ins!.Offset).ToArray()); Assert.Equal("wait-for-input", vm.HaltReason); } [Fact] public void Sc0000FirstWait_DispatchesRealHistoryHoverCallback() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table); var host = new Sc0000HoverHost(); var vm = new VirtualMachine(script, table, host, new VmOptions(MaxSteps: 1_000_000)); host.Vm = vm; vm.Globals[0x6c1] = 1; Assert.Throws(() => vm.Run()); Assert.True(host.InputCallbackFrames > 0); Assert.True(host.SawHistoryHover); } [Fact] public void AutoMessageOpcodes_RoundTripVmServiceState() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var script = ScriptAssembler.Assemble(table, "AUTO", new List<(int, Operand[])> { (0x1b7, new[] { I(1) }), (0x1b6, new[] { G(0x120) }), (0x1b7, new[] { I(0) }), (0x2, Array.Empty()), }, Array.Empty()); var vm = new VirtualMachine(script, table, new RecordingHost()); vm.Run(); Assert.Equal(1, vm.Globals.GetValueOrDefault(0x120)); Assert.False(vm.AutoMessageEnabled); } [Fact] public void AutoMessageTimeOpcodes_ConfigureWaitSchedulerState() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var script = ScriptAssembler.Assemble(table, "AUTO_TIMES", new List<(int, Operand[])> { (0x1b9, new[] { I(0), I(750) }), (0x1b9, new[] { I(1), I(2250) }), (0x1b8, new[] { I(0), G(0x130) }), (0x1b8, new[] { I(1), G(0x131) }), (0x1b7, new[] { I(1) }), (0x72, new[] { I(1) }), (0x2, Array.Empty()), }, Array.Empty()); var host = new AutoStateHost(); var vm = new VirtualMachine(script, table, host); vm.Run(); Assert.Equal(750, vm.Globals.GetValueOrDefault(0x130)); Assert.Equal(2250, vm.Globals.GetValueOrDefault(0x131)); Assert.Equal(new AdvAutoWaitState(true, false, 750, 2250), host.State); Assert.Equal(1, host.Waits); } [Fact] public void VoicePlayback_MarksAutoWaitUntilBlockMarkResetsIt() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var voiced = ScriptAssembler.Assemble(table, "AUTO_VOICE", new List<(int, Operand[])> { (0xc4, new[] { I(12) }), (0x72, new[] { I(1) }), (0x2, Array.Empty()), }, Array.Empty()); var reset = ScriptAssembler.Assemble(table, "AUTO_VOICE_RESET", new List<(int, Operand[])> { (0xc4, new[] { I(12) }), (0x1bc, Array.Empty()), (0x72, new[] { I(1) }), (0x2, Array.Empty()), }, Array.Empty()); var voicedHost = new AutoStateHost(); var resetHost = new AutoStateHost(); new VirtualMachine(voiced, table, voicedHost).Run(); new VirtualMachine(reset, table, resetHost).Run(); Assert.True(voicedHost.State.VoicePending); Assert.False(resetHost.State.VoicePending); } [Fact] public void MessageSkipOpcodes_RetainStateAcrossTransientResetUntilExplicitlyDisabled() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var script = ScriptAssembler.Assemble(table, "MESSAGE_SKIP", new List<(int, Operand[])> { (0x88, new[] { I(1) }), (0x19a, new[] { G(0x140) }), (0x1c7, new[] { G(0x141) }), (0x101, Array.Empty()), (0x1c7, new[] { G(0x142) }), (0x88, new[] { I(0) }), (0x19a, new[] { G(0x143) }), (0x2, Array.Empty()), }, Array.Empty()); var host = new RecordingHost(); var vm = new VirtualMachine(script, table, host); vm.Run(); Assert.Equal(1, vm.Globals.GetValueOrDefault(0x140)); Assert.Equal(1, vm.Globals.GetValueOrDefault(0x141)); Assert.Equal(1, vm.Globals.GetValueOrDefault(0x142)); Assert.Equal(0, vm.Globals.GetValueOrDefault(0x143)); Assert.False(vm.MessageSkipEnabled); Assert.Equal(new[] { true, false }, host.MessageSkipChanges); } [Fact] public void AdvSkipService_SuspendsAndRestoresPersistentMessageSkip() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var script = ScriptAssembler.Assemble(table, "MESSAGE_SKIP_LIFECYCLE", new List<(int, Operand[])> { (0x88, new[] { I(1) }), (0x19b, Array.Empty()), (0x19a, new[] { G(0x144) }), (0x1c7, new[] { G(0x145) }), (0x19c, Array.Empty()), (0x1c7, new[] { G(0x146) }), (0x2, Array.Empty()), }, Array.Empty()); var host = new RecordingHost(); var vm = new VirtualMachine(script, table, host); vm.Run(); Assert.Equal(1, vm.Globals.GetValueOrDefault(0x144)); Assert.Equal(0, vm.Globals.GetValueOrDefault(0x145)); Assert.Equal(1, vm.Globals.GetValueOrDefault(0x146)); Assert.True(vm.MessageSkipEnabled); Assert.Equal(new[] { true, false, true }, host.MessageSkipChanges); } [Fact] public void AdvSkipService_RestoreIncludesLiveReadSkipChannel() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var script = ScriptAssembler.Assemble(table, "READ_SKIP_LIFECYCLE", new List<(int, Operand[])> { (0x19b, Array.Empty()), (0x19c, Array.Empty()), (0x1c7, new[] { G(0x147) }), (0x2, Array.Empty()), }, Array.Empty()); var host = new RecordingHost { AdvReadSkip = true }; var vm = new VirtualMachine(script, table, host); vm.Run(); Assert.Equal(1, vm.Globals.GetValueOrDefault(0x147)); Assert.False(vm.MessageSkipEnabled); Assert.Equal(new[] { false, true }, host.MessageSkipChanges); } [Fact] public void Sc0000MessageSkipButton_EnablesPersistentServiceState() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table); var host = new Sc0000MessageSkipHost(); var vm = new VirtualMachine(script, table, host, new VmOptions(MaxSteps: 1_000_000)); host.Vm = vm; vm.Globals[0x6c1] = 1; Assert.Throws(() => vm.Run()); Assert.True(vm.MessageSkipEnabled); Assert.True(host.MessageSkip); Assert.Contains(true, host.MessageSkipChanges); } [Fact] public void CursorOpcodes_ForwardResourceAndClearToHost() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var script = ScriptAssembler.Assemble(table, "CURSOR", new List<(int, Operand[])> { (0x86, new[] { I(0x3318) }), (0x87, Array.Empty()), (0x2, Array.Empty()), }, Array.Empty()); var host = new RecordingHost(); new VirtualMachine(script, table, host).Run(); Assert.Equal(new long[] { 0x3318 }, host.CursorResources); Assert.Equal(1, host.CursorClearCount); } [Fact] public void Sc0000HideWindowButton_RunsRealHidewinAndReturnsToAdvWait() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var scene = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table); var hide = Sys4Loader.Load(Paths.Scripts()["HIDEWIN.BIN"], table); var provider = new MapProvider(new Dictionary { [0x20] = hide }); var trace = new RecordingTraceSink { TracingSteps = true }; var host = new Sc0000HideWindowHost(); var vm = new VirtualMachine(scene, table, host, new VmOptions(MaxSteps: 1_000_000), provider, trace); host.Vm = vm; vm.Globals[0x6c1] = 1; vm.Globals[0x62425] = 1; // inherited native ADV scheduler state, mirrored by Godot Main Assert.Throws(() => vm.Run()); Assert.True(host.HideLoopSleeps >= 4, $"hide sleeps={host.HideLoopSleeps}; halt={vm.HaltReason}; frames={string.Join(',', trace.Events.Where(e => e.Kind == Age.Engine.Diagnostics.TraceEventKind.FrameEnter).Select(e => e.Name))}; tail={string.Join(',', trace.Events.Where(e => e.Kind == Age.Engine.Diagnostics.TraceEventKind.Step).TakeLast(30).Select(e => $"{e.Ins!.Offset:x}:{e.Opcode:x}"))}"); Assert.Contains(trace.Events, e => e.Kind == Age.Engine.Diagnostics.TraceEventKind.FrameEnter && e.Name?.EndsWith("HIDEWIN.BIN", StringComparison.OrdinalIgnoreCase) == true); Assert.True(host.HideReturned); Assert.Equal(new[] { true, false }, host.AdvPagePresentationSuspended); } [Fact] public void MessageSkipState_ReachesHostBeforeFollowingOpcodeCadenceYields() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var script = ScriptAssembler.Assemble(table, "MESSAGE_SKIP_CADENCE", new List<(int, Operand[])> { (0x88, new[] { I(1) }), (0x55, new[] { G(0x150), I(1) }), (0x55, new[] { G(0x151), I(1) }), (0x88, new[] { I(0) }), (0x2, Array.Empty()), }, Array.Empty()); var host = new MessageSkipCadenceHost(); new VirtualMachine(script, table, host).Run(); Assert.Equal(3, host.ActiveSkipYields); } [Fact] public void AdvCoroutineYield_RunsHandlerAThenHandlerBAndResumesAfterOpcode() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); const int handlerA = 17, handlerB = 23; var script = ScriptAssembler.Assemble(table, "ADV_COROUTINE", new List<(int, Operand[])> { (0x7b, new[] { I(handlerA), I(handlerB) }), (0x55, new[] { G(0x160), I(1) }), (0x199, Array.Empty()), (0x55, new[] { G(0x163), I(1) }), (0x2, Array.Empty()), (0x55, new[] { G(0x161), I(1) }), (0x199, Array.Empty()), (0x55, new[] { G(0x162), I(1) }), (0x7c, Array.Empty()), }, Array.Empty()); var host = new RecordingHost(); var vm = new VirtualMachine(script, table, host); vm.Run(); Assert.Equal(1, vm.Globals.GetValueOrDefault(0x160)); Assert.Equal(1, vm.Globals.GetValueOrDefault(0x161)); Assert.Equal(1, vm.Globals.GetValueOrDefault(0x162)); Assert.Equal(1, vm.Globals.GetValueOrDefault(0x163)); Assert.Equal(new[] { true, false }, host.AdvPagePresentationSuspended); Assert.Equal("exit", vm.HaltReason); } [Fact] public void BitSetAndReset_UseBitIndicesRatherThanLiteralMasks() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var script = ScriptAssembler.Assemble(table, "BIT_INDEX", new List<(int, Operand[])> { (0x135, new[] { G(0x168), I(1) }), (0x135, new[] { G(0x168), I(4) }), (0x136, new[] { G(0x168), I(1) }), (0x2, Array.Empty()), }, Array.Empty()); var vm = new VirtualMachine(script, table, new RecordingHost()); vm.Run(); Assert.Equal(0x10, vm.Globals.GetValueOrDefault(0x168)); } [Fact] public void BitSet_RejectsNativeOutOfRangeIndex() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var script = ScriptAssembler.Assemble(table, "BIT_RANGE", new List<(int, Operand[])> { (0x135, new[] { G(0x169), I(32) }), (0x2, Array.Empty()), }, Array.Empty()); var vm = new VirtualMachine(script, table, new RecordingHost()); vm.Run(); Assert.Equal("bit-index-out-of-range:32", vm.HaltReason); Assert.Equal(0, vm.Globals.GetValueOrDefault(0x169)); } [Fact] public void MouseCallback_UsesLivePointerAndButtonState() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); const int callback = 7; var script = ScriptAssembler.Assemble(table, "MOUSE_CALLBACK", new List<(int, Operand[])> { (0xcc, new[] { I(0), I(callback) }), (0xcd, Array.Empty()), (0x2, Array.Empty()), (0x109, new[] { G(0x170), G(0x171) }), (0x108, new[] { G(0x172) }), (0x5, Array.Empty()), }, Array.Empty()); var vm = new VirtualMachine(script, table, new RecordingHost()); vm.UpdatePointer(321, 456); vm.UpdateMouseButtonState(0x1, true); vm.Run(); Assert.Equal(321, vm.Globals.GetValueOrDefault(0x170)); Assert.Equal(456, vm.Globals.GetValueOrDefault(0x171)); Assert.Equal(1, vm.Globals.GetValueOrDefault(0x172)); } [Fact] public void JoyCallbackTable_DispatchesHeldInput() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); const int callback = 8; var script = ScriptAssembler.Assemble(table, "JOY_CALLBACK", new List<(int, Operand[])> { (0xfb, new[] { I(0), I(callback) }), (0xff, Array.Empty()), (0x100, Array.Empty()), (0x2, Array.Empty()), (0x55, new[] { G(0x180), I(1) }), (0x5, Array.Empty()), }, Array.Empty()); var vm = new VirtualMachine(script, table, new RecordingHost()); vm.UpdateInputCallbackState(0, true); vm.Run(); Assert.Equal(1, vm.Globals.GetValueOrDefault(0x180)); } private static Operand I(long value) => new(0, value); private static Operand G(long address) => new(3, address); }