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

orion-ecs / keen-eye / 30113831081

24 Jul 2026 05:39PM UTC coverage: 64.224% (-0.07%) from 64.291%
30113831081

push

github

tyevco
fix(audio): Fix non-looping replay, WAV validation, paused-source recycling, double master volume (#1185,#1186,#1188,#1189)

- #1185: AudioSourceSystem now restarts a stopped backend source when State is set back to Playing (distinguishes a replay request from a natural finish via CurrentSound validity), so non-looping sources are replayable per the AudioSource.State contract.
- #1186: WavDecoder validates each chunk size against the remaining bytes and throws AudioLoadException on negative/oversized sizes, preventing the ArgumentOutOfRangeException slice crash and the negative-size infinite loop on untrusted files.
- #1188: SourcePool.Update only recycles Stopped sources (Paused treated as still-active), so Pause/PauseAll no longer kills pooled one-shots on the next frame.
- #1189: SilkAudioContext applies master + Master-channel volume only via the listener gain; ComputeEffectiveVolume no longer re-multiplies them into the per-source gain, eliminating the squared master volume and the new-vs-playing inconsistency.

Adds KeenEyes.Audio.Tests (AudioSourceSystem replay/finish, via fake IAudioDevice/IAudioContext) and KeenEyes.Audio.Silk.Tests (WavDecoder malformed-input validation). #1188/#1189 are OpenAL-device-bound and verified by code trace.

Closes #1185
Closes #1186
Closes #1188
Closes #1189

8612 of 12731 branches covered (67.65%)

Branch coverage included in aggregate %.

10 of 15 new or added lines in 4 files covered. (66.67%)

368 existing lines in 26 files now uncovered.

51312 of 80574 relevant lines covered (63.68%)

0.99 hits per line

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

91.96
/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;
1✔
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
        // Note: any ref into PredictionState must be re-fetched AFTER ApplyServerState.
72
        // Applying a registered-but-absent component migrates the entity to a new
73
        // archetype, invalidating earlier refs; writing through a stale ref lands in the
74
        // vacated slot and is silently lost (#1097).
75

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

82
        var predictedStates = buffer.GetStatesForTick(serverTick);
1✔
83
        if (predictedStates is null)
1✔
84
        {
85
            // No prediction for this tick, just apply server state.
86
            ApplyServerState(entity, serverStates);
1✔
87
            ref var predStateAfterApply = ref World.Get<PredictionState>(entity);
1✔
88
            predStateAfterApply.LastConfirmedTick = serverTick;
1✔
89
            return;
1✔
90
        }
91

92
        // Compare predicted vs server state
93
        var correctionMagnitude = ComputeCorrectionMagnitude(predictedStates, serverStates);
1✔
94

95
        if (correctionMagnitude > 0f)
1✔
96
        {
97
            // Reconcile applies server state (possible migration) and sets correction
98
            // fields on its own freshly fetched ref, so fetch again here for the flags.
99
            Reconcile(entity, serverTick, serverStates, correctionMagnitude);
1✔
100

101
            ref var predState = ref World.Get<PredictionState>(entity);
1✔
102
            predState.MispredictionDetected = true;
1✔
103
            predState.LastConfirmedTick = serverTick;
1✔
104
        }
105
        else
106
        {
107
            ref var predState = ref World.Get<PredictionState>(entity);
1✔
108
            predState.MispredictionDetected = false;
1✔
109
            predState.LastCorrectionMagnitude = 0f;
1✔
110
            predState.LastConfirmedTick = serverTick;
1✔
111
        }
112

113
        // Clean up old predictions
114
        buffer.RemoveOlderThan(serverTick);
1✔
115
    }
1✔
116

117
    private void SavePredictedState(Entity entity)
118
    {
119
        if (!predictionBuffers.TryGetValue(entity, out var buffer))
1✔
120
        {
121
            buffer = new PredictionBuffer(plugin.Config.InputBufferSize);
1✔
122
            predictionBuffers[entity] = buffer;
1✔
123
        }
124

125
        ref var predState = ref World.Get<PredictionState>(entity);
1✔
126
        var tick = plugin.CurrentTick;
1✔
127

128
        // Save all replicated component states
129
        var serializer = plugin.Config.Serializer;
1✔
130
        if (serializer is null)
1✔
131
        {
132
            return;
1✔
133
        }
134

135
        if (World is ISnapshotCapability snapshot)
1✔
136
        {
137
            foreach (var (type, value) in snapshot.GetComponents(entity))
1✔
138
            {
139
                if (serializer.IsNetworkSerializable(type))
1✔
140
                {
141
                    // Clone the value to avoid storing a reference
142
                    buffer.SaveState(tick, type, CloneValue(type, value));
1✔
143
                }
144
            }
145
        }
146

147
        predState.LastPredictedTick = tick;
1✔
148
    }
