From 58e914305a86f8c19cb0d63d5fe875541b0bca73 Mon Sep 17 00:00:00 2001 From: Malionaro Date: Fri, 11 Sep 2026 16:36:07 +0200 Subject: [PATCH 1/8] Replicate building and tree fire starts (PR-D) --- .../Core/Protocol/ProtocolConstants.cs | 7 +- .../GameplayCommandRegistry.cs | 2 + .../Commands/Simulation/FireIgniteCommand.cs | 114 +++++++ .../Systems/Pipeline/SyncRealizeSystem.cs | 5 + .../Sync/Systems/Simulation/FireSyncSystem.cs | 304 ++++++++++++++++++ CS2MultiplayerMod/Mod.cs | 6 + 6 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 CS2MultiplayerMod/Game/Sync/Commands/Simulation/FireIgniteCommand.cs create mode 100644 CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs diff --git a/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs b/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs index 1c915d8..5825fba 100644 --- a/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs +++ b/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs @@ -4,6 +4,11 @@ public static class ProtocolConstants { /// /// Wire-format version. Bump when message layout changes to refuse handshake on mismatch. + /// v69 adds command id 33, fire ignition: one command per building or tree + /// fire start, carrying the target's prefab and position plus the ignition intensity. + /// Only starts travel; the burn, the spread and the extinguish run locally on every + /// machine, the same start-only shape as disaster events. A v68 peer does not know + /// id 33, so the bump refuses it at the handshake instead of dropping its fires silently. /// v65 adds the barrier-only Begin stage: a join streams its world only to whoever joined, /// and every other peer crosses the same barrier without being sent or installing one. /// v65 also widens the accepted range of a course endpoint's split position. A @@ -221,7 +226,7 @@ public static class ProtocolConstants // v66 adds bounded display-only hover geometry to player presence updates. // v68 batches one brush frame so dense tree strokes do not overflow or trickle in. // Object-brush display markers are also excluded from terrain synchronization. - public const int ProtocolVersion = 68; + public const int ProtocolVersion = 69; /// /// Hard cap on a single payload, guarding against corrupt length prefixes. diff --git a/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs b/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs index beb77b7..f29f4de 100644 --- a/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs +++ b/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs @@ -27,6 +27,7 @@ internal static class GameplayCommandRegistry GrowableLifecycleCommand.Id, ModTypeTableCommand.Id, ModStateCommand.Id, ObjectPlacementBatchCommand.Id, ObjectDeleteBatchCommand.Id, + FireIgniteCommand.Id, }; internal static void Register(MultiplayerSession session) @@ -73,6 +74,7 @@ internal static string Name(ushort id) case ModStateCommand.Id: return "mod-state"; case ObjectPlacementBatchCommand.Id: return "object-place-batch"; case ObjectDeleteBatchCommand.Id: return "object-delete-batch"; + case FireIgniteCommand.Id: return "fire-ignite"; default: return "unknown"; } } diff --git a/CS2MultiplayerMod/Game/Sync/Commands/Simulation/FireIgniteCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/FireIgniteCommand.cs new file mode 100644 index 0000000..d222b91 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/FireIgniteCommand.cs @@ -0,0 +1,114 @@ +using CS2MultiplayerMod.Core.Protocol; +using CS2MultiplayerMod.Core.Sync; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// "Something caught fire, here, this strongly." Carries a fire start the receiving + /// game cannot derive for itself - which building or tree ignited and how hard - + /// and never the burn itself. Each machine then runs the fire with its own + /// simulation (escalation, spread, rescue, extinguish), so one small message covers + /// a fire of any length. Same start-only shape as . + /// + /// Only starts travel, in both directions: every machine rolls its own ignitions + /// and reports them, so both cities converge on the union of fires. Ends stay local: + /// each simulation extinguishes on its own clock, and the damage left behind is + /// already host-authoritative through the growable condition sync. + /// + public sealed class FireIgniteCommand : ISimulationCommand + { + public const ushort Id = 33; + public const int MaxEncodedBytes = 256; + + /// + /// Ceiling on the ignition intensity. The game's own range is small; this only + /// stops a forged unsurvivable inferno, it never constrains a real fire. + /// + public const float MaxIntensityValue = 1000f; + + /// Name of the ignited building or tree prefab, resolved locally by the receiver. + public string PrefabName; + + /// World position of the ignited target (buildings do not move). + public float X, Y, Z; + + /// Ignition strength as the sender's simulation rolled it. + public float Intensity; + + public ushort CommandId => Id; + + public void Write(NetworkWriter writer) + { + ValidateForWrite(); + writer.WriteString(PrefabName); + writer.WriteFloat(X); writer.WriteFloat(Y); writer.WriteFloat(Z); + writer.WriteFloat(Intensity); + } + + public void Read(NetworkReader reader) + { + PrefabName = WireGuard.ReadName(reader); + X = WireGuard.ReadCoordinate(reader); + Y = WireGuard.ReadCoordinate(reader); + Z = WireGuard.ReadCoordinate(reader); + Intensity = ReadIntensity(reader); + + if (reader.Remaining != 0) + throw new ProtocolException("Trailing bytes in fire ignite: " + reader.Remaining + "."); + } + + public byte[] Encode() + { + var writer = new NetworkWriter(96); + Write(writer); + if (writer.Length > MaxEncodedBytes) + throw new ProtocolException("Fire ignite exceeds the " + MaxEncodedBytes + "-byte cap."); + return writer.ToArray(); + } + + public static FireIgniteCommand Decode(byte[] body) + { + if (body == null) + throw new ProtocolException("Missing fire ignite body."); + if (body.Length > MaxEncodedBytes) + throw new ProtocolException("Fire ignite exceeds the " + MaxEncodedBytes + "-byte cap."); + var command = new FireIgniteCommand(); + command.Read(new NetworkReader(body)); + return command; + } + + private void ValidateForWrite() + { + if (string.IsNullOrEmpty(PrefabName) || PrefabName.Length > WireGuard.MaxNameLength) + throw new ProtocolException("Invalid fire target prefab name."); + for (int i = 0; i < PrefabName.Length; i++) + if (char.IsControl(PrefabName[i])) + throw new ProtocolException("Control character in fire target prefab name."); + ValidateCoordinate(X, "X"); + ValidateCoordinate(Y, "Y"); + ValidateCoordinate(Z, "Z"); + ValidateIntensity(Intensity); + } + + private static float ReadIntensity(NetworkReader reader) + { + float value = WireGuard.ReadFinite(reader); + ValidateIntensity(value); + return value; + } + + private static void ValidateIntensity(float value) + { + if (float.IsNaN(value) || float.IsInfinity(value) || + value < 0f || value > MaxIntensityValue) + throw new ProtocolException("Implausible fire intensity: " + value + "."); + } + + private static void ValidateCoordinate(float value, string label) + { + if (float.IsNaN(value) || float.IsInfinity(value) || + value < -WireGuard.MaxCoordinate || value > WireGuard.MaxCoordinate) + throw new ProtocolException("Invalid fire coordinate " + label + "."); + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Pipeline/SyncRealizeSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Pipeline/SyncRealizeSystem.cs index c0b47fc..5229d07 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Pipeline/SyncRealizeSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Pipeline/SyncRealizeSystem.cs @@ -26,6 +26,7 @@ public partial class SyncRealizeSystem : GameSystemBase private RouteSyncSystem _routeSync; private TilePurchaseSyncSystem _tileSync; private DisasterSyncSystem _disasterSync; + private FireSyncSystem _fireSync; private GrowableSyncSystem _growableSync; private Mods.ModStateSyncSystem _modStateSync; @@ -45,6 +46,7 @@ protected override void OnCreate() _routeSync = World.GetOrCreateSystemManaged(); _tileSync = World.GetOrCreateSystemManaged(); _disasterSync = World.GetOrCreateSystemManaged(); + _fireSync = World.GetOrCreateSystemManaged(); _growableSync = World.GetOrCreateSystemManaged(); _modStateSync = World.GetOrCreateSystemManaged(); } @@ -179,6 +181,9 @@ protected override void OnUpdate() // dependency - but they must still be created here: the game's event initialization // runs later this frame and only ever looks at freshly Created events. Step("DisasterSync", _disasterSync.RealizePending); + // A realized ignition only sets OnFire on an existing building or tree - + // no definitions, no terrain - so it rides the same slot as disasters. + Step("FireSync", _fireSync.RealizePending); // Last: what another mod stores is stored against a road, a junction or a building, // so everything that could still be creating one this frame has to have run. A // closure whose carrier is genuinely still in the backlog waits in its own hold diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs new file mode 100644 index 0000000..8840305 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs @@ -0,0 +1,304 @@ +using System.Collections.Concurrent; +using Game; +using Game.Common; +using Game.Prefabs; +using Game.Simulation; +using Game.Tools; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; +using CS2MultiplayerMod.Core.Diagnostics; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Diagnostics; +using CS2MultiplayerMod.Game.Sync.Commands; +using CS2MultiplayerMod.Game.Sync.Infrastructure; +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Replicates the *start* of a building or tree fire - and nothing else. One + /// per ignition carries what the receiving game cannot + /// derive for itself (which target, how hard); every machine then runs the burn with + /// its own simulation (escalation, spread, rescue, extinguish). Streaming the burn + /// would put a message on the wire every dozen frames for as long as anything smoulders. + /// + /// Both sides capture and both sides realize, so two cities converge on the union of + /// their fires. That differs from disasters on purpose: disaster rolls can be switched + /// off on clients, but there is no safe switch for ignition alone - disabling the + /// ignite pipeline would also pile up the spread requests the local burn keeps + /// producing. Ends stay local on both sides: each simulation extinguishes on its own + /// clock, and the damage left behind is already host-authoritative through the + /// growable condition sync. + /// + /// Realization never creates an ignite event, it adds OnFire to the matched + /// target the way the game's own ignite pipeline would. A replica therefore never + /// shows up in the capture query and no echo guard is needed: receiving the same + /// start twice finds the target already burning and skips it silently. + /// + public partial class FireSyncSystem : GameSystemBase + { + /// Ignitions arrive rarely; a per-frame cap keeps a wildfire night from stalling a frame. + private const int MaxRealizePerFrame = 4; + + /// Target match tolerance, squared metres (2 m): buildings do not move. + private const float MatchTolSq = 4f; + + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + + private PrefabSystem _prefabSystem; + private PrefabIndex _prefabIndex; + private SimulationSystem _simulation; + private EntityQuery _createdIgnites; + private EntityQuery _liveTargets; + private CommandObserver _observer; + private long _skippedTargets; + + protected override void OnCreate() + { + base.OnCreate(); + + _prefabSystem = World.GetOrCreateSystemManaged(); + _prefabIndex = new PrefabIndex(_prefabSystem, GetEntityQuery(ComponentType.ReadOnly())); + _simulation = World.GetOrCreateSystemManaged(); + + _createdIgnites = GetEntityQuery(new EntityQueryDesc + { + All = SyncQuery.ReadOnly(), + None = SyncQuery.ReadOnly(), + }); + + _liveTargets = GetEntityQuery(new EntityQueryDesc + { + All = SyncQuery.ReadOnly(), + None = SyncQuery.ReadOnly(), + }); + + _observer = SyncObserverBinding.Bind( + () => new CommandObserver(_incoming, FireIgniteCommand.Id) + { + MaxBodyBytes = FireIgniteCommand.MaxEncodedBytes, + }, + DrainQueue); + } + + protected override void OnDestroy() + { + SyncInbox.UnregisterDrain(DrainQueue); + SyncObserverBinding.Unbind(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + using (Diagnostics.SyncProfiler.Measure("FireSync")) + { + MultiplayerService service = Mod.Service; + if (service == null || !service.GameplaySyncReady) return; + + CaptureIgnites(service.Session); + } + } + + /// Called by during ToolUpdate, next to disasters: + /// a fire is a plain simulation state change, no definitions and no terrain involved. + public void RealizePending() + { + MultiplayerService service = Mod.Service; + if (service == null) return; + if (!service.GameplaySyncReady) + { + SyncInbox.Clear(_incoming); + return; + } + + MultiplayerSession session = service.Session; + int realized = 0; + SimulationCommandMessage message; + while (realized < MaxRealizePerFrame && _incoming.TryDequeue(out message)) + { + if (message.OriginPlayerId == session.LocalPlayerId) continue; + + FireIgniteCommand command; + try { command = FireIgniteCommand.Decode(message.Body); } + catch (System.Exception ex) + { + SyncLog.Warn(LogTopic.City, "FireSync: dropping malformed command: " + + ex.Message); + continue; + } + + if (Realize(command, message.OriginPlayerId)) realized++; + } + } + + // ---- Capture ------------------------------------------------------------ + + private void CaptureIgnites(MultiplayerSession session) + { + if (_createdIgnites.IsEmptyIgnoreFilter) return; + + NativeArray events = _createdIgnites.ToEntityArray(Allocator.Temp); + try + { + for (int i = 0; i < events.Length; i++) + { + Entity entity = events[i]; + global::Game.Events.Ignite ignite = + EntityManager.GetComponentData(entity); + Entity target = ignite.m_Target; + + if (target == Entity.Null || !EntityManager.Exists(target)) + { + Skip("untargeted"); + continue; + } + if (!EntityManager.HasComponent(target)) + { + // The game's own ignite pipeline requires PrefabRef on the target + // too, so this request dies locally as well - nothing to replicate. + Skip("target without prefab"); + continue; + } + if (EntityManager.HasComponent(target)) + { + // Vehicles move: no stable identity to match on the receiver, and + // vehicles simulate locally on every machine anyway. + Skip("vehicle target"); + continue; + } + if (!EntityManager.HasComponent(target)) + { + Skip("target without position"); + continue; + } + + Entity targetPrefab = + EntityManager.GetComponentData(target).m_Prefab; + string prefabName = _prefabIndex.NameOf(targetPrefab); + if (string.IsNullOrEmpty(prefabName)) + { + Skip("unresolvable target prefab"); + continue; + } + float3 position = EntityManager + .GetComponentData(target).m_Position; + + var command = new FireIgniteCommand + { + PrefabName = prefabName, + X = position.x, + Y = position.y, + Z = position.z, + Intensity = math.clamp(ignite.m_Intensity, 0f, + FireIgniteCommand.MaxIntensityValue), + }; + try + { + session.SendCommand(0, FireIgniteCommand.Id, command.Encode()); + } + catch (System.Exception ex) + { + SyncLog.Warn(LogTopic.City, "FireSync: refusing to send ignite of '" + + prefabName + "': " + ex.Message); + continue; + } + SyncLog.Detail(LogTopic.City, "FireSync sent ignite of '" + prefabName + + "' at " + position + ", intensity " + command.Intensity + "."); + } + } + finally + { + events.Dispose(); + } + } + + private void Skip(string reason) + { + _skippedTargets++; + SyncLog.Detail(LogTopic.City, "FireSync: not replicating ignite with " + reason + + " (total skipped this session: " + _skippedTargets + ")."); + } + + // ---- Realize ------------------------------------------------------------ + + private bool Realize(FireIgniteCommand command, int originPlayerId) + { + Entity prefab; + if (!_prefabIndex.TryResolve(command.PrefabName, out prefab)) + { + SyncLog.Warn(LogTopic.City, "FireSync: no local prefab named '" + + command.PrefabName + "'; ignoring the ignition."); + return false; + } + + float3 target = new float3(command.X, command.Y, command.Z); + Entity best = Entity.Null; + float bestDistSq = MatchTolSq; + + NativeArray candidates = _liveTargets.ToEntityArray(Allocator.Temp); + try + { + for (int i = 0; i < candidates.Length; i++) + { + Entity candidate = candidates[i]; + if (EntityManager.GetComponentData(candidate).m_Prefab != prefab) + continue; + float3 position = EntityManager + .GetComponentData(candidate).m_Position; + float distSq = math.distancesq(position, target); + if (distSq < bestDistSq) + { + bestDistSq = distSq; + best = candidate; + } + } + } + finally + { + candidates.Dispose(); + } + + if (best == Entity.Null) + { + SyncLog.Warn(LogTopic.City, "FireSync: no local '" + command.PrefabName + + "' near " + target + "; ignoring the ignition."); + return false; + } + if (EntityManager.HasComponent(best)) + { + // Already burning here - either our own simulation got there first or this + // is the echo of a start both sides rolled. Either way there is nothing to do. + return true; + } + + // What the game's ignite pipeline would have installed: the burn state plus the + // batch marker it uses to let installed upgrades react. Rescue requests, icons + // and journal entries derive from the running burn on this machine. + EntityManager.AddComponentData(best, new global::Game.Events.OnFire + { + m_Intensity = command.Intensity, + m_RequestFrame = _simulation.frameIndex, + }); + EntityManager.AddComponent(best); + if (EntityManager.HasBuffer(best)) + { + DynamicBuffer upgrades = + EntityManager.GetBuffer(best); + for (int i = 0; i < upgrades.Length; i++) + if (EntityManager.Exists(upgrades[i].m_Upgrade)) + EntityManager.AddComponent(upgrades[i].m_Upgrade); + } + + SyncLog.Detail(LogTopic.City, "FireSync realized ignite of '" + command.PrefabName + + "' at " + target + " from player " + originPlayerId + "."); + return true; + } + + private void DrainQueue() + { + SyncInbox.Clear(_incoming); + } + } +} diff --git a/CS2MultiplayerMod/Mod.cs b/CS2MultiplayerMod/Mod.cs index 8f1faf1..4c1b20e 100644 --- a/CS2MultiplayerMod/Mod.cs +++ b/CS2MultiplayerMod/Mod.cs @@ -385,6 +385,12 @@ public void OnLoad(UpdateSystem updateSystem) // the Created tag it keys on is gone by the next frame. Capturing here reads the // resolved disaster, not an empty shell. updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + // ModificationEnd, next to disasters: an ignite request only becomes a placed + // event once the game's own event pass has run, and its Created tag is gone by + // the next frame. Fires have no disaster-style local suppression - every + // machine rolls its own ignitions and reports them, both cities converging on + // the union - so this detector stays on for every role. + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); // After the game's own auto-name initialization, which runs late in ModificationEnd and // is what fills in a new street's or district's name draw. Capturing before it would // read the draw one frame stale. ModificationEnd also keeps working while the game is From aa59550bc9f5ab87afc58b8e2f758320decbf96b Mon Sep 17 00:00:00 2001 From: Malionaro Date: Fri, 11 Sep 2026 17:23:39 +0200 Subject: [PATCH 2/8] Retry missing targets and bound attempts per frame (codex review) --- .../Sync/Systems/Simulation/FireSyncSystem.cs | 70 ++++++++++++++++--- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs index 8840305..4bd0cca 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Collections.Generic; using Game; using Game.Common; using Game.Prefabs; @@ -40,11 +41,16 @@ public partial class FireSyncSystem : GameSystemBase /// Ignitions arrive rarely; a per-frame cap keeps a wildfire night from stalling a frame. private const int MaxRealizePerFrame = 4; + /// How long a target gets to show up before its ignition is dropped (10 s). + private const long RetryWindowMs = 10000; + /// Target match tolerance, squared metres (2 m): buildings do not move. private const float MatchTolSq = 4f; private readonly ConcurrentQueue _incoming = new ConcurrentQueue(); + private readonly List<(FireIgniteCommand command, int originPlayerId, long deadline)> _retry = + new List<(FireIgniteCommand, int, long)>(); private PrefabSystem _prefabSystem; private PrefabIndex _prefabIndex; @@ -101,6 +107,15 @@ protected override void OnUpdate() } } + /// What one realize attempt concluded. Only Retry keeps the command. + private enum Outcome + { + /// Applied, already burning, or permanently unresolvable - done. + Done, + /// Known prefab, live target not present yet - try again within the window. + Retry, + } + /// Called by during ToolUpdate, next to disasters: /// a fire is a plain simulation state change, no definitions and no terrain involved. public void RealizePending() @@ -110,13 +125,43 @@ public void RealizePending() if (!service.GameplaySyncReady) { SyncInbox.Clear(_incoming); + if (_retry.Count > 0) _retry.Clear(); return; } MultiplayerSession session = service.Session; - int realized = 0; + long now = service.NowMs; + int attempts = 0; + + // Oldest first: ignitions whose targets had not arrived when they were tried. + // Expired ones are dropped, unattempted ones keep their order behind this frame. + List<(FireIgniteCommand command, int originPlayerId, long deadline)> due = + new List<(FireIgniteCommand, int, long)>(); + for (int i = 0; i < _retry.Count; i++) + { + if (_retry[i].deadline < now) + { + SyncLog.Detail(LogTopic.City, "FireSync: giving up on ignite of '" + + _retry[i].command.PrefabName + "' whose target never arrived."); + continue; + } + due.Add(_retry[i]); + } + _retry.Clear(); + foreach (var pending in due) + { + if (attempts >= MaxRealizePerFrame) + { + _retry.Add(pending); + continue; + } + attempts++; + if (Realize(pending.command, pending.originPlayerId) == Outcome.Retry) + _retry.Add((pending.command, pending.originPlayerId, pending.deadline)); + } + SimulationCommandMessage message; - while (realized < MaxRealizePerFrame && _incoming.TryDequeue(out message)) + while (attempts < MaxRealizePerFrame && _incoming.TryDequeue(out message)) { if (message.OriginPlayerId == session.LocalPlayerId) continue; @@ -129,7 +174,11 @@ public void RealizePending() continue; } - if (Realize(command, message.OriginPlayerId)) realized++; + // Every scan counts toward the cap, success or not: a burst of commands + // for missing targets must not turn one frame into thousands of city scans. + attempts++; + if (Realize(command, message.OriginPlayerId) == Outcome.Retry) + _retry.Add((command, message.OriginPlayerId, now + RetryWindowMs)); } } @@ -223,14 +272,15 @@ private void Skip(string reason) // ---- Realize ------------------------------------------------------------ - private bool Realize(FireIgniteCommand command, int originPlayerId) + private Outcome Realize(FireIgniteCommand command, int originPlayerId) { Entity prefab; if (!_prefabIndex.TryResolve(command.PrefabName, out prefab)) { + // Terminal: prefabs ship with the game and DLC, they never arrive mid-session. SyncLog.Warn(LogTopic.City, "FireSync: no local prefab named '" + command.PrefabName + "'; ignoring the ignition."); - return false; + return Outcome.Done; } float3 target = new float3(command.X, command.Y, command.Z); @@ -262,15 +312,15 @@ private bool Realize(FireIgniteCommand command, int originPlayerId) if (best == Entity.Null) { - SyncLog.Warn(LogTopic.City, "FireSync: no local '" + command.PrefabName + - "' near " + target + "; ignoring the ignition."); - return false; + // Not a drop: the placement carrying this target may still be held + // upstream (terrain deferral) and arrive a few frames later. + return Outcome.Retry; } if (EntityManager.HasComponent(best)) { // Already burning here - either our own simulation got there first or this // is the echo of a start both sides rolled. Either way there is nothing to do. - return true; + return Outcome.Done; } // What the game's ignite pipeline would have installed: the burn state plus the @@ -293,7 +343,7 @@ private bool Realize(FireIgniteCommand command, int originPlayerId) SyncLog.Detail(LogTopic.City, "FireSync realized ignite of '" + command.PrefabName + "' at " + target + " from player " + originPlayerId + "."); - return true; + return Outcome.Done; } private void DrainQueue() From ea85c6ef920806cd7c2bf9a44deb39892ea3fa06 Mon Sep 17 00:00:00 2001 From: Malionaro Date: Thu, 17 Sep 2026 19:50:25 +0200 Subject: [PATCH 3/8] Harden fire sync: Exists guards in capture and match scan, throttle skip logging --- .../Game/Sync/Systems/Simulation/FireSyncSystem.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs index 4bd0cca..f6eb6fc 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs @@ -59,6 +59,7 @@ public partial class FireSyncSystem : GameSystemBase private EntityQuery _liveTargets; private CommandObserver _observer; private long _skippedTargets; + private readonly Dictionary _skipsByReason = new Dictionary(); protected override void OnCreate() { @@ -194,6 +195,7 @@ private void CaptureIgnites(MultiplayerSession session) for (int i = 0; i < events.Length; i++) { Entity entity = events[i]; + if (!EntityManager.Exists(entity)) continue; global::Game.Events.Ignite ignite = EntityManager.GetComponentData(entity); Entity target = ignite.m_Target; @@ -266,6 +268,12 @@ private void CaptureIgnites(MultiplayerSession session) private void Skip(string reason) { _skippedTargets++; + long perReason; + if (!_skipsByReason.TryGetValue(reason, out perReason)) perReason = 0; + _skipsByReason[reason] = perReason + 1; + // Waldbrand nights produce hundreds of untargeted/vehicle skips: log the + // first and then every 50th so the line stays evidence, not spam. + if (_skippedTargets != 1 && _skippedTargets % 50 != 0) return; SyncLog.Detail(LogTopic.City, "FireSync: not replicating ignite with " + reason + " (total skipped this session: " + _skippedTargets + ")."); } @@ -293,8 +301,11 @@ private Outcome Realize(FireIgniteCommand command, int originPlayerId) for (int i = 0; i < candidates.Length; i++) { Entity candidate = candidates[i]; + if (!EntityManager.Exists(candidate)) continue; + if (!EntityManager.HasComponent(candidate)) continue; if (EntityManager.GetComponentData(candidate).m_Prefab != prefab) continue; + if (!EntityManager.HasComponent(candidate)) continue; float3 position = EntityManager .GetComponentData(candidate).m_Position; float distSq = math.distancesq(position, target); From 889bb58d3c065ad241c089ea7aebdf3fae5666b3 Mon Sep 17 00:00:00 2001 From: Malionaro Date: Thu, 17 Sep 2026 19:50:43 +0200 Subject: [PATCH 4/8] Fix comment language in fire skip throttle --- .../Game/Sync/Systems/Simulation/FireSyncSystem.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs index f6eb6fc..a563aa1 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/FireSyncSystem.cs @@ -271,7 +271,7 @@ private void Skip(string reason) long perReason; if (!_skipsByReason.TryGetValue(reason, out perReason)) perReason = 0; _skipsByReason[reason] = perReason + 1; - // Waldbrand nights produce hundreds of untargeted/vehicle skips: log the + // Wildfire nights produce hundreds of untargeted/vehicle skips: log the // first and then every 50th so the line stays evidence, not spam. if (_skippedTargets != 1 && _skippedTargets % 50 != 0) return; SyncLog.Detail(LogTopic.City, "FireSync: not replicating ignite with " + reason + From 4b357e703b76eb45a44160e7469136969e1ef5f0 Mon Sep 17 00:00:00 2001 From: Malionaro Date: Fri, 11 Sep 2026 17:32:41 +0200 Subject: [PATCH 5/8] Replicate service building abandonment and destruction markers (PR-G) --- .../Core/Protocol/ProtocolConstants.cs | 9 +- .../GameplayCommandRegistry.cs | 2 + .../Simulation/ServiceBuildingStateCommand.cs | 113 +++++ .../Systems/Pipeline/SyncRealizeSystem.cs | 4 + .../ServiceBuildingStateSyncSystem.cs | 410 ++++++++++++++++++ CS2MultiplayerMod/Mod.cs | 6 + 6 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 CS2MultiplayerMod/Game/Sync/Commands/Simulation/ServiceBuildingStateCommand.cs create mode 100644 CS2MultiplayerMod/Game/Sync/Systems/Simulation/ServiceBuildingStateSyncSystem.cs diff --git a/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs b/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs index 5825fba..f04aae9 100644 --- a/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs +++ b/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs @@ -4,9 +4,14 @@ public static class ProtocolConstants { /// /// Wire-format version. Bump when message layout changes to refuse handshake on mismatch. + /// v70 adds command id 34, service building state: one command per + /// abandonment, condemnation or destruction marker change on a non-spawnable + /// building, carrying the standing prefab, its position and the resulting marker + /// set. Growables keep their own lifecycle command and removals stay with delete + /// sync; a v69 peer knows neither the id nor the marker ownership and is refused + /// at the handshake instead of diverging silently. /// v69 adds command id 33, fire ignition: one command per building or tree /// fire start, carrying the target's prefab and position plus the ignition intensity. - /// Only starts travel; the burn, the spread and the extinguish run locally on every /// machine, the same start-only shape as disaster events. A v68 peer does not know /// id 33, so the bump refuses it at the handshake instead of dropping its fires silently. /// v65 adds the barrier-only Begin stage: a join streams its world only to whoever joined, @@ -226,7 +231,7 @@ public static class ProtocolConstants // v66 adds bounded display-only hover geometry to player presence updates. // v68 batches one brush frame so dense tree strokes do not overflow or trickle in. // Object-brush display markers are also excluded from terrain synchronization. - public const int ProtocolVersion = 69; + public const int ProtocolVersion = 70; /// /// Hard cap on a single payload, guarding against corrupt length prefixes. diff --git a/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs b/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs index f29f4de..3a1a699 100644 --- a/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs +++ b/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs @@ -28,6 +28,7 @@ internal static class GameplayCommandRegistry ModTypeTableCommand.Id, ModStateCommand.Id, ObjectPlacementBatchCommand.Id, ObjectDeleteBatchCommand.Id, FireIgniteCommand.Id, + ServiceBuildingStateCommand.Id, }; internal static void Register(MultiplayerSession session) @@ -75,6 +76,7 @@ internal static string Name(ushort id) case ObjectPlacementBatchCommand.Id: return "object-place-batch"; case ObjectDeleteBatchCommand.Id: return "object-delete-batch"; case FireIgniteCommand.Id: return "fire-ignite"; + case ServiceBuildingStateCommand.Id: return "service-building-state"; default: return "unknown"; } } diff --git a/CS2MultiplayerMod/Game/Sync/Commands/Simulation/ServiceBuildingStateCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/ServiceBuildingStateCommand.cs new file mode 100644 index 0000000..0ebbf64 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Commands/Simulation/ServiceBuildingStateCommand.cs @@ -0,0 +1,113 @@ +using CS2MultiplayerMod.Core.Protocol; +using CS2MultiplayerMod.Core.Sync; + +namespace CS2MultiplayerMod.Game.Sync.Commands +{ + /// + /// "This service building's state changed." Carries the abandonment, condemnation or + /// destruction markers of a NON-spawnable building - power plants, fire stations, + /// hospitals, signature buildings - the part of building state no other pipeline owns: + /// growable lifecycle sync only watches spawnables, and delete sync only watches removals. + /// A burned-down power plant that stays pristine on the peer is exactly the divergence + /// this closes: the ruin provides no coverage there while it provides none here either. + /// + /// Marker gain and marker loss both travel (repairs converge too); removal itself stays + /// with delete sync, which replicates every non-spawnable removal already. + /// + public sealed class ServiceBuildingStateCommand : ISimulationCommand + { + public const ushort Id = 34; + public const int MaxEncodedBytes = 256; + + public const byte StateAbandoned = 1 << 0; + public const byte StateCondemned = 1 << 1; + public const byte StateDestroyed = 1 << 2; + + /// Name of the standing building's prefab, resolved locally by the receiver. + public string PrefabName; + + /// World position of the building (service buildings do not move). + public float X, Y, Z; + + /// Resulting marker set; zero means "all markers cleared". + public byte StateFlags; + + public ushort CommandId => Id; + + public void Write(NetworkWriter writer) + { + ValidateForWrite(); + writer.WriteString(PrefabName); + writer.WriteFloat(X); writer.WriteFloat(Y); writer.WriteFloat(Z); + writer.WriteByte(StateFlags); + } + + public void Read(NetworkReader reader) + { + PrefabName = WireGuard.ReadName(reader); + X = WireGuard.ReadCoordinate(reader); + Y = WireGuard.ReadCoordinate(reader); + Z = WireGuard.ReadCoordinate(reader); + StateFlags = ReadStateFlags(reader); + + if (reader.Remaining != 0) + throw new ProtocolException("Trailing bytes in service building state: " + + reader.Remaining + "."); + } + + public byte[] Encode() + { + var writer = new NetworkWriter(64); + Write(writer); + if (writer.Length > MaxEncodedBytes) + throw new ProtocolException("Service building state exceeds the " + + MaxEncodedBytes + "-byte cap."); + return writer.ToArray(); + } + + public static ServiceBuildingStateCommand Decode(byte[] body) + { + if (body == null) + throw new ProtocolException("Missing service building state body."); + if (body.Length > MaxEncodedBytes) + throw new ProtocolException("Service building state exceeds the " + + MaxEncodedBytes + "-byte cap."); + var command = new ServiceBuildingStateCommand(); + command.Read(new NetworkReader(body)); + return command; + } + + private void ValidateForWrite() + { + if (string.IsNullOrEmpty(PrefabName) || PrefabName.Length > WireGuard.MaxNameLength) + throw new ProtocolException("Invalid service building prefab name."); + for (int i = 0; i < PrefabName.Length; i++) + if (char.IsControl(PrefabName[i])) + throw new ProtocolException("Control character in service building prefab name."); + ValidateCoordinate(X, "X"); + ValidateCoordinate(Y, "Y"); + ValidateCoordinate(Z, "Z"); + ValidateStateFlags(StateFlags); + } + + private static byte ReadStateFlags(NetworkReader reader) + { + byte value = reader.ReadByte(); + ValidateStateFlags(value); + return value; + } + + private static void ValidateStateFlags(byte value) + { + if ((value & ~(StateAbandoned | StateCondemned | StateDestroyed)) != 0) + throw new ProtocolException("Unknown service building state flags: " + value + "."); + } + + private static void ValidateCoordinate(float value, string label) + { + if (float.IsNaN(value) || float.IsInfinity(value) || + value < -WireGuard.MaxCoordinate || value > WireGuard.MaxCoordinate) + throw new ProtocolException("Invalid service building coordinate " + label + "."); + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Pipeline/SyncRealizeSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Pipeline/SyncRealizeSystem.cs index 5229d07..e5bfa91 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Pipeline/SyncRealizeSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Pipeline/SyncRealizeSystem.cs @@ -27,6 +27,7 @@ public partial class SyncRealizeSystem : GameSystemBase private TilePurchaseSyncSystem _tileSync; private DisasterSyncSystem _disasterSync; private FireSyncSystem _fireSync; + private ServiceBuildingStateSyncSystem _serviceBuildingStateSync; private GrowableSyncSystem _growableSync; private Mods.ModStateSyncSystem _modStateSync; @@ -47,6 +48,7 @@ protected override void OnCreate() _tileSync = World.GetOrCreateSystemManaged(); _disasterSync = World.GetOrCreateSystemManaged(); _fireSync = World.GetOrCreateSystemManaged(); + _serviceBuildingStateSync = World.GetOrCreateSystemManaged(); _growableSync = World.GetOrCreateSystemManaged(); _modStateSync = World.GetOrCreateSystemManaged(); } @@ -184,6 +186,8 @@ protected override void OnUpdate() // A realized ignition only sets OnFire on an existing building or tree - // no definitions, no terrain - so it rides the same slot as disasters. Step("FireSync", _fireSync.RealizePending); + // Marker writes next to fire realizes: same plain-component shape, same slot. + Step("ServiceBuildingState", _serviceBuildingStateSync.RealizePending); // Last: what another mod stores is stored against a road, a junction or a building, // so everything that could still be creating one this frame has to have run. A // closure whose carrier is genuinely still in the backlog waits in its own hold diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ServiceBuildingStateSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ServiceBuildingStateSyncSystem.cs new file mode 100644 index 0000000..c8d80f1 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ServiceBuildingStateSyncSystem.cs @@ -0,0 +1,410 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using Game; +using Game.Buildings; +using Game.Common; +using Game.Objects; +using Game.Prefabs; +using Game.Simulation; +using Game.Tools; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; +using CS2MultiplayerMod.Core.Diagnostics; +using CS2MultiplayerMod.Core.Protocol.Messages; +using CS2MultiplayerMod.Core.Session; +using CS2MultiplayerMod.Game.Diagnostics; +using CS2MultiplayerMod.Game.Sync.Commands; +using CS2MultiplayerMod.Game.Sync.Infrastructure; +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Replicates the Abandoned / Condemned / Destroyed markers of non-spawnable buildings + /// (service buildings, signature buildings) - the one building-state slice with no owner: + /// growable lifecycle sync only watches spawnables, and delete sync only watches removals. + /// One per transition carries the resulting + /// marker set; the receiver writes the same markers and nudges dependents with Updated. + /// + /// Detection is a rolling scan over native UpdateFrame buckets (16 slices, same shape as + /// the growable state scan), seeded once per session so pre-session ruins never broadcast. + /// Realization resolves the standing building by prefab and position and retries briefly + /// when a placement is still in flight - the same window the fire sync uses. + /// + public partial class ServiceBuildingStateSyncSystem : GameSystemBase + { + /// Native UpdateFrame partitions entities into this many buckets. + private const int ScanBuckets = 16; + + /// Realize attempts per frame: each one scans the live buildings. + private const int MaxRealizePerFrame = 4; + + /// How long a missing match gets retried before its state is dropped. + private const long RetryWindowMs = 10000; + + /// Building match tolerance, squared metres (2 m): buildings do not move. + private const float MatchTolSq = 4f; + + private readonly ConcurrentQueue _incoming = + new ConcurrentQueue(); + private readonly Dictionary _lastSeen = new Dictionary(); + private readonly ReplicationGuard _guard = new ReplicationGuard(); + private readonly List<(ServiceBuildingStateCommand command, long deadline)> _retry = + new List<(ServiceBuildingStateCommand, long)>(); + + private PrefabSystem _prefabSystem; + private PrefabIndex _prefabIndex; + private EntityQuery _scanBuildings; + private EntityQuery _liveBuildings; + private CommandObserver _observer; + private bool _seeded; + private int _scanBucket; + + protected override void OnCreate() + { + base.OnCreate(); + + _prefabSystem = World.GetOrCreateSystemManaged(); + _prefabIndex = new PrefabIndex(_prefabSystem, GetEntityQuery(ComponentType.ReadOnly())); + + _scanBuildings = GetEntityQuery(new EntityQueryDesc + { + All = SyncQuery.ReadOnly(), + None = SyncQuery.ReadOnly(), + }); + + _liveBuildings = GetEntityQuery(new EntityQueryDesc + { + All = SyncQuery.ReadOnly(), + None = SyncQuery.ReadOnly(), + }); + + _observer = SyncObserverBinding.Bind( + () => new CommandObserver(_incoming, ServiceBuildingStateCommand.Id) + { + MaxBodyBytes = ServiceBuildingStateCommand.MaxEncodedBytes, + }, + DrainQueue); + } + + protected override void OnDestroy() + { + SyncInbox.UnregisterDrain(DrainQueue); + SyncObserverBinding.Unbind(_observer); + base.OnDestroy(); + } + + protected override void OnUpdate() + { + using (Diagnostics.SyncProfiler.Measure("ServiceBuildingState")) + { + MultiplayerService service = Mod.Service; + if (service == null) return; + + MultiplayerSession session = service.Session; + if (!service.GameplaySyncReady) + { + if (_lastSeen.Count > 0) _lastSeen.Clear(); + if (_retry.Count > 0) _retry.Clear(); + _guard.Clear(); + _seeded = false; + return; + } + + long now = service.NowMs; + _guard.Prune(now); + + if (!_seeded) + { + SeedCache(); + _seeded = true; + return; + } + + ScanBucket(session, now); + PruneDead(); + } + } + + /// Called by during ToolUpdate, next to fire sync: + /// marker writes are plain component changes, no definitions and no terrain involved. + public void RealizePending() + { + MultiplayerService service = Mod.Service; + if (service == null) return; + if (!service.GameplaySyncReady) + { + SyncInbox.Clear(_incoming); + if (_retry.Count > 0) _retry.Clear(); + return; + } + + MultiplayerSession session = service.Session; + long now = service.NowMs; + int attempts = 0; + + List<(ServiceBuildingStateCommand command, long deadline)> due = null; + for (int i = 0; i < _retry.Count; i++) + { + if (_retry[i].deadline < now) + { + SyncLog.Detail(LogTopic.Buildings, "ServiceBuildingState: giving up on '" + + _retry[i].command.PrefabName + "' whose building never arrived."); + continue; + } + (due ?? (due = new List<(ServiceBuildingStateCommand, long)>())) + .Add(_retry[i]); + } + _retry.Clear(); + if (due != null) + { + for (int i = 0; i < due.Count; i++) + { + if (attempts >= MaxRealizePerFrame) + { + for (int j = i; j < due.Count; j++) _retry.Add(due[j]); + break; + } + attempts++; + if (!Realize(due[i].command, now)) + _retry.Add((due[i].command, due[i].deadline)); + } + } + + SimulationCommandMessage message; + while (attempts < MaxRealizePerFrame && _incoming.TryDequeue(out message)) + { + if (message.OriginPlayerId == session.LocalPlayerId) continue; + + ServiceBuildingStateCommand command; + try { command = ServiceBuildingStateCommand.Decode(message.Body); } + catch (System.Exception ex) + { + SyncLog.Warn(LogTopic.Buildings, "ServiceBuildingState: dropping malformed command: " + + ex.Message); + continue; + } + + // Every scan counts toward the cap, match or not. + attempts++; + if (!Realize(command, now)) + _retry.Add((command, now + RetryWindowMs)); + } + } + + // ---- Capture ------------------------------------------------------------ + + /// + /// Learn every non-spawnable building's markers when sync starts (both sides hold the + /// same downloaded world) without sending anything. Without this, every pre-session + /// ruin would broadcast once on its first scan pass. + /// + private void SeedCache() + { + _scanBuildings.ResetFilter(); + NativeArray entities = _scanBuildings.ToEntityArray(Allocator.Temp); + try + { + for (int i = 0; i < entities.Length; i++) + { + Entity entity = entities[i]; + if (!IsCovered(entity)) continue; + _lastSeen[entity] = ReadFlags(entity); + } + SyncLog.Detail(LogTopic.Buildings, "ServiceBuildingState: watching " + + _lastSeen.Count + " service building(s)."); + } + finally + { + entities.Dispose(); + } + } + + private void ScanBucket(MultiplayerSession session, long now) + { + _scanBuildings.SetSharedComponentFilter(new UpdateFrame((uint)_scanBucket)); + _scanBucket = (_scanBucket + 1) % ScanBuckets; + + NativeArray entities = _scanBuildings.ToEntityArray(Allocator.Temp); + try + { + for (int i = 0; i < entities.Length; i++) + { + Entity entity = entities[i]; + if (!IsCovered(entity)) continue; + + byte current = ReadFlags(entity); + byte known; + if (!_lastSeen.TryGetValue(entity, out known)) + { + // New to us (placed after seeding): adopt silently, like the seed. + _lastSeen[entity] = current; + continue; + } + if (current == known) continue; + + Entity prefab = EntityManager.GetComponentData(entity).m_Prefab; + string prefabName = _prefabIndex.NameOf(prefab); + float3 position = EntityManager.GetComponentData(entity).m_Position; + if (string.IsNullOrEmpty(prefabName)) + { + _lastSeen[entity] = current; + continue; + } + + string key = ReplicationGuard.Key(prefabName, position); + _lastSeen[entity] = current; + if (_guard.Consume(key, now)) continue; // our own realize - no echo + + var command = new ServiceBuildingStateCommand + { + PrefabName = prefabName, + X = position.x, + Y = position.y, + Z = position.z, + StateFlags = current, + }; + try + { + session.SendCommand(0, ServiceBuildingStateCommand.Id, command.Encode()); + } + catch (System.Exception ex) + { + SyncLog.Warn(LogTopic.Buildings, "ServiceBuildingState: refusing to send '" + + prefabName + "': " + ex.Message); + continue; + } + SyncLog.Detail(LogTopic.Buildings, "ServiceBuildingState sent '" + prefabName + + "' at " + position + ", flags " + current + "."); + } + } + finally + { + entities.Dispose(); + } + } + + private void PruneDead() + { + if (_lastSeen.Count == 0) return; + List dead = null; + foreach (Entity entity in _lastSeen.Keys) + { + if (EntityManager.Exists(entity) && + !EntityManager.HasComponent(entity)) continue; + // Gone or demolished: removals replicate through delete sync, and a marker + // for a grave nobody stands on carries nothing. Drop silently. + if (dead == null) dead = new List(); + dead.Add(entity); + } + if (dead == null) return; + for (int i = 0; i < dead.Count; i++) _lastSeen.Remove(dead[i]); + } + + /// + /// True for buildings this system owns: everything that is not an autonomous growable. + /// Spawnables stay with growable lifecycle sync (same predicate it uses); removals of + /// any kind stay with delete sync. + /// + private bool IsCovered(Entity entity) + { + if (entity == Entity.Null || !EntityManager.Exists(entity) || + !EntityManager.HasComponent(entity)) return false; + Entity prefab = EntityManager.GetComponentData(entity).m_Prefab; + if (prefab == Entity.Null || !EntityManager.Exists(prefab)) return false; + return !EntityManager.HasComponent(prefab) || + EntityManager.HasComponent(prefab); + } + + private byte ReadFlags(Entity entity) + { + byte flags = 0; + if (EntityManager.HasComponent(entity)) + flags |= ServiceBuildingStateCommand.StateAbandoned; + if (EntityManager.HasComponent(entity)) + flags |= ServiceBuildingStateCommand.StateCondemned; + if (EntityManager.HasComponent(entity)) + flags |= ServiceBuildingStateCommand.StateDestroyed; + return flags; + } + + // ---- Realize ------------------------------------------------------------ + + /// True when the command settled (applied, already matching, or permanently unresolvable). + private bool Realize(ServiceBuildingStateCommand command, long now) + { + Entity prefab; + if (!_prefabIndex.TryResolve(command.PrefabName, out prefab)) + { + SyncLog.Warn(LogTopic.Buildings, "ServiceBuildingState: no local prefab named '" + + command.PrefabName + "'; ignoring."); + return true; + } + + float3 target = new float3(command.X, command.Y, command.Z); + Entity best = Entity.Null; + float bestDistSq = MatchTolSq; + + NativeArray candidates = _liveBuildings.ToEntityArray(Allocator.Temp); + try + { + for (int i = 0; i < candidates.Length; i++) + { + Entity candidate = candidates[i]; + if (EntityManager.GetComponentData(candidate).m_Prefab != prefab) + continue; + float3 position = EntityManager + .GetComponentData(candidate).m_Position; + float distSq = math.distancesq(position, target); + if (distSq < bestDistSq) + { + bestDistSq = distSq; + best = candidate; + } + } + } + finally + { + candidates.Dispose(); + } + + if (best == Entity.Null) return false; + + // A growable standing where the command points is owned by growable lifecycle + // sync - never drive the same markers from two systems. + Entity bestPrefab = EntityManager.GetComponentData(best).m_Prefab; + if (EntityManager.HasComponent(bestPrefab) && + !EntityManager.HasComponent(bestPrefab)) + return true; + + bool changed = false; + changed |= SetMarker(best, + (command.StateFlags & ServiceBuildingStateCommand.StateAbandoned) != 0); + changed |= SetMarker(best, + (command.StateFlags & ServiceBuildingStateCommand.StateCondemned) != 0); + changed |= SetMarker(best, + (command.StateFlags & ServiceBuildingStateCommand.StateDestroyed) != 0); + if (changed && !EntityManager.HasComponent(best)) + EntityManager.AddComponent(best); + + _guard.Mark(ReplicationGuard.Key(command.PrefabName, target), now); + if (changed) + SyncLog.Detail(LogTopic.Buildings, "ServiceBuildingState realized '" + + command.PrefabName + "' at " + target + ", flags " + command.StateFlags + "."); + return true; + } + + private bool SetMarker(Entity entity, bool wanted) where T : unmanaged, IComponentData + { + bool has = EntityManager.HasComponent(entity); + if (has == wanted) return false; + if (wanted) EntityManager.AddComponent(entity); + else EntityManager.RemoveComponent(entity); + return true; + } + + private void DrainQueue() + { + SyncInbox.Clear(_incoming); + } + } +} diff --git a/CS2MultiplayerMod/Mod.cs b/CS2MultiplayerMod/Mod.cs index 4c1b20e..3a04fdc 100644 --- a/CS2MultiplayerMod/Mod.cs +++ b/CS2MultiplayerMod/Mod.cs @@ -391,6 +391,12 @@ public void OnLoad(UpdateSystem updateSystem) // machine rolls its own ignitions and reports them, both cities converging on // the union - so this detector stays on for every role. updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + // ModificationEnd, next to fire sync: marker transitions are detected on a + // rolling UpdateFrame scan rather than on tool tags, so any phase with live + // entities would do - sharing the event-sync slot keeps the ordering obvious. + // Only non-spawnables are watched here; growables stay with their lifecycle + // system and removals stay with delete sync. + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); // After the game's own auto-name initialization, which runs late in ModificationEnd and // is what fills in a new street's or district's name draw. Capturing before it would // read the draw one frame stale. ModificationEnd also keeps working while the game is From 2896cfdfe0419b62b5798adeded95aecc23beb8c Mon Sep 17 00:00:00 2001 From: Malionaro Date: Fri, 11 Sep 2026 17:38:43 +0200 Subject: [PATCH 6/8] Include UpdateFrame in scan query (codex review) --- .../Systems/Simulation/ServiceBuildingStateSyncSystem.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ServiceBuildingStateSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ServiceBuildingStateSyncSystem.cs index c8d80f1..dee7ce9 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ServiceBuildingStateSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ServiceBuildingStateSyncSystem.cs @@ -66,9 +66,12 @@ protected override void OnCreate() _prefabSystem = World.GetOrCreateSystemManaged(); _prefabIndex = new PrefabIndex(_prefabSystem, GetEntityQuery(ComponentType.ReadOnly())); + // UpdateFrame is required, not incidental: the rolling scan filters this + // query by it, and filtering by an absent component throws. Same tradeoff + // as the growable state scan - a building without UpdateFrame stays invisible. _scanBuildings = GetEntityQuery(new EntityQueryDesc { - All = SyncQuery.ReadOnly(), + All = SyncQuery.ReadOnly(), None = SyncQuery.ReadOnly(), }); From 6ebd88e27b493594d9e7f92dd31caffcc4521d13 Mon Sep 17 00:00:00 2001 From: Malionaro Date: Thu, 17 Sep 2026 19:52:29 +0200 Subject: [PATCH 7/8] Harden service building sync: interval-gated prune, Exists guards in scan and match --- .../ServiceBuildingStateSyncSystem.cs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ServiceBuildingStateSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ServiceBuildingStateSyncSystem.cs index dee7ce9..0fd3c5a 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ServiceBuildingStateSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ServiceBuildingStateSyncSystem.cs @@ -44,6 +44,9 @@ public partial class ServiceBuildingStateSyncSystem : GameSystemBase /// Building match tolerance, squared metres (2 m): buildings do not move. private const float MatchTolSq = 4f; + /// Dead-entity prune interval (30 s, same as the audits). + private const long PruneIntervalMs = 30000; + private readonly ConcurrentQueue _incoming = new ConcurrentQueue(); private readonly Dictionary _lastSeen = new Dictionary(); @@ -58,6 +61,7 @@ public partial class ServiceBuildingStateSyncSystem : GameSystemBase private CommandObserver _observer; private bool _seeded; private int _scanBucket; + private long _nextPruneMs; protected override void OnCreate() { @@ -110,6 +114,7 @@ protected override void OnUpdate() if (_retry.Count > 0) _retry.Clear(); _guard.Clear(); _seeded = false; + _nextPruneMs = 0; return; } @@ -120,11 +125,12 @@ protected override void OnUpdate() { SeedCache(); _seeded = true; + _nextPruneMs = now + PruneIntervalMs; return; } ScanBucket(session, now); - PruneDead(); + PruneDead(now); } } @@ -245,6 +251,9 @@ private void ScanBucket(MultiplayerSession session, long now) } if (current == known) continue; + // Re-check: the building may have been demolished between the + // covered check above and these resolving reads. + if (!EntityManager.Exists(entity)) continue; Entity prefab = EntityManager.GetComponentData(entity).m_Prefab; string prefabName = _prefabIndex.NameOf(prefab); float3 position = EntityManager.GetComponentData(entity).m_Position; @@ -286,9 +295,10 @@ private void ScanBucket(MultiplayerSession session, long now) } } - private void PruneDead() + private void PruneDead(long now) { - if (_lastSeen.Count == 0) return; + if (now < _nextPruneMs || _lastSeen.Count == 0) return; + _nextPruneMs = now + PruneIntervalMs; List dead = null; foreach (Entity entity in _lastSeen.Keys) { @@ -353,8 +363,11 @@ private bool Realize(ServiceBuildingStateCommand command, long now) for (int i = 0; i < candidates.Length; i++) { Entity candidate = candidates[i]; + if (!EntityManager.Exists(candidate)) continue; + if (!EntityManager.HasComponent(candidate)) continue; if (EntityManager.GetComponentData(candidate).m_Prefab != prefab) continue; + if (!EntityManager.HasComponent(candidate)) continue; float3 position = EntityManager .GetComponentData(candidate).m_Position; float distSq = math.distancesq(position, target); From e7b6dc28ed4b942a5f69c3d96b9feb698d5626a9 Mon Sep 17 00:00:00 2001 From: Malionaro Date: Fri, 18 Sep 2026 17:50:36 +0200 Subject: [PATCH 8/8] Restore Only-starts-travel doc line dropped in dev rebase --- CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs b/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs index f04aae9..7d35bad 100644 --- a/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs +++ b/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs @@ -12,6 +12,7 @@ public static class ProtocolConstants /// at the handshake instead of diverging silently. /// v69 adds command id 33, fire ignition: one command per building or tree /// fire start, carrying the target's prefab and position plus the ignition intensity. + /// Only starts travel; the burn, the spread and the extinguish run locally on every /// machine, the same start-only shape as disaster events. A v68 peer does not know /// id 33, so the bump refuses it at the handshake instead of dropping its fires silently. /// v65 adds the barrier-only Begin stage: a join streams its world only to whoever joined,