From 58e914305a86f8c19cb0d63d5fe875541b0bca73 Mon Sep 17 00:00:00 2001 From: Malionaro Date: Fri, 11 Sep 2026 16:36:07 +0200 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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 +