• 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

92.86
/src/KeenEyes.Core/Systems/AutoSaveSystem.cs
1
using KeenEyes.Capabilities;
2
using KeenEyes.Serialization;
3

4
namespace KeenEyes.Systems;
5

6
/// <summary>
7
/// A system that automatically saves the world state at configurable intervals.
8
/// </summary>
9
/// <remarks>
10
/// <para>
11
/// The AutoSaveSystem monitors elapsed time and optionally entity changes to trigger
12
/// automatic saves. It supports both full snapshots and delta (incremental) saves.
13
/// </para>
14
/// <para>
15
/// Delta saves are significantly smaller than full saves when few entities change
16
/// between saves. The system automatically creates a new baseline after a configurable
17
/// number of deltas to prevent long restoration chains.
18
/// </para>
19
/// <para>
20
/// This system requires the world to implement <see cref="ISaveLoadCapability"/>.
21
/// The standard <see cref="World"/> class provides this capability.
22
/// </para>
23
/// </remarks>
24
/// <example>
25
/// <code>
26
/// // Create auto-save system with default config
27
/// var autoSave = new AutoSaveSystem&lt;MySerializer&gt;(serializer);
28
/// world.AddSystem(autoSave, SystemPhase.PostRender);
29
///
30
/// // Configure for more frequent saves
31
/// autoSave.Config = AutoSaveConfig.Frequent;
32
///
33
/// // Manually trigger a save
34
/// autoSave.SaveNow();
35
/// </code>
36
/// </example>
37
/// <typeparam name="TSerializer">The serializer type that implements both serialization interfaces.</typeparam>
38
public sealed class AutoSaveSystem<TSerializer> : SystemBase
39
    where TSerializer : IComponentSerializer, IBinaryComponentSerializer
