• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

orion-ecs / keen-eye / 29934050223

22 Jul 2026 03:34PM UTC coverage: 62.874% (+0.3%) from 62.599%
29934050223

push

github

tyevco
feat(replay): Reconstruct ghost frames at delta markers

Fixes #1035

8230 of 12446 branches covered (66.13%)

Branch coverage included in aggregate %.

64 of 85 new or added lines in 2 files covered. (75.29%)

657 existing lines in 8 files now uncovered.

49313 of 79075 relevant lines covered (62.36%)

0.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.32
/src/KeenEyes.Network/Systems/ClientPredictionSystem.cs
1
using KeenEyes.Capabilities;
2
using KeenEyes.Network.Components;
3
using KeenEyes.Network.Prediction;
4
using KeenEyes.Network.Serialization;
5

6
namespace KeenEyes.Network.Systems;
7

8
/// <summary>
9
/// System that handles client-side prediction, misprediction detection, and reconciliation.
10
/// </summary>
11
/// <remarks>
12
/// <para>
13
/// This system runs on the client and manages the prediction lifecycle:
14
/// 1. Saves predicted states after local simulation
15
/// 2. Compares server-confirmed states against predictions
16
/// 3. Triggers reconciliation (rollback + replay) on misprediction
17
/// </para>
18
/// </remarks>
19
/// <param name="plugin">The network client plugin.</param>
20
/// <param name="interpolator">Optional interpolator for smooth corrections.</param>
21
/// <param name="applyInput">Function to apply an input during replay.</param>
22
public sealed class ClientPredictionSystem(
1✔
23
    NetworkClientPlugin plugin,
1✔
24
    INetworkInterpolator? interpolator = null,
1✔
25
    Action<Entity, object>? applyInput = null) : SystemBase
