Implement History timed callback scrolling

This commit is contained in:
gamer147
2026-07-19 21:15:15 -04:00
parent 3fa09b8b42
commit 5ba16505d6
10 changed files with 291 additions and 37 deletions

View File

@@ -0,0 +1,66 @@
using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
public class TimedCallbackOpsTests
{
private const int T_IMM = 0, T_GINT = 3;
private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson);
private static Operand I(long value) => new(T_IMM, value);
private static Operand G(int address) => new(T_GINT, address);
private sealed class ClockHost(long overshootMs = 0) : RecordingHost
{
private long _now;
public override long InputClockMilliseconds => _now;
public override void Sleep(long duration)
{
base.Sleep(duration);
_now += duration + overshootMs;
}
}
private static Script BuildTwoEventSequence()
=> ScriptAssembler.Assemble(Table, "TIMED_CALLBACKS",
new List<(int, Operand[])>
{
(0xd3, Array.Empty<Operand>()), // 0
(0xd4, new[] { I(10), I(3), I(14), I(22) }), // 1: two events + look-ahead sentinel
(0xd5, new[] { I(-1) }), // 10
(0x2, Array.Empty<Operand>()), // 13
(0x50, new[] { G(0x100), G(0x100), I(1) }), // 14: on-time callback
(0x5, Array.Empty<Operand>()), // 21: ret
(0x50, new[] { G(0x101), G(0x101), I(1) }), // 22: catch-up callback
(0x5, Array.Empty<Operand>()), // 29: ret
}, Array.Empty<string>());
[Fact]
public void RelativeSequenceRunsEveryPrimaryCallbackAtItsDeadline()
{
var host = new ClockHost();
var vm = new VirtualMachine(BuildTwoEventSequence(), Table, host);
vm.Run();
Assert.Equal("exit", vm.HaltReason);
Assert.Equal(2, vm.Globals[0x100]);
Assert.Equal(0, vm.Globals.GetValueOrDefault(0x101));
Assert.Equal(new long[] { 10, 10 }, host.SleptDurations);
}
[Fact]
public void SequenceUsesCatchUpTargetWhenTheFollowingDeadlineHasPassed()
{
var host = new ClockHost(overshootMs: 15);
var vm = new VirtualMachine(BuildTwoEventSequence(), Table, host);
vm.Run();
Assert.Equal("exit", vm.HaltReason);
Assert.Equal(1, vm.Globals[0x100]);
Assert.Equal(1, vm.Globals[0x101]);
Assert.Equal(new long[] { 10 }, host.SleptDurations);
}
}

View File

@@ -6,6 +6,8 @@ namespace Age.Engine.Vm;
/// are shared across frames; everything here is per-call and discarded on return.</summary>
internal sealed class ExecFrame
{
internal readonly record struct TimedCallback(long DeadlineMs, int PrimaryOffset, int CatchUpOffset);
public readonly Script Script;
public int Pc; // entry instruction index
public readonly Frame Locals = new();
@@ -22,6 +24,10 @@ internal sealed class ExecFrame
public int MouseCallbackTarget = -1; // op 0xcc target dword offset
public long MouseCallbackIntervalMs;
public long MouseCallbackNextAtMs;
public readonly List<TimedCallback> TimedCallbacks = new(); // ops 0xd3/0xd4/0xd5
public int TimedCallbackCursor;
public long? TimedCallbackStartedAtMs;
public int TimedCallbackAbortOffset = -1;
public readonly HotspotRegistry Hotspots = new();
public ExecFrame(Script script, int pc) { Script = script; Pc = pc; }
}

View File

@@ -739,6 +739,63 @@ public sealed class VirtualMachine
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 "u00425960":
case "begin-timed-callback-sequence": // 0xd3: clear the frame-local relative schedule
_cur.TimedCallbacks.Clear();
_cur.TimedCallbackCursor = 0;
_cur.TimedCallbackStartedAtMs = null;
_cur.TimedCallbackAbortOffset = -1;
return pc + 1;
case "u004266F0":
case "append-relative-timed-callbacks": // 0xd4: (interval ms, count, on-time PC, catch-up PC)
{
long intervalMs = Read(a[0]);
int count = System.Math.Max(0, unchecked((int)Read(a[1])));
int primaryOffset = unchecked((int)Read(a[2]));
int catchUpOffset = unchecked((int)Read(a[3]));
long deadlineMs = _cur.TimedCallbacks.Count == 0 ? 0 : _cur.TimedCallbacks[^1].DeadlineMs;
for (int i = 0; i < count; i++)
{
deadlineMs += intervalMs;
_cur.TimedCallbacks.Add(new ExecFrame.TimedCallback(
deadlineMs, primaryOffset, catchUpOffset));
}
return pc + 1;
}
case "u004262C0":
case "run-timed-callback-sequence": // 0xd5: dispatch each scheduled local callback, resuming here after ret
{
if (_cur.TimedCallbackStartedAtMs == null)
{
_cur.TimedCallbackStartedAtMs = _host.InputClockMilliseconds;
_cur.TimedCallbackAbortOffset = unchecked((int)Read(a[0]));
}
// Native op 0xd5 stops at last_index rather than count. The final entry is a
// look-ahead sentinel: it supplies the next deadline for the preceding event but
// is not itself dispatched.
if (_cur.TimedCallbackCursor >= _cur.TimedCallbacks.Count - 1)
{
_cur.TimedCallbackStartedAtMs = null;
return pc + 1;
}
var callback = _cur.TimedCallbacks[_cur.TimedCallbackCursor];
long elapsedMs = _host.InputClockMilliseconds - _cur.TimedCallbackStartedAtMs.Value;
if (elapsedMs < callback.DeadlineMs)
{
_host.Sleep(callback.DeadlineMs - elapsedMs);
elapsedMs = _host.InputClockMilliseconds - _cur.TimedCallbackStartedAtMs.Value;
}
bool fellBehind = _cur.TimedCallbackCursor + 1 < _cur.TimedCallbacks.Count
&& _cur.TimedCallbacks[_cur.TimedCallbackCursor + 1].DeadlineMs < elapsedMs;
int targetOffset = fellBehind ? callback.CatchUpOffset : callback.PrimaryOffset;
_cur.TimedCallbackCursor++;
if (targetOffset < 0 || !_cur.Script.IndexByOffset.TryGetValue(targetOffset, out int target))
return pc;
_cur.CallStack.Add(pc);
return target;
}
case "u0041B290":
case "set-message-skip": // 0x88: persistent all-message fast-forward service state
_messageSkipEnabled = Read(a[0]) != 0;