From 4af70c3f45cfa76f9007c651a4127ae773d17609 Mon Sep 17 00:00:00 2001 From: Malionaro Date: Thu, 10 Sep 2026 17:56:19 +0200 Subject: [PATCH 1/4] Add read-only traffic-control audit (PR-A1) --- .../Systems/Nets/TrafficControlAuditSystem.cs | 287 ++++++++++++++++++ CS2MultiplayerMod/Mod.cs | 6 + 2 files changed, 293 insertions(+) create mode 100644 CS2MultiplayerMod/Game/Sync/Systems/Nets/TrafficControlAuditSystem.cs diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Nets/TrafficControlAuditSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Nets/TrafficControlAuditSystem.cs new file mode 100644 index 0000000..3ae3de5 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Systems/Nets/TrafficControlAuditSystem.cs @@ -0,0 +1,287 @@ +using System.Collections.Generic; +using Game; +using Game.Common; +using Game.Net; +using Game.Prefabs; +using Game.Tools; +using Unity.Collections; +using Unity.Entities; +using CS2MultiplayerMod.Core.Diagnostics; +using CS2MultiplayerMod.Game.Diagnostics; +using CS2MultiplayerMod.Game.Sync.Infrastructure; + +namespace CS2MultiplayerMod.Game.Sync.Systems +{ + /// + /// Read-only audit for node traffic control (traffic lights / priority / stop state). + /// + /// Context: placing, upgrading or removing a traffic light / all-way stop / roundabout + /// travels as an Upgraded composition change through NetUpgradeSyncSystem, and + /// crosswalks travel as edge upgrades the same way. So a player-visible traffic-control + /// change that does NOT arrive as an Upgraded edit would silently diverge both + /// cities until the next world resync - and nothing would ever log why. + /// + /// This system sends nothing, writes no components and needs no protocol change. It + /// watches Updated nodes and reports the one case the upgrade pipeline cannot + /// see: the runtime presence/flags changing while the + /// Upgraded flags stay identical (vanilla toggle path, mod edit, or a native + /// re-init that never converged). Live signal phase (current group / timer) is + /// deliberately ignored: every machine simulates its own traffic, so phases diverge + /// by construction and syncing them would fight TrafficLightSystem every few frames. + /// + /// If the logs stay quiet, the upgrade path covers everything and no follow-up sync + /// system is needed. If bypass lines appear, they carry prefab + position + before / + /// after facts so the follow-up command (PR-A2) can be shaped from real evidence. + /// + public partial class TrafficControlAuditSystem : GameSystemBase + { + /// Settle window after a known upgrade edit for native re-init (10 s). + private const long SettleGraceMs = 10000; + + /// Per-node log throttle for repeat bypass observations (60 s). + private const long BypassLogCooldownMs = 60000; + + /// Session summary interval once bypasses were seen (60 s). + private const long SummaryIntervalMs = 60000; + + /// Dead-entity prune interval (30 s). + private const long PruneIntervalMs = 30000; + + private struct Observed + { + public uint General, Left, Right; + public bool HasUpgraded; + public bool HasLights; + public byte LightFlags; + public byte SignalGroups; + public long SuppressUntilMs; + public long LastBypassLogMs; + } + + private readonly Dictionary _observed = new Dictionary(); + + private PrefabSystem _prefabSystem; + private EntityQuery _updatedNodes; + private EntityQuery _liveNodes; + private bool _seeded; + private long _bypassTotal; + private long _bypassReported; + private long _lastSummaryMs; + private long _nextPruneMs; + + protected override void OnCreate() + { + base.OnCreate(); + + _prefabSystem = World.GetOrCreateSystemManaged(); + + // Any player/mod/native traffic-control edit raises Updated on the node, + // whether or not it carries an Upgraded composition change. + _updatedNodes = GetEntityQuery(new EntityQueryDesc + { + All = SyncQuery.ReadOnly(), + None = SyncQuery.ReadOnly(), + }); + + _liveNodes = GetEntityQuery(new EntityQueryDesc + { + All = SyncQuery.ReadOnly(), + None = SyncQuery.ReadOnly(), + }); + } + + protected override void OnUpdate() + { + using (Diagnostics.SyncProfiler.Measure("TrafficControlAudit")) + { + MultiplayerService service = Mod.Service; + if (service == null) return; + + if (!service.GameplaySyncReady) + { + if (_observed.Count > 0) _observed.Clear(); + _seeded = false; + _bypassTotal = 0; + _bypassReported = 0; + _lastSummaryMs = 0; + _nextPruneMs = 0; + return; + } + + long now = service.NowMs; + + if (!_seeded) + { + SeedCache(); + _seeded = true; + _nextPruneMs = now + PruneIntervalMs; + return; + } + + ObserveUpdated(now); + PruneDead(now); + MaybeSummarize(service, now); + } + } + + /// + /// Learn the current traffic state of every node when sync starts (both sides hold + /// the same downloaded world) without logging anything. Without this baseline every + /// pre-session traffic light would look like a bypass on its first Updated tick. + /// + private void SeedCache() + { + NativeArray entities = _liveNodes.ToEntityArray(Allocator.Temp); + try + { + for (int i = 0; i < entities.Length; i++) + _observed[entities[i]] = Read(entities[i], 0); + if (entities.Length > 0) + SyncLog.Detail(LogTopic.Nets, "TrafficControlAudit: watching " + + entities.Length + " node(s), audit only (sends nothing)."); + } + finally + { + entities.Dispose(); + } + } + + private void ObserveUpdated(long now) + { + if (_updatedNodes.IsEmptyIgnoreFilter) return; + + NativeArray entities = _updatedNodes.ToEntityArray(Allocator.Temp); + try + { + for (int i = 0; i < entities.Length; i++) + { + Entity entity = entities[i]; + Observed current = Read(entity, now); + + Observed known; + if (!_observed.TryGetValue(entity, out known)) + { + // Node appeared after seeding (fresh placement): placements and + // upgrades travel through their own sync systems - not a bypass. + _observed[entity] = current; + continue; + } + + bool upgradeChanged = current.HasUpgraded != known.HasUpgraded || + current.General != known.General || + current.Left != known.Left || + current.Right != known.Right; + + if (upgradeChanged) + { + // Known path: NetUpgradeSyncSystem owns this edit. Silence the + // node while the native pipeline re-initializes runtime state. + current.SuppressUntilMs = now + SettleGraceMs; + current.LastBypassLogMs = known.LastBypassLogMs; + _observed[entity] = current; + continue; + } + + bool trafficChanged = current.HasLights != known.HasLights || + current.LightFlags != known.LightFlags || + current.SignalGroups != known.SignalGroups; + + if (!trafficChanged) + { + // Unrelated Updated (neighbour edit, passing traffic) - keep the + // settle suppression already stored, refresh the rest. + current.SuppressUntilMs = known.SuppressUntilMs; + current.LastBypassLogMs = known.LastBypassLogMs; + _observed[entity] = current; + continue; + } + + if (now < known.SuppressUntilMs) + { + // Native re-init after a known upgrade (Apply strips TrafficLights + // and the game rebuilds it) - settling, not a bypass. + current.SuppressUntilMs = known.SuppressUntilMs; + current.LastBypassLogMs = known.LastBypassLogMs; + _observed[entity] = current; + continue; + } + + current.SuppressUntilMs = known.SuppressUntilMs; + current.LastBypassLogMs = known.LastBypassLogMs; + _observed[entity] = current; + + if (now - known.LastBypassLogMs < BypassLogCooldownMs) continue; + + known = _observed[entity]; + known.LastBypassLogMs = now; + _observed[entity] = known; + _bypassTotal++; + + string prefab = PrefabIndex.SafeName(_prefabSystem, entity); + string pos = EntityManager.GetComponentData(entity).m_Position.ToString(); + SyncLog.Detail(LogTopic.Nets, + "TrafficControlAudit: node traffic state changed without an upgrade edit " + + "(bypass path?) at '" + prefab + "' pos=" + pos + + ": lights " + (known.HasLights ? "yes" : "no") + "->" + + (current.HasLights ? "yes" : "no") + + ", flags " + known.LightFlags + "->" + current.LightFlags + + ", groups " + known.SignalGroups + "->" + current.SignalGroups + + " (bypass #" + _bypassTotal + ", audit only - nothing sent)."); + } + } + finally + { + entities.Dispose(); + } + } + + private Observed Read(Entity entity, long now) + { + var observed = new Observed { SuppressUntilMs = now }; + if (EntityManager.HasComponent(entity)) + { + CompositionFlags flags = EntityManager.GetComponentData(entity).m_Flags; + observed.HasUpgraded = true; + observed.General = (uint)flags.m_General; + observed.Left = (uint)flags.m_Left; + observed.Right = (uint)flags.m_Right; + } + if (EntityManager.HasComponent(entity)) + { + TrafficLights lights = EntityManager.GetComponentData(entity); + observed.HasLights = true; + observed.LightFlags = (byte)lights.m_Flags; + observed.SignalGroups = lights.m_SignalGroupCount; + } + return observed; + } + + private void PruneDead(long now) + { + if (now < _nextPruneMs || _observed.Count == 0) return; + _nextPruneMs = now + PruneIntervalMs; + + List dead = null; + foreach (Entity entity in _observed.Keys) + { + if (EntityManager.Exists(entity)) continue; + if (dead == null) dead = new List(); + dead.Add(entity); + } + if (dead == null) return; + for (int i = 0; i < dead.Count; i++) _observed.Remove(dead[i]); + } + + private void MaybeSummarize(MultiplayerService service, long now) + { + if (service == null) return; + if (_bypassTotal <= _bypassReported) return; + if (now - _lastSummaryMs < SummaryIntervalMs) return; + _lastSummaryMs = now; + _bypassReported = _bypassTotal; + SyncLog.Detail(LogTopic.Nets, "TrafficControlAudit: " + _bypassTotal + + " bypass-style traffic change(s) seen this session (audit only - " + + "if this stays at 0, the upgrade path covers all traffic control)."); + } + } +} diff --git a/CS2MultiplayerMod/Mod.cs b/CS2MultiplayerMod/Mod.cs index 93897f9..4018b3d 100644 --- a/CS2MultiplayerMod/Mod.cs +++ b/CS2MultiplayerMod/Mod.cs @@ -324,6 +324,12 @@ public void OnLoad(UpdateSystem updateSystem) updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); + // Read-only audit, no commands, no protocol change: watches Updated nodes for + // traffic-control changes that bypass the Upgraded composition path (vanilla + // toggle, mod edit, failed native re-init). ModificationEnd keeps it on the + // same tags NetUpgradeSyncSystem reads. Logs only - a follow-up sync system + // (PR-A2) is only needed if bypass lines actually appear in the wild. + updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd); From 5349d8095be0a5b14842919e0f05bcc21dd30c02 Mon Sep 17 00:00:00 2001 From: Malionaro Date: Thu, 10 Sep 2026 18:07:19 +0200 Subject: [PATCH 2/4] Fix audit log before/after state and first-observation throttle (codex review) --- .../Systems/Nets/TrafficControlAuditSystem.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Nets/TrafficControlAuditSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Nets/TrafficControlAuditSystem.cs index 3ae3de5..ac5a7ed 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Nets/TrafficControlAuditSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Nets/TrafficControlAuditSystem.cs @@ -210,11 +210,12 @@ private void ObserveUpdated(long now) current.LastBypassLogMs = known.LastBypassLogMs; _observed[entity] = current; - if (now - known.LastBypassLogMs < BypassLogCooldownMs) continue; + // 0 means "never logged": without the guard the first bypass of a + // session started within 60 s of the service clock would be + // throttled away and never counted, faking a quiet audit. + if (known.LastBypassLogMs != 0 && + now - known.LastBypassLogMs < BypassLogCooldownMs) continue; - known = _observed[entity]; - known.LastBypassLogMs = now; - _observed[entity] = known; _bypassTotal++; string prefab = PrefabIndex.SafeName(_prefabSystem, entity); @@ -227,6 +228,12 @@ private void ObserveUpdated(long now) ", flags " + known.LightFlags + "->" + current.LightFlags + ", groups " + known.SignalGroups + "->" + current.SignalGroups + " (bypass #" + _bypassTotal + ", audit only - nothing sent)."); + + // Stamp AFTER the log: known above is still the pre-change snapshot, + // current is the post-change state already stored in the cache. + Observed stamped = _observed[entity]; + stamped.LastBypassLogMs = now; + _observed[entity] = stamped; } } finally From 1a16a854ced80fa5f059044d354b8ecdd8d40ea2 Mon Sep 17 00:00:00 2001 From: Malionaro Date: Fri, 11 Sep 2026 10:11:07 +0200 Subject: [PATCH 3/4] Resolve prefab name through PrefabRef (codex review) --- .../Game/Sync/Systems/Nets/TrafficControlAuditSystem.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Nets/TrafficControlAuditSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Nets/TrafficControlAuditSystem.cs index ac5a7ed..26d31ca 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Nets/TrafficControlAuditSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Nets/TrafficControlAuditSystem.cs @@ -218,7 +218,10 @@ private void ObserveUpdated(long now) _bypassTotal++; - string prefab = PrefabIndex.SafeName(_prefabSystem, entity); + // Live node, not a prefab: resolve through PrefabRef like the other + // capture systems do (SafeName only takes prefab entities). + Entity prefabEntity = EntityManager.GetComponentData(entity).m_Prefab; + string prefab = PrefabIndex.SafeName(_prefabSystem, prefabEntity); string pos = EntityManager.GetComponentData(entity).m_Position.ToString(); SyncLog.Detail(LogTopic.Nets, "TrafficControlAudit: node traffic state changed without an upgrade edit " + From 5d3626574fc414025936cf3db06d065df26fc31d Mon Sep 17 00:00:00 2001 From: Malionaro Date: Thu, 17 Sep 2026 19:47:40 +0200 Subject: [PATCH 4/4] Harden traffic-control audit: Exists guard, shared suppression helper, count throttled bypasses --- .../Systems/Nets/TrafficControlAuditSystem.cs | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Nets/TrafficControlAuditSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Nets/TrafficControlAuditSystem.cs index 26d31ca..0296ea7 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Nets/TrafficControlAuditSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Nets/TrafficControlAuditSystem.cs @@ -65,6 +65,7 @@ private struct Observed private EntityQuery _liveNodes; private bool _seeded; private long _bypassTotal; + private long _bypassSuppressed; private long _bypassReported; private long _lastSummaryMs; private long _nextPruneMs; @@ -102,6 +103,7 @@ protected override void OnUpdate() if (_observed.Count > 0) _observed.Clear(); _seeded = false; _bypassTotal = 0; + _bypassSuppressed = 0; _bypassReported = 0; _lastSummaryMs = 0; _nextPruneMs = 0; @@ -156,6 +158,7 @@ private void ObserveUpdated(long now) for (int i = 0; i < entities.Length; i++) { Entity entity = entities[i]; + if (!EntityManager.Exists(entity)) continue; Observed current = Read(entity, now); Observed known; @@ -176,9 +179,7 @@ private void ObserveUpdated(long now) { // Known path: NetUpgradeSyncSystem owns this edit. Silence the // node while the native pipeline re-initializes runtime state. - current.SuppressUntilMs = now + SettleGraceMs; - current.LastBypassLogMs = known.LastBypassLogMs; - _observed[entity] = current; + _observed[entity] = CarrySuppression(current, known, now + SettleGraceMs); continue; } @@ -190,9 +191,7 @@ private void ObserveUpdated(long now) { // Unrelated Updated (neighbour edit, passing traffic) - keep the // settle suppression already stored, refresh the rest. - current.SuppressUntilMs = known.SuppressUntilMs; - current.LastBypassLogMs = known.LastBypassLogMs; - _observed[entity] = current; + _observed[entity] = CarrySuppression(current, known, known.SuppressUntilMs); continue; } @@ -200,21 +199,21 @@ private void ObserveUpdated(long now) { // Native re-init after a known upgrade (Apply strips TrafficLights // and the game rebuilds it) - settling, not a bypass. - current.SuppressUntilMs = known.SuppressUntilMs; - current.LastBypassLogMs = known.LastBypassLogMs; - _observed[entity] = current; + _observed[entity] = CarrySuppression(current, known, known.SuppressUntilMs); continue; } - current.SuppressUntilMs = known.SuppressUntilMs; - current.LastBypassLogMs = known.LastBypassLogMs; - _observed[entity] = current; + _observed[entity] = CarrySuppression(current, known, known.SuppressUntilMs); // 0 means "never logged": without the guard the first bypass of a // session started within 60 s of the service clock would be // throttled away and never counted, faking a quiet audit. if (known.LastBypassLogMs != 0 && - now - known.LastBypassLogMs < BypassLogCooldownMs) continue; + now - known.LastBypassLogMs < BypassLogCooldownMs) + { + _bypassSuppressed++; + continue; + } _bypassTotal++; @@ -245,6 +244,18 @@ private void ObserveUpdated(long now) } } + /// + /// Store a refreshed snapshot while keeping the settle suppression and the + /// log throttle of the previous observation. One helper for the four + /// cache-update sites so the two preserved fields cannot drift apart again. + /// + private static Observed CarrySuppression(Observed current, Observed known, long suppressUntilMs) + { + current.SuppressUntilMs = suppressUntilMs; + current.LastBypassLogMs = known.LastBypassLogMs; + return current; + } + private Observed Read(Entity entity, long now) { var observed = new Observed { SuppressUntilMs = now }; @@ -290,7 +301,8 @@ private void MaybeSummarize(MultiplayerService service, long now) _lastSummaryMs = now; _bypassReported = _bypassTotal; SyncLog.Detail(LogTopic.Nets, "TrafficControlAudit: " + _bypassTotal + - " bypass-style traffic change(s) seen this session (audit only - " + + " bypass-style traffic change(s) seen this session (" + _bypassSuppressed + + " further repeat(s) throttled, audit only - " + "if this stays at 0, the upgrade path covers all traffic control)."); } }