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} + + + + +