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

orion-ecs / keen-eye / 29923911837

22 Jul 2026 01:26PM UTC coverage: 62.597% (-2.6%) from 65.151%
29923911837

push

github

tyevco
test(mcp): Update TestBridge mocks to current interface surface

MockTestBridge and MockCaptureController never implemented the members added
during the MCP Tools Expansion, so tests/KeenEyes.Mcp.TestBridge.Tests did not
compile on main. MockStateController had also drifted (GetComponentAsync and
EntitySnapshot.Components now use JsonElement).

- MockTestBridge: add Window, Time, Systems, Mutation, Profile, Snapshot, AI,
  Replay and InputContext, backed by new hand-written mock controllers plus
  KeenEyes.Testing's MockInputContext, mirroring the existing recording /
  canned-result mock style.
- MockCaptureController: add CaptureRegionAsync, GetRegionScreenshotBytesAsync
  and SaveRegionScreenshotAsync.
- MockStateController: return JsonElement from GetComponentAsync and store
  component data as JsonElement to match the current IStateController.
- Rename pre-existing PascalCase private fields in InputParsingTests to
  camelCase so the project passes dotnet format once it is in CI.

The project was MISSING from KeenEyes.slnx, which is why the compile break went
unnoticed (CI builds the solution). Adding it to the solution is itself the
cheap drift guard requested in the issue: CI now compiles the mocks against the
interfaces, so any future interface addition breaks the build immediately. All
80 tests pass under --max-parallel-test-modules 1.

Fixes #1025

8144 of 12399 branches covered (65.68%)

Branch coverage included in aggregate %.

48975 of 78850 relevant lines covered (62.11%)

0.95 hits per line

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

93.22
/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
    // Track last sent component values per entity for delta detection (broadcast path).
31
    private readonly Dictionary<Entity, Dictionary<Type, object>> lastSentState = [];
1✔
32

33
    // Track bytes sent this tick for bandwidth limiting
34
    private int bytesSentThisTick;
35

36
    // Cached owner-authoritative strategy lookup (rebuilt if the serializer changes).
37
    private OwnerAuthoritativeComponentSet? ownerAuthTypes;
38
    private INetworkSerializer? ownerAuthTypesSource;
39

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

43
    // Interest management state (only used when an interest manager is configured).
44
    private float interestAccumulator;
45
    private readonly List<int> clientIdScratch = [];
1✔
46
    private readonly HashSet<Entity> relevanceScratch = [];
1✔
47
    private readonly List<Entity> scopeExitScratch = [];
1✔
48
    private readonly HashSet<Entity> sentEntitiesScratch = [];
1✔
49

50
    /// <inheritdoc/>
51
    public override void Update(float deltaTime)
52
    {
53
        // Time toward the next relevance recomputation passes regardless of
54
        // whether this frame lands on a network tick.
55
        interestAccumulator += deltaTime;
1✔
56

57
        // Advance tick
58
        if (!plugin.Tick(deltaTime))
1✔
59
        {
60
            return; // Not time for a network tick yet
1✔
61
        }
62

63
        if (plugin.Config.InterestManager is { } interestManager)
1✔
64
        {
65
            UpdateFiltered(deltaTime, interestManager);
1✔
66
        }
67
        else
68
        {
69
            UpdateBroadcast(deltaTime);
1✔
70
        }
71
    }
1✔
72

73
    #region Broadcast path (no interest manager)
74

75
    private void UpdateBroadcast(float deltaTime)
