Implement ADV controls and native Auto timing

This commit is contained in:
gamer147
2026-07-18 17:16:06 -04:00
parent 96e5306148
commit 05518a200f
20 changed files with 1184 additions and 256 deletions

View File

@@ -0,0 +1,53 @@
using Age.Engine.Hosting;
using Xunit;
public class AdvAutoAdvanceTimerTests
{
[Fact]
public void UnvoicedWait_UsesAutoMessageTime1()
{
var timer = new AdvAutoAdvanceTimer();
var state = new AdvAutoWaitState(true, false, 500, 2000);
Assert.False(timer.Poll(state, false, 1000));
Assert.False(timer.Poll(state, false, 2999));
Assert.True(timer.Poll(state, false, 3000));
}
[Fact]
public void VoicedWait_WaitsForVoiceThenUsesAutoMessageTime0()
{
var timer = new AdvAutoAdvanceTimer();
var state = new AdvAutoWaitState(true, true, 500, 2000);
Assert.False(timer.Poll(state, true, 1000));
Assert.False(timer.Poll(state, true, 9000));
Assert.False(timer.Poll(state, false, 9000));
Assert.False(timer.Poll(state, false, 9499));
Assert.True(timer.Poll(state, false, 9500));
}
[Fact]
public void DisablingAuto_CancelsDeadlineAndReenableStartsFresh()
{
var timer = new AdvAutoAdvanceTimer();
var on = new AdvAutoWaitState(true, false, 500, 2000);
Assert.False(timer.Poll(on, false, 0));
Assert.False(timer.Poll(on with { Enabled = false }, false, 1500));
Assert.False(timer.Poll(on, false, 5000));
Assert.False(timer.Poll(on, false, 6999));
Assert.True(timer.Poll(on, false, 7000));
}
[Fact]
public void ZeroConfiguration_UsesNativeHundredMillisecondFallback()
{
var timer = new AdvAutoAdvanceTimer();
var state = new AdvAutoWaitState(true, false, 0, 0);
Assert.False(timer.Poll(state, false, 0));
Assert.False(timer.Poll(state, false, 99));
Assert.True(timer.Poll(state, false, 100));
}
}

View File

@@ -0,0 +1,214 @@
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<bool> 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<bool> serviceInputCallback)
{
Vm.UpdatePointer(684, 572);
while (serviceInputCallback()) { }
SawHistoryHover = Vm.Globals.GetValueOrDefault(0x6c9) == 1;
throw new StopAtFirstWaitException();
}
}
private sealed class AutoStateHost : RecordingHost
{
public AdvAutoWaitState State;
public override void WaitForInput(int layoutSlot, Func<bool> serviceInputCallback,
Func<AdvAutoWaitState> autoWaitState)
{
while (serviceInputCallback()) { }
State = autoWaitState();
Waits++;
}
}
[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<Operand>()),
(0x72, new[] { I(1) }),
(0x2, Array.Empty<Operand>()),
(0x55, new[] { G(0x100), I(1) }),
(0x5, Array.Empty<Operand>()),
(0x55, new[] { G(0x101), I(1) }),
(0x5, Array.Empty<Operand>()),
(0x8f, new[] { I(36) }),
(0x5, Array.Empty<Operand>()),
(0x1b7, new[] { I(1) }),
(0x5, Array.Empty<Operand>()),
}, Array.Empty<string>());
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<StopAtFirstWaitException>(() => 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<Operand>()),
}, Array.Empty<string>());
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<Operand>()),
}, Array.Empty<string>());
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<Operand>()),
}, Array.Empty<string>());
var reset = ScriptAssembler.Assemble(table, "AUTO_VOICE_RESET", new List<(int, Operand[])>
{
(0xc4, new[] { I(12) }),
(0x1bc, Array.Empty<Operand>()),
(0x72, new[] { I(1) }),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
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);
}
private static Operand I(long value) => new(0, value);
private static Operand G(long address) => new(3, address);
}

View File

