This commit is contained in:
SepComet 2026-04-06 16:19:44 +08:00
parent a1ede230bb
commit 79474b53aa
42 changed files with 1264 additions and 18 deletions

View File

@ -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"

View File

@ -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}

View File

@ -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);
}
}

View File

@ -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<PredictedMoveStep> replayInputs)

View File

@ -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<Vector3> OnServerPosChanged;
public UnityAction<Vector3> OnClientPosChanged;
public UnityAction<long> OnServerTickChanged;
public UnityAction<long> OnStartTickOffsetChanged;
public UnityAction<long> OnClientTickChanged;
public UnityAction<Vector3, Vector3, float, float> OnCorrectionMagnitudeChanged;
public UnityAction<long> 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;
}
}

View File

@ -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>();
rigidbody.useGravity = false;
var movement = gameObject.AddComponent<MovementComponent>();
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<PredictedMoveStep>
{
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>();
rigidbody.useGravity = false;
rigidbody.interpolation = RigidbodyInterpolation.None;
var movement = gameObject.AddComponent<MovementComponent>();
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>();
rigidbody.useGravity = false;
rigidbody.interpolation = RigidbodyInterpolation.None;
var movement = gameObject.AddComponent<MovementComponent>();
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()
{

View File

@ -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.

11
TODO.md
View File

@ -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.

61
check_result.md Normal file
View File

@ -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: 180fClient: 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会触发瞬移

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-06

View File

@ -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`

View File

@ -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

View File

@ -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`.

View File

@ -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

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-06

View File

@ -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.

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-06

View File

@ -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<PredictedMoveStep> 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-06

View File

@ -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.

View File

@ -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.

View File

@ -0,0 +1,3 @@
# Spec Changes
No new capabilities introduced. This is a measurement/validation step with no spec-level changes.

View File

@ -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

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-06

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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