76
    {
77
        // Check for clients that need full snapshots
78
        foreach (var client in plugin.GetConnectedClients())
1✔
79
        {
80
            if (client.NeedsFullSnapshot)
1✔
81
            {
82
                plugin.SendFullSnapshot(client.ClientId);
1✔
83
                client.NeedsFullSnapshot = false;
1✔
84
            }
85
        }
86

87
        var serializer = plugin.Config.Serializer;
1✔
88
        var config = plugin.Config;
1✔
89

90
        // Calculate bytes per tick budget
91
        var bytesPerTick = config.EnableBandwidthLimiting
1✔
92
            ? config.MaxBandwidthBytesPerSecond / config.TickRate
1✔
93
            : int.MaxValue;
1✔
94
        bytesSentThisTick = 0;
1✔
95

96
        // Collect entities that need updates and sort by priority
97
        entitiesToUpdate.Clear();
1✔
98

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

105
        foreach (var entity in World.Query<NetworkId, NetworkState>())
1✔
106
        {
107
            ref var networkState = ref World.Get<NetworkState>(entity);
1✔
108

109
            // Accumulate priority over time
110
            networkState.AccumulatedPriority += deltaTime;
1✔
111

112
            if (history is not null && serializer is not null && World is ISnapshotCapability snapshot)
1✔
113
            {
114
                history.Capture(entity, plugin.CurrentTick, snapshot, serializer);
1✔
115
            }
116

117
            if (ShouldSendEntity(entity, ref networkState, serializer))
1✔
118
            {
119
                entitiesToUpdate.Add((entity, networkState.AccumulatedPriority, networkState.NeedsFullSync));
1✔
120
            }
121
        }
122

123
        // Sort by priority (higher first), with full sync entities always at front
124
        entitiesToUpdate.Sort((a, b) =>
1✔
125
        {
1✔
126
            // Full sync entities have highest priority
1✔
127
            if (a.needsFullSync != b.needsFullSync)
1✔
128
            {
1✔
129
                return a.needsFullSync ? -1 : 1;
×
130
            }
1✔
131
            return b.priority.CompareTo(a.priority);
1✔
132
        });
1✔
133

134
        // Send entity updates within bandwidth budget
135
        foreach (var (entity, _, _) in entitiesToUpdate)
1✔
136
        {
137
            ref var networkState = ref World.Get<NetworkState>(entity);
1✔
138
            ref readonly var networkId = ref World.Get<NetworkId>(entity);
1✔
139

140
            // Check bandwidth budget
141
            if (config.EnableBandwidthLimiting && bytesSentThisTick >= bytesPerTick)
1✔
142
            {
143
                // Don't reset priority for entities we couldn't send
144
                break;
×
145
            }
146

147
            SendEntityUpdate(entity, networkId, ref networkState);
1✔
148
            networkState.LastSentTick = plugin.CurrentTick;
1✔
149
            networkState.AccumulatedPriority = 0; // Reset priority after sending
1✔
150

151
            // Stop if we've exceeded budget (but we already sent this message)
152
            if (config.EnableBandwidthLimiting && bytesSentThisTick > bytesPerTick)
1✔
153
            {
154
                break;
×
155
            }
156
        }
157

158
        // Pump the transport to flush outgoing data
159
        plugin.Transport.Update();
1✔
160
    }
1✔
161

162
    private bool ShouldSendEntity(Entity entity, ref NetworkState state, INetworkSerializer? serializer)
163
    {
164
        // Always send if needs full sync
165
        if (state.NeedsFullSync)
1✔
166
        {
167
            return true;
1✔
168
        }
169

170
        // If no serializer, we can only send spawn/despawn
171
        if (serializer is null)
1✔
172
        {
173
            return false;
×
174
        }
175

176
        // Check if any replicated component has changed
177
        if (!lastSentState.TryGetValue(entity, out var entityState))
1✔
178
        {
179
            // Never sent this entity - needs update
180
            return true;
×
181
        }
182

183
        // Compare current state to last sent state using delta masks for efficiency
184
        if (World is ISnapshotCapability snapshot)
1✔
185
        {
186
            foreach (var (type, value) in snapshot.GetComponents(entity))
1✔
187
            {
188
                if (!serializer.IsNetworkSerializable(type))
1✔
189
                {
190
                    continue;
191
                }
192

193
                if (!entityState.TryGetValue(type, out var lastValue))
1✔
194
                {
195
                    // New component - needs update
196
                    return true;
×
197
                }
198

199
                // Use dirty mask for delta-supported types, fallback to Equals for others
200
                if (serializer.SupportsDelta(type))
1✔
201
                {
202
                    if (serializer.GetDirtyMask(type, value, lastValue) != 0)
×
203
                    {
204
                        return true;
×
205
                    }
206
                }
207
                else if (!Equals(lastValue, value))
1✔
208
                {
209
                    return true;
1✔
210
                }
211
            }
212
        }
213

214
        return false;
×
215
    }
1✔
216

217
    private void SendEntityUpdate(Entity entity, NetworkId networkId, ref NetworkState state)
218
    {
219
        var serializer = plugin.Config.Serializer;
1✔
220

221
        if (state.NeedsFullSync)
1✔
222
        {
223
            // Send full entity state. The full snapshot is always broadcast to every
224
            // client (including a client owner) because it carries the entity's initial
225
            // component values; the owner immediately overrides its owner-authoritative
226
            // components with its own upstream state.
227
            var writer = new NetworkMessageWriter(sendBuffer);
1✔
228
            writer.WriteHeader(MessageType.EntitySpawn, plugin.CurrentTick);
1✔
229

230
            var owner = World.Has<NetworkOwner>(entity)
1✔
231
                ? World.Get<NetworkOwner>(entity)
1✔
232
                : NetworkOwner.Server;
1✔
233

234
            writer.WriteEntitySpawn(networkId.Value, owner.ClientId);
1✔
235

236
            // Write all replicated components (full serialization)
237
            WriteReplicatedComponentsFull(entity, ref writer, serializer);
1✔
238

239
            state.NeedsFullSync = false;
1✔
240

241
            var span = writer.GetWrittenSpan();
1✔
242
            bytesSentThisTick += span.Length;
1✔
243
            plugin.SendToAll(span, DeliveryMode.UnreliableSequenced);
1✔
244
        }
245
        else
246
        {
247
            SendDeltaUpdate(entity, networkId, serializer);
1✔
248
        }
249

250
        // Update last sent state for delta tracking
251
        SaveSentState(entity, serializer);
1✔
252
    }
1✔
253

254
    private void SendDeltaUpdate(Entity entity, NetworkId networkId, INetworkSerializer? serializer)
255
    {
256
        var owner = World.Has<NetworkOwner>(entity)
1✔
257
            ? World.Get<NetworkOwner>(entity)
1✔
258
            : NetworkOwner.Server;
1✔
259

260
        // Echo suppression: for a client-owned entity, owner-authoritative components
261
        // must not be sent back to the owner (its own state is authoritative). Other
262
        // components (server-authoritative, predicted, interpolated) still reach the
263
        // owner so reconciliation and server updates work. Server-owned entities have
264
        // no client owner to echo to and use the standard single broadcast.
265
        if (serializer is not null && owner.ClientId != NetworkOwner.ServerClientId)
1✔
266
        {
267
            SendClientOwnedDelta(entity, networkId, owner.ClientId, serializer);
1✔
268
            return;
1✔
269
        }
270

271
        var writer = new NetworkMessageWriter(sendBuffer);
1✔
272
        writer.WriteHeader(MessageType.ComponentDelta, plugin.CurrentTick);
1✔
273
        writer.WriteUInt32(networkId.Value);
1✔
274
        WriteReplicatedComponentsDelta(entity, ref writer, serializer);
1✔
275

276
        var span = writer.GetWrittenSpan();
1✔
277
        bytesSentThisTick += span.Length;
1✔
278
        plugin.SendToAll(span, DeliveryMode.UnreliableSequenced);
1✔
279
    }
1✔
280

281
    private void SendClientOwnedDelta(Entity entity, NetworkId networkId, int ownerId, INetworkSerializer serializer)
