Implement native ADV Hide Window path

This commit is contained in:
gamer147
2026-07-18 21:14:06 -04:00
parent 6255f42794
commit d8ead3da6e
18 changed files with 578 additions and 35 deletions

View File

@@ -0,0 +1,28 @@
using System.Linq;
using Age.Engine.Sys4;
using Xunit;
public class CurDecoderTests
{
[Fact]
public void HimegariCursor_DecodesPixelsAndHotspot()
{
var resources = ResourceMap.Load();
var entry = resources.ResolveCursor(0x3318);
Assert.NotNull(entry);
Assert.Equal("CURSOR03.CUR", entry!.Name);
var cursor = resources.DecodeCursor(entry);
Assert.Equal(32, cursor.Image.Width);
Assert.Equal(32, cursor.Image.Height);
Assert.Equal(25, cursor.HotspotX);
Assert.Equal(25, cursor.HotspotY);
Assert.Contains(cursor.Image.Pixels.Where((_, i) => i % 4 == 3), alpha => alpha == 0);
Assert.Contains(cursor.Image.Pixels.Where((_, i) => i % 4 == 3), alpha => alpha == 255);
}
[Fact]
public void TruncatedCursor_IsRejected()
=> Assert.Throws<InvalidDataException>(() => CurDecoder.Decode(new byte[21], "bad.cur"));
}

View File

@@ -90,6 +90,35 @@ public class HotspotInputTests
}
}
private sealed class Sc0000HideWindowHost : RecordingHost
{
public VirtualMachine Vm = null!;
private long _now;
public int HideLoopSleeps;
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.UpdateMouseButtonState(0x2, true); // native right-click close/restore gesture
}
}
public override void WaitForInput(int layoutSlot, Func<bool> serviceInputCallback)
{
Vm.UpdatePointer(772, 572);
while (serviceInputCallback()) { }
Vm.UpdateMouseButtonState(0x1, true);
Assert.True(Vm.TryActivatePointer(772, 572));
while (serviceInputCallback()) { }
throw new StopAtFirstWaitException();
}
}
[Fact]
public void ArmedHotspot_DispatchesHoverAndConsumesActivationWithoutAdvancingPage()
{
@@ -278,6 +307,46 @@ public class HotspotInputTests
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<Operand>()),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
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<long, Script> { [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<StopAtFirstWaitException>(() => vm.Run());
Assert.True(host.HideLoopSleeps >= 2,
$"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);
}
[Fact]
public void MessageSkipState_ReachesHostBeforeFollowingOpcodeCadenceYields()
{
@@ -297,6 +366,81 @@ public class HotspotInputTests
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<Operand>()),
(0x55, new[] { G(0x163), I(1) }),
(0x2, Array.Empty<Operand>()),
(0x55, new[] { G(0x161), I(1) }),
(0x199, Array.Empty<Operand>()),
(0x55, new[] { G(0x162), I(1) }),
(0x7c, Array.Empty<Operand>()),
}, Array.Empty<string>());
var vm = new VirtualMachine(script, table, new RecordingHost());
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("exit", vm.HaltReason);
}
[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<Operand>()),
(0x2, Array.Empty<Operand>()),
(0x109, new[] { G(0x170), G(0x171) }),
(0x108, new[] { G(0x172) }),
(0x5, Array.Empty<Operand>()),
}, Array.Empty<string>());
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<Operand>()),
(0x100, Array.Empty<Operand>()),
(0x2, Array.Empty<Operand>()),
(0x55, new[] { G(0x180), I(1) }),
(0x5, Array.Empty<Operand>()),
}, Array.Empty<string>());
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);
}

View File

@@ -25,6 +25,8 @@ internal class RecordingHost : IHost
public readonly List<(int Target, long Duration)> BgmFades = new();
public readonly List<(long Resource, int Surface, long Flags, long SyncMask)> Movies = new();
public readonly List<bool> MessageSkipChanges = new();
public readonly List<long> CursorResources = new();
public int CursorClearCount;
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)
@@ -40,7 +42,10 @@ internal class RecordingHost : IHost
Func<AdvAutoWaitState> autoWaitState)
=> WaitForInput(layoutSlot, serviceInputCallback);
public void InputCallbackCompleted(GfxState gfx) => InputCallbackFrames++;
public void Sleep(long duration) => SleptDurations.Add(duration);
public virtual long InputClockMilliseconds => Environment.TickCount64;
public void SetCursorResource(long resourceId) => CursorResources.Add(resourceId);
public void ClearCursorResource() => CursorClearCount++;
public virtual void Sleep(long duration) => SleptDurations.Add(duration);
public virtual void FrameYield() { }
public bool IsMessageSkipActive => MessageSkip;
public void SetMessageSkipActive(bool active)