1✔
26
{
27
    private readonly Dictionary<Entity, PredictionBuffer> predictionBuffers = [];
1✔
28
    private readonly INetworkInterpolator? smoothingInterpolator = interpolator;
1✔
29

30
    /// <summary>
31
    /// Gets the prediction buffer for an entity.
32
    /// </summary>
33
    /// <param name="entity">The entity.</param>
34
    /// <returns>The prediction buffer, or null if not found.</returns>
35
    public PredictionBuffer? GetPredictionBuffer(Entity entity)
36
    {
37
        return predictionBuffers.TryGetValue(entity, out var buffer) ? buffer : null;
×
38
    }
39

40
    /// <inheritdoc/>
41
    public override void Update(float deltaTime)
42
    {
43
        if (!plugin.Config.EnablePrediction)
1✔
44
        {
45
            return;
×
46
        }
47

48
        // Save predicted states for locally owned entities
49
        foreach (var entity in World.Query<LocallyOwned, Predicted, PredictionState>())
1✔
50
        {
51
            SavePredictedState(entity);
1✔
52
        }
53
    }
1✔
54

55
    /// <summary>
56
    /// Called when server state is received to check for misprediction.
57
    /// </summary>
58
    /// <param name="entity">The entity that received server state.</param>
59
    /// <param name="serverTick">The tick the server state is for.</param>
60
    /// <param name="serverStates">The server component states.</param>
61
    public void OnServerStateReceived(
62
        Entity entity,
63
        uint serverTick,
64
        IReadOnlyDictionary<Type, object> serverStates)
65
    {
66
        if (!World.Has<PredictionState>(entity))
1✔
67
        {
68
            return;
×
69
        }
70

71
        ref var predState = ref World.Get<PredictionState>(entity);
1✔
72

73
        // Check if we have a prediction for this tick
74
        if (!predictionBuffers.TryGetValue(entity, out var buffer))
1✔
75
        {
76
            return;
×
77
        }
78

79
        var predictedStates = buffer.GetStatesForTick(serverTick);
1✔
80
        if (predictedStates is null)
1✔
81
        {
82
            // No prediction for this tick, just apply server state
83
            ApplyServerState(entity, serverStates);
×
84
            predState.LastConfirmedTick = serverTick;
×
85
            return;
×
86
        }
87

88
        // Compare predicted vs server state
89
        var correctionMagnitude = ComputeCorrectionMagnitude(predictedStates, serverStates);
1✔
90

91
        if (correctionMagnitude > 0f)
1✔
92
        {
93
            predState.MispredictionDetected = true;
1✔
94
            Reconcile(entity, serverTick, serverStates, correctionMagnitude);
1✔
95
        }
96
        else
97
        {
98
            predState.MispredictionDetected = false;
1✔
99
            predState.LastCorrectionMagnitude = 0f;
1✔
100
        }
101

102
        // Update confirmed tick and clean up old predictions
103
        predState.LastConfirmedTick = serverTick;
1✔
104
        buffer.RemoveOlderThan(serverTick);
1✔
105
    }
1✔
106

107
    private void SavePredictedState(Entity entity)
108
    {
109
        if (!predictionBuffers.TryGetValue(entity, out var buffer))
1✔
110
        {
111
            buffer = new PredictionBuffer(plugin.Config.InputBufferSize);
1✔
112
            predictionBuffers[entity] = buffer;
1✔
113
        }
114

115
        ref var predState = ref World.Get<PredictionState>(entity);
1✔
116
        var tick = plugin.CurrentTick;
1✔
117

118
        // Save all replicated component states
119
        var serializer = plugin.Config.Serializer;
1✔
120
        if (serializer is null)
1✔
121
        {
122
            return;
1✔
123
        }
124

125
        if (World is ISnapshotCapability snapshot)
1✔
126
        {
127
            foreach (var (type, value) in snapshot.GetComponents(entity))
1✔
128
            {
129
                if (serializer.IsNetworkSerializable(type))
1✔
130
                {
131
                    // Clone the value to avoid storing a reference
132
                    buffer.SaveState(tick, type, CloneValue(type, value));
1✔
133
                }
134
            }
135
        }
136

137
        predState.LastPredictedTick = tick;
1✔
138
    }
1✔
139

140
    /// <summary>
141
    /// Computes the correction magnitude as the fraction of server-confirmed component
142
    /// states that diverged from the prediction beyond the misprediction threshold.
143
    /// </summary>
144
    /// <param name="predicted">The predicted component states saved for the confirmed tick.</param>
145
    /// <param name="server">The authoritative component states received from the server.</param>
146
    /// <returns>
147
    /// A value in [0, 1]: 0 when every compared component matched the prediction,
148
    /// 1 when every compared component had to be corrected. A component with no
149
    /// saved prediction counts as corrected.
150
    /// </returns>
151
    /// <remarks>
152
    /// Component mismatch uses the same comparison as misprediction detection (the
153
    /// serializer's epsilon-based dirty mask when delta encoding is supported, exact
154
    /// equality otherwise). The abstractions cannot measure spatial distance without
155
    /// reflection - the interpolator only blends values and the serializer only reports
156
    /// which fields changed - so the normalized mismatch fraction is the most honest
157
    /// measurable definition of correction size.
158
    /// </remarks>
159
    private float ComputeCorrectionMagnitude(
160
        IReadOnlyDictionary<Type, object> predicted,
161
        IReadOnlyDictionary<Type, object> server)
162
    {
163
        var threshold = plugin.Config.MispredictionThreshold;
1✔
164
        var comparedCount = 0;
1✔
165
        var correctedCount = 0;
1✔
166

167
        foreach (var (type, serverValue) in server)
1✔
168
        {
169
            comparedCount++;
1✔
170

171
            if (!predicted.TryGetValue(type, out var predictedValue) ||
1✔
172
                !ValuesApproximatelyEqual(predictedValue, serverValue, threshold))
1✔
173
            {
174
                correctedCount++;
1✔
175
            }
176
        }
177

178
        return comparedCount == 0 ? 0f : (float)correctedCount / comparedCount;
1✔
179
    }
180

181
    private bool ValuesApproximatelyEqual(object predicted, object server, float threshold)
182
    {
183
        // Fast path: exact equality
184
        if (predicted.Equals(server))
1✔
185
        {
186
            return true;
1✔
187
        }
188

189
        // Types must match
190
        var type = predicted.GetType();
1✔
191
        if (type != server.GetType())
1✔
192
        {
UNCOV
193
            return false;
×
194
        }
195

196
        // For threshold-based comparison, use the network serializer's delta detection
197
        if (threshold > 0)
1✔
198
        {
199
            var serializer = plugin.Config.Serializer;
1✔
200
            if (serializer is not null && serializer.SupportsDelta(type))
1✔
201
            {
202
                // If the dirty mask is 0, no fields have changed significantly
203
                // The dirty mask uses epsilon-based comparison for floats
204
                var dirtyMask = serializer.GetDirtyMask(type, predicted, server);
1✔
205
                return dirtyMask == 0;
1✔
206
            }
207
        }
208

209
        // Fallback: not approximately equal if not exactly equal
UNCOV
210
        return false;
×
211
    }
212

213
    private void Reconcile(
214
        Entity entity,
215
        uint serverTick,
216
        IReadOnlyDictionary<Type, object> serverStates,
217
        float correctionMagnitude)
218
    {
219
        // Step 1: Apply server state (rollback)
220
        ApplyServerState(entity, serverStates);
1✔
221

222
        // Step 2: Replay inputs from serverTick to current tick
223
        if (applyInput is not null)
1✔
224
        {
225
            var inputBuffer = plugin.GetInputBuffer(entity) as IInputBuffer;
1✔
226
            if (inputBuffer is not null)
1✔
227
            {
228
                foreach (var input in inputBuffer.GetInputsFromBoxed(serverTick + 1))
1✔
229
                {
230
                    applyInput(entity, input);
1✔
231
                }
232
            }
233
        }
234

235
        // Record the correction magnitude (fraction of compared components corrected,
236
        // see ComputeCorrectionMagnitude) for smoothing consumers
237
        ref var predState = ref World.Get<PredictionState>(entity);
1✔
238
        predState.LastCorrectionMagnitude = correctionMagnitude;
1✔
239

240
        // Store interpolator availability for potential future smoothing
241
        predState.SmoothingAvailable = smoothingInterpolator is not null;
1✔
242
    }
1✔
243

244
    private void ApplyServerState(Entity entity, IReadOnlyDictionary<Type, object> serverStates)
245
    {
246
        foreach (var (type, value) in serverStates)
1✔
247
        {
248
            World.SetComponent(entity, type, value);
1✔
249
        }
250
    }
1✔
251

252
    private static object CloneValue(Type type, object value)
253
    {
254
        // For value types (structs), boxing already creates a copy
255
        if (type.IsValueType)
1✔
256
        {
257
            return value;
1✔
258
        }
259

260
        // For reference types, we'd need deep cloning
261
        // Network components should be value types (structs)
262
        return value;
263
    }
264

265
    /// <summary>
266
    /// Registers an entity for prediction tracking.
267
    /// </summary>
268
    /// <param name="entity">The entity to track.</param>
269
    public void RegisterEntity(Entity entity)
270
    {
UNCOV
271
        if (!predictionBuffers.ContainsKey(entity))
×
272
        {
UNCOV
273
            predictionBuffers[entity] = new PredictionBuffer(plugin.Config.InputBufferSize);
×
274
        }
UNCOV
275
    }
×
276

277
    /// <summary>
278
    /// Unregisters an entity from prediction tracking.
279
    /// </summary>
280
    /// <param name="entity">The entity to unregister.</param>
281
    public void UnregisterEntity(Entity entity)
282
    {
UNCOV
283
        predictionBuffers.Remove(entity);
×
UNCOV
284
    }
×
285
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc