From a0c58420421d8991f170a59486085efcded265fb Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 17 Sep 2026 21:50:44 +0200 Subject: [PATCH 1/9] fix: Dyson sphere visibility, tutorial/dashboard sync, replicator multiplier, and ILS stutter - Dyson Sphere & Sails: Fix invisible solar sails and sphere components on client reconnect by triggering RequestLoadStar, restoring render masks/buffers, and adding thread safety to status packets. - BuildTool: Prevent IndexOutOfRangeException in UpdateGizmos and GetPrefabDesc via bounds checking and finalizer error suppression. - Dashboard & Tutorials: Persist and sync custom dashboard layouts and tutorial progress across reconnects and planet switches. - Replicator: Synchronize and persist 10x crafting multiplier per player. - Logistics: Add station bounds checks to prevent interstellar ship stuttering. - Saves & Stability: Fix revision 8/9 deserialization checks (SandCount and FightData) to prevent save state resets. --- .gitignore | 1 + NebulaAPI/GameState/IPlayerData.cs | 2 +- NebulaModel/DataStructures/MechaData.cs | 6 +- NebulaModel/DataStructures/PlayerData.cs | 82 +++- .../GameHistoryUnlockTutorialPacket.cs | 13 + .../Packets/Players/PlayerDashboardPacket.cs | 15 + .../PlayerReplicatorMultipliersPacket.cs | 15 + .../Universe/DysonSphereStatusPacket.cs | 6 +- NebulaNetwork/Client.cs | 4 +- .../GameHistoryFeatureKeyProcessor.cs | 46 ++- .../GameHistoryUnlockTutorialProcessor.cs | 34 ++ .../Players/PlayerDashboardProcessor.cs | 53 +++ .../Players/PlayerMechaDataProcessor.cs | 8 +- .../PlayerReplicatorMultipliersProcessor.cs | 35 ++ .../Session/SyncCompleteProcessor.cs | 36 +- .../Universe/DysonSailDataProcessor.cs | 4 +- .../Universe/DysonSphereDataProcessor.cs | 137 +++++-- .../Editor/DysonSphereColorChangeProcessor.cs | 8 +- NebulaNetwork/Server.cs | 4 +- NebulaPatcher/NebulaPatcher.csproj | 4 +- .../Patches/Dynamic/AdvisorLogic_Patch.cs | 45 ++ .../Patches/Dynamic/DESelection_Patch.cs | 16 +- .../Dynamic/DysonBlueprintData_Patch.cs | 4 +- .../Patches/Dynamic/DysonSphereLayer_Patch.cs | 14 +- .../Patches/Dynamic/GameData_Patch.cs | 27 +- .../Patches/Dynamic/GameHistoryData_Patch.cs | 81 +++- .../Patches/Dynamic/GameLogic_Patch.cs | 39 +- .../Patches/Dynamic/GamePrefsData_Patch.cs | 49 ++- .../Dynamic/PlanetModelingManager_Patch.cs | 7 +- .../Patches/Dynamic/UIDashboard_Patch.cs | 113 +++++ .../Patches/Dynamic/UIEscMenu_Patch.cs | 3 +- .../Dynamic/UIReplicatorWindow_Patch.cs | 150 +++++++ .../Patches/Dynamic/UIStarmap_Patch.cs | 9 +- NebulaPatcher/Patches/Misc/Debugging.cs | 2 +- NebulaPatcher/Patches/Misc/Fix_Patches.cs | 136 ++++++- .../StationComponent_Transpiler.cs | 9 +- NebulaWorld/GameStates/GameStatesManager.cs | 385 +++++++++++++++++- NebulaWorld/Planet/PlanetManager.cs | 45 +- NebulaWorld/SaveManager.cs | 16 +- NebulaWorld/Universe/DysonSphereManager.cs | 66 ++- 40 files changed, 1616 insertions(+), 113 deletions(-) create mode 100644 NebulaModel/Packets/GameHistory/GameHistoryUnlockTutorialPacket.cs create mode 100644 NebulaModel/Packets/Players/PlayerDashboardPacket.cs create mode 100644 NebulaModel/Packets/Players/PlayerReplicatorMultipliersPacket.cs create mode 100644 NebulaNetwork/PacketProcessors/GameHistory/GameHistoryUnlockTutorialProcessor.cs create mode 100644 NebulaNetwork/PacketProcessors/Players/PlayerDashboardProcessor.cs create mode 100644 NebulaNetwork/PacketProcessors/Players/PlayerReplicatorMultipliersProcessor.cs create mode 100644 NebulaPatcher/Patches/Dynamic/AdvisorLogic_Patch.cs create mode 100644 NebulaPatcher/Patches/Dynamic/UIDashboard_Patch.cs create mode 100644 NebulaPatcher/Patches/Dynamic/UIReplicatorWindow_Patch.cs diff --git a/.gitignore b/.gitignore index da332978a..7137daae4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ # Nebula-specific files DevEnv.targets dist +.agents/ # User-specific files diff --git a/NebulaAPI/GameState/IPlayerData.cs b/NebulaAPI/GameState/IPlayerData.cs index 07e2359af..a433daa5b 100644 --- a/NebulaAPI/GameState/IPlayerData.cs +++ b/NebulaAPI/GameState/IPlayerData.cs @@ -1,4 +1,4 @@ -#region +#region using NebulaAPI.DataStructures; using NebulaAPI.Interfaces; diff --git a/NebulaModel/DataStructures/MechaData.cs b/NebulaModel/DataStructures/MechaData.cs index c82ed1a6b..a2a6a47d1 100644 --- a/NebulaModel/DataStructures/MechaData.cs +++ b/NebulaModel/DataStructures/MechaData.cs @@ -1,4 +1,4 @@ -#region +#region using System.IO; using NebulaAPI.DataStructures; @@ -17,6 +17,7 @@ public MechaData() { // This is needed for the serialization and deserialization Forge = new MechaForge { tasks = [] }; + FightData = new MechaFightData(); TechBonuses = new PlayerTechBonuses(); } @@ -133,6 +134,7 @@ public void UpdateMech(Player destination) public void Import(INetDataReader reader, int revision) { TechBonuses = new PlayerTechBonuses(); + FightData = new MechaFightData(); Inventory = new StorageComponent(4); DeliveryPackage = new DeliveryPackage(); DeliveryPackage.Init(); @@ -141,7 +143,7 @@ public void Import(INetDataReader reader, int revision) Forge = new MechaForge { tasks = [], extraItems = new ItemBundle() }; ConstructionModule = new ConstructionModuleComponent(); TechBonuses.Import(reader, revision); - SandCount = reader.GetInt(); + SandCount = revision >= 8 ? reader.GetLong() : reader.GetInt(); CoreEnergy = reader.GetDouble(); ReactorEnergy = reader.GetDouble(); var isPayloadPresent = reader.GetBool(); diff --git a/NebulaModel/DataStructures/PlayerData.cs b/NebulaModel/DataStructures/PlayerData.cs index fea6fab6f..d2455d82a 100644 --- a/NebulaModel/DataStructures/PlayerData.cs +++ b/NebulaModel/DataStructures/PlayerData.cs @@ -1,4 +1,4 @@ -#region +#region using System; using System.IO; @@ -20,6 +20,8 @@ public PlayerData() DIYAppearance = null; DIYItemId = Array.Empty(); DIYItemValue = Array.Empty(); + DashboardData = null; + ReplicatorMultipliersData = null; } public PlayerData(ushort playerId, int localPlanetId, string username = null, Float3 localPlanetPosition = new(), @@ -37,6 +39,8 @@ public PlayerData() DIYAppearance = null; DIYItemId = Array.Empty(); DIYItemValue = Array.Empty(); + DashboardData = null; + ReplicatorMultipliersData = null; } public string Username { get; set; } @@ -52,6 +56,8 @@ public PlayerData() public MechaAppearance DIYAppearance { get; set; } public int[] DIYItemId { get; set; } public int[] DIYItemValue { get; set; } + public byte[] DashboardData { get; set; } + public byte[] ReplicatorMultipliersData { get; set; } public void Serialize(INetDataWriter writer) { @@ -93,6 +99,18 @@ public void Serialize(INetDataWriter writer) writer.Put(DIYItemId[i]); writer.Put(DIYItemValue[i]); } + writer.Put(DashboardData != null); + if (DashboardData != null) + { + writer.Put(DashboardData.Length); + writer.Put(DashboardData); + } + writer.Put(ReplicatorMultipliersData != null); + if (ReplicatorMultipliersData != null) + { + writer.Put(ReplicatorMultipliersData.Length); + writer.Put(ReplicatorMultipliersData); + } } public void Deserialize(INetDataReader reader) @@ -138,11 +156,43 @@ public void Deserialize(INetDataReader reader) DIYItemId[i] = reader.GetInt(); DIYItemValue[i] = reader.GetInt(); } + var isDashboardPresent = reader.GetBool(); + if (isDashboardPresent) + { + var len = reader.GetInt(); + DashboardData = new byte[len]; + reader.GetBytes(DashboardData, len); + } + else + { + DashboardData = null; + } + if (reader.AvailableBytes > 0) + { + var isMultipliersPresent = reader.GetBool(); + if (isMultipliersPresent) + { + var len = reader.GetInt(); + ReplicatorMultipliersData = new byte[len]; + reader.GetBytes(ReplicatorMultipliersData, len); + } + else + { + ReplicatorMultipliersData = null; + } + } + else + { + ReplicatorMultipliersData = null; + } } public IPlayerData CreateCopyWithoutMechaData() { - return new PlayerData(PlayerId, LocalPlanetId, Username, LocalPlanetPosition, UPosition, Rotation, BodyRotation); + var copy = new PlayerData(PlayerId, LocalPlanetId, Username, LocalPlanetPosition, UPosition, Rotation, BodyRotation); + copy.DashboardData = DashboardData; + copy.ReplicatorMultipliersData = ReplicatorMultipliersData; + return copy; } // Backward compatiblity for older versions @@ -208,5 +258,33 @@ public void Import(INetDataReader reader, int revision) DIYItemValue[i] = reader.GetInt(); } } + if (revision >= 9 && reader.AvailableBytes > 0) + { + var isDashboardPresent = reader.GetBool(); + if (isDashboardPresent) + { + var len = reader.GetInt(); + DashboardData = new byte[len]; + reader.GetBytes(DashboardData, len); + } + else + { + DashboardData = null; + } + } + if (revision >= 9 && reader.AvailableBytes > 0) + { + var isMultipliersPresent = reader.GetBool(); + if (isMultipliersPresent) + { + var len = reader.GetInt(); + ReplicatorMultipliersData = new byte[len]; + reader.GetBytes(ReplicatorMultipliersData, len); + } + else + { + ReplicatorMultipliersData = null; + } + } } } diff --git a/NebulaModel/Packets/GameHistory/GameHistoryUnlockTutorialPacket.cs b/NebulaModel/Packets/GameHistory/GameHistoryUnlockTutorialPacket.cs new file mode 100644 index 000000000..ea04cdc27 --- /dev/null +++ b/NebulaModel/Packets/GameHistory/GameHistoryUnlockTutorialPacket.cs @@ -0,0 +1,13 @@ +namespace NebulaModel.Packets.GameHistory; + +public class GameHistoryUnlockTutorialPacket +{ + public GameHistoryUnlockTutorialPacket() { } + + public GameHistoryUnlockTutorialPacket(int tutorialId) + { + TutorialId = tutorialId; + } + + public int TutorialId { get; set; } +} diff --git a/NebulaModel/Packets/Players/PlayerDashboardPacket.cs b/NebulaModel/Packets/Players/PlayerDashboardPacket.cs new file mode 100644 index 000000000..4116697b9 --- /dev/null +++ b/NebulaModel/Packets/Players/PlayerDashboardPacket.cs @@ -0,0 +1,15 @@ +namespace NebulaModel.Packets.Players; + +public class PlayerDashboardPacket +{ + public PlayerDashboardPacket() { } + + public PlayerDashboardPacket(ushort playerId, byte[] dashboardData) + { + PlayerId = playerId; + DashboardData = dashboardData; + } + + public ushort PlayerId { get; set; } + public byte[] DashboardData { get; set; } +} diff --git a/NebulaModel/Packets/Players/PlayerReplicatorMultipliersPacket.cs b/NebulaModel/Packets/Players/PlayerReplicatorMultipliersPacket.cs new file mode 100644 index 000000000..a852d85ba --- /dev/null +++ b/NebulaModel/Packets/Players/PlayerReplicatorMultipliersPacket.cs @@ -0,0 +1,15 @@ +namespace NebulaModel.Packets.Players; + +public class PlayerReplicatorMultipliersPacket +{ + public PlayerReplicatorMultipliersPacket() { } + + public PlayerReplicatorMultipliersPacket(ushort playerId, byte[] multipliersData) + { + PlayerId = playerId; + MultipliersData = multipliersData; + } + + public ushort PlayerId { get; set; } + public byte[] MultipliersData { get; set; } +} diff --git a/NebulaModel/Packets/Universe/DysonSphereStatusPacket.cs b/NebulaModel/Packets/Universe/DysonSphereStatusPacket.cs index 006810b0d..602121285 100644 --- a/NebulaModel/Packets/Universe/DysonSphereStatusPacket.cs +++ b/NebulaModel/Packets/Universe/DysonSphereStatusPacket.cs @@ -1,4 +1,4 @@ -#region +#region using NebulaAPI.Packets; @@ -13,6 +13,10 @@ public DysonSphereStatusPacket() { } public DysonSphereStatusPacket(DysonSphere dysonSphere) { + if (dysonSphere?.starData == null) + { + return; + } StarIndex = dysonSphere.starData.index; GrossRadius = dysonSphere.grossRadius; EnergyReqCurrentTick = dysonSphere.energyReqCurrentTick; diff --git a/NebulaNetwork/Client.cs b/NebulaNetwork/Client.cs index 84763fc54..8fc260525 100644 --- a/NebulaNetwork/Client.cs +++ b/NebulaNetwork/Client.cs @@ -1,4 +1,4 @@ -#region +#region using System; using System.IO; @@ -82,7 +82,7 @@ public void Start() PacketUtils.RegisterAllPacketProcessorsInAssembly(assembly, PacketProcessor as NebulaNetPacketProcessor, false); } #if DEBUG - PacketProcessor.SimulateLatency = true; + PacketProcessor.SimulateLatency = false; #endif clientSocket = new WebSocket($"{serverProtocol}://{ServerEndpoint}/socket"); diff --git a/NebulaNetwork/PacketProcessors/GameHistory/GameHistoryFeatureKeyProcessor.cs b/NebulaNetwork/PacketProcessors/GameHistory/GameHistoryFeatureKeyProcessor.cs index 444814c47..59e252d68 100644 --- a/NebulaNetwork/PacketProcessors/GameHistory/GameHistoryFeatureKeyProcessor.cs +++ b/NebulaNetwork/PacketProcessors/GameHistory/GameHistoryFeatureKeyProcessor.cs @@ -1,4 +1,4 @@ -#region +#region using NebulaAPI.Packets; using NebulaModel.Networking; @@ -13,8 +13,16 @@ namespace NebulaNetwork.PacketProcessors.GameHistory; [RegisterPacketProcessor] public class GameHistoryFeatureKeyProcessor : PacketProcessor { + private const int MAX_ADVISOR_TIP_ID = FeatureID.ADVISOR_TIP_USED_START - FeatureID.ADVISOR_TIP_START; // 1000 + private const int ADVISOR_TIP_USED_END = FeatureID.ADVISOR_TIP_USED_START + MAX_ADVISOR_TIP_ID; // 2002000 + protected override void ProcessPacket(GameHistoryFeatureKeyPacket packet, NebulaConnection conn) { + if (packet == null) + { + return; + } + if (IsHost) { Multiplayer.Session.Network.SendPacketExclude(packet, conn); @@ -24,11 +32,24 @@ protected override void ProcessPacket(GameHistoryFeatureKeyPacket packet, Nebula { if (packet.Add) { - GameMain.data.history.RegFeatureKey(packet.FeatureId); + GameMain.data?.history?.RegFeatureKey(packet.FeatureId); + + if (packet.FeatureId >= FeatureID.ADVISOR_TIP_START && packet.FeatureId < FeatureID.ADVISOR_TIP_USED_START) + { + int tipId = packet.FeatureId - FeatureID.ADVISOR_TIP_START; + GameMain.gameScenario?.advisorLogic?.SetAdvisorTipFinished(tipId); + DismissAdvisorTipUI(tipId); + } + else if (packet.FeatureId >= FeatureID.ADVISOR_TIP_USED_START && packet.FeatureId < ADVISOR_TIP_USED_END) + { + int tipId = packet.FeatureId - FeatureID.ADVISOR_TIP_USED_START; + GameMain.gameScenario?.advisorLogic?.SetAdvisorTipUsed(tipId); + DismissAdvisorTipUI(tipId); + } } else { - GameMain.data.history.UnregFeatureKey(packet.FeatureId); + GameMain.data?.history?.UnregFeatureKey(packet.FeatureId); } if (packet.FeatureId == 1100002) @@ -39,4 +60,23 @@ protected override void ProcessPacket(GameHistoryFeatureKeyPacket packet, Nebula } } } + + private static void DismissAdvisorTipUI(int tipId) + { + var advisorTip = UIRoot.instance?.uiGame?.advisorTip; + if (advisorTip == null) + { + return; + } + + if (advisorTip.playingTip?.ID == tipId) + { + advisorTip.StopAdvisorTip(); + } + advisorTip.requests?.RemoveAll(id => id == tipId); + if (advisorTip.nextTip?.ID == tipId) + { + advisorTip.nextTip = null; + } + } } diff --git a/NebulaNetwork/PacketProcessors/GameHistory/GameHistoryUnlockTutorialProcessor.cs b/NebulaNetwork/PacketProcessors/GameHistory/GameHistoryUnlockTutorialProcessor.cs new file mode 100644 index 000000000..e24a8ec9d --- /dev/null +++ b/NebulaNetwork/PacketProcessors/GameHistory/GameHistoryUnlockTutorialProcessor.cs @@ -0,0 +1,34 @@ +#region + +using NebulaAPI.Packets; +using NebulaModel.Networking; +using NebulaModel.Packets; +using NebulaModel.Packets.GameHistory; +using NebulaWorld; + +#endregion + +namespace NebulaNetwork.PacketProcessors.GameHistory; + +[RegisterPacketProcessor] +public class GameHistoryUnlockTutorialProcessor : PacketProcessor +{ + protected override void ProcessPacket(GameHistoryUnlockTutorialPacket packet, NebulaConnection conn) + { + if (packet == null || packet.TutorialId <= 0) + { + return; + } + + if (IsHost) + { + Multiplayer.Session.Network.SendPacketExclude(packet, conn); + } + + using (Multiplayer.Session.History.IsIncomingRequest.On()) + { + GameMain.data?.history?.UnlockTutorial(packet.TutorialId); + UIRoot.instance?.uiGame?.tutorialTip?.CloseTip(packet.TutorialId); + } + } +} diff --git a/NebulaNetwork/PacketProcessors/Players/PlayerDashboardProcessor.cs b/NebulaNetwork/PacketProcessors/Players/PlayerDashboardProcessor.cs new file mode 100644 index 000000000..e8c643e89 --- /dev/null +++ b/NebulaNetwork/PacketProcessors/Players/PlayerDashboardProcessor.cs @@ -0,0 +1,53 @@ +#region + +using NebulaAPI.Networking; +using NebulaAPI.Packets; +using NebulaModel.Networking; +using NebulaModel.Packets; +using NebulaModel.Packets.Players; +using NebulaWorld.Planet; + +#endregion + +namespace NebulaNetwork.PacketProcessors.Players; + +[RegisterPacketProcessor] +public class PlayerDashboardProcessor : PacketProcessor +{ + protected override void ProcessPacket(PlayerDashboardPacket packet, NebulaConnection conn) + { + if (IsHost) + { + var player = Players.Get(conn) ?? Players.Get(conn, EConnectionStatus.Syncing); + if (player?.Data is NebulaModel.DataStructures.PlayerData playerData) + { + playerData.DashboardData = packet.DashboardData; + } + } + else + { + if (packet.DashboardData != null && packet.DashboardData.Length > 0) + { + PlanetManager.PreservedDashboardData = packet.DashboardData; + if (GameMain.data?.statistics?.charts != null) + { + try + { + using var reader = new BinaryUtils.Reader(packet.DashboardData); + GameMain.data.statistics.charts.Import(reader.BinaryReader); + var dashboard = UIRoot.instance?.uiGame?.dashboard; + if (dashboard != null) + { + dashboard.DetermineCharts(); + dashboard.UpdateCharts(); + } + } + catch (System.Exception e) + { + NebulaModel.Logger.Log.Warn($"Failed to import dashboard data: {e}"); + } + } + } + } + } +} diff --git a/NebulaNetwork/PacketProcessors/Players/PlayerMechaDataProcessor.cs b/NebulaNetwork/PacketProcessors/Players/PlayerMechaDataProcessor.cs index ff13e73cf..9729eaba3 100644 --- a/NebulaNetwork/PacketProcessors/Players/PlayerMechaDataProcessor.cs +++ b/NebulaNetwork/PacketProcessors/Players/PlayerMechaDataProcessor.cs @@ -1,5 +1,6 @@ -#region +#region +using NebulaAPI.Networking; using NebulaAPI.Packets; using NebulaModel; using NebulaModel.Logger; @@ -22,10 +23,11 @@ protected override void ProcessPacket(PlayerMechaData packet, NebulaConnection c return; } - var player = Multiplayer.Session.Server.Players.Get(conn); + var player = Multiplayer.Session.Server.Players.Get(conn) + ?? Multiplayer.Session.Server.Players.Get(conn, EConnectionStatus.Syncing); if (player == null) { - Log.Warn("Can't find the connected player for PlayerMechaData!"); + Log.Warn($"Can't find the connected player for PlayerMechaData! (connId: {conn?.Id}, status: {conn?.ConnectionStatus})"); return; } diff --git a/NebulaNetwork/PacketProcessors/Players/PlayerReplicatorMultipliersProcessor.cs b/NebulaNetwork/PacketProcessors/Players/PlayerReplicatorMultipliersProcessor.cs new file mode 100644 index 000000000..fcb687a0d --- /dev/null +++ b/NebulaNetwork/PacketProcessors/Players/PlayerReplicatorMultipliersProcessor.cs @@ -0,0 +1,35 @@ +#region + +using NebulaAPI.Networking; +using NebulaAPI.Packets; +using NebulaModel.Networking; +using NebulaModel.Packets; +using NebulaModel.Packets.Players; +using NebulaWorld.GameStates; + +#endregion + +namespace NebulaNetwork.PacketProcessors.Players; + +[RegisterPacketProcessor] +public class PlayerReplicatorMultipliersProcessor : PacketProcessor +{ + protected override void ProcessPacket(PlayerReplicatorMultipliersPacket packet, NebulaConnection conn) + { + if (IsHost) + { + var player = Players.Get(conn) ?? Players.Get(conn, EConnectionStatus.Syncing); + if (player?.Data is NebulaModel.DataStructures.PlayerData playerData) + { + playerData.ReplicatorMultipliersData = packet.MultipliersData; + } + } + else + { + if (packet.MultipliersData != null && packet.MultipliersData.Length > 0) + { + GameStatesManager.ApplyReplicatorMultipliers(packet.MultipliersData); + } + } + } +} diff --git a/NebulaNetwork/PacketProcessors/Session/SyncCompleteProcessor.cs b/NebulaNetwork/PacketProcessors/Session/SyncCompleteProcessor.cs index 5e7b04b2d..b107fed81 100644 --- a/NebulaNetwork/PacketProcessors/Session/SyncCompleteProcessor.cs +++ b/NebulaNetwork/PacketProcessors/Session/SyncCompleteProcessor.cs @@ -1,4 +1,4 @@ -#region +#region using NebulaAPI.GameState; using NebulaAPI.Networking; @@ -36,6 +36,12 @@ protected override void ProcessPacket(SyncComplete packet, NebulaConnection conn } Multiplayer.Session.World.OnAllPlayersSyncCompleted(); + + var currentStar = GameMain.localStar ?? GameMain.data?.localStar ?? GameMain.data?.localPlanet?.star; + if (currentStar != null) + { + PlanetModelingManager.RequestLoadStar(currentStar); + } } } @@ -116,6 +122,34 @@ private void ServerSyncComplete(SyncComplete packet, NebulaConnection conn) player.Data.DIYItemValue)); } + // if the client has custom dashboard data saved on server, send it to them + if (player.Data is NebulaModel.DataStructures.PlayerData playerData) + { + if ((playerData.DashboardData == null || playerData.DashboardData.Length == 0) && + SaveManager.PlayerSaves.TryGetValue(clientCertHash, out var saved) && saved is NebulaModel.DataStructures.PlayerData savedP && + savedP.DashboardData != null && savedP.DashboardData.Length > 0) + { + playerData.DashboardData = savedP.DashboardData; + } + + if (playerData.DashboardData != null && playerData.DashboardData.Length > 0) + { + player.SendPacket(new PlayerDashboardPacket(player.Id, playerData.DashboardData)); + } + + if ((playerData.ReplicatorMultipliersData == null || playerData.ReplicatorMultipliersData.Length == 0) && + SaveManager.PlayerSaves.TryGetValue(clientCertHash, out var saved2) && saved2 is NebulaModel.DataStructures.PlayerData savedP2 && + savedP2.ReplicatorMultipliersData != null && savedP2.ReplicatorMultipliersData.Length > 0) + { + playerData.ReplicatorMultipliersData = savedP2.ReplicatorMultipliersData; + } + + if (playerData.ReplicatorMultipliersData != null && playerData.ReplicatorMultipliersData.Length > 0) + { + player.SendPacket(new PlayerReplicatorMultipliersPacket(player.Id, playerData.ReplicatorMultipliersData)); + } + } + Multiplayer.Session.World.OnAllPlayersSyncCompleted(); } } diff --git a/NebulaNetwork/PacketProcessors/Universe/DysonSailDataProcessor.cs b/NebulaNetwork/PacketProcessors/Universe/DysonSailDataProcessor.cs index 03b474feb..efeedc98a 100644 --- a/NebulaNetwork/PacketProcessors/Universe/DysonSailDataProcessor.cs +++ b/NebulaNetwork/PacketProcessors/Universe/DysonSailDataProcessor.cs @@ -1,4 +1,4 @@ -#region +#region using NebulaAPI.Packets; using NebulaModel.Networking; @@ -16,7 +16,7 @@ internal class DysonSailDataProcessor : PacketProcessor protected override void ProcessPacket(DysonSailDataPacket packet, NebulaConnection conn) { var dysonSphere = GameMain.data.dysonSpheres[packet.StarIndex]; - if (dysonSphere == null) + if (dysonSphere?.swarm == null || !dysonSphere.swarm.OrbitExist(packet.OrbitId)) { return; } diff --git a/NebulaNetwork/PacketProcessors/Universe/DysonSphereDataProcessor.cs b/NebulaNetwork/PacketProcessors/Universe/DysonSphereDataProcessor.cs index 154598aa8..00309afaf 100644 --- a/NebulaNetwork/PacketProcessors/Universe/DysonSphereDataProcessor.cs +++ b/NebulaNetwork/PacketProcessors/Universe/DysonSphereDataProcessor.cs @@ -1,4 +1,4 @@ -#region +#region using System; using NebulaAPI; @@ -28,28 +28,42 @@ protected override void ProcessPacket(DysonSphereData packet, NebulaConnection c { case DysonSphereRespondEvent.List: //Overwrite content assigned by UIDETopFunction.SetDysonComboBox() - var dysonBox = UIRoot.instance.uiGame.dysonEditor.controlPanel.topFunction.dysonBox; - using (var br = new BinaryUtils.Reader(packet.BinaryData).BinaryReader) + var dysonBox = UIRoot.instance?.uiGame?.dysonEditor?.controlPanel?.topFunction?.dysonBox; + if (dysonBox != null) { - dysonBox.Items = []; - dysonBox.ItemsData = []; - var count = br.ReadInt32(); - for (var i = 0; i < count; i++) + using (var br = new BinaryUtils.Reader(packet.BinaryData).BinaryReader) { - var starIndex = br.ReadInt32(); - dysonBox.Items.Add(GameMain.galaxy.stars[starIndex].displayName); - dysonBox.ItemsData.Add(starIndex); + dysonBox.Items = []; + dysonBox.ItemsData = []; + var count = br.ReadInt32(); + for (var i = 0; i < count; i++) + { + var starIndex = br.ReadInt32(); + dysonBox.Items.Add(GameMain.galaxy.stars[starIndex].displayName); + dysonBox.ItemsData.Add(starIndex); + } } + var index = dysonBox.ItemsData.FindIndex(x => + x == UIRoot.instance?.uiGame?.dysonEditor?.selection?.viewStar?.index); + dysonBox.itemIndex = index >= 0 ? index : 0; } - var index = dysonBox.ItemsData.FindIndex(x => - x == UIRoot.instance.uiGame.dysonEditor.selection.viewStar?.index); - dysonBox.itemIndex = index >= 0 ? index : 0; break; case DysonSphereRespondEvent.Load: // The whole fragment is received GameStatesManager.FragmentSize = 0; - //Failsafe, if client does not have instantiated sphere for the star, it will create dummy one that will be replaced during import + //Failsafe: if client already has an instantiated sphere for the star, free its resources cleanly + if (GameMain.data.dysonSpheres[packet.StarIndex] != null) + { + try + { + GameMain.data.dysonSpheres[packet.StarIndex].Free(); + } + catch (Exception e) + { + Log.Warn($"Exception while freeing existing dyson sphere {packet.StarIndex}: {e}"); + } + } GameMain.data.dysonSpheres[packet.StarIndex] = new DysonSphere(); GameMain.data.statistics.production.Init(GameMain.data); //Another failsafe, DysonSphere import requires initialized factory statistics @@ -58,29 +72,98 @@ protected override void ProcessPacket(DysonSphereData packet, NebulaConnection c GameMain.data.statistics.production.factoryStatPool[0] = new FactoryProductionStat(); GameMain.data.statistics.production.factoryStatPool[0].Init(); } - GameMain.data.dysonSpheres[packet.StarIndex].Init(GameMain.data, GameMain.data.galaxy.stars[packet.StarIndex]); - + var dysonSphere = GameMain.data.dysonSpheres[packet.StarIndex]; var star = GameMain.galaxy.stars[packet.StarIndex]; - Log.Info($"Parsing {packet.BinaryData.Length} bytes of data for DysonSphere {star.name} (INDEX: {star.id})"); - using (var reader = new BinaryUtils.Reader(packet.BinaryData)) + using (Multiplayer.Session.DysonSpheres.IncomingDysonSwarmPacket.On()) + using (Multiplayer.Session.DysonSpheres.IsIncomingRequest.On()) { - GameMain.data.dysonSpheres[packet.StarIndex].Import(reader.BinaryReader); + dysonSphere.Init(GameMain.data, GameMain.data.galaxy.stars[packet.StarIndex]); + dysonSphere.ResetNew(); + + Log.Info($"Parsing {packet.BinaryData.Length} bytes of data for DysonSphere {star.name} (INDEX: {star.id})"); + using (var reader = new BinaryUtils.Reader(packet.BinaryData)) + { + dysonSphere.Import(reader.BinaryReader); + } } - if (UIRoot.instance.uiGame.dysonEditor.active) + + // Ensure render masks and models are active and rendered + dysonSphere.inGameRenderMaskS = -1; + dysonSphere.inGameRenderMaskL = -1; + dysonSphere.inEditorRenderMaskS = -1; + dysonSphere.inEditorRenderMaskL = -1; + + if (UIRoot.instance?.uiGame?.dysonEditor != null && UIRoot.instance.uiGame.dysonEditor.active) + { + DysonSphere.renderPlace = ERenderPlace.Dysonmap; + } + else if (UIRoot.instance?.uiGame?.starmap != null && UIRoot.instance.uiGame.starmap.active) + { + DysonSphere.renderPlace = ERenderPlace.Starmap; + } + else + { + DysonSphere.renderPlace = ERenderPlace.Universe; + } + + if (dysonSphere.nrdCapacity > 0 && dysonSphere.nrdBuffer == null) { - UIRoot.instance.uiGame.dysonEditor.selection.SetViewStar(GameMain.galaxy.stars[packet.StarIndex]); - var dysonBox2 = UIRoot.instance.uiGame.dysonEditor.controlPanel.topFunction.dysonBox; - var index2 = dysonBox2.ItemsData.FindIndex(x => - x == UIRoot.instance.uiGame.dysonEditor.selection.viewStar?.index); - dysonBox2.itemIndex = index2 >= 0 ? index2 : 0; + dysonSphere.SetNrdCapacity(dysonSphere.nrdCapacity); } + + dysonSphere.LayerSort(); + if (dysonSphere.layersSorted != null) + { + for (var i = 0; i < dysonSphere.layersSorted.Length; i++) + { + var layer = dysonSphere.layersSorted[i]; + if (layer?.shellPool == null) continue; + for (var j = 1; j < layer.shellCursor; j++) + { + var shell = layer.shellPool[j]; + if (shell != null && shell.id == j && (shell.mesh == null || shell.material == null)) + { + shell.GenerateModelObjects(); + } + } + } + } + + dysonSphere.modelRenderer?.RebuildModels(); + dysonSphere.swarm?.CalibrateOrbitCursor(); + dysonSphere.swarm?.SetOrbitColorBuffer(); + + Multiplayer.Session.DysonSpheres.LoadedSpheres.Add(packet.StarIndex); + try + { + if (UIRoot.instance?.uiGame?.dysonEditor != null && UIRoot.instance.uiGame.dysonEditor.active) + { + var selection = UIRoot.instance.uiGame.dysonEditor.selection; + selection?.SetViewStar(GameMain.galaxy.stars[packet.StarIndex]); + var dysonBox2 = UIRoot.instance.uiGame.dysonEditor.controlPanel?.topFunction?.dysonBox; + if (dysonBox2?.ItemsData != null && dysonBox2.ItemsData.Count > 0) + { + var index2 = dysonBox2.ItemsData.FindIndex(x => + x == selection?.viewStar?.index); + dysonBox2.itemIndex = index2 >= 0 ? index2 : 0; + } + } + } + catch (Exception e) + { + Log.Warn($"Error updating dyson editor UI after load: {e}"); + } + finally + { + Multiplayer.Session.DysonSpheres.RequestingIndex = -1; + Multiplayer.Session.DysonSpheres.IsNormal = true; + } + if (Multiplayer.Session.IsGameLoaded) { // Don't fade out when client is still joining InGamePopup.FadeOut(); } - Multiplayer.Session.DysonSpheres.RequestingIndex = -1; - Multiplayer.Session.DysonSpheres.IsNormal = true; try { diff --git a/NebulaNetwork/PacketProcessors/Universe/Editor/DysonSphereColorChangeProcessor.cs b/NebulaNetwork/PacketProcessors/Universe/Editor/DysonSphereColorChangeProcessor.cs index 00cfea3f2..020a97519 100644 --- a/NebulaNetwork/PacketProcessors/Universe/Editor/DysonSphereColorChangeProcessor.cs +++ b/NebulaNetwork/PacketProcessors/Universe/Editor/DysonSphereColorChangeProcessor.cs @@ -1,4 +1,4 @@ -#region +#region using System; using NebulaAPI.Packets; @@ -33,7 +33,7 @@ protected override void ProcessPacket(DysonSphereColorChangePacket packet, Nebul switch (packet.Type) { case DysonSphereColorChangePacket.ComponentType.Node: - var node = packet.Index < layer.nodeCursor ? layer.nodePool[packet.Index] : null; + var node = packet.Index >= 0 && packet.Index < layer.nodeCursor ? layer.nodePool[packet.Index] : null; if (node != null) { node.color = color; @@ -42,7 +42,7 @@ protected override void ProcessPacket(DysonSphereColorChangePacket packet, Nebul break; case DysonSphereColorChangePacket.ComponentType.Frame: - var frame = packet.Index < layer.frameCursor ? layer.framePool[packet.Index] : null; + var frame = packet.Index >= 0 && packet.Index < layer.frameCursor ? layer.framePool[packet.Index] : null; if (frame != null) { frame.color = color; @@ -51,7 +51,7 @@ protected override void ProcessPacket(DysonSphereColorChangePacket packet, Nebul break; case DysonSphereColorChangePacket.ComponentType.Shell: - var shell = packet.Index < layer.shellCursor ? layer.shellPool[packet.Index] : null; + var shell = packet.Index >= 0 && packet.Index < layer.shellCursor ? layer.shellPool[packet.Index] : null; if (shell != null) { shell.color = color; diff --git a/NebulaNetwork/Server.cs b/NebulaNetwork/Server.cs index 4396226af..1a96d7a8b 100644 --- a/NebulaNetwork/Server.cs +++ b/NebulaNetwork/Server.cs @@ -1,4 +1,4 @@ -#region +#region using System; using System.Collections.Concurrent; @@ -187,7 +187,7 @@ public void Start() PacketUtils.RegisterAllPacketProcessorsInAssembly(assembly, PacketProcessor as NebulaNetPacketProcessor, true); } #if DEBUG - PacketProcessor.SimulateLatency = true; + PacketProcessor.SimulateLatency = false; #endif if (Config.Options.EnableUPnpOrPmpSupport) diff --git a/NebulaPatcher/NebulaPatcher.csproj b/NebulaPatcher/NebulaPatcher.csproj index 51f182738..8f1d11e90 100644 --- a/NebulaPatcher/NebulaPatcher.csproj +++ b/NebulaPatcher/NebulaPatcher.csproj @@ -1,4 +1,4 @@ - + net472 @@ -18,7 +18,7 @@ - + diff --git a/NebulaPatcher/Patches/Dynamic/AdvisorLogic_Patch.cs b/NebulaPatcher/Patches/Dynamic/AdvisorLogic_Patch.cs new file mode 100644 index 000000000..22a4da248 --- /dev/null +++ b/NebulaPatcher/Patches/Dynamic/AdvisorLogic_Patch.cs @@ -0,0 +1,45 @@ +#region + +using HarmonyLib; +using NebulaWorld; +using NebulaWorld.GameStates; + +#endregion + +namespace NebulaPatcher.Patches.Dynamic; + +[HarmonyPatch(typeof(AdvisorLogic))] +public class AdvisorLogic_Patch +{ + private const int MAX_ADVISOR_TIP_ID = FeatureID.ADVISOR_TIP_USED_START - FeatureID.ADVISOR_TIP_START; // 1000 + + [HarmonyPostfix] + [HarmonyPatch(nameof(AdvisorLogic.SetAdvisorTipFinished))] + public static void SetAdvisorTipFinished_Postfix(AdvisorLogic __instance, int tipId) + { + if (tipId < 0 || tipId >= MAX_ADVISOR_TIP_ID || !Multiplayer.IsActive || Multiplayer.Session.History.IsIncomingRequest) + { + return; + } + + int featureId = FeatureID.ADVISOR_TIP_START + tipId; + GameStatesManager.PreserveFeatureKey(featureId); + var history = __instance?.gameData?.history ?? GameMain.data?.history; + history?.RegFeatureKey(featureId); + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(AdvisorLogic.SetAdvisorTipUsed))] + public static void SetAdvisorTipUsed_Postfix(AdvisorLogic __instance, int tipId) + { + if (tipId < 0 || tipId >= MAX_ADVISOR_TIP_ID || !Multiplayer.IsActive || Multiplayer.Session.History.IsIncomingRequest) + { + return; + } + + int featureId = FeatureID.ADVISOR_TIP_USED_START + tipId; + GameStatesManager.PreserveFeatureKey(featureId); + var history = __instance?.gameData?.history ?? GameMain.data?.history; + history?.RegFeatureKey(featureId); + } +} diff --git a/NebulaPatcher/Patches/Dynamic/DESelection_Patch.cs b/NebulaPatcher/Patches/Dynamic/DESelection_Patch.cs index 381e949ca..beb9e9e3d 100644 --- a/NebulaPatcher/Patches/Dynamic/DESelection_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/DESelection_Patch.cs @@ -1,4 +1,4 @@ -#region +#region using HarmonyLib; using NebulaModel.Packets.Universe; @@ -40,14 +40,14 @@ public static bool SetViewStar_Prefix(ref StarData starData) UIRoot.instance.uiGame.dysonEditor._Close(); return false; } - if (GameMain.data.dysonSpheres[starData.index] == null) + if (starData == null || GameMain.data.dysonSpheres[starData.index] == null) { //Local dyson sphere hasn't loaded yet, close the UI UIRoot.instance.uiGame.dysonEditor._Close(); return false; } } - if (starData == null || GameMain.data.dysonSpheres[starData.index] != null) + if (starData == null || (GameMain.data.dysonSpheres[starData.index] != null && Multiplayer.Session.DysonSpheres.LoadedSpheres.Contains(starData.index))) { return true; } @@ -67,4 +67,14 @@ public static bool SetViewStar_Prefix(ref StarData starData) dysonBox.itemIndex = index >= 0 ? index : 0; return false; } + + [HarmonyPostfix] + [HarmonyPatch(typeof(UIDysonEditor), nameof(UIDysonEditor._OnClose))] + public static void UIDysonEditor_OnClose_Postfix() + { + if (Multiplayer.IsActive && Multiplayer.Session.LocalPlayer.IsClient) + { + Multiplayer.Session.DysonSpheres.UnloadRemoteDysonSpheres(); + } + } } diff --git a/NebulaPatcher/Patches/Dynamic/DysonBlueprintData_Patch.cs b/NebulaPatcher/Patches/Dynamic/DysonBlueprintData_Patch.cs index af9c80e7f..b6b09c376 100644 --- a/NebulaPatcher/Patches/Dynamic/DysonBlueprintData_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/DysonBlueprintData_Patch.cs @@ -1,4 +1,4 @@ -#region +#region using HarmonyLib; using NebulaModel.Packets.Universe.Editor; @@ -32,7 +32,7 @@ public static void FromBase64String_Postfix(DysonBlueprintDataIOError __result, return; } Multiplayer.Session.DysonSpheres.InBlueprint = false; - if (Multiplayer.Session.DysonSpheres.IsIncomingRequest || __result != DysonBlueprintDataIOError.OK) + if (Multiplayer.Session.DysonSpheres.IsIncomingRequest || __result != DysonBlueprintDataIOError.OK || sphere?.starData == null) { return; } diff --git a/NebulaPatcher/Patches/Dynamic/DysonSphereLayer_Patch.cs b/NebulaPatcher/Patches/Dynamic/DysonSphereLayer_Patch.cs index 6b9abba27..5b01f8a38 100644 --- a/NebulaPatcher/Patches/Dynamic/DysonSphereLayer_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/DysonSphereLayer_Patch.cs @@ -1,4 +1,4 @@ -#region +#region using System.Collections.Generic; using HarmonyLib; @@ -18,7 +18,7 @@ internal class DysonSphereLayer_Patch [HarmonyPatch(nameof(DysonSphereLayer.NewDysonNode))] public static void NewDysonNode_Prefix(DysonSphereLayer __instance, int protoId, Vector3 pos) { - if (!Multiplayer.IsActive || Multiplayer.Session.DysonSpheres.IsIncomingRequest) + if (!Multiplayer.IsActive || Multiplayer.Session.DysonSpheres.IsIncomingRequest || Multiplayer.Session.DysonSpheres.InBlueprint) { return; } @@ -33,7 +33,7 @@ public static void NewDysonNode_Prefix(DysonSphereLayer __instance, int protoId, [HarmonyPatch(nameof(DysonSphereLayer.NewDysonFrame))] public static void NewDysonFrame_Prefix(DysonSphereLayer __instance, int protoId, int nodeAId, int nodeBId, bool euler) { - if (!Multiplayer.IsActive || Multiplayer.Session.DysonSpheres.IsIncomingRequest) + if (!Multiplayer.IsActive || Multiplayer.Session.DysonSpheres.IsIncomingRequest || Multiplayer.Session.DysonSpheres.InBlueprint) { return; } @@ -48,7 +48,7 @@ public static void NewDysonFrame_Prefix(DysonSphereLayer __instance, int protoId [HarmonyPatch(nameof(DysonSphereLayer.RemoveDysonFrame))] public static void RemoveDysonFrame_Prefix(DysonSphereLayer __instance, int frameId) { - if (Multiplayer.IsActive && !Multiplayer.Session.DysonSpheres.IsIncomingRequest) + if (Multiplayer.IsActive && !Multiplayer.Session.DysonSpheres.IsIncomingRequest && !Multiplayer.Session.DysonSpheres.InBlueprint) { Multiplayer.Session.Network.SendPacket(new DysonSphereRemoveFramePacket(__instance.starData.index, __instance.id, frameId)); @@ -59,7 +59,7 @@ public static void RemoveDysonFrame_Prefix(DysonSphereLayer __instance, int fram [HarmonyPatch(nameof(DysonSphereLayer.RemoveDysonNode))] public static void RemoveDysonNode_Prefix(DysonSphereLayer __instance, int nodeId) { - if (Multiplayer.IsActive && !Multiplayer.Session.DysonSpheres.IsIncomingRequest) + if (Multiplayer.IsActive && !Multiplayer.Session.DysonSpheres.IsIncomingRequest && !Multiplayer.Session.DysonSpheres.InBlueprint) { Multiplayer.Session.Network.SendPacket(new DysonSphereRemoveNodePacket(__instance.starData.index, __instance.id, nodeId)); @@ -70,7 +70,7 @@ public static void RemoveDysonNode_Prefix(DysonSphereLayer __instance, int nodeI [HarmonyPatch(nameof(DysonSphereLayer.NewDysonShell))] public static void NewDysonShell_Prefix(DysonSphereLayer __instance, int protoId, List nodeIds) { - if (!Multiplayer.IsActive || Multiplayer.Session.DysonSpheres.IsIncomingRequest) + if (!Multiplayer.IsActive || Multiplayer.Session.DysonSpheres.IsIncomingRequest || Multiplayer.Session.DysonSpheres.InBlueprint) { return; } @@ -85,7 +85,7 @@ public static void NewDysonShell_Prefix(DysonSphereLayer __instance, int protoId [HarmonyPatch(nameof(DysonSphereLayer.RemoveDysonShell))] public static void RemoveDysonShell_Prefix(DysonSphereLayer __instance, int shellId) { - if (Multiplayer.IsActive && !Multiplayer.Session.DysonSpheres.IsIncomingRequest) + if (Multiplayer.IsActive && !Multiplayer.Session.DysonSpheres.IsIncomingRequest && !Multiplayer.Session.DysonSpheres.InBlueprint) { Multiplayer.Session.Network.SendPacket(new DysonSphereRemoveShellPacket(__instance.starData.index, __instance.id, shellId)); diff --git a/NebulaPatcher/Patches/Dynamic/GameData_Patch.cs b/NebulaPatcher/Patches/Dynamic/GameData_Patch.cs index 2e34b09c1..1298c4fe6 100644 --- a/NebulaPatcher/Patches/Dynamic/GameData_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/GameData_Patch.cs @@ -1,4 +1,4 @@ -#region +#region using System; using System.IO; @@ -231,6 +231,25 @@ public static bool OnActivePlanetFactoryLoaded_Prefix(GameData __instance, Plane { Log.Error("NebulaModAPI.OnPlanetLoadFinished error:\n" + e); } + + if (PlanetManager.PreservedDashboardData != null && PlanetManager.PreservedDashboardData.Length > 0 && GameMain.data?.statistics?.charts != null) + { + try + { + using var ms = new MemoryStream(PlanetManager.PreservedDashboardData); + using var reader = new BinaryReader(ms); + GameMain.data.statistics.charts.Import(reader); + } + catch (Exception e) + { + Log.Warn($"Failed to restore preserved dashboard: {e}"); + } + } + + if (GameStatesManager.PreservedReplicatorMultipliers != null && GameStatesManager.PreservedReplicatorMultipliers.Length > 0) + { + GameStatesManager.ApplyReplicatorMultipliers(GameStatesManager.PreservedReplicatorMultipliers); + } } // call this here as it would not be called normally on the client, but its needed to set GameMain.data.galacticTransport.stationCursor @@ -294,6 +313,11 @@ public static void SetForNewGame_Postfix(GameData __instance) var planet = __instance.galaxy.PlanetById(UIVirtualStarmap_Transpiler.CustomBirthPlanet); __instance.ArrivePlanet(planet); } + + if (__instance.localStar != null) + { + PlanetModelingManager.RequestLoadStar(__instance.localStar); + } } [HarmonyPostfix, HarmonyPriority(Priority.High)] @@ -350,6 +374,7 @@ public static void ArriveStar_Prefix(StarData star) } Multiplayer.Session.Network.SendPacket(new PlayerUpdateLocalStarId(Multiplayer.Session.LocalPlayer.Id, star.id)); Multiplayer.Session.Network.SendPacket(new ILSArriveStarPlanetRequest(star.id)); + PlanetModelingManager.RequestLoadStar(star); } [HarmonyPrefix] diff --git a/NebulaPatcher/Patches/Dynamic/GameHistoryData_Patch.cs b/NebulaPatcher/Patches/Dynamic/GameHistoryData_Patch.cs index d866e80b1..87d6f0517 100644 --- a/NebulaPatcher/Patches/Dynamic/GameHistoryData_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/GameHistoryData_Patch.cs @@ -1,9 +1,10 @@ -#region +#region using HarmonyLib; using NebulaModel.Logger; using NebulaModel.Packets.GameHistory; using NebulaWorld; +using NebulaWorld.GameStates; #endregion @@ -153,4 +154,82 @@ public static void NotifyTechUnlock_Postfix(int _techId, int _level) GameMain.mainPlayer.mecha.lab.itemPoints.Clear(); Multiplayer.Session.Network.SendPacket(new GameHistoryUnlockTechPacket(_techId, _level)); } + + [HarmonyPrefix] + [HarmonyPatch(nameof(GameHistoryData.RegFeatureKey))] + public static void RegFeatureKey_Prefix(GameHistoryData __instance, int featureId, out bool __state) + { + __state = Multiplayer.IsActive && __instance != null && (__instance.featureKeys == null || !__instance.featureKeys.Contains(featureId)); + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(GameHistoryData.RegFeatureKey))] + public static void RegFeatureKey_Postfix(int featureId, bool __state) + { + if (!Multiplayer.IsActive) + { + return; + } + + GameStatesManager.PreserveFeatureKey(featureId); + + if (!__state || Multiplayer.Session.History.IsIncomingRequest) + { + return; + } + + Multiplayer.Session.Network.SendPacket(new GameHistoryFeatureKeyPacket(featureId, true)); + } + + [HarmonyPrefix] + [HarmonyPatch(nameof(GameHistoryData.UnregFeatureKey))] + public static void UnregFeatureKey_Prefix(GameHistoryData __instance, int featureId, out bool __state) + { + __state = Multiplayer.IsActive && __instance?.featureKeys != null && __instance.featureKeys.Contains(featureId); + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(GameHistoryData.UnregFeatureKey))] + public static void UnregFeatureKey_Postfix(int featureId, bool __state) + { + if (!Multiplayer.IsActive) + { + return; + } + + GameStatesManager.UnpreserveFeatureKey(featureId); + + if (!__state || Multiplayer.Session.History.IsIncomingRequest) + { + return; + } + + Multiplayer.Session.Network.SendPacket(new GameHistoryFeatureKeyPacket(featureId, false)); + } + + [HarmonyPrefix] + [HarmonyPatch(nameof(GameHistoryData.UnlockTutorial))] + public static void UnlockTutorial_Prefix(GameHistoryData __instance, int tutorialId, out bool __state) + { + __state = Multiplayer.IsActive && __instance != null && tutorialId > 0 && (__instance.tutorialUnlocked == null || !__instance.tutorialUnlocked.Contains(tutorialId)); + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(GameHistoryData.UnlockTutorial))] + public static void UnlockTutorial_Postfix(int tutorialId, bool __state) + { + if (tutorialId <= 0 || !Multiplayer.IsActive) + { + return; + } + + GameStatesManager.PreserveTutorial(tutorialId); + + if (!__state || Multiplayer.Session.History.IsIncomingRequest) + { + return; + } + + Multiplayer.Session.Network.SendPacket(new GameHistoryUnlockTutorialPacket(tutorialId)); + } } diff --git a/NebulaPatcher/Patches/Dynamic/GameLogic_Patch.cs b/NebulaPatcher/Patches/Dynamic/GameLogic_Patch.cs index 2979cbdd1..c79388fe6 100644 --- a/NebulaPatcher/Patches/Dynamic/GameLogic_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/GameLogic_Patch.cs @@ -1,4 +1,4 @@ -#region +#region using System; using HarmonyLib; @@ -55,10 +55,26 @@ public static void LogicFrame_Postfix(bool __runOriginal) } catch (Exception e) { - _ = e; -#if DEBUG - Log.Warn(e); -#endif + var currentTick = GameMain.gameTick; + if (currentTick - lastILSLogTick > 180 || lastILSLogTick == 0) + { + lastILSLogTick = currentTick; + Log.Warn($"[LogicFrame_Postfix] Visual effects update error: {e.GetType().Name}: {e.Message}\n{e.StackTrace}"); + } + } + } + + private static long lastILSLogTick; + private static int ilsErrorCount; + + private static void LogILSUpdateError(StationComponent station, Exception e) + { + ilsErrorCount++; + var currentTick = GameMain.gameTick; + if (currentTick - lastILSLogTick > 180 || lastILSLogTick == 0) + { + lastILSLogTick = currentTick; + Log.Warn($"[ILSUpdateShipPos] Error on station gid={station.gid} (planetId={station.planetId}, ships={station.workShipCount}, errors={ilsErrorCount}): {e.GetType().Name}: {e.Message}\n{e.StackTrace}"); } } @@ -90,9 +106,16 @@ private static void ILSUpdateShipPos(long time) var planet = GameMain.galaxy.PlanetById(stationComponent.planetId); if (planet == null) continue; - StationComponent_Transpiler.ILSUpdateShipPos(stationComponent, - planet.factory, timeGene, shipSailSpeed, shipWarpSpeed, - shipCarries, gStationPool, astroPoses, ref relativePos, ref relativeRot, starmap, null); + try + { + StationComponent_Transpiler.ILSUpdateShipPos(stationComponent, + planet.factory, timeGene, shipSailSpeed, shipWarpSpeed, + shipCarries, gStationPool, astroPoses, ref relativePos, ref relativeRot, starmap, null); + } + catch (Exception e) + { + LogILSUpdateError(stationComponent, e); + } } } } diff --git a/NebulaPatcher/Patches/Dynamic/GamePrefsData_Patch.cs b/NebulaPatcher/Patches/Dynamic/GamePrefsData_Patch.cs index 816201ef4..812bf8121 100644 --- a/NebulaPatcher/Patches/Dynamic/GamePrefsData_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/GamePrefsData_Patch.cs @@ -1,8 +1,10 @@ -#region +#region +using System.Collections.Generic; using HarmonyLib; using NebulaModel; using NebulaWorld; +using NebulaWorld.GameStates; #endregion @@ -15,23 +17,42 @@ internal class GamePrefsData_Patch [HarmonyPatch(nameof(GamePrefsData.Restore))] public static void Restore_Postfix() { - if (!Multiplayer.IsActive || Multiplayer.Session.LocalPlayer.IsHost) + if (Multiplayer.IsActive && !Multiplayer.Session.LocalPlayer.IsHost) { - return; + NebulaModel.Logger.Log.Debug("Apply save prefs"); + var uiGame = UIRoot.instance.uiGame; + PowerSystemRenderer.powerGraphOn = Config.Options.ShowDetailPowerGrid; + uiGame.dfVeinOn = Config.Options.ShowDetailVeinDistribution; + uiGame.dfSpaceGuideOn = Config.Options.ShowDetailSpaceNavigation; + DefenseSystemRenderer.turretGraphOn = Config.Options.ShowDetailDefenseArea; + EntitySignRenderer.showSign = Config.Options.ShowDetailBuildingAlarm; + EntitySignRenderer.showIcon = Config.Options.ShowDetailBuildingIcon; + PostEffectController.headlight = Config.Options.ShowGuidingLight; + if (GameMain.sectorModel != null) + { + GameMain.sectorModel.disableHPBars = !Config.Options.ShowDetailHpBars; + } } - NebulaModel.Logger.Log.Debug("Apply save prefs"); - var uiGame = UIRoot.instance.uiGame; - PowerSystemRenderer.powerGraphOn = Config.Options.ShowDetailPowerGrid; - uiGame.dfVeinOn = Config.Options.ShowDetailVeinDistribution; - uiGame.dfSpaceGuideOn = Config.Options.ShowDetailSpaceNavigation; - DefenseSystemRenderer.turretGraphOn = Config.Options.ShowDetailDefenseArea; - EntitySignRenderer.showSign = Config.Options.ShowDetailBuildingAlarm; - EntitySignRenderer.showIcon = Config.Options.ShowDetailBuildingIcon; - PostEffectController.headlight = Config.Options.ShowGuidingLight; - if (GameMain.sectorModel != null) + var replicator = UIRoot.instance?.uiGame?.replicator; + if (replicator != null) { - GameMain.sectorModel.disableHPBars = !Config.Options.ShowDetailHpBars; + GameStatesManager.RestoreReplicatorMultipliers(replicator); + } + } + + [HarmonyPrefix] + [HarmonyPatch(nameof(GamePrefsData.Collect))] + public static void Collect_Prefix(GamePrefsData __instance) + { + var replicator = UIRoot.instance?.uiGame?.replicator; + if (replicator != null && replicator.multipliers != null && replicator.multipliers.Count > 0) + { + __instance.replicatorMultipliers ??= new Dictionary(); + foreach (var kv in replicator.multipliers) + { + __instance.replicatorMultipliers[kv.Key] = kv.Value; + } } } } diff --git a/NebulaPatcher/Patches/Dynamic/PlanetModelingManager_Patch.cs b/NebulaPatcher/Patches/Dynamic/PlanetModelingManager_Patch.cs index fa8a81250..015031b7d 100644 --- a/NebulaPatcher/Patches/Dynamic/PlanetModelingManager_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/PlanetModelingManager_Patch.cs @@ -1,4 +1,4 @@ -#region +#region using System; using System.Collections.Generic; @@ -97,9 +97,10 @@ public static bool RequestLoadStar_Prefix(StarData star) InternalLoadPlanetsRequestGenerator(star.planets); - Multiplayer.Session.DysonSpheres.UnloadRemoteDysonSpheres(); + Multiplayer.Session.DysonSpheres.UnloadRemoteDysonSpheres(star.index); // Request initial dysonSphere data - if (GameMain.data.dysonSpheres[star.index] == null) + if ((GameMain.data.dysonSpheres[star.index] == null || !Multiplayer.Session.DysonSpheres.LoadedSpheres.Contains(star.index)) && + Multiplayer.Session.DysonSpheres.RequestingIndex != star.index) { Multiplayer.Session.DysonSpheres.RequestDysonSphere(star.index, false); } diff --git a/NebulaPatcher/Patches/Dynamic/UIDashboard_Patch.cs b/NebulaPatcher/Patches/Dynamic/UIDashboard_Patch.cs new file mode 100644 index 000000000..92df6fd3e --- /dev/null +++ b/NebulaPatcher/Patches/Dynamic/UIDashboard_Patch.cs @@ -0,0 +1,113 @@ +#region + +using System; +using System.IO; +using HarmonyLib; +using NebulaModel.Logger; +using NebulaModel.Packets.Players; +using NebulaWorld; +using NebulaWorld.Planet; + +#endregion + +namespace NebulaPatcher.Patches.Dynamic; + +[HarmonyPatch] +internal class UIDashboard_Patch +{ + [HarmonyPrefix] + [HarmonyPatch(typeof(UIDashboard), nameof(UIDashboard._OnOpen))] + public static void UIDashboard_OnOpen_Prefix() + { + if (!Multiplayer.IsActive || Multiplayer.Session.LocalPlayer.IsHost) + { + return; + } + + if (GameMain.data?.statistics?.charts != null && + (GameMain.data.statistics.charts.statPlans == null || GameMain.data.statistics.charts.statPlans.count == 0) && + PlanetManager.PreservedDashboardData != null && PlanetManager.PreservedDashboardData.Length > 0) + { + try + { + using var ms = new MemoryStream(PlanetManager.PreservedDashboardData); + using var reader = new BinaryReader(ms); + GameMain.data.statistics.charts.Import(reader); + } + catch (Exception e) + { + Log.Warn($"Failed to restore dashboard data on open: {e}"); + } + } + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(UIDashboard), nameof(UIDashboard._OnClose))] + public static void UIDashboard_OnClose_Postfix(UIDashboard __instance) + { + try + { + __instance.CollectStates(); + } + catch (Exception e) + { + Log.Warn($"Failed to collect dashboard states: {e}"); + } + SyncDashboardToServer(); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(CustomCharts), nameof(CustomCharts.CreateOrFindStatPlan))] + public static void CustomCharts_CreateOrFindStatPlan_Postfix() + { + SyncDashboardToServer(); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(CustomCharts), nameof(CustomCharts.RemoveStatPlan))] + public static void CustomCharts_RemoveStatPlan_Postfix() + { + SyncDashboardToServer(); + } + + public static void SyncDashboardToServer() + { + if (!Multiplayer.IsActive || Multiplayer.Session.LocalPlayer.IsHost) + { + return; + } + if (GameMain.data?.statistics?.charts == null) + { + return; + } + + try + { + var dashboard = UIRoot.instance?.uiGame?.dashboard; + if (dashboard != null && dashboard.active) + { + dashboard.CollectStates(); + } + + var charts = GameMain.data.statistics.charts; + if ((charts.statPlans == null || charts.statPlans.count == 0) && + PlanetManager.PreservedDashboardData != null && PlanetManager.PreservedDashboardData.Length > 0) + { + return; + } + + using var ms = new MemoryStream(); + using (var writer = new BinaryWriter(ms)) + { + charts.Export(writer); + } + var data = ms.ToArray(); + PlanetManager.PreservedDashboardData = data; + Multiplayer.Session.Network.SendPacket(new PlayerDashboardPacket(Multiplayer.Session.LocalPlayer.Id, data)); + } + catch (Exception e) + { + Log.Warn($"Failed to sync dashboard to server: {e}"); + } + } +} diff --git a/NebulaPatcher/Patches/Dynamic/UIEscMenu_Patch.cs b/NebulaPatcher/Patches/Dynamic/UIEscMenu_Patch.cs index bf56e4c4e..8fc2b344b 100644 --- a/NebulaPatcher/Patches/Dynamic/UIEscMenu_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/UIEscMenu_Patch.cs @@ -1,4 +1,4 @@ -#region +#region using System; using System.Diagnostics.CodeAnalysis; @@ -74,6 +74,7 @@ private static void QuitGame() } else if (GameMain.mainPlayer?.mecha != null) { + UIDashboard_Patch.SyncDashboardToServer(); GameMain.mainPlayer.mecha.lab.ManageTakeback(); // Refund items to player package Multiplayer.Session.Network.SendPacket(new PlayerMechaData(GameMain.mainPlayer)); Thread.Sleep(100); // Wait for async packet send diff --git a/NebulaPatcher/Patches/Dynamic/UIReplicatorWindow_Patch.cs b/NebulaPatcher/Patches/Dynamic/UIReplicatorWindow_Patch.cs new file mode 100644 index 000000000..3f0bc92e9 --- /dev/null +++ b/NebulaPatcher/Patches/Dynamic/UIReplicatorWindow_Patch.cs @@ -0,0 +1,150 @@ +#region + +using System; +using HarmonyLib; +using NebulaAPI; +using NebulaModel.Logger; +using NebulaModel.Packets.Players; +using NebulaWorld; +using NebulaWorld.GameStates; + +#endregion + +namespace NebulaPatcher.Patches.Dynamic; + +[HarmonyPatch(typeof(UIReplicatorWindow))] +internal class UIReplicatorWindow_Patch +{ + [HarmonyPostfix] + [HarmonyPatch(nameof(UIReplicatorWindow._OnInit))] + public static void _OnInit_Postfix(UIReplicatorWindow __instance) + { + try + { + GameStatesManager.RestoreReplicatorMultipliers(__instance); + } + catch (Exception e) + { + Log.Warn($"Failed to restore replicator multipliers on _OnInit: {e}"); + } + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(UIReplicatorWindow._OnOpen))] + public static void _OnOpen_Postfix(UIReplicatorWindow __instance) + { + try + { + GameStatesManager.RestoreReplicatorMultipliers(__instance); + } + catch (Exception e) + { + Log.Warn($"Failed to restore replicator multipliers on _OnOpen: {e}"); + } + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(UIReplicatorWindow._OnClose))] + public static void _OnClose_Postfix(UIReplicatorWindow __instance) + { + OnMultiplierChanged(__instance); + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(UIReplicatorWindow.OnPlusButtonClick))] + public static void OnPlusButtonClick_Postfix(UIReplicatorWindow __instance) + { + OnMultiplierChanged(__instance); + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(UIReplicatorWindow.OnMinusButtonClick))] + public static void OnMinusButtonClick_Postfix(UIReplicatorWindow __instance) + { + OnMultiplierChanged(__instance); + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(UIReplicatorWindow.OnSelectedRecipeChange))] + public static void OnSelectedRecipeChange_Postfix(UIReplicatorWindow __instance) + { + try + { + if (__instance != null && __instance.selectedRecipe != null && __instance.multiValueText != null) + { + int multi = 1; + if (__instance.multipliers != null && __instance.multipliers.TryGetValue(__instance.selectedRecipe.ID, out int val) && val > 1) + { + multi = val; + } + __instance.multiValueText.text = $"{multi}x"; + } + } + catch (Exception e) + { + Log.Warn($"Failed to update multiValueText on recipe change: {e}"); + } + } + + public static void OnMultiplierChanged(UIReplicatorWindow instance) + { + if (instance == null) + { + return; + } + + try + { + // Sync to GameMain.data.preferences + if (GameMain.data?.preferences != null && instance.multipliers != null) + { + GameMain.data.preferences.replicatorMultipliers ??= new System.Collections.Generic.Dictionary(); + foreach (var kv in instance.multipliers) + { + GameMain.data.preferences.replicatorMultipliers[kv.Key] = kv.Value; + } + } + + var bytes = GameStatesManager.ExportReplicatorMultipliers(); + SyncMultipliersToServer(bytes); + } + catch (Exception e) + { + Log.Warn($"Failed to process multiplier change: {e}"); + } + } + + public static void SyncMultipliersToServer(byte[] data = null) + { + if (!Multiplayer.IsActive) + { + return; + } + + data ??= GameStatesManager.ExportReplicatorMultipliers(); + if (data == null || data.Length == 0) + { + return; + } + + if (Multiplayer.Session.LocalPlayer.IsHost) + { + if (Multiplayer.Session.LocalPlayer.Data is NebulaModel.DataStructures.PlayerData hostData) + { + hostData.ReplicatorMultipliersData = data; + } + } + else + { + try + { + Multiplayer.Session.Network.SendPacket( + new PlayerReplicatorMultipliersPacket(Multiplayer.Session.LocalPlayer.Id, data)); + } + catch (Exception e) + { + Log.Warn($"Failed to sync replicator multipliers to server: {e}"); + } + } + } +} diff --git a/NebulaPatcher/Patches/Dynamic/UIStarmap_Patch.cs b/NebulaPatcher/Patches/Dynamic/UIStarmap_Patch.cs index 6a9c8656f..189fb7a70 100644 --- a/NebulaPatcher/Patches/Dynamic/UIStarmap_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/UIStarmap_Patch.cs @@ -1,4 +1,4 @@ -#region +#region using System.Diagnostics.CodeAnalysis; using HarmonyLib; @@ -28,9 +28,14 @@ public static void _OnLateUpdate_Postfix(UIStarmap __instance) [SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Original Function Name")] public static void _OnClose_Postfix() { + s_queryingIndex = -1; if (Multiplayer.IsActive) { Multiplayer.Session.World.ClearPlayerNameTagsOnStarmap(); + if (Multiplayer.Session.LocalPlayer.IsClient) + { + Multiplayer.Session.DysonSpheres.UnloadRemoteDysonSpheres(); + } } } @@ -65,7 +70,7 @@ public static void QueryDysonSphere(UIStarmap __instance) } var starIndex = __instance.focusStar.star.index; - if (GameMain.data.dysonSpheres[starIndex] == null) + if (GameMain.data.dysonSpheres[starIndex] == null || !Multiplayer.Session.DysonSpheres.LoadedSpheres.Contains(starIndex)) { if (s_queryingIndex != starIndex) { diff --git a/NebulaPatcher/Patches/Misc/Debugging.cs b/NebulaPatcher/Patches/Misc/Debugging.cs index 8be257fef..aacf736c4 100644 --- a/NebulaPatcher/Patches/Misc/Debugging.cs +++ b/NebulaPatcher/Patches/Misc/Debugging.cs @@ -1,4 +1,4 @@ -#if DEBUG +#if ENABLE_DEBUG_CHEATS #region diff --git a/NebulaPatcher/Patches/Misc/Fix_Patches.cs b/NebulaPatcher/Patches/Misc/Fix_Patches.cs index 4c5ccf148..521b8427e 100644 --- a/NebulaPatcher/Patches/Misc/Fix_Patches.cs +++ b/NebulaPatcher/Patches/Misc/Fix_Patches.cs @@ -1,4 +1,4 @@ -#region +#region using System; using HarmonyLib; @@ -32,6 +32,140 @@ public static Exception DeterminePreviews(Exception __exception, BuildTool_Path return null; } + // IndexOutOfRangeException: Index was outside the bounds of the array. + // at BuildTool.UpdateGizmos (BuildModel model) [0x0009a] ;IL_009A + // at BuildTool_Path.UpdateGizmos (BuildModel model) [0x00000] ;IL_0000 + // at BuildTool_Path._OnTick (long time) [0x00051] ;IL_0051 + // at BuildTool._GameTick (long time) [0x000cf] ;IL_00CF + [HarmonyPrefix] + [HarmonyPatch(typeof(BuildTool), nameof(BuildTool.UpdateGizmos))] + [HarmonyPatch(typeof(BuildTool_Path), nameof(BuildTool_Path.UpdateGizmos))] + [HarmonyPatch(typeof(BuildTool_Click), nameof(BuildTool_Click.UpdateGizmos))] + [HarmonyPatch(typeof(BuildTool_Addon), nameof(BuildTool_Addon.UpdateGizmos))] + [HarmonyPatch(typeof(BuildTool_Inserter), nameof(BuildTool_Inserter.UpdateGizmos))] + public static void UpdateGizmos_Prefix(BuildTool __instance, BuildModel model) + { + var factory = __instance?.factory; + if (factory == null) + { + return; + } + + if (model != null) + { + if (model.startGizmoObjId > 0 && (factory.entityPool == null || model.startGizmoObjId >= factory.entityPool.Length)) + { + model.startGizmoObjId = 0; + } + else if (model.startGizmoObjId < 0 && (factory.prebuildPool == null || -model.startGizmoObjId >= factory.prebuildPool.Length)) + { + model.startGizmoObjId = 0; + } + + if (model.endGizmoObjId > 0 && (factory.entityPool == null || model.endGizmoObjId >= factory.entityPool.Length)) + { + model.endGizmoObjId = 0; + } + else if (model.endGizmoObjId < 0 && (factory.prebuildPool == null || -model.endGizmoObjId >= factory.prebuildPool.Length)) + { + model.endGizmoObjId = 0; + } + } + + if (__instance is BuildTool_Path pathTool) + { + if (pathTool.startObjectId > 0 && (factory.entityPool == null || pathTool.startObjectId >= factory.entityPool.Length)) + { + pathTool.startObjectId = 0; + } + else if (pathTool.startObjectId < 0 && (factory.prebuildPool == null || -pathTool.startObjectId >= factory.prebuildPool.Length)) + { + pathTool.startObjectId = 0; + } + + if (pathTool.castObjectId > 0 && (factory.entityPool == null || pathTool.castObjectId >= factory.entityPool.Length)) + { + pathTool.castObjectId = 0; + } + else if (pathTool.castObjectId < 0 && (factory.prebuildPool == null || -pathTool.castObjectId >= factory.prebuildPool.Length)) + { + pathTool.castObjectId = 0; + } + } + } + + [HarmonyFinalizer] + [HarmonyPatch(typeof(BuildTool), nameof(BuildTool.UpdateGizmos))] + [HarmonyPatch(typeof(BuildTool_Path), nameof(BuildTool_Path.UpdateGizmos))] + [HarmonyPatch(typeof(BuildTool_Click), nameof(BuildTool_Click.UpdateGizmos))] + [HarmonyPatch(typeof(BuildTool_Addon), nameof(BuildTool_Addon.UpdateGizmos))] + [HarmonyPatch(typeof(BuildTool_Inserter), nameof(BuildTool_Inserter.UpdateGizmos))] + public static Exception UpdateGizmos_Finalizer(Exception __exception, BuildTool __instance, BuildModel model) + { + if (__exception != null) + { + if (model != null) + { + model.startGizmoObjId = 0; + model.endGizmoObjId = 0; + model.previewGizmoOn = false; + } + if (__instance is BuildTool_Path pathTool) + { + pathTool.startObjectId = 0; + pathTool.castObjectId = 0; + pathTool.startNearestAddonAreaIdx = 0; + pathTool.startTarget = Vector3.zero; + pathTool.pathPointCount = 0; + } + } + return null; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(BuildTool), nameof(BuildTool.GetPrefabDesc))] + public static bool GetPrefabDesc_Prefix(BuildTool __instance, int objId, ref PrefabDesc __result) + { + var factory = __instance?.factory; + if (factory == null || objId == 0) + { + __result = null; + return false; + } + + if (objId > 0) + { + if (factory.entityPool == null || objId >= factory.entityPool.Length) + { + __result = null; + return false; + } + } + else + { + var prebuildId = -objId; + if (factory.prebuildPool == null || prebuildId >= factory.prebuildPool.Length) + { + __result = null; + return false; + } + } + + return true; + } + + [HarmonyFinalizer] + [HarmonyPatch(typeof(BuildTool), nameof(BuildTool.GetPrefabDesc))] + public static Exception GetPrefabDesc_Finalizer(Exception __exception, ref PrefabDesc __result) + { + if (__exception != null) + { + __result = null; + return null; + } + return null; + } + // IndexOutOfRangeException: Index was outside the bounds of the array. // at CargoTraffic.SetBeltState(System.Int32 beltId, System.Int32 state); (IL_002D) // at CargoTraffic.SetBeltSelected(System.Int32 beltId); (IL_0000) diff --git a/NebulaPatcher/Patches/Transpilers/StationComponent_Transpiler.cs b/NebulaPatcher/Patches/Transpilers/StationComponent_Transpiler.cs index b6d4490b4..158c0ffad 100644 --- a/NebulaPatcher/Patches/Transpilers/StationComponent_Transpiler.cs +++ b/NebulaPatcher/Patches/Transpilers/StationComponent_Transpiler.cs @@ -1,4 +1,4 @@ -#region +#region using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -334,7 +334,7 @@ static IEnumerable Transpiler(IEnumerable inst new CodeInstruction(OpCodes.Ldarg_S, 6), // gStationPool new CodeInstruction(OpCodes.Ldloc_S, shipDataRef), // shipData new CodeInstruction(OpCodes.Ldfld, AccessTools.Field(typeof(ShipData), "otherGId")), - new CodeInstruction(OpCodes.Ldelem, typeof(StationComponent)), + new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(StationComponent_Transpiler), nameof(IsValidStation))), new CodeInstruction(OpCodes.Brtrue, jmpNormalFlow), new CodeInstruction(OpCodes.Ldloc_S, loopIndex), new CodeInstruction(OpCodes.Ldc_I4_1), @@ -451,6 +451,11 @@ static IEnumerable Transpiler(IEnumerable inst } } + public static bool IsValidStation(StationComponent[] gStationPool, int gid) + { + return gid > 0 && gStationPool != null && gid < gStationPool.Length && gStationPool[gid] != null; + } + private delegate void ShipEnterWarpState(StationComponent stationComponent, int j); private delegate void AddItem(StationComponent stationComponent, ref ShipData shipData); diff --git a/NebulaWorld/GameStates/GameStatesManager.cs b/NebulaWorld/GameStates/GameStatesManager.cs index 235851314..1b19b1236 100644 --- a/NebulaWorld/GameStates/GameStatesManager.cs +++ b/NebulaWorld/GameStates/GameStatesManager.cs @@ -1,6 +1,7 @@ -#region +#region using System; +using System.Collections.Generic; using NebulaModel; using NebulaModel.Logger; using NebulaModel.Networking; @@ -16,8 +17,230 @@ public class GameStatesManager : IDisposable public const float MaxUPS = 240f; public const float MinUPS = 30f; public static bool DuringReconnect { get; set; } + private static readonly HashSet preservedFeatureKeys = new(); + private static readonly HashSet preservedTutorialUnlocked = new(); private static int bufferLength; + public static bool IsAdvisorOrTutorialFeatureKey(int featureId) + { + return (featureId >= FeatureID.ADVISOR_TIP_START && featureId < FeatureID.GOAL_STATE) || + (featureId >= FeatureID.VEIN_SCAN && featureId <= FeatureID.HIDE_GRID_SPLIT_TIP); + } + + public static void PreserveFeatureKey(int featureId) + { + if (IsAdvisorOrTutorialFeatureKey(featureId)) + { + preservedFeatureKeys.Add(featureId); + } + } + + public static void UnpreserveFeatureKey(int featureId) + { + if (IsAdvisorOrTutorialFeatureKey(featureId)) + { + preservedFeatureKeys.Remove(featureId); + } + } + + public static void PreserveTutorial(int tutorialId) + { + if (tutorialId > 0) + { + preservedTutorialUnlocked.Add(tutorialId); + } + } + + public static byte[] PreservedReplicatorMultipliers { get; set; } + + public static void ApplyReplicatorMultipliers(byte[] data, UIReplicatorWindow replicatorTarget = null) + { + if (data == null || data.Length < 4) + { + return; + } + + try + { + using var ms = new System.IO.MemoryStream(data); + using var reader = new System.IO.BinaryReader(ms); + int count = reader.ReadInt32(); + if (count < 0 || count > 50000 || data.Length < 4 + count * 8) + { + return; + } + + PreservedReplicatorMultipliers = data; + + var gameData = GameMain.data; + if (gameData?.preferences != null) + { + gameData.preferences.replicatorMultipliers ??= new Dictionary(); + } + + var replicator = replicatorTarget ?? UIRoot.instance?.uiGame?.replicator; + if (replicator != null) + { + replicator.multipliers ??= new Dictionary(); + } + + for (int i = 0; i < count; i++) + { + int recipeId = reader.ReadInt32(); + int multi = reader.ReadInt32(); + if (gameData?.preferences?.replicatorMultipliers != null) + { + gameData.preferences.replicatorMultipliers[recipeId] = multi; + } + if (replicator?.multipliers != null) + { + replicator.multipliers[recipeId] = multi; + } + } + + if (replicator != null && replicator.selectedRecipe != null && replicator.multiValueText != null) + { + int currentMulti = 1; + if (replicator.multipliers != null && replicator.multipliers.TryGetValue(replicator.selectedRecipe.ID, out int val) && val > 1) + { + currentMulti = val; + } + replicator.multiValueText.text = $"{currentMulti}x"; + } + } + catch (Exception e) + { + Log.Warn($"Failed to apply replicator multipliers: {e}"); + } + } + + public static byte[] ExportReplicatorMultipliers() + { + Dictionary source = null; + + var replicator = UIRoot.instance?.uiGame?.replicator; + if (replicator?.multipliers != null && replicator.multipliers.Count > 0) + { + source = replicator.multipliers; + } + else if (GameMain.data?.preferences?.replicatorMultipliers != null && GameMain.data.preferences.replicatorMultipliers.Count > 0) + { + source = GameMain.data.preferences.replicatorMultipliers; + } + + if (source != null && source.Count > 0) + { + try + { + using var ms = new System.IO.MemoryStream(); + using var writer = new System.IO.BinaryWriter(ms); + writer.Write(source.Count); + foreach (var kv in source) + { + writer.Write(kv.Key); + writer.Write(kv.Value); + } + var bytes = ms.ToArray(); + PreservedReplicatorMultipliers = bytes; + return bytes; + } + catch (Exception e) + { + Log.Warn($"Failed to export replicator multipliers: {e}"); + } + } + + return PreservedReplicatorMultipliers ?? Array.Empty(); + } + + public static void RestoreReplicatorMultipliers(UIReplicatorWindow replicator) + { + if (replicator == null) + { + return; + } + replicator.multipliers ??= new Dictionary(); + + if (PreservedReplicatorMultipliers != null && PreservedReplicatorMultipliers.Length > 0) + { + ApplyReplicatorMultipliers(PreservedReplicatorMultipliers, replicator); + return; + } + + var prefMultipliers = GameMain.data?.preferences?.replicatorMultipliers; + if (prefMultipliers != null && prefMultipliers.Count > 0) + { + foreach (var kv in prefMultipliers) + { + replicator.multipliers[kv.Key] = kv.Value; + } + PreservedReplicatorMultipliers = ExportReplicatorMultipliers(); + } + else if (replicator.multipliers.Count > 0) + { + if (GameMain.data?.preferences != null) + { + gameDataPreferencesSync(replicator); + } + PreservedReplicatorMultipliers = ExportReplicatorMultipliers(); + } + + if (replicator.selectedRecipe != null && replicator.multiValueText != null) + { + int currentMulti = 1; + if (replicator.multipliers.TryGetValue(replicator.selectedRecipe.ID, out int val) && val > 1) + { + currentMulti = val; + } + replicator.multiValueText.text = $"{currentMulti}x"; + } + } + + private static void gameDataPreferencesSync(UIReplicatorWindow replicator) + { + GameMain.data.preferences.replicatorMultipliers ??= new Dictionary(); + foreach (var kv in replicator.multipliers) + { + GameMain.data.preferences.replicatorMultipliers[kv.Key] = kv.Value; + } + } + + public static byte[] ExportDashboardData() + { + var dashboard = UIRoot.instance?.uiGame?.dashboard; + if (dashboard != null && dashboard.active) + { + try + { + dashboard.CollectStates(); + } + catch (Exception e) + { + Log.Warn($"Failed to collect dashboard states: {e}"); + } + } + + var charts = GameMain.data?.statistics?.charts; + if (charts?.statPlans != null && charts.statPlans.count > 0) + { + try + { + using var ms = new System.IO.MemoryStream(); + using var writer = new System.IO.BinaryWriter(ms); + charts.Export(writer); + var bytes = ms.ToArray(); + Planet.PlanetManager.PreservedDashboardData = bytes; + return bytes; + } + catch (Exception e) + { + Log.Warn($"Failed to export dashboard data: {e}"); + } + } + + return Planet.PlanetManager.PreservedDashboardData; + } + public static long RealGameTick => GameMain.gameTick; public static float RealUPS => (float)FPSController.currentUPS; public static long LastSaveTime { get; set; } // UnixTimeSeconds @@ -46,6 +269,13 @@ public GameStatesManager() public void Dispose() { + if (!DuringReconnect) + { + preservedFeatureKeys.Clear(); + preservedTutorialUnlocked.Clear(); + PreservedReplicatorMultipliers = null; + } + LastSaveTime = FragmentSize = 0; sandboxToolsEnabled = false; historyBinaryData = null; @@ -221,16 +451,140 @@ public void ImportGlobalGameData(GlobalGameDataResponse packet) public void OverwriteGlobalGameData(GameData data) { + if (data == null) + { + return; + } + if (historyBinaryData != null) { Log.Info("Parsing History data from the server..."); GameMain.sandboxToolsEnabled = sandboxToolsEnabled; + + if (data.history != null) + { + if (data.history.featureKeys != null) + { + foreach (int key in data.history.featureKeys) + { + PreserveFeatureKey(key); + } + } + if (data.history.tutorialUnlocked != null) + { + foreach (int tutorialId in data.history.tutorialUnlocked) + { + PreserveTutorial(tutorialId); + } + } + } + + if (data.history == null) + { + data.history = new GameHistoryData(); + } + data.history.Init(data); using (var reader = new BinaryUtils.Reader(historyBinaryData)) { data.history.Import(reader.BinaryReader); } historyBinaryData = null; + + data.history.featureKeys ??= new HashSet(); + data.history.tutorialUnlocked ??= new HashSet(); + + if (preservedFeatureKeys.Count > 0) + { + var keysToRestore = new List(preservedFeatureKeys); + foreach (int key in keysToRestore) + { + if (!data.history.HasFeatureKey(key)) + { + data.history.RegFeatureKey(key); + } + } + } + if (preservedTutorialUnlocked.Count > 0) + { + var tutorialsToRestore = new List(preservedTutorialUnlocked); + foreach (int tutorialId in tutorialsToRestore) + { + if (tutorialId > 0 && !data.history.TutorialUnlocked(tutorialId)) + { + data.history.UnlockTutorial(tutorialId); + } + } + } + + if (data.history.featureKeys != null) + { + foreach (int key in data.history.featureKeys) + { + PreserveFeatureKey(key); + } + } + if (data.history.tutorialUnlocked != null) + { + foreach (int tutorialId in data.history.tutorialUnlocked) + { + PreserveTutorial(tutorialId); + } + } + + using (Multiplayer.Session.History.IsIncomingRequest.On()) + { + if (data.history.featureKeys != null) + { + int maxAdvisorUsedKey = FeatureID.ADVISOR_TIP_USED_START + (FeatureID.ADVISOR_TIP_USED_START - FeatureID.ADVISOR_TIP_START); + foreach (int key in data.history.featureKeys) + { + if (key >= FeatureID.ADVISOR_TIP_START && key < FeatureID.ADVISOR_TIP_USED_START) + { + int tipId = key - FeatureID.ADVISOR_TIP_START; + GameMain.gameScenario?.advisorLogic?.SetAdvisorTipFinished(tipId); + } + else if (key >= FeatureID.ADVISOR_TIP_USED_START && key < maxAdvisorUsedKey) + { + int tipId = key - FeatureID.ADVISOR_TIP_USED_START; + GameMain.gameScenario?.advisorLogic?.SetAdvisorTipUsed(tipId); + } + } + } + } + + var advisorTip = UIRoot.instance?.uiGame?.advisorTip; + if (advisorTip != null) + { + if (advisorTip.playingTip != null && + (data.history.HasFeatureKey(FeatureID.ADVISOR_TIP_START + advisorTip.playingTip.ID) || + data.history.HasFeatureKey(FeatureID.ADVISOR_TIP_USED_START + advisorTip.playingTip.ID))) + { + advisorTip.StopAdvisorTip(); + } + advisorTip.requests?.RemoveAll(id => + data.history.HasFeatureKey(FeatureID.ADVISOR_TIP_START + id) || + data.history.HasFeatureKey(FeatureID.ADVISOR_TIP_USED_START + id)); + if (advisorTip.nextTip != null && + (data.history.HasFeatureKey(FeatureID.ADVISOR_TIP_START + advisorTip.nextTip.ID) || + data.history.HasFeatureKey(FeatureID.ADVISOR_TIP_USED_START + advisorTip.nextTip.ID))) + { + advisorTip.nextTip = null; + } + } + + var tutorialTip = UIRoot.instance?.uiGame?.tutorialTip; + if (tutorialTip != null && tutorialTip.entryShowed != null) + { + for (int i = tutorialTip.entryShowed.Count - 1; i >= 0; i--) + { + var entry = tutorialTip.entryShowed[i]; + if (entry != null && data.history.TutorialUnlocked(entry.tutorialId)) + { + tutorialTip.CloseTip(entry.tutorialId); + } + } + } } if (galacticTransportBinaryData != null) { @@ -292,7 +646,36 @@ public void OverwriteGlobalGameData(GameData data) data.galacticDigital.Import(reader.BinaryReader); } galacticDigitalBinaryData = null; + } + + if (PreservedReplicatorMultipliers != null && PreservedReplicatorMultipliers.Length > 0) + { + ApplyReplicatorMultipliers(PreservedReplicatorMultipliers); + } + + if (Planet.PlanetManager.PreservedDashboardData != null && Planet.PlanetManager.PreservedDashboardData.Length > 0 && data.statistics?.charts != null) + { + try + { + using var reader = new BinaryUtils.Reader(Planet.PlanetManager.PreservedDashboardData); + data.statistics.charts.Import(reader.BinaryReader); + var dashboard = UIRoot.instance?.uiGame?.dashboard; + if (dashboard != null) + { + dashboard.DetermineCharts(); + dashboard.UpdateCharts(); + } + } + catch (Exception e) + { + Log.Warn($"Failed to restore dashboard data in OverwriteGlobalGameData: {e}"); + } + } + var currentStar = data.localStar ?? data.localPlanet?.star; + if (currentStar != null) + { + PlanetModelingManager.RequestLoadStar(currentStar); } } diff --git a/NebulaWorld/Planet/PlanetManager.cs b/NebulaWorld/Planet/PlanetManager.cs index da37b2ae4..c2939b94b 100644 --- a/NebulaWorld/Planet/PlanetManager.cs +++ b/NebulaWorld/Planet/PlanetManager.cs @@ -1,4 +1,4 @@ -#region +#region using System; using System.Collections.Generic; @@ -17,11 +17,16 @@ public class PlanetManager : IDisposable public Dictionary PendingFactories { get; set; } = new(); public Dictionary PendingTerrainData { get; set; } = new(); public bool EnableVeinPacket { get; set; } = true; + public static byte[] PreservedDashboardData { get; set; } public void Dispose() { PendingFactories = null; PendingTerrainData = null; + if (!GameStates.GameStatesManager.DuringReconnect) + { + PreservedDashboardData = null; + } GC.SuppressFinalize(this); } @@ -42,8 +47,40 @@ public static void UnloadAllFactories() gameData.factoryCount = 0; Multiplayer.Session.Combat.OnAstroFactoryUnload(); } - // Temporarily clear all CustomCharts on the unloaded factories to avoid errors - gameData.statistics.charts.Free(); - gameData.statistics.charts.Init(gameData); + + // Ensure active dashboard state is cleanly collected and preserved before charts.Free() and charts.Init() + var dashboard = UIRoot.instance?.uiGame?.dashboard; + if (dashboard != null && dashboard.active) + { + try + { + dashboard.CollectStates(); + dashboard._Close(); + } + catch (Exception e) + { + Log.Warn($"Failed to collect dashboard states before factory unload: {e}"); + } + } + + // Temporarily clear all CustomCharts on the unloaded factories to avoid errors, but preserve layout + if (gameData.statistics?.charts?.statPlans != null && gameData.statistics.charts.statPlans.count > 0) + { + try + { + using var ms = new System.IO.MemoryStream(); + using (var writer = new System.IO.BinaryWriter(ms)) + { + gameData.statistics.charts.Export(writer); + } + PreservedDashboardData = ms.ToArray(); + } + catch (Exception e) + { + Log.Warn($"Failed to snapshot charts before factory unload: {e}"); + } + } + gameData.statistics?.charts?.Free(); + gameData.statistics?.charts?.Init(gameData); } } diff --git a/NebulaWorld/SaveManager.cs b/NebulaWorld/SaveManager.cs index fcc773538..59ebd2265 100644 --- a/NebulaWorld/SaveManager.cs +++ b/NebulaWorld/SaveManager.cs @@ -1,4 +1,4 @@ -#region +#region using System; using System.Collections.Generic; @@ -8,6 +8,7 @@ using NebulaModel.Logger; using NebulaModel.Networking.Serialization; using NebulaModel.Utils; +using NebulaWorld.GameStates; #endregion @@ -16,7 +17,7 @@ namespace NebulaWorld; public static class SaveManager { private const string FILE_EXTENSION = ".server"; - private const ushort REVISION = 8; + private const ushort REVISION = 9; private static readonly Dictionary playerSaves = new(); public static IReadOnlyDictionary PlayerSaves => playerSaves; @@ -43,6 +44,15 @@ public static void SaveServerData(string saveName) //Add host's data netDataWriter.Put(CryptoUtils.GetCurrentUserPublicKeyHash()); + if (Multiplayer.Session.LocalPlayer.Data is PlayerData hostData) + { + hostData.ReplicatorMultipliersData = GameStatesManager.ExportReplicatorMultipliers(); + var dashboardData = GameStatesManager.ExportDashboardData(); + if (dashboardData != null && dashboardData.Length > 0) + { + hostData.DashboardData = dashboardData; + } + } Multiplayer.Session.LocalPlayer.Data.Serialize(netDataWriter); File.WriteAllBytes(path, netDataWriter.Data); @@ -121,7 +131,7 @@ public static void LoadServerData(bool loadSaveFile) Log.Info($"Loading server data revision {revision} (Latest {REVISION})"); if (revision != REVISION) { - // Supported revision: 5~8 + // Supported revision: 5~9 if (revision is < 5 or > REVISION) { throw new Exception($"Unsupported version {revision}"); diff --git a/NebulaWorld/Universe/DysonSphereManager.cs b/NebulaWorld/Universe/DysonSphereManager.cs index 6b0de58ed..1276d1db5 100644 --- a/NebulaWorld/Universe/DysonSphereManager.cs +++ b/NebulaWorld/Universe/DysonSphereManager.cs @@ -1,4 +1,4 @@ -#region +#region using System; using System.Collections.Generic; @@ -26,9 +26,29 @@ public class DysonSphereManager : IDisposable public bool IsNormal { get; set; } = true; //Client side: is the spheres data normal or desynced public bool InBlueprint { get; set; } //In the processing of importing blueprint public int RequestingIndex { get; set; } = -1; //StarIndex of the dyson sphere requesting + public HashSet LoadedSpheres { get; } = []; public void Dispose() { + if (GameMain.data?.dysonSpheres != null) + { + foreach (var i in LoadedSpheres) + { + if (i >= 0 && i < GameMain.data.dysonSpheres.Length && GameMain.data.dysonSpheres[i] != null) + { + try + { + GameMain.data.dysonSpheres[i].Free(); + } + catch (Exception e) + { + Log.Warn($"Exception while freeing sphere {i} on dispose: {e}"); + } + GameMain.data.dysonSpheres[i] = null; + } + } + } + LoadedSpheres.Clear(); GC.SuppressFinalize(this); } @@ -128,9 +148,19 @@ public void UnRegisterPlayer(INebulaConnection conn) public void UpdateSphereStatusIfNeeded() { - foreach (var packet in statusPackets) + DysonSphereStatusPacket[] packets; + using (GetSubscribers(out _)) + { + packets = statusPackets.ToArray(); + } + + foreach (var packet in packets) { var dysonSphere = GameMain.data.dysonSpheres[packet.StarIndex]; + if (dysonSphere == null) + { + continue; + } //Update dyson sphere when the status changes if (Math.Abs(packet.GrossRadius - dysonSphere.grossRadius) < 0.000000001 && packet.EnergyReqCurrentTick == dysonSphere.energyReqCurrentTick && @@ -160,18 +190,40 @@ public void RequestDysonSphere(int starIndex, bool showInfo = true) } } - public void UnloadRemoteDysonSpheres() + public void UnloadRemoteDysonSpheres(int keepStarIndex = -1) { + if (GameMain.data?.dysonSpheres == null) + { + return; + } + //The editor will throw errors if there are no dyson spheres available - var currentId = GameMain.localStar?.index ?? UIRoot.instance.uiGame.dysonEditor.selection.viewStar?.index ?? -1; + var currentId = keepStarIndex >= 0 ? keepStarIndex : (GameMain.localStar?.index ?? GameMain.data?.localStar?.index ?? GameMain.data?.localPlanet?.star?.index ?? -1); + var editorId = UIRoot.instance?.uiGame?.dysonEditor?.selection?.viewStar?.index ?? -1; + + // If player's current system and editor system are not yet resolved (e.g. during load or in deep space), do not unload + if (currentId == -1 && editorId == -1) + { + return; + } + for (var i = 0; i < GameMain.data.dysonSpheres.Length; i++) { - if (GameMain.data.dysonSpheres[i] == null || i == currentId) + if (GameMain.data.dysonSpheres[i] == null || i == currentId || i == editorId) { continue; } Log.Info($"Unload DysonSphere at system {GameMain.galaxy.stars[i].displayName} (Index: {i})"); Multiplayer.Session.Network.SendPacket(new DysonSphereLoadRequest(i, DysonSphereRequestEvent.Unload)); + LoadedSpheres.Remove(i); + try + { + GameMain.data.dysonSpheres[i].Free(); + } + catch (Exception e) + { + Log.Warn($"Exception while freeing dyson sphere {i}: {e}"); + } GameMain.data.dysonSpheres[i] = null; } IsNormal = true; @@ -217,8 +269,8 @@ public static int QueryOrbitId(DysonSwarm swarm) public static void ClearSelection(int starIndex, int layerId = -1) { - var selection = UIRoot.instance.uiGame.dysonEditor.selection; - if (selection.viewStar == null || selection.viewStar.index != starIndex) + var selection = UIRoot.instance?.uiGame?.dysonEditor?.selection; + if (selection == null || selection.viewStar == null || selection.viewStar.index != starIndex) { return; } From 70eec09b15177a8764812285d4ac4efdeb84a8c5 Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 18 Sep 2026 15:44:26 +0200 Subject: [PATCH 2/9] fix(power): resolve empty energy sector pie charts and sufficiency gauge on client --- .../UIStatisticsPowerDetailPanel_Patch.cs | 346 ++++++++++++++++++ .../Dynamic/UIStatisticsWindow_Patch.cs | 4 +- 2 files changed, 348 insertions(+), 2 deletions(-) create mode 100644 NebulaPatcher/Patches/Dynamic/UIStatisticsPowerDetailPanel_Patch.cs diff --git a/NebulaPatcher/Patches/Dynamic/UIStatisticsPowerDetailPanel_Patch.cs b/NebulaPatcher/Patches/Dynamic/UIStatisticsPowerDetailPanel_Patch.cs new file mode 100644 index 000000000..9a1c7488d --- /dev/null +++ b/NebulaPatcher/Patches/Dynamic/UIStatisticsPowerDetailPanel_Patch.cs @@ -0,0 +1,346 @@ +#region + +using System; +using System.Collections.Generic; +using HarmonyLib; +using NebulaModel.Logger; +using NebulaWorld; +using UnityEngine; +using UnityEngine.UI; + +#endregion + +namespace NebulaPatcher.Patches.Dynamic; + +[HarmonyPatch] +internal class UIStatisticsPowerDetailPanel_Patch +{ + private static readonly Color[] DefaultColors = + [ + new Color(0.2f, 0.75f, 1.0f, 1f), // Bright Blue + new Color(1.0f, 0.65f, 0.2f, 1f), // Orange + new Color(0.3f, 0.9f, 0.4f, 1f), // Green + new Color(1.0f, 0.85f, 0.2f, 1f), // Yellow + new Color(0.9f, 0.35f, 0.35f, 1f), // Red/Coral + new Color(0.7f, 0.4f, 1.0f, 1f), // Purple + new Color(0.2f, 0.9f, 0.9f, 1f), // Cyan + new Color(1.0f, 0.4f, 0.7f, 1f), // Pink + new Color(0.6f, 0.8f, 0.2f, 1f), // Lime + new Color(0.9f, 0.5f, 0.1f, 1f), // Amber + new Color(0.4f, 0.6f, 1.0f, 1f), // Soft Blue + new Color(0.8f, 0.8f, 0.8f, 1f) // Light Gray + ]; + + private struct SliceInfo + { + public int index; + public string name; + public double value; + public double fill; + public double offset; + } + + [HarmonyFinalizer] + [HarmonyPatch(typeof(UIStatisticsPowerDetailPanel), nameof(UIStatisticsPowerDetailPanel.RefreshPowerDatas))] + public static Exception RefreshPowerDatas_Finalizer(Exception __exception) + { + // Suppress any remote factory NRE on clients + if (__exception != null && Multiplayer.IsActive && !Multiplayer.Session.LocalPlayer.IsHost) + { + return null; + } + return __exception; + } + + [HarmonyFinalizer] + [HarmonyPatch(typeof(UIStatisticsPowerDetailPanel), nameof(UIStatisticsPowerDetailPanel.RefreshGraphs))] + public static Exception RefreshGraphs_Finalizer(Exception __exception, UIStatisticsPowerDetailPanel __instance) + { + if (!Multiplayer.IsActive || Multiplayer.Session.LocalPlayer.IsHost || __instance == null) + { + return __exception; + } + + try + { + ApplyPowerPanelGraphsFix(__instance); + } + catch (Exception e) + { + Log.Warn($"ApplyPowerPanelGraphsFix error: {e}"); + } + + return null; // Suppress any exception in base method + } + + [HarmonyFinalizer] + [HarmonyPatch(typeof(UIChartAstroPower), nameof(UIChartAstroPower.RefreshGraphs))] + public static Exception UIChartAstroPower_RefreshGraphs_Finalizer(Exception __exception, UIChartAstroPower __instance) + { + if (!Multiplayer.IsActive || Multiplayer.Session.LocalPlayer.IsHost || __instance == null) + { + return __exception; + } + + try + { + ApplyChartAstroPowerFix(__instance); + } + catch (Exception e) + { + Log.Warn($"ApplyChartAstroPowerFix error: {e}"); + } + + return null; + } + + private static void ApplyPowerPanelGraphsFix(UIStatisticsPowerDetailPanel panel) + { + // 1. Generation Small Pie Chart + var genSlices = CollectSlices(panel.powerGenEntries, out var totalGen); + if (panel.powerGenGraphSmall != null) + { + UpdateSectorGraph(panel.powerGenGraphSmall, genSlices); + } + + // 2. Consumption Small Pie Chart + var conSlices = CollectSlices(panel.powerConEntries, out var totalCon); + if (panel.powerConGraphSmall != null) + { + UpdateSectorGraph(panel.powerConGraphSmall, conSlices); + } + + // 3. Large Graph on the Left (Sufficiency or selected breakdown) + var largeGraph = panel.powerGenGraphLarge != null && panel.powerGenGraphLarge.gameObject.activeInHierarchy + ? panel.powerGenGraphLarge + : (panel.powerConGraphLarge != null && panel.powerConGraphLarge.gameObject.activeInHierarchy ? panel.powerConGraphLarge : null); + + if (largeGraph != null) + { + var sub = largeGraph.subText != null ? largeGraph.subText.text : ""; + var main = largeGraph.mainText != null ? largeGraph.mainText.text : ""; + + if (sub.IndexOf("Sufficiency", StringComparison.OrdinalIgnoreCase) >= 0 || + main.IndexOf("%", StringComparison.OrdinalIgnoreCase) >= 0 || + sub.Length == 0) + { + // Display sufficiency gauge + var ratio = totalCon > 0 ? totalGen / totalCon : (totalGen > 0 ? 1.0 : 0.0); + var fill = Math.Min(1.0, Math.Max(0.0, ratio)); + Color suffColor; + if (ratio >= 1.0) + suffColor = new Color(0.2f, 0.75f, 1.0f, 1f); // Cyan + else if (ratio >= 0.8) + suffColor = new Color(0.3f, 0.85f, 0.4f, 1f); // Green + else if (ratio >= 0.5) + suffColor = new Color(1.0f, 0.65f, 0.2f, 1f); // Orange + else + suffColor = new Color(0.9f, 0.25f, 0.25f, 1f); // Red + + var suffSlices = new List + { + new() + { + index = 0, + name = sub.Length > 0 ? sub : "Sufficiency", + value = ratio, + fill = fill, + offset = 0.0 + } + }; + UpdateSectorGraph(largeGraph, suffSlices, suffColor); + } + else if (sub.IndexOf("Generation", StringComparison.OrdinalIgnoreCase) >= 0) + { + UpdateSectorGraph(largeGraph, genSlices); + } + else if (sub.IndexOf("Consumption", StringComparison.OrdinalIgnoreCase) >= 0) + { + UpdateSectorGraph(largeGraph, conSlices); + } + else + { + // Default to sufficiency + var ratio = totalCon > 0 ? totalGen / totalCon : (totalGen > 0 ? 1.0 : 0.0); + var fill = Math.Min(1.0, Math.Max(0.0, ratio)); + var suffSlices = new List + { + new() + { + index = 0, + name = "Sufficiency", + value = ratio, + fill = fill, + offset = 0.0 + } + }; + UpdateSectorGraph(largeGraph, suffSlices, new Color(0.2f, 0.75f, 1.0f, 1f)); + } + } + } + + private static void ApplyChartAstroPowerFix(UIChartAstroPower chart) + { + var genSlices = CollectSlices(chart.powerGenEntries, out var totalGen); + if (chart.genSectorGraph != null) + { + UpdateSectorGraph(chart.genSectorGraph, genSlices); + } + + var conSlices = CollectSlices(chart.powerConEntries, out var totalCon); + if (chart.conSectorGraph != null) + { + UpdateSectorGraph(chart.conSectorGraph, conSlices); + } + + if (chart.powerRoundFg != null) + { + var ratio = totalCon > 0 ? Mathf.Clamp01((float)(totalGen / totalCon)) : (totalGen > 0 ? 1f : 0f); + chart.powerRoundFg.fillAmount = ratio; + if (ratio >= 1f) + chart.powerRoundFg.color = chart.powerRoundFgColor0; + else if (ratio >= 0.8f) + chart.powerRoundFg.color = chart.powerRoundFgColor1; + else if (ratio >= 0.5f) + chart.powerRoundFg.color = chart.powerRoundFgColor2; + else + chart.powerRoundFg.color = chart.powerRoundFgColor3; + } + } + + private static List CollectSlices(List entries, out double totalPower) + { + totalPower = 0; + var list = new List(); + if (entries == null) return list; + + for (var i = 0; i < entries.Count; i++) + { + var entry = entries[i]; + if (entry != null && entry.gameObject.activeSelf && entry.power > 0) + { + totalPower += entry.power; + } + } + + if (totalPower <= 0) return list; + + var currentOffset = 0.0; + var sliceIdx = 0; + for (var i = 0; i < entries.Count; i++) + { + var entry = entries[i]; + if (entry != null && entry.gameObject.activeSelf && entry.power > 0) + { + var fill = entry.power / totalPower; + var name = entry.itemNameText != null ? entry.itemNameText.text : ""; + list.Add(new SliceInfo + { + index = sliceIdx++, + name = name, + value = entry.power, + fill = fill, + offset = currentOffset + }); + currentOffset += fill; + } + } + + return list; + } + + private static void UpdateSectorGraph(UISectorGraph graph, List slices, Color? overrideColor = null) + { + if (graph == null) return; + + var count = slices != null ? slices.Count : 0; + + // Ensure fanDatas capacity + if (graph.fanDatas == null || graph.fanDatas.Length < count) + { + var newDatas = new UISectorGraph.FanData[Math.Max(count + 4, 32)]; + if (graph.fanDatas != null) Array.Copy(graph.fanDatas, newDatas, graph.fanDatas.Length); + graph.fanDatas = newDatas; + } + + // Ensure fans capacity + if (graph.fans == null || graph.fans.Length < graph.fanDatas.Length) + { + var newFans = new UISectorFan[graph.fanDatas.Length]; + if (graph.fans != null) Array.Copy(graph.fans, newFans, graph.fans.Length); + graph.fans = newFans; + } + + // Populate FanDatas + for (var i = 0; i < count; i++) + { + var slice = slices[i]; + graph.fanDatas[i].index = slice.index; + graph.fanDatas[i].name = slice.name; + graph.fanDatas[i].value = slice.value; + graph.fanDatas[i].fill = slice.fill; + graph.fanDatas[i].offset = slice.offset; + graph.fanDatas[i].cursor = slice.offset + slice.fill * 0.5; + graph.fanDatas[i].level = 0; + graph.fanDatas[i].parent = -1; + } + + graph.fanCount = count; + + // Try game's native Refresh first + try + { + graph.Refresh(); + } + catch + { + // Fall back to direct instantiation/setup below + } + + // Direct instantiation & configuration guarantee + Transform parent = graph.levelGroups != null && graph.levelGroups.Length > 0 && graph.levelGroups[0] != null + ? graph.levelGroups[0] + : graph.rectTrans; + + Sprite sprite = graph.levelSprites != null && graph.levelSprites.Length > 0 ? graph.levelSprites[0] : null; + + for (var i = 0; i < count; i++) + { + var slice = slices[i]; + if (graph.fans[i] == null && graph.fanPrefab != null) + { + graph.fans[i] = UnityEngine.Object.Instantiate(graph.fanPrefab, parent); + graph.fans[i].graph = graph; + } + + var fan = graph.fans[i]; + if (fan != null && fan.fanImage != null) + { + var img = fan.fanImage; + if (sprite != null) img.sprite = sprite; + img.type = Image.Type.Filled; + img.fillMethod = Image.FillMethod.Radial360; + img.fillOrigin = (int)Image.Origin360.Top; + img.fillClockwise = true; + img.fillAmount = (float)slice.fill; + + var c = overrideColor ?? + (graph.colors != null && graph.colors.Length > 0 + ? graph.colors[slice.index % graph.colors.Length] + : DefaultColors[slice.index % DefaultColors.Length]); + img.color = c; + img.rectTransform.localEulerAngles = new Vector3(0f, 0f, (float)(-slice.offset * 360.0)); + fan.gameObject.SetActive(true); + } + } + + // Deactivate unused fans + for (var j = count; j < graph.fans.Length; j++) + { + if (graph.fans[j] != null) + { + graph.fans[j].gameObject.SetActive(false); + } + } + } +} diff --git a/NebulaPatcher/Patches/Dynamic/UIStatisticsWindow_Patch.cs b/NebulaPatcher/Patches/Dynamic/UIStatisticsWindow_Patch.cs index 38e0959a8..43b5672a9 100644 --- a/NebulaPatcher/Patches/Dynamic/UIStatisticsWindow_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/UIStatisticsWindow_Patch.cs @@ -1,4 +1,4 @@ -#region +#region using System.Diagnostics.CodeAnalysis; using HarmonyLib; @@ -59,7 +59,7 @@ public static void AstroBoxToValue_Postfix(UIStatisticsWindow __instance) { if (!Multiplayer.IsActive || Multiplayer.Session.LocalPlayer.IsHost) return; - if (__instance.isStatisticsTab && __instance.lastAstroFilter != __instance.astroFilter) + if ((__instance.isStatisticsTab || __instance.isPowerTab) && __instance.lastAstroFilter != __instance.astroFilter) { if (__instance.astroFilter != 0) { From bbe853e451cb5dd0f2d6d4a9db54927018ee2b5e Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 18 Sep 2026 15:54:39 +0200 Subject: [PATCH 3/9] fix(shield): prevent relay stations from landing on shielded planets in multiplayer --- .../Patches/Dynamic/DFRelayComponent_Patch.cs | 49 +++++++++++++- .../Patches/Dynamic/PlanetATField_Patch.cs | 64 +++++++++++++++++++ .../Patches/Misc/Dedicated_Server_Patches.cs | 10 +-- 3 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 NebulaPatcher/Patches/Dynamic/PlanetATField_Patch.cs diff --git a/NebulaPatcher/Patches/Dynamic/DFRelayComponent_Patch.cs b/NebulaPatcher/Patches/Dynamic/DFRelayComponent_Patch.cs index 1108893f2..517092ac1 100644 --- a/NebulaPatcher/Patches/Dynamic/DFRelayComponent_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/DFRelayComponent_Patch.cs @@ -1,8 +1,9 @@ -#region +#region using HarmonyLib; using NebulaWorld; using NebulaModel.Packets.Combat.DFRelay; +using UnityEngine; #endregion @@ -22,11 +23,57 @@ public static bool SearchTargetPlaceProcess_Prefix() return false; } + [HarmonyPrefix] + [HarmonyPatch(nameof(DFRelayComponent.CheckLandCondition))] + public static bool CheckLandCondition_Prefix(PlanetFactory factory, Vector3 tarpos, ref bool __result) + { + if (!Multiplayer.IsActive) return true; + + var atField = factory?.planetATField; + if (atField != null && atField.energy > 0 && atField.generatorCount > 0) + { + if (atField.generatorCount >= 7 || + atField.globeDefenceCoveryRatio >= 0.95 || + atField.globeFillRatio >= 0.95 || + atField.isSpherical) + { + __result = false; + return false; + } + + if (!atField.TestRelayCondition(tarpos)) + { + __result = false; + return false; + } + } + + return true; + } + [HarmonyPrefix] [HarmonyPatch(nameof(DFRelayComponent.RealizePlanetBase))] public static bool RealizePlanetBase_Prefix(DFRelayComponent __instance) { if (!Multiplayer.IsActive) return true; + + if (Multiplayer.Session.IsServer) + { + var planet = GameMain.galaxy?.PlanetById(__instance.targetAstroId); + var atField = planet?.factory?.planetATField; + if (atField != null && atField.energy > 0 && atField.generatorCount > 0) + { + if (atField.generatorCount >= 7 || + atField.globeDefenceCoveryRatio >= 0.95 || + atField.globeFillRatio >= 0.95 || + atField.isSpherical) + { + NebulaModel.Logger.Log.Info($"Blocking relay {__instance.id} from realizing base on fully shielded planet {planet.name}"); + return false; + } + } + } + if (Multiplayer.Session.IsClient) return Multiplayer.Session.Enemies.IsIncomingRelayRequest; Multiplayer.Session.Network.SendPacket(new DFRelayRealizePlanetBasePacket(__instance)); diff --git a/NebulaPatcher/Patches/Dynamic/PlanetATField_Patch.cs b/NebulaPatcher/Patches/Dynamic/PlanetATField_Patch.cs new file mode 100644 index 000000000..4dec91a90 --- /dev/null +++ b/NebulaPatcher/Patches/Dynamic/PlanetATField_Patch.cs @@ -0,0 +1,64 @@ +#region + +using System; +using HarmonyLib; +using NebulaWorld; +using UnityEngine; + +#endregion + +namespace NebulaPatcher.Patches.Dynamic; + +[HarmonyPatch(typeof(PlanetATField))] +internal class PlanetATField_Patch +{ + [HarmonyPostfix] + [HarmonyPatch(nameof(PlanetATField.TestRelayCondition))] + public static void TestRelayCondition_Postfix(PlanetATField __instance, Vector3 relayPos, ref bool __result) + { + if (!Multiplayer.IsActive) return; + + // If vanilla check already rejected landing (result == false), preserve it + if (!__result) return; + + // If shields have energy and working generators + if (__instance.energy > 0 && __instance.generatorCount > 0) + { + // 1. Full globe coverage check (7+ generators, or 95%+ coverage ratio, or isSpherical) + if (__instance.generatorCount >= 7 || + __instance.globeDefenceCoveryRatio >= 0.95 || + __instance.globeFillRatio >= 0.95 || + __instance.isSpherical) + { + __result = false; + return; + } + + // 2. Point-based coverage check against active generators + if (__instance.generatorMatrix != null && relayPos.sqrMagnitude > 0.001f) + { + var relayDist = relayPos.magnitude; + var relayDir = relayPos / relayDist; + var count = Math.Min(__instance.generatorCount, __instance.generatorMatrix.Length); + + for (var i = 0; i < count; i++) + { + var gen = __instance.generatorMatrix[i]; + var genPos = new Vector3(gen.x, gen.y, gen.z); + var genDist = genPos.magnitude; + var radius = gen.w; + + if (genDist > 0.001f && radius > 0f) + { + var chordDist = Vector3.Distance(relayDir * genDist, genPos); + if (chordDist <= radius * 1.05f) + { + __result = false; + return; + } + } + } + } + } + } +} diff --git a/NebulaPatcher/Patches/Misc/Dedicated_Server_Patches.cs b/NebulaPatcher/Patches/Misc/Dedicated_Server_Patches.cs index 813d7630d..90eac5e10 100644 --- a/NebulaPatcher/Patches/Misc/Dedicated_Server_Patches.cs +++ b/NebulaPatcher/Patches/Misc/Dedicated_Server_Patches.cs @@ -1,4 +1,4 @@ -#region +#region using System; using System.Collections.Generic; @@ -208,12 +208,4 @@ public static bool RecalculatePhysicsShape_Prefix(PlanetATField __instance) return false; } - - [HarmonyPostfix] - [HarmonyPatch(typeof(PlanetATField), nameof(PlanetATField.TestRelayCondition))] - public static void StopLanding(PlanetATField __instance, ref bool __result) - { - // Balance: Stop relay landing when there are 7 or more working shield generators - __result &= !(__instance.energy > 0 && __instance.generatorCount >= 7); - } } From ffc4ea9a5298f00c61c187975c7d916ca59011b2 Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 18 Sep 2026 16:02:09 +0200 Subject: [PATCH 4/9] fix(shield): remove arbitrary generator count check in favor of real coverage ratio --- NebulaPatcher/Patches/Dynamic/DFRelayComponent_Patch.cs | 6 ++---- NebulaPatcher/Patches/Dynamic/PlanetATField_Patch.cs | 5 ++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/NebulaPatcher/Patches/Dynamic/DFRelayComponent_Patch.cs b/NebulaPatcher/Patches/Dynamic/DFRelayComponent_Patch.cs index 517092ac1..5ed8e9cb4 100644 --- a/NebulaPatcher/Patches/Dynamic/DFRelayComponent_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/DFRelayComponent_Patch.cs @@ -32,8 +32,7 @@ public static bool CheckLandCondition_Prefix(PlanetFactory factory, Vector3 tarp var atField = factory?.planetATField; if (atField != null && atField.energy > 0 && atField.generatorCount > 0) { - if (atField.generatorCount >= 7 || - atField.globeDefenceCoveryRatio >= 0.95 || + if (atField.globeDefenceCoveryRatio >= 0.95 || atField.globeFillRatio >= 0.95 || atField.isSpherical) { @@ -63,8 +62,7 @@ public static bool RealizePlanetBase_Prefix(DFRelayComponent __instance) var atField = planet?.factory?.planetATField; if (atField != null && atField.energy > 0 && atField.generatorCount > 0) { - if (atField.generatorCount >= 7 || - atField.globeDefenceCoveryRatio >= 0.95 || + if (atField.globeDefenceCoveryRatio >= 0.95 || atField.globeFillRatio >= 0.95 || atField.isSpherical) { diff --git a/NebulaPatcher/Patches/Dynamic/PlanetATField_Patch.cs b/NebulaPatcher/Patches/Dynamic/PlanetATField_Patch.cs index 4dec91a90..883ed854c 100644 --- a/NebulaPatcher/Patches/Dynamic/PlanetATField_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/PlanetATField_Patch.cs @@ -24,9 +24,8 @@ public static void TestRelayCondition_Postfix(PlanetATField __instance, Vector3 // If shields have energy and working generators if (__instance.energy > 0 && __instance.generatorCount > 0) { - // 1. Full globe coverage check (7+ generators, or 95%+ coverage ratio, or isSpherical) - if (__instance.generatorCount >= 7 || - __instance.globeDefenceCoveryRatio >= 0.95 || + // 1. Full globe coverage check (95%+ coverage ratio or isSpherical) + if (__instance.globeDefenceCoveryRatio >= 0.95 || __instance.globeFillRatio >= 0.95 || __instance.isSpherical) { From 9c0d1a12e760de6893d3b243f1b3cf9803b4ec47 Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 18 Sep 2026 19:05:55 +0200 Subject: [PATCH 5/9] fix: render concentric dual ring power graphs on clients and fix IndexOutOfRangeException --- .../UIStatisticsPowerDetailPanel_Patch.cs | 355 ++++++++++++------ 1 file changed, 248 insertions(+), 107 deletions(-) diff --git a/NebulaPatcher/Patches/Dynamic/UIStatisticsPowerDetailPanel_Patch.cs b/NebulaPatcher/Patches/Dynamic/UIStatisticsPowerDetailPanel_Patch.cs index 9a1c7488d..8aa6664a6 100644 --- a/NebulaPatcher/Patches/Dynamic/UIStatisticsPowerDetailPanel_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/UIStatisticsPowerDetailPanel_Patch.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Text; using HarmonyLib; using NebulaModel.Logger; using NebulaWorld; @@ -15,31 +16,31 @@ namespace NebulaPatcher.Patches.Dynamic; [HarmonyPatch] internal class UIStatisticsPowerDetailPanel_Patch { - private static readonly Color[] DefaultColors = - [ - new Color(0.2f, 0.75f, 1.0f, 1f), // Bright Blue - new Color(1.0f, 0.65f, 0.2f, 1f), // Orange - new Color(0.3f, 0.9f, 0.4f, 1f), // Green - new Color(1.0f, 0.85f, 0.2f, 1f), // Yellow - new Color(0.9f, 0.35f, 0.35f, 1f), // Red/Coral - new Color(0.7f, 0.4f, 1.0f, 1f), // Purple - new Color(0.2f, 0.9f, 0.9f, 1f), // Cyan - new Color(1.0f, 0.4f, 0.7f, 1f), // Pink - new Color(0.6f, 0.8f, 0.2f, 1f), // Lime - new Color(0.9f, 0.5f, 0.1f, 1f), // Amber - new Color(0.4f, 0.6f, 1.0f, 1f), // Soft Blue - new Color(0.8f, 0.8f, 0.8f, 1f) // Light Gray - ]; + private static readonly Color CyanColor = new(0.2f, 0.75f, 1.0f, 1f); + private static readonly Color OrangeColor = new(1.0f, 0.65f, 0.2f, 1f); private struct SliceInfo { - public int index; + public bool isOrange; + public int level; + public int parent; public string name; public double value; public double fill; public double offset; } + [HarmonyFinalizer] + [HarmonyPatch(typeof(UISectorGraph), nameof(UISectorGraph.Refresh))] + public static Exception UISectorGraph_Refresh_Finalizer(Exception __exception) + { + if (__exception != null && Multiplayer.IsActive && !Multiplayer.Session.LocalPlayer.IsHost) + { + return null; + } + return __exception; + } + [HarmonyFinalizer] [HarmonyPatch(typeof(UIStatisticsPowerDetailPanel), nameof(UIStatisticsPowerDetailPanel.RefreshPowerDatas))] public static Exception RefreshPowerDatas_Finalizer(Exception __exception) @@ -73,6 +74,17 @@ public static Exception RefreshGraphs_Finalizer(Exception __exception, UIStatist return null; // Suppress any exception in base method } + [HarmonyFinalizer] + [HarmonyPatch(typeof(UIStatisticsPowerDetailPanel), nameof(UIStatisticsPowerDetailPanel.RefreshDetailEntries))] + public static Exception RefreshDetailEntries_Finalizer(Exception __exception) + { + if (__exception != null && Multiplayer.IsActive && !Multiplayer.Session.LocalPlayer.IsHost) + { + return null; + } + return __exception; + } + [HarmonyFinalizer] [HarmonyPatch(typeof(UIChartAstroPower), nameof(UIChartAstroPower.RefreshGraphs))] public static Exception UIChartAstroPower_RefreshGraphs_Finalizer(Exception __exception, UIChartAstroPower __instance) @@ -96,21 +108,37 @@ public static Exception UIChartAstroPower_RefreshGraphs_Finalizer(Exception __ex private static void ApplyPowerPanelGraphsFix(UIStatisticsPowerDetailPanel panel) { - // 1. Generation Small Pie Chart - var genSlices = CollectSlices(panel.powerGenEntries, out var totalGen); + // 1. Generation Small Pie Chart (Top Center) + var genSlices = CollectSlices(panel.powerGenEntries, out var totalGen, wantOrange: false); if (panel.powerGenGraphSmall != null) { UpdateSectorGraph(panel.powerGenGraphSmall, genSlices); + if (panel.powerGenGraphSmall.subText != null && string.IsNullOrEmpty(panel.powerGenGraphSmall.subText.text)) + { + panel.powerGenGraphSmall.subText.text = "Generation".Translate(); + } + if (panel.powerGenGraphSmall.mainText != null) + { + panel.powerGenGraphSmall.mainText.text = FormatPower(panel.sb1, totalGen); + } } - // 2. Consumption Small Pie Chart - var conSlices = CollectSlices(panel.powerConEntries, out var totalCon); + // 2. Consumption Small Pie Chart (Bottom Center) + var conSlices = CollectSlices(panel.powerConEntries, out var totalCon, wantOrange: true); if (panel.powerConGraphSmall != null) { UpdateSectorGraph(panel.powerConGraphSmall, conSlices); + if (panel.powerConGraphSmall.subText != null && string.IsNullOrEmpty(panel.powerConGraphSmall.subText.text)) + { + panel.powerConGraphSmall.subText.text = "Consumption".Translate(); + } + if (panel.powerConGraphSmall.mainText != null) + { + panel.powerConGraphSmall.mainText.text = FormatPower(panel.sb2, totalCon); + } } - // 3. Large Graph on the Left (Sufficiency or selected breakdown) + // 3. Large Graph on the Left (Sufficiency dual concentric rings or selected breakdown) var largeGraph = panel.powerGenGraphLarge != null && panel.powerGenGraphLarge.gameObject.activeInHierarchy ? panel.powerGenGraphLarge : (panel.powerConGraphLarge != null && panel.powerConGraphLarge.gameObject.activeInHierarchy ? panel.powerConGraphLarge : null); @@ -118,76 +146,112 @@ private static void ApplyPowerPanelGraphsFix(UIStatisticsPowerDetailPanel panel) if (largeGraph != null) { var sub = largeGraph.subText != null ? largeGraph.subText.text : ""; - var main = largeGraph.mainText != null ? largeGraph.mainText.text : ""; + var isGenMode = string.Equals(sub, "Generation".Translate(), StringComparison.OrdinalIgnoreCase) || + sub.IndexOf("Generation", StringComparison.OrdinalIgnoreCase) >= 0; + var isConMode = (largeGraph == panel.powerConGraphLarge) || + string.Equals(sub, "Consumption".Translate(), StringComparison.OrdinalIgnoreCase) || + sub.IndexOf("Consumption", StringComparison.OrdinalIgnoreCase) >= 0; - if (sub.IndexOf("Sufficiency", StringComparison.OrdinalIgnoreCase) >= 0 || - main.IndexOf("%", StringComparison.OrdinalIgnoreCase) >= 0 || - sub.Length == 0) - { - // Display sufficiency gauge - var ratio = totalCon > 0 ? totalGen / totalCon : (totalGen > 0 ? 1.0 : 0.0); - var fill = Math.Min(1.0, Math.Max(0.0, ratio)); - Color suffColor; - if (ratio >= 1.0) - suffColor = new Color(0.2f, 0.75f, 1.0f, 1f); // Cyan - else if (ratio >= 0.8) - suffColor = new Color(0.3f, 0.85f, 0.4f, 1f); // Green - else if (ratio >= 0.5) - suffColor = new Color(1.0f, 0.65f, 0.2f, 1f); // Orange - else - suffColor = new Color(0.9f, 0.25f, 0.25f, 1f); // Red - - var suffSlices = new List - { - new() - { - index = 0, - name = sub.Length > 0 ? sub : "Sufficiency", - value = ratio, - fill = fill, - offset = 0.0 - } - }; - UpdateSectorGraph(largeGraph, suffSlices, suffColor); - } - else if (sub.IndexOf("Generation", StringComparison.OrdinalIgnoreCase) >= 0) + if (isGenMode) { UpdateSectorGraph(largeGraph, genSlices); + if (largeGraph.mainText != null) + { + largeGraph.mainText.text = FormatPower(panel.sb3, totalGen); + } } - else if (sub.IndexOf("Consumption", StringComparison.OrdinalIgnoreCase) >= 0) + else if (isConMode) { UpdateSectorGraph(largeGraph, conSlices); + if (largeGraph.mainText != null) + { + largeGraph.mainText.text = FormatPower(panel.sb3, totalCon); + } } else { - // Default to sufficiency - var ratio = totalCon > 0 ? totalGen / totalCon : (totalGen > 0 ? 1.0 : 0.0); - var fill = Math.Min(1.0, Math.Max(0.0, ratio)); - var suffSlices = new List + // Default: Sufficiency Mode with Dual Concentric Rings (identical to singleplayer vanilla DSP) + var suffSlices = new List(); + var ratio = totalCon > 0 ? (totalGen / totalCon) : (totalGen > 0 ? 1.0 : 0.0); + var suffFill = Math.Min(1.0, Math.Max(0.0, ratio)); + + // Outer Ring (Level 0, Cyan/Blue): Sufficiency percentage + suffSlices.Add(new SliceInfo + { + isOrange = false, + level = 0, + parent = -1, + name = "Sufficiency".Translate(), + value = totalGen, + fill = suffFill, + offset = 0.0 + }); + + // Inner Ring (Level 1, Warm Orange): Machine consumption breakdown relative to capacity + if (panel.powerConEntries != null && panel.powerConEntries.Count > 0) { - new() + var normalizer = Math.Max(totalGen, totalCon); + var currentInnerOffset = 0.0; + + for (var i = 0; i < panel.powerConEntries.Count; i++) { - index = 0, - name = "Sufficiency", - value = ratio, - fill = fill, - offset = 0.0 + var entry = panel.powerConEntries[i]; + if (entry != null && entry.gameObject.activeSelf && entry.power > 0) + { + var sliceFill = normalizer > 0 ? (entry.power / normalizer) : 0.0; + var name = entry.itemNameText != null ? entry.itemNameText.text : ""; + if (string.IsNullOrEmpty(name) && entry.itemId > 0) + { + name = LDB.items?.Select(entry.itemId)?.Name ?? ""; + } + + suffSlices.Add(new SliceInfo + { + isOrange = true, + level = 1, + parent = 0, + name = name, + value = entry.power, + fill = sliceFill, + offset = currentInnerOffset + }); + currentInnerOffset += sliceFill; + } } - }; - UpdateSectorGraph(largeGraph, suffSlices, new Color(0.2f, 0.75f, 1.0f, 1f)); + } + + UpdateSectorGraph(largeGraph, suffSlices); + + if (largeGraph.subText != null) + { + largeGraph.subText.text = "Sufficiency".Translate(); + } + + if (largeGraph.mainText != null) + { + largeGraph.mainText.text = (ratio * 100.0).ToString("0.0") + "%"; + if (ratio >= 1.0) + largeGraph.mainText.color = CyanColor; + else if (ratio >= 0.8) + largeGraph.mainText.color = new Color(0.3f, 0.85f, 0.4f, 1f); // Green + else if (ratio >= 0.5) + largeGraph.mainText.color = OrangeColor; + else + largeGraph.mainText.color = new Color(0.9f, 0.25f, 0.25f, 1f); // Red + } } } } private static void ApplyChartAstroPowerFix(UIChartAstroPower chart) { - var genSlices = CollectSlices(chart.powerGenEntries, out var totalGen); + var genSlices = CollectSlices(chart.powerGenEntries, out var totalGen, wantOrange: false); if (chart.genSectorGraph != null) { UpdateSectorGraph(chart.genSectorGraph, genSlices); } - var conSlices = CollectSlices(chart.powerConEntries, out var totalCon); + var conSlices = CollectSlices(chart.powerConEntries, out var totalCon, wantOrange: true); if (chart.conSectorGraph != null) { UpdateSectorGraph(chart.conSectorGraph, conSlices); @@ -208,7 +272,7 @@ private static void ApplyChartAstroPowerFix(UIChartAstroPower chart) } } - private static List CollectSlices(List entries, out double totalPower) + private static List CollectSlices(List entries, out double totalPower, bool wantOrange) { totalPower = 0; var list = new List(); @@ -226,7 +290,6 @@ private static List CollectSlices(List if (totalPower <= 0) return list; var currentOffset = 0.0; - var sliceIdx = 0; for (var i = 0; i < entries.Count; i++) { var entry = entries[i]; @@ -234,9 +297,16 @@ private static List CollectSlices(List { var fill = entry.power / totalPower; var name = entry.itemNameText != null ? entry.itemNameText.text : ""; + if (string.IsNullOrEmpty(name) && entry.itemId > 0) + { + name = LDB.items?.Select(entry.itemId)?.Name ?? ""; + } + list.Add(new SliceInfo { - index = sliceIdx++, + isOrange = wantOrange, + level = 0, + parent = -1, name = name, value = entry.power, fill = fill, @@ -249,10 +319,45 @@ private static List CollectSlices(List return list; } - private static void UpdateSectorGraph(UISectorGraph graph, List slices, Color? overrideColor = null) + private static int GetColorIndex(UISectorGraph graph, bool wantOrange) + { + if (graph.colors == null || graph.colors.Length == 0) return 0; + if (graph.colors.Length == 1) return 0; + + for (var i = 0; i < graph.colors.Length; i++) + { + var isOrange = graph.colors[i].r > graph.colors[i].b; + if (isOrange == wantOrange) + { + return i; + } + } + + return wantOrange ? Math.Min(1, graph.colors.Length - 1) : 0; + } + + private static void UpdateSectorGraph(UISectorGraph graph, List slices) { if (graph == null) return; + // Ensure palette has both cyan and orange + if (graph.colors == null || graph.colors.Length == 0) + { + graph.colors = new[] { CyanColor, OrangeColor }; + } + else if (graph.colors.Length == 1) + { + var c0 = graph.colors[0]; + var c1 = c0.r > c0.b ? CyanColor : OrangeColor; + graph.colors = new[] { c0, c1 }; + } + + // Ensure tmp_sum has capacity for multi-level calculations + if (graph.tmp_sum == null || graph.tmp_sum.Length < 4) + { + graph.tmp_sum = new double[4]; + } + var count = slices != null ? slices.Count : 0; // Ensure fanDatas capacity @@ -271,42 +376,41 @@ private static void UpdateSectorGraph(UISectorGraph graph, List slice graph.fans = newFans; } + var maxLevel = Math.Max(0, (graph.levelGroups?.Length ?? 1) - 1); + // Populate FanDatas for (var i = 0; i < count; i++) { var slice = slices[i]; - graph.fanDatas[i].index = slice.index; + var colorIdx = GetColorIndex(graph, slice.isOrange); + var level = Math.Max(0, Math.Min(slice.level, maxLevel)); + + graph.fanDatas[i].index = colorIdx; graph.fanDatas[i].name = slice.name; graph.fanDatas[i].value = slice.value; graph.fanDatas[i].fill = slice.fill; graph.fanDatas[i].offset = slice.offset; graph.fanDatas[i].cursor = slice.offset + slice.fill * 0.5; - graph.fanDatas[i].level = 0; - graph.fanDatas[i].parent = -1; + graph.fanDatas[i].level = level; + graph.fanDatas[i].parent = slice.parent; } graph.fanCount = count; - // Try game's native Refresh first - try - { - graph.Refresh(); - } - catch + // Ensure fans are instantiated, correctly parented, and configured + for (var i = 0; i < count; i++) { - // Fall back to direct instantiation/setup below - } + var slice = slices[i]; + var level = Math.Max(0, Math.Min(slice.level, maxLevel)); - // Direct instantiation & configuration guarantee - Transform parent = graph.levelGroups != null && graph.levelGroups.Length > 0 && graph.levelGroups[0] != null - ? graph.levelGroups[0] - : graph.rectTrans; + var parent = (graph.levelGroups != null && level < graph.levelGroups.Length && graph.levelGroups[level] != null) + ? graph.levelGroups[level] + : graph.rectTrans; - Sprite sprite = graph.levelSprites != null && graph.levelSprites.Length > 0 ? graph.levelSprites[0] : null; + var sprite = (graph.levelSprites != null && level < graph.levelSprites.Length) + ? graph.levelSprites[level] + : null; - for (var i = 0; i < count; i++) - { - var slice = slices[i]; if (graph.fans[i] == null && graph.fanPrefab != null) { graph.fans[i] = UnityEngine.Object.Instantiate(graph.fanPrefab, parent); @@ -314,23 +418,29 @@ private static void UpdateSectorGraph(UISectorGraph graph, List slice } var fan = graph.fans[i]; - if (fan != null && fan.fanImage != null) + if (fan != null) { - var img = fan.fanImage; - if (sprite != null) img.sprite = sprite; - img.type = Image.Type.Filled; - img.fillMethod = Image.FillMethod.Radial360; - img.fillOrigin = (int)Image.Origin360.Top; - img.fillClockwise = true; - img.fillAmount = (float)slice.fill; - - var c = overrideColor ?? - (graph.colors != null && graph.colors.Length > 0 - ? graph.colors[slice.index % graph.colors.Length] - : DefaultColors[slice.index % DefaultColors.Length]); - img.color = c; - img.rectTransform.localEulerAngles = new Vector3(0f, 0f, (float)(-slice.offset * 360.0)); - fan.gameObject.SetActive(true); + if (fan.transform.parent != parent) + { + fan.transform.SetParent(parent, false); + } + + if (fan.fanImage != null) + { + var img = fan.fanImage; + if (sprite != null) img.sprite = sprite; + img.type = Image.Type.Filled; + img.fillMethod = Image.FillMethod.Radial360; + img.fillOrigin = (int)Image.Origin360.Top; + img.fillClockwise = true; + img.fillAmount = (float)slice.fill; + + var colorIdx = GetColorIndex(graph, slice.isOrange); + img.color = graph.colors[colorIdx]; + img.rectTransform.localEulerAngles = new Vector3(0f, 0f, (float)(-slice.offset * 360.0)); + } + + fan.gameObject.SetActive(slice.fill > 0.0001); } } @@ -342,5 +452,36 @@ private static void UpdateSectorGraph(UISectorGraph graph, List slice graph.fans[j].gameObject.SetActive(false); } } + + // Run game's native Refresh for tooltip bounds and totals + try + { + graph.Refresh(); + } + catch + { + // Silently ignore + } + } + + private static string FormatPower(StringBuilder sb, double power) + { + if (sb == null) sb = new StringBuilder(); + sb.Clear(); + try + { + StringBuilderUtility.WriteKMGPower(sb, 0, (long)power, false); + return sb.ToString(); + } + catch + { + if (power >= 1_000_000_000) + return $"{(power / 1_000_000_000):0.00} GW"; + if (power >= 1_000_000) + return $"{(power / 1_000_000):0.00} MW"; + if (power >= 1_000) + return $"{(power / 1_000):0.00} kW"; + return $"{power:0} W"; + } } } From deeaa7c601083d71dc94c5b5f8bae100b4f8277b Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 18 Sep 2026 19:28:57 +0200 Subject: [PATCH 6/9] fix(combat): restore singleplayer combat behavior for Dark Fog enemies and turrets --- .../Patches/Dynamic/DFGBaseComponent_Patch.cs | 14 ++++-- .../Dynamic/DFGTurretComponent_Patch.cs | 16 ++++--- .../Dynamic/DFSTurretComponent_Patch.cs | 5 +-- .../Dynamic/EnemyDFHiveSystem_Patch.cs | 6 +-- .../EnemyDFGroundSystem_Transpiler.cs | 43 ++++++++++++++++++- 5 files changed, 66 insertions(+), 18 deletions(-) diff --git a/NebulaPatcher/Patches/Dynamic/DFGBaseComponent_Patch.cs b/NebulaPatcher/Patches/Dynamic/DFGBaseComponent_Patch.cs index 00c1b029f..de5bc31a7 100644 --- a/NebulaPatcher/Patches/Dynamic/DFGBaseComponent_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/DFGBaseComponent_Patch.cs @@ -16,10 +16,18 @@ internal class DFGBaseComponent_Patch { [HarmonyPrefix] [HarmonyPatch(nameof(DFGBaseComponent.UnderAttack), new Type[] { })] - public static bool UnderAttack_Prefix1() + public static bool UnderAttack_Prefix1(DFGBaseComponent __instance) { - // Handle in PlayerAction_Combat.ActivateBaseEnemyManually - return !Multiplayer.IsActive; + if (!Multiplayer.IsActive) return true; + if (Multiplayer.Session.IsClient) return false; + + // Restore singleplayer behavior: When base takes damage (e.g. from turrets/missiles/drones), wake up defense units + if (__instance.activeTick <= 0) + { + __instance.activeTick = 3; + __instance.ActiveAllUnit(GameMain.gameTick); + } + return false; } [HarmonyPrefix] diff --git a/NebulaPatcher/Patches/Dynamic/DFGTurretComponent_Patch.cs b/NebulaPatcher/Patches/Dynamic/DFGTurretComponent_Patch.cs index c591cac69..7b8561ac2 100644 --- a/NebulaPatcher/Patches/Dynamic/DFGTurretComponent_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/DFGTurretComponent_Patch.cs @@ -1,4 +1,4 @@ -#region +#region using System.Linq; using HarmonyLib; @@ -32,7 +32,10 @@ public static bool TargetIsInRange_Prefix(ref DFGTurretComponent __instance, Pla { ref var ptr = ref players[i]; dist2 = Vector3.SqrMagnitude(ptr.skillTargetL - __instance.muzzleWPos); - __result = ptr.isAlive && (__instance.CounterAttackPlayer(factory, @base) || dist2 <= (__instance.sensorRange * __instance.sensorRange)); + // Allow counter-attack when provoked up to realistic mortar/artillery distance (250m) + var maxCounterDist2 = Mathf.Max(__instance.realAttactRange * __instance.realAttactRange * 2.25f, 62500f); + var canCounter = __instance.CounterAttackPlayer(factory, @base) && dist2 <= maxCounterDist2; + __result = ptr.isAlive && (canCounter || dist2 <= (__instance.sensorRange * __instance.sensorRange)); return false; } } @@ -79,7 +82,9 @@ public static bool SearchTarget(ref DFGTurretComponent __instance, PlanetFactory return true; } + var maxCounterDist2 = Mathf.Max(__instance.realAttactRange * __instance.realAttactRange * 2.25f, 62500f); var counterAttackFlag = __instance.CounterAttackPlayer(factory, @base) + && closestDist <= maxCounterDist2 && (__instance.target.type != ETargetType.Player || __instance.target.id != playerId); if (counterAttackFlag || closestDist <= __instance.realAttactRange * __instance.realAttactRange) { @@ -117,9 +122,10 @@ public static void Aim(ref DFGTurretComponent __instance, PlanetFactory factory) [HarmonyPostfix] [HarmonyPatch(nameof(DFGTurretComponent.CounterAttackPlayer))] - public static void CounterAttackPlayer(ref bool __result) + public static void CounterAttackPlayer(DFGBaseComponent @base, ref bool __result) { - // Disable in MP due to unknown bug that cause host player gets hit from nowhere - __result &= !Multiplayer.IsActive; + if (!Multiplayer.IsActive) return; + // In MP: Restore counter-attacks when base is provoked and has hatred + __result = @base != null && @base.hatred.max.value > 0; } } diff --git a/NebulaPatcher/Patches/Dynamic/DFSTurretComponent_Patch.cs b/NebulaPatcher/Patches/Dynamic/DFSTurretComponent_Patch.cs index 764d2c846..eeb8f3c10 100644 --- a/NebulaPatcher/Patches/Dynamic/DFSTurretComponent_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/DFSTurretComponent_Patch.cs @@ -1,4 +1,4 @@ -#region +#region using System; using HarmonyLib; @@ -100,8 +100,7 @@ public static bool SearchTarget(ref DFSTurretComponent __instance, EnemyDFHiveSy var dy = vectorLF2.y - ptr.pos.y; var dz = vectorLF2.z - ptr.pos.z; var sqrDist = (float)(dx * dx + dy * dy + dz * dz); - var coef = ((hive.hatred.max.targetType == ETargetType.Player) ? 1f : 0.64f); - if (sqrDist <= sqrRealAttackRange * coef && sqrDist < sqrDistToTarget) + if (sqrDist <= sqrRealAttackRange && sqrDist < sqrDistToTarget) { etargetType = ETargetType.Player; targetId = players[cloestIndex].id; // Set to playerId diff --git a/NebulaPatcher/Patches/Dynamic/EnemyDFHiveSystem_Patch.cs b/NebulaPatcher/Patches/Dynamic/EnemyDFHiveSystem_Patch.cs index 64e2fb497..c68ce5b7a 100644 --- a/NebulaPatcher/Patches/Dynamic/EnemyDFHiveSystem_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/EnemyDFHiveSystem_Patch.cs @@ -1,4 +1,4 @@ -#region +#region using System; using HarmonyLib; @@ -64,10 +64,6 @@ public static bool AssaultingWavesDetermineAI_Prefix(EnemyDFHiveSystem __instanc { __instance.lancerAssaultCountBase = __instance.GetLancerAssaultCountInitial(aggressiveLevel); } - if (__instance.lancerAssaultCountBase > 360f) - { - __instance.lancerAssaultCountBase = 360f; - } // Skip the part of if (this.evolve.threat >= this.evolve.maxThreat) in client return false; } diff --git a/NebulaPatcher/Patches/Transpilers/EnemyDFGroundSystem_Transpiler.cs b/NebulaPatcher/Patches/Transpilers/EnemyDFGroundSystem_Transpiler.cs index 8c32fcf40..354a4b682 100644 --- a/NebulaPatcher/Patches/Transpilers/EnemyDFGroundSystem_Transpiler.cs +++ b/NebulaPatcher/Patches/Transpilers/EnemyDFGroundSystem_Transpiler.cs @@ -7,6 +7,7 @@ using NebulaModel.Logger; using NebulaModel.Packets.Combat.GroundEnemy; using NebulaWorld; +using UnityEngine; #endregion @@ -188,10 +189,24 @@ private static void SyncHatredTarget(EnemyDFGroundSystem groundSystem, ref Enemy if (Multiplayer.Session.IsServer) { - // TODO: Sync fighter drones in future. For now stop enemy unit from targeting craft. if (enemyUnit.hatred.max.objectType == EObjectType.Craft) { - enemyUnit.hatred.ClearMax(); + var craftPool = groundSystem.factory?.craftPool; + var craftId = enemyUnit.hatred.max.objectId; + var craftValid = craftPool != null && craftId > 0 && craftId < craftPool.Length && craftPool[craftId].id == craftId; + if (!craftValid) + { + // If the craft is not in the server's local pool (e.g. spawned by client), target the nearest alive player on this planet + var nearestPlayerId = GetNearestPlayerId(planetId, enemyUnit.enemyId, groundSystem); + if (nearestPlayerId > 0) + { + enemyUnit.hatred.HateTarget(ETargetType.Player, nearestPlayerId, 500, 500, EHatredOperation.Set); + } + else + { + enemyUnit.hatred.ClearMax(); + } + } } var currentTarget = enemyUnit.hatred.max.target; if (targets[enemyId] != currentTarget) @@ -209,6 +224,30 @@ private static void SyncHatredTarget(EnemyDFGroundSystem groundSystem, ref Enemy } } + private static int GetNearestPlayerId(int planetId, int enemyId, EnemyDFGroundSystem groundSystem) + { + var players = Multiplayer.Session.Combat.Players; + if (players == null || players.Length == 0 || groundSystem?.factory?.enemyPool == null) return 0; + ref var enemy = ref groundSystem.factory.enemyPool[enemyId]; + var enemyPos = (Vector3)enemy.pos; + + var nearestId = 0; + var nearestDist2 = float.MaxValue; + for (var i = 0; i < players.Length; i++) + { + if (players[i].planetId == planetId && players[i].isAlive) + { + var d2 = Vector3.SqrMagnitude(players[i].position - enemyPos); + if (d2 < nearestDist2) + { + nearestDist2 = d2; + nearestId = players[i].id; + } + } + } + return nearestId; + } + [HarmonyTranspiler] [HarmonyPatch(nameof(EnemyDFGroundSystem.DeactivateUnit))] public static IEnumerable DeactivateUnit_Transpiler(IEnumerable instructions) From 380c508baa36e37992ce76cbf659a070499dbfb5 Mon Sep 17 00:00:00 2001 From: Joshua Date: Sat, 19 Sep 2026 12:15:42 +0200 Subject: [PATCH 7/9] fix(power): add UISectorGraph._OnUpdate crash immunity and restore singleplayer group slices --- .../UIStatisticsPowerDetailPanel_Patch.cs | 207 +++++++++++++++--- 1 file changed, 171 insertions(+), 36 deletions(-) diff --git a/NebulaPatcher/Patches/Dynamic/UIStatisticsPowerDetailPanel_Patch.cs b/NebulaPatcher/Patches/Dynamic/UIStatisticsPowerDetailPanel_Patch.cs index 8aa6664a6..4baddade7 100644 --- a/NebulaPatcher/Patches/Dynamic/UIStatisticsPowerDetailPanel_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/UIStatisticsPowerDetailPanel_Patch.cs @@ -16,8 +16,32 @@ namespace NebulaPatcher.Patches.Dynamic; [HarmonyPatch] internal class UIStatisticsPowerDetailPanel_Patch { - private static readonly Color CyanColor = new(0.2f, 0.75f, 1.0f, 1f); - private static readonly Color OrangeColor = new(1.0f, 0.65f, 0.2f, 1f); + private static readonly Color CyanColor = new(0.00f, 0.85f, 1.00f, 1f); + private static readonly Color OrangeColor = new(1.00f, 0.60f, 0.05f, 1f); + + private static readonly Color[] CyanPalette = new Color[] + { + new(0.00f, 0.85f, 1.00f, 1f), // Bright Cyan + new(0.12f, 0.72f, 0.95f, 1f), // Sky Blue + new(0.25f, 0.88f, 0.95f, 1f), // Light Aqua + new(0.05f, 0.60f, 0.85f, 1f), // Deep Cyan + new(0.35f, 0.92f, 1.00f, 1f), // Vivid Azure + new(0.00f, 0.78f, 0.88f, 1f), // Teal Cyan + new(0.20f, 0.65f, 0.92f, 1f), // Ocean Blue + new(0.40f, 0.80f, 1.00f, 1f), // Ice Blue + }; + + private static readonly Color[] OrangePalette = new Color[] + { + new(1.00f, 0.60f, 0.05f, 1f), // Warm Amber Orange + new(1.00f, 0.45f, 0.15f, 1f), // Deep Coral Orange + new(1.00f, 0.72f, 0.20f, 1f), // Bright Golden Orange + new(0.95f, 0.35f, 0.10f, 1f), // Fiery Orange + new(1.00f, 0.80f, 0.35f, 1f), // Light Amber + new(0.90f, 0.40f, 0.05f, 1f), // Burnt Orange + new(1.00f, 0.55f, 0.25f, 1f), // Warm Coral + new(0.85f, 0.30f, 0.00f, 1f), // Dark Rust Orange + }; private struct SliceInfo { @@ -30,6 +54,25 @@ private struct SliceInfo public double offset; } + [HarmonyPrefix] + [HarmonyPatch(typeof(UISectorGraph), nameof(UISectorGraph._OnUpdate))] + public static void UISectorGraph_OnUpdate_Prefix(UISectorGraph __instance) + { + if (!Multiplayer.IsActive || Multiplayer.Session.LocalPlayer.IsHost || __instance == null) return; + SanitizeSectorGraphState(__instance); + } + + [HarmonyFinalizer] + [HarmonyPatch(typeof(UISectorGraph), nameof(UISectorGraph._OnUpdate))] + public static Exception UISectorGraph_OnUpdate_Finalizer(Exception __exception) + { + if (__exception != null && Multiplayer.IsActive && !Multiplayer.Session.LocalPlayer.IsHost) + { + return null; // Suppress client-side exception so UI update loop never crashes + } + return __exception; + } + [HarmonyFinalizer] [HarmonyPatch(typeof(UISectorGraph), nameof(UISectorGraph.Refresh))] public static Exception UISectorGraph_Refresh_Finalizer(Exception __exception) @@ -209,7 +252,7 @@ private static void ApplyPowerPanelGraphsFix(UIStatisticsPowerDetailPanel panel) { isOrange = true, level = 1, - parent = 0, + parent = -1, name = name, value = entry.power, fill = sliceFill, @@ -319,44 +362,125 @@ private static List CollectSlices(List return list; } - private static int GetColorIndex(UISectorGraph graph, bool wantOrange) + private static void SanitizeSectorGraphState(UISectorGraph graph) { - if (graph.colors == null || graph.colors.Length == 0) return 0; - if (graph.colors.Length == 1) return 0; + if (graph == null) return; - for (var i = 0; i < graph.colors.Length; i++) + // 1. Ensure colors has ample capacity (at least 32 distinct colors) + if (graph.colors == null || graph.colors.Length < 32) { - var isOrange = graph.colors[i].r > graph.colors[i].b; - if (isOrange == wantOrange) + var oldColors = graph.colors; + graph.colors = new Color[32]; + for (var i = 0; i < 32; i++) { - return i; + if (oldColors != null && i < oldColors.Length) + graph.colors[i] = oldColors[i]; + else + graph.colors[i] = i < 16 ? CyanPalette[i % CyanPalette.Length] : OrangePalette[i % OrangePalette.Length]; } } - return wantOrange ? Math.Min(1, graph.colors.Length - 1) : 0; - } + // 2. Ensure levelRanges has at least 8 elements (supports up to 4 levels) + if (graph.levelRanges == null || graph.levelRanges.Length < 8) + { + var oldRanges = graph.levelRanges; + var r0 = (oldRanges != null && oldRanges.Length > 0) ? oldRanges[0] : 60f; + var r1 = (oldRanges != null && oldRanges.Length > 1) ? oldRanges[1] : 90f; + graph.levelRanges = new float[8] + { + r0, r1, // Level 0: Outer ring + r0 * 0.72f, r1 * 0.82f, // Level 1: Inner ring + r0 * 0.45f, r1 * 0.55f, // Level 2 + r0 * 0.20f, r1 * 0.30f // Level 3 + }; + } - private static void UpdateSectorGraph(UISectorGraph graph, List slices) - { - if (graph == null) return; + // 3. Ensure tmp_sum has at least 8 elements + if (graph.tmp_sum == null || graph.tmp_sum.Length < 8) + { + graph.tmp_sum = new double[8]; + } - // Ensure palette has both cyan and orange - if (graph.colors == null || graph.colors.Length == 0) + // 4. Ensure levelGroups has at least 4 elements + if (graph.levelGroups == null || graph.levelGroups.Length < 4) { - graph.colors = new[] { CyanColor, OrangeColor }; + var newGroups = new RectTransform[4]; + if (graph.levelGroups != null) + { + for (var i = 0; i < graph.levelGroups.Length && i < 4; i++) + newGroups[i] = graph.levelGroups[i]; + } + if (newGroups[0] == null) newGroups[0] = graph.rectTrans; + for (var i = 1; i < 4; i++) + { + if (newGroups[i] == null) + { + var childName = $"LevelGroup_{i}"; + var existing = graph.rectTrans != null ? graph.rectTrans.Find(childName) : null; + if (existing != null) + { + newGroups[i] = existing.GetComponent(); + } + else if (graph.rectTrans != null) + { + var go = new GameObject(childName, typeof(RectTransform)); + var rt = go.GetComponent(); + rt.SetParent(graph.rectTrans, false); + rt.anchorMin = Vector2.zero; + rt.anchorMax = Vector2.one; + rt.offsetMin = Vector2.zero; + rt.offsetMax = Vector2.zero; + rt.localScale = i == 1 ? new Vector3(0.82f, 0.82f, 1f) : Vector3.one; + newGroups[i] = rt; + } + } + } + graph.levelGroups = newGroups; } - else if (graph.colors.Length == 1) + + // 5. Ensure levelSprites has at least 4 elements + if (graph.levelSprites == null || graph.levelSprites.Length < 4) { - var c0 = graph.colors[0]; - var c1 = c0.r > c0.b ? CyanColor : OrangeColor; - graph.colors = new[] { c0, c1 }; + var newSprites = new Sprite[4]; + var baseSprite = (graph.levelSprites != null && graph.levelSprites.Length > 0) ? graph.levelSprites[0] : null; + if (graph.levelSprites != null) + { + for (var i = 0; i < graph.levelSprites.Length && i < 4; i++) + newSprites[i] = graph.levelSprites[i]; + } + for (var i = 0; i < 4; i++) + { + if (newSprites[i] == null) newSprites[i] = baseSprite; + } + graph.levelSprites = newSprites; } - // Ensure tmp_sum has capacity for multi-level calculations - if (graph.tmp_sum == null || graph.tmp_sum.Length < 4) + // 6. Clamp coreFanIndex, grayFanIndex, hoveredFanIndex + if (graph.coreFanIndex >= graph.fanCount) graph.coreFanIndex = -1; + if (graph.grayFanIndex >= graph.fanCount) graph.grayFanIndex = -1; + if (graph.hoveredFanIndex >= graph.fanCount) graph.hoveredFanIndex = -1; + + // 7. Sanitize fanDatas + if (graph.fanDatas != null) { - graph.tmp_sum = new double[4]; + var maxLvl = Math.Max(0, (graph.levelRanges.Length / 2) - 1); + var maxCol = Math.Max(0, graph.colors.Length - 1); + for (var i = 0; i < graph.fanCount && i < graph.fanDatas.Length; i++) + { + if (graph.fanDatas[i].index < 0 || graph.fanDatas[i].index > maxCol) + graph.fanDatas[i].index = 0; + if (graph.fanDatas[i].level < 0 || graph.fanDatas[i].level > maxLvl) + graph.fanDatas[i].level = 0; + graph.fanDatas[i].parent = -1; + } } + } + + private static void UpdateSectorGraph(UISectorGraph graph, List slices) + { + if (graph == null) return; + + SanitizeSectorGraphState(graph); var count = slices != null ? slices.Count : 0; @@ -376,14 +500,12 @@ private static void UpdateSectorGraph(UISectorGraph graph, List slice graph.fans = newFans; } - var maxLevel = Math.Max(0, (graph.levelGroups?.Length ?? 1) - 1); - // Populate FanDatas for (var i = 0; i < count; i++) { var slice = slices[i]; - var colorIdx = GetColorIndex(graph, slice.isOrange); - var level = Math.Max(0, Math.Min(slice.level, maxLevel)); + var colorIdx = (slice.isOrange ? 16 : 0) + (i % 8); + if (colorIdx >= graph.colors.Length) colorIdx = 0; graph.fanDatas[i].index = colorIdx; graph.fanDatas[i].name = slice.name; @@ -391,17 +513,22 @@ private static void UpdateSectorGraph(UISectorGraph graph, List slice graph.fanDatas[i].fill = slice.fill; graph.fanDatas[i].offset = slice.offset; graph.fanDatas[i].cursor = slice.offset + slice.fill * 0.5; - graph.fanDatas[i].level = level; - graph.fanDatas[i].parent = slice.parent; + graph.fanDatas[i].level = Math.Max(0, Math.Min(slice.level, 3)); + graph.fanDatas[i].parent = -1; } graph.fanCount = count; + graph.coreFanIndex = -1; + graph.grayFanIndex = -1; + graph.hoveredFanIndex = -1; + + const double GAP = 0.0018; // Clean visible separation gap between slices // Ensure fans are instantiated, correctly parented, and configured for (var i = 0; i < count; i++) { var slice = slices[i]; - var level = Math.Max(0, Math.Min(slice.level, maxLevel)); + var level = Math.Max(0, Math.Min(slice.level, 3)); var parent = (graph.levelGroups != null && level < graph.levelGroups.Length && graph.levelGroups[level] != null) ? graph.levelGroups[level] @@ -433,11 +560,19 @@ private static void UpdateSectorGraph(UISectorGraph graph, List slice img.fillMethod = Image.FillMethod.Radial360; img.fillOrigin = (int)Image.Origin360.Top; img.fillClockwise = true; - img.fillAmount = (float)slice.fill; - var colorIdx = GetColorIndex(graph, slice.isOrange); - img.color = graph.colors[colorIdx]; - img.rectTransform.localEulerAngles = new Vector3(0f, 0f, (float)(-slice.offset * 360.0)); + // Subtle separation gap between adjacent slices (only when multiple slices) + var hasGap = count > 1 && slice.fill > (GAP * 2.2) && slice.fill < 0.999; + var fillAmt = hasGap ? (slice.fill - GAP) : slice.fill; + var offsetAmt = hasGap ? (slice.offset + GAP * 0.5) : slice.offset; + + img.fillAmount = (float)Math.Max(0.0005, fillAmt); + + var color = slice.isOrange + ? OrangePalette[i % OrangePalette.Length] + : CyanPalette[i % CyanPalette.Length]; + img.color = color; + img.rectTransform.localEulerAngles = new Vector3(0f, 0f, (float)(-offsetAmt * 360.0)); } fan.gameObject.SetActive(slice.fill > 0.0001); From 2a8730990b8cb14ef1935a9742b46aa5110b5cf8 Mon Sep 17 00:00:00 2001 From: Joshua Date: Sat, 19 Sep 2026 12:37:12 +0200 Subject: [PATCH 8/9] fix(markers): fix beacon and marker display in map view (M) for multiplayer --- .../Universe/MarkerSettingUpdateProcessor.cs | 13 ++++-- .../Patches/Dynamic/GameData_Patch.cs | 7 +++ .../Patches/Dynamic/UIGlobemap_Patch.cs | 43 +++++++++++++++++++ .../Patches/Dynamic/UIMarkerDetail_Patch.cs | 23 ++++++++-- .../Patches/Dynamic/UIPlanetGlobe_Patch.cs | 18 +++++++- NebulaWorld/GameStates/GameStatesManager.cs | 1 + 6 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 NebulaPatcher/Patches/Dynamic/UIGlobemap_Patch.cs diff --git a/NebulaNetwork/PacketProcessors/Universe/MarkerSettingUpdateProcessor.cs b/NebulaNetwork/PacketProcessors/Universe/MarkerSettingUpdateProcessor.cs index 3aa04062b..ebdef7289 100644 --- a/NebulaNetwork/PacketProcessors/Universe/MarkerSettingUpdateProcessor.cs +++ b/NebulaNetwork/PacketProcessors/Universe/MarkerSettingUpdateProcessor.cs @@ -1,4 +1,4 @@ -#region +#region using NebulaAPI.Packets; using NebulaModel.Logger; @@ -109,11 +109,18 @@ protected override void ProcessPacket(MarkerSettingUpdatePacket packet, NebulaCo } //Update UI Window too if it is viewing the current marker - var window = UIRoot.instance.uiGame.markerWindow; - if (window.active && window.markerId == packet.MarkerId && window.factory == factory) + var window = UIRoot.instance?.uiGame?.markerWindow; + if (window != null && window.active && window.markerId == packet.MarkerId && window.factory == factory) { window.markerDesc.Refresh(); } + + // If globemap / marker detail is open, refresh nodes + var markerDetail = UIRoot.instance?.uiGame?.markerDetail; + if (markerDetail != null && markerDetail.active && markerDetail.inspectPlanet == factory.planet) + { + markerDetail.UpdateNodes(); + } } } } diff --git a/NebulaPatcher/Patches/Dynamic/GameData_Patch.cs b/NebulaPatcher/Patches/Dynamic/GameData_Patch.cs index 1298c4fe6..82bf83cd3 100644 --- a/NebulaPatcher/Patches/Dynamic/GameData_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/GameData_Patch.cs @@ -223,6 +223,13 @@ public static bool OnActivePlanetFactoryLoaded_Prefix(GameData __instance, Plane // Same pattern as galacticTransport.Arragement() below (and vanilla GameData.Import line 936). GameMain.data.galacticDigital?.Arragement(); + if (UIRoot.instance?.uiGame?.markerDetail != null && planet != null && planet.factory != null) + { + UIRoot.instance.uiGame.markerDetail.inspectPlanet = null; + UIRoot.instance.uiGame.markerDetail.SetInspectPlanet(planet); + UIRoot.instance.uiGame.markerDetail.UpdateNodes(); + } + try { NebulaModAPI.OnPlanetLoadFinished?.Invoke(planet.id); diff --git a/NebulaPatcher/Patches/Dynamic/UIGlobemap_Patch.cs b/NebulaPatcher/Patches/Dynamic/UIGlobemap_Patch.cs new file mode 100644 index 000000000..6c517c5cb --- /dev/null +++ b/NebulaPatcher/Patches/Dynamic/UIGlobemap_Patch.cs @@ -0,0 +1,43 @@ +#region + +using System; +using HarmonyLib; +using NebulaModel.Logger; +using NebulaWorld; + +#endregion + +namespace NebulaPatcher.Patches.Dynamic; + +[HarmonyPatch(typeof(UIGlobemap))] +internal class UIGlobemap_Patch +{ + [HarmonyPostfix] + [HarmonyPatch(nameof(UIGlobemap._OnOpen))] + public static void _OnOpen_Postfix() + { + if (!Multiplayer.IsActive) + { + return; + } + + try + { + // Rebuild markerCursor and recycle list so MarkerRenderer and MarkerUIRenderer display beacons + GameMain.data?.galacticDigital?.Arragement(); + + // Refresh marker details (floating title & todo text) for local planet + var uiGame = UIRoot.instance?.uiGame; + if (uiGame?.markerDetail != null && GameMain.localPlanet != null && GameMain.localPlanet.factory != null) + { + uiGame.markerDetail.inspectPlanet = null; + uiGame.markerDetail.SetInspectPlanet(GameMain.localPlanet); + uiGame.markerDetail.UpdateNodes(); + } + } + catch (Exception e) + { + Log.Warn($"UIGlobemap._OnOpen_Postfix error: {e}"); + } + } +} diff --git a/NebulaPatcher/Patches/Dynamic/UIMarkerDetail_Patch.cs b/NebulaPatcher/Patches/Dynamic/UIMarkerDetail_Patch.cs index cc6a259e0..55ead51d6 100644 --- a/NebulaPatcher/Patches/Dynamic/UIMarkerDetail_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/UIMarkerDetail_Patch.cs @@ -1,6 +1,7 @@ -#region +#region using HarmonyLib; +using NebulaWorld; #endregion @@ -11,13 +12,27 @@ internal class UIMarkerDetail_Patch { [HarmonyPrefix] [HarmonyPatch(typeof(UIMarkerDetail), nameof(UIMarkerDetail.SetInspectPlanet))] - public static void SetInspectPlanet_Prefix(UIMarkerDetail __instance) + public static bool SetInspectPlanet_Prefix(UIMarkerDetail __instance, PlanetData _planet) { - // When teleporting to another planet, the inspect planet factory can be null - // So set the inspectPlanet to null first in here + if (!Multiplayer.IsActive) + { + return true; + } + + // When teleporting to another planet or loading, the target planet factory can be null + // Prevent vanilla crash and clear current inspection safely + if (_planet != null && _planet.factory == null) + { + __instance.inspectPlanet = null; + __instance.allNode?.Clear(); + return false; + } + if (__instance.inspectPlanet != null && __instance.inspectPlanet.factory == null) { __instance.inspectPlanet = null; } + + return true; } } diff --git a/NebulaPatcher/Patches/Dynamic/UIPlanetGlobe_Patch.cs b/NebulaPatcher/Patches/Dynamic/UIPlanetGlobe_Patch.cs index f54474cda..deeeac06b 100644 --- a/NebulaPatcher/Patches/Dynamic/UIPlanetGlobe_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/UIPlanetGlobe_Patch.cs @@ -1,4 +1,4 @@ -#region +#region using HarmonyLib; using NebulaAPI; @@ -33,4 +33,20 @@ public static void OnNameInputEndEdit_Postfix() GameMain.localStar.id, NebulaModAPI.PLANET_NONE)); } } + + [HarmonyPostfix] + [HarmonyPatch(nameof(UIPlanetGlobe._OnUpdate))] + public static void _OnUpdate_Postfix(UIPlanetGlobe __instance) + { + if (!Multiplayer.IsActive) + { + return; + } + + if (!__instance.markerUnlocker && GameMain.history != null && (GameMain.history.markerUnlocked || GameMain.history.TechUnlocked(1105))) + { + __instance.markerUnlocker = true; + __instance.DistributeButtons(); + } + } } diff --git a/NebulaWorld/GameStates/GameStatesManager.cs b/NebulaWorld/GameStates/GameStatesManager.cs index 1b19b1236..f9e91ea8c 100644 --- a/NebulaWorld/GameStates/GameStatesManager.cs +++ b/NebulaWorld/GameStates/GameStatesManager.cs @@ -645,6 +645,7 @@ public void OverwriteGlobalGameData(GameData data) { data.galacticDigital.Import(reader.BinaryReader); } + data.galacticDigital.Arragement(); galacticDigitalBinaryData = null; } From 7922f4cae6d234dcbab7886e4e502592a409de36 Mon Sep 17 00:00:00 2001 From: Joshua Dorst Date: Sat, 19 Sep 2026 20:43:53 +0200 Subject: [PATCH 9/9] fix(power): render the vanilla Power Dashboard on clients The mod previously suppressed vanilla's power statistics derivation on clients and hand-drew its own pie charts with hardcoded palettes, so the dashboard did not match the unmodded game. Root cause: ProductionStatistics derives the dashboard from six fields (genCapacities, conDemands, genCount, conCount, totalGenCapacity, totalConDemand) by iterating each factory's powerSystem. Clients do not simulate remote factories, so the local derivation dereferenced null and threw. Fix: the host runs the genuine vanilla derivation and streams the results to clients, which then render with unmodified game UI code. - Add StatisticsPowerDataPacket and StatisticsPowerDataProcessor - ExportPowerData calls the vanilla RefreshPowerGenerationCapacites and RefreshPowerConsumptionDemands, restoring the host's own arrays after - ImportPowerData copies into the existing arrays in place, because UISectorGraph binds to the array instance during _OnInit - Skip the client-side recompute at the root cause in ProductionStatistics_Patch - Rebind the sector graphs when the arrays are reallocated, so the rings render instead of staying empty while the text is already correct - Fix astroId vs id for the star filter (star astroId is index * 100) --- .../Statistics/StatisticsPowerDataPacket.cs | 19 + .../StatisticsPowerDataProcessor.cs | 21 + .../StatisticsRequestEventProcessor.cs | 12 +- .../Dynamic/ProductionStatistics_Patch.cs | 19 + .../UIStatisticsPowerDetailPanel_Patch.cs | 637 ++---------------- .../Dynamic/UIStatisticsWindow_Patch.cs | 3 +- NebulaWorld/Statistics/StatisticsManager.cs | 192 +++++- 7 files changed, 318 insertions(+), 585 deletions(-) create mode 100644 NebulaModel/Packets/Statistics/StatisticsPowerDataPacket.cs create mode 100644 NebulaNetwork/PacketProcessors/Statistics/StatisticsPowerDataProcessor.cs diff --git a/NebulaModel/Packets/Statistics/StatisticsPowerDataPacket.cs b/NebulaModel/Packets/Statistics/StatisticsPowerDataPacket.cs new file mode 100644 index 000000000..44185a32a --- /dev/null +++ b/NebulaModel/Packets/Statistics/StatisticsPowerDataPacket.cs @@ -0,0 +1,19 @@ +namespace NebulaModel.Packets.Statistics; + +/// +/// Carries the vanilla power statistics (generation capacities, consumption demands, +/// totals and build counts) so clients can render the unmodified Power Dashboard. +/// +public class StatisticsPowerDataPacket +{ + public StatisticsPowerDataPacket() { } + + public StatisticsPowerDataPacket(int astroFilter, byte[] powerBinaryData) + { + AstroFilter = astroFilter; + PowerBinaryData = powerBinaryData; + } + + public int AstroFilter { get; set; } + public byte[] PowerBinaryData { get; set; } +} diff --git a/NebulaNetwork/PacketProcessors/Statistics/StatisticsPowerDataProcessor.cs b/NebulaNetwork/PacketProcessors/Statistics/StatisticsPowerDataProcessor.cs new file mode 100644 index 000000000..40cf3edc0 --- /dev/null +++ b/NebulaNetwork/PacketProcessors/Statistics/StatisticsPowerDataProcessor.cs @@ -0,0 +1,21 @@ +#region + +using NebulaAPI.Packets; +using NebulaModel.Networking; +using NebulaModel.Packets; +using NebulaModel.Packets.Statistics; +using NebulaWorld; + +#endregion + +namespace NebulaNetwork.PacketProcessors.Statistics; + +[RegisterPacketProcessor] +internal class StatisticsPowerDataProcessor : PacketProcessor +{ + protected override void ProcessPacket(StatisticsPowerDataPacket packet, NebulaConnection conn) + { + using var reader = new BinaryUtils.Reader(packet.PowerBinaryData); + Multiplayer.Session.Statistics.ImportPowerData(reader.BinaryReader); + } +} diff --git a/NebulaNetwork/PacketProcessors/Statistics/StatisticsRequestEventProcessor.cs b/NebulaNetwork/PacketProcessors/Statistics/StatisticsRequestEventProcessor.cs index 1f171c6f4..4f2bb3806 100644 --- a/NebulaNetwork/PacketProcessors/Statistics/StatisticsRequestEventProcessor.cs +++ b/NebulaNetwork/PacketProcessors/Statistics/StatisticsRequestEventProcessor.cs @@ -31,7 +31,7 @@ protected override void ProcessPacket(StatisticsRequestEvent packet, NebulaConne { case StatisticEvent.WindowOpened: { - Multiplayer.Session.Statistics.RegisterPlayer(conn, player.Id); + Multiplayer.Session.Statistics.RegisterPlayer(conn, player.Id, packet.AstroFilter); using (var writer = new BinaryUtils.Writer()) { @@ -39,6 +39,7 @@ protected override void ProcessPacket(StatisticsRequestEvent packet, NebulaConne conn.SendPacket(new StatisticsDataPacket(writer.CloseAndGetBytes())); } SendExtraData(conn, packet.AstroFilter); + SendPowerData(conn, packet.AstroFilter); break; } case StatisticEvent.WindowClosed: @@ -46,11 +47,20 @@ protected override void ProcessPacket(StatisticsRequestEvent packet, NebulaConne break; case StatisticEvent.AstroFilterChanged: + Multiplayer.Session.Statistics.UpdateAstroFilter(player.Id, packet.AstroFilter); SendExtraData(conn, packet.AstroFilter); + SendPowerData(conn, packet.AstroFilter); break; } } + private static void SendPowerData(NebulaConnection conn, int astroFilter) + { + using var writer = new BinaryUtils.Writer(); + Multiplayer.Session.Statistics.ExportPowerData(writer.BinaryWriter, astroFilter); + conn.SendPacket(new StatisticsPowerDataPacket(astroFilter, writer.CloseAndGetBytes())); + } + static void SendExtraData(NebulaConnection conn, int astroFilter) { if (astroFilter == 0) return; diff --git a/NebulaPatcher/Patches/Dynamic/ProductionStatistics_Patch.cs b/NebulaPatcher/Patches/Dynamic/ProductionStatistics_Patch.cs index ebefdec48..083efdf99 100644 --- a/NebulaPatcher/Patches/Dynamic/ProductionStatistics_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/ProductionStatistics_Patch.cs @@ -44,4 +44,23 @@ public static bool GameTick_Prefix(ProductionStatistics __instance) } return true; } + + /// + /// Clients do not simulate remote factories, so the vanilla power statistics derivation + /// (RefreshPowerGenerationCapacites / RefreshPowerConsumptionDemands) would + /// dereference null PlanetFactory.powerSystem instances and throw. + /// + /// The host runs this vanilla code path and streams the resulting values through + /// StatisticsPowerDataPacket, so clients skip the local recompute and keep the + /// values they received. This makes the Power Dashboard render exactly as in singleplayer. + /// + [HarmonyPrefix] + [HarmonyPatch(nameof(ProductionStatistics.RefreshPowerGenerationCapacites))] + [HarmonyPatch(nameof(ProductionStatistics.RefreshPowerConsumptionDemands))] + [HarmonyPatch(nameof(ProductionStatistics.RefreshPowerNetworkGenerationCapacites))] + [HarmonyPatch(nameof(ProductionStatistics.RefreshPowerNetworkConsumptionDemands))] + public static bool RefreshPowerData_Prefix() + { + return !Multiplayer.IsActive || Multiplayer.Session.LocalPlayer.IsHost; + } } diff --git a/NebulaPatcher/Patches/Dynamic/UIStatisticsPowerDetailPanel_Patch.cs b/NebulaPatcher/Patches/Dynamic/UIStatisticsPowerDetailPanel_Patch.cs index 4baddade7..0c7af492b 100644 --- a/NebulaPatcher/Patches/Dynamic/UIStatisticsPowerDetailPanel_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/UIStatisticsPowerDetailPanel_Patch.cs @@ -1,622 +1,95 @@ #region -using System; -using System.Collections.Generic; -using System.Text; using HarmonyLib; using NebulaModel.Logger; using NebulaWorld; -using UnityEngine; -using UnityEngine.UI; #endregion namespace NebulaPatcher.Patches.Dynamic; -[HarmonyPatch] +/// +/// Repairs the Power Dashboard ring graphs in multiplayer sessions. +/// +/// Root cause: UISectorGraph binds its data source once, inside _OnInit, and caches +/// fanCount from that array's length. On a client (and when the statistics data arrives +/// after the UI was built) ProductionStatistics reallocates genCapacities / +/// conDemands, leaving the graphs bound to a stale, all-zero array instance. +/// +/// The visible symptom is deceptive: the value texts and the right-hand detail lists read the +/// live arrays on every frame, so they are correct, while the rings stay completely invisible +/// because they still draw from the empty old arrays. +/// +/// This patch detects that the graphs are pointed at an outdated array and re-runs the game's own +/// _Free / _Init / _Open lifecycle on them, which is exactly how the vanilla +/// UI binds them. No rendering of our own is performed. +/// +[HarmonyPatch(typeof(UIStatisticsPowerDetailPanel))] internal class UIStatisticsPowerDetailPanel_Patch { - private static readonly Color CyanColor = new(0.00f, 0.85f, 1.00f, 1f); - private static readonly Color OrangeColor = new(1.00f, 0.60f, 0.05f, 1f); + /// Array instances the graphs were last bound to (see ). + private static long[] s_boundGenCapacities; + private static long[] s_boundConDemands; - private static readonly Color[] CyanPalette = new Color[] + [HarmonyPostfix] + [HarmonyPatch(nameof(UIStatisticsPowerDetailPanel._OnUpdate))] + private static void _OnUpdate_Postfix(UIStatisticsPowerDetailPanel __instance) { - new(0.00f, 0.85f, 1.00f, 1f), // Bright Cyan - new(0.12f, 0.72f, 0.95f, 1f), // Sky Blue - new(0.25f, 0.88f, 0.95f, 1f), // Light Aqua - new(0.05f, 0.60f, 0.85f, 1f), // Deep Cyan - new(0.35f, 0.92f, 1.00f, 1f), // Vivid Azure - new(0.00f, 0.78f, 0.88f, 1f), // Teal Cyan - new(0.20f, 0.65f, 0.92f, 1f), // Ocean Blue - new(0.40f, 0.80f, 1.00f, 1f), // Ice Blue - }; - - private static readonly Color[] OrangePalette = new Color[] - { - new(1.00f, 0.60f, 0.05f, 1f), // Warm Amber Orange - new(1.00f, 0.45f, 0.15f, 1f), // Deep Coral Orange - new(1.00f, 0.72f, 0.20f, 1f), // Bright Golden Orange - new(0.95f, 0.35f, 0.10f, 1f), // Fiery Orange - new(1.00f, 0.80f, 0.35f, 1f), // Light Amber - new(0.90f, 0.40f, 0.05f, 1f), // Burnt Orange - new(1.00f, 0.55f, 0.25f, 1f), // Warm Coral - new(0.85f, 0.30f, 0.00f, 1f), // Dark Rust Orange - }; - - private struct SliceInfo - { - public bool isOrange; - public int level; - public int parent; - public string name; - public double value; - public double fill; - public double offset; - } - - [HarmonyPrefix] - [HarmonyPatch(typeof(UISectorGraph), nameof(UISectorGraph._OnUpdate))] - public static void UISectorGraph_OnUpdate_Prefix(UISectorGraph __instance) - { - if (!Multiplayer.IsActive || Multiplayer.Session.LocalPlayer.IsHost || __instance == null) return; - SanitizeSectorGraphState(__instance); - } - - [HarmonyFinalizer] - [HarmonyPatch(typeof(UISectorGraph), nameof(UISectorGraph._OnUpdate))] - public static Exception UISectorGraph_OnUpdate_Finalizer(Exception __exception) - { - if (__exception != null && Multiplayer.IsActive && !Multiplayer.Session.LocalPlayer.IsHost) + if (!Multiplayer.IsActive || __instance == null) { - return null; // Suppress client-side exception so UI update loop never crashes - } - return __exception; - } - - [HarmonyFinalizer] - [HarmonyPatch(typeof(UISectorGraph), nameof(UISectorGraph.Refresh))] - public static Exception UISectorGraph_Refresh_Finalizer(Exception __exception) - { - if (__exception != null && Multiplayer.IsActive && !Multiplayer.Session.LocalPlayer.IsHost) - { - return null; - } - return __exception; - } - - [HarmonyFinalizer] - [HarmonyPatch(typeof(UIStatisticsPowerDetailPanel), nameof(UIStatisticsPowerDetailPanel.RefreshPowerDatas))] - public static Exception RefreshPowerDatas_Finalizer(Exception __exception) - { - // Suppress any remote factory NRE on clients - if (__exception != null && Multiplayer.IsActive && !Multiplayer.Session.LocalPlayer.IsHost) - { - return null; - } - return __exception; - } - - [HarmonyFinalizer] - [HarmonyPatch(typeof(UIStatisticsPowerDetailPanel), nameof(UIStatisticsPowerDetailPanel.RefreshGraphs))] - public static Exception RefreshGraphs_Finalizer(Exception __exception, UIStatisticsPowerDetailPanel __instance) - { - if (!Multiplayer.IsActive || Multiplayer.Session.LocalPlayer.IsHost || __instance == null) - { - return __exception; + return; } try { - ApplyPowerPanelGraphsFix(__instance); - } - catch (Exception e) - { - Log.Warn($"ApplyPowerPanelGraphsFix error: {e}"); - } - - return null; // Suppress any exception in base method - } - - [HarmonyFinalizer] - [HarmonyPatch(typeof(UIStatisticsPowerDetailPanel), nameof(UIStatisticsPowerDetailPanel.RefreshDetailEntries))] - public static Exception RefreshDetailEntries_Finalizer(Exception __exception) - { - if (__exception != null && Multiplayer.IsActive && !Multiplayer.Session.LocalPlayer.IsHost) - { - return null; - } - return __exception; - } - - [HarmonyFinalizer] - [HarmonyPatch(typeof(UIChartAstroPower), nameof(UIChartAstroPower.RefreshGraphs))] - public static Exception UIChartAstroPower_RefreshGraphs_Finalizer(Exception __exception, UIChartAstroPower __instance) - { - if (!Multiplayer.IsActive || Multiplayer.Session.LocalPlayer.IsHost || __instance == null) - { - return __exception; - } - - try - { - ApplyChartAstroPowerFix(__instance); - } - catch (Exception e) - { - Log.Warn($"ApplyChartAstroPowerFix error: {e}"); - } - - return null; - } - - private static void ApplyPowerPanelGraphsFix(UIStatisticsPowerDetailPanel panel) - { - // 1. Generation Small Pie Chart (Top Center) - var genSlices = CollectSlices(panel.powerGenEntries, out var totalGen, wantOrange: false); - if (panel.powerGenGraphSmall != null) - { - UpdateSectorGraph(panel.powerGenGraphSmall, genSlices); - if (panel.powerGenGraphSmall.subText != null && string.IsNullOrEmpty(panel.powerGenGraphSmall.subText.text)) - { - panel.powerGenGraphSmall.subText.text = "Generation".Translate(); - } - if (panel.powerGenGraphSmall.mainText != null) - { - panel.powerGenGraphSmall.mainText.text = FormatPower(panel.sb1, totalGen); - } - } - - // 2. Consumption Small Pie Chart (Bottom Center) - var conSlices = CollectSlices(panel.powerConEntries, out var totalCon, wantOrange: true); - if (panel.powerConGraphSmall != null) - { - UpdateSectorGraph(panel.powerConGraphSmall, conSlices); - if (panel.powerConGraphSmall.subText != null && string.IsNullOrEmpty(panel.powerConGraphSmall.subText.text)) - { - panel.powerConGraphSmall.subText.text = "Consumption".Translate(); - } - if (panel.powerConGraphSmall.mainText != null) - { - panel.powerConGraphSmall.mainText.text = FormatPower(panel.sb2, totalCon); - } - } - - // 3. Large Graph on the Left (Sufficiency dual concentric rings or selected breakdown) - var largeGraph = panel.powerGenGraphLarge != null && panel.powerGenGraphLarge.gameObject.activeInHierarchy - ? panel.powerGenGraphLarge - : (panel.powerConGraphLarge != null && panel.powerConGraphLarge.gameObject.activeInHierarchy ? panel.powerConGraphLarge : null); - - if (largeGraph != null) - { - var sub = largeGraph.subText != null ? largeGraph.subText.text : ""; - var isGenMode = string.Equals(sub, "Generation".Translate(), StringComparison.OrdinalIgnoreCase) || - sub.IndexOf("Generation", StringComparison.OrdinalIgnoreCase) >= 0; - var isConMode = (largeGraph == panel.powerConGraphLarge) || - string.Equals(sub, "Consumption".Translate(), StringComparison.OrdinalIgnoreCase) || - sub.IndexOf("Consumption", StringComparison.OrdinalIgnoreCase) >= 0; - - if (isGenMode) - { - UpdateSectorGraph(largeGraph, genSlices); - if (largeGraph.mainText != null) - { - largeGraph.mainText.text = FormatPower(panel.sb3, totalGen); - } - } - else if (isConMode) - { - UpdateSectorGraph(largeGraph, conSlices); - if (largeGraph.mainText != null) - { - largeGraph.mainText.text = FormatPower(panel.sb3, totalCon); - } - } - else - { - // Default: Sufficiency Mode with Dual Concentric Rings (identical to singleplayer vanilla DSP) - var suffSlices = new List(); - var ratio = totalCon > 0 ? (totalGen / totalCon) : (totalGen > 0 ? 1.0 : 0.0); - var suffFill = Math.Min(1.0, Math.Max(0.0, ratio)); - - // Outer Ring (Level 0, Cyan/Blue): Sufficiency percentage - suffSlices.Add(new SliceInfo - { - isOrange = false, - level = 0, - parent = -1, - name = "Sufficiency".Translate(), - value = totalGen, - fill = suffFill, - offset = 0.0 - }); - - // Inner Ring (Level 1, Warm Orange): Machine consumption breakdown relative to capacity - if (panel.powerConEntries != null && panel.powerConEntries.Count > 0) - { - var normalizer = Math.Max(totalGen, totalCon); - var currentInnerOffset = 0.0; - - for (var i = 0; i < panel.powerConEntries.Count; i++) - { - var entry = panel.powerConEntries[i]; - if (entry != null && entry.gameObject.activeSelf && entry.power > 0) - { - var sliceFill = normalizer > 0 ? (entry.power / normalizer) : 0.0; - var name = entry.itemNameText != null ? entry.itemNameText.text : ""; - if (string.IsNullOrEmpty(name) && entry.itemId > 0) - { - name = LDB.items?.Select(entry.itemId)?.Name ?? ""; - } - - suffSlices.Add(new SliceInfo - { - isOrange = true, - level = 1, - parent = -1, - name = name, - value = entry.power, - fill = sliceFill, - offset = currentInnerOffset - }); - currentInnerOffset += sliceFill; - } - } - } - - UpdateSectorGraph(largeGraph, suffSlices); - - if (largeGraph.subText != null) - { - largeGraph.subText.text = "Sufficiency".Translate(); - } - - if (largeGraph.mainText != null) - { - largeGraph.mainText.text = (ratio * 100.0).ToString("0.0") + "%"; - if (ratio >= 1.0) - largeGraph.mainText.color = CyanColor; - else if (ratio >= 0.8) - largeGraph.mainText.color = new Color(0.3f, 0.85f, 0.4f, 1f); // Green - else if (ratio >= 0.5) - largeGraph.mainText.color = OrangeColor; - else - largeGraph.mainText.color = new Color(0.9f, 0.25f, 0.25f, 1f); // Red - } - } - } - } - - private static void ApplyChartAstroPowerFix(UIChartAstroPower chart) - { - var genSlices = CollectSlices(chart.powerGenEntries, out var totalGen, wantOrange: false); - if (chart.genSectorGraph != null) - { - UpdateSectorGraph(chart.genSectorGraph, genSlices); + RebindIfStale(__instance); } - - var conSlices = CollectSlices(chart.powerConEntries, out var totalCon, wantOrange: true); - if (chart.conSectorGraph != null) - { - UpdateSectorGraph(chart.conSectorGraph, conSlices); - } - - if (chart.powerRoundFg != null) + catch (System.Exception e) { - var ratio = totalCon > 0 ? Mathf.Clamp01((float)(totalGen / totalCon)) : (totalGen > 0 ? 1f : 0f); - chart.powerRoundFg.fillAmount = ratio; - if (ratio >= 1f) - chart.powerRoundFg.color = chart.powerRoundFgColor0; - else if (ratio >= 0.8f) - chart.powerRoundFg.color = chart.powerRoundFgColor1; - else if (ratio >= 0.5f) - chart.powerRoundFg.color = chart.powerRoundFgColor2; - else - chart.powerRoundFg.color = chart.powerRoundFgColor3; + // Never let a cosmetic repair break the statistics window. + Log.Warn($"Power dashboard rebind failed: {e}"); } } - private static List CollectSlices(List entries, out double totalPower, bool wantOrange) + private static void RebindIfStale(UIStatisticsPowerDetailPanel panel) { - totalPower = 0; - var list = new List(); - if (entries == null) return list; - - for (var i = 0; i < entries.Count; i++) - { - var entry = entries[i]; - if (entry != null && entry.gameObject.activeSelf && entry.power > 0) - { - totalPower += entry.power; - } - } - - if (totalPower <= 0) return list; - - var currentOffset = 0.0; - for (var i = 0; i < entries.Count; i++) + var production = GameMain.statistics?.production; + var genCapacities = production?.genCapacities; + var conDemands = production?.conDemands; + if (genCapacities == null || conDemands == null) { - var entry = entries[i]; - if (entry != null && entry.gameObject.activeSelf && entry.power > 0) - { - var fill = entry.power / totalPower; - var name = entry.itemNameText != null ? entry.itemNameText.text : ""; - if (string.IsNullOrEmpty(name) && entry.itemId > 0) - { - name = LDB.items?.Select(entry.itemId)?.Name ?? ""; - } - - list.Add(new SliceInfo - { - isOrange = wantOrange, - level = 0, - parent = -1, - name = name, - value = entry.power, - fill = fill, - offset = currentOffset - }); - currentOffset += fill; - } + return; } - return list; - } - - private static void SanitizeSectorGraphState(UISectorGraph graph) - { - if (graph == null) return; - - // 1. Ensure colors has ample capacity (at least 32 distinct colors) - if (graph.colors == null || graph.colors.Length < 32) + // Cheap per-frame guard: two reference comparisons. + if (ReferenceEquals(s_boundGenCapacities, genCapacities) && + ReferenceEquals(s_boundConDemands, conDemands)) { - var oldColors = graph.colors; - graph.colors = new Color[32]; - for (var i = 0; i < 32; i++) - { - if (oldColors != null && i < oldColors.Length) - graph.colors[i] = oldColors[i]; - else - graph.colors[i] = i < 16 ? CyanPalette[i % CyanPalette.Length] : OrangePalette[i % OrangePalette.Length]; - } + return; } - // 2. Ensure levelRanges has at least 8 elements (supports up to 4 levels) - if (graph.levelRanges == null || graph.levelRanges.Length < 8) - { - var oldRanges = graph.levelRanges; - var r0 = (oldRanges != null && oldRanges.Length > 0) ? oldRanges[0] : 60f; - var r1 = (oldRanges != null && oldRanges.Length > 1) ? oldRanges[1] : 90f; - graph.levelRanges = new float[8] - { - r0, r1, // Level 0: Outer ring - r0 * 0.72f, r1 * 0.82f, // Level 1: Inner ring - r0 * 0.45f, r1 * 0.55f, // Level 2 - r0 * 0.20f, r1 * 0.30f // Level 3 - }; - } + s_boundGenCapacities = genCapacities; + s_boundConDemands = conDemands; - // 3. Ensure tmp_sum has at least 8 elements - if (graph.tmp_sum == null || graph.tmp_sum.Length < 8) - { - graph.tmp_sum = new double[8]; - } - - // 4. Ensure levelGroups has at least 4 elements - if (graph.levelGroups == null || graph.levelGroups.Length < 4) - { - var newGroups = new RectTransform[4]; - if (graph.levelGroups != null) - { - for (var i = 0; i < graph.levelGroups.Length && i < 4; i++) - newGroups[i] = graph.levelGroups[i]; - } - if (newGroups[0] == null) newGroups[0] = graph.rectTrans; - for (var i = 1; i < 4; i++) - { - if (newGroups[i] == null) - { - var childName = $"LevelGroup_{i}"; - var existing = graph.rectTrans != null ? graph.rectTrans.Find(childName) : null; - if (existing != null) - { - newGroups[i] = existing.GetComponent(); - } - else if (graph.rectTrans != null) - { - var go = new GameObject(childName, typeof(RectTransform)); - var rt = go.GetComponent(); - rt.SetParent(graph.rectTrans, false); - rt.anchorMin = Vector2.zero; - rt.anchorMax = Vector2.one; - rt.offsetMin = Vector2.zero; - rt.offsetMax = Vector2.zero; - rt.localScale = i == 1 ? new Vector3(0.82f, 0.82f, 1f) : Vector3.one; - newGroups[i] = rt; - } - } - } - graph.levelGroups = newGroups; - } - - // 5. Ensure levelSprites has at least 4 elements - if (graph.levelSprites == null || graph.levelSprites.Length < 4) - { - var newSprites = new Sprite[4]; - var baseSprite = (graph.levelSprites != null && graph.levelSprites.Length > 0) ? graph.levelSprites[0] : null; - if (graph.levelSprites != null) - { - for (var i = 0; i < graph.levelSprites.Length && i < 4; i++) - newSprites[i] = graph.levelSprites[i]; - } - for (var i = 0; i < 4; i++) - { - if (newSprites[i] == null) newSprites[i] = baseSprite; - } - graph.levelSprites = newSprites; - } - - // 6. Clamp coreFanIndex, grayFanIndex, hoveredFanIndex - if (graph.coreFanIndex >= graph.fanCount) graph.coreFanIndex = -1; - if (graph.grayFanIndex >= graph.fanCount) graph.grayFanIndex = -1; - if (graph.hoveredFanIndex >= graph.fanCount) graph.hoveredFanIndex = -1; - - // 7. Sanitize fanDatas - if (graph.fanDatas != null) - { - var maxLvl = Math.Max(0, (graph.levelRanges.Length / 2) - 1); - var maxCol = Math.Max(0, graph.colors.Length - 1); - for (var i = 0; i < graph.fanCount && i < graph.fanDatas.Length; i++) - { - if (graph.fanDatas[i].index < 0 || graph.fanDatas[i].index > maxCol) - graph.fanDatas[i].index = 0; - if (graph.fanDatas[i].level < 0 || graph.fanDatas[i].level > maxLvl) - graph.fanDatas[i].level = 0; - graph.fanDatas[i].parent = -1; - } - } + Rebind(panel.powerGenGraphLarge, genCapacities); + Rebind(panel.powerConGraphLarge, conDemands); + Rebind(panel.powerGenGraphSmall, genCapacities); + Rebind(panel.powerConGraphSmall, conDemands); } - private static void UpdateSectorGraph(UISectorGraph graph, List slices) + /// + /// Replays the vanilla lifecycle so the graph rebinds to the current array. + /// _Free is required first because _Init deliberately no-ops once inited. + /// + private static void Rebind(UISectorGraph graph, long[] data) { - if (graph == null) return; - - SanitizeSectorGraphState(graph); - - var count = slices != null ? slices.Count : 0; - - // Ensure fanDatas capacity - if (graph.fanDatas == null || graph.fanDatas.Length < count) - { - var newDatas = new UISectorGraph.FanData[Math.Max(count + 4, 32)]; - if (graph.fanDatas != null) Array.Copy(graph.fanDatas, newDatas, graph.fanDatas.Length); - graph.fanDatas = newDatas; - } - - // Ensure fans capacity - if (graph.fans == null || graph.fans.Length < graph.fanDatas.Length) + if (graph == null) { - var newFans = new UISectorFan[graph.fanDatas.Length]; - if (graph.fans != null) Array.Copy(graph.fans, newFans, graph.fans.Length); - graph.fans = newFans; + return; } - // Populate FanDatas - for (var i = 0; i < count; i++) - { - var slice = slices[i]; - var colorIdx = (slice.isOrange ? 16 : 0) + (i % 8); - if (colorIdx >= graph.colors.Length) colorIdx = 0; - - graph.fanDatas[i].index = colorIdx; - graph.fanDatas[i].name = slice.name; - graph.fanDatas[i].value = slice.value; - graph.fanDatas[i].fill = slice.fill; - graph.fanDatas[i].offset = slice.offset; - graph.fanDatas[i].cursor = slice.offset + slice.fill * 0.5; - graph.fanDatas[i].level = Math.Max(0, Math.Min(slice.level, 3)); - graph.fanDatas[i].parent = -1; - } - - graph.fanCount = count; - graph.coreFanIndex = -1; - graph.grayFanIndex = -1; - graph.hoveredFanIndex = -1; - - const double GAP = 0.0018; // Clean visible separation gap between slices - - // Ensure fans are instantiated, correctly parented, and configured - for (var i = 0; i < count; i++) - { - var slice = slices[i]; - var level = Math.Max(0, Math.Min(slice.level, 3)); - - var parent = (graph.levelGroups != null && level < graph.levelGroups.Length && graph.levelGroups[level] != null) - ? graph.levelGroups[level] - : graph.rectTrans; - - var sprite = (graph.levelSprites != null && level < graph.levelSprites.Length) - ? graph.levelSprites[level] - : null; - - if (graph.fans[i] == null && graph.fanPrefab != null) - { - graph.fans[i] = UnityEngine.Object.Instantiate(graph.fanPrefab, parent); - graph.fans[i].graph = graph; - } - - var fan = graph.fans[i]; - if (fan != null) - { - if (fan.transform.parent != parent) - { - fan.transform.SetParent(parent, false); - } - - if (fan.fanImage != null) - { - var img = fan.fanImage; - if (sprite != null) img.sprite = sprite; - img.type = Image.Type.Filled; - img.fillMethod = Image.FillMethod.Radial360; - img.fillOrigin = (int)Image.Origin360.Top; - img.fillClockwise = true; - - // Subtle separation gap between adjacent slices (only when multiple slices) - var hasGap = count > 1 && slice.fill > (GAP * 2.2) && slice.fill < 0.999; - var fillAmt = hasGap ? (slice.fill - GAP) : slice.fill; - var offsetAmt = hasGap ? (slice.offset + GAP * 0.5) : slice.offset; - - img.fillAmount = (float)Math.Max(0.0005, fillAmt); - - var color = slice.isOrange - ? OrangePalette[i % OrangePalette.Length] - : CyanPalette[i % CyanPalette.Length]; - img.color = color; - img.rectTransform.localEulerAngles = new Vector3(0f, 0f, (float)(-offsetAmt * 360.0)); - } - - fan.gameObject.SetActive(slice.fill > 0.0001); - } - } - - // Deactivate unused fans - for (var j = count; j < graph.fans.Length; j++) - { - if (graph.fans[j] != null) - { - graph.fans[j].gameObject.SetActive(false); - } - } - - // Run game's native Refresh for tooltip bounds and totals - try - { - graph.Refresh(); - } - catch - { - // Silently ignore - } - } - - private static string FormatPower(StringBuilder sb, double power) - { - if (sb == null) sb = new StringBuilder(); - sb.Clear(); - try - { - StringBuilderUtility.WriteKMGPower(sb, 0, (long)power, false); - return sb.ToString(); - } - catch - { - if (power >= 1_000_000_000) - return $"{(power / 1_000_000_000):0.00} GW"; - if (power >= 1_000_000) - return $"{(power / 1_000_000):0.00} MW"; - if (power >= 1_000) - return $"{(power / 1_000):0.00} kW"; - return $"{power:0} W"; - } + graph._Free(); + graph._Init(data); + graph._Open(); } } diff --git a/NebulaPatcher/Patches/Dynamic/UIStatisticsWindow_Patch.cs b/NebulaPatcher/Patches/Dynamic/UIStatisticsWindow_Patch.cs index 43b5672a9..8ddf97158 100644 --- a/NebulaPatcher/Patches/Dynamic/UIStatisticsWindow_Patch.cs +++ b/NebulaPatcher/Patches/Dynamic/UIStatisticsWindow_Patch.cs @@ -25,7 +25,8 @@ public static void _OnOpen_Postfix(UIStatisticsWindow __instance) var astroFilter = __instance.astroFilter; if (astroFilter == 0) { - astroFilter = GameMain.localPlanet?.astroId ?? (GameMain.localStar?.id ?? 0); + // astroId, not id: the star astro filter is starIndex * 100. + astroFilter = GameMain.localPlanet?.astroId ?? (GameMain.localStar?.astroId ?? 0); } Multiplayer.Session.Network.SendPacket(new StatisticsRequestEvent(StatisticEvent.WindowOpened, astroFilter)); } diff --git a/NebulaWorld/Statistics/StatisticsManager.cs b/NebulaWorld/Statistics/StatisticsManager.cs index 7c7682ab5..52fa89fd4 100644 --- a/NebulaWorld/Statistics/StatisticsManager.cs +++ b/NebulaWorld/Statistics/StatisticsManager.cs @@ -55,6 +55,7 @@ public void Dispose() statisticalSnapShots = null; planetDataMap = null; factoryIndexMap = null; + threadSafe.RequestorAstroFilters.Clear(); GC.SuppressFinalize(this); } @@ -157,6 +158,26 @@ public void SendBroadcastIfNeeded(long time) player.Value.SendPacket(dataPacket); } } + + //Keep the clients' Power Dashboard in sync with the host's simulation. + //Group by astro filter so the (relatively costly) vanilla derivation runs once per filter. + var powerDataPerFilter = new Dictionary(); + foreach (var player in requestors) + { + if (!threadSafe.RequestorAstroFilters.TryGetValue(player.Key, out var astroFilter)) + { + continue; + } + if (!powerDataPerFilter.TryGetValue(astroFilter, out var powerData)) + { + using var writer = new BinaryUtils.Writer(); + ExportPowerData(writer.BinaryWriter, astroFilter); + powerData = writer.CloseAndGetBytes(); + powerDataPerFilter[astroFilter] = powerData; + } + player.Value.SendPacket(new StatisticsPowerDataPacket(astroFilter, powerData)); + } + ClearCapturedData(); } } @@ -170,11 +191,12 @@ private void ExportCurrentTickData(BinaryWriter bw) } } - public void RegisterPlayer(NebulaConnection nebulaConnection, ushort playerId) + public void RegisterPlayer(NebulaConnection nebulaConnection, ushort playerId, int astroFilter) { using (GetRequestors(out var requestors)) { requestors.Add(playerId, nebulaConnection); + threadSafe.RequestorAstroFilters[playerId] = astroFilter; } if (IsStatisticsNeeded) @@ -189,6 +211,7 @@ public void UnRegisterPlayer(ushort playerId) { using (GetRequestors(out var requestors)) { + threadSafe.RequestorAstroFilters.Remove(playerId); if (requestors.Remove(playerId) && requestors.Count == 0) { IsStatisticsNeeded = false; @@ -196,6 +219,17 @@ public void UnRegisterPlayer(ushort playerId) } } + public void UpdateAstroFilter(ushort playerId, int astroFilter) + { + using (GetRequestors(out _)) + { + if (threadSafe.RequestorAstroFilters.ContainsKey(playerId)) + { + threadSafe.RequestorAstroFilters[playerId] = astroFilter; + } + } + } + public void ExportAllData(BinaryWriter bw) { var Stats = GameMain.statistics; @@ -290,6 +324,159 @@ public long UpdateTotalChargedEnergy(int factoryIndex) return PowerEnergyStoredData[factoryIndex]; } + /// + /// Runs the vanilla power statistics derivation for the given astro filter and serializes + /// the result. This calls the same public methods the + /// vanilla Power Dashboard uses, so the host computes values identical to singleplayer + /// without depending on any UI, which also keeps dedicated servers working. + /// + /// + /// The UI astro filter convention is: -1 = all factories, 0 = local planet/star, + /// star = starIndex * 100, planet = planet astroId. The resolution below mirrors + /// UIStatisticsPowerDetailPanel.RefreshPowerDatas exactly. + /// + public void ExportPowerData(BinaryWriter bw, int astroFilter) + { + var production = GameMain.statistics?.production; + if (production == null) + { + WriteEmptyPowerData(bw); + return; + } + + // The refresh methods write into the shared arrays the host UI also renders from, so + // snapshot them and restore afterwards to avoid disturbing the host's own view. + var genCapacities = production.genCapacities; + var conDemands = production.conDemands; + var genCount = production.genCount; + var conCount = production.conCount; + var savedGenCapacities = (long[])genCapacities.Clone(); + var savedConDemands = (long[])conDemands.Clone(); + var savedGenCount = (int[])genCount.Clone(); + var savedConCount = (int[])conCount.Clone(); + var savedTotalGenCapacity = production.totalGenCapacity; + var savedTotalConDemand = production.totalConDemand; + + try + { + var filter = ResolveAstroFilter(astroFilter); + production.RefreshPowerGenerationCapacites(filter); + production.RefreshPowerConsumptionDemands(filter); + + WriteArray(bw, genCapacities); + WriteArray(bw, conDemands); + WriteArray(bw, genCount); + WriteArray(bw, conCount); + bw.Write(production.totalGenCapacity); + bw.Write(production.totalConDemand); + } + finally + { + Array.Copy(savedGenCapacities, genCapacities, genCapacities.Length); + Array.Copy(savedConDemands, conDemands, conDemands.Length); + Array.Copy(savedGenCount, genCount, genCount.Length); + Array.Copy(savedConCount, conCount, conCount.Length); + production.totalGenCapacity = savedTotalGenCapacity; + production.totalConDemand = savedTotalConDemand; + } + } + + /// + /// Translates the UI astro filter into the value expected by + /// , where 0 means + /// "all factories". Mirrors the branching in the vanilla power detail panel. + /// + private static int ResolveAstroFilter(int astroFilter) + { + if (astroFilter == -1) + { + return 0; + } + if (astroFilter == 0) + { + // "Local" - resolve to the host's local planet, or all factories if there is none. + return GameMain.data?.localPlanet?.astroId ?? 0; + } + return astroFilter; + } + + private static void WriteEmptyPowerData(BinaryWriter bw) + { + bw.Write(0); + bw.Write(0); + bw.Write(0); + bw.Write(0); + bw.Write(0L); + bw.Write(0L); + } + + /// + /// Copies the received power statistics into the vanilla arrays in place. + /// UISectorGraph binds its data source to the array instance during _OnInit, so the + /// existing arrays must be mutated rather than replaced. The vanilla panel refreshes its + /// graphs every frame, so no explicit UI refresh is needed here. + /// + public void ImportPowerData(BinaryReader br) + { + var production = GameMain.statistics?.production; + if (production?.genCapacities == null || production.conDemands == null || + production.genCount == null || production.conCount == null) + { + return; + } + + ReadArray(br, production.genCapacities); + ReadArray(br, production.conDemands); + ReadArray(br, production.genCount); + ReadArray(br, production.conCount); + production.totalGenCapacity = br.ReadInt64(); + production.totalConDemand = br.ReadInt64(); + } + + private static void WriteArray(BinaryWriter bw, long[] values) + { + bw.Write(values.Length); + foreach (var value in values) + { + bw.Write(value); + } + } + + private static void WriteArray(BinaryWriter bw, int[] values) + { + bw.Write(values.Length); + foreach (var value in values) + { + bw.Write(value); + } + } + + private static void ReadArray(BinaryReader br, long[] values) + { + var length = br.ReadInt32(); + for (var i = 0; i < length; i++) + { + var value = br.ReadInt64(); + if (i < values.Length) + { + values[i] = value; + } + } + } + + private static void ReadArray(BinaryReader br, int[] values) + { + var length = br.ReadInt32(); + for (var i = 0; i < length; i++) + { + var value = br.ReadInt32(); + if (i < values.Length) + { + values[i] = value; + } + } + } + public void GetReferenceSpeedTip(BinaryWriter bw, int itemId, int astroFilter, int itemCycle, int productionProtoId) { if (referenceSpeedTip == null) return; @@ -599,5 +786,8 @@ private void RefreshReferenceSpeedTipEntries() private sealed class ThreadSafe { internal readonly Dictionary Requestors = new(); + + /// Astro filter each player currently has selected in the statistics power tab. + internal readonly Dictionary RequestorAstroFilters = new(); } }