@@ -6,11 +6,12 @@ using Age.Engine.Model;
/// <summary>Shared test doubles: a host that records observable effects, and an in-memory script
/// provider for synthetic call-script targets.</summary>
internal sealed class RecordingHost : IHost
internal class RecordingHost : IHost
{
public int Waits;
public int Presents;
public int TransitionWaits;
public int InputCallbackFrames;
public bool MessageSkip;
public bool AdvReadSkip;
public readonly List<(int Offset, string Text)> Lines = new();
@@ -29,6 +30,15 @@ internal sealed class RecordingHost : IHost
=> SurfaceStrings.Add((surfaceSlot, x, y, text));
public void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) => WaitIndicators.Add(config);
public void WaitForInput() => Waits++;
public virtual void WaitForInput(int layoutSlot, Func<bool> serviceInputCallback)
{
while (serviceInputCallback()) { }
WaitForInput();
}
public virtual void WaitForInput(int layoutSlot, Func<bool> serviceInputCallback,
Func<AdvAutoWaitState> autoWaitState)
=> WaitForInput(layoutSlot, serviceInputCallback);
public void InputCallbackCompleted(GfxState gfx) => InputCallbackFrames++;
public void Sleep(long duration) => SleptDurations.Add(duration);
public void FrameYield() { }
public bool IsMessageSkipActive => MessageSkip;

View File

@@ -0,0 +1,50 @@
namespace Age.Engine.Hosting;
/// <summary>
/// Native ADV Auto timing policy. Unvoiced waits use AutoMessageTime1; voiced waits park until voice
/// completion and then use AutoMessageTime0. The host supplies a monotonic clock and live voice state.
/// </summary>
public sealed class AdvAutoAdvanceTimer
{
private bool _armed;
private bool _waitingForVoice;
private long _deadlineMs;
public void Reset()
{
_armed = false;
_waitingForVoice = false;
_deadlineMs = 0;
}
public bool Poll(AdvAutoWaitState state, bool voiceActive, long nowMs)
{
if (!state.Enabled)
{
Reset();
return false;
}
if (!_armed)
{
_armed = true;
_waitingForVoice = state.VoicePending && voiceActive;
if (!_waitingForVoice)
_deadlineMs = nowMs + EffectiveDelay(
state.VoicePending ? state.PostVoiceDelayMs : state.UnvoicedDelayMs);
}
if (_waitingForVoice)
{
if (voiceActive) return false;
_waitingForVoice = false;
_deadlineMs = nowMs + EffectiveDelay(state.PostVoiceDelayMs);
}
return nowMs >= _deadlineMs;
}
// Native callers substitute 100 ms when either configuration getter returns zero.
private static long EffectiveDelay(long configuredMs)
=> configuredMs == 0 ? 100 : System.Math.Max(1, configuredMs);
}

View File

@@ -7,6 +7,9 @@ public readonly record struct AdvWaitIndicatorConfig(
int SourceX, int SourceY, int CellWidth, int CellHeight,
int TerminalFrame, long FramePeriodMs);
public readonly record struct AdvAutoWaitState(
bool Enabled, bool VoicePending, long PostVoiceDelayMs, long UnvoicedDelayMs);
public interface IHost
{
void ShowText(int offset, string text);
@@ -17,6 +20,18 @@ public interface IHost
void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) { }
void WaitForInput();
void WaitForInput(int layoutSlot) => WaitForInput();
// Interactive hosts service script callbacks on the VM thread while the enclosing ADV page remains
// parked. The callback returns true while another queued input callback is ready to run.
void WaitForInput(int layoutSlot, Func<bool> serviceInputCallback)
{
while (serviceInputCallback()) { }
WaitForInput(layoutSlot);
}
void WaitForInput(int layoutSlot, Func<bool> serviceInputCallback,
Func<AdvAutoWaitState> autoWaitState)
=> WaitForInput(layoutSlot, serviceInputCallback);
void WakeInputCallbackService() { }
void InputCallbackCompleted(GfxState gfx) { }
void Sleep(long duration);
void FrameYield();
// Native 0x1c7/0x1cc query two distinct ADV skip channels. Headless and non-interactive

View File

@@ -14,5 +14,6 @@ internal sealed class ExecFrame
public int? CoroutineYieldHandlerA; // op 0x7b: native per-frame handler PCs
public int? CoroutineYieldHandlerB;
public readonly Dictionary<int, int> CoroutineYieldVisits = new(); // instruction index -> visits
public readonly HotspotRegistry Hotspots = new();
public ExecFrame(Script script, int pc) { Script = script; Pc = pc; }
}

View File

