RUDPClient/openspec/changes/archive/2026-04-06-align-replay-gra.../design.md

3.0 KiB

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:

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