40
{
41
    private readonly TSerializer serializer;
42
    private AutoSaveConfig config;
43

44
    private float timeSinceLastSave;
45
    private int changesSinceLastSave;
46
    private int currentDeltaSequence;
47
    private WorldSnapshot? baselineSnapshot;
48
    private bool isInitialized;
49
    private EventSubscription? entityCreatedSubscription;
50
    private EventSubscription? entityDestroyedSubscription;
51

52
    /// <summary>
53
    /// Creates a new auto-save system with the specified serializer.
54
    /// </summary>
55
    /// <param name="serializer">The component serializer for AOT-compatible serialization.</param>
56
    /// <param name="config">Optional configuration. Uses default if not specified.</param>
57
    public AutoSaveSystem(TSerializer serializer, AutoSaveConfig? config = null)
1✔
58
    {
59
        ArgumentNullException.ThrowIfNull(serializer);
1✔
60
        this.serializer = serializer;
1✔
61
        this.config = config ?? AutoSaveConfig.Default;
1✔
62
    }
1✔
63

64
    /// <summary>
65
    /// Gets or sets the auto-save configuration.
66
    /// </summary>
67
    public AutoSaveConfig Config
68
    {
69
        get => config;
1✔
70
        set
71
        {
72
            ArgumentNullException.ThrowIfNull(value);
1✔
73
            config = value;
1✔
74
        }
1✔
75
    }
76

77
    /// <summary>
78
    /// Gets the time elapsed since the last save.
79
    /// </summary>
80
    public float TimeSinceLastSave => timeSinceLastSave;
1✔
81

82
    /// <summary>
83
    /// Gets the number of tracked entity changes (creations and destructions) since the last save.
84
    /// </summary>
85
    /// <remarks>
86
    /// This is the value compared against <see cref="AutoSaveConfig.ChangeThreshold"/> to drive
87
    /// the change-based auto-save trigger. It resets to zero after each successful save.
88
    /// </remarks>
89
    public int ChangesSinceLastSave => changesSinceLastSave;
1✔
90

91
    /// <summary>
92
    /// Gets the current delta sequence number (0 if no saves have occurred).
93
    /// </summary>
94
    public int CurrentDeltaSequence => currentDeltaSequence;
1✔
95

96
    /// <summary>
97
    /// Gets whether a baseline snapshot exists.
98
    /// </summary>
99
    public bool HasBaseline => baselineSnapshot is not null;
1✔
100

101
    /// <summary>
102
    /// Event raised when an auto-save occurs.
103
    /// </summary>
104
    public event Action<SaveSlotInfo>? OnAutoSave;
105

106
    /// <summary>
107
    /// Event raised when an auto-save fails.
108
    /// </summary>
109
    public event Action<Exception>? OnAutoSaveError;
110

111
    /// <inheritdoc />
112
    protected override void OnInitialize()
113
    {
114
        isInitialized = true;
1✔
115

116
        // Subscribe to entity lifecycle events to drive the change-based trigger. Entity
117
        // creations and destructions since the last save are counted and compared against
118
        // AutoSaveConfig.ChangeThreshold in Update.
119
        entityCreatedSubscription = World.OnEntityCreated((_, _) => changesSinceLastSave++);
1✔
120
        entityDestroyedSubscription = World.OnEntityDestroyed(_ => changesSinceLastSave++);
1✔
121

122
        // Try to load existing baseline if it exists
123
        if (World is ISaveLoadCapability saveLoad && saveLoad.SaveSlotExists(config.BaselineSlotName))
1✔
124
        {
125
            try
126
            {
127
                var info = saveLoad.GetSaveSlotInfo(config.BaselineSlotName);
1✔
128
                if (info is not null)
1✔
129
                {
130
                    // Read the baseline snapshot for delta comparison WITHOUT mutating the
131
                    // live world. Using LoadFromSlot here would clear the current scene and
132
                    // replace it with the baseline's entities (see issue #1131).
133
                    baselineSnapshot = saveLoad.ReadSnapshotFromSlot(config.BaselineSlotName, serializer);
1✔
134

135
                    // Find the highest existing delta sequence
136
                    currentDeltaSequence = FindHighestDeltaSequence(saveLoad);
1✔
137
                }
138
            }
1✔
UNCOV
139
            catch
×
140
            {
141
                // If loading fails, we'll create a new baseline on first save
UNCOV
142
                baselineSnapshot = null;
×
UNCOV
143
                currentDeltaSequence = 0;
×
UNCOV
144
            }
×
145
        }
146
    }
1✔
147

148
    /// <inheritdoc />
149
    public override void Update(float deltaTime)
150
    {
151
        if (!config.Enabled || !isInitialized || World is not ISaveLoadCapability saveLoad)
1✔
152
        {
153
            return;
1✔
154
        }
155

156
        timeSinceLastSave += deltaTime;
1✔
157

158
        // Check if we should save
159
        bool shouldSave = false;
1✔
160

161
        // Time-based trigger
162
        if (config.AutoSaveIntervalSeconds > 0 && timeSinceLastSave >= config.AutoSaveIntervalSeconds)
1✔
163
        {
164
            shouldSave = true;
1✔
165
        }
166

167
        // Change-based trigger
168
        if (config.ChangeThreshold > 0 && changesSinceLastSave >= config.ChangeThreshold)
1✔
169
        {
170
            shouldSave = true;
1✔
171
        }
172

173
        if (shouldSave)
1✔
174
        {
175
            PerformAutoSave(saveLoad);
1✔
176
        }
177
    }
1✔
178

179
    /// <summary>
180
    /// Manually triggers an auto-save immediately.
181
    /// </summary>
182
    /// <returns>The save slot info, or null if save failed.</returns>
183
    public SaveSlotInfo? SaveNow()
184
    {
185
        if (!isInitialized || World is not ISaveLoadCapability saveLoad)
1✔
186
        {
187
            return null;
1✔
188
        }
189

190
        return PerformAutoSave(saveLoad);
1✔
191
    }
192

193
    /// <summary>
194
    /// Creates a new baseline snapshot, resetting the delta chain.
195
    /// </summary>
196
    /// <returns>The save slot info for the new baseline.</returns>
197
    public SaveSlotInfo? CreateNewBaseline()
198
    {
199
        if (!isInitialized || World is not ISaveLoadCapability saveLoad)
1✔
200
        {
201
            return null;
1✔
202
        }
203

204
        return SaveBaseline(saveLoad);
1✔
205
    }
206

207
    /// <summary>
208
    /// Resets the auto-save state, clearing any existing baseline.
209
    /// </summary>
210
    public void Reset()
211
    {
212
        timeSinceLastSave = 0;
1✔
213
        changesSinceLastSave = 0;
1✔
214
        currentDeltaSequence = 0;
1✔
215
        baselineSnapshot = null;
1✔
216
    }
1✔
217

218
    private SaveSlotInfo? PerformAutoSave(ISaveLoadCapability saveLoad)
219
    {
220
        try
221
        {
222
            SaveSlotInfo info;
223

224
            if (config.UseDeltaSaves && baselineSnapshot is not null)
1✔
225
            {
226
                // Check if we should create a new baseline
227
                if (currentDeltaSequence >= config.MaxDeltasBeforeBaseline)
1✔
228
                {
229
                    info = SaveBaseline(saveLoad);
1✔
230
                }
231
                else
232
                {
233
                    info = SaveDelta(saveLoad);
1✔
234
                }
235
            }
236
            else
237
            {
238
                // Create baseline (either first save or full saves mode)
239
                info = SaveBaseline(saveLoad);
1✔
240
            }
241

242
            // Reset trigger accumulators
243
            timeSinceLastSave = 0;
1✔
244
            changesSinceLastSave = 0;
1✔
245

246
            // Clear dirty flags if configured
247
            if (config.ClearDirtyFlagsAfterSave)
1✔
248
            {
249
                saveLoad.ClearAllDirtyFlags();
1✔
250
            }
251

252
            // Raise event
253
            OnAutoSave?.Invoke(info);
1✔
254

255
            return info;
1✔
256
        }
UNCOV
257
        catch (Exception ex)
×
258
        {
UNCOV
259
            OnAutoSaveError?.Invoke(ex);
×
UNCOV
260
            return null;
×
261
        }
262
    }
1✔
263

264
    private SaveSlotInfo SaveBaseline(ISaveLoadCapability saveLoad)
265
    {
266
        // Create snapshot
267
        var snapshot = saveLoad.CreateSnapshot(serializer);
1✔
268

269
        // Save as baseline
270
        var info = saveLoad.SaveToSlot(config.BaselineSlotName, serializer, config.SaveOptions);
1✔
271

272
        // Update state
273
        baselineSnapshot = snapshot;
1✔
274
        currentDeltaSequence = 0;
1✔
275

276
        // Clean up old deltas
277
        CleanupOldDeltas(saveLoad);
1✔
278

279
        return info;
1✔
280
    }
281

282
    private SaveSlotInfo SaveDelta(ISaveLoadCapability saveLoad)
283
    {
284
        // Increment sequence
285
        currentDeltaSequence++;
1✔
286

287
        // Create true delta by comparing current state to baseline
288
        var delta = saveLoad.CreateDelta(
1✔
289
            baselineSnapshot!,
1✔
290
            serializer,
1✔
291
            config.BaselineSlotName,
1✔
292
            currentDeltaSequence);
1✔
293

294
        // If delta is empty, skip saving
295
        if (delta.IsEmpty)
1✔
296
        {
297
            currentDeltaSequence--;
1✔
298
            // Return a placeholder info for the baseline
299
            return saveLoad.GetSaveSlotInfo(config.BaselineSlotName)
1✔
300
                ?? throw new InvalidOperationException("Baseline slot not found");
1✔
301
        }
302

303
        // Save delta to slot
304
        var slotName = config.GetDeltaSlotName(currentDeltaSequence);
1✔
305
        var info = saveLoad.SaveDeltaToSlot(slotName, delta, serializer, config.SaveOptions with
1✔
306
        {
1✔
307
            DisplayName = $"Auto-save (Delta #{currentDeltaSequence})"
1✔
308
        });
1✔
309

310
        return info;
1✔
311
    }
312

313
    private void CleanupOldDeltas(ISaveLoadCapability saveLoad)
314
    {
315
        // Remove old delta files when a new baseline is created
316
        for (int i = 1; i <= config.MaxDeltasBeforeBaseline + 5; i++)
1✔
317
        {
318
            var slotName = config.GetDeltaSlotName(i);
1✔
319
            if (saveLoad.SaveSlotExists(slotName))
1✔
320
            {
321
                saveLoad.DeleteSaveSlot(slotName);
1✔
322
            }
323
        }
324
    }
1✔
325

326
    private int FindHighestDeltaSequence(ISaveLoadCapability saveLoad)
327
    {
328
        int highest = 0;
1✔
329
        for (int i = 1; i <= config.MaxDeltasBeforeBaseline + 5; i++)
1✔
330
        {
331
            if (saveLoad.SaveSlotExists(config.GetDeltaSlotName(i)))
1✔
332
            {
UNCOV
333
                highest = i;
×
334
            }
335
        }
336
        return highest;
1✔
337
    }
338

339
    /// <inheritdoc />
340
    protected override void Dispose(bool disposing)
341
    {
342
        if (disposing)
1✔
343
        {
344
            entityCreatedSubscription?.Dispose();
1✔
345
            entityDestroyedSubscription?.Dispose();
1✔
346
            entityCreatedSubscription = null;
1✔
347
            entityDestroyedSubscription = null;
1✔
348
        }
349

350
        base.Dispose(disposing);
1✔
351
    }
1✔
352
}
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