282
    {
283
        var ownerAuth = GetOwnerAuthTypes(serializer);
1✔
284
        var changed = CollectChangedComponents(entity, serializer, lastSentState);
1✔
285

286
        var toOwnerAndOthers = new List<(Type type, object current, object? baseline)>();
1✔
287
        var toOthersOnly = new List<(Type type, object current, object? baseline)>();
1✔
288
        foreach (var component in changed)
1✔
289
        {
290
            if (ownerAuth.Contains(component.type))
1✔
291
            {
292
                toOthersOnly.Add(component);
1✔
293
            }
294
            else
295
            {
296
                toOwnerAndOthers.Add(component);
1✔
297
            }
298
        }
299

300
        // Owner-authoritative components: relay to every client except the owner.
301
        if (toOthersOnly.Count > 0)
1✔
302
        {
303
            var span = WriteDeltaMessage(networkId, toOthersOnly, serializer);
1✔
304
            bytesSentThisTick += span.Length;
1✔
305
            plugin.SendToAllExcept(ownerId, span, DeliveryMode.UnreliableSequenced);
1✔
306
        }
307

308
        // Remaining components: broadcast to all clients including the owner.
309
        if (toOwnerAndOthers.Count > 0)
1✔
310
        {
311
            var span = WriteDeltaMessage(networkId, toOwnerAndOthers, serializer);
1✔
312
            bytesSentThisTick += span.Length;
1✔
313
            plugin.SendToAll(span, DeliveryMode.UnreliableSequenced);
1✔
314
        }
315
    }
1✔
316

317
    #endregion
318

319
    #region Filtered path (interest manager configured)
320

321
    private void UpdateFiltered(float deltaTime, IInterestManager interestManager)
322
    {
323
        var serializer = plugin.Config.Serializer;
1✔
324
        var config = plugin.Config;
1✔
325

326
        // The bandwidth budget applies per client in filtered mode.
327
        var bytesPerTick = config.EnableBandwidthLimiting
1✔
328
            ? config.MaxBandwidthBytesPerSecond / config.TickRate
1✔
329
            : int.MaxValue;
1✔
330

331
        // Late joiners are synchronized through scope entry (a targeted
332
        // EntitySpawn per relevant entity) instead of the broadcast full
333
        // snapshot, which would leak out-of-scope entities.
334
        foreach (var client in plugin.GetConnectedClients())
1✔
335
        {
336
            client.NeedsFullSnapshot = false;
1✔
337
        }
338

339
        // Capture lag-compensation history and accumulate priority exactly as
340
        // the broadcast path does, reusing the same single iteration.
341
        var history = plugin.StateHistory;
1✔
342
        entitiesToUpdate.Clear();
1✔
343

344
        foreach (var entity in World.Query<NetworkId, NetworkState>())
1✔
345
        {
346
            ref var networkState = ref World.Get<NetworkState>(entity);
1✔
347
            networkState.AccumulatedPriority += deltaTime;
1✔
348

349
            if (history is not null && serializer is not null && World is ISnapshotCapability snapshot)
1✔
350
            {
351
                history.Capture(entity, plugin.CurrentTick, snapshot, serializer);
×
352
            }
353

354
            entitiesToUpdate.Add((entity, networkState.AccumulatedPriority, false));
1✔
355
        }
356

357
        RecomputeRelevanceIfDue(interestManager);
1✔
358

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

363
        sentEntitiesScratch.Clear();
1✔
364
        foreach (var client in plugin.GetConnectedClients())
1✔
365
        {
366
            SendUpdatesToClient(client, serializer, bytesPerTick);
1✔
367
        }
368

369
        // Reset priority for entities that produced at least one message this tick.
370
        foreach (var entity in sentEntitiesScratch)
1✔
371
        {
372
            ref var networkState = ref World.Get<NetworkState>(entity);
1✔
373
            networkState.LastSentTick = plugin.CurrentTick;
1✔
374
            networkState.AccumulatedPriority = 0;
1✔
375
            networkState.NeedsFullSync = false;
1✔
376
        }
377

378
        // Pump the transport to flush outgoing data
379
        plugin.Transport.Update();
1✔
380
    }
