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]