diff --git a/src/AcDream.App/Rendering/CameraFrameController.cs b/src/AcDream.App/Rendering/CameraFrameController.cs
index ff56239d..949942d7 100644
--- a/src/AcDream.App/Rendering/CameraFrameController.cs
+++ b/src/AcDream.App/Rendering/CameraFrameController.cs
@@ -94,11 +94,23 @@ internal sealed class CameraFrameController : ICameraFramePhase
_spatialReconciler.Reconcile();
MovementResult result = playerFrame.Movement;
+ // #429 defect 2: the chase camera smooths toward the PRESENTED player
+ // position, which lives on the retail 30 Hz object clock (see
+ // PlayerMovementController.PresentedDeltaSeconds). Integrating the
+ // damping with wall dt made the camera step full wall time on long
+ // frames while the presented player under-advanced against the
+ // quantum — measured ~1 m of camera/player decoherence in one frame,
+ // the felt run-hitch. Retail ties the camera to the physics-update
+ // callback (SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60), i.e.
+ // the same clock as the body; the presented delta restores that.
+ // Manual zoom/pitch adjustment above stays on wall dt — it is a
+ // user-input rate, not target chasing.
+ float cameraDt = controller.PresentedDeltaSeconds;
legacy.Update(
result.RenderPosition,
controller.Yaw,
isOnGround: result.IsOnGround,
- dt: timing.SimulationDeltaSecondsSingle);
+ dt: cameraDt);
retail?.Update(
result.RenderPosition,
@@ -106,7 +118,7 @@ internal sealed class CameraFrameController : ICameraFramePhase
playerVelocity: controller.BodyVelocity,
isOnGround: result.IsOnGround,
contactPlaneNormal: controller.ContactPlane.Normal,
- dt: timing.SimulationDeltaSecondsSingle,
+ dt: cameraDt,
cellId: controller.CellId,
selfEntityId: controller.LocalEntityId,
trackedTargetPoint: _combatTarget.GetTrackedTargetPoint());
diff --git a/src/AcDream.App/Rendering/PlayerPresentationProbe.cs b/src/AcDream.App/Rendering/PlayerPresentationProbe.cs
deleted file mode 100644
index d3a471b0..00000000
--- a/src/AcDream.App/Rendering/PlayerPresentationProbe.cs
+++ /dev/null
@@ -1,75 +0,0 @@
-using System.Diagnostics;
-using System.Globalization;
-using System.Numerics;
-
-namespace AcDream.App.Rendering;
-
-///
-/// TEMPORARY #429 apparatus. One CSV row per rendered frame:
-/// seconds,camX,camY,camZ,playerX,playerY,playerZ, where seconds is a
-/// Stopwatch-derived monotonic time and player is the frame's
-/// PlayerViewPosition — the presented local-player position driving
-/// lighting/visibility this frame. Off unless
-/// ACDREAM_PROBE_PLAYER_PRESENT=<path> is set (one null check per
-/// frame). Analysis: a hitch frame shows the player's per-frame delta
-/// collapsing to ~0 or doubling; whether the SAME frame's dt is smooth
-/// separates a presentation-phase bug (pack frame graph sampling a stale
-/// snapshot) from genuine frame-pacing spikes. Strip with the #429 fix.
-///
-internal sealed class PlayerPresentationProbe : IDisposable
-{
- private readonly StreamWriter _writer;
- private readonly long _startTimestamp = Stopwatch.GetTimestamp();
- private int _linesSinceFlush;
-
- private PlayerPresentationProbe(StreamWriter writer)
- {
- _writer = writer;
- _writer.WriteLine("seconds,camX,camY,camZ,playerX,playerY,playerZ");
- }
-
- internal static PlayerPresentationProbe? CreateFromEnvironment()
- {
- string? path = Environment.GetEnvironmentVariable("ACDREAM_PROBE_PLAYER_PRESENT");
- if (string.IsNullOrWhiteSpace(path))
- return null;
-
- try
- {
- return new PlayerPresentationProbe(new StreamWriter(path, append: false));
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine(
- $"[player-present] probe file '{path}' could not be opened: {ex.Message}");
- return null;
- }
- }
-
- internal void Observe(in Vector3 cameraPosition, in Vector3 playerViewPosition)
- {
- double seconds = (Stopwatch.GetTimestamp() - _startTimestamp)
- / (double)Stopwatch.Frequency;
- _writer.WriteLine(string.Create(
- CultureInfo.InvariantCulture,
- $"{seconds:F6},{cameraPosition.X:F4},{cameraPosition.Y:F4},{cameraPosition.Z:F4},{playerViewPosition.X:F4},{playerViewPosition.Y:F4},{playerViewPosition.Z:F4}"));
- if (++_linesSinceFlush >= 240)
- {
- _linesSinceFlush = 0;
- _writer.Flush();
- }
- }
-
- public void Dispose()
- {
- try
- {
- _writer.Flush();
- _writer.Dispose();
- }
- catch
- {
- // A probe must never turn teardown fallible.
- }
- }
-}
diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs
index 86be4d0a..7fdc66ae 100644
--- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs
+++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs
@@ -450,10 +450,6 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
private readonly HashSet _visibleCells = [];
private bool _visibleCellsValid;
- /// TEMPORARY #429 — see .
- private readonly PlayerPresentationProbe? _playerPresentProbe =
- PlayerPresentationProbe.CreateFromEnvironment();
-
public RuntimeWorldFrameEnvironmentPreparation(
RuntimeOptions options,
WorldTimeService worldTime,
@@ -489,10 +485,6 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
activeDayGroup,
camera.Position);
- // TEMPORARY #429 apparatus — one CSV row per frame; off unless
- // ACDREAM_PROBE_PLAYER_PRESENT names a file. Strip with the fix.
- _playerPresentProbe?.Observe(camera.Position, roots.PlayerViewPosition);
-
UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell);
_lighting.UpdateViewerLight(roots.PlayerViewPosition);
_lighting.Tick(camera.Position);
diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
index 3714e39f..66ed3c81 100644
--- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
+++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
@@ -344,6 +344,60 @@ public sealed class PlayerMovementController
///
internal bool AdvancedObjectQuantumLastTick { get; private set; }
+ ///
+ /// #429 defect 2: how far the PRESENTED position's own clock advanced in
+ /// the last tick, in seconds. The presented position
+ /// () lives on the retail 30 Hz object clock —
+ /// a lerp between the last two simulated quanta whose alpha clamps at 1 —
+ /// so near-quantum-length host frames alias against the 33.3 ms quantum
+ /// and the presented position under-advances relative to wall time. Any
+ /// consumer that smooths toward the presented position (the chase camera)
+ /// must integrate THIS delta, not the host frame's wall dt, or the two
+ /// visibly decohere on long frames (measured at ~1 m in one frame, the
+ /// felt run-hitch). Retail ties its camera to the physics-update callback
+ /// (SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60) — the same
+ /// clock — which is the behavior this delta restores.
+ ///
+ public float PresentedDeltaSeconds { get; private set; }
+
+ ///
+ /// Seconds spanned by the prev→curr snapshot pair the presentation lerp
+ /// interpolates across — the length of the LAST simulated quantum, which
+ /// the retail clock makes variable (MinQuantum..MaxQuantum). Maintained by
+ /// , the single per-tick chokepoint.
+ ///
+ private float _lastQuantumSeconds = PhysicsBody.MinQuantum;
+
+ private float ComputePresentedDelta(
+ float wallDt,
+ double pendingBeforeSeconds,
+ in RetailObjectQuantumBatch batch)
+ {
+ if (batch.Discarded)
+ {
+ // The lerp base was reset to the current body position — the
+ // presented position snapped. Hand the camera the wall step so it
+ // snaps along rather than freezing mid-teleport.
+ _lastQuantumSeconds = PhysicsBody.MinQuantum;
+ return wallDt;
+ }
+ // Presented time = t(curr) − lastInterval + min(pending, lastInterval):
+ // continuous across quantum fires, long-run rate 1 with bounded jitter.
+ // The per-tick advance is the simulated seconds plus the phase change.
+ float previousInterval = _lastQuantumSeconds;
+ float simulated = 0f;
+ if (batch.Count > 0)
+ {
+ simulated = batch.FullSteps * PhysicsBody.MaxQuantum + batch.Remainder;
+ _lastQuantumSeconds = batch.GetQuantum(batch.Count - 1);
+ }
+ float interval = _lastQuantumSeconds;
+ float before = Math.Min((float)pendingBeforeSeconds, previousInterval);
+ float after = Math.Min((float)_objectClock.PendingSeconds, interval);
+ float delta = simulated + (after - interval) - (before - previousInterval);
+ return Math.Max(delta, 0f);
+ }
+
///
/// Returns retail's canonical outbound Position: the physics body's
/// carried cell id plus its landblock-local frame origin. Retail
@@ -1043,6 +1097,7 @@ public sealed class PlayerMovementController
{
EnsurePublishedForRuntimeOperation();
AdvancedObjectQuantumLastTick = false;
+ PresentedDeltaSeconds = 0f;
if (float.IsFinite(elapsedSeconds) && elapsedSeconds > 0f)
_simTimeSeconds += elapsedSeconds;
_objectClock.Deactivate();
@@ -2188,8 +2243,16 @@ public sealed class PlayerMovementController
private Vector3 ComputeRenderPosition()
{
+ // #429 defect 2 (residual): the prev→curr snapshot pair spans the LAST
+ // SIMULATED QUANTUM, whose length is variable — the retail clock
+ // simulates everything above MinQuantum in one step, so a long host
+ // frame produces a 34-100 ms quantum. Normalizing the interpolation by
+ // the fixed MinQuantum made the presented position freeze the frame a
+ // long quantum fired (alpha reset to 0 across a bigger gap) and then
+ // replay at gap/MinQuantum speed — the residual whole-view hiccup
+ // after the camera-clock fix. Normalize by the actual interval.
float alpha = Math.Clamp(
- (float)(_objectClock.PendingSeconds / PhysicsBody.MinQuantum),
+ (float)(_objectClock.PendingSeconds / _lastQuantumSeconds),
0f,
1f);
return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha);
@@ -2206,6 +2269,7 @@ public sealed class PlayerMovementController
{
EnsurePublishedForRuntimeOperation();
AdvancedObjectQuantumLastTick = false;
+ PresentedDeltaSeconds = 0f;
if (!float.IsFinite(dt) || dt <= 0f)
{
return CapturePresentationResult() with
@@ -2216,12 +2280,14 @@ public sealed class PlayerMovementController
}
_simTimeSeconds += dt;
+ double pendingBeforeSeconds = _objectClock.PendingSeconds;
bool reactivated = _objectClock.Activate();
_body.TransientState |= TransientStateFlags.Active;
RetailObjectQuantumBatch batch = reactivated
? default
: _objectClock.Advance(dt);
AdvancedObjectQuantumLastTick = batch.Count > 0;
+ PresentedDeltaSeconds = ComputePresentedDelta(dt, pendingBeforeSeconds, in batch);
if (batch.Discarded)
{
_prevPhysicsPos = _body.Position;
@@ -2326,6 +2392,7 @@ public sealed class PlayerMovementController
{
EnsurePublishedForRuntimeOperation();
AdvancedObjectQuantumLastTick = false;
+ PresentedDeltaSeconds = 0f;
// Reject a malformed host-frame duration at the controller boundary.
// The retail object clock cannot sanitize state that input/jump/yaw
// code already mutated; in particular Infinity would never converge
@@ -2641,12 +2708,15 @@ public sealed class PlayerMovementController
// stale gaps above HugeQuantum. Every admitted quantum executes the
// whole object update below; animation never runs on a render-only
// fragment.
+ double pendingBeforeSeconds = _objectClock.PendingSeconds;
bool reactivated = _objectClock.Activate();
_body.TransientState |= TransientStateFlags.Active;
RetailObjectQuantumBatch quantumBatch = reactivated
? default
: _objectClock.Advance(dt);
AdvancedObjectQuantumLastTick = quantumBatch.Count > 0;
+ PresentedDeltaSeconds =
+ ComputePresentedDelta(dt, pendingBeforeSeconds, in quantumBatch);
bool justLanded = false;
if (quantumBatch.Discarded)
{
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
index 3ddb04a1..1f794ed1 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
@@ -546,8 +546,14 @@ public class PlayerMovementControllerTests
Assert.True(halfFrame.RenderPosition.X < firstTick.Position.X,
$"Render X={halfFrame.RenderPosition.X} should stay between {start.X} and {firstTick.Position.X}");
- float expectedMidpoint = start.X + ((firstTick.Position.X - start.X) * 0.5f);
- Assert.Equal(expectedMidpoint, halfFrame.RenderPosition.X, precision: 3);
+ // #429 defect 2 (residual): the interpolation normalizes by the LAST
+ // SIMULATED QUANTUM's length — here the ObjectTick-long remainder
+ // quantum, not the fixed MinQuantum — so the presented position
+ // advances at a continuous rate across variable-length quanta instead
+ // of freezing then over-speeding after a long host frame.
+ float alpha = (PhysicsBody.MinQuantum * 0.5f) / ObjectTick;
+ float expected = start.X + ((firstTick.Position.X - start.X) * alpha);
+ Assert.Equal(expected, halfFrame.RenderPosition.X, precision: 3);
}
[Fact]
@@ -653,27 +659,32 @@ public class PlayerMovementControllerTests
}
[Fact]
- public void Update_LeftoverAboveMinQuantum_ClampsRenderAlphaToCurrentPhysicsPosition()
+ public void Update_LeftoverAboveMinQuantum_InterpolatesAcrossTheActualQuantumInterval()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
- controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
+ var start = new Vector3(96f, 96f, 50f);
+ controller.SeedPlacementForTest(start, 0x0001, start);
controller.Yaw = 0f;
var result = controller.Update(
PhysicsBody.MaxQuantum + PhysicsBody.MinQuantum,
new MovementInput(Forward: true));
- // Tolerance, not decimal `precision:` — the AP-7 friction port (P2)
- // shifts the velocity-fallback trajectory by micrometers, and
- // Math.Round-based precision comparison fails when two essentially
- // equal values straddle a 5e-5 rounding boundary (observed: X
- // 96.3427505 vs 96.3427429 — a 7.6 µm gap rounding to 96.3428 vs
- // 96.3427). The clamp contract is "render == physics for
- // presentation"; 1 mm is far below visibility and boundary-immune.
- Assert.Equal(result.Position.X, result.RenderPosition.X, tolerance: 1e-3f);
- Assert.Equal(result.Position.Y, result.RenderPosition.Y, tolerance: 1e-3f);
- Assert.Equal(result.Position.Z, result.RenderPosition.Z, tolerance: 1e-3f);
+ // #429 defect 2 (residual): one MaxQuantum step simulates and
+ // MinQuantum is retained as pending, so the prev→curr interpolation
+ // pair spans a MaxQuantum-long interval and the presented position
+ // sits MinQuantum INTO it — lerp(start, Position, Min/Max) — rather
+ // than clamping onto the authoritative body. The former clamp
+ // contract ("render == physics when leftover >= MinQuantum")
+ // presented a forward rate spike after every long host frame; the
+ // continuous-rate contract is what keeps the presentation and the
+ // chase camera (which integrates PresentedDeltaSeconds) coherent.
+ float alpha = PhysicsBody.MinQuantum / PhysicsBody.MaxQuantum;
+ Vector3 expected = Vector3.Lerp(start, result.Position, alpha);
+ Assert.Equal(expected.X, result.RenderPosition.X, tolerance: 1e-3f);
+ Assert.Equal(expected.Y, result.RenderPosition.Y, tolerance: 1e-3f);
+ Assert.Equal(expected.Z, result.RenderPosition.Z, tolerance: 1e-3f);
}
[Fact]