1✔
381

382
    private void RecomputeRelevanceIfDue(IInterestManager interestManager)
383
    {
384
        var due = interestManager.UpdateFrequencyHz <= 0f
1✔
385
            || interestAccumulator >= 1f / interestManager.UpdateFrequencyHz;
1✔
386

387
        if (!due)
1✔
388
        {
389
            // New clients get an immediate relevance set so initial replication
390
            // does not wait for the next scheduled update.
391
            foreach (var client in plugin.GetConnectedClients())
1✔
392
            {
393
                if (!client.InterestInitialized)
1✔
394
                {
395
                    due = true;
1✔
396
                    break;
1✔
397
                }
398
            }
399
        }
400

401
        if (!due)
1✔
402
        {
403
            return;
1✔
404
        }
405

406
        interestAccumulator = 0f;
1✔
407

408
        clientIdScratch.Clear();
1✔
409
        foreach (var client in plugin.GetConnectedClients())
1✔
410
        {
411
            clientIdScratch.Add(client.ClientId);
1✔
412
        }
413

414
        interestManager.BeginUpdate(World, CollectionsMarshal.AsSpan(clientIdScratch));
1✔
415

416
        foreach (var client in plugin.GetConnectedClients())
1✔
417
        {
418
            RecomputeClientRelevance(client, interestManager);
1✔
419
        }
420
    }
1✔
421

422
    private void RecomputeClientRelevance(ClientState client, IInterestManager interestManager)
423
    {
424
        relevanceScratch.Clear();
1✔
425

426
        foreach (var (entity, _, _) in entitiesToUpdate)
1✔
427
        {
428
            var ownerId = World.Has<NetworkOwner>(entity)
1✔
429
                ? World.Get<NetworkOwner>(entity).ClientId
1✔
430
                : NetworkOwner.ServerClientId;
1✔
431

432
            // Invariant: a client's own entities are always relevant to it,
433
            // regardless of the interest manager's verdict.
434
            if (ownerId == client.ClientId || interestManager.IsRelevant(World, client.ClientId, entity))
1✔
435
            {
436
                relevanceScratch.Add(entity);
1✔
437
            }
438
        }
439

440
        // Entities leaving scope are despawned on this client. Entities entering
441
        // scope need no explicit action: having no per-client baseline, they get
442
        // a full EntitySpawn in the send phase.
443
        scopeExitScratch.Clear();
1✔
444
        foreach (var entity in client.RelevantEntities)
1✔
445
        {
446
            if (!relevanceScratch.Contains(entity))
1✔
447
            {
448
                scopeExitScratch.Add(entity);
1✔
449
            }
450
        }
451

452
        foreach (var entity in scopeExitScratch)
1✔
453
        {
454
            SendScopeExit(client, entity);
1✔
455
        }
456

457
        client.RelevantEntities.Clear();
1✔
458
        client.RelevantEntities.UnionWith(relevanceScratch);
1✔
459
        client.InterestInitialized = true;
1✔
460
    }
1✔
461

462
    private void SendScopeExit(ClientState client, Entity entity)
463
    {
464
        // Drop the per-client baseline so a later re-entry re-sends full state.
465
        client.LastSentState.Remove(entity);
1✔
466

467
        if (!plugin.NetworkIds.TryGetNetworkId(entity, out var networkId))
1✔
468
        {
469
            // Entity was destroyed; the plugin already broadcast its despawn.
470
            return;
×
471
        }
472

473
        Span<byte> buffer = stackalloc byte[16];
1✔
474
        var writer = new NetworkMessageWriter(buffer);
1✔
475
        writer.WriteHeader(MessageType.EntityDespawn, plugin.CurrentTick);
1✔
476
        writer.WriteEntityDespawn(networkId.Value);
1✔
477
        plugin.SendToClient(client.ClientId, writer.GetWrittenSpan(), DeliveryMode.ReliableOrdered);
1✔
478
    }