1✔
149

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

177
        foreach (var (type, serverValue) in server)
1✔
178
        {
179
            comparedCount++;
1✔
180

181
            if (!predicted.TryGetValue(type, out var predictedValue) ||
1✔
182
                !ValuesApproximatelyEqual(predictedValue, serverValue, threshold))
1✔
183
            {
184
                correctedCount++;
1✔
185
            }
186
        }
187

188
        return comparedCount == 0 ? 0f : (float)correctedCount / comparedCount;
1✔
189
    }
190

191
    private bool ValuesApproximatelyEqual(object predicted, object server, float threshold)
192
    {
193
        // Fast path: exact equality
194
        if (predicted.Equals(server))
1✔
195
        {
196
            return true;
1✔
197
        }
198

199
        // Types must match
200
        var type = predicted.GetType();
1✔
201
        if (type != server.GetType())
1✔
202
        {
UNCOV
203
            return false;
×
204
        }
205

206
        // For threshold-based comparison, use the network serializer's delta detection
207
        if (threshold > 0)
1✔
208
        {
209
            var serializer = plugin.Config.Serializer;
1✔
210
            if (serializer is not null && serializer.SupportsDelta(type))
1✔
211
            {
212
                // If the dirty mask is 0, no fields have changed significantly
213
                // The dirty mask uses epsilon-based comparison for floats
214
                var dirtyMask = serializer.GetDirtyMask(type, predicted, server);
1✔
215
                return dirtyMask == 0;
1✔
216
            }
217
        }
218

219
        // Fallback: not approximately equal if not exactly equal
UNCOV
220
        return false;
×
221
    }
222

223
    private void Reconcile(
224
        Entity entity,
225
        uint serverTick,
226
        IReadOnlyDictionary<Type, object> serverStates,
227
        float correctionMagnitude)
228
    {
229
        // Step 1: Apply server state (rollback)
230
        ApplyServerState(entity, serverStates);
1✔
231

232
        // Step 2: Replay inputs from serverTick to current tick
233
        if (applyInput is not null)
1✔
234
        {
235
            var inputBuffer = plugin.GetInputBuffer(entity) as IInputBuffer;
1✔
236
            if (inputBuffer is not null)
1✔
237
            {
238
                foreach (var input in inputBuffer.GetInputsFromBoxed(serverTick + 1))
1✔
239
                {
240
                    applyInput(entity, input);
1✔
241
                }
242
            }
243
        }
244

245
        // Record the correction magnitude (fraction of compared components corrected,
246
        // see ComputeCorrectionMagnitude) for smoothing consumers
247
        ref var predState = ref World.Get<PredictionState>(entity);
1✔
248
        predState.LastCorrectionMagnitude = correctionMagnitude;
1✔
249

250
        // Store interpolator availability for potential future smoothing
251
        predState.SmoothingAvailable = smoothingInterpolator is not null;
1✔
252
    }
1✔
253

254
    private void ApplyServerState(Entity entity, IReadOnlyDictionary<Type, object> serverStates)
255
    {
256
        foreach (var (type, value) in serverStates)
1✔
257
        {
258
            World.SetComponent(entity, type, value);
1✔
259
        }
260
    }
1✔
261

262
    private static object CloneValue(Type type, object value)
263
    {
264
        // For value types (structs), boxing already creates a copy
265
        if (type.IsValueType)
1✔
266
        {
267
            return value;
1✔
268
        }
269

270
        // For reference types, we'd need deep cloning
271
        // Network components should be value types (structs)
272
        return value;
273
    }
274

275
    /// <summary>
276
    /// Registers an entity for prediction tracking.
277
    /// </summary>
278
    /// <param name="entity">The entity to track.</param>
279
    public void RegisterEntity(Entity entity)
280
    {
UNCOV
281
        if (!predictionBuffers.ContainsKey(entity))
×
282
        {
283
            predictionBuffers[entity] = new PredictionBuffer(plugin.Config.InputBufferSize);
×
284
        }
UNCOV
285
    }
×
286

287
    /// <summary>
288
    /// Unregisters an entity from prediction tracking.
289
    /// </summary>
290
    /// <param name="entity">The entity to unregister.</param>
291
    public void UnregisterEntity(Entity entity)
292
    {
293
        predictionBuffers.Remove(entity);
1✔
294
    }
1✔
295
}
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