@@ -0,0 +1,109 @@
namespace Age.Engine.Vm;
/// <summary>Per-script-frame registry populated by SYS4 ops 0x90/0x94/0x97.</summary>
internal sealed class HotspotRegistry
{
private sealed class Entry
{
public int Left, Top, Right, Bottom;
public int EnterTarget, LeaveTarget, ActivateTarget;
public int? InputBit;
public bool Contains(int x, int y)
=> x >= Left && x <= Right && y >= Top && y <= Bottom;
}
private readonly List<Entry> _entries = new();
private readonly Queue<int> _pendingTargets = new();
private int _hovered = -1;
private bool _replaceOnNextRegister;
public bool Armed { get; private set; }
public bool HasDefinitions => _entries.Count != 0;
public void Register(int x, int y, int width, int height,
int enterTarget, int leaveTarget, int activateTarget)
{
if (_replaceOnNextRegister)
{
_entries.Clear();
_replaceOnNextRegister = false;
}
_entries.Add(new Entry
{
Left = x, Top = y, Right = x + width, Bottom = y + height,
EnterTarget = enterTarget, LeaveTarget = leaveTarget,
ActivateTarget = activateTarget,
});
}
public void BindKey(int x, int y, int width, int height, int inputBit)
{
int right = x + width, bottom = y + height;
var entry = _entries.FirstOrDefault(e => e.Left == x && e.Top == y
&& e.Right == right && e.Bottom == bottom);
if (entry != null) entry.InputBit = inputBit;
}
public bool Arm(int pointerX, int pointerY)
{
_replaceOnNextRegister = false;
Armed = true;
return UpdatePointer(pointerX, pointerY);
}
public bool UpdatePointer(int x, int y)
{
if (!Armed) return false;
int hit = FindHit(x, y);
if (hit == _hovered) return false;
if (_hovered >= 0) QueueTarget(_entries[_hovered].LeaveTarget);
_hovered = hit;
if (_hovered >= 0) QueueTarget(_entries[_hovered].EnterTarget);
return _pendingTargets.Count != 0;
}
public bool Activate(int x, int y)
{
if (!Armed) return false;
int hit = FindHit(x, y);
if (hit < 0) return false;
int target = _entries[hit].ActivateTarget;
// Native consumes the active input registration before dispatch. Its ADV scheduler revisits the
// shared registration routine afterward; retain the definitions so the blocking host can model
// that revisit without advancing the enclosing dialogue page.
_pendingTargets.Clear();
_hovered = -1;
Armed = false;
QueueTarget(target);
return true;
}
public bool RearmAfterCallback(int pointerX, int pointerY)
=> !Armed && _entries.Count != 0 && Arm(pointerX, pointerY);
public bool TryDequeue(out int target) => _pendingTargets.TryDequeue(out target);
public void Reset()
{
_pendingTargets.Clear();
_hovered = -1;
Armed = false;
// The native ADV coroutine can republish the shared definitions after a cancellation. If script
// code explicitly registers again first, replace this retained template instead of duplicating it.
_replaceOnNextRegister = _entries.Count != 0;
}
private int FindHit(int x, int y)
{
for (int i = 0; i < _entries.Count; i++)
if (_entries[i].Contains(x, y)) return i;
return -1;
}
private void QueueTarget(int target)
{
if (target >= 0 && (uint)target != uint.MaxValue) _pendingTargets.Enqueue(target);
}
}

View File