1✔
479

480
    private void SendUpdatesToClient(ClientState client, INetworkSerializer? serializer, int bytesPerTick)
481
    {
482
        var limiting = plugin.Config.EnableBandwidthLimiting;
1✔
483
        var bytesSent = 0;
1✔
484

485
        foreach (var (entity, _, _) in entitiesToUpdate)
1✔
486
        {
487
            // Per-client bandwidth budget: stop sending to this client once
488
            // exhausted; unsent changes remain dirty against this client's
489
            // baseline and go out on later ticks.
490
            if (limiting && bytesSent >= bytesPerTick)
1✔
491
            {
492
                break;
×
493
            }
494

495
            if (!client.RelevantEntities.Contains(entity))
1✔
496
            {
497
                continue;
498
            }
499

500
            // No baseline means the entity is new to this client (scope entry,
501
            // registration, or re-entry after exit): send a full spawn.
502
            var sent = client.LastSentState.ContainsKey(entity)
1✔
503
                ? SendDeltaToClient(client, entity, serializer)
1✔
504
                : SendSpawnToClient(client, entity, serializer);
1✔
505

506
            if (sent > 0)
1✔
507
            {
508
                bytesSent += sent;
1✔
509
                sentEntitiesScratch.Add(entity);
1✔
510
            }
511
        }
512
    }
1✔
513

514
    private int SendSpawnToClient(ClientState client, Entity entity, INetworkSerializer? serializer)
515
    {
516
        ref readonly var networkId = ref World.Get<NetworkId>(entity);
1✔
517

518
        var owner = World.Has<NetworkOwner>(entity)
1✔
519
            ? World.Get<NetworkOwner>(entity)
1✔
520
            : NetworkOwner.Server;
1✔
521

522
        var writer = new NetworkMessageWriter(sendBuffer);
1✔
523
        writer.WriteHeader(MessageType.EntitySpawn, plugin.CurrentTick);
1✔
524
        writer.WriteEntitySpawn(networkId.Value, owner.ClientId);
1✔
525
        WriteReplicatedComponentsFull(entity, ref writer, serializer);
1✔
526

527
        var span = writer.GetWrittenSpan();
1✔
528

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

533
        SaveSentStateForClient(client, entity, serializer);
1✔
534
        return span.Length;
1✔
535
    }
536

537
    private int SendDeltaToClient(ClientState client, Entity entity, INetworkSerializer? serializer)
538
    {
539
        if (serializer is null)
1✔
540
        {
541
            return 0; // Without a serializer only spawn/despawn replicate.
×
542
        }
543

544
        var changed = CollectChangedComponents(entity, serializer, client.LastSentState);
1✔
545
        if (changed.Count == 0)
1✔
546
        {
547
            return 0;
1✔
548
        }
549

550
        var owner = World.Has<NetworkOwner>(entity)
1✔
551
            ? World.Get<NetworkOwner>(entity)
1✔
552
            : NetworkOwner.Server;
1✔
553

554
        // Echo suppression: owner-authoritative components are never sent back
555
        // to the owning client (its own state is authoritative for them).
556
        if (owner.ClientId == client.ClientId && owner.ClientId != NetworkOwner.ServerClientId)
1✔
557
        {
558
            var ownerAuth = GetOwnerAuthTypes(serializer);
1✔
559
            changed.RemoveAll(component => ownerAuth.Contains(component.type));
1✔
560
            if (changed.Count == 0)
1✔
561
            {
562
                return 0;
1✔
563
            }
564
        }
565

566
        ref readonly var networkId = ref World.Get<NetworkId>(entity);
1✔
567
        var span = WriteDeltaMessage(networkId, changed, serializer);
1✔
568
        plugin.SendToClient(client.ClientId, span, DeliveryMode.UnreliableSequenced);
1✔
569

570
        SaveSentStateForClient(client, entity, serializer);
1✔
571
        return span.Length;
1✔
572
    }
