fix: align animation pacing and transforms with native
This commit is contained in:
@@ -20,10 +20,47 @@ public class FrameClockTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectiveBudget_ScalesBySpeed_AndFloorsAtOne()
|
||||
public void Advance_RetainsFractionalMillisecondsAtSlowSpeed()
|
||||
{
|
||||
Assert.Equal(30, new FrameClock { OpsPerFrame = 30, Speed = 1.0 }.EffectiveBudget);
|
||||
Assert.Equal(120, new FrameClock { OpsPerFrame = 30, Speed = 4.0 }.EffectiveBudget);
|
||||
Assert.Equal(1, new FrameClock { OpsPerFrame = 0, Speed = 1.0 }.EffectiveBudget);
|
||||
var c = new FrameClock { Speed = 0.1 };
|
||||
for (int i = 0; i < 10; i++) c.Advance(1.0 / 144.0);
|
||||
Assert.Equal(6, c.NowMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WallClockPacer_RateDoesNotDependOnRenderCallbackCount()
|
||||
{
|
||||
static long Simulate(int callbacks)
|
||||
{
|
||||
var c = new FrameClock();
|
||||
var p = new WallClockOpPacer(c);
|
||||
long ops = 0;
|
||||
p.OpcodeCompleted(); ops++;
|
||||
for (int frame = 0; frame < callbacks; frame++)
|
||||
{
|
||||
c.Advance(1.0 / callbacks);
|
||||
while (p.CanRunNext) { p.OpcodeCompleted(); ops++; }
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
long at60 = Simulate(60), at144 = Simulate(144), at240 = Simulate(240);
|
||||
Assert.InRange(at60, 198, 202);
|
||||
Assert.InRange(at144, 198, 202);
|
||||
Assert.InRange(at240, 198, 202);
|
||||
Assert.InRange(System.Math.Abs(at60 - at144), 0, 2);
|
||||
Assert.InRange(System.Math.Abs(at60 - at240), 0, 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WallClockPacer_ResetDropsParkedTimeCredit()
|
||||
{
|
||||
var c = new FrameClock();
|
||||
var p = new WallClockOpPacer(c);
|
||||
p.OpcodeCompleted();
|
||||
c.Advance(10);
|
||||
p.Reset();
|
||||
p.OpcodeCompleted();
|
||||
Assert.False(p.CanRunNext);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,23 @@ public class GfxAnimationTests
|
||||
Assert.Equal(1, vm.Gfx.AnimClockGeneration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetAnimClock_DispatchClearsOnlyGlobalServiceClock()
|
||||
{
|
||||
var t = T();
|
||||
var scene = ScriptAssembler.Assemble(t, "CLOCKRESET", new List<(int, Operand[])>
|
||||
{
|
||||
MovGI(1, 400),
|
||||
(0x238, new[] { G(1) }),
|
||||
(0x243, System.Array.Empty<Operand>()),
|
||||
Exit(),
|
||||
}, System.Array.Empty<string>());
|
||||
var vm = new VirtualMachine(scene, t, new RecordingHost());
|
||||
vm.Run();
|
||||
Assert.Equal(0, vm.Gfx.AnimClockDurationTicks);
|
||||
Assert.Equal(2, vm.Gfx.AnimClockGeneration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotSamplesDelayedMatrixChannels_WithoutUsingZAsOpacity()
|
||||
{
|
||||
@@ -138,4 +155,30 @@ public class GfxAnimationTests
|
||||
Assert.True(done.Rotation.Enabled);
|
||||
Assert.Equal(255, done.Alpha); // scale-Z=3, translation-Z=99, rotation-axis-Z=5: still opaque
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transform2D_UsesNativeAnchoredRowVectorOrder_AndDirectProjection()
|
||||
{
|
||||
var t = new TransformState(2, 3, 99, 10, -7, 1234, 100, 50, 888);
|
||||
var p = Transform2DMath.Apply(120, 60, t);
|
||||
Assert.Equal((150.0, 73.0), p);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transform2D_NegativeScaleMovesFarEdgeAcrossAnchor()
|
||||
{
|
||||
var t = new TransformState(-2, 1, 1, 0, 0, 0, 100, 0, 0);
|
||||
var left = Transform2DMath.Apply(90, 0, t);
|
||||
var right = Transform2DMath.Apply(110, 0, t);
|
||||
Assert.Equal((120.0, 0.0), left);
|
||||
Assert.Equal((80.0, 0.0), right);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transform2D_MatchesCapturedNativeSc0000ScaleEndpoint()
|
||||
{
|
||||
// Native handle 0xcbc0: base=(0,600), anchor=(400,1000), scale=5.
|
||||
var t = new TransformState(5, 5, 1, 0, 0, 0, 400, 1000, 0);
|
||||
Assert.Equal((-1600.0, -1000.0), Transform2DMath.Apply(0, 600, t));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,75 @@
|
||||
namespace Age.Engine.Hosting;
|
||||
|
||||
/// <summary>Host-owned virtual clock + per-frame op budget. Pure (no threading): the Godot host
|
||||
/// advances it once per rendered frame and consults it to pace the VM. The one <see cref="Speed"/>
|
||||
/// factor is the future (unwired) Ctrl fast-forward multiplier — scaling it scales the throttle
|
||||
/// budget, sleeps, and the anim tween together. See docs/superpowers/specs/2026-07-08-frame-stepped-vm-design.md.</summary>
|
||||
/// <summary>Host-owned virtual clock. Godot advances it from real elapsed time; VM pacing, sleeps, and
|
||||
/// retained graphics all consume this same timebase. Fractional milliseconds are retained so diagnostic
|
||||
/// slow motion does not stall on high-refresh displays.</summary>
|
||||
public sealed class FrameClock
|
||||
{
|
||||
/// <summary>Monotonic virtual time in milliseconds (scaled by Speed).</summary>
|
||||
public long NowMs { get; private set; }
|
||||
private long _nowMs;
|
||||
private double _fractionalMs;
|
||||
|
||||
/// <summary>Speed multiplier. 1.0 = normal. The future Ctrl hook (ADV-scoped); leave at 1.0 for now.</summary>
|
||||
/// <summary>Monotonic virtual time in milliseconds (scaled by Speed).</summary>
|
||||
public long NowMs => System.Threading.Interlocked.Read(ref _nowMs);
|
||||
|
||||
/// <summary>Speed multiplier. 1.0 = normal. A lower diagnostic value slows VM progress, sleeps, and
|
||||
/// graphics together; a future ADV-scoped Ctrl hook can drive the same seam.</summary>
|
||||
public double Speed = 1.0;
|
||||
|
||||
/// <summary>Base per-frame interpreter op budget (tunable by eye; ~30 ≈ 1,800 ops/sec at 60fps).</summary>
|
||||
public int OpsPerFrame = 30;
|
||||
/// <summary>Native normal-playback interpreter cadence. The old 1,800 figure counted calls to
|
||||
/// vm_operand_fetch, not completed opcodes. A live 1,890 ms transform section executes about 407
|
||||
/// port opcodes. A normal-speed replay at 215/s retained the object for 1,798 ms; 200/s reaches
|
||||
/// the native 1,890 ms endpoint before the same teardown path.</summary>
|
||||
public double OpsPerSecond = 200.0;
|
||||
|
||||
/// <summary>Advance the clock by one rendered frame's real delta (seconds), scaled by Speed.</summary>
|
||||
public void Advance(double realDeltaSeconds) => NowMs += (long)(realDeltaSeconds * 1000.0 * Speed);
|
||||
|
||||
/// <summary>Ops the VM may run before yielding a frame, scaled by Speed (min 1).</summary>
|
||||
public int EffectiveBudget => System.Math.Max(1, (int)System.Math.Round(OpsPerFrame * Speed));
|
||||
public void Advance(double realDeltaSeconds)
|
||||
{
|
||||
double scaled = realDeltaSeconds * 1000.0 * Speed + _fractionalMs;
|
||||
long whole = (long)System.Math.Floor(scaled);
|
||||
_fractionalMs = scaled - whole;
|
||||
if (whole > 0) System.Threading.Interlocked.Add(ref _nowMs, whole);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pure wall-clock opcode rate limiter. The VM thread records completed opcodes and waits whenever
|
||||
/// it has consumed the allowance earned from <see cref=FrameClock.NowMs/>. Reset after a blocking wait so
|
||||
/// parked time never turns into a catch-up burst.</summary>
|
||||
public sealed class WallClockOpPacer
|
||||
{
|
||||
private readonly FrameClock _clock;
|
||||
private bool _started;
|
||||
private long _epochMs;
|
||||
private long _completed;
|
||||
|
||||
public WallClockOpPacer(FrameClock clock) => _clock = clock;
|
||||
|
||||
public void OpcodeCompleted()
|
||||
{
|
||||
if (!_started)
|
||||
{
|
||||
_started = true;
|
||||
_epochMs = _clock.NowMs;
|
||||
_completed = 0;
|
||||
}
|
||||
_completed++;
|
||||
}
|
||||
|
||||
/// <summary>Whether the next opcode may execute at the clock's current time.</summary>
|
||||
public bool CanRunNext
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_started) return true;
|
||||
long elapsed = System.Math.Max(0, _clock.NowMs - _epochMs);
|
||||
long allowance = 1 + (long)System.Math.Floor(elapsed * _clock.OpsPerSecond / 1000.0);
|
||||
return _completed < allowance;
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_started = false;
|
||||
_epochMs = 0;
|
||||
_completed = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +234,12 @@ public sealed class GfxState
|
||||
lock (_lock) { AnimClockDurationTicks = durationTicks; AnimClockGeneration++; }
|
||||
}
|
||||
|
||||
/// <summary>Op 0x243: reset the separate global animation-service clock.</summary>
|
||||
public void ResetAnimClock()
|
||||
{
|
||||
lock (_lock) { AnimClockDurationTicks = 0; AnimClockGeneration++; }
|
||||
}
|
||||
|
||||
/// <summary>Back-compat: snapshot with no animation clock (nowMs = 0) — deterministic, for headless
|
||||
/// callers and existing tests.</summary>
|
||||
public IReadOnlyList<RenderObject> SnapshotVisibleObjects() => SnapshotVisibleObjects(0);
|
||||
|
||||
11
engine/Age.Engine/Model/Transform2DMath.cs
Normal file
11
engine/Age.Engine/Model/Transform2DMath.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Age.Engine.Model;
|
||||
|
||||
/// <summary>The axis-aligned 2D reduction of AGE's row-vector object matrix. Native composition is
|
||||
/// T(-anchor) * scale * middle(rotation) * translation * T(anchor). With rotation deferred, a point is
|
||||
/// therefore anchor + (point-anchor)*scale + translation; Z remains a retained 3D channel, not opacity.</summary>
|
||||
public static class Transform2DMath
|
||||
{
|
||||
public static (double X, double Y) Apply(double x, double y, TransformState transform)
|
||||
=> (transform.AnchorX + (x - transform.AnchorX) * transform.ScaleX + transform.TranslateX,
|
||||
transform.AnchorY + (y - transform.AnchorY) * transform.ScaleY + transform.TranslateY);
|
||||
}
|
||||
@@ -377,6 +377,11 @@ public sealed class VirtualMachine
|
||||
Gfx.SetRotationCycle(Read(a[0]), Read(a[1]), (Read(a[2]), Read(a[3]), Read(a[4]))); return pc + 1;
|
||||
case "set-anim-clock": // 0x238 (duration) — global, non-blocking (host advances it per-frame)
|
||||
Gfx.SetAnimClock(Read(a[0])); return pc + 1;
|
||||
case "reset-anim-clock": // 0x243: reset the separate global animation-service clock
|
||||
Gfx.ResetAnimClock(); return pc + 1;
|
||||
case "mark-frame-yield": // 0x21c: host already yields after every completed opcode
|
||||
case "clear-gfx-command-queue": // 0x224: retained compositor does not use this native queue
|
||||
return pc + 1;
|
||||
default:
|
||||
// Stub is per-instruction frequency (the VM handles ~30 ops; the rest hit here, e.g.
|
||||
// 0x258/0x259 stmt markers appear en masse), so gate it with Step — else --trace floods.
|
||||
|
||||
Reference in New Issue
Block a user