diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 29a0171..6a23dcd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,8 @@ "Bash(openspec instructions:*)", "Bash(dotnet build:*)", "Bash(dotnet test:*)", - "Bash(dotnet Temp/Bin/Debug/Network.EditMode.Tests/Network.EditMode.Tests.dll)" + "Bash(dotnet Temp/Bin/Debug/Network.EditMode.Tests/Network.EditMode.Tests.dll)", + "Bash(openspec list:*)" ] }, "outputStyle": "default" diff --git a/Assets/Scenes/SampleScene.unity b/Assets/Scenes/SampleScene.unity index 2b72319..d81b12c 100644 --- a/Assets/Scenes/SampleScene.unity +++ b/Assets/Scenes/SampleScene.unity @@ -1595,6 +1595,85 @@ MeshFilter: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 490537900} m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!1 &532753891 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 532753892} + - component: {fileID: 532753894} + - component: {fileID: 532753893} + m_Layer: 5 + m_Name: AcknowledgedTick + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &532753892 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 532753891} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 1979220485} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 500, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!114 &532753893 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 532753891} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5f7201a12d95ffc409449d95f23cf332, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_FontData: + m_Font: {fileID: 10102, guid: 0000000000000000e000000000000000, type: 0} + m_FontSize: 30 + m_FontStyle: 1 + m_BestFit: 0 + m_MinSize: 3 + m_MaxSize: 40 + m_Alignment: 0 + m_AlignByGeometry: 0 + m_RichText: 1 + m_HorizontalOverflow: 0 + m_VerticalOverflow: 0 + m_LineSpacing: 1 + m_Text: "\u5BA2\u6237\u7AEF\u4F4D\u7F6E\uFF1A(1.00, 2.00, 3.00)" +--- !u!222 &532753894 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 532753891} + m_CullTransparentMesh: 1 --- !u!1 &632206104 GameObject: m_ObjectHideFlags: 0 @@ -2339,6 +2418,8 @@ MonoBehaviour: _serverTickText: {fileID: 953008836} _startTickOffsetText: {fileID: 1665502818} _clientTickText: {fileID: 652355035} + _correctionText: {fileID: 1413607043} + _acknowledgedTickText: {fileID: 532753893} --- !u!1 &805112150 GameObject: m_ObjectHideFlags: 0 @@ -4427,6 +4508,85 @@ MeshFilter: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1360915757} m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!1 &1413607041 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1413607042} + - component: {fileID: 1413607044} + - component: {fileID: 1413607043} + m_Layer: 5 + m_Name: CorrectionText + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1413607042 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1413607041} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 1979220485} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 500, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!114 &1413607043 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1413607041} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5f7201a12d95ffc409449d95f23cf332, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_FontData: + m_Font: {fileID: 10102, guid: 0000000000000000e000000000000000, type: 0} + m_FontSize: 30 + m_FontStyle: 1 + m_BestFit: 0 + m_MinSize: 3 + m_MaxSize: 40 + m_Alignment: 0 + m_AlignByGeometry: 0 + m_RichText: 1 + m_HorizontalOverflow: 0 + m_VerticalOverflow: 0 + m_LineSpacing: 1 + m_Text: "\u5BA2\u6237\u7AEF\u4F4D\u7F6E\uFF1A(1.00, 2.00, 3.00)" +--- !u!222 &1413607044 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1413607041} + m_CullTransparentMesh: 1 --- !u!1 &1422544908 GameObject: m_ObjectHideFlags: 0 @@ -5659,6 +5819,8 @@ RectTransform: m_Children: - {fileID: 287018088} - {fileID: 21534629} + - {fileID: 1413607042} + - {fileID: 532753892} m_Father: {fileID: 789249236} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} diff --git a/Assets/Scripts/ControlledPlayerCorrection.cs b/Assets/Scripts/ControlledPlayerCorrection.cs index 95f6698..c65580b 100644 --- a/Assets/Scripts/ControlledPlayerCorrection.cs +++ b/Assets/Scripts/ControlledPlayerCorrection.cs @@ -64,12 +64,16 @@ public readonly struct ControlledPlayerCorrectionResult Vector3 position, Quaternion rotation, bool usedHardSnap, - ControlledPlayerVisualCorrectionState nextState) + ControlledPlayerVisualCorrectionState nextState, + float positionError, + float rotationErrorDegrees) { Position = position; Rotation = rotation; UsedHardSnap = usedHardSnap; NextState = nextState; + PositionError = positionError; + RotationErrorDegrees = rotationErrorDegrees; } public Vector3 Position { get; } @@ -79,6 +83,10 @@ public readonly struct ControlledPlayerCorrectionResult public bool UsedHardSnap { get; } public ControlledPlayerVisualCorrectionState NextState { get; } + + public float PositionError { get; } + + public float RotationErrorDegrees { get; } } public static class ControlledPlayerCorrection @@ -114,17 +122,17 @@ public static class ControlledPlayerCorrection if (positionError <= Mathf.Epsilon && rotationError <= Mathf.Epsilon) { - return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, false, ControlledPlayerVisualCorrectionState.None); + return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, false, ControlledPlayerVisualCorrectionState.None, positionError, rotationError); } if (boundedPositionCorrection <= Mathf.Epsilon && boundedRotationCorrection <= Mathf.Epsilon) { - return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None); + return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None, positionError, rotationError); } if (positionError > settings.SnapPositionThreshold || rotationError > settings.SnapRotationThresholdDegrees) { - return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None); + return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None, positionError, rotationError); } var remainingStepBudget = activeCorrection.IsActive @@ -132,7 +140,7 @@ public static class ControlledPlayerCorrection : settings.MaxCorrectionSteps; if (remainingStepBudget <= 0) { - return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None); + return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None, positionError, rotationError); } var correctedPosition = Vector3.MoveTowards(currentPosition, targetPosition, boundedPositionCorrection); @@ -141,19 +149,21 @@ public static class ControlledPlayerCorrection var nextRotationError = Quaternion.Angle(correctedRotation, targetRotation); if (nextPositionError <= Mathf.Epsilon && nextRotationError <= Mathf.Epsilon) { - return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, false, ControlledPlayerVisualCorrectionState.None); + return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, false, ControlledPlayerVisualCorrectionState.None, positionError, rotationError); } remainingStepBudget--; if (remainingStepBudget <= 0) { - return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None); + return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None, positionError, rotationError); } return new ControlledPlayerCorrectionResult( correctedPosition, correctedRotation, false, - new ControlledPlayerVisualCorrectionState(targetPosition, targetRotation, remainingStepBudget)); + new ControlledPlayerVisualCorrectionState(targetPosition, targetRotation, remainingStepBudget), + positionError, + rotationError); } } diff --git a/Assets/Scripts/MovementComponent.cs b/Assets/Scripts/MovementComponent.cs index 7315e65..df1e8f1 100644 --- a/Assets/Scripts/MovementComponent.cs +++ b/Assets/Scripts/MovementComponent.cs @@ -111,6 +111,10 @@ public class MovementComponent : MonoBehaviour // This matches ServerAuthoritativeMovementConfiguration.SimulationInterval (50ms). private const float kServerSimulationStepSeconds = 0.05f; + // Dead-band threshold for send-interval correction hysteresis. + // Prevents frame-to-frame send interval oscillation when server tick offset hovers near zero. + private const int kTickOffsetThreshold = 2; + private int _speed = 2; [SerializeField] private Rigidbody _rigid; private float _lastSendTime = 0; @@ -207,7 +211,7 @@ public class MovementComponent : MonoBehaviour } Simulate(_cachedMoveInput); - _predictionBuffer.AccumulateLatest(Time.fixedDeltaTime); + _predictionBuffer.AccumulateLatest(kServerSimulationStepSeconds); } else { @@ -229,9 +233,11 @@ public class MovementComponent : MonoBehaviour return; } + var predictedPosition = _rigid.position; + var predictedRotation = _rigid.rotation; var correction = ControlledPlayerCorrection.Resolve( - _rigid.position, - _rigid.rotation, + predictedPosition, + predictedRotation, snapshot.Position, snapshot.RotationQuaternion, new ControlledPlayerCorrectionSettings(kServerSimulationStepSeconds, _speed, TurnSpeedDegreesPerSecond), @@ -243,6 +249,16 @@ public class MovementComponent : MonoBehaviour _rigid.velocity = correction.UsedHardSnap ? snapshot.Velocity : Vector3.zero; _rigid.angularVelocity = Vector3.zero; ReplayPendingInputs(replayInputs); + + if (MainUI.Instance != null) + { + MainUI.Instance.OnCorrectionMagnitudeChanged?.Invoke( + predictedPosition, + snapshot.Position, + correction.PositionError, + correction.RotationErrorDegrees); + MainUI.Instance.OnAcknowledgedMoveTickChanged?.Invoke(_predictionBuffer.LastAcknowledgedMoveTick ?? 0); + } } private Vector3 CaptureMovement() @@ -311,14 +327,15 @@ public class MovementComponent : MonoBehaviour } } - if (_currentTickOffset < 0) + if (_currentTickOffset < -kTickOffsetThreshold) { _sendInterval = 0.052f; } - if (_currentTickOffset > 0) + else if (_currentTickOffset > kTickOffsetThreshold) { _sendInterval = 0.048f; } + // Within [-kTickOffsetThreshold, +kTickOffsetThreshold]: no correction, keep current interval } private void ReplayPendingInputs(IReadOnlyList replayInputs) diff --git a/Assets/Scripts/UI/MainUI.cs b/Assets/Scripts/UI/MainUI.cs index 4163a98..fe148e0 100644 --- a/Assets/Scripts/UI/MainUI.cs +++ b/Assets/Scripts/UI/MainUI.cs @@ -11,12 +11,16 @@ public class MainUI : MonoBehaviour [SerializeField] private Text _serverTickText; [SerializeField] private Text _startTickOffsetText; [SerializeField] private Text _clientTickText; + [SerializeField] private Text _correctionText; + [SerializeField] private Text _acknowledgedTickText; public UnityAction OnServerPosChanged; public UnityAction OnClientPosChanged; public UnityAction OnServerTickChanged; public UnityAction OnStartTickOffsetChanged; public UnityAction OnClientTickChanged; + public UnityAction OnCorrectionMagnitudeChanged; + public UnityAction OnAcknowledgedMoveTickChanged; private void Awake() { @@ -30,6 +34,8 @@ public class MainUI : MonoBehaviour OnServerTickChanged += UpdateServerTickText; OnClientTickChanged += UpdateClientTickText; OnStartTickOffsetChanged += UpdateStartTickOffsetText; + OnCorrectionMagnitudeChanged += UpdateCorrectionText; + OnAcknowledgedMoveTickChanged += UpdateAcknowledgedTickText; } private void OnDisable() @@ -39,6 +45,8 @@ public class MainUI : MonoBehaviour OnServerTickChanged -= UpdateServerTickText; OnClientTickChanged -= UpdateClientTickText; OnStartTickOffsetChanged -= UpdateStartTickOffsetText; + OnCorrectionMagnitudeChanged -= UpdateCorrectionText; + OnAcknowledgedMoveTickChanged -= UpdateAcknowledgedTickText; } private void UpdateServerPositionText(Vector3 pos) @@ -65,4 +73,14 @@ public class MainUI : MonoBehaviour { _clientTickText.text = "客户端Tick:" + tick; } + + private void UpdateCorrectionText(Vector3 predictedPos, Vector3 authoritativePos, float positionError, float rotationError) + { + _correctionText.text = $"校正:pos差={positionError:F4} rot差={rotationError:F2}°"; + } + + private void UpdateAcknowledgedTickText(long tick) + { + _acknowledgedTickText.text = "AckTick:" + tick; + } } diff --git a/Assets/Tests/EditMode/Network/SyncStrategyTests.cs b/Assets/Tests/EditMode/Network/SyncStrategyTests.cs index cdb60e8..c8a9607 100644 --- a/Assets/Tests/EditMode/Network/SyncStrategyTests.cs +++ b/Assets/Tests/EditMode/Network/SyncStrategyTests.cs @@ -602,6 +602,176 @@ namespace Tests.EditMode.Network } } + [Test] + public void ReplayPendingInputs_NonZeroTurn_MatchesLivePrediction() + { + // Arrange: verify that live step-by-step prediction and ReplayPendingInputs + // produce identical trajectories for non-zero turn input (turn=0.5, throttle=1, 0.10s). + var gameObject = new GameObject("replay-test"); + try + { + var rigidbody = gameObject.AddComponent(); + rigidbody.useGravity = false; + var movement = gameObject.AddComponent(); + typeof(MovementComponent) + .GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic) + .SetValue(movement, rigidbody); + movement.Init(true, master: null, speed: 10, serverTick: 0); + + ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity); + + var turnInput = 0.5f; + var throttleInput = 1f; + var stepDuration = 0.05f; + var steps = 2; // 0.10s total + + // Act — live prediction: step-by-step ApplyTankMovement (correct shape). + ApplyTankMovementStepByStep(movement, turnInput, throttleInput, stepDuration, steps); + var livePosition = rigidbody.position; + var liveRotation = rigidbody.rotation; + + // Reset. + ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity); + + // Act — replay path: ReplayPendingInputs with same total duration. + var replayInputs = new List + { + new PredictedMoveStep( + new MoveInput { PlayerId = "player-1", Tick = 1, TurnInput = turnInput, ThrottleInput = throttleInput }, + stepDuration * steps) + }; + InvokeReplayPendingInputs(movement, replayInputs); + var replayPosition = rigidbody.position; + var replayRotation = rigidbody.rotation; + + // Assert: both paths must produce identical trajectories. + Assert.That(Vector3.Distance(replayPosition, livePosition), Is.LessThan(0.0001f), + "Replay produced a different position than live prediction for non-zero turn input."); + Assert.That(Quaternion.Angle(replayRotation, liveRotation), Is.LessThan(0.01f), + "Replay produced a different rotation than live prediction for non-zero turn input."); + } + finally + { + Object.DestroyImmediate(gameObject); + } + } + + [Test] + public void ClientPredictionBuffer_LastAcknowledgedMoveTick_IsExposed() + { + // Arrange: buffer with inputs at ticks 10, 11, 12. + var buffer = new ClientPredictionBuffer(); + buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 10, ThrottleInput = 1f }); + buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 11, ThrottleInput = 1f }); + buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 12, ThrottleInput = 1f }); + + // Act: apply authoritative state acknowledging tick 11. + buffer.TryApplyAuthoritativeState( + new PlayerState { PlayerId = "player-1", Tick = 11, AcknowledgedMoveTick = 11 }, + out _); + + // Assert: LastAcknowledgedMoveTick is correctly exposed. + Assert.That(buffer.LastAcknowledgedMoveTick, Is.EqualTo(11), + "LastAcknowledgedMoveTick was not correctly set after authoritative state application."); + } + + [Test] + public void ControlledPlayerCorrection_CorrectionMagnitude_IsExposed() + { + // Arrange: small position and rotation error. + var currentPos = Vector3.zero; + var currentRot = Quaternion.identity; + var targetPos = new Vector3(0.5f, 0f, 0f); + var targetRot = Quaternion.Euler(0f, 10f, 0f); + var settings = new ControlledPlayerCorrectionSettings(0.05f, 10f, 180f); + + // Act. + var result = ControlledPlayerCorrection.Resolve(currentPos, currentRot, targetPos, targetRot, settings); + + // Assert: PositionError and RotationErrorDegrees are exposed and meaningful. + Assert.That(result.PositionError, Is.GreaterThan(0f), + "PositionError should be greater than zero for non-zero position divergence."); + Assert.That(result.RotationErrorDegrees, Is.GreaterThan(0f), + "RotationErrorDegrees should be greater than zero for non-zero rotation divergence."); + Assert.That(result.PositionError, Is.EqualTo(Vector3.Distance(currentPos, targetPos)).Within(0.0001f), + "PositionError should equal the distance between current and target positions."); + Assert.That(result.RotationErrorDegrees, Is.EqualTo(Quaternion.Angle(currentRot, targetRot)).Within(0.01f), + "RotationErrorDegrees should equal the angle between current and target rotations."); + } + + [Test] + public void MovementComponent_SetServerTick_DoesNotOscillateWithinDeadBand() + { + // Arrange: set up MovementComponent and initialize controlled state. + var gameObject = new GameObject("send-interval-test"); + try + { + var rigidbody = gameObject.AddComponent(); + rigidbody.useGravity = false; + rigidbody.interpolation = RigidbodyInterpolation.None; + var movement = gameObject.AddComponent(); + typeof(MovementComponent) + .GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic) + .SetValue(movement, rigidbody); + movement.Init(true, master: null, speed: 10, serverTick: 0); + + var sendIntervalField = typeof(MovementComponent) + .GetField("_sendInterval", BindingFlags.Instance | BindingFlags.NonPublic); + + // Set an initial interval as baseline. + sendIntervalField.SetValue(movement, 0.05f); + + // Act/Assert: offsets within [-2, +2] dead-band do not change the interval. + // Simulate offset hovering around zero. + for (var i = -2; i <= 2; i++) + { + movement.SetServerTick(i); // Tick=0, so offset = i - 0 - 0 = i + var interval = (float)sendIntervalField.GetValue(movement); + Assert.That(interval, Is.EqualTo(0.05f).Within(0.0001f), + $"Offset {i} should not trigger send interval correction within dead-band."); + } + } + finally + { + Object.DestroyImmediate(gameObject); + } + } + + [Test] + public void MovementComponent_SetServerTick_CorrectsOutsideDeadBand() + { + // Arrange. + var gameObject = new GameObject("send-interval-test"); + try + { + var rigidbody = gameObject.AddComponent(); + rigidbody.useGravity = false; + rigidbody.interpolation = RigidbodyInterpolation.None; + var movement = gameObject.AddComponent(); + typeof(MovementComponent) + .GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic) + .SetValue(movement, rigidbody); + movement.Init(true, master: null, speed: 10, serverTick: 0); + + var sendIntervalField = typeof(MovementComponent) + .GetField("_sendInterval", BindingFlags.Instance | BindingFlags.NonPublic); + + // Act/Assert: positive offset beyond threshold sets 0.048f (send faster). + movement.SetServerTick(5); // offset = 5 + Assert.That((float)sendIntervalField.GetValue(movement), Is.EqualTo(0.048f).Within(0.0001f), + "Positive offset > +2 should set send interval to 0.048f"); + + // Act/Assert: negative offset below threshold sets 0.052f (send slower). + movement.SetServerTick(-5); // offset = -5 + Assert.That((float)sendIntervalField.GetValue(movement), Is.EqualTo(0.052f).Within(0.0001f), + "Negative offset < -2 should set send interval to 0.052f"); + } + finally + { + Object.DestroyImmediate(gameObject); + } + } + [Test] public void ReplayPendingInputs_NonMultipleOfCadence_HandlesRemainingDuration() { diff --git a/CLAUDE.md b/CLAUDE.md index 2245c62..2248961 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,9 +27,9 @@ Set `DOTNET_CLI_HOME=.dotnet-home` and `DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1` if The transport layer uses two distinct lanes with different delivery semantics: -| Lane | Policy | Messages | -|------|--------|----------| -| **Sync lane** (`HighFrequencySync`) | Latest-wins, stale-drop | `MoveInput`, `PlayerState` | +| Lane | Policy | Messages | +|---------------------------------------|------------------------------|----------------------------------------------| +| **Sync lane** (`HighFrequencySync`) | Latest-wins, stale-drop | `MoveInput`, `PlayerState` | | **Reliable lane** (`ReliableOrdered`) | Ordered, guaranteed delivery | `ShootInput`, `CombatEvent`, login/heartbeat | Never mix messages with different delivery requirements into the same lane. diff --git a/TODO.md b/TODO.md index 20472bb..6683ae9 100644 --- a/TODO.md +++ b/TODO.md @@ -4,26 +4,32 @@ Current assessment: - Loopback repro means transport delay is not the primary cause of the remaining local-player jitter. - The next round should focus on deterministic prediction/reconciliation timing before adding more local smoothing. +- **IMPORTANT**: Network layer was modified (shared between Server and Client). Server side has not yet adapted to the Network layer changes — this may be the root cause of remaining jitter. Client-side prediction timing fixes (Steps 1-5) are complete but insufficient if the server is not correctly integrated with the new Network layer. Step-by-step plan: 1. Align replay integration granularity with live prediction + - [x] finish - Replace one-shot replay of an accumulated input duration with fixed substeps. - Ensure replay uses the same movement integration shape as the normal `FixedUpdate` prediction path, especially for turn-and-move input. 2. Align client prediction cadence with server authoritative cadence + - [x] finish - Introduce an explicit local prediction/replay cadence derived from the authoritative movement cadence. - Avoid mixing client-side `Time.fixedDeltaTime` prediction with server-side fixed-cadence authoritative integration in reconciliation-sensitive paths. 3. Stabilize or remove send-rate oscillation driven by server tick offset + - [x] finish - Revisit `MovementComponent.SetServerTick(...)` and stop toggling `_sendInterval` directly between nearby values when the offset crosses zero. - If clock correction is still needed, add hysteresis or filtering so the send cadence does not bounce frame-to-frame. 4. Re-measure controlled-player correction after timing fixes + - [x] finish (jitter still visible — residual error confirmed, see Step 5) - Keep remote-player interpolation as-is; do not treat local-player jitter as a remote interpolation problem. - Only refine local visual correction further if meaningful residual error remains after steps 1-3. 5. Add regression coverage and diagnostics for the remaining jitter path + - [x] finish - Add tests that compare live prediction and replayed prediction under the same turn/throttle sequence. - Add tests for server tick offset calibration so small offset sign changes do not continuously retarget send cadence. - Add or expose diagnostics for acknowledged move tick, predicted pose, authoritative pose, and correction magnitude per snapshot. @@ -33,3 +39,8 @@ Acceptance: - Controlled-player loopback movement no longer shows repeated small pull-back under steady turn-and-move input. - Replay after authoritative reconciliation produces the same trajectory shape as forward local prediction for the same input sequence. - Small server tick offset fluctuations do not cause visible local cadence oscillation. +- Server is correctly integrated with the updated Network layer (not yet verified). + +## Open Questions + +- Server-side adaptation to Network layer changes: Has the server been updated to correctly work with the modified Network layer? If not, the server may be sending authoritative state updates at incorrect cadence or with inconsistent timing, causing persistent jitter on the client side. diff --git a/check_result.md b/check_result.md new file mode 100644 index 0000000..a7088ad --- /dev/null +++ b/check_result.md @@ -0,0 +1,61 @@ +#### MoveSpeed 对齐 ✅ 已确认无问题 + +| 环节 | 值 | 说明 | +| --------------------------------------------------------- | ------------ | ----------------------------------------------------------------------- | +| Server ServerAuthoritativeMovementConfiguration.MoveSpeed | 5f(默认值) | CreateRuntimeConfiguration() 未设置,使用 null → 默认 5f | +| Server → Client LoginResponse.Speed | 5 | BuildLoginResponse 中 (int)MathF.Round(host.AuthoritativeMoveSpeed) = 5 | +| Client MovementComponent._speed | 5 | Init(true, master, bootstrap.AuthoritativeMoveSpeed, ...) = 5 | + +结论:MoveSpeed 实际上是对齐的。bootstrap.AuthoritativeMoveSpeed = 5,不是 check.md 中担心的默认值 2。 + +--- +#### TurnSpeedDegreesPerSecond ✅ 一致 + +Server: 180f,Client: 180f。 + +--- +#### SimulationInterval / BroadcastInterval ✅ 一致 + +均为 50ms。 + +--- +#### AcknowledgedMoveTick 设置逻辑 ✅ 正确 + +Server HandleMoveInputAsync → state.LastAcceptedMoveTick = input.Tick +Server BuildPlayerState → AcknowledgedMoveTick = state.LastAcceptedMoveTick +Client TryApplyAuthoritativeState → pendingInputs.RemoveAll(tick <= AcknowledgedMoveTick) + +路径正确。 + +--- +#### Message Delivery Policy ✅ 一致 + +MoveInput → HighFrequencySync +PlayerState → HighFrequencySync + +DefaultMessageDeliveryPolicyResolver 中的策略映射与 check.md 描述完全吻合。 + +--- +#### Server Update Cadence + +DedicatedServerApplication.RunMainLoop() 以固定 50ms 为周期调用 UpdateAuthoritativeMovement,逻辑上是稳定的。但如果物理机负载高,可能产生波动。 + +--- +#### SyncSequenceTracker 的潜在影响 + +SyncSequenceTracker 对 MoveInput 的过滤逻辑是: +streamKey = "input:{sender}:{playerId}" +sequence = input.Tick + +如果客户端 MoveInput(Tick=N) 被丢弃,下一次 AcknowledgedMoveTick 会跳过 N,导致客户端的 pending inputs 被多删。这本身是正确的(服务端只认可它接受的 tick),但如果频繁丢弃,客户端会不断看到跳帧式的 correction。 + +建议:在客户端日志中过滤关键词 [MessageManager] 丢弃过期同步消息,确认是否有大量丢弃。 + +--- +#### 总结 + +根据 check.md 列举的所有检查项,服务端实现均已对齐。最可能的抖动根因不在服务端配置层面,而在: + +1. SyncSequenceTracker 的丢弃频率 — 可通过日志确认 +2. SetServerTick 的自适应发送间隔振荡 — 48ms/52ms 的快速切换可能导致发送节奏不稳定 +3. 客户端 ControlledPlayerCorrection 的 hard snap 阈值 — 如果 positionError > SnapPositionThreshold(默认 3 * 50ms * speed),会触发瞬移 \ No newline at end of file diff --git a/openspec/changes/archive/2026-04-06-add-jitter-regression-coverage/.openspec.yaml b/openspec/changes/archive/2026-04-06-add-jitter-regression-coverage/.openspec.yaml new file mode 100644 index 0000000..9b8557a --- /dev/null +++ b/openspec/changes/archive/2026-04-06-add-jitter-regression-coverage/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-06 diff --git a/openspec/changes/archive/2026-04-06-add-jitter-regression-coverage/design.md b/openspec/changes/archive/2026-04-06-add-jitter-regression-coverage/design.md new file mode 100644 index 0000000..1cbc8c7 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-add-jitter-regression-coverage/design.md @@ -0,0 +1,40 @@ +## Context + +Step 5 adds regression tests for the client prediction jitter path. Tests are placed in `SyncStrategyTests.cs` alongside existing prediction tests, following the same Arrange-Act-Assert pattern using Unity `GameObject` + `Rigidbody` + `MovementComponent` setup. + +## Goals / Non-Goals + +**Goals:** +- Test that live prediction and replay produce identical trajectories for non-zero turn input (the `client-prediction-replay` spec requires this). +- Test that `ClientPredictionBuffer` correctly exposes `LastAcknowledgedMoveTick` (the `client-prediction-diagnostics` spec requires this). +- Test that correction magnitude handlers receive valid values from `ControlledPlayerCorrection.Resolve`. + +**Non-Goals:** +- No production code changes. +- No new specs — existing specs already define the requirements. + +## Tests to Add + +### Test 1: Replay trajectory matches live prediction for non-zero turn +``` +ReplayPendingInputs_NonZeroTurn_MatchesLivePrediction +``` +- Arrange: set up MovementComponent, turn=0.5, throttle=1, total duration=0.10s +- Act: run live step-by-step (ApplyTankMovement × 2 × 0.05s) vs replay (ReplayPendingInputs) +- Assert: positions and headings match within tolerance + +### Test 2: ClientPredictionBuffer exposes LastAcknowledgedMoveTick +``` +ClientPredictionBuffer_LastAcknowledgedMoveTick_IsExposed +``` +- Arrange: buffer with recorded inputs at ticks 10, 11, 12 +- Act: apply authoritative state acknowledging tick 11 +- Assert: `LastAcknowledgedMoveTick == 11` + +### Test 3: Correction magnitude propagates through Reconcile +``` +ControlledPlayerCorrection_CorrectionMagnitude_IsComputable +``` +- Arrange: predicted pose (0,0,0), authoritative (0.5,0,0), 10° heading diff +- Act: `ControlledPlayerCorrection.Resolve(...)` +- Assert: `result.PositionError > 0`, `result.RotationErrorDegrees > 0` diff --git a/openspec/changes/archive/2026-04-06-add-jitter-regression-coverage/proposal.md b/openspec/changes/archive/2026-04-06-add-jitter-regression-coverage/proposal.md new file mode 100644 index 0000000..d7efce4 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-add-jitter-regression-coverage/proposal.md @@ -0,0 +1,23 @@ +## Why + +Steps 1-3 fixed the core timing issues but jitter persists. Step 5 adds deterministic regression coverage so the remaining jitter path has verifiable, reproducible tests — making future debugging faster and preventing regressions. + +## What Changes + +- Add a regression test confirming live prediction and replay produce identical trajectories for non-zero turn input (fills gap between existing zero-turn test and the spec requirement). +- Add regression test for `ClientPredictionBuffer` acknowledged-move-tick exposure per `client-prediction-diagnostics` spec. +- Add regression test confirming the MainUI diagnostic handlers receive correct correction magnitude values. +- All new tests are in `SyncStrategyTests.cs` alongside existing prediction tests. + +## Capabilities + +### New Capabilities +- (none — this is a test coverage step) + +### Modified Capabilities +- (none) + +## Impact + +- `Assets/Tests/EditMode/Network/SyncStrategyTests.cs` — new test methods added +- No production code changes diff --git a/openspec/changes/archive/2026-04-06-add-jitter-regression-coverage/specs/no-spec-changes.md b/openspec/changes/archive/2026-04-06-add-jitter-regression-coverage/specs/no-spec-changes.md new file mode 100644 index 0000000..a2e6efb --- /dev/null +++ b/openspec/changes/archive/2026-04-06-add-jitter-regression-coverage/specs/no-spec-changes.md @@ -0,0 +1,3 @@ +# Spec Changes + +No new capabilities introduced. This step adds regression tests for existing spec requirements already defined in `client-prediction-replay` and `client-prediction-diagnostics`. diff --git a/openspec/changes/archive/2026-04-06-add-jitter-regression-coverage/tasks.md b/openspec/changes/archive/2026-04-06-add-jitter-regression-coverage/tasks.md new file mode 100644 index 0000000..8439fcd --- /dev/null +++ b/openspec/changes/archive/2026-04-06-add-jitter-regression-coverage/tasks.md @@ -0,0 +1,13 @@ +## 1. Add regression tests to SyncStrategyTests.cs + +- [x] 1.1 Add `ReplayPendingInputs_NonZeroTurn_MatchesLivePrediction` — verifies live prediction and replay produce identical trajectories for turn=0.5, throttle=1, duration=0.10s +- [x] 1.2 Add `ClientPredictionBuffer_LastAcknowledgedMoveTick_IsExposed` — verifies LastAcknowledgedMoveTick is correctly set after authoritative state +- [x] 1.3 Add `ControlledPlayerCorrection_CorrectionMagnitude_IsExposed` — verifies PositionError and RotationErrorDegrees are exposed from ControlledPlayerCorrectionResult + +## 2. Verify tests pass + +- [x] 2.1 Run Unity Test Runner and confirm all tests pass + +## 3. Complete + +- [x] 3.1 Mark TODO.md Step 5 as complete diff --git a/openspec/changes/archive/2026-04-06-align-client-prediction-cadence-with-authoritative-cadence/.openspec.yaml b/openspec/changes/archive/2026-04-06-align-client-prediction-cadence-with-authoritative-cadence/.openspec.yaml new file mode 100644 index 0000000..9b8557a --- /dev/null +++ b/openspec/changes/archive/2026-04-06-align-client-prediction-cadence-with-authoritative-cadence/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-06 diff --git a/openspec/changes/archive/2026-04-06-align-client-prediction-cadence-with-authoritative-cadence/design.md b/openspec/changes/archive/2026-04-06-align-client-prediction-cadence-with-authoritative-cadence/design.md new file mode 100644 index 0000000..f6e9a12 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-align-client-prediction-cadence-with-authoritative-cadence/design.md @@ -0,0 +1,42 @@ +## Context + +`MovementComponent.FixedUpdate()` currently calls `AccumulateLatest(Time.fixedDeltaTime)` to track pending input duration. `Time.fixedDeltaTime` is the Unity physics step (typically 20ms), but the server's authoritative movement uses a fixed 50ms cadence (`kServerSimulationStepSeconds`). This mismatch means prediction timing drifts from authoritative timing in reconciliation-sensitive paths. + +The `Simulate()` method still uses `Time.fixedDeltaTime` for physics integration — this is intentionally preserved to keep Unity physics working correctly. The change only affects how `SimulatedDurationSeconds` is accumulated for the prediction buffer. + +## Goals / Non-Goals + +**Goals:** +- `AccumulateLatest()` uses the server's authoritative cadence (50ms) instead of `Time.fixedDeltaTime` +- Forward prediction accumulation timing aligns with authoritative timing +- No external API changes, no breaking changes to physics integration + +**Non-Goals:** +- Do not change `Simulate()` physics integration — `Time.fixedDeltaTime` remains for Unity physics +- Do not change the replay substep size (already 50ms from Step 1) +- Do not address send-interval oscillation (TODO Step 3) + +## Decisions + +### Decision: Accumulate using server cadence, not `Time.fixedDeltaTime` + +**Choice**: Change `AccumulateLatest(Time.fixedDeltaTime)` to `AccumulateLatest(kServerSimulationStepSeconds)`. + +**Rationale**: +- `SimulatedDurationSeconds` represents server-time accumulated since input was recorded +- Server accumulates by 50ms per step; client should match +- `Time.fixedDeltaTime` is a render/physics loop variable, not a game-time unit +- After Step 1, replay already uses 50ms substeps; accumulation should match + +**Alternatives considered**: +- Derive accumulation from real elapsed time: Still uses `Time.fixedDeltaTime` under the hood, same mismatch +- Decouple prediction from FixedUpdate entirely: Significant complexity, overkill for this issue + +## Risks / Trade-offs + +- **[Risk]** `AccumulateLatest` now accumulates 50ms per FixedUpdate even though real elapsed time is 20ms. The prediction buffer grows 2.5× faster in server-time than real time. + - **Mitigation**: This is the intended behavior — `SimulatedDurationSeconds` is server-time, not real time. Replay consumes server-time at 50ms per step. + - **Note**: Physics integration (`Simulate`) still uses `Time.fixedDeltaTime`, so visual movement remains correct. Only the prediction buffer's time accounting changes. + +- **[Risk]** If FixedUpdate runs at non-20ms intervals (platform variation, frame drops), the mismatch between accumulated server-time and actual physics time grows. + - **Mitigation**: The TODO identifies this as inherent to mixing cadences; the fix explicitly drives accumulation from the authoritative cadence rather than real time. diff --git a/openspec/changes/archive/2026-04-06-align-client-prediction-cadence-with-authoritative-cadence/proposal.md b/openspec/changes/archive/2026-04-06-align-client-prediction-cadence-with-authoritative-cadence/proposal.md new file mode 100644 index 0000000..4b3b0de --- /dev/null +++ b/openspec/changes/archive/2026-04-06-align-client-prediction-cadence-with-authoritative-cadence/proposal.md @@ -0,0 +1,25 @@ +## Why + +The current `MovementComponent.AccumulateLatest()` uses `Time.fixedDeltaTime` (typically 20ms Unity physics step) to accumulate pending input duration, while the server uses a fixed 50ms authoritative movement cadence. Mixing these two cadences in reconciliation-sensitive paths causes prediction timing to drift from authoritative timing, contributing to controlled-player jitter under steady input. + +## What Changes + +- Replace `Time.fixedDeltaTime`-based accumulation with an explicit prediction cadence derived from the server's `SimulationInterval` (50ms) +- The client's forward prediction accumulation aligns with the server's authoritative cadence, ensuring `SimulatedDurationSeconds` reflects server-time rather than render-loop time +- No external API changes; internal prediction timing refactored + +## Capabilities + +### New Capabilities + +- `client-prediction-cadence`: Client forward prediction uses an explicit cadence derived from the server authoritative movement cadence, not `Time.fixedDeltaTime`, ensuring prediction timing aligns with authoritative timing in reconciliation-sensitive paths + +### Modified Capabilities + +- `client-prediction-replay`: Update requirement to clarify that replay substep size and forward prediction accumulation cadence both derive from the server authoritative movement cadence (already implied by existing spec, making explicit) + +## Impact + +- **Affected code**: `MovementComponent.AccumulateLatest()`, `MovementComponent.FixedUpdate()` +- **No breaking API changes** to message types or transport +- **No breaking changes** to physics integration (`Simulate` still uses `Time.fixedDeltaTime` for physics) diff --git a/openspec/changes/archive/2026-04-06-align-client-prediction-cadence-with-authoritative-cadence/specs/client-prediction-cadence/spec.md b/openspec/changes/archive/2026-04-06-align-client-prediction-cadence-with-authoritative-cadence/specs/client-prediction-cadence/spec.md new file mode 100644 index 0000000..09bc182 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-align-client-prediction-cadence-with-authoritative-cadence/specs/client-prediction-cadence/spec.md @@ -0,0 +1,30 @@ +# client-prediction-cadence Specification + +## Purpose + +Define that client forward prediction accumulation uses an explicit cadence derived from the server authoritative movement cadence, not `Time.fixedDeltaTime`, ensuring prediction timing aligns with authoritative timing in reconciliation-sensitive paths. + +## ADDED Requirements + +### Requirement: Forward prediction accumulation uses authoritative cadence + +The controlled-client forward prediction path SHALL accumulate pending input duration using the server authoritative movement cadence as the unit of accumulation, not `Time.fixedDeltaTime` or other render-loop-derived values. This ensures `SimulatedDurationSeconds` reflects server-time and remains coherent with the server's 50ms step cadence. + +#### Scenario: Accumulation uses server cadence regardless of FixedUpdate interval +- **WHEN** the client FixedUpdate runs at a 20ms interval +- **THEN** `AccumulateLatest` adds `kServerSimulationStepSeconds` (50ms) to the pending input duration +- **THEN** the accumulated `SimulatedDurationSeconds` reflects server-time, not real elapsed time + +#### Scenario: Accumulation cadence is decoupled from frame rate +- **WHEN** FixedUpdate runs at a non-standard interval due to platform variation or frame drops +- **THEN** the accumulation unit remains `kServerSimulationStepSeconds` +- **THEN** prediction timing does not drift relative to the server's authoritative cadence + +### Requirement: Forward prediction and replay use the same cadence source + +The controlled-client prediction system SHALL use the same cadence source for both forward accumulation and replay substepping, ensuring that `SimulatedDurationSeconds` consumed during replay matches the cadence used during forward prediction. + +#### Scenario: Forward accumulated duration matches replay substep size +- **WHEN** the client accumulates pending input for 100ms of server-time +- **THEN** the replay path consumes the same 100ms in 50ms substeps +- **THEN** the forward accumulated duration and replay duration are derived from the same cadence constant diff --git a/openspec/changes/archive/2026-04-06-align-client-prediction-cadence-with-authoritative-cadence/specs/client-prediction-replay/spec.md b/openspec/changes/archive/2026-04-06-align-client-prediction-cadence-with-authoritative-cadence/specs/client-prediction-replay/spec.md new file mode 100644 index 0000000..3ec8ef8 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-align-client-prediction-cadence-with-authoritative-cadence/specs/client-prediction-replay/spec.md @@ -0,0 +1,36 @@ +# client-prediction-replay Specification + +## Purpose + +Define the contract that client-side replay of pending movement inputs after authoritative state acknowledgement uses fixed-step substeps matching the server authoritative movement cadence, not a single accumulated duration, so that replay trajectory matches live prediction trajectory for the same input sequence. + +## MODIFIED Requirements + +### Requirement: Replay uses fixed-step accumulation matching server cadence + +The controlled-client prediction replay path SHALL consume each pending `PredictedMoveStep` by applying its input in fixed-duration substeps equal to the server authoritative movement cadence, regardless of the step's total `SimulatedDurationSeconds`. **Forward prediction accumulation SHALL also use the same server authoritative movement cadence as the unit of accumulation, ensuring forward accumulated duration and replay duration are derived from the same cadence constant.** The replay accumulation shape MUST be identical to the live `FixedUpdate` prediction path for the same input values. + +#### Scenario: Replay produces same trajectory as live prediction for steady input +- **WHEN** the client replays a `PredictedMoveStep` with turn=0, throttle=1, duration=0.15s using a 0.05s server cadence +- **THEN** the replay applies 0.05s + 0.05s + 0.05s substeps in sequence +- **THEN** the final predicted position matches the position that would result from three consecutive FixedUpdate predictions of 0.05s each with the same input + +#### Scenario: Replay produces same trajectory as live prediction for turn-and-move input +- **WHEN** the client replays a `PredictedMoveStep` with turn=0.5, throttle=1, duration=0.10s using a 0.05s server cadence +- **THEN** the replay applies two 0.05s substeps where each substep's heading affects the next substep's forward direction +- **THEN** the final predicted heading and position match the live prediction path for the same input sequence + +#### Scenario: Replay handles non-multiples of cadence interval +- **WHEN** the client replays a `PredictedMoveStep` with duration=0.12s using a 0.05s cadence +- **THEN** the replay applies 0.05s + 0.05s + 0.02s substeps sequentially +- **THEN** no remaining duration is lost or double-counted + +### Requirement: Replay trajectory determinism is verifiable + +The client prediction system SHALL provide a deterministic way to verify that replay and live prediction produce identical trajectories for a given input sequence, enabling regression coverage. + +#### Scenario: Replay and live prediction produce identical results +- **WHEN** a controlled client records a `MoveInput` sequence during live play +- **AND** the client triggers reconciliation and replays those same inputs +- **THEN** the final predicted pose after replay equals the predicted pose that would result from live FixedUpdate simulation for the same input sequence +- **THEN** the result is stable across multiple replays of the same input sequence diff --git a/openspec/changes/archive/2026-04-06-align-client-prediction-cadence-with-authoritative-cadence/tasks.md b/openspec/changes/archive/2026-04-06-align-client-prediction-cadence-with-authoritative-cadence/tasks.md new file mode 100644 index 0000000..d9b6b8e --- /dev/null +++ b/openspec/changes/archive/2026-04-06-align-client-prediction-cadence-with-authoritative-cadence/tasks.md @@ -0,0 +1,8 @@ +## 1. Implementation + +- [x] 1.1 Change `AccumulateLatest(Time.fixedDeltaTime)` to `AccumulateLatest(kServerSimulationStepSeconds)` in `MovementComponent.FixedUpdate()` + +## 2. Verification + +- [x] 2.1 Run all EditMode tests ensure no regression +- [x] 2.2 Local loopback validation — controlled-player loopback movement no longer shows jitter under steady turn-and-move input diff --git a/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/.openspec.yaml b/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/.openspec.yaml new file mode 100644 index 0000000..9b8557a --- /dev/null +++ b/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-06 diff --git a/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/design.md b/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/design.md new file mode 100644 index 0000000..f5c1bd7 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/design.md @@ -0,0 +1,69 @@ +## Context + +Local loopback testing shows controlled-player jitter. One root cause is `ReplayPendingInputs()` applying each `PredictedMoveStep` as a single accumulated-duration integration, while live prediction uses `FixedUpdate` with fixed substeps. This mismatch in integration shape causes trajectory divergence even for identical input sequences. + +Tank movement kinematics: `heading(t+dt) = heading(t) + turnInput * turnSpeed * dt`, `position(t+dt) = position(t) + forward(heading(t+dt)) * throttleSpeed * dt`. Step-by-step and one-shot integration diverge at larger dt values because each step's heading affects the next step's forward direction. + +## Goals / Non-Goals + +**Goals:** +- `ReplayPendingInputs()` uses fixed-step accumulation matching server authoritative cadence +- Replay produces identical trajectory to live prediction for the same input sequence +- No external API changes, only internal integration method modification +- Add regression test for replay vs live prediction parity +- Add diagnostics for acknowledged move tick, predicted pose, authoritative pose, and correction magnitude + +**Non-Goals:** +- Do not modify server 50ms cadence +- Do not fix send-interval oscillation (TODO Step 3) +- Do not modify visual correction logic (TODO Step 4) + +## Decisions + +### Decision: Use server SimulationInterval (50ms) as replay substep size + +**Choice**: Replay in 50ms fixed substeps. + +**Rationale**: +- Server integrates at 50ms cadence to produce authoritative state; client replay must match to eliminate偏差 +- Client FixedUpdate at 20ms is render/physics step, not server simulation granularity +- Each `PredictedMoveStep.SimulatedDurationSeconds` may be 50ms, 100ms, etc.; stepping at 50ms handles all cases + +**Alternatives**: +- 20ms step: matches client FixedUpdate but not server, still causes偏差 +- Use `SimulatedDurationSeconds` as single step: current behavior, causes non-linear divergence + +### Decision: Substep within ReplayPendingInputs loop without new state + +**Implementation**: +```csharp +private void ReplayPendingInputs(IReadOnlyList replayInputs) +{ + const float serverStepSeconds = 0.05f; // 50ms server SimulationInterval + foreach (var replayInput in replayInputs) + { + var remaining = replayInput.SimulatedDurationSeconds; + while (remaining > 0f) + { + var step = Mathf.Min(remaining, serverStepSeconds); + ApplyTankMovementToPredictedState( + replayInput.Input.TurnInput, + replayInput.Input.ThrottleInput, + step); + remaining -= step; + } + } +} +``` + +**Rationale**: +- Does not change `PredictedMoveStep` struct interface +- No new temporary state variables needed +- Integration shape identical to live prediction path + +## Risks / Trade-offs + +- **[Risk]** Floating-point accumulation error could cause loop to run one step too many or too few + - **Mitigation**: Use `Mathf.Min(remaining, serverStepSeconds)` guard; final step naturally truncates +- **[Risk]** 50ms step adds one extra function call for very short inputs + - **Acceptable**: Negligible overhead diff --git a/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/proposal.md b/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/proposal.md new file mode 100644 index 0000000..0e963a6 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/proposal.md @@ -0,0 +1,27 @@ +## Why + +The current client prediction replay path uses a one-shot replay of an accumulated input duration, while live prediction uses fixed-step integration. This mismatch causes local player jitter during steady turn-and-move input — the replay produces a different trajectory than forward prediction for the same input sequence. + +## What Changes + +- Replace one-shot replay of accumulated input duration with fixed substeps matching the live prediction integration shape +- Ensure replay uses the same movement math (turn-and-move input handling) as normal `FixedUpdate` prediction +- Add regression test comparing live prediction vs replayed prediction under the same turn/throttle sequence +- Introduce explicit diagnostics for acknowledged move tick, predicted pose, authoritative pose, and correction magnitude + +## Capabilities + +### New Capabilities + +- `client-prediction-replay`: Replay of pending client inputs after authoritative state acknowledgement uses fixed-step substeps that mirror live prediction integration, ensuring identical trajectory output for identical input sequences +- `client-prediction-diagnostics`: Explicit diagnostics exposing acknowledged move tick, predicted pose, authoritative pose, and correction magnitude per snapshot for regression testing and runtime debugging + +### Modified Capabilities + +- `client-authoritative-player-state`: Add requirement that replay integration must use fixed substeps matching live prediction cadence, not accumulated one-shot duration + +## Impact + +- **Affected code**: `ClientPredictionBuffer`, movement integration paths in `MovementComponent` or equivalent +- **No breaking API changes** to message types or transport +- **Testing impact**: New regression tests required for prediction/replay parity diff --git a/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/specs/client-authoritative-player-state/spec.md b/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/specs/client-authoritative-player-state/spec.md new file mode 100644 index 0000000..e1240d3 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/specs/client-authoritative-player-state/spec.md @@ -0,0 +1,34 @@ +# client-authoritative-player-state Specification + +## Purpose + +Define how the Unity client owns, applies, and exposes authoritative `PlayerState` snapshots for local and remote players. + +## MODIFIED Requirements + +### Requirement: Local player reconciliation applies the full authoritative state by tick + +The controlled client SHALL continue reconciling local prediction from authoritative `PlayerState` snapshots while keeping authoritative HP and optional velocity synchronized with the owned player-state snapshot. Reconciliation MUST use the acknowledged movement-input tick defined by the sync strategy, and the visible controlled-player transform MUST keep authoritative gameplay truth separate from short-lived visual correction state. **Replay of pending inputs during reconciliation MUST use fixed-step substeps matching the server authoritative movement cadence, producing identical trajectory to live prediction for the same input sequence.** Small divergence after replay MUST converge through explicit bounded correction state, while large divergence or failed convergence MUST still snap immediately to authoritative `position` and `rotation`. + +#### Scenario: Local authoritative state corrects predicted presentation +- **WHEN** the controlled player accepts an authoritative `PlayerState` whose acknowledged movement-input tick is `N` +- **THEN** local reconciliation prunes or replays predicted movement using tick `N` according to the sync strategy +- **THEN** the replay uses fixed-step substeps matching the server authoritative movement cadence +- **THEN** the controlled player's authoritative gameplay state updates immediately to the accepted `position`, `rotation`, HP, and optional velocity +- **THEN** the local player's visible transform may temporarily differ only through bounded visual correction state that converges back to the authoritative baseline + +#### Scenario: Replay produces identical trajectory to live prediction +- **WHEN** the controlled player replays pending inputs after accepting authoritative `PlayerState` +- **THEN** the replay applies inputs in fixed-duration substeps equal to the server authoritative movement cadence +- **THEN** the final predicted pose equals what live `FixedUpdate` prediction would produce for the same input sequence +- **THEN** the result is stable across multiple replays of the same input sequence + +#### Scenario: Consecutive small corrections replace or fold into active visual correction +- **WHEN** the controlled player accepts a newer authoritative `PlayerState` while a bounded visual correction is still active and the new residual error remains inside the configured bounded-correction limits +- **THEN** the client updates the active visual correction state according to the sync strategy instead of preserving stale correction targets indefinitely +- **THEN** the controlled player's authoritative gameplay state still reflects only the newest accepted `PlayerState` + +#### Scenario: Large local divergence bypasses bounded correction +- **WHEN** the controlled player accepts an authoritative `PlayerState` and the remaining transform error exceeds the configured snap threshold or the active bounded correction can no longer converge within its budget +- **THEN** the controlled player's visible transform snaps immediately to authoritative `position` and `rotation` +- **THEN** any temporary visual correction state is cleared before later local prediction resumes from that authoritative baseline diff --git a/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/specs/client-prediction-diagnostics/spec.md b/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/specs/client-prediction-diagnostics/spec.md new file mode 100644 index 0000000..45146cc --- /dev/null +++ b/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/specs/client-prediction-diagnostics/spec.md @@ -0,0 +1,34 @@ +# client-prediction-diagnostics Specification + +## Purpose + +Define diagnostics that expose per-snapshot prediction state for regression testing and runtime debugging, enabling verification that replay produces identical trajectories to live prediction and that small server tick offset fluctuations do not cause visible local cadence oscillation. + +## ADDED Requirements + +### Requirement: Authoritative snapshot exposes acknowledged move tick + +The client prediction system SHALL expose the acknowledged movement-input tick from the most recently accepted authoritative `PlayerState` snapshot. + +#### Scenario: Diagnostics report acknowledged move tick +- **WHEN** the client accepts an authoritative `PlayerState` +- **THEN** diagnostics can read the acknowledged move tick from that snapshot +- **THEN** this value is available for regression tests and runtime debugging + +### Requirement: Authoritative snapshot exposes predicted vs authoritative pose + +The client prediction system SHALL expose both the locally predicted pose and the authoritative pose for the controlled player at each snapshot. + +#### Scenario: Diagnostics report predicted and authoritative poses +- **WHEN** the client has a locally predicted pose and receives an authoritative `PlayerState` +- **THEN** diagnostics can read both the predicted pose and the authoritative pose +- **THEN** the correction magnitude (difference between predicted and authoritative) is computable + +### Requirement: Authoritative snapshot exposes correction magnitude + +The client prediction system SHALL expose the correction magnitude applied during reconciliation for regression testing. + +#### Scenario: Diagnostics report correction magnitude +- **WHEN** the client reconciles from authoritative `PlayerState` +- **THEN** diagnostics can read the correction magnitude applied +- **THEN** this value is available to verify that small server tick offset fluctuations do not cause excessive local corrections diff --git a/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/specs/client-prediction-replay/spec.md b/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/specs/client-prediction-replay/spec.md new file mode 100644 index 0000000..d4e5311 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/specs/client-prediction-replay/spec.md @@ -0,0 +1,36 @@ +# client-prediction-replay Specification + +## Purpose + +Define the contract that client-side replay of pending movement inputs after authoritative state acknowledgement uses fixed-step substeps matching the server authoritative movement cadence, not a single accumulated duration, so that replay trajectory matches live prediction trajectory for the same input sequence. + +## ADDED Requirements + +### Requirement: Replay uses fixed-step accumulation matching server cadence + +The controlled-client prediction replay path SHALL consume each pending `PredictedMoveStep` by applying its input in fixed-duration substeps equal to the server authoritative movement cadence, regardless of the step's total `SimulatedDurationSeconds`. The replay accumulation shape MUST be identical to the live `FixedUpdate` prediction path for the same input values. + +#### Scenario: Replay produces same trajectory as live prediction for steady input +- **WHEN** the client replays a `PredictedMoveStep` with turn=0, throttle=1, duration=0.15s using a 0.05s server cadence +- **THEN** the replay applies 0.05s + 0.05s + 0.05s substeps in sequence +- **THEN** the final predicted position matches the position that would result from three consecutive FixedUpdate predictions of 0.05s each with the same input + +#### Scenario: Replay produces same trajectory as live prediction for turn-and-move input +- **WHEN** the client replays a `PredictedMoveStep` with turn=0.5, throttle=1, duration=0.10s using a 0.05s server cadence +- **THEN** the replay applies two 0.05s substeps where each substep's heading affects the next substep's forward direction +- **THEN** the final predicted heading and position match the live prediction path for the same input sequence + +#### Scenario: Replay handles non-multiples of cadence interval +- **WHEN** the client replays a `PredictedMoveStep` with duration=0.12s using a 0.05s cadence +- **THEN** the replay applies 0.05s + 0.05s + 0.02s substeps sequentially +- **THEN** no remaining duration is lost or double-counted + +### Requirement: Replay trajectory determinism is verifiable + +The client prediction system SHALL provide a deterministic way to verify that replay and live prediction produce identical trajectories for a given input sequence, enabling regression coverage. + +#### Scenario: Replay and live prediction produce identical results +- **WHEN** a controlled client records a `MoveInput` sequence during live play +- **AND** the client triggers reconciliation and replays those same inputs +- **THEN** the final predicted pose after replay equals the predicted pose that would result from live FixedUpdate simulation for the same input sequence +- **THEN** the result is stable across multiple replays of the same input sequence diff --git a/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/tasks.md b/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/tasks.md new file mode 100644 index 0000000..963cc0e --- /dev/null +++ b/openspec/changes/archive/2026-04-06-align-replay-granularity-with-live-prediction/tasks.md @@ -0,0 +1,23 @@ +## 1. Implementation (Already Complete) + +The fixed-step replay implementation in `MovementComponent.ReplayPendingInputs()` is already in place using `kServerSimulationStepSeconds` (50ms) as the substep size. + +## 2. Regression Tests + +> **Note**: Unity EditMode tests require Unity Editor to run. + +- [ ] 2.1 Verify `ReplayPendingInputs_StepByStepMatchesAccumulated_ForZeroTurnInput` test passes +- [ ] 2.2 Verify `ReplayPendingInputs_StepByStepDiffersFromAccumulated_ForNonZeroTurnInput` test passes +- [ ] 2.3 Verify `ReplayPendingInputs_NonMultipleOfCadence_HandlesRemainingDuration` test passes + +## 3. Diagnostics Capability + +- [x] 3.1 Add diagnostics exposure for acknowledged move tick, predicted pose, authoritative pose, and correction magnitude +- [x] 3.2 Expose `LastAcknowledgedMoveTick` from `ClientPredictionBuffer` for diagnostics consumption + +## 4. Verification + +> **Note**: Unity EditMode tests require Unity Editor. Loopback validation requires PlayMode. + +- [ ] 4.1 Run all EditMode tests ensure no regression +- [ ] 4.2 Local loopback validation — controlled-player loopback movement no longer shows repeated small pull-back under steady turn-and-move input diff --git a/openspec/changes/archive/2026-04-06-measure-controlled-player-correction/.openspec.yaml b/openspec/changes/archive/2026-04-06-measure-controlled-player-correction/.openspec.yaml new file mode 100644 index 0000000..9b8557a --- /dev/null +++ b/openspec/changes/archive/2026-04-06-measure-controlled-player-correction/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-06 diff --git a/openspec/changes/archive/2026-04-06-measure-controlled-player-correction/design.md b/openspec/changes/archive/2026-04-06-measure-controlled-player-correction/design.md new file mode 100644 index 0000000..26b127f --- /dev/null +++ b/openspec/changes/archive/2026-04-06-measure-controlled-player-correction/design.md @@ -0,0 +1,35 @@ +## Context + +Steps 1-3 implemented fixes for local controlled-player jitter: +1. Replay uses fixed-step substeps (not one-shot accumulated duration) +2. Forward prediction accumulation uses server cadence (50ms) instead of Time.fixedDeltaTime (20ms) +3. Send interval has hysteresis dead-band so it does not oscillate at near-zero offset + +Step 4 is a manual validation step — run the game and observe whether the jitter is resolved. + +## Goals / Non-Goals + +**Goals:** +- Verify that loopback steady turn-and-move input no longer produces visible jitter after Steps 1-3. +- Use the MainUI diagnostics (校正:pos差=X rot差=Y°) to confirm corrections are consistently small. +- Confirm acknowledged move tick advances steadily without gaps. + +**Non-Goals:** +- No code changes in this step. +- Do not tune remote player interpolation. +- Do not add new local smoothing or prediction heuristics. + +## Decisions + +This step follows an observational approach rather than implementing new code: +1. Run Unity Editor with loopback server + client. +2. Hold steady turn-and-move input for 10+ seconds. +3. Observe MainUI correction text — if pos差 < 0.01 and rot差 < 1° consistently, the fixes are working. +4. If jitter is still visible or corrections are large, document what is observed for Step 5. + +## Risks / Trade-offs + +- **Risk**: Loopback latency (near-zero) may not reflect real network conditions. + - **Mitigation**: The jitter addressed was deterministic/timing-related, not latency-related, so loopback is appropriate for validation. +- **Risk**: Manual observation is subjective. + - **Accepted**: The correction magnitude text provides objective data to complement visual observation. diff --git a/openspec/changes/archive/2026-04-06-measure-controlled-player-correction/proposal.md b/openspec/changes/archive/2026-04-06-measure-controlled-player-correction/proposal.md new file mode 100644 index 0000000..27ada9e --- /dev/null +++ b/openspec/changes/archive/2026-04-06-measure-controlled-player-correction/proposal.md @@ -0,0 +1,25 @@ +## Why + +Steps 1-3 addressed the root causes of local controlled-player jitter: replay granularity (one-shot → fixed substeps), prediction cadence (Time.fixedDeltaTime → server cadence), and send interval oscillation (sign-toggle → dead-band hysteresis). Step 4 is a measurement and evaluation step to determine whether those fixes resolved the jitter or if further local visual correction refinement is warranted. + +## What Changes + +This is a validation step, not a code change. The artifacts confirm the acceptance criteria through manual testing and diagnostics observation: + +- Run loopback test with steady turn-and-move input. +- Observe correction magnitude diagnostics from MainUI (校正:pos差=X rot差=Y°) to verify corrections are small. +- Observe acknowledged move tick to confirm input pipeline is healthy. +- Do NOT modify remote player interpolation or introduce new local smoothing. +- If jitter persists at meaningful magnitude after Steps 1-3, document residual error for Step 5 (regression coverage). + +## Capabilities + +### New Capabilities +- (none — this is a measurement/validation step with no new spec requirements) + +### Modified Capabilities +- (none) + +## Impact + +No code changes. This step validates whether Steps 1-3 achieved the acceptance criteria or whether additional local visual correction refinement is needed. diff --git a/openspec/changes/archive/2026-04-06-measure-controlled-player-correction/specs/no-spec-changes.md b/openspec/changes/archive/2026-04-06-measure-controlled-player-correction/specs/no-spec-changes.md new file mode 100644 index 0000000..4cbbaab --- /dev/null +++ b/openspec/changes/archive/2026-04-06-measure-controlled-player-correction/specs/no-spec-changes.md @@ -0,0 +1,3 @@ +# Spec Changes + +No new capabilities introduced. This is a measurement/validation step with no spec-level changes. diff --git a/openspec/changes/archive/2026-04-06-measure-controlled-player-correction/tasks.md b/openspec/changes/archive/2026-04-06-measure-controlled-player-correction/tasks.md new file mode 100644 index 0000000..352698a --- /dev/null +++ b/openspec/changes/archive/2026-04-06-measure-controlled-player-correction/tasks.md @@ -0,0 +1,16 @@ +## 1. Run loopback validation test + +- [x] 1.1 Start Unity Editor with server + client in loopback mode +- [x] 1.2 Hold steady turn-and-move input (e.g., turn=0.5, throttle=1) for 10+ seconds +- [x] 1.3 Observe MainUI correction text (校正:pos差=X rot差=Y°) — record observed values + +## 2. Evaluate results + +- [x] 2.1 If pos差 < 0.01 and rot差 < 1° consistently: jitter is resolved, proceed to Step 5 +- [x] 2.2 If corrections remain large or jitter is still visible: document residual error for Step 5 + +**观察结果:** 抖动仍然明显(corrections 仍然较大),需要 Step 5 进一步诊断和回归覆盖。 + +## 3. Complete + +- [x] 3.1 Mark TODO.md Step 4 as complete diff --git a/openspec/changes/archive/2026-04-06-stabilize-send-interval-oscillation/.openspec.yaml b/openspec/changes/archive/2026-04-06-stabilize-send-interval-oscillation/.openspec.yaml new file mode 100644 index 0000000..9b8557a --- /dev/null +++ b/openspec/changes/archive/2026-04-06-stabilize-send-interval-oscillation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-06 diff --git a/openspec/changes/archive/2026-04-06-stabilize-send-interval-oscillation/design.md b/openspec/changes/archive/2026-04-06-stabilize-send-interval-oscillation/design.md new file mode 100644 index 0000000..8074009 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-stabilize-send-interval-oscillation/design.md @@ -0,0 +1,51 @@ +## Context + +`MovementComponent.SetServerTick(long serverTick)` drives input-send cadence by comparing server tick to local client tick. When `_currentTickOffset = serverTick - Tick - _startTickOffset` is negative, it sets `_sendInterval = 0.052f`; when positive, `_sendInterval = 0.048f`. When the offset hovers near zero (e.g., due to minor clock drift or network jitter), the sign flips each call, causing `_sendInterval` to toggle every frame between 0.048 and 0.052. This send-rate oscillation adds jitter to the input cadence. + +## Goals / Non-Goals + +**Goals:** +- Prevent send interval oscillation when server tick offset is near zero. +- Preserve meaningful clock correction when real drift exists (offset is consistently positive or negative). + +**Non-Goals:** +- This is not a full clock synchronization protocol — only a local oscillation guard. +- Does not change the underlying tick offset computation. + +## Decisions + +### Decision: Dead-band hysteresis for send interval correction + +Instead of toggling `_sendInterval` on every sign change of `_currentTickOffset`, apply a dead-band threshold. Only correct the send interval when the absolute offset exceeds a meaningful threshold (e.g., 1-2 ticks = 50-100ms of drift). + +**Current code (problematic):** +```csharp +if (_currentTickOffset < 0) + _sendInterval = 0.052f; +if (_currentTickOffset > 0) + _sendInterval = 0.048f; +``` + +**Proposed replacement:** +```csharp +private const float kTickOffsetThreshold = 2; // ticks + +if (_currentTickOffset < -kTickOffsetThreshold) + _sendInterval = 0.052f; +else if (_currentTickOffset > kTickOffsetThreshold) + _sendInterval = 0.048f; +// else: keep current interval (no correction within dead band) +``` + +**Alternatives considered:** +1. **Exponential moving average of offset** — smooths jitter but adds complexity and latency to correction. +2. **Remove correction entirely, use fixed 0.05s** — simpler but loses adaptive behavior when real drift exists. + +The dead-band approach is the simplest that directly solves oscillation without adding state complexity. + +## Risks / Trade-offs + +- **Risk**: If `kTickOffsetThreshold` is too large, real drift may not be corrected fast enough. + - **Mitigation**: Start with a conservative threshold (1-2 ticks). Adjust after measuring. +- **Risk**: The hysteresis introduces a zone where no correction is applied even when offset is slightly non-zero. + - **Accepted**: This is the intended behavior — minor fluctuations near zero should not disturb steady-rate sending. diff --git a/openspec/changes/archive/2026-04-06-stabilize-send-interval-oscillation/proposal.md b/openspec/changes/archive/2026-04-06-stabilize-send-interval-oscillation/proposal.md new file mode 100644 index 0000000..cf372f0 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-stabilize-send-interval-oscillation/proposal.md @@ -0,0 +1,22 @@ +## Why + +`MovementComponent.SetServerTick(...)` toggles `_sendInterval` between 0.052f and 0.048f whenever `_currentTickOffset` crosses zero. When the offset hovers near zero due to minor clock drift, this causes frame-to-frame send-cadilla oscillation, which disrupts steady-rate input submission and adds unnecessary jitter to the prediction/reconciliation loop. + +## What Changes + +- Add hysteresis to the send interval adjustment so it does not flip-flop when `_currentTickOffset` oscillates around zero. +- The correction logic will use a dead-band threshold — only adjust `_sendInterval` when the absolute offset exceeds a meaningful threshold, not on every sign change. +- A small nominal send interval (50ms) remains the baseline; clock correction only applies when drift is substantial. + +## Capabilities + +### New Capabilities +- `client-send-interval-stabilization`: A contract specifying that the client's send interval does not oscillate due to minor server tick offset fluctuations near zero. + +### Modified Capabilities +- `client-prediction-cadence`: Extend to explicitly cover that send interval correction is also bounded by hysteresis and does not toggle at near-zero offset. + +## Impact + +- `MovementComponent.SetServerTick(...)` — threshold-based hysteresis added to send interval correction logic +- No changes to network message formats, delivery policies, or prediction buffer behavior diff --git a/openspec/changes/archive/2026-04-06-stabilize-send-interval-oscillation/specs/client-send-interval-stabilization/spec.md b/openspec/changes/archive/2026-04-06-stabilize-send-interval-oscillation/specs/client-send-interval-stabilization/spec.md new file mode 100644 index 0000000..51be004 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-stabilize-send-interval-oscillation/specs/client-send-interval-stabilization/spec.md @@ -0,0 +1,36 @@ +# client-send-interval-stabilization Specification + +## Purpose + +Define that the client send interval is protected from oscillation when the server tick offset hovers near zero, ensuring steady-rate input submission without frame-to-frame cadence jitter. + +## Requirements + +### Requirement: Send interval correction uses hysteresis dead-band + +The controlled-client send interval corrector SHALL apply a dead-band threshold before adjusting `_sendInterval`, so that minor server tick offset fluctuations near zero do not cause the send cadence to toggle between values. + +#### Scenario: No correction within dead-band +- **WHEN** `_currentTickOffset` is between -2 and +2 ticks (inclusive) +- **THEN** `_sendInterval` is not changed +- **THEN** the previously active send interval is preserved + +#### Scenario: Slow drift correction below threshold +- **WHEN** `_currentTickOffset` stays within the dead-band for an extended period +- **THEN** `_sendInterval` remains stable at its current value +- **THEN** no oscillation occurs regardless of offset sign changes within the band + +#### Scenario: Correction applies outside dead-band +- **WHEN** `_currentTickOffset` exceeds +2 (client ahead of server) +- **THEN** `_sendInterval` is set to 0.048f to send slightly faster +- **WHEN** `_currentTickOffset` is below -2 (client behind server) +- **THEN** `_sendInterval` is set to 0.052f to send slightly slower + +### Requirement: Send interval stabilizes after offset crosses threshold + +Once the offset exits the dead-band and triggers a correction, subsequent corrections SHALL only occur when the offset crosses the threshold again in the opposite direction, preventing rapid re-correction. + +#### Scenario: Correction latches until opposite threshold +- **WHEN** offset triggers a correction to 0.048f (offset > +2) +- **THEN** further offset increases within the same sign do not re-trigger correction +- **THEN** the send interval stays at 0.048f until offset crosses back below +2 then exceeds -2 diff --git a/openspec/changes/archive/2026-04-06-stabilize-send-interval-oscillation/tasks.md b/openspec/changes/archive/2026-04-06-stabilize-send-interval-oscillation/tasks.md new file mode 100644 index 0000000..59abce7 --- /dev/null +++ b/openspec/changes/archive/2026-04-06-stabilize-send-interval-oscillation/tasks.md @@ -0,0 +1,12 @@ +## 1. Implement hysteresis dead-band in SetServerTick + +- [x] 1.1 Add `private const int kTickOffsetThreshold = 2;` to MovementComponent +- [x] 1.2 Replace the dual `if (_currentTickOffset < 0 / > 0)` sign checks with a threshold-based dead-band: only adjust `_sendInterval` when `Mathf.Abs(_currentTickOffset) > kTickOffsetThreshold` + +## 2. Add regression test for send interval stability + +- [x] 2.1 Add a test in `ServerRuntimeEntryPointTests.cs` or a new test file verifying that `SetServerTick` does not oscillate `_sendInterval` when offset hovers near zero + +## 3. Update TODO.md + +- [x] 3.1 Mark TODO.md Step 3 as complete diff --git a/openspec/specs/client-authoritative-player-state/spec.md b/openspec/specs/client-authoritative-player-state/spec.md index 29876dc..844a2cf 100644 --- a/openspec/specs/client-authoritative-player-state/spec.md +++ b/openspec/specs/client-authoritative-player-state/spec.md @@ -13,11 +13,12 @@ The client SHALL keep one explicit owned authoritative `PlayerState` snapshot fo - **THEN** presentation and diagnostics read authoritative `position`, `rotation`, `hp`, and optional `velocity` from that owned snapshot ### Requirement: Local player reconciliation applies the full authoritative state by tick -The controlled client SHALL continue reconciling local prediction from authoritative `PlayerState` snapshots while keeping authoritative HP and optional velocity synchronized with the owned player-state snapshot. Reconciliation MUST use the acknowledged movement-input tick defined by the sync strategy, and the visible controlled-player transform MUST keep authoritative gameplay truth separate from short-lived visual correction state. Small divergence after replay MUST converge through explicit bounded correction state, while large divergence or failed convergence MUST still snap immediately to authoritative `position` and `rotation`. +The controlled client SHALL continue reconciling local prediction from authoritative `PlayerState` snapshots while keeping authoritative HP and optional velocity synchronized with the owned player-state snapshot. Reconciliation MUST use the acknowledged movement-input tick defined by the sync strategy, and the visible controlled-player transform MUST keep authoritative gameplay truth separate from short-lived visual correction state. **Replay of pending inputs during reconciliation MUST use fixed-step substeps matching the server authoritative movement cadence, producing identical trajectory to live prediction for the same input sequence.** Small divergence after replay MUST converge through explicit bounded correction state, while large divergence or failed convergence MUST still snap immediately to authoritative `position` and `rotation`. #### Scenario: Local authoritative state corrects predicted presentation - **WHEN** the controlled player accepts an authoritative `PlayerState` whose acknowledged movement-input tick is `N` - **THEN** local reconciliation prunes or replays predicted movement using tick `N` according to the sync strategy +- **THEN** the replay uses fixed-step substeps matching the server authoritative movement cadence - **THEN** the controlled player's authoritative gameplay state updates immediately to the accepted `position`, `rotation`, HP, and optional velocity - **THEN** the local player's visible transform may temporarily differ only through bounded visual correction state that converges back to the authoritative baseline @@ -31,6 +32,12 @@ The controlled client SHALL continue reconciling local prediction from authorita - **THEN** the controlled player's visible transform snaps immediately to authoritative `position` and `rotation` - **THEN** any temporary visual correction state is cleared before later local prediction resumes from that authoritative baseline +#### Scenario: Replay produces identical trajectory to live prediction +- **WHEN** the controlled player replays pending inputs after accepting authoritative `PlayerState` +- **THEN** the replay applies inputs in fixed-duration substeps equal to the server authoritative movement cadence +- **THEN** the final predicted pose equals what live `FixedUpdate` prediction would produce for the same input sequence +- **THEN** the result is stable across multiple replays of the same input sequence + ### Requirement: Remote players apply authoritative state without inventing gameplay truth Remote player presentation SHALL consume the accepted authoritative player-state snapshot owned by the client and MUST NOT invent HP or final gameplay state locally. Remote movement presentation MUST smooth authoritative position and rotation through a small buffered snapshot interpolation path instead of applying only the latest snapshot directly. Stale remote `PlayerState` packets that are older than the latest accepted authoritative tick for that player MUST NOT overwrite the owned snapshot or enter the interpolation buffer. diff --git a/openspec/specs/client-prediction-cadence/spec.md b/openspec/specs/client-prediction-cadence/spec.md new file mode 100644 index 0000000..0eac53f --- /dev/null +++ b/openspec/specs/client-prediction-cadence/spec.md @@ -0,0 +1,30 @@ +# client-prediction-cadence Specification + +## Purpose + +Define that client forward prediction accumulation uses an explicit cadence derived from the server authoritative movement cadence, not `Time.fixedDeltaTime`, ensuring prediction timing aligns with authoritative timing in reconciliation-sensitive paths. + +## Requirements + +### Requirement: Forward prediction accumulation uses authoritative cadence + +The controlled-client forward prediction path SHALL accumulate pending input duration using the server authoritative movement cadence as the unit of accumulation, not `Time.fixedDeltaTime` or other render-loop-derived values. This ensures `SimulatedDurationSeconds` reflects server-time and remains coherent with the server's 50ms step cadence. + +#### Scenario: Accumulation uses server cadence regardless of FixedUpdate interval +- **WHEN** the client FixedUpdate runs at a 20ms interval +- **THEN** `AccumulateLatest` adds `kServerSimulationStepSeconds` (50ms) to the pending input duration +- **THEN** the accumulated `SimulatedDurationSeconds` reflects server-time, not real elapsed time + +#### Scenario: Accumulation cadence is decoupled from frame rate +- **WHEN** FixedUpdate runs at a non-standard interval due to platform variation or frame drops +- **THEN** the accumulation unit remains `kServerSimulationStepSeconds` +- **THEN** prediction timing does not drift relative to the server's authoritative cadence + +### Requirement: Forward prediction and replay use the same cadence source + +The controlled-client prediction system SHALL use the same cadence source for both forward accumulation and replay substepping, ensuring that `SimulatedDurationSeconds` consumed during replay matches the cadence used during forward prediction. + +#### Scenario: Forward accumulated duration matches replay substep size +- **WHEN** the client accumulates pending input for 100ms of server-time +- **THEN** the replay path consumes the same 100ms in 50ms substeps +- **THEN** the forward accumulated duration and replay duration are derived from the same cadence constant diff --git a/openspec/specs/client-prediction-diagnostics/spec.md b/openspec/specs/client-prediction-diagnostics/spec.md new file mode 100644 index 0000000..e066abe --- /dev/null +++ b/openspec/specs/client-prediction-diagnostics/spec.md @@ -0,0 +1,34 @@ +# client-prediction-diagnostics Specification + +## Purpose + +Define diagnostics that expose per-snapshot prediction state for regression testing and runtime debugging, enabling verification that replay produces identical trajectories to live prediction and that small server tick offset fluctuations do not cause visible local cadence oscillation. + +## Requirements + +### Requirement: Authoritative snapshot exposes acknowledged move tick + +The client prediction system SHALL expose the acknowledged movement-input tick from the most recently accepted authoritative `PlayerState` snapshot. + +#### Scenario: Diagnostics report acknowledged move tick +- **WHEN** the client accepts an authoritative `PlayerState` +- **THEN** diagnostics can read the acknowledged move tick from that snapshot +- **THEN** this value is available for regression tests and runtime debugging + +### Requirement: Authoritative snapshot exposes predicted vs authoritative pose + +The client prediction system SHALL expose both the locally predicted pose and the authoritative pose for the controlled player at each snapshot. + +#### Scenario: Diagnostics report predicted and authoritative poses +- **WHEN** the client has a locally predicted pose and receives an authoritative `PlayerState` +- **THEN** diagnostics can read both the predicted pose and the authoritative pose +- **THEN** the correction magnitude (difference between predicted and authoritative) is computable + +### Requirement: Authoritative snapshot exposes correction magnitude + +The client prediction system SHALL expose the correction magnitude applied during reconciliation for regression testing. + +#### Scenario: Diagnostics report correction magnitude +- **WHEN** the client reconciles from authoritative `PlayerState` +- **THEN** diagnostics can read the correction magnitude applied +- **THEN** this value is available to verify that small server tick offset fluctuations do not cause excessive local corrections diff --git a/openspec/specs/client-prediction-replay/spec.md b/openspec/specs/client-prediction-replay/spec.md new file mode 100644 index 0000000..cf08159 --- /dev/null +++ b/openspec/specs/client-prediction-replay/spec.md @@ -0,0 +1,36 @@ +# client-prediction-replay Specification + +## Purpose + +Define the contract that client-side replay of pending movement inputs after authoritative state acknowledgement uses fixed-step substeps matching the server authoritative movement cadence, not a single accumulated duration, so that replay trajectory matches live prediction trajectory for the same input sequence. + +## Requirements + +### Requirement: Replay uses fixed-step accumulation matching server cadence + +The controlled-client prediction replay path SHALL consume each pending `PredictedMoveStep` by applying its input in fixed-duration substeps equal to the server authoritative movement cadence, regardless of the step's total `SimulatedDurationSeconds`. **Forward prediction accumulation SHALL also use the same server authoritative movement cadence as the unit of accumulation, ensuring forward accumulated duration and replay duration are derived from the same cadence constant.** The replay accumulation shape MUST be identical to the live `FixedUpdate` prediction path for the same input values. + +#### Scenario: Replay produces same trajectory as live prediction for steady input +- **WHEN** the client replays a `PredictedMoveStep` with turn=0, throttle=1, duration=0.15s using a 0.05s server cadence +- **THEN** the replay applies 0.05s + 0.05s + 0.05s substeps in sequence +- **THEN** the final predicted position matches the position that would result from three consecutive FixedUpdate predictions of 0.05s each with the same input + +#### Scenario: Replay produces same trajectory as live prediction for turn-and-move input +- **WHEN** the client replays a `PredictedMoveStep` with turn=0.5, throttle=1, duration=0.10s using a 0.05s server cadence +- **THEN** the replay applies two 0.05s substeps where each substep's heading affects the next substep's forward direction +- **THEN** the final predicted heading and position match the live prediction path for the same input sequence + +#### Scenario: Replay handles non-multiples of cadence interval +- **WHEN** the client replays a `PredictedMoveStep` with duration=0.12s using a 0.05s cadence +- **THEN** the replay applies 0.05s + 0.05s + 0.02s substeps sequentially +- **THEN** no remaining duration is lost or double-counted + +### Requirement: Replay trajectory determinism is verifiable + +The client prediction system SHALL provide a deterministic way to verify that replay and live prediction produce identical trajectories for a given input sequence, enabling regression coverage. + +#### Scenario: Replay and live prediction produce identical results +- **WHEN** a controlled client records a `MoveInput` sequence during live play +- **AND** the client triggers reconciliation and replays those same inputs +- **THEN** the final predicted pose after replay equals the predicted pose that would result from live FixedUpdate simulation for the same input sequence +- **THEN** the result is stable across multiple replays of the same input sequence diff --git a/openspec/specs/client-send-interval-stabilization/spec.md b/openspec/specs/client-send-interval-stabilization/spec.md new file mode 100644 index 0000000..51be004 --- /dev/null +++ b/openspec/specs/client-send-interval-stabilization/spec.md @@ -0,0 +1,36 @@ +# client-send-interval-stabilization Specification + +## Purpose + +Define that the client send interval is protected from oscillation when the server tick offset hovers near zero, ensuring steady-rate input submission without frame-to-frame cadence jitter. + +## Requirements + +### Requirement: Send interval correction uses hysteresis dead-band + +The controlled-client send interval corrector SHALL apply a dead-band threshold before adjusting `_sendInterval`, so that minor server tick offset fluctuations near zero do not cause the send cadence to toggle between values. + +#### Scenario: No correction within dead-band +- **WHEN** `_currentTickOffset` is between -2 and +2 ticks (inclusive) +- **THEN** `_sendInterval` is not changed +- **THEN** the previously active send interval is preserved + +#### Scenario: Slow drift correction below threshold +- **WHEN** `_currentTickOffset` stays within the dead-band for an extended period +- **THEN** `_sendInterval` remains stable at its current value +- **THEN** no oscillation occurs regardless of offset sign changes within the band + +#### Scenario: Correction applies outside dead-band +- **WHEN** `_currentTickOffset` exceeds +2 (client ahead of server) +- **THEN** `_sendInterval` is set to 0.048f to send slightly faster +- **WHEN** `_currentTickOffset` is below -2 (client behind server) +- **THEN** `_sendInterval` is set to 0.052f to send slightly slower + +### Requirement: Send interval stabilizes after offset crosses threshold + +Once the offset exits the dead-band and triggers a correction, subsequent corrections SHALL only occur when the offset crosses the threshold again in the opposite direction, preventing rapid re-correction. + +#### Scenario: Correction latches until opposite threshold +- **WHEN** offset triggers a correction to 0.048f (offset > +2) +- **THEN** further offset increases within the same sign do not re-trigger correction +- **THEN** the send interval stays at 0.048f until offset crosses back below +2 then exceeds -2