573

574
    private void SaveSentStateForClient(ClientState client, Entity entity, INetworkSerializer? serializer)
575
    {
576
        if (!client.LastSentState.TryGetValue(entity, out var entityState))
1✔
577
        {
578
            // Record the entity even without a serializer so the spawn is not resent.
579
            entityState = [];
1✔
580
            client.LastSentState[entity] = entityState;
1✔
581
        }
582

583
        if (serializer is null)
1✔
584
        {
585
            return;
×
586
        }
587

588
        if (World is ISnapshotCapability snapshot)
1✔
589
        {
590
            foreach (var (type, value) in snapshot.GetComponents(entity))
1✔
591
            {
592
                if (serializer.IsNetworkSerializable(type))
1✔
593
                {
594
                    entityState[type] = value;
1✔
595
                }
596
            }
597
        }
598
    }
1✔
599

600
    #endregion
601

602
    private ReadOnlySpan<byte> WriteDeltaMessage(
603
        NetworkId networkId,
604
        List<(Type type, object current, object? baseline)> components,
605
        INetworkSerializer serializer)
606
    {
607
        var writer = new NetworkMessageWriter(sendBuffer);
1✔
608
        writer.WriteHeader(MessageType.ComponentDelta, plugin.CurrentTick);
1✔
609
        writer.WriteUInt32(networkId.Value);
1✔
610
        WriteDeltaComponents(ref writer, components, serializer);
1✔
611
        return writer.GetWrittenSpan();
1✔
612
    }
613

614
    private void WriteReplicatedComponentsFull(Entity entity, ref NetworkMessageWriter writer, INetworkSerializer? serializer)
615
    {
616
        if (serializer is null)
1✔
617
        {
618
            writer.WriteComponentCount(0);
1✔
619
            return;
1✔
620
        }
621

622
        // Collect all replicated components
623
        var toSend = new List<(Type, object)>();
1✔
624
        if (World is ISnapshotCapability snapshot)
1✔
625
        {
626
            foreach (var (type, value) in snapshot.GetComponents(entity))
1✔
627
            {
628
                if (serializer.IsNetworkSerializable(type))
1✔
629
                {
630
                    toSend.Add((type, value));
1✔
631
                }
632
            }
633
        }
634

635
        writer.WriteComponentCount((byte)toSend.Count);
1✔
636
        foreach (var (type, value) in toSend)
1✔
637
        {
638
            writer.WriteComponent(serializer, type, value);
1✔
639
        }
640
    }
1✔
641

642
    private void WriteReplicatedComponentsDelta(Entity entity, ref NetworkMessageWriter writer, INetworkSerializer? serializer)
643
    {
644
        if (serializer is null)
1✔
645
        {
646
            writer.WriteComponentCount(0);
×
647
            return;
×
648
        }
649

650
        var toSend = CollectChangedComponents(entity, serializer, lastSentState);
1✔
651
        WriteDeltaComponents(ref writer, toSend, serializer);
1✔
652
    }
1✔
653

654
    private List<(Type type, object current, object? baseline)> CollectChangedComponents(
655
        Entity entity,
656
        INetworkSerializer serializer,
657
        Dictionary<Entity, Dictionary<Type, object>> sentStates)
