From 6dd1171af78e6daf4e37d2f3f348388219ff1c63 Mon Sep 17 00:00:00 2001 From: macery12 Date: Thu, 8 Oct 2026 19:24:41 -0500 Subject: [PATCH] Synchronize building conditions from the host Replicate fire state, building warnings and home health summaries in bounded batches. Bind client snapshots to building lifetimes and reject mismatched protocol versions to prevent stale or incompatible updates. Keep host authority during client building simulation and reset caches on connection and level transitions. Add local diagnostics and 16 regression groups covering serialization, identity, recovery and patch target metadata. Validation: Release build and all regression groups pass. In-game multiplayer validation: user confirmed the preview works correctly. --- scripts/test-building-sync.ps1 | 68 +++ src/api/Commands/Command.cs | 3 + src/basegame/CSM.BaseGame.csproj | 5 + .../Data/Buildings/BuildingStatusCommand.cs | 274 +++++++++++ .../Buildings/BuildingStatusHandler.cs | 30 ++ src/basegame/Helpers/BuildingStatusCache.cs | 125 +++++ src/basegame/Helpers/BuildingStatusSync.cs | 340 ++++++++++++++ .../Injections/BuildingStatusPatches.cs | 240 ++++++++++ src/csm/Commands/CommandInternal.cs | 7 + .../Data/Internal/ConnectionRequestCommand.cs | 3 + .../Data/Internal/ConnectionResultCommand.cs | 3 + .../Internal/ConnectionRequestHandler.cs | 14 +- .../Internal/ConnectionResultHandler.cs | 7 + src/csm/Extensions/LoadingExtension.cs | 4 + src/csm/Extensions/ThreadingExtension.cs | 25 + src/csm/Networking/Client.cs | 3 +- src/csm/Panels/ChatLogPanel.cs | 4 + tests/BuildingStatusSync.Tests.cs | 440 ++++++++++++++++++ tests/BuildingStatusSync.Tests.csproj | 58 +++ 19 files changed, 1651 insertions(+), 2 deletions(-) create mode 100644 scripts/test-building-sync.ps1 create mode 100644 src/basegame/Commands/Data/Buildings/BuildingStatusCommand.cs create mode 100644 src/basegame/Commands/Handler/Buildings/BuildingStatusHandler.cs create mode 100644 src/basegame/Helpers/BuildingStatusCache.cs create mode 100644 src/basegame/Helpers/BuildingStatusSync.cs create mode 100644 src/basegame/Injections/BuildingStatusPatches.cs create mode 100644 tests/BuildingStatusSync.Tests.cs create mode 100644 tests/BuildingStatusSync.Tests.csproj diff --git a/scripts/test-building-sync.ps1 b/scripts/test-building-sync.ps1 new file mode 100644 index 00000000..2f93c418 --- /dev/null +++ b/scripts/test-building-sync.ps1 @@ -0,0 +1,68 @@ +param( + [string]$MSBuildPath = "MSBuild.exe", + [string]$GameDirectory = "" +) +$ErrorActionPreference = "Stop" +$workspaceRoot = Split-Path -Parent $PSScriptRoot +$assembliesDirectory = Join-Path $workspaceRoot "assemblies" +$gameReferences = @("Assembly-CSharp.dll", "Assembly-CSharp-firstpass.dll", "PopsApiWrapper.dll", + "ColossalManaged.dll", "ICities.dll", "UnityEngine.dll", "UnityEngine.UI.dll") + +if ($GameDirectory) { + $managedDirectory = Join-Path $GameDirectory "Cities_Data\Managed" + foreach ($reference in $gameReferences) { + $source = Join-Path $managedDirectory $reference + if (!(Test-Path -LiteralPath $source)) { + throw "Game reference missing: $source" + } + } + foreach ($reference in $gameReferences) { + Copy-Item -LiteralPath (Join-Path $managedDirectory $reference) -Destination (Join-Path $assembliesDirectory $reference) -Force + } +} + +foreach ($reference in $gameReferences) { + if (!(Test-Path -LiteralPath (Join-Path $assembliesDirectory $reference))) { + throw "Missing $reference. Run this script with -GameDirectory pointing to the original game installation." + } +} + +$assetsPath = Join-Path $workspaceRoot "src\basegame\obj\project.assets.json" +if (!(Test-Path -LiteralPath $assetsPath)) { + throw "Restore the CSM solution's NuGet dependencies first." +} +$assets = Get-Content -LiteralPath $assetsPath -Raw | ConvertFrom-Json +$harmonyPackage = $assets.libraries.PSObject.Properties.Name | Where-Object { $_ -like "CitiesHarmony.Harmony/*" } | Select-Object -First 1 +if (!$harmonyPackage) { + throw "CitiesHarmony.Harmony is missing from the restored assets." +} + +$harmonyAssembly = $null +foreach ($folder in $assets.packageFolders.PSObject.Properties.Name) { + $candidate = Join-Path $folder ($harmonyPackage.ToLowerInvariant() + "/lib/net35/CitiesHarmony.Harmony.dll") + if (Test-Path -LiteralPath $candidate) { + $harmonyAssembly = $candidate + break + } +} +if (!$harmonyAssembly) { + throw "The restored CitiesHarmony.Harmony assembly cannot be found." +} + +$buildArguments = @((Join-Path $workspaceRoot "tests\BuildingStatusSync.Tests.csproj"), "/t:Build", + "/p:Configuration=Release", "/p:PostBuildEvent=", "/p:HarmonyAssemblyPath=$harmonyAssembly", "/verbosity:minimal", "/nologo") +$sdkDirectory = Join-Path ([Environment]::GetFolderPath("ProgramFilesX86")) "Windows Kits\10" +if (Test-Path -LiteralPath $sdkDirectory) { + $buildArguments += "/p:TargetPlatformSdkPath=$sdkDirectory" + $buildArguments += "/p:TargetPlatformDisplayName=Windows" +} + +& $MSBuildPath @buildArguments +if ($LASTEXITCODE -ne 0) { + throw "Building status regression runner failed to build." +} + +& (Join-Path $workspaceRoot "tests\bin\Release\BuildingStatusSync.Tests.exe") +if ($LASTEXITCODE -ne 0) { + throw "Building status regression checks failed." +} diff --git a/src/api/Commands/Command.cs b/src/api/Commands/Command.cs index 10ec08ff..900aac80 100644 --- a/src/api/Commands/Command.cs +++ b/src/api/Commands/Command.cs @@ -5,6 +5,9 @@ namespace CSM.API.Commands public static class Command { public static Action SendToAll, SendToServer, SendToClients; + + /// Server-only response to a connected player. + public static Action SendToClient; public static Func GetCommandHandler; public static MultiplayerRole CurrentRole { get; set; } diff --git a/src/basegame/CSM.BaseGame.csproj b/src/basegame/CSM.BaseGame.csproj index 72b732a9..547c0c84 100644 --- a/src/basegame/CSM.BaseGame.csproj +++ b/src/basegame/CSM.BaseGame.csproj @@ -66,6 +66,11 @@ + + + + + diff --git a/src/basegame/Commands/Data/Buildings/BuildingStatusCommand.cs b/src/basegame/Commands/Data/Buildings/BuildingStatusCommand.cs new file mode 100644 index 00000000..ce78dffb --- /dev/null +++ b/src/basegame/Commands/Data/Buildings/BuildingStatusCommand.cs @@ -0,0 +1,274 @@ +using System.Collections.Generic; +using CSM.API.Commands; +using ProtoBuf; +using UnityEngine; + +namespace CSM.BaseGame.Commands.Data.Buildings +{ + [ProtoContract] + public class BuildingStatusCommand : CommandBase + { + [ProtoMember(1)] + public string HostSession { get; set; } + + [ProtoMember(2)] + public string RequestSession { get; set; } + + [ProtoMember(3)] + public List Buildings { get; set; } + } + + [ProtoContract] + public class RequestBuildingStatusCommand : CommandBase + { + [ProtoMember(1)] + public string Session { get; set; } + + [ProtoMember(2)] + public List Buildings { get; set; } + } + + [ProtoContract] + public class BuildingStatusRequest + { + [ProtoMember(1)] + public ushort BuildingId { get; set; } + + [ProtoMember(2)] + public ulong Token { get; set; } + + [ProtoMember(3)] + public ushort InfoIndex { get; set; } + + [ProtoMember(4)] + public Vector3 Position { get; set; } + + [ProtoMember(5)] + public float Angle { get; set; } + + [ProtoMember(6)] + public byte Width { get; set; } + + [ProtoMember(7)] + public byte Length { get; set; } + + public bool Matches(ref Building building) + { + return BuildingStatusState.IsLive(ref building) && building.m_infoIndex == InfoIndex && + building.m_position.Equals(Position) && building.m_angle == Angle && + building.m_width == Width && building.m_length == Length; + } + } + + [ProtoContract] + public class BuildingHomeHealth + { + [ProtoMember(1)] + public int Sick { get; set; } + + [ProtoMember(2)] + public int TotalSick { get; set; } + + [ProtoMember(3)] + public int SickChildren { get; set; } + + [ProtoMember(4)] + public int SickSeniors { get; set; } + + [ProtoMember(5)] + public int Dead { get; set; } + + public bool SameAs(BuildingHomeHealth other) + { + return other != null && Sick == other.Sick && TotalSick == other.TotalSick && + SickChildren == other.SickChildren && SickSeniors == other.SickSeniors && Dead == other.Dead; + } + } + + [ProtoContract] + public class BuildingStatusState + { + // Never replace structural flags, settings, IDs, buffer links, or the local build index. + public const Building.Flags ConditionMask = Building.Flags.Active | Building.Flags.Abandoned | + Building.Flags.Collapsed | Building.Flags.Flooded | Building.Flags.RoadAccessFailed | Building.Flags.RateReduced; + public const int TimerCount = 11; + + [ProtoMember(1)] + public ushort BuildingId { get; set; } + + [ProtoMember(2)] + public uint HostBuildIndex { get; set; } + + [ProtoMember(3)] + public ushort InfoIndex { get; set; } + + [ProtoMember(4)] + public Vector3 Position { get; set; } + + [ProtoMember(5)] + public float Angle { get; set; } + + [ProtoMember(6)] + public byte Width { get; set; } + + [ProtoMember(7)] + public byte Length { get; set; } + + [ProtoMember(8)] + public uint Conditions { get; set; } + + [ProtoMember(9)] + public ulong Problems1 { get; set; } + + [ProtoMember(10)] + public ulong Problems2 { get; set; } + + [ProtoMember(11)] + public byte FireIntensity { get; set; } + + [ProtoMember(12)] + public byte FireDamage { get; set; } + + [ProtoMember(13)] + public byte ConstructState { get; set; } + + [ProtoMember(14)] + public byte Health { get; set; } + + [ProtoMember(15)] + public byte ChildHealth { get; set; } + + [ProtoMember(16)] + public byte SeniorHealth { get; set; } + + [ProtoMember(17)] + public byte[] ProblemTimers { get; set; } + + [ProtoMember(18)] + public BuildingHomeHealth HomeHealth { get; set; } + // Only baseline replies carry a token; broadcasts cannot bind a new building lifetime. + [ProtoMember(19)] + public ulong RequestToken { get; set; } + + [ProtoMember(20)] + public byte WaterPollution { get; set; } + + [ProtoMember(21)] + public byte Happiness { get; set; } + + public static bool IsLive(ref Building building) + { + return (building.m_flags & (Building.Flags.Created | Building.Flags.Deleted)) == Building.Flags.Created; + } + + public static BuildingStatusState Capture(ushort id, ref Building building, BuildingHomeHealth homeHealth) + { + Building.Frame frame = building.GetLastFrameData(); + return new BuildingStatusState + { + BuildingId = id, + HostBuildIndex = building.m_buildIndex, + InfoIndex = building.m_infoIndex, + Position = building.m_position, + Angle = building.m_angle, + Width = building.m_width, + Length = building.m_length, + Conditions = (uint)(building.m_flags & ConditionMask), + Problems1 = (ulong)building.m_problems.m_Problems1, + Problems2 = (ulong)building.m_problems.m_Problems2, + FireIntensity = building.m_fireIntensity, + FireDamage = frame.m_fireDamage, + ConstructState = frame.m_constructState, + Health = building.m_health, + ChildHealth = building.m_childHealth, + SeniorHealth = building.m_seniorHealth, + WaterPollution = building.m_waterPollution, + Happiness = building.m_happiness, + HomeHealth = homeHealth, + ProblemTimers = new[] { building.m_electricityProblemTimer, building.m_heatingProblemTimer, + building.m_waterProblemTimer, building.m_workerProblemTimer, building.m_incomingProblemTimer, + building.m_outgoingProblemTimer, building.m_healthProblemTimer, building.m_deathProblemTimer, + building.m_serviceProblemTimer, building.m_taxProblemTimer, building.m_majorProblemTimer } + }; + } + + public bool MatchesGeometry(ref Building building) + { + return IsLive(ref building) && building.m_infoIndex == InfoIndex && building.m_position.Equals(Position) && + building.m_angle == Angle && building.m_width == Width && building.m_length == Length; + } + + public bool IsValid() + { + return BuildingId != 0 && ProblemTimers != null && ProblemTimers.Length == TimerCount && + (Conditions & ~(uint)ConditionMask) == 0 && + !float.IsNaN(Position.x) && !float.IsNaN(Position.y) && !float.IsNaN(Position.z) && + !float.IsInfinity(Position.x) && !float.IsInfinity(Position.y) && !float.IsInfinity(Position.z) && + !float.IsNaN(Angle) && !float.IsInfinity(Angle) && + (HomeHealth == null || (HomeHealth.Sick >= 0 && HomeHealth.TotalSick >= HomeHealth.Sick && + HomeHealth.SickChildren >= 0 && HomeHealth.SickSeniors >= 0 && HomeHealth.Dead >= 0)); + } + + public bool SameAs(BuildingStatusState other) + { + if (other == null || HostBuildIndex != other.HostBuildIndex || InfoIndex != other.InfoIndex || + !Position.Equals(other.Position) || Angle != other.Angle || Width != other.Width || Length != other.Length || + Conditions != other.Conditions || Problems1 != other.Problems1 || Problems2 != other.Problems2 || + FireIntensity != other.FireIntensity || FireDamage != other.FireDamage || + (((Conditions | other.Conditions) & (uint)Building.Flags.Collapsed) != 0 && ConstructState != other.ConstructState) || + Health != other.Health || ChildHealth != other.ChildHealth || SeniorHealth != other.SeniorHealth || + WaterPollution != other.WaterPollution || Happiness != other.Happiness || + (HomeHealth == null ? other.HomeHealth != null : !HomeHealth.SameAs(other.HomeHealth))) + return false; + for (int i = 0; i < TimerCount; i++) + if (ProblemTimers[i] != other.ProblemTimers[i]) + return false; + return true; + } + + public void ApplyHomeHealth(ref Citizen.BehaviourData behaviour, int aliveCount) + { + if (HomeHealth == null) + return; + behaviour.m_sickCount = HomeHealth.Sick; + behaviour.m_totalSickCount = HomeHealth.TotalSick; + behaviour.m_sickChildCount = HomeHealth.SickChildren; + behaviour.m_sickSeniorCount = HomeHealth.SickSeniors; + behaviour.m_deadCount = HomeHealth.Dead; + // Preserve local population/identity while keeping the building average health authoritative. + behaviour.m_healthAccumulation = Health * aliveCount; + behaviour.m_childHealthAccumulation = ChildHealth * (behaviour.m_childCount + behaviour.m_teenCount); + behaviour.m_seniorHealthAccumulation = SeniorHealth * behaviour.m_seniorCount; + } + + public void ApplyFields(ref Building building) + { + bool wasCollapsed = (building.m_flags & Building.Flags.Collapsed) != 0; + building.m_flags = (building.m_flags & ~ConditionMask) | (Building.Flags)Conditions; + building.m_problems = new Notification.ProblemStruct((Notification.Problem1)Problems1, (Notification.Problem2)Problems2); + building.m_fireIntensity = FireIntensity; + // Fill the interpolation history too, so a repaired fire cannot leave a stale burning frame behind. + building.m_frame0.m_fireDamage = building.m_frame1.m_fireDamage = + building.m_frame2.m_fireDamage = building.m_frame3.m_fireDamage = FireDamage; + if (wasCollapsed || (building.m_flags & Building.Flags.Collapsed) != 0) + building.m_frame0.m_constructState = building.m_frame1.m_constructState = + building.m_frame2.m_constructState = building.m_frame3.m_constructState = ConstructState; + building.m_health = Health; + building.m_childHealth = ChildHealth; + building.m_seniorHealth = SeniorHealth; + building.m_electricityProblemTimer = ProblemTimers[0]; + building.m_heatingProblemTimer = ProblemTimers[1]; + building.m_waterProblemTimer = ProblemTimers[2]; + building.m_workerProblemTimer = ProblemTimers[3]; + building.m_incomingProblemTimer = ProblemTimers[4]; + building.m_outgoingProblemTimer = ProblemTimers[5]; + building.m_healthProblemTimer = ProblemTimers[6]; + building.m_deathProblemTimer = ProblemTimers[7]; + building.m_serviceProblemTimer = ProblemTimers[8]; + building.m_taxProblemTimer = ProblemTimers[9]; + building.m_majorProblemTimer = ProblemTimers[10]; + building.m_waterPollution = WaterPollution; + building.m_happiness = Happiness; + } + } +} diff --git a/src/basegame/Commands/Handler/Buildings/BuildingStatusHandler.cs b/src/basegame/Commands/Handler/Buildings/BuildingStatusHandler.cs new file mode 100644 index 00000000..0fa75f9b --- /dev/null +++ b/src/basegame/Commands/Handler/Buildings/BuildingStatusHandler.cs @@ -0,0 +1,30 @@ +using CSM.API.Commands; +using CSM.BaseGame.Commands.Data.Buildings; +using CSM.BaseGame.Helpers; + +namespace CSM.BaseGame.Commands.Handler.Buildings +{ + public class BuildingStatusHandler : CommandHandler + { + public BuildingStatusHandler() + { + RelayOnServer = false; + } + protected override void Handle(BuildingStatusCommand command) + { + BuildingStatusSync.Receive(command); + } + } + + public class RequestBuildingStatusHandler : CommandHandler + { + public RequestBuildingStatusHandler() + { + RelayOnServer = false; + } + protected override void Handle(RequestBuildingStatusCommand command) + { + BuildingStatusSync.Answer(command); + } + } +} diff --git a/src/basegame/Helpers/BuildingStatusCache.cs b/src/basegame/Helpers/BuildingStatusCache.cs new file mode 100644 index 00000000..06ad00fe --- /dev/null +++ b/src/basegame/Helpers/BuildingStatusCache.cs @@ -0,0 +1,125 @@ +using System; +using CSM.BaseGame.Commands.Data.Buildings; + +namespace CSM.BaseGame.Helpers +{ + // The simulation thread replaces entries; UI readers use complete snapshots. + public class BuildingStatusCache + { + private class Entry + { + public uint LocalBuildIndex; + public BuildingStatusState State; + public ulong PendingToken; + public bool Bound; + public long LastRequest; + } + + private Entry[] _entries = new Entry[ushort.MaxValue + 1]; + private ulong _nextToken; + public int BoundCount { get; private set; } + public int PendingCount { get; private set; } + public string Session { get; private set; } + public string HostSession { get; private set; } + + public BuildingStatusCache() + { + Reset(); + } + + public void Reset() + { + _entries = new Entry[ushort.MaxValue + 1]; + _nextToken = 0; + BoundCount = PendingCount = 0; + Session = Guid.NewGuid().ToString("N"); + HostSession = null; + } + + public void Invalidate(ushort id) + { + Entry entry = _entries[id]; + if (entry != null) + { + if (entry.Bound) + BoundCount--; + if (entry.PendingToken != 0) + PendingCount--; + } + _entries[id] = null; + } + + public BuildingStatusState Get(ushort id, ref Building building) + { + Entry entry = _entries[id]; + return entry != null && entry.LocalBuildIndex == building.m_buildIndex && entry.State.MatchesGeometry(ref building) + ? entry.State : null; + } + + public BuildingStatusState Seed(ushort id, ref Building building) + { + BuildingStatusState state = Get(id, ref building); + if (state != null || !BuildingStatusState.IsLive(ref building)) + return state; + Invalidate(id); + state = BuildingStatusState.Capture(id, ref building, null); + _entries[id] = new Entry { LocalBuildIndex = building.m_buildIndex, State = state }; + return state; + } + + public BuildingStatusRequest Request(ushort id, ref Building building, long now, long retryTicks) + { + if (id == 0 || Seed(id, ref building) == null) + return null; + Entry entry = _entries[id]; + if (entry.Bound || (entry.PendingToken != 0 && now - entry.LastRequest < retryTicks)) + return null; + if (entry.PendingToken == 0) + PendingCount++; + ulong token = entry.PendingToken != 0 ? entry.PendingToken : ++_nextToken; + _entries[id] = new Entry + { + LocalBuildIndex = entry.LocalBuildIndex, + State = entry.State, + PendingToken = token, + LastRequest = now + }; + return new BuildingStatusRequest + { + BuildingId = id, + Token = token, + InfoIndex = building.m_infoIndex, + Position = building.m_position, + Angle = building.m_angle, + Width = building.m_width, + Length = building.m_length + }; + } + + public bool Accept(BuildingStatusState state, string requestSession, string hostSession, ref Building building) + { + if (state == null || !state.IsValid() || string.IsNullOrEmpty(hostSession) || !state.MatchesGeometry(ref building)) + return false; + Entry entry = _entries[state.BuildingId]; + if (entry == null || entry.LocalBuildIndex != building.m_buildIndex || !entry.State.MatchesGeometry(ref building)) + return false; + if (!string.IsNullOrEmpty(requestSession)) + { + if (requestSession != Session || state.RequestToken == 0 || state.RequestToken != entry.PendingToken || + (HostSession != null && HostSession != hostSession)) + return false; + HostSession = hostSession; + } + else if (!entry.Bound || HostSession != hostSession || entry.State.HostBuildIndex != state.HostBuildIndex || state.RequestToken != 0) + { + return false; + } + if (!entry.Bound) + BoundCount++; + if (entry.PendingToken != 0) + PendingCount--; + _entries[state.BuildingId] = new Entry { LocalBuildIndex = entry.LocalBuildIndex, State = state, Bound = true }; + return true; + } + } +} diff --git a/src/basegame/Helpers/BuildingStatusSync.cs b/src/basegame/Helpers/BuildingStatusSync.cs new file mode 100644 index 00000000..c80433d2 --- /dev/null +++ b/src/basegame/Helpers/BuildingStatusSync.cs @@ -0,0 +1,340 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using CSM.API; +using CSM.API.Commands; +using CSM.API.Helpers; +using CSM.BaseGame.Commands.Data.Buildings; + +namespace CSM.BaseGame.Helpers +{ + public static class BuildingStatusSync + { + public const int ProtocolVersion = 1; + public const int BatchSize = 64; + private const int SweepSize = 512; + private static readonly long FlushTicks = Math.Max(1, Stopwatch.Frequency / 10); + private static readonly long RefreshTicks = Stopwatch.Frequency * 10; + private static readonly long RetryTicks = Stopwatch.Frequency * 2; + private static readonly BuildingStatusCache ClientCache = new BuildingStatusCache(); + private static readonly HashSet Dirty = new HashSet(); + private static BuildingStatusState[] _sent = new BuildingStatusState[ushort.MaxValue + 1]; + private static long[] _lastSent = new long[ushort.MaxValue + 1]; + private static string _hostSession = Guid.NewGuid().ToString("N"); + private static MultiplayerRole _role; + private static bool _connected; + private static long _lastFlush; + private static int _cursor = 1; + private static int _sentStates; + private static int _receivedStates; + private static int _resetRequested; + private static int _resetApplied; + + [ThreadStatic] + private static int _clientSimulationDepth; + + private static bool ResetPending + { + get + { + return Interlocked.CompareExchange(ref _resetRequested, 0, 0) != _resetApplied; + } + } + + public static string Diagnostics() + { + if (!_connected || ResetPending) + return "Building sync: waiting for a connected multiplayer session."; + if (Command.CurrentRole == MultiplayerRole.Client) + return "Building sync: " + ClientCache.BoundCount + " buildings bound to host, " + + ClientCache.PendingCount + " baseline requests pending, " + _receivedStates + " states received."; + return "Building sync: host authority active, " + _sentStates + " building states sent."; + } + + public static bool InClientSimulation + { + get + { + return _clientSimulationDepth != 0; + } + } + + public static void EnterClientSimulation() + { + _clientSimulationDepth++; + } + + public static void ExitClientSimulation() + { + _clientSimulationDepth--; + } + + // Loading callbacks can run on the UI thread. Only the simulation thread clears mutable caches. + public static void Reset() + { + Interlocked.Increment(ref _resetRequested); + } + + private static void Clear() + { + ClientCache.Reset(); + Dirty.Clear(); + _sent = new BuildingStatusState[ushort.MaxValue + 1]; + _lastSent = new long[ushort.MaxValue + 1]; + _hostSession = Guid.NewGuid().ToString("N"); + _lastFlush = 0; + _cursor = 1; + _sentStates = _receivedStates = 0; + _connected = false; + _role = MultiplayerRole.None; + } + + public static void UpdateConnection(MultiplayerRole role, bool connected) + { + int resetVersion = Interlocked.CompareExchange(ref _resetRequested, 0, 0); + if (resetVersion != _resetApplied || _role != role || (_connected && !connected)) + { + Clear(); + _resetApplied = resetVersion; + } + _role = role; + _connected = connected; + } + + public static void Invalidate(ushort id) + { + if (id == 0) + return; + ClientCache.Invalidate(id); + _sent[id] = null; + MarkDirty(id); + } + + public static void MarkDirty(ushort id) + { + if (_connected && !ResetPending && Command.CurrentRole == MultiplayerRole.Server && id != 0) + Dirty.Add(id); + } + + public static void MarkFamilyDirty(ushort id) + { + if (!_connected || ResetPending || Command.CurrentRole != MultiplayerRole.Server) + return; + Building[] buildings = BuildingManager.instance.m_buildings.m_buffer; + int visits = 0; + while (id != 0 && id < buildings.Length && visits++ < buildings.Length) + { + MarkDirty(id); + id = buildings[id].m_subBuilding; + } + } + + public static BuildingStatusState GetClientState(ushort id, ref Building building) + { + return _connected && !ResetPending && Command.CurrentRole == MultiplayerRole.Client ? ClientCache.Get(id, ref building) : null; + } + + public static void Enforce(ushort id, ref Building building) + { + BuildingStatusState state = _connected && !ResetPending && Command.CurrentRole == MultiplayerRole.Client + ? ClientCache.Seed(id, ref building) : null; + if (state != null) + Apply(id, state, ref building); + } + + private static void Apply(ushort id, BuildingStatusState state, ref Building building) + { + BuildingManager manager = BuildingManager.instance; + Building.Flags oldFlags = building.m_flags; + Notification.ProblemStruct oldProblems = building.m_problems; + Building.Frame oldFrame = building.GetLastFrameData(); + byte oldFire = building.m_fireIntensity; + byte oldHealth = building.m_health, oldChild = building.m_childHealth, oldSenior = building.m_seniorHealth; + byte oldHappiness = building.m_happiness; + state.ApplyFields(ref building); + IgnoreHelper.Instance.StartIgnore(); + try + { + if (oldProblems != building.m_problems) + { + manager.UpdateNotifications(id, oldProblems, building.m_problems); + // Clearing a major warning calls CheckRoadAccess, which may otherwise replace the host flags/problems. + state.ApplyFields(ref building); + } + Building.Flags changedFlags = oldFlags ^ building.m_flags; + if (changedFlags != 0) + manager.UpdateFlags(id, changedFlags); + if (oldFire != building.m_fireIntensity || oldFrame.m_fireDamage != state.FireDamage || + ((oldFlags | building.m_flags) & Building.Flags.Collapsed) != 0 && oldFrame.m_constructState != state.ConstructState || + (changedFlags & (Building.Flags.Active | Building.Flags.Abandoned | Building.Flags.Collapsed | Building.Flags.Flooded)) != 0) + { + manager.UpdateBuildingRenderer(id, true); + manager.UpdateBuildingColors(id); + } + else if (oldHealth != building.m_health || oldChild != building.m_childHealth || + oldSenior != building.m_seniorHealth || oldHappiness != building.m_happiness) + { + manager.UpdateBuildingColors(id); + } + } + finally + { + IgnoreHelper.Instance.EndIgnore(); + } + } + + public static void Receive(BuildingStatusCommand command) + { + if (!_connected || ResetPending || Command.CurrentRole != MultiplayerRole.Client || command.SenderId != -1 || + command.Buildings == null || command.Buildings.Count > BatchSize) + return; + Building[] buildings = BuildingManager.instance.m_buildings.m_buffer; + foreach (BuildingStatusState state in command.Buildings) + { + if (state == null || state.BuildingId == 0 || state.BuildingId >= buildings.Length) + continue; + ushort id = state.BuildingId; + if (ClientCache.Accept(state, command.RequestSession, command.HostSession, ref buildings[id])) + { + Apply(id, state, ref buildings[id]); + _receivedStates++; + } + } + } + + public static void Answer(RequestBuildingStatusCommand command) + { + if (!_connected || ResetPending || Command.CurrentRole != MultiplayerRole.Server || Command.SendToClient == null || + string.IsNullOrEmpty(command.Session) || command.Session.Length != 32 || + command.Buildings == null || command.Buildings.Count > BatchSize) + return; + Building[] buildings = BuildingManager.instance.m_buildings.m_buffer; + List replies = new List(); + foreach (BuildingStatusRequest request in command.Buildings) + { + if (request == null || request.BuildingId == 0 || request.BuildingId >= buildings.Length || request.Token == 0 || + !request.Matches(ref buildings[request.BuildingId])) + continue; + BuildingStatusState state = Capture(request.BuildingId, ref buildings[request.BuildingId]); + state.RequestToken = request.Token; + replies.Add(state); + } + if (replies.Count != 0) + Command.SendToClient(command.SenderId, new BuildingStatusCommand + { + HostSession = _hostSession, + RequestSession = command.Session, + Buildings = replies + }); + } + + public static void SimulationStep() + { + if (!_connected || ResetPending) + return; + long now = Stopwatch.GetTimestamp(); + if (_lastFlush != 0 && now - _lastFlush < FlushTicks) + return; + _lastFlush = now; + Building[] buildings = BuildingManager.instance.m_buildings.m_buffer; + int limit = Math.Min(buildings.Length, ushort.MaxValue + 1); + if (limit <= 1) + return; + if (Command.CurrentRole == MultiplayerRole.Server) + { + for (int i = 0; i < SweepSize; i++) + { + if (_cursor >= limit) + _cursor = 1; + Dirty.Add((ushort)_cursor++); + } + List batch = new List(BatchSize); + foreach (ushort id in Dirty) + { + if (id >= limit || !BuildingStatusState.IsLive(ref buildings[id])) + { + _sent[id] = null; + continue; + } + BuildingStatusState state = Capture(id, ref buildings[id]); + if (state.SameAs(_sent[id]) && now - _lastSent[id] < RefreshTicks) + continue; + batch.Add(state); + _sent[id] = state; + _lastSent[id] = now; + if (batch.Count == BatchSize) + { + Send(batch); + batch = new List(BatchSize); + } + } + Dirty.Clear(); + if (batch.Count != 0) + Send(batch); + } + else if (Command.CurrentRole == MultiplayerRole.Client) + { + List batch = new List(BatchSize); + for (int i = 0; i < SweepSize; i++) + { + if (_cursor >= limit) + _cursor = 1; + ushort id = (ushort)_cursor++; + BuildingStatusRequest request = ClientCache.Request(id, ref buildings[id], now, RetryTicks); + if (request == null) + continue; + batch.Add(request); + if (batch.Count == BatchSize) + { + Request(batch); + batch = new List(BatchSize); + } + } + if (batch.Count != 0) + Request(batch); + } + } + + private static void Send(List batch) + { + Command.SendToClients(new BuildingStatusCommand { HostSession = _hostSession, Buildings = batch }); + _sentStates += batch.Count; + } + + private static void Request(List batch) + { + Command.SendToServer(new RequestBuildingStatusCommand { Session = ClientCache.Session, Buildings = batch }); + } + + private static BuildingStatusState Capture(ushort id, ref Building building) + { + CitizenManager citizens = CitizenManager.instance; + Citizen.BehaviourData behaviour = default(Citizen.BehaviourData); + uint unit = building.m_citizenUnits; + int visits = 0; + while (unit != 0) + { + if (unit >= citizens.m_units.m_buffer.Length || visits++ >= citizens.m_units.m_buffer.Length) + { + Log.Warn("Invalid citizen unit chain while reading building health: " + id); + return BuildingStatusState.Capture(id, ref building, null); + } + if ((citizens.m_units.m_buffer[unit].m_flags & CitizenUnit.Flags.Home) != 0) + { + int alive = 0, total = 0; + citizens.m_units.m_buffer[unit].GetCitizenHomeBehaviour(ref behaviour, ref alive, ref total); + } + unit = citizens.m_units.m_buffer[unit].m_nextUnit; + } + return BuildingStatusState.Capture(id, ref building, new BuildingHomeHealth + { + Sick = behaviour.m_sickCount, + TotalSick = behaviour.m_totalSickCount, + SickChildren = behaviour.m_sickChildCount, + SickSeniors = behaviour.m_sickSeniorCount, + Dead = behaviour.m_deadCount + }); + } + } +} diff --git a/src/basegame/Injections/BuildingStatusPatches.cs b/src/basegame/Injections/BuildingStatusPatches.cs new file mode 100644 index 00000000..47b84d29 --- /dev/null +++ b/src/basegame/Injections/BuildingStatusPatches.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using CSM.API.Commands; +using CSM.API.Helpers; +using CSM.BaseGame.Commands.Data.Buildings; +using CSM.BaseGame.Helpers; +using HarmonyLib; + +namespace CSM.BaseGame.Injections +{ + [HarmonyPatch] + public class BuildingSimulationStatusPatch + { + public struct Scope + { + public bool Client; + public bool AbandonmentDisabled; + } + + public static IEnumerable TargetMethods() + { + Type[] signature = { typeof(ushort), typeof(Building).MakeByRefType() }; + foreach (Type type in typeof(BuildingAI).Assembly.GetTypes()) + { + if (!typeof(BuildingAI).IsAssignableFrom(type)) + continue; + MethodInfo method = type.GetMethod("SimulationStep", BindingFlags.Instance | BindingFlags.Public | + BindingFlags.NonPublic | BindingFlags.DeclaredOnly, null, signature, null); + if (method != null && !method.IsAbstract) + yield return method; + } + } + + public static void Prefix(ushort __0, ref Building __1, out Scope __state) + { + __state = default(Scope); + if (Command.CurrentRole != MultiplayerRole.Client) + return; + __state.Client = true; + __state.AbandonmentDisabled = BuildingManager.instance.m_abandonmentDisabled; + BuildingManager.instance.m_abandonmentDisabled = true; + BuildingStatusSync.EnterClientSimulation(); + BuildingStatusSync.Enforce(__0, ref __1); + } + + public static Exception Finalizer(ushort __0, ref Building __1, Scope __state, Exception __exception) + { + if (__state.Client) + { + try + { + BuildingStatusSync.Enforce(__0, ref __1); + } + finally + { + BuildingManager.instance.m_abandonmentDisabled = __state.AbandonmentDisabled; + BuildingStatusSync.ExitClientSimulation(); + } + } + else if (__exception == null) + BuildingStatusSync.MarkDirty(__0); + return __exception; + } + } + + [HarmonyPatch(typeof(CommonBuildingAI), "HandleFire")] + public class HostFireSimulationPatch + { + public static bool Prefix(ushort buildingID, ref Building data, ref Building.Frame frameData) + { + if (Command.CurrentRole != MultiplayerRole.Client) + return true; + BuildingStatusState state = BuildingStatusSync.GetClientState(buildingID, ref data); + if (state != null) + { + frameData.m_fireDamage = state.FireDamage; + if ((state.Conditions & (uint)Building.Flags.Collapsed) != 0) + frameData.m_constructState = state.ConstructState; + } + return false; + } + + public static void Postfix(ushort buildingID, ref Building data) + { + if (data.m_fireIntensity != 0 || data.GetLastFrameData().m_fireDamage != 0) + BuildingStatusSync.MarkFamilyDirty(buildingID); + } + } + + [HarmonyPatch(typeof(CommonBuildingAI), "BurnBuilding")] + public class HostIgnitionPatch + { + public static bool Prefix(bool testOnly, ref bool __result) + { + if (Command.CurrentRole != MultiplayerRole.Client || testOnly) + return true; + __result = false; + return false; + } + + public static void Postfix(ushort buildingID, bool testOnly, bool __result) + { + if (__result && !testOnly) + BuildingStatusSync.MarkFamilyDirty(buildingID); + } + } + + [HarmonyPatch] + public class HostExtinguishingPatch + { + public static IEnumerable TargetMethods() + { + Type[] signature = { typeof(ushort), typeof(Vehicle).MakeByRefType(), typeof(ushort), typeof(Building).MakeByRefType() }; + yield return AccessTools.DeclaredMethod(typeof(FireTruckAI), "ExtinguishFire", signature); + yield return AccessTools.DeclaredMethod(typeof(FireCopterAI), "ExtinguishFire", signature); + } + + public static bool Prefix(ref Building buildingData, ref bool __result) + { + if (Command.CurrentRole != MultiplayerRole.Client) + return true; + // Let local vehicles finish their task when the host ends the fire. + __result = buildingData.m_fireIntensity == 0; + return false; + } + + public static void Postfix(ushort buildingID) + { + BuildingStatusSync.MarkFamilyDirty(buildingID); + } + } + + [HarmonyPatch(typeof(CommonBuildingAI), "HandleSick")] + public class HostBuildingSicknessPatch + { + public static bool Prefix() + { + return Command.CurrentRole != MultiplayerRole.Client; + } + } + + [HarmonyPatch(typeof(CommonBuildingAI), "GetHomeBehaviour")] + public class HostHomeHealthPatch + { + public static void Postfix(ushort buildingID, ref Building buildingData, ref Citizen.BehaviourData behaviour, int aliveCount) + { + BuildingStatusState state = BuildingStatusSync.GetClientState(buildingID, ref buildingData); + if (state != null) + state.ApplyHomeHealth(ref behaviour, aliveCount); + } + } + + [HarmonyPatch(typeof(BuildingManager), "ReleaseBuilding", new[] { typeof(ushort) })] + public class HostSimulationReleasePatch + { + public static bool AllowSimulationRelease(Building.Flags flags) + { + // CommonBuildingAI and DecorationBuildingAI finish an explicit demolition in their simulation wrapper. + return (flags & Building.Flags.Demolishing) != 0; + } + + public static bool Prefix(BuildingManager __instance, ushort building) + { + if (Command.CurrentRole != MultiplayerRole.Client || !BuildingStatusSync.InClientSimulation) + return true; + return building != 0 && building < __instance.m_buildings.m_buffer.Length && + AllowSimulationRelease(__instance.m_buildings.m_buffer[building].m_flags); + } + } + + [HarmonyPatch(typeof(CommonBuildingAI), "CollapseBuilding")] + public class HostSimulationCollapsePatch + { + public static bool Prefix(bool testOnly, ref bool __result) + { + if (Command.CurrentRole != MultiplayerRole.Client || !BuildingStatusSync.InClientSimulation || testOnly) + return true; + __result = false; + return false; + } + + public static void Postfix(ushort buildingID, bool testOnly, bool __result) + { + if (__result && !testOnly) + BuildingStatusSync.MarkFamilyDirty(buildingID); + } + } + + [HarmonyPatch(typeof(BuildingManager), "CreateBuilding")] + public class BuildingStatusCreatePatch + { + public static void Postfix(bool __result, ushort building) + { + if (__result) + BuildingStatusSync.Invalidate(building); + } + } + + [HarmonyPatch(typeof(BuildingManager), "ReleaseBuildingImplementation")] + public class BuildingStatusReleasePatch + { + public static void Prefix(ushort building) + { + BuildingStatusSync.Invalidate(building); + } + } + + [HarmonyPatch(typeof(BuildingManager), "RelocateBuildingImpl")] + public class BuildingStatusRelocatePatch + { + public static void Postfix(ushort building) + { + BuildingStatusSync.Invalidate(building); + } + } + + [HarmonyPatch(typeof(BuildingManager), "UpdateBuildingInfo", new[] { typeof(ushort), typeof(BuildingInfo) })] + public class BuildingStatusInfoPatch + { + public static void Postfix(ushort building) + { + BuildingStatusSync.Invalidate(building); + } + } + + [HarmonyPatch] + public class BuildingStatusRebuildPatch + { + public static IEnumerable TargetMethods() + { + return OnRebuildClicked.TargetMethods(); + } + + public static void Postfix(object __instance) + { + BuildingStatusSync.Invalidate(ReflectionHelper.GetAttr(__instance, "buildingID")); + } + } +} diff --git a/src/csm/Commands/CommandInternal.cs b/src/csm/Commands/CommandInternal.cs index ae85405a..a4769856 100644 --- a/src/csm/Commands/CommandInternal.cs +++ b/src/csm/Commands/CommandInternal.cs @@ -277,6 +277,13 @@ public void RefreshModel() public CommandInternal() { Command.ConnectToCSM(this.SendToAll, this.SendToServer, this.SendToClients, this.GetCommandHandler); + Command.SendToClient = (id, command) => + { + if (MultiplayerManager.Instance.CurrentRole == MultiplayerRole.Server && + MultiplayerManager.Instance.CurrentServer.ConnectedPlayers.TryGetValue(id, out CSMPlayer player) && + player.Status == ClientStatus.Connected) + SendToClient(player, command); + }; } } } diff --git a/src/csm/Commands/Data/Internal/ConnectionRequestCommand.cs b/src/csm/Commands/Data/Internal/ConnectionRequestCommand.cs index dbdf8632..020016b3 100644 --- a/src/csm/Commands/Data/Internal/ConnectionRequestCommand.cs +++ b/src/csm/Commands/Data/Internal/ConnectionRequestCommand.cs @@ -66,5 +66,8 @@ public class ConnectionRequestCommand : CommandBase /// [ProtoMember(8)] public List Mods { get; set; } + + [ProtoMember(9)] + public int BuildingStatusProtocol { get; set; } } } diff --git a/src/csm/Commands/Data/Internal/ConnectionResultCommand.cs b/src/csm/Commands/Data/Internal/ConnectionResultCommand.cs index d8159e75..b9409762 100644 --- a/src/csm/Commands/Data/Internal/ConnectionResultCommand.cs +++ b/src/csm/Commands/Data/Internal/ConnectionResultCommand.cs @@ -60,5 +60,8 @@ public class ConnectionResultCommand : CommandBase /// [ProtoMember(7)] public string ServerToken { get; set; } + + [ProtoMember(8)] + public int BuildingStatusProtocol { get; set; } } } diff --git a/src/csm/Commands/Handler/Internal/ConnectionRequestHandler.cs b/src/csm/Commands/Handler/Internal/ConnectionRequestHandler.cs index 98fae33f..7c6f8229 100644 --- a/src/csm/Commands/Handler/Internal/ConnectionRequestHandler.cs +++ b/src/csm/Commands/Handler/Internal/ConnectionRequestHandler.cs @@ -8,6 +8,7 @@ using CSM.API.Commands; using CSM.API.Networking; using CSM.API.Networking.Status; +using CSM.BaseGame.Helpers; using CSM.Commands.Data.Internal; using CSM.Helpers; using CSM.Mods; @@ -34,6 +35,16 @@ public void HandleOnServer(ConnectionRequestCommand command, NetPeer peer) { Log.Info("Received connection request."); + if (command.BuildingStatusProtocol != BuildingStatusSync.ProtocolVersion) + { + CommandInternal.Instance.SendToClient(peer, new ConnectionResultCommand + { + Success = false, + Reason = "Building synchronization versions differ. Install matching CSM builds on all players." + }); + return; + } + string joiningVersion = command.GameVersion; string appVersion = BuildConfig.applicationVersion; @@ -162,7 +173,8 @@ public void HandleOnServer(ConnectionRequestCommand command, NetPeer peer) { Success = true, ClientId = peer.Id, - ServerToken = MultiplayerManager.Instance.CurrentServer.ServerToken + ServerToken = MultiplayerManager.Instance.CurrentServer.ServerToken, + BuildingStatusProtocol = BuildingStatusSync.ProtocolVersion }); PrepareWorldLoad(newPlayer); diff --git a/src/csm/Commands/Handler/Internal/ConnectionResultHandler.cs b/src/csm/Commands/Handler/Internal/ConnectionResultHandler.cs index 1b44fa16..bc21a8b3 100644 --- a/src/csm/Commands/Handler/Internal/ConnectionResultHandler.cs +++ b/src/csm/Commands/Handler/Internal/ConnectionResultHandler.cs @@ -5,6 +5,7 @@ using CSM.API.Commands; using CSM.API.Networking; using CSM.API.Networking.Status; +using CSM.BaseGame.Helpers; using CSM.Commands.Data.Internal; using CSM.Helpers; using CSM.Mods; @@ -26,6 +27,12 @@ protected override void Handle(ConnectionResultCommand command) if (MultiplayerManager.Instance.CurrentClient.Status != ClientStatus.Connecting) return; + if (command.Success && command.BuildingStatusProtocol != BuildingStatusSync.ProtocolVersion) + { + command.Success = false; + command.Reason = "Building synchronization versions differ. Install matching CSM builds on all players."; + } + // If we are allowed to connect if (command.Success) { diff --git a/src/csm/Extensions/LoadingExtension.cs b/src/csm/Extensions/LoadingExtension.cs index 96d5be1b..9a84f7be 100644 --- a/src/csm/Extensions/LoadingExtension.cs +++ b/src/csm/Extensions/LoadingExtension.cs @@ -4,6 +4,7 @@ using ColossalFramework.UI; using CSM.API.Commands; using CSM.API.Networking.Status; +using CSM.BaseGame.Helpers; using CSM.Commands; using CSM.Commands.Data.Internal; using CSM.Commands.Handler.Game; @@ -74,6 +75,8 @@ public override void OnLevelLoaded(LoadMode mode) private void ResetData() { + BuildingStatusSync.Reset(); + // Pause simulation on game load and reset cached speed and pause states SpeedPauseHelper.ResetSpeedAndPauseState(); @@ -92,6 +95,7 @@ public override void OnLevelUnloading() base.OnLevelUnloading(); ModSupport.Instance.OnLevelUnloading(); + BuildingStatusSync.Reset(); //Code below destroys any created UI from the screen. try diff --git a/src/csm/Extensions/ThreadingExtension.cs b/src/csm/Extensions/ThreadingExtension.cs index 3c28a1b8..bce7411a 100644 --- a/src/csm/Extensions/ThreadingExtension.cs +++ b/src/csm/Extensions/ThreadingExtension.cs @@ -4,6 +4,7 @@ using CSM.API.Commands; using CSM.API.Helpers; using CSM.API.Networking.Status; +using CSM.BaseGame.Helpers; using CSM.BaseGame.Injections; using CSM.Commands; using CSM.Helpers; @@ -16,6 +17,26 @@ public class ThreadingExtension : ThreadingExtensionBase { private DateTime _lastEconomyAndDropSync; + private static void RefreshBuildingStatusConnection() + { + MultiplayerManager manager = MultiplayerManager.Instance; + bool connected = manager.IsConnected(); + if (manager.CurrentRole == MultiplayerRole.Server) + { + connected = false; + foreach (CSMPlayer player in manager.CurrentServer.ConnectedPlayers.Values) + { + if (player.Status != ClientStatus.Connected) + continue; + + connected = true; + break; + } + } + + BuildingStatusSync.UpdateConnection(manager.CurrentRole, connected); + } + public override void OnCreated(IThreading threading) { Singleton.Ensure(); @@ -24,6 +45,7 @@ public override void OnCreated(IThreading threading) public override void OnBeforeSimulationTick() { base.OnBeforeSimulationTick(); + RefreshBuildingStatusConnection(); // Normally, the game is paused when the player is in Esc or similar menus. We ignore this setting. if (SimulationManager.instance.ForcedSimulationPaused && MultiplayerManager.Instance.CurrentRole != MultiplayerRole.None) @@ -60,6 +82,9 @@ public override void OnAfterSimulationTick() _lastEconomyAndDropSync = DateTime.Now; } + RefreshBuildingStatusConnection(); + BuildingStatusSync.SimulationStep(); + // Finish transactions TransactionHandler.FinishSend(); diff --git a/src/csm/Networking/Client.cs b/src/csm/Networking/Client.cs index 0451d127..627f0b8b 100644 --- a/src/csm/Networking/Client.cs +++ b/src/csm/Networking/Client.cs @@ -422,7 +422,8 @@ private void ListenerOnPeerConnectedEvent(NetPeer peer) Username = Config.Username, ExpansionBitMask = DLCHelper.GetOwnedExpansions(), ModderPackBitMask = DLCHelper.GetOwnedModderPacks(), - Mods = ModSupport.Instance.RequiredModsForSync + Mods = ModSupport.Instance.RequiredModsForSync, + BuildingStatusProtocol = BuildingStatusSync.ProtocolVersion }; Log.Info("Sending connection request to server..."); diff --git a/src/csm/Panels/ChatLogPanel.cs b/src/csm/Panels/ChatLogPanel.cs index a3e8e9ec..e2b4fc88 100644 --- a/src/csm/Panels/ChatLogPanel.cs +++ b/src/csm/Panels/ChatLogPanel.cs @@ -128,6 +128,10 @@ public ChatLogPanel() { Process.Start(Log.Instance.CurrentLogFile); }), + new ChatCommand("building-sync", "Shows local building synchronization progress.", (command) => + { + PrintGameMessage(global::CSM.BaseGame.Helpers.BuildingStatusSync.Diagnostics()); + }), new ChatCommand("sync", "Redownloads the entire save", (command) => { if (MultiplayerManager.Instance.CurrentRole == MultiplayerRole.Client) diff --git a/tests/BuildingStatusSync.Tests.cs b/tests/BuildingStatusSync.Tests.cs new file mode 100644 index 00000000..98be1c1d --- /dev/null +++ b/tests/BuildingStatusSync.Tests.cs @@ -0,0 +1,440 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CSM.API.Commands; +using CSM.BaseGame.Commands.Data.Buildings; +using CSM.BaseGame.Commands.Handler.Buildings; +using CSM.BaseGame.Helpers; +using CSM.BaseGame.Injections; +using CSM.Commands.Data.Internal; +using CSM.Models; +using ProtoBuf.Meta; +using UnityEngine; + +internal static class BuildingStatusSyncTests +{ + private static int _passed; + private const ushort Id = 42; + private static readonly string Host = new string('a', 32); + + private static void Check(bool result, string message) + { + if (!result) + throw new Exception(message); + } + + private static void Run(string name, Action action) + { + action(); + Console.WriteLine("PASS " + name); + _passed++; + } + + private static Building CreateBuilding() + { + return new Building + { + m_flags = Building.Flags.Created | Building.Flags.Active | Building.Flags.Completed, + m_buildIndex = 70, + m_infoIndex = 3, + m_position = new Vector3(10, 12, 14), + m_angle = 90, + m_width = 2, + m_length = 4, + m_frame0 = new Building.Frame { m_constructState = 255 } + }; + } + + private static BuildingStatusState State(ref Building building, ulong token = 0) + { + BuildingStatusState state = BuildingStatusState.Capture(Id, ref building, new BuildingHomeHealth()); + state.HostBuildIndex = 7000; + state.RequestToken = token; + return state; + } + + private static RuntimeTypeModel Model() + { + RuntimeTypeModel model = RuntimeTypeModel.Create(); + model[typeof(Vector3)].SetSurrogate(typeof(Vector3Surrogate)); + model.Add(typeof(CommandBase), true); + model[typeof(CommandBase)].AddSubType(100, typeof(BuildingStatusCommand)); + model[typeof(CommandBase)].AddSubType(101, typeof(RequestBuildingStatusCommand)); + model[typeof(CommandBase)].AddSubType(102, typeof(ConnectionRequestCommand)); + model[typeof(CommandBase)].AddSubType(103, typeof(ConnectionResultCommand)); + model.Add(typeof(BuildingStatusCommand), true); + model.Add(typeof(RequestBuildingStatusCommand), true); + model.Add(typeof(ConnectionRequestCommand), true); + model.Add(typeof(ConnectionResultCommand), true); + model.CompileInPlace(); + return model; + } + + private static T RoundTrip(RuntimeTypeModel model, T value) where T : CommandBase + { + using (MemoryStream stream = new MemoryStream()) + { + model.Serialize(stream, value); + stream.Position = 0; + return (T)model.Deserialize(stream, null, typeof(CommandBase)); + } + } + + private static BuildingStatusState Bind(BuildingStatusCache cache, ref Building building) + { + BuildingStatusRequest request = cache.Request(Id, ref building, 100, 20); + BuildingStatusState state = State(ref building, request.Token); + Check(cache.Accept(state, cache.Session, Host, ref building), "baseline was rejected"); + return state; + } + + private static void VerifyArguments(Type patch, MethodBase target) + { + Check(target != null, patch.Name + " has a missing game target"); + foreach (MethodInfo hook in patch.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == "Prefix" || m.Name == "Postfix" || m.Name == "Finalizer")) + { + foreach (ParameterInfo parameter in hook.GetParameters()) + { + if (parameter.Name.StartsWith("__")) + continue; + ParameterInfo original = target.GetParameters().FirstOrDefault(p => p.Name == parameter.Name); + Check(original != null, patch.Name + " references missing argument " + parameter.Name); + Type parameterType = parameter.ParameterType.IsByRef ? parameter.ParameterType.GetElementType() : parameter.ParameterType; + Type originalType = original.ParameterType.IsByRef ? original.ParameterType.GetElementType() : original.ParameterType; + Check(parameterType == originalType, patch.Name + " argument type mismatch: " + parameter.Name); + } + } + } + + private static void PacketRoundTrip() + { + Building b = CreateBuilding(); + BuildingStatusState state = State(ref b, 9); + state.Health = 80; + state.ChildHealth = 90; + state.SeniorHealth = 60; + state.Problems1 = (1UL << 63) | (ulong)Notification.Problem1.Fire; + state.Problems2 = (1UL << 63) | 5; + state.HomeHealth = new BuildingHomeHealth { Sick = 3, TotalSick = 5, SickChildren = 2, SickSeniors = 1 }; + RuntimeTypeModel model = Model(); + BuildingStatusCommand decoded = RoundTrip(model, new BuildingStatusCommand + { + SenderId = -1, + HostSession = Host, + RequestSession = Host, + Buildings = new List { state } + }); + Check(decoded.SenderId == -1 && decoded.Buildings[0].SameAs(state), "state changed over wire"); + Check(decoded.Buildings[0].RequestToken == 9 && decoded.Buildings[0].Position.Equals(b.m_position), "identity lost"); + state.Problems1 = state.Problems2 = 0; + state.HomeHealth = new BuildingHomeHealth(); + decoded = RoundTrip(model, new BuildingStatusCommand { HostSession = Host, Buildings = new List { state } }); + Check(decoded.Buildings[0].Problems1 == 0 && decoded.Buildings[0].Problems2 == 0 && decoded.Buildings[0].HomeHealth.Sick == 0, + "default-valued clears were not transmitted"); + } + + private static void FireRecovery() + { + Building b = CreateBuilding(); + b.m_problems = new Notification.ProblemStruct(Notification.Problem1.Fire | Notification.Problem1.MajorProblem, + (Notification.Problem2)4); + b.m_fireIntensity = 50; + b.m_frame0.m_fireDamage = b.m_frame1.m_fireDamage = b.m_frame2.m_fireDamage = b.m_frame3.m_fireDamage = 200; + Building host = CreateBuilding(); + BuildingStatusState repaired = State(ref host); + repaired.ApplyFields(ref b); + Check(b.m_problems.IsNone && b.m_fireIntensity == 0 && b.m_frame0.m_fireDamage == 0 && + b.m_frame1.m_fireDamage == 0 && b.m_frame2.m_fireDamage == 0 && b.m_frame3.m_fireDamage == 0, + "a client frame retained fire or a warning"); + } + + private static void PreserveUnrelatedFields() + { + Building b = CreateBuilding(); + b.m_flags |= Building.Flags.Historical | Building.Flags.CustomName | Building.Flags.Incoming; + b.m_citizenUnits = 1234; + b.m_guestVehicles = 5; + b.m_ownVehicles = 6; + b.m_subBuilding = 10; + b.m_productionRate = 75; + b.m_frame0.m_constructState = 123; + Building host = CreateBuilding(); + BuildingStatusState state = State(ref host); + state.ConstructState = 255; + state.ApplyFields(ref b); + Check((b.m_flags & (Building.Flags.Historical | Building.Flags.CustomName | Building.Flags.Incoming)) == + (Building.Flags.Historical | Building.Flags.CustomName | Building.Flags.Incoming), "settings replaced"); + Check(b.m_citizenUnits == 1234 && b.m_guestVehicles == 5 && b.m_ownVehicles == 6 && b.m_subBuilding == 10 && + b.m_buildIndex == 70 && b.m_productionRate == 75 && b.m_frame0.m_constructState == 123, + "unrelated game state overwritten"); + } + + private static void CollapseAndRebuild() + { + Building b = CreateBuilding(); + Building host = CreateBuilding(); + host.m_flags |= Building.Flags.Collapsed; + host.m_frame0.m_constructState = 0; + host.m_frame0.m_fireDamage = 255; + State(ref host).ApplyFields(ref b); + Check((b.m_flags & Building.Flags.Collapsed) != 0 && b.m_frame3.m_constructState == 0, "collapse history not applied"); + host = CreateBuilding(); + State(ref host).ApplyFields(ref b); + Check((b.m_flags & Building.Flags.Collapsed) == 0 && b.m_frame3.m_constructState == 255 && b.m_frame3.m_fireDamage == 0, + "rebuild retained collapse damage"); + } + + private static void HomeHealthRecovery() + { + Building b = CreateBuilding(); + BuildingStatusState state = State(ref b); + state.Health = 70; + state.ChildHealth = 80; + state.SeniorHealth = 60; + state.HomeHealth = new BuildingHomeHealth { Sick = 2, TotalSick = 3, SickChildren = 1, SickSeniors = 1, Dead = 1 }; + Citizen.BehaviourData behaviour = new Citizen.BehaviourData + { + m_sickCount = 9, + m_totalSickCount = 10, + m_childCount = 1, + m_teenCount = 2, + m_seniorCount = 3, + m_incomeAccumulation = 500, + m_waterConsumption = 7 + }; + state.ApplyHomeHealth(ref behaviour, 10); + Check(behaviour.m_sickCount == 2 && behaviour.m_totalSickCount == 3 && behaviour.m_deadCount == 1 && + behaviour.m_healthAccumulation == 700 && behaviour.m_childHealthAccumulation == 240 && behaviour.m_seniorHealthAccumulation == 180, + "host health summary did not replace local sickness"); + Check(behaviour.m_childCount == 1 && behaviour.m_teenCount == 2 && behaviour.m_seniorCount == 3 && + behaviour.m_incomeAccumulation == 500 && behaviour.m_waterConsumption == 7, "population or economy aggregates replaced"); + state.HomeHealth = new BuildingHomeHealth(); + state.ApplyHomeHealth(ref behaviour, 10); + Check(behaviour.m_sickCount == 0 && behaviour.m_totalSickCount == 0 && behaviour.m_deadCount == 0, "recovery left stale sickness"); + } + + private static void BaselineBuildIndices() + { + Building b = CreateBuilding(); + BuildingStatusCache cache = new BuildingStatusCache(); + Bind(cache, ref b); + Check(cache.Get(Id, ref b).HostBuildIndex == 7000 && b.m_buildIndex == 70 && cache.BoundCount == 1 && cache.PendingCount == 0, + "binding assumed equal build counters"); + } + + private static void RejectStaleBroadcasts() + { + Building b = CreateBuilding(); + BuildingStatusCache cache = new BuildingStatusCache(); + cache.Seed(Id, ref b); + Check(!cache.Accept(State(ref b), null, Host, ref b), "unsolicited status bound a building"); + Bind(cache, ref b); + BuildingStatusState stale = State(ref b); + stale.HostBuildIndex++; + Check(!cache.Accept(stale, null, Host, ref b), "different host lifetime accepted"); + stale.HostBuildIndex--; + Check(cache.Accept(stale, null, Host, ref b), "valid delta rejected"); + } + + private static void BuildingIdReuse() + { + Building b = CreateBuilding(); + BuildingStatusCache cache = new BuildingStatusCache(); + BuildingStatusRequest old = cache.Request(Id, ref b, 100, 20); + cache.Invalidate(Id); + BuildingStatusRequest next = cache.Request(Id, ref b, 101, 20); + Check(next.Token != old.Token && cache.PendingCount == 1, "reuse retained request identity"); + Check(!cache.Accept(State(ref b, old.Token), cache.Session, Host, ref b), "old reply attached to reused slot"); + Check(cache.Accept(State(ref b, next.Token), cache.Session, Host, ref b), "new lifetime failed to bind"); + } + + private static void ReloadSession() + { + Building b = CreateBuilding(); + BuildingStatusCache cache = new BuildingStatusCache(); + string oldSession = cache.Session; + BuildingStatusRequest old = cache.Request(Id, ref b, 100, 20); + cache.Reset(); + BuildingStatusRequest next = cache.Request(Id, ref b, 100, 20); + Check(old.Token == next.Token && oldSession != cache.Session, "test did not exercise token reuse"); + Check(!cache.Accept(State(ref b, old.Token), oldSession, Host, ref b), "old save reply accepted"); + Check(cache.Accept(State(ref b, next.Token), cache.Session, Host, ref b), "new save baseline rejected"); + } + + private static void RejectMalformedState() + { + Building b = CreateBuilding(); + BuildingStatusCache cache = new BuildingStatusCache(); + Bind(cache, ref b); + BuildingStatusState state = State(ref b); + b.m_buildIndex++; + Check(!cache.Accept(state, null, Host, ref b), "local reuse was not detected"); + b.m_buildIndex--; + state.Position = new Vector3(11, 12, 14); + Check(!cache.Accept(state, null, Host, ref b), "wrong location accepted"); + state = State(ref b); + state.ProblemTimers = new byte[1]; + Check(!cache.Accept(state, null, Host, ref b), "truncated timers accepted"); + state = State(ref b); + state.Conditions |= (uint)Building.Flags.Deleted; + Check(!cache.Accept(state, null, Host, ref b), "structural flag injection accepted"); + b.m_flags |= Building.Flags.Deleted; + Check(cache.Get(Id, ref b) == null, "deleted building retained live status"); + } + + private static void BaselineRetry() + { + Building b = CreateBuilding(); + BuildingStatusCache cache = new BuildingStatusCache(); + BuildingStatusRequest first = cache.Request(Id, ref b, 0, 100); + Check(cache.Request(Id, ref b, 99, 100) == null, "retry too early"); + Check(cache.Request(Id, ref b, 100, 100).Token == first.Token && cache.PendingCount == 1, "retry lost nonce/accounting"); + BuildingStatusState sick = State(ref b); + sick.HomeHealth.Sick = sick.HomeHealth.TotalSick = 1; + Check(!sick.SameAs(State(ref b)), "recovery did not cause a delta"); + sick.Problems2 = 1; + Check(!sick.SameAs(State(ref b)), "secondary warning did not cause a delta"); + } + + private static void HostFireAuthority() + { + Building b = CreateBuilding(); + Building.Frame frame = b.GetLastFrameData(); + bool result = false; + foreach (MultiplayerRole role in new[] { MultiplayerRole.None, MultiplayerRole.Server }) + { + Command.CurrentRole = role; + Check(HostFireSimulationPatch.Prefix(Id, ref b, ref frame) && HostIgnitionPatch.Prefix(false, ref result) && + HostExtinguishingPatch.Prefix(ref b, ref result), "host/single-player fire blocked"); + } + Command.CurrentRole = MultiplayerRole.Client; + Check(!HostFireSimulationPatch.Prefix(Id, ref b, ref frame) && !HostIgnitionPatch.Prefix(false, ref result), "client can ignite/progress"); + Check(HostIgnitionPatch.Prefix(true, ref result), "test-only ignition query blocked"); + b.m_fireIntensity = 80; + Check(!HostExtinguishingPatch.Prefix(ref b, ref result) && !result && b.m_fireIntensity == 80, + "client extinguished host fire"); + b.m_fireIntensity = 0; + Check(!HostExtinguishingPatch.Prefix(ref b, ref result) && result, "local truck cannot finish after host clear"); + Command.CurrentRole = MultiplayerRole.None; + } + + private static void PreserveExplicitDemolition() + { + Command.CurrentRole = MultiplayerRole.Client; + bool result = true; + Check(HostSimulationReleasePatch.Prefix(null, Id), "tool release blocked outside simulation"); + BuildingStatusSync.EnterClientSimulation(); + try + { + Check(!HostSimulationReleasePatch.AllowSimulationRelease(Building.Flags.Created), "client simulation can delete host building"); + Check(HostSimulationReleasePatch.AllowSimulationRelease(Building.Flags.Created | Building.Flags.Demolishing), + "explicit demolition cannot finish inside simulation"); + Check(!HostSimulationCollapsePatch.Prefix(false, ref result) && !result, "client can collapse independently"); + Check(HostSimulationCollapsePatch.Prefix(true, ref result), "test-only collapse query blocked"); + } + finally { BuildingStatusSync.ExitClientSimulation(); Command.CurrentRole = MultiplayerRole.None; } + } + + private static void PatchTargetMetadata() + { + List wrappers = BuildingSimulationStatusPatch.TargetMethods().ToList(); + Check(wrappers.Count >= 4 && wrappers.Distinct().Count() == wrappers.Count, "simulation targets missing/duplicated"); + foreach (MethodBase m in wrappers) + VerifyArguments(typeof(BuildingSimulationStatusPatch), m); + foreach (MethodBase m in HostExtinguishingPatch.TargetMethods()) + VerifyArguments(typeof(HostExtinguishingPatch), m); + foreach (MethodBase m in BuildingStatusRebuildPatch.TargetMethods()) + { + VerifyArguments(typeof(BuildingStatusRebuildPatch), m); + Check(m.DeclaringType.GetField("buildingID", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) != null, + "rebuild identity field missing"); + } + VerifyArguments(typeof(HostFireSimulationPatch), typeof(CommonBuildingAI).GetMethod("HandleFire", BindingFlags.Instance | BindingFlags.NonPublic)); + VerifyArguments(typeof(HostHomeHealthPatch), typeof(CommonBuildingAI).GetMethod("GetHomeBehaviour", BindingFlags.Instance | BindingFlags.NonPublic)); + VerifyArguments(typeof(HostIgnitionPatch), typeof(CommonBuildingAI).GetMethod("BurnBuilding")); + VerifyArguments(typeof(HostSimulationCollapsePatch), typeof(CommonBuildingAI).GetMethod("CollapseBuilding")); + VerifyArguments(typeof(BuildingStatusCreatePatch), typeof(BuildingManager).GetMethod("CreateBuilding")); + VerifyArguments(typeof(BuildingStatusReleasePatch), typeof(BuildingManager).GetMethod("ReleaseBuildingImplementation", BindingFlags.Instance | BindingFlags.NonPublic)); + VerifyArguments(typeof(BuildingStatusRelocatePatch), typeof(BuildingManager).GetMethod("RelocateBuildingImpl", BindingFlags.Instance | BindingFlags.NonPublic)); + VerifyArguments(typeof(BuildingStatusInfoPatch), typeof(BuildingManager).GetMethod("UpdateBuildingInfo", new[] { typeof(ushort), typeof(BuildingInfo) })); + Console.WriteLine("Verified " + wrappers.Count + " simulation wrappers and rebuild/fire metadata."); + } + + private static void PacketBatchSize() + { + Check(!new BuildingStatusHandler().RelayOnServer && !new RequestBuildingStatusHandler().RelayOnServer, + "a client can relay status authority"); + Building b = CreateBuilding(); + List batch = new List(); + for (int i = 1; i <= BuildingStatusSync.BatchSize; i++) + { + BuildingStatusState state = State(ref b, ulong.MaxValue); + state.BuildingId = (ushort)i; + state.Problems1 = state.Problems2 = ulong.MaxValue; + state.HomeHealth = new BuildingHomeHealth + { + Sick = int.MaxValue, + TotalSick = int.MaxValue, + SickChildren = int.MaxValue, + SickSeniors = int.MaxValue, + Dead = int.MaxValue + }; + state.ProblemTimers = Enumerable.Repeat((byte)255, BuildingStatusState.TimerCount).ToArray(); + batch.Add(state); + } + using (MemoryStream stream = new MemoryStream()) + { + Model().Serialize(stream, new BuildingStatusCommand { SenderId = -1, HostSession = Host, RequestSession = Host, Buildings = batch }); + Check(stream.Length < 16384, "full packet exceeds bounded test size"); + Console.WriteLine("Full 64-building test packet: " + stream.Length + " bytes."); + } + } + + private static void HandshakeProtocol() + { + RuntimeTypeModel model = Model(); + Check(RoundTrip(model, new ConnectionRequestCommand()).BuildingStatusProtocol == 0 && + RoundTrip(model, new ConnectionResultCommand()).BuildingStatusProtocol == 0, "old payload appears compatible"); + Check(RoundTrip(model, new ConnectionRequestCommand { BuildingStatusProtocol = BuildingStatusSync.ProtocolVersion }).BuildingStatusProtocol == + BuildingStatusSync.ProtocolVersion, "client capability lost"); + Check(RoundTrip(model, new ConnectionResultCommand { BuildingStatusProtocol = BuildingStatusSync.ProtocolVersion }).BuildingStatusProtocol == + BuildingStatusSync.ProtocolVersion, "host capability lost"); + } + + public static int Main() + { + try + { + Run("wire round-trip preserves full warning masks, zero clears, health and identity", PacketRoundTrip); + Run("a repaired fire clears every interpolation frame and both warning fields", FireRecovery); + Run("condition updates preserve structure, player settings and local population/vehicle links", PreserveUnrelatedFields); + Run("collapse and rebuild update construction history without retaining a collapsed frame", CollapseAndRebuild); + Run("building sickness/recovery follows host summaries without changing unrelated population aggregates", HomeHealthRecovery); + Run("baseline binds different local and host build counters without changing local identity", BaselineBuildIndices); + Run("broadcasts cannot bind an unknown building and stale host lifetimes cannot overwrite a binding", RejectStaleBroadcasts); + Run("ID reuse at the same position and prefab rejects an old baseline token", BuildingIdReuse); + Run("a save reload rejects late replies even when token numbers restart", ReloadSession); + Run("local identity, moved geometry, malformed timers and unknown condition flags are rejected", RejectMalformedState); + Run("baseline retry is throttled and zero sickness counts cause a delta", BaselineRetry); + Run("fire authority leaves host/single-player intact and clients cannot extinguish independently", HostFireAuthority); + Run("client simulation cannot delete/collapse a building but explicit tool actions still run", PreserveExplicitDemolition); + Run("all game patch targets exist and named arguments match the original DLL metadata", PatchTargetMetadata); + Run("status packets never relay client authority and a full batch remains bounded", PacketBatchSize); + Run("old handshake payloads default to protocol zero, new ones retain capability", HandshakeProtocol); + Console.WriteLine(_passed + " regression groups passed."); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + ReflectionTypeLoadException load = ex as ReflectionTypeLoadException; + if (load != null) + foreach (string message in load.LoaderExceptions.Select(e => e.Message).Distinct()) + Console.Error.WriteLine(message); + return 1; + } + } +} diff --git a/tests/BuildingStatusSync.Tests.csproj b/tests/BuildingStatusSync.Tests.csproj new file mode 100644 index 00000000..a1ec2f51 --- /dev/null +++ b/tests/BuildingStatusSync.Tests.csproj @@ -0,0 +1,58 @@ + + + + Release + Exe + x64 + false + BuildingStatusSync.Tests + v4.8 + bin/$(Configuration)/ + latest + + + + + + ../assemblies/PopsApiWrapper.dll + + + ../assemblies/Assembly-CSharp-firstpass.dll + + + ../assemblies/Assembly-CSharp.dll + + + ../assemblies/ColossalManaged.dll + + + ../assemblies/ICities.dll + + + ../assemblies/UnityEngine.dll + + + ../assemblies/UnityEngine.UI.dll + + + ../assemblies/protobuf-net.dll + + + $(HarmonyAssemblyPath) + + + CSM.BaseGame + {bfc8de00-f495-4388-9e36-f1471f8ed578} + + + CSM + {63933537-DA87-4026-A44C-382298FBB399} + + + CSM.API + {ab27eacd-b9a9-42bc-bf8a-3b25aabff6ca} + + + + +