@@ -8,6 +8,7 @@ public sealed class VirtualMachine
private const long NoJump = 0xFFFFFFFF;
private const int HALT = int.MinValue;
private const int FRAME_RETURN = int.MinValue + 1;
private const int HOTSPOT_RETURN = int.MinValue + 2;
private const int SceneEntryCoroutineGate = 0xaba5c;
private const int T_IMM = 0, T_STR = 2, T_GINT = 3, T_GFLOAT = 4, T_GSTR = 5, T_GPTR = 6,
T_GSTRPTR = 8, T_LINT = 9, T_LFLOAT = 10, T_LSTR = 11, T_LPTR = 12,
@@ -22,6 +23,13 @@ public sealed class VirtualMachine
private ExecFrame _cur = null!;
private int _depth;
private readonly ITraceSink _sink;
private readonly object _interactiveLock = new();
private ExecFrame? _interactiveFrame;
private int _pointerX = int.MinValue, _pointerY = int.MinValue;
private bool _autoMessageEnabled;
private long _autoMessageTime0Ms = 500;
private long _autoMessageTime1Ms = 2000;
private bool _autoVoicePending;
public long CallScriptDispatches { get; private set; }
public Dictionary<int, long> Globals { get; } = new();
@@ -32,12 +40,43 @@ public sealed class VirtualMachine
public List<(int Offset, string Text, string Script)> Emitted { get; } = new();
public string? HaltReason { get; private set; }
public long Steps { get; private set; }
public bool AutoMessageEnabled => _autoMessageEnabled;
public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null,
IScriptProvider? provider = null, ITraceSink? sink = null)
{ _s = s; _t = t; _host = host; _o = o ?? new VmOptions(); _provider = provider;
_sink = sink ?? NullTraceSink.Instance; }
/// <summary>Update the native 800x600 cursor coordinate without advancing the current ADV page.</summary>
public void UpdatePointer(int x, int y)
{
bool wake = false;
lock (_interactiveLock)
{
_pointerX = x; _pointerY = y;
if (_interactiveFrame != null)
wake = _interactiveFrame.Hotspots.UpdatePointer(x, y);
}
if (wake) _host.WakeInputCallbackService();
}
/// <summary>Queue an armed hotspot's activation callback. True means the click was consumed.</summary>
public bool TryActivatePointer(int x, int y)
{
bool consumed = false;
lock (_interactiveLock)
{
_pointerX = x; _pointerY = y;
if (_interactiveFrame != null)
{
_interactiveFrame.Hotspots.UpdatePointer(x, y);
consumed = _interactiveFrame.Hotspots.Activate(x, y);
}
}
if (consumed) _host.WakeInputCallbackService();
return consumed;
}
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 : "";
@@ -156,6 +195,8 @@ public sealed class VirtualMachine
private FrameOutcome RunFrame(ExecFrame frame, FrameCause cause, long callId = 0)
{
ExecFrame? previousInteractiveFrame;
lock (_interactiveLock) previousInteractiveFrame = _interactiveFrame;
var prev = _cur; _cur = frame; _depth++;
_sink.Emit(TraceEvent.FrameEnter(frame.Script.Name, _depth, cause, callId));
var outcome = FrameOutcome.RanOff;
@@ -172,10 +213,49 @@ public sealed class VirtualMachine
pc = next;
}
_sink.Emit(TraceEvent.FrameExit(frame.Script.Name, _depth, outcome.ToString()));
lock (_interactiveLock)
{
if (cause == FrameCause.CallScript)
_interactiveFrame = previousInteractiveFrame?.Hotspots.Armed == true
? previousInteractiveFrame : null;
else if (ReferenceEquals(_interactiveFrame, frame))
_interactiveFrame = null;
}
_cur = prev; _depth--;
return outcome;
}
private bool ServiceHotspotCallback()
{
int target;
lock (_interactiveLock)
{
if (_interactiveFrame == null || !_interactiveFrame.Hotspots.TryDequeue(out target)) return false;
}
if (_cur.Script.IndexByOffset.TryGetValue(target, out int pc))
{
_cur.CallStack.Add(HOTSPOT_RETURN);
while (pc >= 0 && pc < _cur.Script.Instructions.Count)
{
if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; break; }
Steps++;
if (_sink.TracingSteps) _sink.Emit(TraceEvent.Step(pc, _cur.Script.Instructions[pc], _depth));
int next = Step(_cur.Script.Instructions[pc], pc);
_host.FrameYield();
if (next == HOTSPOT_RETURN || next == FRAME_RETURN) break;
if (next == HALT) break;
pc = next;
}
// A malformed callback must not leave its sentinel in the page's ordinary local-call stack.
int sentinel = _cur.CallStack.LastIndexOf(HOTSPOT_RETURN);
if (sentinel >= 0) _cur.CallStack.RemoveAt(sentinel);
}
lock (_interactiveLock)
_interactiveFrame?.Hotspots.RearmAfterCallback(_pointerX, _pointerY);
_host.InputCallbackCompleted(Gfx);
return true;
}
private int Step(Instruction ins, int pc)
{
int op = ins.Opcode;
@@ -295,7 +375,55 @@ public sealed class VirtualMachine
case "wait-for-input":
// Faithful headless: no player => halt here rather than plow past every prompt (see VmOptions).
if (_o.HaltAtWaitForInput) { HaltReason ??= "wait-for-input"; return HALT; }
_host.WaitForInput((int)Read(a[0])); return pc + 1;
// The native ADV chrome is a coroutine: after an earlier 0x93 cancellation its shared
// registration pass runs again before a stable message wait. Our blocking host models that
// scheduler boundary by re-arming the frame's retained definitions here.
bool wakeAtWait = false;
lock (_interactiveLock)
{
if (_cur.Hotspots.HasDefinitions && !_cur.Hotspots.Armed)
{
_interactiveFrame = _cur;
wakeAtWait = _cur.Hotspots.Arm(_pointerX, _pointerY);
}
}
if (wakeAtWait) _host.WakeInputCallbackService();
_host.WaitForInput((int)Read(a[0]), ServiceHotspotCallback,
() => new AdvAutoWaitState(_autoMessageEnabled, _autoVoicePending,
_autoMessageTime0Ms, _autoMessageTime1Ms));
return pc + 1;
case "u0041BEB0":
case "register-hotspot-callbacks": // 0x90: inclusive rect + enter/leave/activate local callbacks
lock (_interactiveLock)
_cur.Hotspots.Register((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]),
(int)Read(a[4]), (int)Read(a[5]), (int)Read(a[6]));
return pc + 1;
case "u00415040":
case "cancel-hotspot-wait": // 0x93
lock (_interactiveLock)
{
_cur.Hotspots.Reset();
if (ReferenceEquals(_interactiveFrame, _cur)) _interactiveFrame = null;
}
return pc + 1;
case "u00415090":
case "arm-hotspot-wait": // 0x94
{
bool wake;
lock (_interactiveLock)
{
_interactiveFrame = _cur;
wake = _cur.Hotspots.Arm(_pointerX, _pointerY);
}
if (wake) _host.WakeInputCallbackService();
return pc + 1;
}
case "u0041C150":
case "bind-hotspot-key": // 0x97: keyboard/pad routing is a later host-input slice
lock (_interactiveLock)
_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 "sleep": // 0xc8 (duration) — pause the host duration ms; headless hosts no-op (parity). Frame pacing.
_host.Sleep(Read(a[0])); return pc + 1;
case "get-message-skip": // 0x1c7: Ctrl/message fast-forward run-state bit
@@ -303,6 +431,24 @@ public sealed class VirtualMachine
case "get-adv-read-skip-state": // 0x1cc: per-message read/click skip service state
case "get-adv-service-state": // compatibility with pre-recovery generated tables
Write(a[0], _host.IsAdvReadSkipActive ? 1 : 0); return pc + 1;
case "u00414F60":
case "get-auto-message": // 0x1b6: VM service state used by the ADV redraw callback
Write(a[0], _autoMessageEnabled ? 1 : 0); return pc + 1;
case "u0041B640":
case "set-auto-message": // 0x1b7
_autoMessageEnabled = Read(a[0]) != 0; return pc + 1;
case "u0041B670":
case "get-auto-message-time": // 0x1b8 (selector 0=post-voice Time0, 1=unvoiced Time1, out)
Write(a[1], Read(a[0]) == 0 ? _autoMessageTime0Ms : _autoMessageTime1Ms); return pc + 1;
case "u0041B710":
case "set-auto-message-time": // 0x1b9 (selector, milliseconds)
if (Read(a[0]) == 0) _autoMessageTime0Ms = Read(a[1]);
else if (Read(a[0]) == 1) _autoMessageTime1Ms = Read(a[1]);
return pc + 1;
case "u00415670":
case "block-mark":
case "reset-message-voice-state": // 0x1bc resets native per-message voice/queued-voice state
_autoVoicePending = false; return pc + 1;
case "end-text-line": case "set-font":
case "comment": case "display-furigana": case "dev_ukn":
return pc + 1;
@@ -329,7 +475,9 @@ public sealed class VirtualMachine
return pc + 1;
}
case "play-bgm": _host.PlayBgm(Read(a[0])); return pc + 1;
case "play-voice": _host.PlayVoice(Read(a[0])); return pc + 1;
case "play-voice":
_autoVoicePending = true;
_host.PlayVoice(Read(a[0])); return pc + 1;
case "play-sound-effect": // 0xb4 / semantics: sfx-load
_host.LoadSoundEffect(Read(a[0]), (int)Read(a[1])); return pc + 1;
case "u0041D050": // 0xb5 / semantics: sfx-start