• 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

95.95
/src/KeenEyes.Network/Systems/NetworkServerSendSystem.cs
1
using System.Runtime.InteropServices;
2
using KeenEyes.Capabilities;
3
using KeenEyes.Network.Components;
4
using KeenEyes.Network.Protocol;
5
using KeenEyes.Network.Replication;
6
using KeenEyes.Network.Serialization;
7
using KeenEyes.Network.Transport;
8

9
namespace KeenEyes.Network.Systems;
10

11
/// <summary>
12
/// Server system that sends state updates to clients.
13
/// </summary>
14
/// <remarks>
15
/// <para>
16
/// Runs in LateUpdate phase after game logic has executed.
17
/// </para>
18
/// <para>
19
/// Without an interest manager configured, updates are built once per tick and
20
/// broadcast to all clients. With <see cref="ServerNetworkConfig.InterestManager"/>
21
/// set, replication is per client: each client has its own relevance set, dirty
22
/// tracking baseline, and bandwidth budget, with scope enter/exit translated
23
/// into targeted entity spawn/despawn messages.
24
/// </para>
25
/// </remarks>
26
public sealed class NetworkServerSendSystem(NetworkServerPlugin plugin) : SystemBase
1✔
27
{
28
    private readonly byte[] sendBuffer = new byte[4096];
1✔
29

30
    // ACKNOWLEDGED per-entity baseline for delta detection (broadcast path): the state
31
    // the connected clients are known to have received. Deltas and change detection are
32
    // computed against this, so a field stays "changed" (and keeps being re-sent) until
33
    // an ack confirms it. Advancing this on every send instead let a single dropped
34
    // unreliable delta desync the client permanently (#1099).
35
    private readonly Dictionary<Entity, Dictionary<Type, object>> lastSentState = [];
1✔
36

37
    // Snapshot of the full component state sent for an entity at pendingTick, promoted
38
    // into lastSentState once every client has acknowledged that tick.
39
    private readonly Dictionary<Entity, Dictionary<Type, object>> pendingState = [];
1✔
40
    private readonly Dictionary<Entity, uint> pendingTick = [];
1✔
41

42
    // Track bytes sent this tick for bandwidth limiting
43
    private int bytesSentThisTick;
44

45
    // Cached owner-authoritative strategy lookup (rebuilt if the serializer changes).
46
    private OwnerAuthoritativeComponentSet? ownerAuthTypes;
47
    private INetworkSerializer? ownerAuthTypesSource;
48

49
    // Pre-allocated list to avoid per-tick allocations
50
    private readonly List<(Entity entity, float priority, bool needsFullSync)> entitiesToUpdate = [];
1✔
51

52
    // Interest management state (only used when an interest manager is configured).
53
    private float interestAccumulator;
54
    private readonly List<int> clientIdScratch = [];
1✔
55
    private readonly HashSet<Entity> relevanceScratch = [];
1✔
56
    private readonly List<Entity> scopeExitScratch = [];
1✔
57
    private readonly HashSet<Entity> sentEntitiesScratch = [];
1✔
58

59
    /// <inheritdoc/>
60
    public override void Update(float deltaTime)
61
    {
62
        // Time toward the next relevance recomputation passes regardless of
63
        // whether this frame lands on a network tick.
64
        interestAccumulator += deltaTime;
1✔
65

66
        // Advance tick
67
        if (!plugin.Tick(deltaTime))
1✔
68
        {
69
            return; // Not time for a network tick yet
1✔
70
        }
71

72
        // Pull the transport's measured RTT into each client's state so lag compensation
73
        // sees real latency instead of a permanent zero (#1102).
74
        plugin.RefreshClientRoundTripTimes();
1✔
75

76
        if (plugin.Config.InterestManager is { } interestManager)
1✔
77
        {
78
            UpdateFiltered(deltaTime, interestManager);
1✔
79
        }
80
        else
81
        {
82
            UpdateBroadcast(deltaTime);
1✔
83
        }
84
    }
1✔
85

86
    #region Broadcast path (no interest manager)
87

88
    private void UpdateBroadcast(float deltaTime)
89
    {
90
        // Promote acknowledged pending snapshots into the confirmed baseline before
91
        // computing this tick's deltas, so changes stay pending until a client ack (#1099).
92
        PromoteAcknowledgedBaselines();
1✔
93

94
        // Check for clients that need full snapshots
95
        foreach (var client in plugin.GetConnectedClients())
1✔
96
        {
97
            if (client.NeedsFullSnapshot)
1✔
98
            {
99
                plugin.SendFullSnapshot(client.ClientId);
1✔
100
                client.NeedsFullSnapshot = false;
1✔
101
            }
102
        }
103

104
        var serializer = plugin.Config.Serializer;
1✔
105
        var config = plugin.Config;
1✔
106

107
        // Calculate bytes per tick budget
108
        var bytesPerTick = config.EnableBandwidthLimiting
1✔
109
            ? config.MaxBandwidthBytesPerSecond / config.TickRate
1✔
110
            : int.MaxValue;
1✔
111
        bytesSentThisTick = 0;
1✔
112

113
        // Collect entities that need updates and sort by priority
114
        entitiesToUpdate.Clear();
1✔
115

116
        // Capture authoritative state for lag compensation once per network tick.
117
        // This reuses the same iteration over networked entities and records every
118
        // entity (not just those sent this tick), so history stays complete even for
119
        // entities that did not change or were dropped by the bandwidth budget.
120
        var history = plugin.StateHistory;
1✔
121

122
        foreach (var entity in World.Query<NetworkId, NetworkState>())
1✔
123
        {
124
            ref var networkState = ref World.Get<NetworkState>(entity);
1✔
125

126
            // Accumulate priority over time
127
            networkState.AccumulatedPriority += deltaTime;
1✔
128

129
            if (history is not null && serializer is not null && World is ISnapshotCapability snapshot)
1✔
130
            {
131
                history.Capture(entity, plugin.CurrentTick, snapshot, serializer);
1✔
132
            }
133

134
            if (ShouldSendEntity(entity, ref networkState, serializer))
1✔
135
            {
136
                entitiesToUpdate.Add((entity, networkState.AccumulatedPriority, networkState.NeedsFullSync));
1✔
137
            }
138
        }
139

140
        // Sort by priority (higher first), with full sync entities always at front
141
        entitiesToUpdate.Sort((a, b) =>
1✔
142
        {
1✔
143
            // Full sync entities have highest priority
1✔
144
            if (a.needsFullSync != b.needsFullSync)
1✔
145
            {
1✔
UNCOV
146
                return a.needsFullSync ? -1 : 1;
×
147
            }
1✔
148
            return b.priority.CompareTo(a.priority);
1✔
149
        });
1✔
150

151
        // Send entity updates within bandwidth budget
152
        foreach (var (entity, _, _) in entitiesToUpdate)
1✔
153
        {
154
            ref var networkState = ref World.Get<NetworkState>(entity);
1✔
155
            ref readonly var networkId = ref World.Get<NetworkId>(entity);
1✔
156

157
            // Check bandwidth budget
158
            if (config.EnableBandwidthLimiting && bytesSentThisTick >= bytesPerTick)
1✔
159
            {
160
                // Don't reset priority for entities we couldn't send
UNCOV
161
                break;
×
162
            }
163

164
            SendEntityUpdate(entity, networkId, ref networkState);
1✔
165
            networkState.LastSentTick = plugin.CurrentTick;
1✔
166
            networkState.AccumulatedPriority = 0; // Reset priority after sending
1✔
167

168
            // Stop if we've exceeded budget (but we already sent this message)
169
            if (config.EnableBandwidthLimiting && bytesSentThisTick > bytesPerTick)
1✔
170
            {
UNCOV
171
                break;
×
172
            }
173
        }
174

175
        // Pump the transport to flush outgoing data
176
        plugin.Transport.Update();
1✔
177
    }
1✔
178

179
    private bool ShouldSendEntity(Entity entity, ref NetworkState state, INetworkSerializer? serializer)
180
    {
181
        // Always send if needs full sync
182
        if (state.NeedsFullSync)
1✔
183
        {
184
            return true;
1✔
185
        }
186

187
        // If no serializer, we can only send spawn/despawn
188
        if (serializer is null)
1✔
189
        {
UNCOV
190
            return false;
×
191
        }
192

193
        // Check if any replicated component has changed
194
        if (!lastSentState.TryGetValue(entity, out var entityState))
1✔
195
        {
196
            // Never sent this entity - needs update
197
            return true;
1✔
198
        }
199

200
        // Compare current state to last sent state using delta masks for efficiency
201
        if (World is ISnapshotCapability snapshot)
1✔
202
        {
203
            foreach (var (type, value) in snapshot.GetComponents(entity))
1✔
204
            {
205
                if (!serializer.IsNetworkSerializable(type))
1✔
206
                {
207
                    continue;
208
                }
209

210
                if (!entityState.TryGetValue(type, out var lastValue))
1✔
211
                {
212
                    // New component - needs update
UNCOV
213
                    return true;
×
214
                }
215

216
                // Use dirty mask for delta-supported types, fallback to Equals for others
217
                if (serializer.SupportsDelta(type))
1✔
218
                {
219
                    if (serializer.GetDirtyMask(type, value, lastValue) != 0)
1✔
220
                    {
221
                        return true;
1✔
222
                    }
223
                }
UNCOV
224
                else if (!Equals(lastValue, value))
×
225
                {
UNCOV
226
                    return true;
×
227
                }
228
            }
229
        }
230

231
        return false;
1✔
232
    }
1✔
233

234
    private void SendEntityUpdate(Entity entity, NetworkId networkId, ref NetworkState state)
235
    {
236
        var serializer = plugin.Config.Serializer;
1✔
237

238
        if (state.NeedsFullSync)
1✔
239
        {
240
            // Send full entity state. The full snapshot is always broadcast to every
241
            // client (including a client owner) because it carries the entity's initial
242
            // component values; the owner immediately overrides its owner-authoritative
243
            // components with its own upstream state.
244
            var writer = new NetworkMessageWriter(sendBuffer);
1✔
245
            writer.WriteHeader(MessageType.EntitySpawn, plugin.CurrentTick);
1✔
246

247
            var owner = World.Has<NetworkOwner>(entity)
1✔
248
                ? World.Get<NetworkOwner>(entity)
1✔
249
                : NetworkOwner.Server;
1✔
250

251
            writer.WriteEntitySpawn(networkId.Value, owner.ClientId);
1✔
252

253
            // Write all replicated components (full serialization)
254
            WriteReplicatedComponentsFull(entity, ref writer, serializer);
1✔
255

256
            state.NeedsFullSync = false;
1✔
257

258
            var span = writer.GetWrittenSpan();
1✔
259
            bytesSentThisTick += span.Length;
1✔
260
            plugin.SendToAll(span, DeliveryMode.UnreliableSequenced);
1✔
261
        }
262
        else
263
        {
264
            SendDeltaUpdate(entity, networkId, serializer);
1✔
265
        }
266

267
        // Record what was sent this tick as the pending snapshot. It becomes the confirmed
268
        // baseline only once the client acknowledges this tick (#1099).
269
        SavePendingState(entity, serializer);
1✔
270
    }
1✔
271

272
    private void SendDeltaUpdate(Entity entity, NetworkId networkId, INetworkSerializer? serializer)
273
    {
274
        var owner = World.Has<NetworkOwner>(entity)
1✔
275
            ? World.Get<NetworkOwner>(entity)
1✔
276
            : NetworkOwner.Server;
1✔
277

278
        // Echo suppression: for a client-owned entity, owner-authoritative components
279
        // must not be sent back to the owner (its own state is authoritative). Other
280
        // components (server-authoritative, predicted, interpolated) still reach the
281
        // owner so reconciliation and server updates work. Server-owned entities have
282
        // no client owner to echo to and use the standard single broadcast.
283
        if (serializer is not null && owner.ClientId != NetworkOwner.ServerClientId)
1✔
284
        {
285
            SendClientOwnedDelta(entity, networkId, owner.ClientId, serializer);
1✔
286
            return;
1✔
287
        }
288

289
        var writer = new NetworkMessageWriter(sendBuffer);
1✔
290
        writer.WriteHeader(MessageType.ComponentDelta, plugin.CurrentTick);
1✔
291
        writer.WriteUInt32(networkId.Value);
1✔
292
        WriteReplicatedComponentsDelta(entity, ref writer, serializer);
1✔
293

294
        var span = writer.GetWrittenSpan();
1✔
295
        bytesSentThisTick += span.Length;
1✔
296
        plugin.SendToAll(span, DeliveryMode.UnreliableSequenced);
1✔
297
    }
1✔
298

299
    private void SendClientOwnedDelta(Entity entity, NetworkId networkId, int ownerId, INetworkSerializer serializer)
300
    {
301
        var ownerAuth = GetOwnerAuthTypes(serializer);
1✔
302
        var changed = CollectChangedComponents(entity, serializer, lastSentState);
1✔
303

304
        var toOwnerAndOthers = new List<(Type type, object current, object? baseline)>();
1✔
305
        var toOthersOnly = new List<(Type type, object current, object? baseline)>();
1✔
306
        foreach (var component in changed)
1✔
307
        {
308
            if (ownerAuth.Contains(component.type))
1✔
309
            {
310
                toOthersOnly.Add(component);
1✔
311
            }
312
            else
313
            {
314
                toOwnerAndOthers.Add(component);
1✔
315
            }
316
        }
317

318
        // Owner-authoritative components: relay to every client except the owner.
319
        if (toOthersOnly.Count > 0)
1✔
320
        {
321
            var span = WriteDeltaMessage(networkId, toOthersOnly, serializer);
1✔
322
            bytesSentThisTick += span.Length;
1✔
323
            plugin.SendToAllExcept(ownerId, span, DeliveryMode.UnreliableSequenced);
1✔
324
        }
325

326
        // Remaining components: broadcast to all clients including the owner.
327
        if (toOwnerAndOthers.Count > 0)
1✔
328
        {
329
            var span = WriteDeltaMessage(networkId, toOwnerAndOthers, serializer);
1✔
330
            bytesSentThisTick += span.Length;
1✔
331
            plugin.SendToAll(span, DeliveryMode.UnreliableSequenced);
1✔
332
        }
333
    }
1✔
334

335
    #endregion
336

337
    #region Filtered path (interest manager configured)
338

339
    private void UpdateFiltered(float deltaTime, IInterestManager interestManager)
340
    {
341
        var serializer = plugin.Config.Serializer;
1✔
342
        var config = plugin.Config;
1✔
343

344
        // The bandwidth budget applies per client in filtered mode.
345
        var bytesPerTick = config.EnableBandwidthLimiting
1✔
346
            ? config.MaxBandwidthBytesPerSecond / config.TickRate
1✔
347
            : int.MaxValue;
1✔
348

349
        // Late joiners are synchronized through scope entry (a targeted
350
        // EntitySpawn per relevant entity) instead of the broadcast full
351
        // snapshot, which would leak out-of-scope entities.
352
        foreach (var client in plugin.GetConnectedClients())
1✔
353
        {
354
            client.NeedsFullSnapshot = false;
1✔
355
        }
356

357
        // Capture lag-compensation history and accumulate priority exactly as
358
        // the broadcast path does, reusing the same single iteration.
359
        var history = plugin.StateHistory;
1✔
360
        entitiesToUpdate.Clear();
1✔
361

362
        foreach (var entity in World.Query<NetworkId, NetworkState>())
1✔
363
        {
364
            ref var networkState = ref World.Get<NetworkState>(entity);
1✔
365
            networkState.AccumulatedPriority += deltaTime;
1✔
366

367
            if (history is not null && serializer is not null && World is ISnapshotCapability snapshot)
1✔
368
            {
UNCOV
369
                history.Capture(entity, plugin.CurrentTick, snapshot, serializer);
×
370
            }
371

372
            entitiesToUpdate.Add((entity, networkState.AccumulatedPriority, false));
1✔
373
        }
374

375
        RecomputeRelevanceIfDue(interestManager);
1✔
376

377
        // Sort by priority (higher first). Per-client full syncs are implied by a
378
        // missing per-client baseline, so no separate full-sync ordering is needed.
379
        entitiesToUpdate.Sort(static (a, b) => b.priority.CompareTo(a.priority));
1✔
380

381
        sentEntitiesScratch.Clear();
1✔
382
        foreach (var client in plugin.GetConnectedClients())
1✔
383
        {
384
            SendUpdatesToClient(client, serializer, bytesPerTick);
1✔
385
        }
386

387
        // Reset priority for entities that produced at least one message this tick.
388
        foreach (var entity in sentEntitiesScratch)
1✔
389
        {
390
            ref var networkState = ref World.Get<NetworkState>(entity);
1✔
391
            networkState.LastSentTick = plugin.CurrentTick;
1✔
392
            networkState.AccumulatedPriority = 0;
1✔
393
            networkState.NeedsFullSync = false;
1✔
394
        }
395

396
        // Pump the transport to flush outgoing data
397
        plugin.Transport.Update();
1✔
398
    }
1✔
399

400
    private void RecomputeRelevanceIfDue(IInterestManager interestManager)
401
    {
402
        var due = interestManager.UpdateFrequencyHz <= 0f
1✔
403
            || interestAccumulator >= 1f / interestManager.UpdateFrequencyHz;
1✔
404

405
        if (!due)
1✔
406
        {
407
            // New clients get an immediate relevance set so initial replication
408
            // does not wait for the next scheduled update.
409
            foreach (var client in plugin.GetConnectedClients())
1✔
410
            {
411
                if (!client.InterestInitialized)
1✔
412
                {
413
                    due = true;
1✔
414
                    break;
1✔
415
                }
416
            }
417
        }
418

419
        if (!due)
1✔
420
        {
421
            return;
1✔
422
        }
423

424
        interestAccumulator = 0f;
1✔
425

426
        clientIdScratch.Clear();
1✔
427
        foreach (var client in plugin.GetConnectedClients())
1✔
428
        {
429
            clientIdScratch.Add(client.ClientId);
1✔
430
        }
431

432
        interestManager.BeginUpdate(World, CollectionsMarshal.AsSpan(clientIdScratch));
1✔
433

434
        foreach (var client in plugin.GetConnectedClients())
1✔
435
        {
436
            RecomputeClientRelevance(client, interestManager);
1✔
437
        }
438
    }
1✔
439

440
    private void RecomputeClientRelevance(ClientState client, IInterestManager interestManager)
441
    {
442
        relevanceScratch.Clear();
1✔
443

444
        foreach (var (entity, _, _) in entitiesToUpdate)
1✔
445
        {
446
            var ownerId = World.Has<NetworkOwner>(entity)
1✔
447
                ? World.Get<NetworkOwner>(entity).ClientId
1✔
448
                : NetworkOwner.ServerClientId;
1✔
449

450
            // Invariant: a client's own entities are always relevant to it,
451
            // regardless of the interest manager's verdict.
452
            if (ownerId == client.ClientId || interestManager.IsRelevant(World, client.ClientId, entity))
1✔
453
            {
454
                relevanceScratch.Add(entity);
1✔
455
            }
456
        }
457

458
        // Entities leaving scope are despawned on this client. Entities entering
459
        // scope need no explicit action: having no per-client baseline, they get
460
        // a full EntitySpawn in the send phase.
461
        scopeExitScratch.Clear();
1✔
462
        foreach (var entity in client.RelevantEntities)
1✔
463
        {
464
            if (!relevanceScratch.Contains(entity))
1✔
465
            {
466
                scopeExitScratch.Add(entity);
1✔
467
            }
468
        }
469

470
        foreach (var entity in scopeExitScratch)
1✔
471
        {
472
            SendScopeExit(client, entity);
1✔
473
        }
474

475
        client.RelevantEntities.Clear();
1✔
476
        client.RelevantEntities.UnionWith(relevanceScratch);
1✔
477
        client.InterestInitialized = true;
1✔
478
    }
1✔
479

480
    private void SendScopeExit(ClientState client, Entity entity)
481
    {
482
        // Drop the per-client baseline so a later re-entry re-sends full state.
483
        client.LastSentState.Remove(entity);
1✔
484

485
        if (!plugin.NetworkIds.TryGetNetworkId(entity, out var networkId))
1✔
486
        {
487
            // Entity was destroyed; the plugin already broadcast its despawn.
UNCOV
488
            return;
×
489
        }
490

491
        Span<byte> buffer = stackalloc byte[16];
1✔
492
        var writer = new NetworkMessageWriter(buffer);
1✔
493
        writer.WriteHeader(MessageType.EntityDespawn, plugin.CurrentTick);
1✔
494
        writer.WriteEntityDespawn(networkId.Value);
1✔
495
        plugin.SendToClient(client.ClientId, writer.GetWrittenSpan(), DeliveryMode.ReliableOrdered);
1✔
496
    }
1✔
497

498
    private void SendUpdatesToClient(ClientState client, INetworkSerializer? serializer, int bytesPerTick)
499
    {
500
        var limiting = plugin.Config.EnableBandwidthLimiting;
1✔
501
        var bytesSent = 0;
1✔
502

503
        foreach (var (entity, _, _) in entitiesToUpdate)
1✔
504
        {
505
            // Per-client bandwidth budget: stop sending to this client once
506
            // exhausted; unsent changes remain dirty against this client's
507
            // baseline and go out on later ticks.
508
            if (limiting && bytesSent >= bytesPerTick)
1✔
509
            {
UNCOV
510
                break;
×
511
            }
512

513
            if (!client.RelevantEntities.Contains(entity))
1✔
514
            {
515
                continue;
516
            }
517

518
            // No baseline means the entity is new to this client (scope entry,
519
            // registration, or re-entry after exit): send a full spawn.
520
            var sent = client.LastSentState.ContainsKey(entity)
1✔
521
                ? SendDeltaToClient(client, entity, serializer)
1✔
522
                : SendSpawnToClient(client, entity, serializer);
1✔
523

524
            if (sent > 0)
1✔
525
            {
526
                bytesSent += sent;
1✔
527
                sentEntitiesScratch.Add(entity);
1✔
528
            }
529
        }
530
    }
1✔
531

532
    private int SendSpawnToClient(ClientState client, Entity entity, INetworkSerializer? serializer)
533
    {
534
        ref readonly var networkId = ref World.Get<NetworkId>(entity);
1✔
535

536
        var owner = World.Has<NetworkOwner>(entity)
1✔
537
            ? World.Get<NetworkOwner>(entity)
1✔
538
            : NetworkOwner.Server;
1✔
539

540
        var writer = new NetworkMessageWriter(sendBuffer);
1✔
541
        writer.WriteHeader(MessageType.EntitySpawn, plugin.CurrentTick);
1✔
542
        writer.WriteEntitySpawn(networkId.Value, owner.ClientId);
1✔
543
        WriteReplicatedComponentsFull(entity, ref writer, serializer);
1✔
544

545
        var span = writer.GetWrittenSpan();
1✔
546

547
        // Scope transitions must arrive: a dropped spawn would leave the entity
548
        // invisible to this client until it re-enters scope.
549
        plugin.SendToClient(client.ClientId, span, DeliveryMode.ReliableOrdered);
1✔
550

551
        SaveSentStateForClient(client, entity, serializer);
1✔
552
        return span.Length;
1✔
553
    }
554

555
    private int SendDeltaToClient(ClientState client, Entity entity, INetworkSerializer? serializer)
556
    {
557
        if (serializer is null)
1✔
558
        {
UNCOV
559
            return 0; // Without a serializer only spawn/despawn replicate.
×
560
        }
561

562
        var changed = CollectChangedComponents(entity, serializer, client.LastSentState);
1✔
563
        if (changed.Count == 0)
1✔
564
        {
565
            return 0;
1✔
566
        }
567

568
        var owner = World.Has<NetworkOwner>(entity)
1✔
569
            ? World.Get<NetworkOwner>(entity)
1✔
570
            : NetworkOwner.Server;
1✔
571

572
        // Echo suppression: owner-authoritative components are never sent back
573
        // to the owning client (its own state is authoritative for them).
574
        if (owner.ClientId == client.ClientId && owner.ClientId != NetworkOwner.ServerClientId)
1✔
575
        {
576
            var ownerAuth = GetOwnerAuthTypes(serializer);
1✔
577
            changed.RemoveAll(component => ownerAuth.Contains(component.type));
1✔
578
            if (changed.Count == 0)
1✔
579
            {
580
                return 0;
1✔
581
            }
582
        }
583

584
        ref readonly var networkId = ref World.Get<NetworkId>(entity);
1✔
585
        var span = WriteDeltaMessage(networkId, changed, serializer);
1✔
586
        plugin.SendToClient(client.ClientId, span, DeliveryMode.UnreliableSequenced);
1✔
587

588
        SaveSentStateForClient(client, entity, serializer);
1✔
589
        return span.Length;
1✔
590
    }
591

592
    private void SaveSentStateForClient(ClientState client, Entity entity, INetworkSerializer? serializer)
593
    {
594
        if (!client.LastSentState.TryGetValue(entity, out var entityState))
1✔
595
        {
596
            // Record the entity even without a serializer so the spawn is not resent.
597
            entityState = [];
1✔
598
            client.LastSentState[entity] = entityState;
1✔
599
        }
600

601
        if (serializer is null)
1✔
602
        {
UNCOV
603
            return;
×
604
        }
605

606
        if (World is ISnapshotCapability snapshot)
1✔
607
        {
608
            foreach (var (type, value) in snapshot.GetComponents(entity))
1✔
609
            {
610
                if (serializer.IsNetworkSerializable(type))
1✔
611
                {
612
                    entityState[type] = value;
1✔
613
                }
614
            }
615
        }
616
    }
1✔
617

618
    #endregion
619

620
    private ReadOnlySpan<byte> WriteDeltaMessage(
621
        NetworkId networkId,
622
        List<(Type type, object current, object? baseline)> components,
623
        INetworkSerializer serializer)
624
    {
625
        var writer = new NetworkMessageWriter(sendBuffer);
1✔
626
        writer.WriteHeader(MessageType.ComponentDelta, plugin.CurrentTick);
1✔
627
        writer.WriteUInt32(networkId.Value);
1✔
628
        WriteDeltaComponents(ref writer, components, serializer);
1✔
629
        return writer.GetWrittenSpan();
1✔
630
    }
631

632
    private void WriteReplicatedComponentsFull(Entity entity, ref NetworkMessageWriter writer, INetworkSerializer? serializer)
633
    {
634
        if (serializer is null)
1✔
635
        {
636
            writer.WriteComponentCount(0);
1✔
637
            return;
1✔
638
        }
639

640
        // Collect all replicated components
641
        var toSend = new List<(Type, object)>();
1✔
642
        if (World is ISnapshotCapability snapshot)
1✔
643
        {
644
            foreach (var (type, value) in snapshot.GetComponents(entity))
1✔
645
            {
646
                if (serializer.IsNetworkSerializable(type))
1✔
647
                {
648
                    toSend.Add((type, value));
1✔
649
                }
650
            }
651
        }
652

653
        writer.WriteComponentCount((byte)toSend.Count);
1✔
654
        foreach (var (type, value) in toSend)
1✔
655
        {
656
            writer.WriteComponent(serializer, type, value);
1✔
657
        }
658
    }
1✔
659

660
    private void WriteReplicatedComponentsDelta(Entity entity, ref NetworkMessageWriter writer, INetworkSerializer? serializer)
661
    {
662
        if (serializer is null)
1✔
663
        {
UNCOV
664
            writer.WriteComponentCount(0);
×
UNCOV
665
            return;
×
666
        }
667

668
        var toSend = CollectChangedComponents(entity, serializer, lastSentState);
1✔
669
        WriteDeltaComponents(ref writer, toSend, serializer);
1✔
670
    }
1✔
671

672
    private List<(Type type, object current, object? baseline)> CollectChangedComponents(
673
        Entity entity,
674
        INetworkSerializer serializer,
675
        Dictionary<Entity, Dictionary<Type, object>> sentStates)
676
    {
677
        // Get last sent state for delta comparison
678
        sentStates.TryGetValue(entity, out var entityLastState);
1✔
679

680
        // Collect components that have changed
681
        var toSend = new List<(Type type, object current, object? baseline)>();
1✔
682
        if (World is ISnapshotCapability snapshot)
1✔
683
        {
684
            foreach (var (type, value) in snapshot.GetComponents(entity))
1✔
685
            {
686
                if (!serializer.IsNetworkSerializable(type))
1✔
687
                {
688
                    continue;
689
                }
690

691
                object? lastValue = null;
1✔
692
                if (entityLastState is not null)
1✔
693
                {
694
                    entityLastState.TryGetValue(type, out lastValue);
1✔
695
                }
696

697
                // Check if changed using delta mask or equality
698
                bool hasChanged;
699
                if (lastValue is null)
1✔
700
                {
701
                    hasChanged = true; // New component
1✔
702
                }
703
                else if (serializer.SupportsDelta(type))
1✔
704
                {
705
                    hasChanged = serializer.GetDirtyMask(type, value, lastValue) != 0;
1✔
706
                }
707
                else
708
                {
709
                    hasChanged = !Equals(lastValue, value);
1✔
710
                }
711

712
                if (hasChanged)
1✔
713
                {
714
                    toSend.Add((type, value, lastValue));
1✔
715
                }
716
            }
717
        }
718

719
        return toSend;
1✔
720
    }
721

722
    private static void WriteDeltaComponents(
723
        ref NetworkMessageWriter writer,
724
        List<(Type type, object current, object? baseline)> components,
725
        INetworkSerializer serializer)
726
    {
727
        writer.WriteComponentCount((byte)components.Count);
1✔
728

729
        // Write each component with delta encoding where supported
730
        foreach (var (type, current, baseline) in components)
1✔
731
        {
732
            // Use delta serialization if we have a baseline and the type supports it
733
            if (baseline is not null && serializer.SupportsDelta(type))
1✔
734
            {
735
                writer.WriteComponentDelta(serializer, type, current, baseline);
1✔
736
            }
737
            else
738
            {
739
                // Fall back to full serialization
740
                writer.WriteComponent(serializer, type, current);
1✔
741
            }
742
        }
743
    }
1✔
744

745
    private OwnerAuthoritativeComponentSet GetOwnerAuthTypes(INetworkSerializer serializer)
746
    {
747
        if (ownerAuthTypes is null || !ReferenceEquals(ownerAuthTypesSource, serializer))
1✔
748
        {
749
            ownerAuthTypes = new OwnerAuthoritativeComponentSet(serializer);
1✔
750
            ownerAuthTypesSource = serializer;
1✔
751
        }
752

753
        return ownerAuthTypes;
1✔
754
    }
755

756
    private void SavePendingState(Entity entity, INetworkSerializer? serializer)
757
    {
758
        if (serializer is null)
1✔
759
        {
760
            return;
1✔
761
        }
762

763
        // The snapshot sent this tick is the full current state of every replicated
764
        // component. Rebuild it fresh each send (rather than mutating in place) so a stale
765
        // entry from an earlier tick cannot linger after a component's value changes.
766
        var entityState = new Dictionary<Type, object>();
1✔
767

768
        if (World is ISnapshotCapability snapshot)
1✔
769
        {
770
            foreach (var (type, value) in snapshot.GetComponents(entity))
1✔
771
            {
772
                if (serializer.IsNetworkSerializable(type))
1✔
773
                {
774
                    // Store a copy of the value (boxing creates a copy for value types)
775
                    entityState[type] = value;
1✔
776
                }
777
            }
778
        }
779

780
        pendingState[entity] = entityState;
1✔
781
        pendingTick[entity] = plugin.CurrentTick;
1✔
782
    }
1✔
783

784
    /// <summary>
785
    /// Promotes each entity's pending snapshot into the confirmed baseline once every
786
    /// connected client has acknowledged the tick the snapshot was sent on.
787
    /// </summary>
788
    /// <remarks>
789
    /// Uses the minimum <see cref="ClientState.LastAckedTick"/> across all clients: the
790
    /// baseline may only advance to a state every client is known to have. With no clients
791
    /// connected there is nothing to confirm, so no promotion occurs.
792
    /// </remarks>
793
    private void PromoteAcknowledgedBaselines()
794
    {
795
        var minAckedTick = uint.MaxValue;
1✔
796
        var clientCount = 0;
1✔
797
        foreach (var client in plugin.GetConnectedClients())
1✔
798
        {
799
            clientCount++;
1✔
800
            if (client.LastAckedTick < minAckedTick)
1✔
801
            {
802
                minAckedTick = client.LastAckedTick;
1✔
803
            }
804
        }
805

806
        if (clientCount == 0)
1✔
807
        {
UNCOV
808
            return;
×
809
        }
810

811
        foreach (var (entity, tick) in pendingTick)
1✔
812
        {
813
            if (tick <= minAckedTick && pendingState.TryGetValue(entity, out var snapshot))
1✔
814
            {
815
                lastSentState[entity] = snapshot;
1✔
816
            }
817
        }
818
    }
1✔
819

820
    /// <summary>
821
    /// Clears tracking state for an entity (call when entity is despawned).
822
    /// </summary>
823
    /// <param name="entity">The entity to clear.</param>
824
    public void ClearEntityState(Entity entity)
825
    {
826
        lastSentState.Remove(entity);
1✔
827
        pendingState.Remove(entity);
1✔
828
        pendingTick.Remove(entity);
1✔
829

830
        foreach (var client in plugin.GetConnectedClients())
1✔
831
        {
832
            client.LastSentState.Remove(entity);
1✔
833
            client.RelevantEntities.Remove(entity);
1✔
834
        }
835
    }
1✔
836
}
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