Implement native ADV Hide Window path
This commit is contained in:
28
engine/Age.Engine.Tests/CurDecoderTests.cs
Normal file
28
engine/Age.Engine.Tests/CurDecoderTests.cs
Normal 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"));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -32,6 +32,11 @@ public interface IHost
|
||||
=> WaitForInput(layoutSlot, serviceInputCallback);
|
||||
void WakeInputCallbackService() { }
|
||||
void InputCallbackCompleted(GfxState gfx) { }
|
||||
// Generic AGE input-callback services (ops 0xcc/0xcd, 0xfb/0xff/0x100, 0x108).
|
||||
// Interactive hosts expose the same monotonic clock used by their frame scheduler.
|
||||
long InputClockMilliseconds => Environment.TickCount64;
|
||||
void SetCursorResource(long resourceId) { }
|
||||
void ClearCursorResource() { }
|
||||
void Sleep(long duration);
|
||||
void FrameYield();
|
||||
// Native 0x1c7/0x1cc query two distinct ADV skip channels. Headless and non-interactive
|
||||
|
||||
80
engine/Age.Engine/Sys4/CurDecoder.cs
Normal file
80
engine/Age.Engine/Sys4/CurDecoder.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace Age.Engine.Sys4;
|
||||
|
||||
/// <summary>A decoded Windows cursor image and its native hotspot.</summary>
|
||||
public sealed record CursorImage(RgbaImage Image, int HotspotX, int HotspotY);
|
||||
|
||||
/// <summary>Decoder for Himegari's monochrome Windows .CUR resources.</summary>
|
||||
public static class CurDecoder
|
||||
{
|
||||
public static CursorImage Decode(ReadOnlySpan<byte> file, string name = "CUR")
|
||||
{
|
||||
if (file.Length < 22 || U16(file, 0) != 0 || U16(file, 2) != 2 || U16(file, 4) < 1)
|
||||
throw new InvalidDataException($"{name}: expected a Windows cursor directory");
|
||||
|
||||
int width = file[6] == 0 ? 256 : file[6];
|
||||
int height = file[7] == 0 ? 256 : file[7];
|
||||
int hotspotX = U16(file, 10);
|
||||
int hotspotY = U16(file, 12);
|
||||
int imageSize = I32(file, 14);
|
||||
int imageOffset = I32(file, 18);
|
||||
if (imageSize <= 0 || imageOffset < 22 || imageOffset > file.Length - imageSize)
|
||||
throw new InvalidDataException($"{name}: cursor image range is invalid");
|
||||
|
||||
int headerSize = I32(file, imageOffset);
|
||||
if (headerSize < 40 || imageOffset > file.Length - headerSize)
|
||||
throw new InvalidDataException($"{name}: unsupported bitmap header");
|
||||
int dibWidth = I32(file, imageOffset + 4);
|
||||
int dibHeight = I32(file, imageOffset + 8);
|
||||
int planes = U16(file, imageOffset + 12);
|
||||
int bitsPerPixel = U16(file, imageOffset + 14);
|
||||
int compression = I32(file, imageOffset + 16);
|
||||
if (dibWidth != width || System.Math.Abs(dibHeight) != height * 2 || planes != 1
|
||||
|| bitsPerPixel != 1 || compression != 0)
|
||||
throw new InvalidDataException($"{name}: expected an uncompressed 1-bit {width}x{height} cursor");
|
||||
|
||||
int paletteOffset = checked(imageOffset + headerSize);
|
||||
if (paletteOffset > file.Length - 8) throw new InvalidDataException($"{name}: palette is truncated");
|
||||
int xorStride = checked(((width + 31) / 32) * 4);
|
||||
int maskBytes = checked(xorStride * height);
|
||||
int xorOffset = checked(paletteOffset + 8);
|
||||
int andOffset = checked(xorOffset + maskBytes);
|
||||
if (andOffset > file.Length - maskBytes) throw new InvalidDataException($"{name}: cursor masks are truncated");
|
||||
|
||||
var rgba = new byte[checked(width * height * 4)];
|
||||
bool bottomUp = dibHeight > 0;
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
int sourceY = bottomUp ? height - 1 - y : y;
|
||||
int xorRow = xorOffset + sourceY * xorStride;
|
||||
int andRow = andOffset + sourceY * xorStride;
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
int shift = 7 - (x & 7);
|
||||
int paletteIndex = (file[xorRow + (x >> 3)] >> shift) & 1;
|
||||
bool transparent = ((file[andRow + (x >> 3)] >> shift) & 1) != 0 && paletteIndex == 0;
|
||||
int palette = paletteOffset + paletteIndex * 4;
|
||||
int dst = (y * width + x) * 4;
|
||||
rgba[dst] = file[palette + 2];
|
||||
rgba[dst + 1] = file[palette + 1];
|
||||
rgba[dst + 2] = file[palette];
|
||||
rgba[dst + 3] = transparent ? (byte)0 : (byte)255;
|
||||
}
|
||||
}
|
||||
|
||||
return new CursorImage(new RgbaImage(width, height, rgba), hotspotX, hotspotY);
|
||||
}
|
||||
|
||||
private static int U16(ReadOnlySpan<byte> data, int offset)
|
||||
{
|
||||
if ((uint)offset > (uint)(data.Length - 2)) throw new InvalidDataException("CUR: truncated field");
|
||||
return BinaryPrimitives.ReadUInt16LittleEndian(data[offset..]);
|
||||
}
|
||||
|
||||
private static int I32(ReadOnlySpan<byte> data, int offset)
|
||||
{
|
||||
if ((uint)offset > (uint)(data.Length - 4)) throw new InvalidDataException("CUR: truncated field");
|
||||
return BinaryPrimitives.ReadInt32LittleEndian(data[offset..]);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,21 @@ public sealed class ResourceMap
|
||||
/// <summary>Decode an AGF directly from loose-first VFS bytes.</summary>
|
||||
public RgbaImage DecodeTexture(AssetEntry entry) => AgfDecoder.Decode(_store, entry);
|
||||
|
||||
/// <summary>Resolve a native packed raw id to one of AGE's Windows cursor resources.</summary>
|
||||
public AssetEntry? ResolveCursor(long resourceId)
|
||||
{
|
||||
var entry = _catalog.ResolvePacked(resourceId);
|
||||
return entry is { IsPlaceholder: false }
|
||||
&& entry.Name.EndsWith(".CUR", StringComparison.OrdinalIgnoreCase) ? entry : null;
|
||||
}
|
||||
|
||||
public CursorImage DecodeCursor(AssetEntry entry)
|
||||
{
|
||||
if (!entry.Name.EndsWith(".CUR", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException($"not a CUR asset: {entry.Name}");
|
||||
return CurDecoder.Decode(_store.ReadAll(entry), entry.Name);
|
||||
}
|
||||
|
||||
public AssetEntry? ResolveName(string name) => _catalog.ResolveName(name);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -13,7 +13,15 @@ internal sealed class ExecFrame
|
||||
public readonly Dictionary<int, int> EmitSeen = new();
|
||||
public int? CoroutineYieldHandlerA; // op 0x7b: native per-frame handler PCs
|
||||
public int? CoroutineYieldHandlerB;
|
||||
public int? CoroutineResumePc; // op 0x199 -> handler A/B -> op 0x7c
|
||||
public bool CoroutineYieldActive;
|
||||
public readonly Dictionary<int, int> CoroutineYieldVisits = new(); // instruction index -> visits
|
||||
public readonly int[] InputCallbackTargets = Enumerable.Repeat(-1, 32).ToArray(); // op 0xfb
|
||||
public int PendingInputCallbackMask; // op 0xff snapshot consumed by op 0x100
|
||||
public int InputCallbackScanIndex;
|
||||
public int MouseCallbackTarget = -1; // op 0xcc target dword offset
|
||||
public long MouseCallbackIntervalMs;
|
||||
public long MouseCallbackNextAtMs;
|
||||
public readonly HotspotRegistry Hotspots = new();
|
||||
public ExecFrame(Script script, int pc) { Script = script; Pc = pc; }
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ public sealed class VirtualMachine
|
||||
private readonly object _interactiveLock = new();
|
||||
private ExecFrame? _interactiveFrame;
|
||||
private int _pointerX = int.MinValue, _pointerY = int.MinValue;
|
||||
private int _mouseButtonState;
|
||||
private int _heldInputCallbackMask;
|
||||
private int _queuedInputCallbackMask;
|
||||
private bool _autoMessageEnabled;
|
||||
private long _autoMessageTime0Ms = 500;
|
||||
private long _autoMessageTime1Ms = 2000;
|
||||
@@ -79,6 +82,39 @@ public sealed class VirtualMachine
|
||||
return consumed;
|
||||
}
|
||||
|
||||
/// <summary>Update one native mouse-button bit (left=0x1, right=0x2 in Himegari).</summary>
|
||||
public void UpdateMouseButtonState(int bit, bool pressed) => UpdateMaskBit(ref _mouseButtonState, bit, pressed);
|
||||
|
||||
/// <summary>Update one held AGE input-callback index used by ops 0xfb/0xff/0x100.</summary>
|
||||
public void UpdateInputCallbackState(int index, bool pressed)
|
||||
{
|
||||
if ((uint)index >= 32) return;
|
||||
UpdateMaskBit(ref _heldInputCallbackMask, 1 << index, pressed);
|
||||
}
|
||||
|
||||
/// <summary>Queue a one-shot AGE input callback, such as the shared release callback at index 10.</summary>
|
||||
public void QueueInputCallback(int index)
|
||||
{
|
||||
if ((uint)index >= 32) return;
|
||||
int bit = 1 << index;
|
||||
int before, after;
|
||||
do
|
||||
{
|
||||
before = Volatile.Read(ref _queuedInputCallbackMask);
|
||||
after = before | bit;
|
||||
} while (Interlocked.CompareExchange(ref _queuedInputCallbackMask, after, before) != before);
|
||||
}
|
||||
|
||||
private static void UpdateMaskBit(ref int field, int bit, bool set)
|
||||
{
|
||||
int before, after;
|
||||
do
|
||||
{
|
||||
before = Volatile.Read(ref field);
|
||||
after = set ? before | bit : before & ~bit;
|
||||
} while (Interlocked.CompareExchange(ref field, after, before) != before);
|
||||
}
|
||||
|
||||
private static long Gi(Dictionary<int, long> d, int k) => d.TryGetValue(k, out var v) ? v : 0;
|
||||
private long ReadGlobal(int k) => ExternalGlobals.TryGetValue(k, out var v) ? v : Gi(Globals, k);
|
||||
private static string Gs(Dictionary<int, string> d, int k) => d.TryGetValue(k, out var v) ? v : "";
|
||||
@@ -253,7 +289,13 @@ public sealed class VirtualMachine
|
||||
if (sentinel >= 0) _cur.CallStack.RemoveAt(sentinel);
|
||||
}
|
||||
lock (_interactiveLock)
|
||||
{
|
||||
// History/Hide callbacks cancel the active registry, run a nested script, then republish the
|
||||
// parent frame's definitions. Nested RunFrame deliberately clears the disarmed interactive
|
||||
// pointer, so restore the still-running parent before rearming its rebuilt registry.
|
||||
if (_interactiveFrame == null && _cur.Hotspots.HasDefinitions) _interactiveFrame = _cur;
|
||||
_interactiveFrame?.Hotspots.RearmAfterCallback(_pointerX, _pointerY);
|
||||
}
|
||||
_host.InputCallbackCompleted(Gfx);
|
||||
return true;
|
||||
}
|
||||
@@ -307,9 +349,31 @@ public sealed class VirtualMachine
|
||||
_cur.CoroutineYieldHandlerA = (int)Read(a[0]);
|
||||
_cur.CoroutineYieldHandlerB = (int)Read(a[1]);
|
||||
return pc + 1;
|
||||
case "u00414D50":
|
||||
case "yield-adv-coroutine": // 0x199: A -> nested service -> B -> 0x7c resume
|
||||
{
|
||||
int? targetOffset;
|
||||
if (!_cur.CoroutineYieldActive)
|
||||
{
|
||||
_cur.CoroutineResumePc = pc + 1;
|
||||
_cur.CoroutineYieldActive = true;
|
||||
targetOffset = _cur.CoroutineYieldHandlerA;
|
||||
}
|
||||
else targetOffset = _cur.CoroutineYieldHandlerB;
|
||||
|
||||
return targetOffset is int offset
|
||||
? _cur.Script.IndexByOffset.GetValueOrDefault(offset, pc + 1)
|
||||
: pc + 1;
|
||||
}
|
||||
case "u00416A90":
|
||||
case "coroutine-resume": // 0x7c: host FrameYield/FrameClock owns re-entry
|
||||
return pc + 1;
|
||||
case "coroutine-resume": // 0x7c: restore the PC saved by op 0x199
|
||||
if (_cur.CoroutineResumePc is int resumePc)
|
||||
{
|
||||
_cur.CoroutineResumePc = null;
|
||||
_cur.CoroutineYieldActive = false;
|
||||
return resumePc;
|
||||
}
|
||||
return pc + 1; // cold bounded scene-entry path
|
||||
case "u0041F9C0":
|
||||
case "coroutine-label-yield": // 0x140: bounded host model for LABEL/J only
|
||||
{
|
||||
@@ -426,6 +490,71 @@ public sealed class VirtualMachine
|
||||
_cur.Hotspots.BindKey((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]),
|
||||
(int)Read(a[3]), (int)Read(a[4]));
|
||||
return pc + 1;
|
||||
case "u0041B210":
|
||||
case "set-cursor-resource": // 0x86: raw indexed .CUR resource
|
||||
_host.SetCursorResource(Read(a[0])); return pc + 1;
|
||||
case "u00414D10":
|
||||
case "clear-cursor-resource": // 0x87
|
||||
_host.ClearCursorResource(); return pc + 1;
|
||||
case "mouse_callback":
|
||||
case "register-mouse-callback": // 0xcc (poll interval ms, local target dword offset)
|
||||
_cur.MouseCallbackIntervalMs = System.Math.Max(0, Read(a[0]));
|
||||
_cur.MouseCallbackTarget = (int)Read(a[1]);
|
||||
_cur.MouseCallbackNextAtMs = _host.InputClockMilliseconds + _cur.MouseCallbackIntervalMs;
|
||||
return pc + 1;
|
||||
case "get-input-type":
|
||||
case "dispatch-mouse-callback": // 0xcd
|
||||
{
|
||||
long now = _host.InputClockMilliseconds;
|
||||
if (_cur.MouseCallbackTarget < 0 || now < _cur.MouseCallbackNextAtMs) return pc + 1;
|
||||
_cur.MouseCallbackNextAtMs = now + _cur.MouseCallbackIntervalMs;
|
||||
if (!_cur.Script.IndexByOffset.TryGetValue(_cur.MouseCallbackTarget, out int target))
|
||||
return pc + 1;
|
||||
_cur.CallStack.Add(pc + 1);
|
||||
return target;
|
||||
}
|
||||
case "joy_callback":
|
||||
case "register-joy-callback": // 0xfb (input index, local target dword offset)
|
||||
{
|
||||
int index = (int)Read(a[0]);
|
||||
if ((uint)index < 32) _cur.InputCallbackTargets[index] = (int)Read(a[1]);
|
||||
return pc + 1;
|
||||
}
|
||||
case "u00415A10":
|
||||
case "poll-joy-callback-input": // 0xff
|
||||
_cur.PendingInputCallbackMask = Volatile.Read(ref _heldInputCallbackMask)
|
||||
| Interlocked.Exchange(ref _queuedInputCallbackMask, 0);
|
||||
_cur.InputCallbackScanIndex = 0;
|
||||
return pc + 1;
|
||||
case "u00415A60":
|
||||
case "dispatch-joy-callbacks": // 0x100
|
||||
while (_cur.InputCallbackScanIndex < 32)
|
||||
{
|
||||
int index = _cur.InputCallbackScanIndex++;
|
||||
if ((_cur.PendingInputCallbackMask & (1 << index)) == 0) continue;
|
||||
int targetOffset = _cur.InputCallbackTargets[index];
|
||||
if (targetOffset < 0 || !_cur.Script.IndexByOffset.TryGetValue(targetOffset, out int target))
|
||||
continue;
|
||||
// Resume on op 0x100 so another simultaneously active input can dispatch.
|
||||
_cur.CallStack.Add(pc);
|
||||
return target;
|
||||
}
|
||||
return pc + 1;
|
||||
case "u00415E70":
|
||||
case "get-mouse-button-state": // 0x108
|
||||
Write(a[0], Volatile.Read(ref _mouseButtonState)); return pc + 1;
|
||||
case "u00415EC0":
|
||||
case "get-cursor-virtual": // 0x109
|
||||
{
|
||||
int x, y;
|
||||
lock (_interactiveLock) { x = _pointerX; y = _pointerY; }
|
||||
Write(a[0], x == int.MinValue ? 0 : x);
|
||||
Write(a[1], y == int.MinValue ? 0 : y);
|
||||
return pc + 1;
|
||||
}
|
||||
case "u0041E540":
|
||||
case "set-cursor-virtual": // 0x10a; retain the virtual position even without OS warping
|
||||
UpdatePointer((int)Read(a[0]), (int)Read(a[1])); return pc + 1;
|
||||
case "sleep": // 0xc8 (duration) — pause the host duration ms; headless hosts no-op (parity). Frame pacing.
|
||||
_host.Sleep(Read(a[0])); return pc + 1;
|
||||
case "u0041B290":
|
||||
|
||||
Reference in New Issue
Block a user