658
    {
659
        // Get last sent state for delta comparison
660
        sentStates.TryGetValue(entity, out var entityLastState);
1✔
661

662
        // Collect components that have changed
663
        var toSend = new List<(Type type, object current, object? baseline)>();
1✔
664
        if (World is ISnapshotCapability snapshot)
1✔
665
        {
666
            foreach (var (type, value) in snapshot.GetComponents(entity))
1✔
667
            {
668
                if (!serializer.IsNetworkSerializable(type))
1✔
669
                {
670
                    continue;
671
                }
672

673
                object? lastValue = null;
1✔
674
                if (entityLastState is not null)
1✔
675
                {
676
                    entityLastState.TryGetValue(type, out lastValue);
1✔
677
                }
678

679
                // Check if changed using delta mask or equality
680
                bool hasChanged;
681
                if (lastValue is null)
1✔
682
                {
683
                    hasChanged = true; // New component
×
684
                }
685
                else if (serializer.SupportsDelta(type))
1✔
686
                {
687
                    hasChanged = serializer.GetDirtyMask(type, value, lastValue) != 0;
×
688
                }
689
                else
690
                {
691
                    hasChanged = !Equals(lastValue, value);
1✔
692
                }
693

694
                if (hasChanged)
1✔
695
                {
696
                    toSend.Add((type, value, lastValue));
1✔
697
                }
698
            }
699
        }
700

701
        return toSend;
1✔
702
    }
703

704
    private static void WriteDeltaComponents(
705
        ref NetworkMessageWriter writer,
706
        List<(Type type, object current, object? baseline)> components,
707
        INetworkSerializer serializer)
708
    {
709
        writer.WriteComponentCount((byte)components.Count);
1✔
710

711
        // Write each component with delta encoding where supported
712
        foreach (var (type, current, baseline) in components)
1✔
713
        {
714
            // Use delta serialization if we have a baseline and the type supports it
715
            if (baseline is not null && serializer.SupportsDelta(type))
1✔
716
            {
717
                writer.WriteComponentDelta(serializer, type, current, baseline);
×
718
            }
719
            else
720
            {
721
                // Fall back to full serialization
722
                writer.WriteComponent(serializer, type, current);
1✔
723
            }
724
        }
725
    }
1✔
726

727
    private OwnerAuthoritativeComponentSet GetOwnerAuthTypes(INetworkSerializer serializer)
728
    {
729
        if (ownerAuthTypes is null || !ReferenceEquals(ownerAuthTypesSource, serializer))
1✔
730
        {
731
            ownerAuthTypes = new OwnerAuthoritativeComponentSet(serializer);
1✔
732
            ownerAuthTypesSource = serializer;
1✔
733
        }
734

735
        return ownerAuthTypes;
1✔
736
    }
737

738
    private void SaveSentState(Entity entity, INetworkSerializer? serializer)
739
    {
740
        if (serializer is null)
1✔
741
        {
742
            return;
1✔
743
        }
744

745
        if (!lastSentState.TryGetValue(entity, out var entityState))
1✔
746
        {
747
            entityState = [];
1✔
748
            lastSentState[entity] = entityState;
1✔
749
        }
750

751
        // Save current state of all replicated components
752
        if (World is ISnapshotCapability snapshot)
1✔
753
        {
754
            foreach (var (type, value) in snapshot.GetComponents(entity))
1✔
755
            {
756
                if (serializer.IsNetworkSerializable(type))
1✔
757
                {
758
                    // Store a copy of the value (boxing creates a copy for value types)
759
                    entityState[type] = value;
1✔
760
                }
761
            }
762
        }
763
    }
1✔
764

765
    /// <summary>
766
    /// Clears tracking state for an entity (call when entity is despawned).
767
    /// </summary>
768
    /// <param name="entity">The entity to clear.</param>
769
    public void ClearEntityState(Entity entity)
770
    {
771
        lastSentState.Remove(entity);
×
772

773
        foreach (var client in plugin.GetConnectedClients())
×
774
        {
775
            client.LastSentState.Remove(entity);
×
776
            client.RelevantEntities.Remove(entity);
×
777
        }
778
    }
×
779
}
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