diff --git a/.gitignore b/.gitignore index 68d765860..f80f3b36d 100644 --- a/.gitignore +++ b/.gitignore @@ -167,3 +167,6 @@ GameData/RemoteTech/Plugins/RemoteTech.xml GameData/RemoteTech/Versioning/RemoteTech.version GameData/build.txt src/RemoteTech/RemoteTech.sln.ide/ + +# In-game test plugin build output (the source lives under tests/) +GameData/RemoteTech.Tests/ diff --git a/RemoteTech.sln b/RemoteTech.sln new file mode 100644 index 000000000..119211d13 --- /dev/null +++ b/RemoteTech.sln @@ -0,0 +1,40 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RemoteTech", "src\RemoteTech\RemoteTech.csproj", "{67470554-F754-4664-AC7C-D002943ACD72}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RemoteTech.Tests", "tests\RemoteTech.Tests\RemoteTech.Tests.csproj", "{A7585803-6434-436E-BFC1-B09A520425D7}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RemoteTech.InGameTests", "tests\RemoteTech.InGameTests\RemoteTech.InGameTests.csproj", "{C5C1CBBB-83DD-4850-A6F8-6823AB14B54E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{065AC022-06A2-4079-9DEC-051B87D76A08}" + ProjectSection(SolutionItems) = preProject + CHANGES.md = CHANGES.md + CONTRIBUTING.md = CONTRIBUTING.md + README.md = README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {67470554-F754-4664-AC7C-D002943ACD72}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {67470554-F754-4664-AC7C-D002943ACD72}.Debug|Any CPU.Build.0 = Debug|Any CPU + {67470554-F754-4664-AC7C-D002943ACD72}.Release|Any CPU.ActiveCfg = Release|Any CPU + {67470554-F754-4664-AC7C-D002943ACD72}.Release|Any CPU.Build.0 = Release|Any CPU + {A7585803-6434-436E-BFC1-B09A520425D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7585803-6434-436E-BFC1-B09A520425D7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7585803-6434-436E-BFC1-B09A520425D7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7585803-6434-436E-BFC1-B09A520425D7}.Release|Any CPU.Build.0 = Release|Any CPU + {C5C1CBBB-83DD-4850-A6F8-6823AB14B54E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C5C1CBBB-83DD-4850-A6F8-6823AB14B54E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C5C1CBBB-83DD-4850-A6F8-6823AB14B54E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C5C1CBBB-83DD-4850-A6F8-6823AB14B54E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/RemoteTech.slnx b/RemoteTech.slnx deleted file mode 100644 index 54c1e38a7..000000000 --- a/RemoteTech.slnx +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/src/RemoteTech/API/API.cs b/src/RemoteTech/API/API.cs index 909a7122e..f74021b8f 100644 --- a/src/RemoteTech/API/API.cs +++ b/src/RemoteTech/API/API.cs @@ -1,5 +1,5 @@ using RemoteTech.RangeModel; -using RemoteTech.Modules; +using RemoteTech.Modules; using RemoteTech.SimpleTypes; using System; using System.Collections.Generic; @@ -10,7 +10,9 @@ namespace RemoteTech.API { public static class API { - /// If true then RTCore will be available in the Space Center + /// + /// If true then RTCore will be available in the Space Center + /// internal static bool enabledInSPC = false; public static bool IsRemoteTechEnabled() @@ -43,7 +45,7 @@ public static bool HasLocalControl(Guid id) public static bool HasFlightComputer(Guid id) { if (RTCore.Instance == null) return false; - var satellite = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(id)).FirstOrDefault(); + var satellite = RTCore.Instance.Satellites[id]; if (satellite == null) return false; var hasFlightComputer = satellite.FlightComputer != null; @@ -55,7 +57,7 @@ public static bool HasFlightComputer(Guid id) public static void AddSanctionedPilot(Guid id, Action autopilot) { if (RTCore.Instance == null) return; - var satellite = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(id)).FirstOrDefault(); + var satellite = RTCore.Instance.Satellites[id]; if (satellite == null || satellite.SignalProcessor == null) return; foreach (var spu in satellite.SignalProcessors) @@ -70,7 +72,7 @@ public static void AddSanctionedPilot(Guid id, Action autopilot public static void RemoveSanctionedPilot(Guid id, Action autopilot) { if (RTCore.Instance == null) return; - var satellite = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(id)).FirstOrDefault(); + var satellite = RTCore.Instance.Satellites[id]; if (satellite == null || satellite.SignalProcessor == null) return; foreach (var spu in satellite.SignalProcessors) @@ -84,10 +86,10 @@ public static void RemoveSanctionedPilot(Guid id, Action autopi public static bool HasAnyConnection(Guid id) { if (RTCore.Instance == null) return false; - var satellite = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(id)).FirstOrDefault(); + var satellite = RTCore.Instance.Satellites[id]; if (satellite == null) return false; - var hasConnection = RTCore.Instance.Network[satellite].Any(); + var hasConnection = RTCore.Instance.Network.IsConnected(satellite); RTLog.Verbose("Flight: {0} Has Connection: {1}", RTLogLevel.API, id, hasConnection); return hasConnection; } @@ -95,54 +97,77 @@ public static bool HasAnyConnection(Guid id) public static bool HasConnectionToKSC(Guid id) { if (RTCore.Instance == null) return false; - var satellite = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(id)).FirstOrDefault(); + var satellite = RTCore.Instance.Satellites[id]; if (satellite == null) return false; - var connectedToKerbin = RTCore.Instance.Network[satellite].Any(r => RTCore.Instance.Network.GroundStations.ContainsKey(r.Goal.Guid)); + var connectedToKerbin = RTCore.Instance.Network.IsConnected(satellite, groundOnly: true); RTLog.Verbose("Flight: {0} Has Connection to Kerbin: {1}", RTLogLevel.API, id, connectedToKerbin); return connectedToKerbin; } - /// Determines if a satellite directly targets a ground station. + /// + /// Determines if a satellite directly targets a ground station. + /// /// The satellite id. /// true if the satellite has an antenna with a ground station as its first link, false otherwise. public static bool HasDirectGroundStation(Guid id) { if (RTCore.Instance == null) return false; - var satellite = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(id)).FirstOrDefault(); + var satellite = RTCore.Instance.Satellites[id]; if (satellite == null) return false; - var targetsGroundStation = RTCore.Instance.Network[satellite].Any(r => RTCore.Instance.Network.GroundStations.ContainsKey(r.Links.FirstOrDefault().Target.Guid)); + var targetsGroundStation = RTCore.Instance.Network.GetLinks(satellite) + .Any(link => RTCore.Instance.Network.GroundStations.ContainsKey(link.Target.Guid)); RTLog.Verbose("Flight: {0} Directly targets a ground station: {1}", RTLogLevel.API, id, targetsGroundStation); return targetsGroundStation; } - /// Gets the name of the ground station directly targeted with the shortest link to the satellite. + /// + /// Gets the name of the ground station directly targeted with the shortest link to the satellite. + /// /// The satellite id. /// name of the ground station if one is found, null otherwise. public static string GetClosestDirectGroundStation(Guid id) { if (RTCore.Instance == null) return null; - var satellite = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(id)).FirstOrDefault(); + var satellite = RTCore.Instance.Satellites[id]; if (satellite == null) return null; - var namedGroundStation = RTCore.Instance.Network[satellite].Where - (r => RTCore.Instance.Network.GroundStations.ContainsKey(r.Links.FirstOrDefault().Target.Guid)).Min().Goal.Name; + ISatellite closest = null; + var bestDistance = double.PositiveInfinity; + foreach (var link in RTCore.Instance.Network.GetLinks(satellite)) + { + if (!RTCore.Instance.Network.GroundStations.ContainsKey(link.Target.Guid)) continue; + + var distance = RangeModelExtensions.DistanceTo(satellite, link.Target); + if (distance < bestDistance) + { + bestDistance = distance; + closest = link.Target; + } + } + if (closest == null) return null; + + var namedGroundStation = closest.Name; RTLog.Verbose("Flight: {0} Directly targets the closest ground station: {1}", RTLogLevel.API, id, namedGroundStation); return namedGroundStation; } - /// Gets the name of the first hop satellite with the shortest link to KSC by the specified satellite. + /// + /// Gets the name of the first hop satellite with the shortest link to KSC by the specified satellite. + /// /// The satellite id. /// name of the satellite if one is found, null otherwise. public static string GetFirstHopToKSC(Guid id) { if (RTCore.Instance == null) return null; - var satellite = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(id)).FirstOrDefault(); + var satellite = RTCore.Instance.Satellites[id]; if (satellite == null) return null; - var namedSatellite = RTCore.Instance.Network[satellite].Where - (r => RTCore.Instance.Network.GroundStations.ContainsKey(r.Goal.Guid)).Min().Links.FirstOrDefault().Target.Name; + var route = RTCore.Instance.Network.GetRoute(satellite, groundOnly: true); + if (route == null || route.Count == 0) return null; + + var namedSatellite = route[0].Target.Name; RTLog.Verbose("Flight: {0} Has first hop satellite with shortest link to KSC: {1}", RTLogLevel.API, id, namedSatellite); return namedSatellite; } @@ -166,25 +191,27 @@ public static Guid GetAntennaTarget(Part part) { return module.Target; } - /// Gets Guids of all satellites in the control route + /// + /// Gets Guids of all satellites in the control route + /// /// The satellite id /// Guid array of all satellite in ground station router public static Guid[] GetControlPath(Guid id) { if (RTCore.Instance == null) return new Guid[]{ }; - var satellite = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(id)).FirstOrDefault(); + var satellite = RTCore.Instance.Satellites[id]; if (satellite == null) return new Guid[] { }; - if (!RTCore.Instance.Network[satellite].Any(r => RTCore.Instance.Network.GroundStations.ContainsKey(r.Goal.Guid))) return new Guid[] { }; + var route = RTCore.Instance.Network.GetRoute(satellite, groundOnly: true); + if (route == null) return new Guid[] { }; - List> bestRouter = RTCore.Instance.Network[satellite].Where(r => RTCore.Instance.Network.GroundStations.ContainsKey(r.Goal.Guid)).Min().Links; - Guid[] guids = new Guid[bestRouter.Count]; + Guid[] guids = new Guid[route.Count]; // Get all satellites till the ground station - for (int i = 0; i < bestRouter.Count; i++) + for (int i = 0; i < route.Count; i++) { - guids[i] = bestRouter[i].Target.Guid; + guids[i] = route[i].Target.Guid; } return guids; @@ -218,13 +245,15 @@ public static Guid GetGroundStationGuid(String name) return groundStation.mGuid; } - /// Gets the name of a satellite. + /// + /// Gets the name of a satellite. + /// /// The satellite id. /// name of the satellite with matching id if found, otherwise null public static string GetName(Guid id) { if (RTCore.Instance == null) return null; - var satellite = RTCore.Instance.Network.Where(sat => sat.Guid.Equals(id)).FirstOrDefault(); + var satellite = RTCore.Instance.Network[id]; if (satellite == null) return null; string satellitename = satellite.Name; @@ -238,22 +267,21 @@ public static Guid GetCelestialBodyGuid(CelestialBody celestialBody) } public static Guid GetNoTargetGuid() { - return new Guid(RTSettings.Instance.NoTargetGuid); + return RTSettings.Instance.NoTargetGuidParsed; } public static Guid GetActiveVesselGuid() { - return new Guid(RTSettings.Instance.ActiveVesselGuid); + return RTSettings.Instance.ActiveVesselGuidParsed; } public static double GetShortestSignalDelay(Guid id) { if (RTCore.Instance == null) return double.PositiveInfinity; - var satellite = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(id)).FirstOrDefault(); + var satellite = RTCore.Instance.Satellites[id]; if (satellite == null) return double.PositiveInfinity; - if (!RTCore.Instance.Network[satellite].Any()) return double.PositiveInfinity; - var shortestDelay = RTCore.Instance.Network[satellite].Min().Delay; + var shortestDelay = RTCore.Instance.Network.ShortestDelay(satellite); RTLog.Verbose("Flight: Shortest signal delay from {0} to {1}", RTLogLevel.API, id, shortestDelay); return shortestDelay; } @@ -261,12 +289,11 @@ public static double GetShortestSignalDelay(Guid id) public static double GetSignalDelayToKSC(Guid id) { if (RTCore.Instance == null) return double.PositiveInfinity; - var satellite = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(id)).FirstOrDefault(); + var satellite = RTCore.Instance.Satellites[id]; if (satellite == null) return double.PositiveInfinity; - if (!RTCore.Instance.Network[satellite].Any(r => RTCore.Instance.Network.GroundStations.ContainsKey(r.Goal.Guid))) return double.PositiveInfinity; - var signalDelaytoKerbin = RTCore.Instance.Network[satellite].Where(r => RTCore.Instance.Network.GroundStations.ContainsKey(r.Goal.Guid)).Min().Delay; + var signalDelaytoKerbin = RTCore.Instance.Network.ShortestDelay(satellite, groundOnly: true); RTLog.Verbose("Connection from {0} to Kerbin Delay: {1}", RTLogLevel.API, id, signalDelaytoKerbin); return signalDelaytoKerbin; } @@ -274,17 +301,12 @@ public static double GetSignalDelayToKSC(Guid id) public static double GetSignalDelayToSatellite(Guid a, Guid b) { if (RTCore.Instance == null) return double.PositiveInfinity; - var satelliteA = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(a)).FirstOrDefault(); - var satelliteB = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(b)).FirstOrDefault(); + var satelliteA = RTCore.Instance.Satellites[a]; + var satelliteB = RTCore.Instance.Satellites[b]; if (satelliteA == null || satelliteB == null) return double.PositiveInfinity; - Func>> neighbors = RTCore.Instance.Network.FindNeighbors; - Func, double> cost = RangeModelExtensions.DistanceTo; - Func heuristic = RangeModelExtensions.DistanceTo; - - var path = NetworkPathfinder.Solve(satelliteA, satelliteB, neighbors, cost, heuristic); - var delayBetween = path.Delay; + var delayBetween = RTCore.Instance.Network.ShortestDelayBetween(satelliteA, satelliteB); RTLog.Verbose("Connection from {0} to {1} Delay: {2}", RTLogLevel.API, a, b, delayBetween); return delayBetween; } @@ -410,7 +432,7 @@ public static bool ChangeGroundStationRange(Guid stationId, float newRange) public static bool SetRadioBlackoutGuid(Guid id, bool flag, string reason = "") { if (RTCore.Instance == null) return false; - var satellite = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(id)).FirstOrDefault(); + var satellite = RTCore.Instance.Satellites[id]; if (satellite == null) return false; satellite.IsInRadioBlackout = flag; @@ -425,7 +447,7 @@ public static bool SetRadioBlackoutGuid(Guid id, bool flag, string reason = "") public static bool GetRadioBlackoutGuid(Guid id) { if (RTCore.Instance == null) return false; - var satellite = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(id)).FirstOrDefault(); + var satellite = RTCore.Instance.Satellites[id]; if (satellite == null) return false; var blackoutFlag = satellite.IsInRadioBlackout; @@ -440,7 +462,7 @@ public static bool GetRadioBlackoutGuid(Guid id) public static bool SetPowerDownGuid(Guid id, bool flag, string reason = "") { if (RTCore.Instance == null) return false; - var satellite = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(id)).FirstOrDefault(); + var satellite = RTCore.Instance.Satellites[id]; if (satellite == null) return false; satellite.PowerShutdownFlag = flag; @@ -455,7 +477,7 @@ public static bool SetPowerDownGuid(Guid id, bool flag, string reason = "") public static bool GetPowerDownGuid(Guid id) { if (RTCore.Instance == null) return false; - var satellite = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(id)).FirstOrDefault(); + var satellite = RTCore.Instance.Satellites[id]; if (satellite == null) return false; var flag = satellite.PowerShutdownFlag; @@ -481,30 +503,33 @@ public static double GetMaxRangeDistance(Guid sat_a, Guid sat_b) if (satelliteA == null || satelliteB == null) return 0.0; //get link object - NetworkLink link = null; + NetworkLink? maybeLink = null; + List interfaces = null; switch (RTSettings.Instance.RangeModelType) { case RangeModel.RangeModel.Additive: - link = RangeModelRoot.GetLink(satelliteA, satelliteB); + maybeLink = RangeModelRoot.GetLink(satelliteA, satelliteB); + interfaces = RangeModelRoot.GetLinkInterfaces(satelliteA, satelliteB); break; default: - link = RangeModelStandard.GetLink(satelliteA, satelliteB); + maybeLink = RangeModelStandard.GetLink(satelliteA, satelliteB); + interfaces = RangeModelStandard.GetLinkInterfaces(satelliteA, satelliteB); break; } - if (link == null) return 0.0; //no connection possible + if (maybeLink == null) return 0.0; //no connection possible //get max distance out of multiple antenna connections var distance = 0.0; var maxDistance = 0.0; - for(int i=0; i < link.Interfaces.Count; i++) + for(int i=0; i < interfaces.Count; i++) { switch (RTSettings.Instance.RangeModelType) { case RangeModel.RangeModel.Additive: - distance = RangeModelRoot.GetRangeInContext(link.Interfaces[i], satelliteB, satelliteA); + distance = RangeModelRoot.GetRangeInContext(interfaces[i], satelliteB, satelliteA); break; default: - distance = RangeModelStandard.GetRangeInContext(link.Interfaces[i], satelliteB, satelliteA); + distance = RangeModelStandard.GetRangeInContext(interfaces[i], satelliteB, satelliteA); break; } maxDistance = Math.Max(maxDistance, Double.IsNaN(distance) ? 0.0 : distance); @@ -524,8 +549,8 @@ public static double GetRangeDistance(Guid sat_a, Guid sat_b) if (RTCore.Instance == null) return 0.0; //sanity check - var satelliteA = RTCore.Instance.Network.Where(sat => sat.Guid.Equals(sat_a)).FirstOrDefault(); - var satelliteB = RTCore.Instance.Network.Where(sat => sat.Guid.Equals(sat_b)).FirstOrDefault(); + var satelliteA = RTCore.Instance.Network[sat_a]; + var satelliteB = RTCore.Instance.Network[sat_b]; if (satelliteA == null || satelliteB == null) return 0.0; diff --git a/src/RemoteTech/AddOns/Kerbalism.cs b/src/RemoteTech/AddOns/Kerbalism.cs index 4d442702c..11546deff 100644 --- a/src/RemoteTech/AddOns/Kerbalism.cs +++ b/src/RemoteTech/AddOns/Kerbalism.cs @@ -3,7 +3,9 @@ namespace RemoteTech.AddOns { - /// Simple class to detect if Kerbalism is loaded + /// + /// Simple class to detect if Kerbalism is loaded + /// public static class Kerbalism { private static readonly Type API; @@ -21,7 +23,9 @@ static Kerbalism() } } - /// Returns true if Kerbalism is detected for the current game + /// + /// Returns true if Kerbalism is detected for the current game + /// public static bool Exists { get { return API != null; } diff --git a/src/RemoteTech/AntennaManager.cs b/src/RemoteTech/AntennaManager.cs index 4d4d296eb..2213a9beb 100644 --- a/src/RemoteTech/AntennaManager.cs +++ b/src/RemoteTech/AntennaManager.cs @@ -15,14 +15,14 @@ public class AntennaManager : IDisposable, IEnumerable public event Action OnRegister = delegate { }; public event Action OnUnregister = delegate { }; - public IEnumerable this[ISatellite s] { get { return For(s.Guid); } } - public IEnumerable this[Vessel v] { get { return For(v.id); } } - public IEnumerable this[Guid g] { get { return For(g); } } + public IReadOnlyList this[ISatellite s] { get { return For(s.Guid); } } + public IReadOnlyList this[Vessel v] { get { return For(v.id); } } + public IReadOnlyList this[Guid g] { get { return For(g); } } private readonly Dictionary> mLoadedAntennaCache = new Dictionary>(); - private readonly Dictionary> mProtoAntennaCache = - new Dictionary>(); + private readonly Dictionary> mProtoAntennaCache = + new Dictionary>(); public AntennaManager() { @@ -35,6 +35,15 @@ public AntennaManager() public void Dispose() { GameEvents.onVesselGoOnRails.Remove(OnVesselGoOnRails); + + foreach (List antennas in mProtoAntennaCache.Values) + { + foreach (ProtoAntenna antenna in antennas) + { + antenna.Dispose(); + } + } + mProtoAntennaCache.Clear(); } public void Register(Guid key, IAntenna antenna) @@ -98,7 +107,7 @@ public void RegisterProtos(Vessel v) { if (!mProtoAntennaCache.ContainsKey(key)) { - mProtoAntennaCache[key] = new List(); + mProtoAntennaCache[key] = new List(); } ProtoAntenna proto = new ProtoAntenna(v, pps, ppms); mProtoAntennaCache[key].Add(proto); @@ -113,25 +122,22 @@ public void UnregisterProtos(Guid key) if (!mProtoAntennaCache.ContainsKey(key)) return; - foreach (IAntenna a in mProtoAntennaCache[key]) + foreach (ProtoAntenna a in mProtoAntennaCache[key]) { OnUnregister.Invoke(a); + a.Dispose(); } mProtoAntennaCache.Remove(key); } - private IEnumerable For(Guid key) + private IReadOnlyList For(Guid key) { if (mLoadedAntennaCache.ContainsKey(key)) - { return mLoadedAntennaCache[key]; - } if (mProtoAntennaCache.ContainsKey(key)) - { return mProtoAntennaCache[key]; - } - return Enumerable.Empty(); + return []; } private void OnVesselGoOnRails(Vessel v) @@ -163,11 +169,6 @@ public static bool IsAntenna(this ProtoPartModuleSnapshot ppms) ppms.GetBool("IsRTActive"); } - public static bool IsAntenna(this PartModule pm) - { - return pm.Fields.GetValue("IsRTAntenna") && - pm.Fields.GetValue("IsRTPowered") && - pm.Fields.GetValue("IsRTActive"); - } + public static bool IsAntenna(this PartModule pm) => pm is IAntenna; } } \ No newline at end of file diff --git a/src/RemoteTech/Collections/ArrayMap.cs b/src/RemoteTech/Collections/ArrayMap.cs new file mode 100644 index 000000000..dd8c02a21 --- /dev/null +++ b/src/RemoteTech/Collections/ArrayMap.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace RemoteTech.Collections; + +public class ArrayMap : IEnumerable> +{ + private readonly Dictionary indices; + private readonly List keys; + private readonly List values; + + public int Count => values.Count; + public V this[K key] + { + get => values[indices[key]]; + set + { + if (indices.TryGetValue(key, out var index)) + { + keys[index] = key; + values[index] = value; + } + else + { + index = values.Count; + indices.Add(key, index); + keys.Add(key); + values.Add(value); + } + } + } + + public ReadOnlySpan Keys => keys.AsReadOnlySpan(); + public Span Values => values.AsSpan(); + + public ArrayMap() + { + indices = []; + keys = []; + values = []; + } + + public ArrayMap(int capacity) + { + indices = new(capacity); + keys = new(capacity); + values = new(capacity); + } + + public bool TryGetValue(K key, out V value) + { + value = default; + if (!indices.TryGetValue(key, out var index)) + return false; + + value = values[index]; + return true; + } + + public bool ContainsKey(K key) => indices.ContainsKey(key); + + public int IndexOf(K key) => indices.TryGetValue(key, out var index) ? index : -1; + + public void Add(K key, V value) + { + var count = Count; + indices.Add(key, count); + keys.Add(key); + values.Add(value); + } + + public bool TryAdd(K key, V value) + { + if (indices.ContainsKey(key)) + return false; + + var index = Count; + indices.Add(key, index); + keys.Add(key); + values.Add(value); + return true; + } + + public bool Remove(K key) + { + if (!indices.TryGetValue(key, out var index)) + return false; + + if (index == Count - 1) + { + indices.Remove(key); + keys.RemoveAt(index); + values.RemoveAt(index); + } + else + { + var count = Count; + var other = keys[index] = keys[count - 1]; + values[index] = values[count - 1]; + + indices[other] = index; + indices.Remove(key); + keys.RemoveAt(count - 1); + values.RemoveAt(count - 1); + } + + return true; + } + + public void Clear() + { + indices.Clear(); + keys.Clear(); + values.Clear(); + } + + public Enumerator GetEnumerator() => new(this); + IEnumerator> IEnumerable>.GetEnumerator() => GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public struct Enumerator(ArrayMap map) : IEnumerator> + { + List.Enumerator keys = map.keys.GetEnumerator(); + List.Enumerator vals = map.values.GetEnumerator(); + + public KeyValuePair Current => new(keys.Current, vals.Current); + object IEnumerator.Current => Current; + + public bool MoveNext() => keys.MoveNext() & vals.MoveNext(); + + void IEnumerator.Reset() + { + DoReset(ref keys); + DoReset(ref vals); + } + + public void Dispose() { } + + private static void DoReset(ref T enumerator) + where T : IEnumerator + { + enumerator.Reset(); + } + } +} \ No newline at end of file diff --git a/src/RemoteTech/Collections/ArrayMinHeap.cs b/src/RemoteTech/Collections/ArrayMinHeap.cs new file mode 100644 index 000000000..0ac9148e2 --- /dev/null +++ b/src/RemoteTech/Collections/ArrayMinHeap.cs @@ -0,0 +1,106 @@ +using System; +using Unity.Collections; + +namespace RemoteTech.Collections; + +/// +/// Binary min-heap over an internally-allocated . +/// Grows on demand, so callers don't need to size it to a total-pushes upper +/// bound themselves. +/// +internal struct ArrayMinHeap(int capacity, Allocator allocator) + where T : unmanaged, IComparable +{ + private NativeList _items = new(capacity, allocator); + + public int Count => _items.Length; + + public void Push(T item) + { + int index = Count; + _items.Add(item); + MoveUp(index); + } + + /// + /// Adds a batch of items in O(n) via bottom-up heapify, rather than O(n log n) + /// from pushing them one at a time. + /// + public void AddRange(NativeArray items) + { + _items.AddRange(items); + + for (int i = (Count >> 1) - 1; i >= 0; i--) + MoveDown(i); + } + + public bool TryPop(out T item) + { + if (Count == 0) + { + item = default; + return false; + } + + item = _items[0]; + _items.RemoveAtSwapBack(0); + + if (Count > 0) + MoveDown(0); + + return true; + } + + /// + /// Sifts the item at up toward the root until its + /// parent is no greater. + /// + private void MoveUp(int index) + { + T item = _items[index]; + while (index > 0) + { + int parent = (index - 1) >> 1; + T parentItem = _items[parent]; + if (item.CompareTo(parentItem) >= 0) + break; + + _items[index] = parentItem; + index = parent; + } + _items[index] = item; + } + + /// + /// Sifts the item at down toward the leaves until + /// both children are no smaller. + /// + private void MoveDown(int index) + { + T item = _items[index]; + int count = Count; + while (true) + { + int left = (index << 1) + 1; + int right = left + 1; + if (left >= count) break; + + int min = left; + T minItem = _items[left]; + if (right < count) + { + T r = _items[right]; + if (r.CompareTo(minItem) < 0) + { + min = right; + minItem = r; + } + } + + if (item.CompareTo(minItem) <= 0) break; + _items[index] = minItem; + index = min; + } + _items[index] = item; + } +} diff --git a/src/RemoteTech/Collections/Extensions.cs b/src/RemoteTech/Collections/Extensions.cs new file mode 100644 index 000000000..9b1e6ed43 --- /dev/null +++ b/src/RemoteTech/Collections/Extensions.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; +using Unity.Mathematics; + +namespace RemoteTech.Collections; + +internal static class RTExtensions +{ + #region NativeArray + public static unsafe ref T GetElement(this NativeArray array, int index) + where T : unmanaged + => ref ((T*)NativeArrayUnsafeUtility.GetUnsafePtr(array))[index]; + + public static void Fill(this NativeArray array, T value) + where T : unmanaged + { + for (int i = 0; i < array.Length; ++i) + array[i] = value; + } + + public static unsafe void Clear(this NativeArray array) + where T : unmanaged + { + UnsafeUtility.MemClear( + NativeArrayUnsafeUtility.GetUnsafePtr(array), + (long)array.Length * UnsafeUtility.SizeOf()); + } + #endregion + + #region NativeList + public static unsafe ref T GetElement(this NativeList list, int index) + where T : unmanaged + { + return ref ((T*)NativeListUnsafeUtility.GetUnsafePtr(list))[index]; + } + + public static void Resize(this NativeList list, int length, T value) + where T : unmanaged + { + var start = list.Length; + list.ResizeUninitialized(length); + + for (int i = start; i < length; ++i) + list[i] = value; + } + #endregion + + #region NativeSlice + public static unsafe NativeArray AsNativeArray(this NativeSlice slice) + where T : unmanaged + { + return NativeArrayUnsafeUtility.ConvertExistingDataToNativeArray( + NativeSliceUnsafeUtility.GetUnsafePtr(slice), + slice.Length, + Allocator.Invalid); + } + #endregion + + #region Vector3d + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double3 ToDouble3(this Vector3d v) => new(v.x, v.y, v.z); + #endregion + + #region List + public static Span AsSpan(this List list) => + ((Span)list._items).Slice(0, list.Count); + + public static ReadOnlySpan AsReadOnly(this Span span) => span; + public static ReadOnlySpan AsReadOnlySpan(this List list) => list.AsSpan(); + #endregion + + #region Span + public static SpanEnumerator GetEnumerator(this Span span) => new(span); + + public static ReadOnlySpanEnumerator GetEnumerator(this ReadOnlySpan span) => new(span); + + public static T[] Concat(this ReadOnlySpan a, ReadOnlySpan b) + { + T[] array = new T[a.Length + b.Length]; + a.CopyTo(array.AsSpan().Slice(0, a.Length)); + b.CopyTo(array.AsSpan().Slice(a.Length)); + return array; + } + #endregion + + #region NativeKeyValueArrays + public static NativeKeyValueArraysEnumerator GetEnumerator(this NativeKeyValueArrays arrays) + where K : unmanaged + where V : unmanaged + { + return new(arrays); + } + #endregion +} + +public ref struct SpanEnumerator(Span span) : IEnumerator +{ + readonly Span span = span; + int index = -1; + + public readonly ref T Current => ref span[index]; + readonly T IEnumerator.Current => Current; + readonly object IEnumerator.Current => Current; + + public bool MoveNext() + { + index += 1; + return index < span.Length; + } + + public void Dispose() { } + + public void Reset() => index = -1; +} + +public ref struct ReadOnlySpanEnumerator(ReadOnlySpan span) : IEnumerator +{ + readonly ReadOnlySpan span = span; + int index = -1; + + public readonly T Current => span[index]; + readonly object IEnumerator.Current => Current; + + public bool MoveNext() + { + index += 1; + return index < span.Length; + } + + public void Dispose() { } + + public void Reset() => index = -1; +} + +internal struct NativeKeyValueArraysEnumerator(NativeKeyValueArrays arrays) : IEnumerator> + where K : unmanaged + where V : unmanaged +{ + int index = -1; + readonly NativeKeyValueArrays arrays = arrays; + + public KeyValuePair Current => new (arrays.Keys[index], arrays.Values[index]); + + object IEnumerator.Current => Current; + + public bool MoveNext() + { + index += 1; + return index < arrays.Keys.Length; + } + + public void Reset() => index = -1; + + public void Dispose() { } +} diff --git a/src/RemoteTech/Collections/IJobParallelForBatchDefer.cs b/src/RemoteTech/Collections/IJobParallelForBatchDefer.cs new file mode 100644 index 000000000..fb6241798 --- /dev/null +++ b/src/RemoteTech/Collections/IJobParallelForBatchDefer.cs @@ -0,0 +1,73 @@ +using System; +using System.Runtime.InteropServices; +using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; +using Unity.Jobs; +using Unity.Jobs.LowLevel.Unsafe; + +namespace RemoteTech.Collections; + +/// +/// A batched equivalent of . +/// +[JobProducerType(typeof(IJobParallelForBatchDeferExtensions.JobData<>))] +internal interface IJobParallelForBatchDefer +{ + void Execute(int start, int count); +} + +internal static class IJobParallelForBatchDeferExtensions +{ + [StructLayout(LayoutKind.Sequential, Size = 1)] + internal struct JobData + where T : struct, IJobParallelForBatchDefer + { + public delegate void ExecuteJobFunction( + ref T jobData, + IntPtr additionalPtr, + IntPtr bufferRangePatchData, + ref JobRanges ranges, + int jobIndex); + + public readonly static IntPtr JobReflectionData = JobsUtility.CreateJobReflectionData( + typeof(T), + typeof(T), + JobType.ParallelFor, + new ExecuteJobFunction(Execute)); + + public static void Execute( + ref T jobData, + IntPtr additionalPtr, + IntPtr bufferRangePatchData, + ref JobRanges ranges, + int jobIndex) + { + while (JobsUtility.GetWorkStealingRange(ref ranges, jobIndex, out var start, out var end)) + { + jobData.Execute(start, end - start); + } + } + } + + public unsafe static JobHandle Schedule( + this T job, + NativeList list, + int innerloopBatchCount, + JobHandle dependsOn = default + ) + where T : struct, IJobParallelForBatchDefer + where U : unmanaged + { + var parameters = new JobsUtility.JobScheduleParameters( + UnsafeUtility.AddressOf(ref job), + JobData.JobReflectionData, + dependsOn, + ScheduleMode.Batched); + + return JobsUtility.ScheduleParallelForDeferArraySize( + ref parameters, + innerloopBatchCount, + NativeListUnsafeUtility.GetInternalListDataPtrUnchecked(ref list), + null); + } +} \ No newline at end of file diff --git a/src/RemoteTech/Collections/IntRange.cs b/src/RemoteTech/Collections/IntRange.cs new file mode 100644 index 000000000..7eef094ed --- /dev/null +++ b/src/RemoteTech/Collections/IntRange.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace RemoteTech.Collections; + +internal readonly struct IntRange(int start, int length) +{ + public int Start { get; }= start; + public int Length { get; } = length; + public int End => Start + Length; + + public bool IsEmpty => Length == 0; + + public RangeEnumerator GetEnumerator() => new(this); +} + +internal struct RangeEnumerator(IntRange range) : IEnumerator +{ + int index = range.Start - 1; + readonly int end = range.End; + + public readonly int Current => index; + readonly object IEnumerator.Current => Current; + + public readonly void Dispose() { } + + public bool MoveNext() + { + index += 1; + return index < end; + } + + public void Reset() => throw new NotImplementedException(); + + public readonly RangeEnumerator GetEnumerator() => this; +} \ No newline at end of file diff --git a/src/RemoteTech/Collections/ObjectExt.cs b/src/RemoteTech/Collections/ObjectExt.cs new file mode 100644 index 000000000..fe5129ff4 --- /dev/null +++ b/src/RemoteTech/Collections/ObjectExt.cs @@ -0,0 +1,27 @@ +using System; +using System.Runtime.CompilerServices; + +namespace RemoteTech.Collections; + +internal static class ObjectExt +{ + /// + /// True if this reference is null or if the instance is destroyed
+ /// Equivalent as testing == null but 4-5 times faster. + ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsNullOrDestroyed(this UnityEngine.Object unityObject) + { + return unityObject is null || unityObject.m_CachedPtr == IntPtr.Zero; + } + + /// + /// True if this reference is not null and the instance is not destroyed
+ /// Equivalent as testing != null but 4-5 times faster. + ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsNotNullOrDestroyed(this UnityEngine.Object unityObject) + { + return unityObject is not null && unityObject.m_CachedPtr != IntPtr.Zero; + } +} \ No newline at end of file diff --git a/src/RemoteTech/Collections/ObjectHandle.cs b/src/RemoteTech/Collections/ObjectHandle.cs new file mode 100644 index 000000000..e01d59bc4 --- /dev/null +++ b/src/RemoteTech/Collections/ObjectHandle.cs @@ -0,0 +1,41 @@ +using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Unity.Jobs; + +namespace RemoteTech.Collections; + +/// +/// A helper struct that allows you to pass managed objects to jobs. +/// +/// +internal struct ObjectHandle(T value, GCHandleType type) : IDisposable + where T : class +{ + GCHandle handle = GCHandle.Alloc(value, type); + + public T Target + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => (T)handle.Target; + } + + public bool IsAllocated => handle.IsAllocated; + + public ObjectHandle(T value) + : this(value, GCHandleType.Normal) { } + + public IntPtr AddrOfPinnedObject() => handle.AddrOfPinnedObject(); + + public void Dispose() => handle.Free(); + + public JobHandle Dispose(JobHandle job) => + new DisposeJob { handle = this }.Schedule(job); + + struct DisposeJob : IJob + { + public ObjectHandle handle; + + public void Execute() => handle.Dispose(); + } +} diff --git a/src/RemoteTech/Collections/SpookyHash.cs b/src/RemoteTech/Collections/SpookyHash.cs new file mode 100644 index 000000000..56dbd30cb --- /dev/null +++ b/src/RemoteTech/Collections/SpookyHash.cs @@ -0,0 +1,187 @@ +using Unity.Collections.LowLevel.Unsafe; +using UnityEngine; + +namespace RemoteTech.Collections; + +/// +/// Burst-compatible port of SpookyHash V2. +/// +/// +/// +/// Unity actually has this available internally, but it is not burst-compatible +/// due to a call made in its static constructor. +/// +internal static unsafe class SpookyHash +{ + const ulong SeedConst = 0xdeadbeefdeadbeefUL; + + static readonly byte[] MixRot = [11, 32, 43, 31, 17, 28, 39, 57, 55, 54, 22, 46]; + static readonly byte[] EndRot = [44, 15, 34, 21, 38, 33, 10, 13, 38, 53, 42, 54]; + static readonly byte[] ShortMixRot = [50, 52, 30, 41, 54, 48, 38, 37, 62, 34, 5, 36]; + static readonly byte[] ShortEndRot = [15, 52, 26, 51, 28, 9, 47, 54, 32, 25, 63]; + + static ulong Rot64(ulong x, int k) => (x << k) | (x >> (64 - k)); + + /// + /// Updates the 128-bit state in place, seeded from 's current value. + /// + public static void Hash128(void* data, int length, ref Hash128 hash) + { + fixed (Hash128* hp = &hash) + { + ulong* seed = (ulong*)hp; + ulong hash1 = seed[0]; + ulong hash2 = seed[1]; + + if (length < 192) + { + Short(data, length, ref hash1, ref hash2); + } + else + { + var s = stackalloc ulong[12]; + for (int i = 0; i < 12; i++) + s[i] = (i % 3) switch { 0 => hash1, 1 => hash2, _ => SeedConst }; + + byte* p = (byte*)data; + byte* end = p + length / 96 * 96; + while (p < end) + { + Mix((ulong*)p, s); + p += 96; + } + + var buf = stackalloc ulong[12]; + int remainder = length - (int)(end - (byte*)data); + UnsafeUtility.MemCpy(buf, p, remainder); + UnsafeUtility.MemClear((byte*)buf + remainder, 96 - remainder); + ((byte*)buf)[95] = (byte)remainder; + + End(buf, s); + + hash1 = s[0]; + hash2 = s[1]; + } + + seed[0] = hash1; + seed[1] = hash2; + } + } + + static void Mix(ulong* data, ulong* s) + { + for (int i = 0; i < 12; i++) + { + int a = i; + int b = (i + 2) % 12; + int c = (i + 10) % 12; + int d = (i + 11) % 12; + int e = (i + 1) % 12; + s[a] += data[a]; + s[b] ^= s[c]; + s[d] ^= s[a]; + s[a] = Rot64(s[a], MixRot[a]); + s[d] += s[e]; + } + } + + static void End(ulong* data, ulong* s) + { + for (int i = 0; i < 12; i++) s[i] += data[i]; + + for (int round = 0; round < 3; round++) + { + for (int i = 0; i < 12; i++) + { + int a = (i + 11) % 12; + int b = (i + 1) % 12; + int c = (i + 2) % 12; + s[a] += s[b]; + s[c] ^= s[a]; + s[b] = Rot64(s[b], EndRot[i]); + } + } + } + + static void ShortMix(ulong* h) + { + for (int k = 0; k < 12; k++) + { + int cur = (2 + k) % 4; + int nxt = (cur + 1) % 4; + int prev2 = (cur + 2) % 4; + h[cur] = Rot64(h[cur], ShortMixRot[k]); + h[cur] += h[nxt]; + h[prev2] ^= h[cur]; + } + } + + static readonly int[] ShortEndX = { 3, 0, 1, 2 }; + static readonly int[] ShortEndY = { 2, 3, 0, 1 }; + + static void ShortEnd(ulong* h) + { + for (int k = 0; k < 11; k++) + { + int x = ShortEndX[k % 4]; + int y = ShortEndY[k % 4]; + h[x] ^= h[y]; + h[y] = Rot64(h[y], ShortEndRot[k]); + h[x] += h[y]; + } + } + + static void Short(void* message, int length, ref ulong hash1, ref ulong hash2) + { + var h = stackalloc ulong[4]; + h[0] = hash1; + h[1] = hash2; + h[2] = SeedConst; + h[3] = SeedConst; + + byte* p = (byte*)message; + int remaining = length; + + while (remaining >= 32) + { + h[2] += *(ulong*)p; + h[3] += *(ulong*)(p + 8); + ShortMix(h); + h[0] += *(ulong*)(p + 16); + h[1] += *(ulong*)(p + 24); + p += 32; + remaining -= 32; + } + + if (remaining >= 16) + { + h[2] += *(ulong*)p; + h[3] += *(ulong*)(p + 8); + ShortMix(h); + p += 16; + remaining -= 16; + } + + h[3] += (ulong)(uint)length << 56; + + if (remaining > 0) + { + // Zero-padded scratch instead of Unity's in-place tail reads, which overrun the buffer for a 7-byte remainder. + var tail = stackalloc byte[16]; + UnsafeUtility.MemClear(tail, 16); + UnsafeUtility.MemCpy(tail, p, remaining); + h[2] += *(ulong*)tail; + h[3] += *(ulong*)(tail + 8); + } + else + { + h[2] += SeedConst; + h[3] += SeedConst; + } + + ShortEnd(h); + + hash1 = h[0]; + hash2 = h[1]; + } +} diff --git a/src/RemoteTech/FlightComputer/Commands/ExternalAPICommand.cs b/src/RemoteTech/FlightComputer/Commands/ExternalAPICommand.cs index 3ce05d512..19859e5bb 100644 --- a/src/RemoteTech/FlightComputer/Commands/ExternalAPICommand.cs +++ b/src/RemoteTech/FlightComputer/Commands/ExternalAPICommand.cs @@ -5,27 +5,49 @@ namespace RemoteTech.FlightComputer.Commands { public class ExternalAPICommand : AbstractCommand { - /// original ConfigNode object passed from the api + /// + /// original ConfigNode object passed from the api + /// private ConfigNode externalData; - /// Name of the mod who passed this command + /// + /// Name of the mod who passed this command + /// private string Executor; - /// Label for this command on the queue + /// + /// Label for this command on the queue + /// private string QueueLabel; - /// Label for this command if its active + /// + /// Label for this command if its active + /// private string ActiveLabel; - /// Label for alert on no power + /// + /// Label for alert on no power + /// private string ShortLabel; - /// The ReflectionType for the methods to invoke + /// + /// The ReflectionType for the methods to invoke + /// private string ReflectionType; - /// Name of the Pop-method on the ReflectionType + /// + /// Name of the Pop-method on the ReflectionType + /// private string ReflectionPopMethod = ""; - /// Name of the Execution-method on the ReflectionType + /// + /// Name of the Execution-method on the ReflectionType + /// private string ReflectionExecuteMethod = ""; - /// Name of the Abort-method on the ReflectionType + /// + /// Name of the Abort-method on the ReflectionType + /// private string ReflectionAbortMethod = ""; - /// GUID of the vessel + /// + /// GUID of the vessel + /// private string GUIDString; - /// true - when this command will be aborted + /// + /// true - when this command will be aborted + /// private bool AbortCommand = false; public override int Priority { get { return 0; } } diff --git a/src/RemoteTech/FlightComputer/Commands/ManeuverCommand.cs b/src/RemoteTech/FlightComputer/Commands/ManeuverCommand.cs index 8482e9004..db78a0e48 100644 --- a/src/RemoteTech/FlightComputer/Commands/ManeuverCommand.cs +++ b/src/RemoteTech/FlightComputer/Commands/ManeuverCommand.cs @@ -6,7 +6,9 @@ namespace RemoteTech.FlightComputer.Commands { public class ManeuverCommand : AbstractCommand { - /// Index id of this maneuver node from patchedConicSolver.maneuverNodes list + /// + /// Index id of this maneuver node from patchedConicSolver.maneuverNodes list + /// [Persistent] public int NodeIndex; /// [Persistent] public string KaCItemId = String.Empty; diff --git a/src/RemoteTech/FlightComputer/FlightComputer.cs b/src/RemoteTech/FlightComputer/FlightComputer.cs index 385d34b2f..723899a1d 100644 --- a/src/RemoteTech/FlightComputer/FlightComputer.cs +++ b/src/RemoteTech/FlightComputer/FlightComputer.cs @@ -16,49 +16,74 @@ namespace RemoteTech.FlightComputer /// public class FlightComputer : IDisposable { - /// Flight computer loaded configuration from persistent save. + /// + /// Flight computer loaded configuration from persistent save. + /// private ConfigNode _fcLoadedConfigs; - /// List of active commands in the flight computer. + /// + /// List of active commands in the flight computer. + /// private readonly SortedDictionary _activeCommands = new SortedDictionary(); - /// List of commands queued in the flight computer. + /// + /// List of commands queued in the flight computer. + /// private readonly List _commandQueue = new List(); - /// Flight control queue: this is a priority queue used to delay . + /// + /// Flight control queue: this is a priority queue used to delay . + /// private readonly PriorityQueue _flightCtrlQueue = new PriorityQueue(); - /// The window of the flight computer. + /// + /// The window of the flight computer. + /// private FlightComputerWindow _flightComputerWindow; - /// Current state of the flight computer. + /// + /// Current state of the flight computer. + /// [Flags] public enum State { - /// Normal state. + /// + /// Normal state. + /// Normal = 0, - /// The flight computer (and its vessel) are packed: vessels are only packed when they come within about 300m of the active vessel. + /// + /// The flight computer (and its vessel) are packed: vessels are only packed when they come within about 300m of the active vessel. + /// Packed = 2, - /// The flight computer (and its vessel) are out of power. + /// + /// The flight computer (and its vessel) are out of power. + /// OutOfPower = 4, - /// The flight computer (and its vessel) have no connection. + /// + /// The flight computer (and its vessel) have no connection. + /// NoConnection = 8, - /// The flight computer signal processor is not the vessel main signal processor (see ). + /// + /// The flight computer signal processor is not the vessel main signal processor (see ). + /// NotMaster = 16, } - /// Gets whether or not it is possible to give input to the flight computer (and consequently, to the vessel). + /// + /// Gets whether or not it is possible to give input to the flight computer (and consequently, to the vessel). + /// public bool InputAllowed { get { var satellite = RTCore.Instance.Network[SignalProcessor.VesselId]; - var connection = RTCore.Instance.Network[satellite]; - return (satellite != null && satellite.HasLocalControl) || (SignalProcessor.Powered && connection.Any()); + return (satellite != null && satellite.HasLocalControl) || (SignalProcessor.Powered && RTCore.Instance.Network.IsConnected(satellite)); } } - /// Gets the delay applied to a flight computer (and hence, its vessel). + /// + /// Gets the delay applied to a flight computer (and hence, its vessel). + /// public double Delay { get @@ -68,70 +93,106 @@ public double Delay if (satellite != null && satellite.HasLocalControl) return 0.0; - var connection = RTCore.Instance.Network[satellite]; - return !connection.Any() ? double.PositiveInfinity : connection.Min().Delay; + return RTCore.Instance.Network.ShortestDelay(satellite); } } - /// Gets the current status of the flight computer. + /// + /// Gets the current status of the flight computer. + /// public State Status { get { var satellite = RTCore.Instance.Network[SignalProcessor.VesselId]; - var connection = RTCore.Instance.Network[satellite]; var status = State.Normal; if (!SignalProcessor.Powered) status |= State.OutOfPower; if (!SignalProcessor.IsMaster) status |= State.NotMaster; - if (!connection.Any()) status |= State.NoConnection; + if (!RTCore.Instance.Network.IsConnected(satellite)) status |= State.NoConnection; if (Vessel.packed) status |= State.Packed; return status; } } - /// Returns true to keep the throttle on the current position without a connection, otherwise false. + /// + /// Returns true to keep the throttle on the current position without a connection, otherwise false. + /// public bool KeepThrottleNoConnect => !RTSettings.Instance.ThrottleZeroOnNoConnection; - /// Returns true to lock the throttle on the current position without a connection, otherwise false. + /// + /// Returns true to lock the throttle on the current position without a connection, otherwise false. + /// public bool LockedThrottleNoConnect = false; - /// Returns the last known position of throttle prior to connection loss. + /// + /// Returns the last known position of throttle prior to connection loss. + /// public float LockedThrottlePositionNoConnect = 0f; - /// Returns true to set the time wrap factor to 1 upon a connection reestablished, otherwise false + /// + /// Returns true to set the time wrap factor to 1 upon a connection reestablished, otherwise false + /// public bool StopTimeWrapOnReconnect => RTSettings.Instance.StopTimeWrapOnReConnection; - /// Returns true to indicate whether a connection loss occurs + /// + /// Returns true to indicate whether a connection loss occurs + /// private bool TimeWrapConnectionLoss = false; - /// Gets or sets the total delay which is the usual light speed delay + any manual delay. + /// + /// Gets or sets the total delay which is the usual light speed delay + any manual delay. + /// public double TotalDelay { get; set; } - /// The target () of a . + /// + /// The target () of a . + /// public ITargetable DelayedTarget { get; set; } - /// The last used by the Flight Computer. + /// + /// The last used by the Flight Computer. + /// public TargetCommand LastTarget; - /// The vessel owning this flight computer. + /// + /// The vessel owning this flight computer. + /// public Vessel Vessel { get; private set; } - /// The signal processor (; ) used by this flight computer. + /// + /// The signal processor (; ) used by this flight computer. + /// public ISignalProcessor SignalProcessor { get; } - /// List of autopilots for this flight computer. Used by external mods to add their own autopilots ( class). + /// + /// List of autopilots for this flight computer. Used by external mods to add their own autopilots ( class). + /// public List> SanctionedPilots { get; } - /// List of commands that are currently active (not queued). + /// + /// List of commands that are currently active (not queued). + /// public IEnumerable ActiveCommands => _activeCommands.Values; - /// List of queued commands in the flight computer. + /// + /// List of queued commands in the flight computer. + /// public IEnumerable QueuedCommands => _commandQueue; - /// Action triggered if the active command is aborted. + /// + /// Action triggered if the active command is aborted. + /// public Action OnActiveCommandAbort; - /// Action triggered if a new command popped to an active command. + /// + /// Action triggered if a new command popped to an active command. + /// public Action OnNewCommandPop; - /// Get the active Flight mode as an (). + /// + /// Get the active Flight mode as an (). + /// public AttitudeCommand CurrentFlightMode => _activeCommands[0] as AttitudeCommand; - /// Proportional Integral Derivative vessel controller. + /// + /// Proportional Integral Derivative vessel controller. + /// public PIDController PIDController; public static double PIDKp = 2.0, PIDKi = 0.8, PIDKd = 1.0; public static readonly double RoverPIDKp = 1.0, RoverPIDKi = 0.0, RoverPIDKd = 0.0; - /// The window of the flight computer. + /// + /// The window of the flight computer. + /// public FlightComputerWindow Window { get @@ -141,10 +202,14 @@ public FlightComputerWindow Window } } - /// Computer able to pilot a rover (part of the flight computer). + /// + /// Computer able to pilot a rover (part of the flight computer). + /// public RoverComputer RoverComputer { get; } - /// Flight Computer constructor. + /// + /// Flight Computer constructor. + /// /// A signal processor (most probably a instance.) public FlightComputer(ISignalProcessor s) { @@ -180,7 +245,9 @@ public FlightComputer(ISignalProcessor s) } } - /// Called when a game switch is requested: close the current computer. + /// + /// Called when a game switch is requested: close the current computer. + /// /// data with from and to scenes. private void OnSceneSwitchRequested(GameEvents.FromToAction data) { @@ -188,7 +255,9 @@ private void OnSceneSwitchRequested(GameEvents.FromToActionCalled when there's a vessel switch, switching from `fromVessel` to `toVessel`. + /// + /// Called when there's a vessel switch, switching from `fromVessel` to `toVessel`. + /// /// The vessel we switch from. /// The vessel we're switching to. private void OnVesselSwitching(Vessel fromVessel, Vessel toVessel) @@ -205,7 +274,9 @@ private void OnVesselSwitching(Vessel fromVessel, Vessel toVessel) _flightComputerWindow?.Hide(); } - /// After switching the vessel hide the current flight computer UI. + /// + /// After switching the vessel hide the current flight computer UI. + /// /// The **new** vessel we are changing to. public void OnVesselChange(Vessel vessel) { @@ -214,7 +285,9 @@ public void OnVesselChange(Vessel vessel) _flightComputerWindow?.Hide(); } - /// Called when the flight computer is disposed. This happens when the is destroyed. + /// + /// Called when the flight computer is disposed. This happens when the is destroyed. + /// public void Dispose() { RTLog.Notify("FlightComputer: Dispose"); @@ -244,7 +317,9 @@ public void Dispose() } } - /// Abort all active commands. + /// + /// Abort all active commands. + /// public void Reset() { foreach (var cmd in _activeCommands.Values) @@ -255,7 +330,9 @@ public void Reset() OnActiveCommandAbort.Invoke(); } - /// Enqueue a command in the flight computer command queue. + /// + /// Enqueue a command in the flight computer command queue. + /// /// The command to be enqueued. /// If true the command is not enqueued. /// If true, the command is executed immediately, otherwise the light speed delay is applied. @@ -280,7 +357,9 @@ public void Enqueue(ICommand cmd, bool ignoreControl = false, bool ignoreDelay = } } - /// Remove a command from the flight computer command queue. + /// + /// Remove a command from the flight computer command queue. + /// /// The command to be removed from the command queue. public void Remove(ICommand cmd) { @@ -288,7 +367,9 @@ public void Remove(ICommand cmd) if (_activeCommands.ContainsValue(cmd)) _activeCommands.Remove(cmd.Priority); } - /// Called by the method during the Update() "Game Logic" engine phase. + /// + /// Called by the method during the Update() "Game Logic" engine phase. + /// /// This checks if there are any commands that can be removed from the FC queue if their delay has elapsed. public void OnUpdate() { @@ -298,7 +379,9 @@ public void OnUpdate() ExecuteConnectionStatusActions(); } - /// Called by the method during the "Physics" engine phase. + /// + /// Called by the method during the "Physics" engine phase. + /// public void OnFixedUpdate() { if (RTCore.Instance == null) return; @@ -345,7 +428,9 @@ public void OnFixedUpdate() } } - /// Updates the last target command used by the flight computer. + /// + /// Updates the last target command used by the flight computer. + /// private void UpdateLastTarget() { int lastTargetIndex = _commandQueue.FindLastIndex(c => (c is TargetCommand)); @@ -364,7 +449,9 @@ private void UpdateLastTarget() } } - /// Enqueue a to the flight control queue. + /// + /// Enqueue a to the flight control queue. + /// /// The to be queued. private void Enqueue(FlightCtrlState fs) { @@ -392,7 +479,9 @@ private void Enqueue(FlightCtrlState fs) _flightCtrlQueue.Enqueue(dfs); } - /// Remove a from the flight control queue. + /// + /// Remove a from the flight control queue. + /// /// The to be removed from the queue. /// The satellite from which the should be removed. private void PopFlightCtrl(FlightCtrlState fcs, ISatellite sat) @@ -422,7 +511,9 @@ private void PopFlightCtrl(FlightCtrlState fcs, ISatellite sat) fcs.CopyFrom(delayed); } - /// Check whether there are commands that can be removed from the flight computer command queue (when their delay time has elapsed). + /// + /// Check whether there are commands that can be removed from the flight computer command queue (when their delay time has elapsed). + /// /// This is done during the Update() phase of the game engine. method. private void PopCommand() { @@ -489,7 +580,9 @@ private void PopCommand() } } - /// Control the flight. Called before the method. + /// + /// Control the flight. Called before the method. + /// /// The input flight control state. private void OnFlyByWirePre(FlightCtrlState fcs) { @@ -520,7 +613,9 @@ private void OnFlyByWirePre(FlightCtrlState fcs) } } - /// Control the flight. Called after the method. + /// + /// Control the flight. Called after the method. + /// /// The input flight control state. private void OnFlyByWirePost(FlightCtrlState fcs) { @@ -545,7 +640,9 @@ private void OnFlyByWirePost(FlightCtrlState fcs) } } - /// Orders the command queue to be chronological. + /// + /// Orders the command queue to be chronological. + /// public void OrderCommandList() { if (_commandQueue.Count <= 0) return; @@ -563,7 +660,9 @@ public void OrderCommandList() } } - /// Restores the flight computer from the persistent save. + /// + /// Restores the flight computer from the persistent save. + /// /// Node with the informations for the flight computer public void Load(ConfigNode configNode) { @@ -663,7 +762,9 @@ public void Load(ConfigNode configNode) UpdateLastTarget(); } - /// Saves all values for the flight computer to the persistent. + /// + /// Saves all values for the flight computer to the persistent. + /// /// Node to save in public void Save(ConfigNode n) { @@ -699,7 +800,9 @@ public void Save(ConfigNode n) n.AddNode(flightNode); } - /// Returns true if there's a least one on the queue. + /// + /// Returns true if there's a least one on the queue. + /// public bool HasManeuverCommands() { if (_commandQueue.Count <= 0) @@ -710,7 +813,9 @@ public bool HasManeuverCommands() return maneuverFound != null; } - /// Looks for the passed on the command queue and returns true if the node is already on the list. + /// + /// Looks for the passed on the command queue and returns true if the node is already on the list. + /// /// Node to search in the queued commands public bool HasManeuverCommandByNode(ManeuverNode node) { @@ -722,7 +827,9 @@ public bool HasManeuverCommandByNode(ManeuverNode node) return maneuverFound != null; } - /// Triggers a for the given + /// + /// Triggers a for the given + /// /// Node to cancel from the queue public void RemoveManeuverCommandByNode(ManeuverNode node) { @@ -749,7 +856,7 @@ private void ExecuteConnectionStatusActions() //stop time wrap if re-connection occurred if (StopTimeWrapOnReconnect && TimeWarp.CurrentRate > 1.0f) { - if (!RTCore.Instance.Satellites[SignalProcessor.VesselId].Connections.Any()) // no connection + if (!RTCore.Instance.Network.IsConnected(RTCore.Instance.Satellites[SignalProcessor.VesselId])) // no connection { TimeWrapConnectionLoss = true; } diff --git a/src/RemoteTech/FlightComputer/UIPartActionMenuPatcher.cs b/src/RemoteTech/FlightComputer/UIPartActionMenuPatcher.cs index 283502442..24cc8f6e7 100644 --- a/src/RemoteTech/FlightComputer/UIPartActionMenuPatcher.cs +++ b/src/RemoteTech/FlightComputer/UIPartActionMenuPatcher.cs @@ -126,7 +126,9 @@ public WrappedField(BaseField baseField, KSPField field) : base(field, baseField { } - /// Gets or sets the future field value. + /// + /// Gets or sets the future field value. + /// public object NewValue { get; set; } public Type NewValueType => FieldInfo.FieldType; diff --git a/src/RemoteTech/ISatellite.cs b/src/RemoteTech/ISatellite.cs index c492aa544..fe5956533 100644 --- a/src/RemoteTech/ISatellite.cs +++ b/src/RemoteTech/ISatellite.cs @@ -5,42 +5,91 @@ namespace RemoteTech { + public struct SatelliteState + { + public Guid Guid; + public Guid Body; + public Vector3d Position; + public bool Powered; + public bool IsCommandStation; + public bool CanRelaySignal; + public bool IsInRadioBlackout; + } + public interface ISatellite { - /// Gets whether or not a satellite if visible in the Tracking station or the Flight Map view. + /// + /// Gets whether or not a satellite if visible in the Tracking station or the Flight Map view. + /// bool Visible { get; } - /// Gets or sets the name of the satellite. + /// + /// Gets or sets the name of the satellite. + /// string Name { get; set; } - /// Gets the satellite id. + /// + /// Gets the satellite id. + /// Guid Guid { get; } - /// Gets a double precision vector for the satellite's world space position. + /// + /// Gets a double precision vector for the satellite's world space position. + /// Vector3d Position { get; } - /// Gets the celestial body around which the satellite is orbiting. + /// + /// Gets the celestial body around which the satellite is orbiting. + /// CelestialBody Body { get; } - /// Gets the color of the ground station mark in Tracking station or Flight map view. + /// + /// Gets the color of the ground station mark in Tracking station or Flight map view. + /// Color MarkColor { get; } - /// Gets if the satellite is actually powered or not. + /// + /// Gets if the satellite is actually powered or not. + /// bool Powered { get; } - /// Gets if the satellite is a RemoteTech command station. + /// + /// Gets if the satellite is a RemoteTech command station. + /// bool IsCommandStation { get; } - /// Gets whether the satellite has local control or not (that is, if it is locally controlled or not). + /// + /// Gets whether the satellite has local control or not (that is, if it is locally controlled or not). + /// bool HasLocalControl { get; } - /// Indicates whether the ISatellite corresponds to a vessel + /// + /// Indicates whether the ISatellite corresponds to a vessel + /// /// true if satellite is vessel or asteroid; otherwise (e.g. a ground station), false. bool isVessel { get; } - /// The vessel hosting the ISatellite, if one exists. + /// + /// The vessel hosting the ISatellite, if one exists. + /// /// The vessel corresponding to this ISatellite. Returns null if !isVessel. Vessel parentVessel { get; } - /// Gets a list of antennas for this satellite. - IEnumerable Antennas { get; } - /// Called on connection refresh to update the connections. + /// + /// Gets a list of antennas for this satellite. + /// + IReadOnlyList Antennas { get; } + /// + /// Called on connection refresh to update the connections. + /// /// List of network routes. void OnConnectionRefresh(List> routes); - /// Gets if the satellite is capable to forward other signals. + /// + /// Gets if the satellite is capable to forward other signals. + /// bool CanRelaySignal { get; } - /// Indicates whether the satellite is in radio blackout. + /// + /// Indicates whether the satellite is in radio blackout. + /// bool IsInRadioBlackout { get; set; } - /// Indicates whether the manual power override is engaged. + /// + /// Indicates whether the manual power override is engaged. + /// bool PowerShutdownFlag { get; set; } + + /// + /// Get all the state needed for a background update at once. + /// + /// + SatelliteState GetState(); } } diff --git a/src/RemoteTech/Modules/MissionControlAntenna.cs b/src/RemoteTech/Modules/MissionControlAntenna.cs index e636be542..3cdb83e77 100644 --- a/src/RemoteTech/Modules/MissionControlAntenna.cs +++ b/src/RemoteTech/Modules/MissionControlAntenna.cs @@ -1,41 +1,72 @@ using System; using System.Linq; +using RemoteTech.SimpleTypes; namespace RemoteTech.Modules { - public sealed class MissionControlAntenna : IAntenna + public sealed class MissionControlAntenna : IAntenna, IConfigNode { - [Persistent] public float Omni = 75000000; - [Persistent] public float Dish = 0.0f; - [Persistent] public double CosAngle = 1.0; + public float Omni = 75000000; + public float Dish = 0.0f; + public double CosAngle = 1.0; /// /// Semicolon seperated list with omni ranges for each tech lvl of the tracking station /// - [Persistent] public string UpgradeableOmni = String.Empty; + public string UpgradeableOmni = String.Empty; /// /// Semicolon seperated list with dish ranges for each tech lvl of the tracking station /// - [Persistent] public string UpgradeableDish = String.Empty; + public string UpgradeableDish = String.Empty; /// /// Semicolon seperated list with CosAngle ranges for each tech lvl of the tracking station /// - [Persistent] public string UpgradeableCosAngle = String.Empty; + public string UpgradeableCosAngle = String.Empty; + + public void Load(ConfigNode node) + { + node.TryGetValue("Omni", ref Omni); + node.TryGetValue("Dish", ref Dish); + node.TryGetValue("CosAngle", ref CosAngle); + node.TryGetValue("UpgradeableOmni", ref UpgradeableOmni); + node.TryGetValue("UpgradeableDish", ref UpgradeableDish); + node.TryGetValue("UpgradeableCosAngle", ref UpgradeableCosAngle); + } + + public void Save(ConfigNode node) + { + node.AddValue("Omni", Omni); + node.AddValue("Dish", Dish); + node.AddValue("CosAngle", CosAngle); + node.AddValue("UpgradeableOmni", UpgradeableOmni); + node.AddValue("UpgradeableDish", UpgradeableDish); + node.AddValue("UpgradeableCosAngle", UpgradeableCosAngle); + } public ISatellite Parent { get; set; } - float IAntenna.Omni { get { return Omni * MissionControlRangeMultiplier; } } - Guid IAntenna.Guid { get { return Parent.Guid; } } - String IAntenna.Name { get { return "Dummy Antenna"; } } - bool IAntenna.Powered { get { return true; } } - public bool Connected { get { return RTCore.Instance.Network.Graph [((IAntenna)this).Guid].Any (l => l.Interfaces.Contains (this)); } } - bool IAntenna.Activated { get { return true; } set { return; } } - float IAntenna.Consumption { get { return 0.0f; } } - bool IAntenna.CanTarget { get { return false; } } - Guid IAntenna.Target { get { return new Guid(RTSettings.Instance.ActiveVesselGuid); } set { return; } } - float IAntenna.Dish { get { return Dish * MissionControlRangeMultiplier; } } - double IAntenna.CosAngle { get { return CosAngle; } } - private float MissionControlRangeMultiplier { get { return RTSettings.Instance.MissionControlRangeMultiplier; } } + private readonly AntennaState mState = new(); + + float IAntenna.Omni => Omni * MissionControlRangeMultiplier; + Guid IAntenna.Guid => Parent.Guid; + string IAntenna.Name => "Dummy Antenna"; + bool IAntenna.Powered => true; + public bool Connected => RTCore.Instance.Network.IsAntennaConnected(this); + bool IAntenna.Activated + { + get => true; + set { } + } + float IAntenna.Consumption => 0.0f; + bool IAntenna.CanTarget => false; + Guid IAntenna.Target + { + get => RTSettings.Instance.ActiveVesselGuidParsed; + set { } + } + float IAntenna.Dish => Dish * MissionControlRangeMultiplier; + double IAntenna.CosAngle => CosAngle; + private float MissionControlRangeMultiplier => RTSettings.Instance.MissionControlRangeMultiplier; public void reloadUpgradeableAntennas(int techlvl = 0) { @@ -93,6 +124,31 @@ public void reloadUpgradeableAntennas(int techlvl = 0) } } + internal void RegisterState() + { + UpdateState(); + mState.Register(); + } + + internal void UnregisterState() => mState.Dispose(); + + internal void UpdateState() + { + IAntenna antenna = this; + mState.Guid = antenna.Guid; + mState.Target = antenna.Target; + mState.Activated = antenna.Activated; + mState.Powered = antenna.Powered; + mState.Connected = RTCore.Instance != null + && RTCore.Instance.Network != null + && RTCore.Instance.Network.IsAntennaConnected(this); + mState.CanTarget = antenna.CanTarget; + mState.Dish = antenna.Dish; + mState.CosAngle = antenna.CosAngle; + mState.Omni = antenna.Omni; + mState.Consumption = antenna.Consumption; + } + public void OnConnectionRefresh() { } public int CompareTo(IAntenna antenna) diff --git a/src/RemoteTech/Modules/ModuleRTAntenna.cs b/src/RemoteTech/Modules/ModuleRTAntenna.cs index 41b5a2fa4..e7500ea3e 100644 --- a/src/RemoteTech/Modules/ModuleRTAntenna.cs +++ b/src/RemoteTech/Modules/ModuleRTAntenna.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Reflection; using System.Text; +using RemoteTech.SimpleTypes; using RemoteTech.UI; using UnityEngine; using KSP.Localization; @@ -11,15 +12,17 @@ namespace RemoteTech.Modules { - /// This module represents a part that can receive control transmissions from another vessel or a ground station. + /// + /// This module represents a part that can receive control transmissions from another vessel or a ground station. + /// /// You must remove any modules from the antenna if using . [KSPModule("#RT_Editor_Antenna")]//Antenna public class ModuleRTAntenna : PartModule, IAntenna, IContractObjectiveModule, IResourceConsumer { public String Name { get { return part.partInfo.title; } } - public Guid Guid { get { return mRegisteredId; } } + public Guid Guid { get { return mState.Guid; } } public bool Powered { get { return IsRTPowered; } } - public bool Connected { get { return (RTCore.Instance != null && RTCore.Instance.Network.Graph [Guid].Any (l => l.Interfaces.Contains (this))); } } + public bool Connected { get { return RTCore.Instance != null && RTCore.Instance.Network.IsAntennaConnected(this); } } public bool Activated { get { return IsRTActive; } set { SetState(value); } } public bool CanAnimate { get { return mDeployFxModules.Count > 0; } } public bool AnimClosed { get { return mDeployFxModules.Any(fx => fx.GetScalar <= 0.1f ); } } @@ -30,12 +33,12 @@ public class ModuleRTAntenna : PartModule, IAntenna, IContractObjectiveModule, I public Guid Target { - get { return RTAntennaTarget; } + get => RTAntennaTarget; set { RTAntennaTarget = value; Events["EventTarget"].guiName = RTUtil.TargetName(Target); - foreach (UIPartActionWindow w in GameObject.FindObjectsOfType(typeof(UIPartActionWindow)).Where(w => ((UIPartActionWindow) w).part == part)) + foreach (UIPartActionWindow w in GameObject.FindObjectsOfType(typeof(UIPartActionWindow)).Where(w => ((UIPartActionWindow)w).part == part)) { w.displayDirty = true; } @@ -94,21 +97,19 @@ public float [KSPField(isPersistant = true)] public bool IsRTAntenna = true, - IsRTActive = false, - IsRTPowered = false, IsRTBroken = false, IsNonRetractable = false; - [KSPField(isPersistant = true)] - public double RTDishCosAngle = 1.0f; + public bool IsRTActive { get => mState.Activated; set => mState.Activated = value; } + public bool IsRTPowered { get => mState.Powered; set => mState.Powered = value; } + public double RTDishCosAngle { get => mState.CosAngle; set => mState.CosAngle = value; } [KSPField(isPersistant = true)] public float RTOmniRange = 0.0f, RTDishRange = 0.0f; - [KSPField] // Persistence handled by Save() - public Guid RTAntennaTarget = Guid.Empty; + public Guid RTAntennaTarget { get => mState.Target; set => mState.Target = value; } [KSPField(guiName = "#RT_ModuleUI_Autothreshold")]//Auto threshold public String GUI_DeReactivation_Status = Localizer.Format("#RT_ModuleUI_Autothreshold_Off");//"Off" @@ -143,6 +144,7 @@ public float private bool isPartActionUIOpened; private UI_FloatRange deactivatePowerThresholdFloatRange; private UI_FloatRange activatePowerThresholdFloatRange; + private readonly AntennaState mState = new(); private enum State { @@ -153,7 +155,7 @@ private enum State Malfunction, } - private Guid mRegisteredId; + private Guid mRegisteredId { get => mState.Guid; set => mState.Guid = value; } public override string GetInfo() { @@ -251,7 +253,8 @@ public virtual void SetState(bool state) if (RTCore.Instance != null) { var satellite = RTCore.Instance.Network[Guid]; - bool route_home = RTCore.Instance.Network[satellite].Any(r => r.Links[0].Interfaces.Contains(this) && RTCore.Instance.Network.GroundStations.ContainsKey(r.Goal.Guid)); + bool route_home = (Omni > 0 || Dish > 0) + && RTCore.Instance.Network[satellite].Any(r => RTCore.Instance.Network.GroundStations.ContainsKey(r.Goal.Guid)); if (mTransmitter == null && route_home) { AddTransmitter(); @@ -374,6 +377,19 @@ public void OnConnectionRefresh() public override void OnLoad(ConfigNode node) { base.OnLoad(node); + + bool isRTActive = IsRTActive; + node.TryGetValue("IsRTActive", ref isRTActive); + IsRTActive = isRTActive; + + bool isRTPowered = IsRTPowered; + node.TryGetValue("IsRTPowered", ref isRTPowered); + IsRTPowered = isRTPowered; + + double rtDishCosAngle = RTDishCosAngle; + node.TryGetValue("RTDishCosAngle", ref rtDishCosAngle); + RTDishCosAngle = rtDishCosAngle; + if (node.HasValue("RTAntennaTarget")) { try @@ -444,6 +460,10 @@ public override void OnSave(ConfigNode node) { node.AddValue("RTAntennaTarget", RTAntennaTarget.ToString()); } + + node.SetValue("IsRTActive", IsRTActive, createIfNotFound: true); + node.SetValue("IsRTPowered", IsRTPowered, createIfNotFound: true); + node.SetValue("RTDishCosAngle", RTDishCosAngle, createIfNotFound: true); } public override void OnAwake() @@ -465,6 +485,10 @@ public override void OnAwake() public override void OnStart(StartState state) { + // Registered here rather than OnAwake: OnAwake also fires on part + // prefabs during compilation, whose state is invalid to register. + mState.Register(); + Actions["ActionOpen"].guiName = ActionMode1Name; Actions["ActionOpen"].active = !IsRTBroken; Actions["ActionClose"].guiName = ActionMode0Name; @@ -618,6 +642,12 @@ private void FixedUpdate() HandleDynamicPressure(); UpdateContext(); ValidateAntennaThresholds(); + + mState.Dish = Dish; + mState.Omni = Omni; + mState.Consumption = Consumption; + mState.CanTarget = CanTarget; + mState.Connected = Connected; } private void UpdateContext() @@ -765,6 +795,7 @@ private IEnumerator SetFXModules_Coroutine(List modules, float tg private void OnDestroy() { RTLog.Notify("ModuleRTAntenna: OnDestroy"); + mState.Dispose(); GameEvents.onVesselWasModified.Remove(OnVesselModified); GameEvents.onPartUndock.Remove(OnPartUndock); if (RTCore.Instance != null && mRegisteredId != Guid.Empty) diff --git a/src/RemoteTech/Modules/ModuleRTAntennaPassive.cs b/src/RemoteTech/Modules/ModuleRTAntennaPassive.cs index 7e240d301..d754dc88d 100644 --- a/src/RemoteTech/Modules/ModuleRTAntennaPassive.cs +++ b/src/RemoteTech/Modules/ModuleRTAntennaPassive.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Text; using KSP.Localization; +using RemoteTech.SimpleTypes; namespace RemoteTech.Modules { @@ -15,7 +16,7 @@ public class ModuleRTAntennaPassive : PartModule, IAntenna public String Name { get { return part.partInfo.title; } } public Guid Guid { get { return vessel.id; } } public bool Powered { get { return Activated; } } - public bool Connected { get { return (RTCore.Instance != null && RTCore.Instance.Network.Graph [Guid].Any (l => l.Interfaces.Contains (this))); } } + public bool Connected { get { return RTCore.Instance != null && RTCore.Instance.Network.IsAntennaConnected(this); } } public bool Activated { get { return Unlocked; } set { return; } } public bool Animating { get { return false; } } @@ -50,12 +51,11 @@ public float [KSPField(isPersistant = true)] public bool IsRTAntenna = true, - IsRTActive = true, - IsRTPowered = false, IsRTBroken = false; - [KSPField(isPersistant = true)] - public double RTDishCosAngle = 1.0f; + public bool IsRTActive { get => mState.Activated; set => mState.Activated = value; } + public bool IsRTPowered { get => mState.Powered; set => mState.Powered = value; } + public double RTDishCosAngle { get => mState.CosAngle; set => mState.CosAngle = value; } [KSPField(isPersistant = true)] public float @@ -75,8 +75,9 @@ public float public int[] mDeployFxModuleIndices, mProgressFxModuleIndices; public ConfigNode mTransmitterConfig; private IScienceDataTransmitter mTransmitter; + private readonly AntennaState mState = new() { Activated = true }; - private Guid mRegisteredId; + private Guid mRegisteredId { get => mState.Guid; set => mState.Guid = value; } public override string GetInfo() { @@ -95,7 +96,8 @@ public virtual void SetState(bool state) if(RTCore.Instance != null) { var satellite = RTCore.Instance.Network[Guid]; - bool route_home = RTCore.Instance.Network[satellite].Any(r => r.Links[0].Interfaces.Contains(this) && RTCore.Instance.Network.GroundStations.ContainsKey(r.Goal.Guid)); + bool route_home = (Omni > 0 || Dish > 0) + && RTCore.Instance.Network[satellite].Any(r => RTCore.Instance.Network.GroundStations.ContainsKey(r.Goal.Guid)); if (mTransmitter == null && route_home) { AddTransmitter(); @@ -115,6 +117,19 @@ public void OnConnectionRefresh() public override void OnLoad(ConfigNode node) { base.OnLoad(node); + + bool isRTActive = IsRTActive; + node.TryGetValue("IsRTActive", ref isRTActive); + IsRTActive = isRTActive; + + bool isRTPowered = IsRTPowered; + node.TryGetValue("IsRTPowered", ref isRTPowered); + IsRTPowered = isRTPowered; + + double rtDishCosAngle = RTDishCosAngle; + node.TryGetValue("RTDishCosAngle", ref rtDishCosAngle); + RTDishCosAngle = rtDishCosAngle; + if (node.HasNode("TRANSMITTER")) { RTLog.Notify("ModuleRTAntennaPassive: Found TRANSMITTER block."); @@ -133,8 +148,21 @@ public override void OnLoad(ConfigNode node) } } + public override void OnSave(ConfigNode node) + { + base.OnSave(node); + + node.SetValue("IsRTActive", IsRTActive, createIfNotFound: true); + node.SetValue("IsRTPowered", IsRTPowered, createIfNotFound: true); + node.SetValue("RTDishCosAngle", RTDishCosAngle, createIfNotFound: true); + } + public override void OnStart(StartState state) { + // Registered here rather than OnAwake: OnAwake also fires on part + // prefabs during compilation, whose state is invalid to register. + mState.Register(); + // workarround for ksp 1.0 if (mTransmitterConfig == null) { @@ -162,6 +190,12 @@ private void FixedUpdate() RTDishRange = Dish; IsRTPowered = Powered; Fields["GUI_OmniRange"].guiActive = Activated && ShowGUI_OmniRange; + + mState.Dish = Dish; + mState.Omni = Omni; + mState.Consumption = Consumption; + mState.CanTarget = CanTarget; + mState.Connected = Connected; } private void AddTransmitter() @@ -218,6 +252,7 @@ private List FindFxModules(int[] indices, bool showUI) private void OnDestroy() { RTLog.Notify("ModuleRTAntennaPassive: OnDestroy"); + mState.Dispose(); GameEvents.onVesselWasModified.Remove(OnVesselModified); GameEvents.onPartUndock.Remove(OnPartUndock); if (RTCore.Instance != null && mRegisteredId != Guid.Empty) diff --git a/src/RemoteTech/Modules/ModuleRTDataTransmitter.cs b/src/RemoteTech/Modules/ModuleRTDataTransmitter.cs index 819a42e06..44c3a5fa0 100644 --- a/src/RemoteTech/Modules/ModuleRTDataTransmitter.cs +++ b/src/RemoteTech/Modules/ModuleRTDataTransmitter.cs @@ -7,7 +7,9 @@ namespace RemoteTech.Modules { - /// Used to transmit science from a vessel with an antenna. + /// + /// Used to transmit science from a vessel with an antenna. + /// public sealed class ModuleRTDataTransmitter : PartModule, IScienceDataTransmitter { //Default parameters unless loaded from antenna configuration diff --git a/src/RemoteTech/Modules/ModuleSPU.cs b/src/RemoteTech/Modules/ModuleSPU.cs index 9b51e9a02..89524344c 100644 --- a/src/RemoteTech/Modules/ModuleSPU.cs +++ b/src/RemoteTech/Modules/ModuleSPU.cs @@ -129,7 +129,7 @@ private State UpdateControlState() } } - if (Satellite == null || !RTCore.Instance.Network[Satellite].Any()) + if (Satellite == null || !RTCore.Instance.Network.IsConnected(Satellite)) { return State.NoConnection; } diff --git a/src/RemoteTech/Modules/ProtoAntenna.cs b/src/RemoteTech/Modules/ProtoAntenna.cs index 033cc7dea..c55087d5f 100644 --- a/src/RemoteTech/Modules/ProtoAntenna.cs +++ b/src/RemoteTech/Modules/ProtoAntenna.cs @@ -1,25 +1,26 @@ -using System; +using System; using System.Linq; +using RemoteTech.SimpleTypes; namespace RemoteTech.Modules { - internal class ProtoAntenna : IAntenna + internal class ProtoAntenna : IAntenna, IDisposable { public String Name { get; private set; } - public Guid Guid { get; private set; } - public bool Powered { get; private set; } - public bool Activated { get; set; } - public bool Connected { get { return RTCore.Instance.Network.Graph [Guid].Any (l => l.Interfaces.Contains (this)); } } - public float Consumption { get; private set; } + public Guid Guid { get => mState.Guid; private set => mState.Guid = value; } + public bool Powered { get => mState.Powered; private set => mState.Powered = value; } + public bool Activated { get => mState.Activated; set => mState.Activated = value; } + public bool Connected { get { return RTCore.Instance.Network.IsAntennaConnected(this); } } + public float Consumption { get => mState.Consumption; private set => mState.Consumption = value; } public bool CanTarget { get { return Dish != -1; } } public Guid Target { - get { return mDishTarget; } + get { return mState.Target; } set { - mDishTarget = value; + mState.Target = value; if (mProtoModule != null) { mProtoModule.moduleValues.SetValue("RTAntennaTarget", value.ToString()); @@ -33,14 +34,13 @@ public Guid Target } } - public float Dish { get; private set; } - public double CosAngle { get; private set; } - public float Omni { get; private set; } + public float Dish { get => mState.Dish; private set => mState.Dish = value; } + public double CosAngle { get => mState.CosAngle; private set => mState.CosAngle = value; } + public float Omni { get => mState.Omni; private set => mState.Omni = value; } private readonly ProtoPartSnapshot mProtoPart; private readonly ProtoPartModuleSnapshot mProtoModule; - - private Guid mDishTarget; + private readonly AntennaState mState = new(); public ProtoAntenna(Vessel v, ProtoPartSnapshot p, ProtoPartModuleSnapshot ppms) { @@ -49,22 +49,38 @@ public ProtoAntenna(Vessel v, ProtoPartSnapshot p, ProtoPartModuleSnapshot ppms) Guid = v.id; mProtoPart = p; mProtoModule = ppms; + try { - mDishTarget = new Guid(ppms.moduleValues.GetValue("RTAntennaTarget")); + mState.Target = new Guid(ppms.moduleValues.GetValue("RTAntennaTarget")); } catch (Exception ex) when (ex is ArgumentNullException || ex is FormatException || ex is OverflowException) { - mDishTarget = Guid.Empty; + mState.Target = Guid.Empty; } - double temp_double; - float temp_float; - bool temp_bool; - Dish = Single.TryParse(ppms.moduleValues.GetValue("RTDishRange"), out temp_float) ? temp_float : 0.0f; - CosAngle = Double.TryParse(ppms.moduleValues.GetValue("RTDishCosAngle"), out temp_double) ? temp_double : 0.0; - Omni = Single.TryParse(ppms.moduleValues.GetValue("RTOmniRange"), out temp_float) ? temp_float : 0.0f; - Powered = Boolean.TryParse(ppms.moduleValues.GetValue("IsRTPowered"), out temp_bool) ? temp_bool : false; - Activated = Boolean.TryParse(ppms.moduleValues.GetValue("IsRTActive"), out temp_bool) ? temp_bool : false; + + float dish = 0f; + if (ppms.moduleValues.TryGetValue("RTDishRange", ref dish)) + Dish = dish; + + double cosAngle = 0f; + if (ppms.moduleValues.TryGetValue("RTDishCosAngle", ref cosAngle)) + CosAngle = cosAngle; + + float omni = 0f; + if (ppms.moduleValues.TryGetValue("RTOmniRange", ref omni)) + Omni = omni; + + bool powered = false; + if (ppms.moduleValues.TryGetValue("IsRTPowered", ref powered)) + Powered = powered; + + bool activated = false; + if (ppms.moduleValues.TryGetValue("IsRTActive", ref activated)) + Activated = activated; + + mState.CanTarget = Dish != -1; + mState.Register(); RTLog.Notify(ToString()); } @@ -79,11 +95,18 @@ public ProtoAntenna(String name, Guid guid, float omni) CosAngle = 1.0f; Activated = true; Powered = true; + mState.CanTarget = CanTarget; + mState.Register(); + } + + public void Dispose() + { + mState.Dispose(); } public void OnConnectionRefresh() { - ; + mState.Connected = Connected; } public int CompareTo(IAntenna antenna) @@ -96,4 +119,4 @@ public override string ToString() return String.Format("ProtoAntenna(Name: {0}, Guid: {1}, Dish: {2}, Omni: {3}, Target: {4}, CosAngle: {5})", Name, Guid, Dish, Omni, Target, CosAngle); } } -} \ No newline at end of file +} diff --git a/src/RemoteTech/Network/CelestialBodyInfo.cs b/src/RemoteTech/Network/CelestialBodyInfo.cs new file mode 100644 index 000000000..5a903ee7f --- /dev/null +++ b/src/RemoteTech/Network/CelestialBodyInfo.cs @@ -0,0 +1,13 @@ +using RemoteTech.Collections; +using Unity.Mathematics; + +namespace RemoteTech.Network; + +internal struct JobBody +{ + public double3 position; + public double radius; + + public int parent; + public IntRange subtree; +} diff --git a/src/RemoteTech/Network/ConeMeshJob.cs b/src/RemoteTech/Network/ConeMeshJob.cs new file mode 100644 index 000000000..c916152bd --- /dev/null +++ b/src/RemoteTech/Network/ConeMeshJob.cs @@ -0,0 +1,135 @@ +using Unity.Burst; +using Unity.Collections; +using Unity.Jobs; +using Unity.Mathematics; +using UnityEngine; + +namespace RemoteTech.Network; + +/// +/// A cone-eligible antenna, gathered once per tick by +/// ; target uses the same +/// encoding as . +/// +internal struct ConeCandidate +{ + public int nodeIndex; + public int target; + public double cosAngle; + public double dishRange; +} + +/// +/// Camera/ScaledSpace snapshot for ; refUp is the camera target body's up axis used for the cone's side-spread. +/// +internal struct ConeViewParams +{ + public double invScale; + public double3 totalOffset; + public float3 refUp; + public float4x4 view, proj, invView, invProj; + public float pixelWidth, pixelHeight, halfWidth; + public int mode; + public Color32 color; +} + +/// +/// Builds a pair of billboard quads (the cone's two side lines) per candidate into the mesh buffers. +/// +[BurstCompile] +internal struct ConeMeshJob : IJobParallelFor +{ + [ReadOnly] public NativeArray candidates; + [ReadOnly] public NativeArray nodes; + [ReadOnly] public NativeArray bodies; + public ConeViewParams p; + + [WriteOnly, NativeDisableParallelForRestriction] public NativeArray verts; + [WriteOnly, NativeDisableParallelForRestriction] public NativeArray indices; + + public void Execute(int i) + { + ConeCandidate c = candidates[i]; + double3 antennaLocal = nodes[c.nodeIndex].position; + double3 targetLocal = c.target > 0 ? nodes[c.target - 1].position : bodies[-c.target - 1].position; + + float3 antenna = LineMeshCore.ToScaled(antennaLocal, p.invScale, p.totalOffset); + float3 target = LineMeshCore.ToScaled(targetLocal, p.invScale, p.totalOffset); + + float dist = math.distance(antenna, target); + float spread = dist * (float)math.tan(math.acos(c.cosAngle)); + float3 space = math.normalizesafe(math.cross(target - antenna, p.refUp)) * spread; + + float lim = math.min((float)(c.dishRange * p.invScale), dist); + float3 end1 = antenna + math.normalizesafe(target + space - antenna) * lim; + float3 end2 = antenna + math.normalizesafe(target - space - antenna) * lim; + + float3 a = LineMeshCore.WorldToScreen(antenna, p.view, p.proj, p.pixelWidth, p.pixelHeight); + float3 e1 = LineMeshCore.WorldToScreen(end1, p.view, p.proj, p.pixelWidth, p.pixelHeight); + float3 e2 = LineMeshCore.WorldToScreen(end2, p.view, p.proj, p.pixelWidth, p.pixelHeight); + + if (p.mode != 0) // 2D flat overlay: clamp to a near plane, flip behind-camera endpoints + { + if (a.z < 0f) + { + float3 coneCenter = LineMeshCore.WorldToScreen(target, p.view, p.proj, p.pixelWidth, p.pixelHeight); + a = LineMeshCore.FlipDirection(a, coneCenter); + } + else if (e1.z < 0f || e2.z < 0f) + { + e1 = LineMeshCore.FlipDirection(e1, a); + e2 = LineMeshCore.FlipDirection(e2, a); + } + + float d = p.pixelHeight * 0.5f + 0.01f; + a.z = a.z >= 0f ? d : -d; + e1.z = e1.z >= 0f ? d : -d; + e2.z = e2.z >= 0f ? d : -d; + } + + float2 dir1 = new(e1.y - a.y, a.x - e1.x); + float2 dir2 = new(e2.y - a.y, a.x - e2.x); + float3 seg1 = new(math.normalizesafe(dir1) * p.halfWidth, 0f); + float3 seg2 = new(math.normalizesafe(dir2) * p.halfWidth, 0f); + + int vb = 8 * i; + verts[vb + 0] = MakeVertex(a - seg1, p, new float2(0f, 1f)); + verts[vb + 1] = MakeVertex(a + seg1, p, new float2(0f, 0f)); + verts[vb + 2] = MakeVertex(e1 - seg1, p, new float2(1f, 1f)); + verts[vb + 3] = MakeVertex(e1 + seg1, p, new float2(1f, 0f)); + verts[vb + 4] = MakeVertex(a - seg2, p, new float2(0f, 1f)); + verts[vb + 5] = MakeVertex(a + seg2, p, new float2(0f, 0f)); + verts[vb + 6] = MakeVertex(e2 - seg2, p, new float2(1f, 1f)); + verts[vb + 7] = MakeVertex(e2 + seg2, p, new float2(1f, 0f)); + + int ib = 12 * i; + uint v = (uint)vb; + indices[ib + 0] = v + 0; indices[ib + 1] = v + 2; indices[ib + 2] = v + 1; + indices[ib + 3] = v + 2; indices[ib + 4] = v + 3; indices[ib + 5] = v + 1; + indices[ib + 6] = v + 4; indices[ib + 7] = v + 6; indices[ib + 8] = v + 5; + indices[ib + 9] = v + 6; indices[ib + 10] = v + 7; indices[ib + 11] = v + 5; + } + + private static LineVertex MakeVertex(float3 screen, in ConeViewParams p, float2 uv) => new LineVertex + { + position = LineMeshCore.ScreenToWorld(screen, p.proj, p.invProj, p.invView, p.pixelWidth, p.pixelHeight), + color = p.color, + uv = uv, + }; +} + +internal struct ConeMeshData +{ + public NativeArray verts; + public NativeArray indices; + public NativeArray bounds; + public JobHandle handle; + public double3 builtOffset; + + public void Dispose() + { + verts.Dispose(); + indices.Dispose(); + bounds.Dispose(); + } +} diff --git a/src/RemoteTech/Network/DrawableMesh.cs b/src/RemoteTech/Network/DrawableMesh.cs new file mode 100644 index 000000000..9ed96da09 --- /dev/null +++ b/src/RemoteTech/Network/DrawableMesh.cs @@ -0,0 +1,203 @@ +using Unity.Collections; +using Unity.Mathematics; +using UnityEngine; +using UnityEngine.Rendering; + +namespace RemoteTech.Network; + +/// +/// A persistent dynamic mesh plus the buffer sizes it was last declared with, so +/// vertex/index buffer params are only re-set when the counts actually change. +/// +internal struct DrawableMesh +{ + private const MeshUpdateFlags MeshFlags = MeshUpdateFlags.DontValidateIndices | MeshUpdateFlags.DontRecalculateBounds; + + // Orbit lines draw on layer 10, we do the same here in order to match appropriately. + private const int ScaledSceneryLayer = 10; + + public Mesh mesh; + private int lastVc, lastIc; + + public void Ensure(string name) + { + if (mesh != null) return; + mesh = new Mesh { name = name }; + mesh.MarkDynamic(); + } + + public void Upload( + NativeArray verts, + NativeArray indices, + in Bounds3 b, + VertexAttributeDescriptor[] layout) + { + int vc = verts.Length, ic = indices.Length; + + if (vc != lastVc) { mesh.SetVertexBufferParams(vc, layout); lastVc = vc; } + mesh.SetVertexBufferData(verts, 0, 0, vc, 0, MeshFlags); + if (ic != lastIc) { mesh.SetIndexBufferParams(ic, IndexFormat.UInt32); lastIc = ic; } + mesh.SetIndexBufferData(indices, 0, 0, ic, MeshFlags); + mesh.subMeshCount = 1; + mesh.SetSubMesh(0, new SubMeshDescriptor(0, ic, MeshTopology.Triangles), MeshUpdateFlags.DontRecalculateBounds); + + float3 center = (b.min + b.max) * 0.5f, size = b.max - b.min; + mesh.bounds = new Bounds(new Vector3(center.x, center.y, center.z), new Vector3(size.x, size.y, size.z)); + } + + public void Draw(Material material, Camera camera, Matrix4x4 model) + => Graphics.DrawMesh(mesh, model, material, ScaledSceneryLayer, camera, 0, null, false, false, false); + + public void Destroy() + { + if (mesh == null) return; + Object.Destroy(mesh); + mesh = null; + } +} + +/// +/// The map-view connection-line mesh: its persistent GPU buffers together with +/// this tick's pending build, scheduled in LateUpdate and drawn in OnPreCull. +/// +internal struct LineMesh +{ + public DrawableMesh drawable; + public LineMeshData frame; + public bool hasFrame; + + public void Ensure() => drawable.Ensure("RTNetworkLines"); + + public void SetFrame(in LineMeshData f) + { + frame = f; + hasFrame = true; + } + + /// + /// Waits on the build, then uploads and draws it translated by + /// onto the current ScaledSpace origin (the build was billboarded against a stale one). + /// + public void CompleteAndDraw(VertexAttributeDescriptor[] layout, Material material, Camera camera, float3 delta) + { + frame.handle.Complete(); + if (frame.verts.Length != 0 && frame.indices.Length != 0) + { + drawable.Upload(frame.verts.AsArray(), frame.indices.AsArray(), frame.bounds[0], layout); + drawable.Draw(material, camera, Matrix4x4.Translate(delta)); + } + DisposeFrame(); + } + + public void Drop() + { + if (!hasFrame) return; + frame.handle.Complete(); + DisposeFrame(); + } + + private void DisposeFrame() + { + frame.Dispose(); + hasFrame = false; + } + + public void Destroy() + { + Drop(); + drawable.Destroy(); + } +} + +/// +/// The map-view dish-cone mesh, mirroring : persistent GPU +/// buffers plus this tick's pending build. +/// +internal struct ConeMesh +{ + public DrawableMesh drawable; + public ConeMeshData frame; + public bool hasFrame; + + public void Ensure() => drawable.Ensure("RTNetworkCones"); + + public void SetFrame(in ConeMeshData f) + { + frame = f; + hasFrame = true; + } + + public void CompleteAndDraw(VertexAttributeDescriptor[] layout, Material material, Camera camera, float3 delta) + { + frame.handle.Complete(); + if (frame.verts.Length != 0 && frame.indices.Length != 0) + { + drawable.Upload(frame.verts, frame.indices, frame.bounds[0], layout); + drawable.Draw(material, camera, Matrix4x4.Translate(delta)); + } + DisposeFrame(); + } + + public void Drop() + { + if (!hasFrame) + return; + frame.handle.Complete(); + DisposeFrame(); + } + + private void DisposeFrame() + { + frame.Dispose(); + hasFrame = false; + } + + public void Destroy() + { + Drop(); + drawable.Destroy(); + } +} + +/// +/// The map-view satellite marks, mirroring : this tick's +/// pending filter/projection build plus the state that produced it (needed to +/// resolve a mark back to its satellite for the mouse-over panel). Unlike the +/// meshes these are drawn as GUI textures in OnGUI, so the frame is harvested +/// there rather than in OnPreCull. +/// +internal struct SatelliteMarks +{ + public SatelliteMarkData frame; + public NetworkState state; + public bool hasFrame; + + public void SetFrame(in SatelliteMarkData f, NetworkState s) + { + frame = f; + state = s; + hasFrame = true; + } + + /// + /// Waits on the build and returns the projected marks; the frame is kept until + /// the next so repeated OnGUI repaints can redraw it. + /// + public NativeArray Complete() + { + frame.handle.Complete(); + return frame.marks; + } + + public void Drop() + { + if (!hasFrame) + return; + + frame.Dispose(); + state = null; + hasFrame = false; + } + + public void Destroy() => Drop(); +} diff --git a/src/RemoteTech/Network/Edges.cs b/src/RemoteTech/Network/Edges.cs new file mode 100644 index 000000000..fb03f7aef --- /dev/null +++ b/src/RemoteTech/Network/Edges.cs @@ -0,0 +1,185 @@ +using System.Runtime.InteropServices; +using RemoteTech.Collections; +using RemoteTech.SimpleTypes; +using Unity.Burst; +using Unity.Collections; +using Unity.Jobs; +using Unity.Mathematics; + +using RModel = RemoteTech.RangeModel.RangeModel; + +namespace RemoteTech.Network; + + +internal struct NetworkEdge +{ + public double distance; + /// Max joint range the two nodes' antennas could achieve, independent + /// of . Only nonzero when . + public double maxRange; + public int aIdx; + public int bIdx; + public LinkType linkType; + + public readonly bool valid => linkType != LinkType.None; +} + +[BurstCompile] +internal struct BuildEdgesJob : IJobParallelFor +{ + public JobConfig config; + public RModel model; + + [ReadOnly] public NativeArray nodes; + [ReadOnly] public NativeArray antennas; + [ReadOnly] public NativeArray bodies; + [ReadOnly] public NativeArray vesselMaxRange; + [ReadOnly] public NativeArray antennaDirections; + [WriteOnly] public NativeArray edges; + + public void Execute(int index) + { + if (model == RModel.Additive) + Execute(index, new AdditiveRangeModel()); + else + Execute(index, new StandardRangeModel()); + } + + void Execute(int index, TRange model) + where TRange : unmanaged, IRangeModel + { + NetworkUpdateMath.DecodePairIndex(index, out int i, out int j); + + JobNode a = nodes[i]; + JobNode b = nodes[j]; + + ref var record = ref edges.GetElement(index); + record = new NetworkEdge() + { + aIdx = i, + bIdx = j, + linkType = LinkType.None, + distance = math.distance(a.position, b.position) + }; + + double distance = record.distance; + + // if either node is in blackout then we have nothing else to do + if (a.InBlackout || b.InBlackout) + return; + + // otherwise, pre-filter by range + if (!NetworkUpdateMath.CouldPossiblyLink(vesselMaxRange[i], vesselMaxRange[j], distance)) + return; + + // attempt line of sight + if (!config.ignoreLineOfSight) + { + int nca = GetNearestCommonAncestor(a.bodyIndex, b.bodyIndex); + JobBody ncaBody = bodies[nca]; + if (!NetworkUpdateMath.HasLineOfSight(a.position, b.position, bodies, ncaBody.subtree)) + return; + } + + // now do the full range model + double oc = config.omniClamp; + double dc = config.dishClamp; + double mult = config.multipleAntennaMultiplier; + + double maxOmniA = MaxOmni(a.antennas); + double maxOmniB = MaxOmni(b.antennas); + double bonusA = NetworkUpdateMath.GetMultipleAntennaBonus(antennas, a.antennas, maxOmniA, mult); + double bonusB = NetworkUpdateMath.GetMultipleAntennaBonus(antennas, b.antennas, maxOmniB, mult); + + double3 dirAB = b.position - a.position; + double3 dirBA = -dirAB; + double invDist = record.distance > 0.0 ? 1.0 / record.distance : 0.0; + double3 dirABNorm = dirAB * invDist; + double3 dirBANorm = dirBA * invDist; + + double maxDishA = MaxConnectedDish(a.antennas, dirABNorm); + double maxDishB = MaxConnectedDish(b.antennas, dirBANorm); + + // CheckRange is symmetric under swapping (r1,clamp1)<->(r2,clamp2), so these + // four also cover the "from B" combinations instead of recomputing them. + double rangeOO = Range(model, maxOmniA + bonusA, oc, maxOmniB + bonusB, oc); + double rangeOD = Range(model, maxOmniA + bonusA, oc, maxDishB, dc); + double rangeDO = Range(model, maxDishA, dc, maxOmniB + bonusB, oc); + double rangeDD = Range(model, maxDishA, dc, maxDishB, dc); + + bool reachAOmni = rangeOO >= distance || rangeOD >= distance; + bool reachADish = rangeDO >= distance || rangeDD >= distance; + bool reachBOmni = rangeOO >= distance || rangeDO >= distance; + bool reachBDish = rangeOD >= distance || rangeDD >= distance; + + bool aSideReaches = reachAOmni || reachADish; + bool bSideReaches = reachBOmni || reachBDish; + if (!aSideReaches || !bSideReaches) + return; + + record.maxRange = math.max(math.max(rangeOO, rangeOD), math.max(rangeDO, rangeDD)); + record.linkType = (reachADish || reachBDish) ? LinkType.Dish : LinkType.Omni; + } + + int GetNearestCommonAncestor(int bodyA, int bodyB) + { + while (bodyA != bodyB) + { + var min = math.min(bodyA, bodyB); + var max = math.max(bodyA, bodyB); + + if (min < 0) + return 0; + + bodyA = min; + bodyB = bodies[max].parent; + } + + return bodyA; + } + + double MaxOmni(IntRange range) + { + double max = 0.0; + foreach (int k in range) + max = math.max(max, antennas[k].omni); + + return max; + } + + /// + /// The range of the largest dish that is actually pointed towards the target + /// direction. + /// + private double MaxConnectedDish(IntRange range, double3 dir) + { + double max = 0.0; + foreach (int k in range) + { + JobAntenna ant = antennas[k]; + if (ant.dish <= 0.0) + continue; + + if (ant.target == 0) + continue; + double dot = math.dot(antennaDirections[k], dir); + if (dot < ant.cosAngle) + continue; + + max = math.max(max, ant.dish); + } + + return max; + } + + private static double Range( + TRange model, + double r1, + double clamp1, + double r2, + double clamp2) + where TRange : unmanaged, IRangeModel + { + return NetworkUpdateMath.CheckRange(model, r1, clamp1, r2, clamp2); + } +} diff --git a/src/RemoteTech/Network/Jobs.cs b/src/RemoteTech/Network/Jobs.cs new file mode 100644 index 000000000..3913841e0 --- /dev/null +++ b/src/RemoteTech/Network/Jobs.cs @@ -0,0 +1,196 @@ +using System; +using System.Runtime.InteropServices; +using RemoteTech.Collections; +using Unity.Burst; +using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; +using Unity.Jobs; + +namespace RemoteTech.Network; + +internal struct JobConfig +{ + public double omniClamp; + public double dishClamp; + public double multipleAntennaMultiplier; + [MarshalAs(UnmanagedType.U1)] + public bool ignoreLineOfSight; + [MarshalAs(UnmanagedType.U1)] + public bool signalRelayEnabled; +} + +/// +/// Given a set of origins this job finds the distance of each node from the +/// nearest origin. +/// +[BurstCompile] +internal struct MultiSourceDijkstraJob : IJob +{ + [ReadOnly] public NativeArray ranges; + [ReadOnly] public NativeArray adjacency; + [ReadOnly] public NativeArray distances; + [ReadOnly] public NativeArray roots; + + // Per-node "may forward a signal onward". Roots and destinations don't need it; + // only intermediate hops do (see NetworkUpdate.CanTransit). + [ReadOnly] public NativeBitArray canTransit; + + public NativeArray scores; + public NativeArray parents; + public NativeArray origins; + + public void Execute() + { + for (int i = 0; i < ranges.Length; ++i) + { + scores[i] = double.PositiveInfinity; + parents[i] = -1; + origins[i] = -1; + } + + var heap = new ArrayMinHeap(ranges.Length, Allocator.Temp); + for (int i = 0; i < roots.Length; ++i) + { + var index = roots[i]; + if (index < 0 || index >= ranges.Length) + continue; + + heap.Push(new() + { + cost = 0.0, + node = index, + parent = -1 + }); + } + + while (heap.TryPop(out var current)) + { + if (current.cost >= scores[current.node]) + continue; + + scores[current.node] = current.cost; + parents[current.node] = current.parent; + if (current.parent == -1) + origins[current.node] = current.node; + else + origins[current.node] = origins[current.parent]; + + // A root (parent == -1) is a route endpoint and always forwards; any + // other node may only be relayed through if it can transit. + if (current.parent != -1 && !canTransit.IsSet(current.node)) + continue; + + foreach (int edge in ranges[current.node]) + { + int node = adjacency[edge]; + var cost = current.cost + distances[edge]; + if (cost >= scores[node]) + continue; + + heap.Push(new() + { + cost = cost, + node = node, + parent = current.node + }); + } + } + } + + struct HeapNode : IComparable + { + public double cost; + public int node; + public int parent; + + public int CompareTo(HeapNode other) => cost.CompareTo(other.cost); + } +} + +/// +/// Single-pair shortest path over the persisted edge table: the minimum total +/// link distance from to . Links exist +/// between powered nodes; the relay constraint gates transit only, so the two +/// endpoints need not be relay-capable (see ). +/// Writes +inf through if the target is unreachable. +/// +[BurstCompile] +internal unsafe struct NetworkPathfindJob : IJob +{ + public JobConfig config; + public int source; + public int target; + + [ReadOnly] public NativeArray nodes; + [ReadOnly] public NativeArray edges; + + [NativeDisableUnsafePtrRestriction] public double* length; + + public void Execute() + { + *length = double.PositiveInfinity; + + int n = nodes.Length; + if ((uint)source >= (uint)n || (uint)target >= (uint)n) + return; + if (source == target) + { + *length = 0.0; + return; + } + + var dist = new NativeArray(n, Allocator.Temp, NativeArrayOptions.UninitializedMemory); + for (int i = 0; i < n; ++i) + dist[i] = double.PositiveInfinity; + + var heap = new ArrayMinHeap(n, Allocator.Temp); + dist[source] = 0.0; + heap.Push(new HeapNode { cost = 0.0, node = source }); + + while (heap.TryPop(out var current)) + { + if (current.cost > dist[current.node]) + continue; + if (current.node == target) + { + *length = current.cost; + return; + } + + int u = current.node; + if (!nodes[u].Powered) + continue; + + // The source is a route endpoint; every other node may only be relayed + // through if it can transit. The target is exempt too — it returns above + // before ever reaching this expansion. + if (u != source && !NetworkUpdate.CanTransit(in config, nodes[u])) + continue; + + for (int v = 0; v < n; ++v) + { + if (v == u) + continue; + + var edge = edges[NetworkUpdateMath.EncodePairIndex(u, v)]; + if (!edge.valid || !nodes[v].Powered) + continue; + + double next = current.cost + edge.distance; + if (next < dist[v]) + { + dist[v] = next; + heap.Push(new HeapNode { cost = next, node = v }); + } + } + } + } + + struct HeapNode : IComparable + { + public double cost; + public int node; + + public int CompareTo(HeapNode other) => cost.CompareTo(other.cost); + } +} diff --git a/src/RemoteTech/Network/LineMeshJob.cs b/src/RemoteTech/Network/LineMeshJob.cs new file mode 100644 index 000000000..6fa9df46d --- /dev/null +++ b/src/RemoteTech/Network/LineMeshJob.cs @@ -0,0 +1,295 @@ +using System.Runtime.InteropServices; +using RemoteTech.SimpleTypes; +using Unity.Burst; +using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; +using Unity.Jobs; +using Unity.Mathematics; +using UnityEngine; + +namespace RemoteTech.Network; + +/// +/// A connection line to draw: two node indices and a resolved colour. +/// +internal struct DrawEdge +{ + public int a; + public int b; + public Color32 color; +} + +/// +/// Axis-aligned bounds in scaled space, computed by . +/// +internal struct Bounds3 +{ + public float3 min; + public float3 max; +} + +/// +/// One mesh vertex; field order matches the renderer's vertex layout (pos, Color32, uv). +/// +[StructLayout(LayoutKind.Sequential)] +internal struct LineVertex +{ + public float3 position; + public Color32 color; + public float2 uv; +} + +/// +/// Per-frame inputs for , captured on the main thread. +/// +internal struct DrawEdgeParams +{ + public int targetNode; // camera-target vessel's node, or -1 + public int nodeCount; + public int pairCount; + public byte showOmni, showDish, showPath, showMultiPath, signalRelay; + public Color32 active, direct, omni, dish, grey; +} + +/// +/// Selects the visible edges and resolves each colour (the old CheckVisibility/CheckColor, by node index), +/// then sizes the vertex/index buffers to the selected-edge count (4 verts / 6 indices each). +/// +[BurstCompile] +internal struct BuildDrawEdgesJob : IJob +{ + [ReadOnly] public NativeArray edges; // triangular pair index + [ReadOnly] public NativeArray parent; // any-root predecessor forest (command-station roots) + [ReadOnly] public NativeArray nodes; // flags: Visible | CanRelay, set at tick time by NetworkUpdateJob + public DrawEdgeParams p; + public NativeList outEdges; + public NativeList verts; + public NativeList indices; + + public void Execute() + { + var onPath = new NativeBitArray(math.max(p.nodeCount, 1), Allocator.Temp, NativeArrayOptions.ClearMemory); + if (p.showPath != 0 && p.targetNode >= 0) + { + for (int cur = p.targetNode; cur >= 0; cur = parent[cur]) + onPath.Set(cur, true); + } + // A route back to the command station is only worth drawing if a visible + // vessel actually sits at its far end; a path rooted at a filtered-out + // vessel stays hidden even though its edges still exist in the forest. + if (p.showMultiPath != 0) + { + for (int i = 0; i < p.nodeCount; i++) + { + if (nodes[i].kind != NodeKind.Vessel || (nodes[i].flags & NodeFlags.Visible) == 0) + continue; + for (int cur = i; cur >= 0; cur = parent[cur]) + onPath.Set(cur, true); + } + } + + outEdges.Capacity = math.max(p.nodeCount, 16); + outEdges.Clear(); + for (int pair = 0; pair < p.pairCount; pair++) + { + NetworkEdge r = edges[pair]; + if (!r.valid) + continue; + + bool tree = parent[r.aIdx] == r.bIdx || parent[r.bIdx] == r.aIdx; + bool onP = tree && onPath.IsSet(r.aIdx) && onPath.IsSet(r.bIdx); + + bool visA = (nodes[r.aIdx].flags & NodeFlags.Visible) != 0; + bool visB = (nodes[r.bIdx].flags & NodeFlags.Visible) != 0; + bool show = onP + || (r.linkType == LinkType.Omni && p.showOmni != 0 && visA && visB) + || (r.linkType == LinkType.Dish && p.showDish != 0 && visA && visB); + if (!show) + continue; + + Color32 col; + if (onP) + col = p.active; + else if (p.signalRelay != 0 && (!nodes[r.aIdx].CanRelay || !nodes[r.bIdx].CanRelay)) + col = p.direct; + else if (r.linkType == LinkType.Omni) + col = p.omni; + else if (r.linkType == LinkType.Dish) + col = p.dish; + else + col = p.grey; + + outEdges.Add(new DrawEdge { a = r.aIdx, b = r.bIdx, color = col }); + } + + onPath.Dispose(); + + int l = outEdges.Length; + verts.ResizeUninitialized(4 * l); + indices.ResizeUninitialized(6 * l); + } +} + +/// +/// Camera/projection helpers shared by the mesh job (replicate Unity's screen transforms). +/// +internal static class LineMeshCore +{ + /// + /// Local (physics) space -> scaled space: the affine transform ScaledSpace applies. + /// + public static float3 ToScaled(double3 local, double invScale, double3 totalOffset) + => (float3)(local * invScale - totalOffset); + + /// + /// Replicates Camera.WorldToScreenPoint (z = world distance in front of the camera). + /// + public static float3 WorldToScreen(float3 world, float4x4 view, float4x4 proj, float pw, float ph) + { + float4 v = math.mul(view, new float4(world, 1f)); + float4 c = math.mul(proj, v); + float3 ndc = c.xyz / c.w; + return new float3((ndc.x * 0.5f + 0.5f) * pw, (ndc.y * 0.5f + 0.5f) * ph, -v.z); + } + + /// + /// Replicates Camera.ScreenToWorldPoint (s.z = world distance in front). + /// + public static float3 ScreenToWorld(float3 s, float4x4 proj, float4x4 invProj, float4x4 invView, float pw, float ph) + { + float2 ndc = new(s.x / pw * 2f - 1f, s.y / ph * 2f - 1f); + float eyeZ = -s.z; + float4 atDepth = math.mul(proj, new float4(0f, 0f, eyeZ, 1f)); + float clipW = atDepth.w; + float ndcZ = atDepth.z / atDepth.w; + float4 clip = new float4(ndc.x, ndc.y, ndcZ, 1f) * clipW; + float4 eye = math.mul(invProj, clip); + float3 eyePt = eye.xyz / eye.w; + return math.mul(invView, new float4(eyePt, 1f)).xyz; + } + + /// + /// Mirror a screen point through a pivot (behind-camera handling for the 2D path). + /// + public static float3 FlipDirection(float3 point, float3 pivot) => 2f * pivot - point; +} + +/// +/// Camera/ScaledSpace snapshot for . +/// +internal struct LineMeshViewParams +{ + public double invScale; + public double3 totalOffset; + public float4x4 view, proj, invView, invProj; + public float pixelWidth, pixelHeight, halfWidth; + public int mode; +} + +/// +/// Builds a billboard quad per edge into the mesh buffers. +/// +[BurstCompile(FloatMode = FloatMode.Fast)] +internal struct LineMeshJob : IJobParallelForDefer +{ + [ReadOnly] public NativeArray edges; // drawEdges.AsDeferredJobArray() + [ReadOnly] public NativeArray nodes; // NetworkState.Nodes (position is local space) + public double invScale; + public double3 totalOffset; + public float4x4 view, proj, invView, invProj; + public float pixelWidth, pixelHeight, halfWidth; + public int mode; // 0 = 3D billboard, 1 = 2D faked-depth overlay + + [WriteOnly, NativeDisableParallelForRestriction] public NativeArray verts; + [WriteOnly, NativeDisableParallelForRestriction] public NativeArray indices; + + public void Execute(int i) + { + DrawEdge e = edges[i]; + float3 a = LineMeshCore.WorldToScreen(LineMeshCore.ToScaled(nodes[e.a].position, invScale, totalOffset), view, proj, pixelWidth, pixelHeight); + float3 b = LineMeshCore.WorldToScreen(LineMeshCore.ToScaled(nodes[e.b].position, invScale, totalOffset), view, proj, pixelWidth, pixelHeight); + + if (mode != 0) // 2D flat overlay: clamp to a near plane, flip behind-camera endpoints + { + if (a.z < 0f) a = LineMeshCore.FlipDirection(a, b); + else if (b.z < 0f) b = LineMeshCore.FlipDirection(b, a); + float d = pixelHeight * 0.5f + 0.01f; + a.z = a.z >= 0.15f ? d : -d; + b.z = b.z >= 0.15f ? d : -d; + } + + float2 dir = new(b.y - a.y, a.x - b.x); + float len = math.length(dir); + float2 ndir = len > 1e-6f ? dir / len : new float2(1f, 0f); + float3 seg = new(ndir * halfWidth, 0f); + + float3 p0 = LineMeshCore.ScreenToWorld(a - seg, proj, invProj, invView, pixelWidth, pixelHeight); + float3 p1 = LineMeshCore.ScreenToWorld(a + seg, proj, invProj, invView, pixelWidth, pixelHeight); + float3 p2 = LineMeshCore.ScreenToWorld(b - seg, proj, invProj, invView, pixelWidth, pixelHeight); + float3 p3 = LineMeshCore.ScreenToWorld(b + seg, proj, invProj, invView, pixelWidth, pixelHeight); + + Color32 col = e.color; + int vb = 4 * i; + verts[vb + 0] = new LineVertex { position = p0, color = col, uv = new float2(0f, 1f) }; + verts[vb + 1] = new LineVertex { position = p1, color = col, uv = new float2(0f, 0f) }; + verts[vb + 2] = new LineVertex { position = p2, color = col, uv = new float2(1f, 1f) }; + verts[vb + 3] = new LineVertex { position = p3, color = col, uv = new float2(1f, 0f) }; + + int ib = 6 * i; + uint v = (uint)vb; + indices[ib + 0] = v + 0; indices[ib + 1] = v + 2; indices[ib + 2] = v + 1; + indices[ib + 3] = v + 2; indices[ib + 4] = v + 3; indices[ib + 5] = v + 1; + } +} + +/// +/// Reduces the written verts to an AABB for mesh.bounds. +/// +[BurstCompile] +internal struct LineBoundsJob : IJob +{ + [ReadOnly] public NativeArray verts; // _verts.AsDeferredJobArray() + [WriteOnly] public NativeArray boundsOut; + + public void Execute() + { + int n = verts.Length; + if (n == 0) + { + boundsOut[0] = new Bounds3 { min = float3.zero, max = float3.zero }; + return; + } + float3 mn = verts[0].position; + float3 mx = mn; + for (int i = 1; i < n; i++) + { + float3 pos = verts[i].position; + mn = math.min(mn, pos); + mx = math.max(mx, pos); + } + boundsOut[0] = new Bounds3 { min = mn, max = mx }; + } +} + +/// +/// One frame's mesh buffers from ; verts are +/// billboarded against , so the draw must translate them onto the +/// current ScaledSpace origin. +/// +internal struct LineMeshData +{ + public NativeList drawEdges; + public NativeList verts; + public NativeList indices; + public NativeArray bounds; + public JobHandle handle; + public double3 builtOffset; + + public void Dispose() + { + if (drawEdges.IsCreated) drawEdges.Dispose(); + if (verts.IsCreated) verts.Dispose(); + if (indices.IsCreated) indices.Dispose(); + if (bounds.IsCreated) bounds.Dispose(); + } +} diff --git a/src/RemoteTech/Network/NetworkState.cs b/src/RemoteTech/Network/NetworkState.cs new file mode 100644 index 000000000..0f18d6fc0 --- /dev/null +++ b/src/RemoteTech/Network/NetworkState.cs @@ -0,0 +1,871 @@ +using System; +using System.Collections.Generic; +using RemoteTech.Collections; +using RemoteTech.SimpleTypes; +using Unity.Burst; +using Unity.Collections; +using Unity.Jobs; +using Unity.Mathematics; +using Unity.Profiling; +using UnityEngine; + +namespace RemoteTech.Network; + +internal class NetworkState : IDisposable +{ + JobHandle handle; + + /// + /// A handle for consumers of this state. It won't block + /// but it will be completed before the arrays are disposed of. + /// + JobHandle dependentHandle; + + ISatellite[] satellites; + + // The settings snapshot this state was built from; reused by point-to-point + // queries so they gate hops the same way the tick's routing did. + JobConfig config; + + // This maps guid to satellite index + NativeHashMap satmap; + NativeArray nodes; + NativeArray bodies; + NativeArray edges; + + // Whether individual antennas are connected. + NativeBitArray connected; + + // Network topology relative to the ground stations + NativeArray scoreGs; + NativeArray parentGs; + NativeArray originGs; + + // Network topology relative to all command stations + NativeArray scoreCs; + NativeArray parentCs; + NativeArray originCs; + + // This tick's per-node route fingerprint (used to diff against the previous + // tick's state without a per-satellite dictionary walk). + NativeList fingerprints; + + // Antenna candidates for rendering the cone mesh + NativeList coneCandidates; + + // Ground stations and command-station vessels eligible for a map-view mark + NativeList markCandidates; + + // Node indices (this tick's indexing) whose fingerprint differs from the + // previous tick's — see ScheduleFingerprintDiff / FireConnectionRefresh. + NativeList changedNodes; + + readonly Dictionary[]> routeCache = []; + + private NetworkState() { } + + static readonly ProfilerMarker CompleteMarker = new("NetworkState.Complete"); + public void Complete() + { + using var scope = CompleteMarker.Auto(); + handle.Complete(); + } + + public void Dispose() + { + Dispose(JobHandle.CombineDependencies(this.handle, dependentHandle)); + } + + public JobHandle Dispose(JobHandle deps) + { + var handle = new NetworkStateDisposeJob { state = new ObjectHandle(this) } + .Schedule(JobHandle.CombineDependencies(deps, this.handle, dependentHandle)); + return handle; + } + + void DisposeInternal() + { + satmap.Dispose(); + nodes.Dispose(); + edges.Dispose(); + connected.Dispose(); + + scoreGs.Dispose(); + parentGs.Dispose(); + originGs.Dispose(); + + scoreCs.Dispose(); + parentCs.Dispose(); + originCs.Dispose(); + + fingerprints.Dispose(); + changedNodes.Dispose(); + + bodies.Dispose(); + coneCandidates.Dispose(); + markCandidates.Dispose(); + } + + void AddDependentHandle(JobHandle handle) + { + dependentHandle = JobHandle.CombineDependencies(dependentHandle, handle); + } + + static readonly ProfilerMarker ScheduleNetworkUpdateMarker = new("NetworkState.ScheduleNetworkUpdate"); + + /// + /// Kick off all jobs needed to compute the current network state. + /// + /// + /// The state from the previous tick, or null. Used to determine which + /// satellite connections have been changed. + /// + public static NetworkState ScheduleNetworkUpdate(NetworkManager manager, NetworkState previous) + { + using var scope = ScheduleNetworkUpdateMarker.Auto(); + var cbs = FlightGlobals.Bodies; + + var handles = new NativeList(16, Allocator.Temp); + + var vesselCount = RTCore.Instance.Satellites.Count; + var groundStationCount = manager.GroundStations.Count; + var nodeCount = vesselCount + groundStationCount; + int edgeCount = NetworkUpdateMath.PairCount(nodeCount); + + var rawVessels = new NativeArray( + vesselCount, Allocator.TempJob, NativeArrayOptions.UninitializedMemory); + var satHandle = new ObjectHandle>( + RTCore.Instance.Satellites.SatelliteCache); + var recordVessels = new NetworkUpdate.RecordVesselInfoJob + { + satellites = satHandle, + infos = rawVessels + }.ScheduleBatch(vesselCount, 24); + + var rawStations = new NativeArray( + groundStationCount, Allocator.TempJob, NativeArrayOptions.UninitializedMemory); + var stationHandle = new ObjectHandle>( + manager.GroundStations); + var recordStations = new NetworkUpdate.RecordGroundStationsJob + { + stations = stationHandle, + states = rawStations + }.Schedule(); + + var rawCbs = new NativeArray( + cbs.Count, Allocator.TempJob, NativeArrayOptions.UninitializedMemory); + var recordCbs = new NetworkUpdate.RecordCelestialBodiesJob + { + bodies = new(cbs), + infos = rawCbs + }.Schedule(); + + var syncHandle = JobHandle.CombineDependencies(recordVessels, recordStations); + JobHandle.ScheduleBatchedJobs(); + + handles.Add(satHandle.Dispose(recordVessels)); + handles.Add(stationHandle.Dispose(recordStations)); + + JobConfig config = new() + { + omniClamp = RTSettings.Instance.OmniRangeClampFactor, + dishClamp = RTSettings.Instance.DishRangeClampFactor, + multipleAntennaMultiplier = RTSettings.Instance.MultipleAntennaMultiplier, + ignoreLineOfSight = RTSettings.Instance.IgnoreLineOfSight, + signalRelayEnabled = RTSettings.Instance.SignalRelayEnabled, + }; + NetworkUpdate.VisibilityState visibility = new() + { + ActiveVessel = FlightGlobals.ActiveVessel?.id ?? default, + TargetVessel = FlightGlobals.fetch?.VesselTarget?.GetVessel().id ?? default, + filter = MapViewFiltering.Instance.IsNotNullOrDestroyed() + ? MapViewFiltering.vesselTypeFilter + : null + }; + + var nodes = new NativeArray( + nodeCount, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); + var guids = new NativeArray( + nodeCount, Allocator.TempJob, NativeArrayOptions.UninitializedMemory); + var bodies = new NativeArray( + cbs.Count, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); + var ranges = new NativeArray( + nodeCount, Allocator.TempJob, NativeArrayOptions.UninitializedMemory); + var antennas = new NativeList(Allocator.TempJob); + var directions = new NativeList(Allocator.TempJob); + var coneCandidates = new NativeList(0, Allocator.Persistent); + var markCandidates = new NativeList(0, Allocator.Persistent); + var satmap = new NativeHashMap(vesselCount, Allocator.Persistent); + var update1 = new NetworkUpdateJob + { + config = config, + visibility = visibility, + vessels = rawVessels, + cbs = rawCbs, + stations = rawStations, + antennaData = StateManager.Antennas.GetStateArray(Allocator.TempJob), + + nodes = nodes, + guids = guids, + bodies = bodies, + antennas = antennas, + directions = directions, + ranges = ranges, + satmap = satmap, + coneCandidates = coneCandidates, + markCandidates = markCandidates, + }.Schedule(JobHandle.CombineDependencies(syncHandle, recordCbs)); + StateManager.Antennas.AddUseHandle(update1); + + var edges = new NativeArray( + edgeCount, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); + var buildEdges = new BuildEdgesJob + { + config = config, + model = RTSettings.Instance.RangeModelType, + nodes = nodes, + bodies = bodies, + antennas = antennas.AsDeferredJobArray(), + vesselMaxRange = ranges, + antennaDirections = directions.AsDeferredJobArray(), + edges = edges + }.Schedule(edgeCount, 256, update1); + + var adjranges = new NativeList(Allocator.TempJob); + var adjacency = new NativeList(Allocator.TempJob); + var distances = new NativeList(Allocator.TempJob); + var connected = new NativeBitArray(nodeCount, Allocator.Persistent); + + var commandStations = new NativeList(Allocator.TempJob); + var groundStations = new NativeList(Allocator.TempJob); + var canTransit = new NativeBitArray(nodeCount, Allocator.TempJob); + + var update2 = new NetworkAdjacencyJob + { + config = config, + nodes = nodes, + edges = edges, + + ranges = adjranges, + adjacency = adjacency, + distances = distances, + connected = connected, + canTransit = canTransit, + + commandStations = commandStations, + groundStations = groundStations, + }.Schedule(buildEdges); + + var scoreGs = new NativeArray(nodeCount, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); + var scoreCs = new NativeArray(nodeCount, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); + var parentGs = new NativeArray(nodeCount, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); + var parentCs = new NativeArray(nodeCount, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); + var originGs = new NativeArray(nodeCount, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); + var originCs = new NativeArray(nodeCount, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); + + var dijkstraDeps = update2; + + var dijkstraCs = new MultiSourceDijkstraJob + { + ranges = adjranges.AsDeferredJobArray(), + adjacency = adjacency.AsDeferredJobArray(), + distances = distances.AsDeferredJobArray(), + roots = commandStations.AsDeferredJobArray(), + canTransit = canTransit, + + scores = scoreCs, + parents = parentCs, + origins = originCs, + }.Schedule(dijkstraDeps); + var dijkstraGs = new MultiSourceDijkstraJob + { + ranges = adjranges.AsDeferredJobArray(), + adjacency = adjacency.AsDeferredJobArray(), + distances = distances.AsDeferredJobArray(), + roots = groundStations.AsDeferredJobArray(), + canTransit = canTransit, + + scores = scoreGs, + parents = parentGs, + origins = originGs, + }.Schedule(dijkstraDeps); + var dijkstraAll = JobHandle.CombineDependencies(dijkstraCs, dijkstraGs); + + handles.Add(new DisposeTempContainersJob + { + antennas = antennas, + directions = directions, + ranges = ranges, + adjranges = adjranges, + adjacency = adjacency, + distances = distances, + commandStations = commandStations, + groundStations = groundStations, + canTransit = canTransit, + }.Schedule(dijkstraAll)); + + var fingerprints = new NativeList(0, Allocator.Persistent); + var changedNodes = new NativeList(0, Allocator.Persistent); + + NativeHashMap prevSatmap; + NativeArray prevFingerprints; + var fingerprintDeps = dijkstraAll; + if (previous is not null) + { + prevSatmap = previous.satmap; + prevFingerprints = previous.fingerprints.AsDeferredJobArray(); + fingerprintDeps = JobHandle.CombineDependencies(fingerprintDeps, previous.handle); + } + else + { + prevSatmap = new NativeHashMap(1, Allocator.Temp); + prevFingerprints = default; + } + + var fingerprintHandle = new NetworkFingerprintJob + { + guids = guids, + originCs = originCs, + parentCs = parentCs, + originGs = originGs, + parentGs = parentGs, + prevSatmap = prevSatmap, + prevFingerprints = prevFingerprints, + fingerprints = fingerprints, + changed = changedNodes, + }.Schedule(fingerprintDeps); + + if (previous is not null) + previous.AddDependentHandle(fingerprintHandle); + else + fingerprintHandle = prevSatmap.Dispose(fingerprintHandle); + + handles.Add(fingerprintHandle); + + JobHandle.ScheduleBatchedJobs(); + var satellites = GetSatellites(manager); + syncHandle.Complete(); + + return new() + { + handle = JobHandle.CombineDependencies(handles.AsArray()), + satellites = satellites, + config = config, + + satmap = satmap, + nodes = nodes, + edges = edges, + connected = connected, + + scoreGs = scoreGs, + parentGs = parentGs, + originGs = originGs, + + scoreCs = scoreCs, + parentCs = parentCs, + originCs = originCs, + + fingerprints = fingerprints, + changedNodes = changedNodes, + + bodies = bodies, + coneCandidates = coneCandidates, + markCandidates = markCandidates, + }; + } + + /// Fires ISatellite.OnConnectionRefresh for every node whose route + /// fingerprint changed since the previous tick. Call after . + internal void FireConnectionRefresh(NetworkManager manager) + { + for (int i = 0; i < changedNodes.Length; i++) + { + var sat = satellites[changedNodes[i]]; + if (sat != null) + sat.OnConnectionRefresh(manager[sat]); + } + } + + private static ISatellite[] GetSatellites(NetworkManager manager) + { + var vessels = RTCore.Instance.Satellites.SatelliteCache.Values; + var ground = manager.GroundStations.Values; + + var satellites = new ISatellite[vessels.Length + ground.Length]; + + for (int i = 0; i < vessels.Length; ++i) + satellites[i] = vessels[i]; + ground.CopyTo(satellites.AsSpan().Slice(vessels.Length)); + + return satellites; + } + + /// + /// Indicates whether the vessel containing this antenna is connected. + /// + /// + /// + public bool IsAntennaConnected(IAntenna antenna) + { + if (antenna is null) + return false; + if (!satmap.TryGetValue(antenna.Guid, out var index)) + return false; + if (!connected.IsSet(index)) + return false; + if (!antenna.Activated) + return false; + return antenna.Omni > 0 || antenna.Dish > 0; + } + + internal bool TryGetNode(ISatellite sat, out int node) + { + if (sat != null) return satmap.TryGetValue(sat.Guid, out node); + node = -1; + return false; + } + + internal ISatellite SatAt(int node) => satellites[node]; + + internal int NodeCount => nodes.Length; + internal int PairCount => edges.Length; + + internal NativeArray Nodes => nodes; + + /// + /// Builds this tick's map-view connection-line mesh (edge selection/colouring + /// through billboard-quad geometry); only / + /// vary per frame. + /// + internal LineMeshData ScheduleLineMesh(in DrawEdgeParams p, in LineMeshViewParams view) + { + var drawEdges = new NativeList(0, Allocator.TempJob); + var verts = new NativeList(0, Allocator.TempJob); + var indices = new NativeList(0, Allocator.TempJob); + var bounds = new NativeArray(1, Allocator.TempJob); + + JobHandle h1 = new BuildDrawEdgesJob + { + edges = edges, + parent = parentCs, + nodes = nodes, + p = p, + outEdges = drawEdges, + verts = verts, + indices = indices, + }.Schedule(handle); + + JobHandle h2 = new LineMeshJob + { + edges = drawEdges.AsDeferredJobArray(), + nodes = nodes, + verts = verts.AsDeferredJobArray(), + indices = indices.AsDeferredJobArray(), + invScale = view.invScale, + totalOffset = view.totalOffset, + view = view.view, + proj = view.proj, + invView = view.invView, + invProj = view.invProj, + pixelWidth = view.pixelWidth, + pixelHeight = view.pixelHeight, + halfWidth = view.halfWidth, + mode = view.mode, + }.Schedule(drawEdges, 1024, h1); + + JobHandle h3 = new LineBoundsJob + { + verts = verts.AsDeferredJobArray(), + boundsOut = bounds, + }.Schedule(h2); + + AddDependentHandle(h3); + + return new LineMeshData + { + drawEdges = drawEdges, + verts = verts, + indices = indices, + bounds = bounds, + handle = h3, + builtOffset = view.totalOffset, + }; + } + + internal int ConeCandidateCount => coneCandidates.Length; + + /// + /// Builds this tick's map-view dish-cone mesh from the candidates + /// gathered during the tick; + /// like it billboards against + /// , so the draw must translate onto the + /// current ScaledSpace origin. + /// + internal ConeMeshData ScheduleConeMesh(in ConeViewParams p) + { + int count = coneCandidates.Length; + var verts = new NativeArray(8 * count, Allocator.TempJob, NativeArrayOptions.UninitializedMemory); + var indices = new NativeArray(12 * count, Allocator.TempJob, NativeArrayOptions.UninitializedMemory); + var bounds = new NativeArray(1, Allocator.TempJob); + + JobHandle h1 = new ConeMeshJob + { + candidates = coneCandidates.AsArray(), + nodes = nodes, + bodies = bodies, + p = p, + verts = verts, + indices = indices, + }.Schedule(count, 128, handle); + + JobHandle h2 = new LineBoundsJob + { + verts = verts, + boundsOut = bounds, + }.Schedule(h1); + + AddDependentHandle(h2); + + return new ConeMeshData { verts = verts, indices = indices, bounds = bounds, handle = h2, builtOffset = p.totalOffset }; + } + + internal int MarkCandidateCount => markCandidates.Length; + + /// + /// Filters this tick's mark candidates to the ones visible in the map view and + /// projects each to its GUI-space screen position; the renderer draws the + /// survivors in OnGUI. + /// + internal SatelliteMarkData ScheduleSatelliteMarks(in SatelliteMarkViewParams p) + { + var marks = new NativeList(markCandidates.Length, Allocator.TempJob); + + JobHandle h = new SatelliteMarkJob + { + candidates = markCandidates.AsArray(), + nodes = nodes, + bodies = bodies, + p = p, + marks = marks, + }.Schedule(handle); + + AddDependentHandle(h); + + return new SatelliteMarkData { marks = marks, handle = h }; + } + + internal double RouteLength(int node, bool groundOnly) + { + var origin = GetOrigin(node, groundOnly); + if (origin < 0) + return double.PositiveInfinity; + + return groundOnly ? scoreGs[node] : scoreCs[node]; + } + + /// + /// True even for a root's own zero-hop self-route; see for the >=1-hop version. + /// + /// + /// Only consider ground stations, not vessels. + public bool GetRouteExists(ISatellite sat, bool groundOnly) + { + if (!satmap.TryGetValue(sat.Guid, out int index)) + return false; + + return groundOnly + ? originGs[index] >= 0 + : originCs[index] >= 0; + } + + /// + /// Unlike , false for a root's own zero-hop self-route. + /// + internal bool RouteExists(int node, bool groundOnly) + { + var origin = GetOrigin(node, groundOnly); + return origin >= 0 && origin != node; + } + + internal ISatellite RouteGoalSat(int node, bool groundOnly) + { + var origin = GetOrigin(node, groundOnly); + return origin >= 0 ? satellites[origin] : null; + } + + private static long HopKey(int node, bool groundOnly) => + ((long)node << 1) | (groundOnly ? 1L : 0L); + + private static readonly NetworkLink[] NoHops = []; + + /// + /// Returns the network route from this satellite to its control station, + /// or null if there is no route. + /// + /// + /// Only consider ground stations, not vessels. + /// + public IReadOnlyList> GetRoute(ISatellite sat, bool groundOnly) + { + if (!satmap.TryGetValue(sat.Guid, out int index)) + return null; + if (GetOrigin(index, groundOnly) < 0) + return null; + + return BuildHops(index, groundOnly); + } + + /// + /// Never null, unlike (empty when unreachable or a root's self-route). + /// + internal IReadOnlyList> RouteHops(int node, bool groundOnly) => + BuildHops(node, groundOnly); + + private IReadOnlyList> BuildHops(int index, bool groundOnly) + { + var cacheKey = HopKey(index, groundOnly); + if (routeCache.TryGetValue(cacheKey, out var cached)) + return cached; + + var origin = GetOrigin(index, groundOnly); + if (origin < 0 || origin == index) + return routeCache[cacheKey] = NoHops; + + int start = index; + int count = 0; + + while (true) + { + var parent = GetParent(index, groundOnly); + if (parent < 0) + break; + + count += 1; + index = parent; + } + + var links = new NetworkLink[count]; + index = start; + for (int i = 0; i < count; ++i) + { + var parent = GetParent(index, groundOnly); + links[i] = BuildLink(index, parent); + index = parent; + } + + return routeCache[cacheKey] = links; + } + + private int GetOrigin(int index, bool groundOnly) => + groundOnly ? originGs[index] : originCs[index]; + + private int GetParent(int index, bool groundOnly) => + groundOnly ? parentGs[index] : parentCs[index]; + + private NetworkLink BuildLink(int fromIdx, int toIdx) + { + var port = LinkType.None; + if (TryGetEdge(fromIdx, toIdx, out var edge) && edge.valid) + port = edge.linkType; + + return new NetworkLink(satellites[toIdx], port); + } + + /// + /// Direct links out of (its adjacency row). + /// + public List> GetLinks(ISatellite sat) + { + if (!TryGetNode(sat, out int node)) + return []; + + return GetLinksByNode(node); + } + + private List> GetLinksByNode(int node) + { + var result = new List>(); + if (!connected.IsSet(node)) + return result; + + for (int j = 0; j < satellites.Length; j++) + { + if (j == node) + continue; + if (TryGetEdge(node, j, out var edge) && edge.valid) + result.Add(new NetworkLink(satellites[j], edge.linkType)); + } + return result; + } + + /// + /// Enumerates the whole adjacency graph (inspection/debug seam). + /// + internal IEnumerable>>> EnumerateLinks() + { + for (int i = 0; i < satellites.Length; i++) + { + var sat = satellites[i]; + if (sat == null) + continue; + yield return new KeyValuePair>>(sat.Guid, GetLinksByNode(i)); + } + } + + /// + /// O(1) shortest signal delay for (optionally to a + /// ground station). +inf if unreachable, 0 if signal delay is disabled. + /// + public double ShortestDelay(ISatellite sat, bool groundOnly = false) + { + if (!TryGetNode(sat, out int index)) + return double.PositiveInfinity; + + var origin = GetOrigin(index, groundOnly); + if (origin < 0) + return double.PositiveInfinity; + if (!RTSettings.Instance.EnableSignalDelay) + return 0.0; + + var length = groundOnly ? scoreGs[index] : scoreCs[index]; + return length / RTSettings.Instance.SpeedOfLight; + } + + /// + /// Shortest signal delay between two specific satellites. Unlike + /// (which routes to the nearest station), this is + /// a point-to-point query solved on demand over the tick's link graph. +inf + /// if either is untracked or no route connects them, 0 if signal delay is + /// disabled. + /// + public double ShortestDelayBetween(ISatellite a, ISatellite b) + { + if (a is null || b is null) + return double.PositiveInfinity; + if (!satmap.TryGetValue(a.Guid, out int src) || !satmap.TryGetValue(b.Guid, out int dst)) + return double.PositiveInfinity; + + var length = RouteLengthBetween(src, dst); + if (double.IsPositiveInfinity(length)) + return double.PositiveInfinity; + if (!RTSettings.Instance.EnableSignalDelay) + return 0.0; + + return length / RTSettings.Instance.SpeedOfLight; + } + + internal unsafe double RouteLengthBetween(int source, int target) + { + handle.Complete(); + + double length = double.PositiveInfinity; + new NetworkPathfindJob + { + config = config, + source = source, + target = target, + nodes = nodes, + edges = edges, + length = &length, + }.Run(); + + return length; + } + + /// + /// The connection routes for (best path to any + /// command/ground station and, if different, to a ground station), shortest + /// first. + /// + public List> BuildConnections(ISatellite sat) + { + var list = new List>(2); + if (!TryGetNode(sat, out int node)) + return list; + + int rootAny = GetOrigin(node, false); + int rootGs = GetOrigin(node, true); + + if (rootAny >= 0) list.Add(new NetworkRoute(this, node, groundOnly: false)); + if (rootGs >= 0 && (rootAny < 0 || rootGs != rootAny)) + list.Add(new NetworkRoute(this, node, groundOnly: true)); + list.Sort(); + return list; + } + + /// Max range the two satellites' antennas could achieve, independent of their + /// current separation. 0 if untracked, or if no antenna pairing currently reaches. + public double GetMaxRangeDistance(ISatellite satA, ISatellite satB) + { + if (satA == null || satB == null) + return 0.0; + if (!satmap.TryGetValue(satA.Guid, out int a) || !satmap.TryGetValue(satB.Guid, out int b)) + return 0.0; + if (a == b) + return 0.0; + + return TryGetEdge(a, b, out var edge) && edge.valid ? edge.maxRange : 0.0; + } + + // `edges` is a flat triangular-pair table (NetworkUpdateMath.DecodePairIndex scheme). + private bool TryGetEdge(int a, int b, out NetworkEdge edge) + { + int lo = math.min(a, b); + int hi = math.max(a, b); + int pairIdx = hi * (hi - 1) / 2 + lo; + + if ((uint)pairIdx < (uint)edges.Length) + { + edge = edges[pairIdx]; + return true; + } + + edge = default; + return false; + } + + + + struct NetworkStateDisposeJob : IJob + { + public ObjectHandle state; + + public void Execute() + { + using var guard = state; + state.Target.DisposeInternal(); + } + } + + /// + /// Disposes the per-tick scratch containers that don't outlive the update in + /// a single job, sparing the scheduler one dispose job per container. + /// + [BurstCompile] + struct DisposeTempContainersJob : IJob + { + public NativeList antennas; + public NativeList directions; + public NativeArray ranges; + public NativeList adjranges; + public NativeList adjacency; + public NativeList distances; + public NativeList commandStations; + public NativeList groundStations; + public NativeBitArray canTransit; + + public void Execute() + { + antennas.Dispose(); + directions.Dispose(); + ranges.Dispose(); + adjranges.Dispose(); + adjacency.Dispose(); + distances.Dispose(); + commandStations.Dispose(); + groundStations.Dispose(); + canTransit.Dispose(); + } + } +} + diff --git a/src/RemoteTech/Network/NetworkUpdate.cs b/src/RemoteTech/Network/NetworkUpdate.cs new file mode 100644 index 000000000..0e1a32434 --- /dev/null +++ b/src/RemoteTech/Network/NetworkUpdate.cs @@ -0,0 +1,791 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using RemoteTech.Collections; +using RemoteTech.SimpleTypes; +using UnityEngine; +using Unity.Burst; +using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; +using Unity.Jobs; +using Unity.Mathematics; +using VesselTypeFilter = MapViewFiltering.VesselTypeFilter ; +using System.Runtime.CompilerServices; +using Unity.Burst.CompilerServices; + +namespace RemoteTech.Network; + +[BurstCompile] +internal static class NetworkUpdate +{ + #region Gather Celestial Bodies + internal struct RawCelestialBodyInfo + { + public Guid guid; + public Vector3d position; + public double radius; + public int instanceID; + public int parentID; + } + + internal struct RecordCelestialBodiesJob : IJob + { + public ObjectHandle> bodies; + public NativeArray infos; + + public void Execute() + { + using var guard = this.bodies; + var bodies = this.bodies.Target.AsReadOnlySpan(); + + int i = 0; + foreach (var body in bodies) + { + infos[i++] = new() + { + guid = RTUtil.Guid(body), + position = body.position, + radius = body.Radius, + instanceID = body.GetInstanceID(), + parentID = body.referenceBody?.GetInstanceID() ?? 0 + }; + } + } + } + + struct BodyComputeState + { + public NativeArray input; + public NativeArray output; + public NativeHashMap mapping; + + NativeMultiHashMap children; + + public void Compute() + { + // If the tree is invalid we can end up with entries that never have + // anything assigned. With uninitialized data that leads to an instant + // crash, better to make things work normally. + output.Clear(); + + children = new(input.Length, Allocator.Temp); + for (int i = 0; i < input.Length; ++i) + { + var parentID = input[i].parentID; + if (parentID == input[i].instanceID) + parentID = 0; + + children.Add(parentID, i); + } + + int index = 0; + if (children.TryGetFirstValue(0, out int childIndex, out var it)) + { + do + { + Dfs(childIndex, -1, ref index); + } while (children.TryGetNextValue(out childIndex, ref it)); + } + + if (index != input.Length) + ThrowUnreachableBody(index, input.Length); + } + + void Dfs(int inputIndex, int parent, ref int index) + { + var body = input[inputIndex]; + + int slot = index++; + ref var jb = ref output.GetElement(slot); + + mapping.Add(body.guid, slot); + jb = new() + { + position = body.position.ToDouble3(), + radius = body.radius, + parent = parent, + }; + + if (children.TryGetFirstValue(body.instanceID, out int childIndex, out var it)) + { + do + { + Dfs(childIndex, slot, ref index); + } while (children.TryGetNextValue(out childIndex, ref it)); + } + + jb.subtree = new(slot, index - slot); + } + + [IgnoreWarning(1370)] + [MethodImpl(MethodImplOptions.NoInlining)] + void ThrowUnreachableBody(int visited, int length) => + throw new InvalidOperationException($"celestial body job only visited {visited}/{length} bodies"); + } + + internal static void ComputeBodyInfo( + NativeArray input, + NativeArray output, + NativeHashMap mapping) + { + var state = new BodyComputeState + { + input = input, + output = output, + mapping = mapping, + }; + + state.Compute(); + } + #endregion + + #region Gather Vessel Info + internal struct RawVesselInfo + { + public SatelliteState state; + public int instanceID; + public VesselType type; + public NodeKind kind; + public Color32 markColor; + [MarshalAs(UnmanagedType.U1)] + public bool landed; + } + + internal struct RecordVesselInfoJob : IJobParallelForBatch + { + public ObjectHandle> satellites; + public NativeArray infos; + + public void Execute(int start, int count) + { + var satellites = this.satellites.Target.Values; + int end = start + count; + + for (int i = start; i < end; ++i) + { + var sat = satellites[i]; + var vessel = sat.Vessel; + + infos[i] = new() + { + state = sat.GetState(), + instanceID = vessel.GetInstanceID(), + type = vessel.DiscoveryInfo.HaveKnowledgeAbout(DiscoveryLevels.Appearance) + ? vessel.vesselType + : VesselType.Unknown, + kind = sat.isVessel + ? NodeKind.Vessel + : NodeKind.GroundStation, + markColor = sat.MarkColor, + landed = vessel.Landed + }; + } + } + } + + internal static void ComputeVesselInfo( + NativeArray raw, + NativeArray nodes, + NativeHashMap satmap, + NativeHashMap bodymap) + { + for (int i = 0; i < raw.Length; ++i) + { + var info = raw[i]; + + nodes[i] = new() + { + guid = info.state.Guid, + position = info.state.Position.ToDouble3(), + bodyIndex = bodymap.TryGetValue(info.state.Body, out var bodyIndex) + ? bodyIndex + : 0, + kind = info.kind, + flags = info.state.GetFlags() + }; + satmap.Add(info.state.Guid, i); + } + } + + internal struct VisibilityState + { + public Guid ActiveVessel; + public Guid TargetVessel; + public VesselTypeFilter? filter; + } + + internal static void ComputeVesselVisibility( + VisibilityState context, + NativeArray infos, + NativeArray nodes) + { + if (context.filter is not VesselTypeFilter filter) + { + for (int i = 0; i < infos.Length; ++i) + nodes.GetElement(i).flags |= NodeFlags.Visible; + return; + } + + for (int i = 0; i < infos.Length; ++i) + { + var info = infos[i]; + var guid = info.state.Guid; + var type = info.type; + + if (EvaluateFilter(in context, guid, type, filter)) + nodes.GetElement(i).flags |= NodeFlags.Visible; + } + } + + static bool EvaluateFilter( + in VisibilityState context, + Guid guid, + VesselType type, + VesselTypeFilter filter) + { + if (guid == context.ActiveVessel) + return true; + if (guid == context.TargetVessel) + return true; + + return type switch + { + VesselType.Debris => (filter & VesselTypeFilter.Debris) != 0, + VesselType.SpaceObject => (filter & VesselTypeFilter.SpaceObjects) != 0, + VesselType.Probe => (filter & VesselTypeFilter.Probes) != 0, + VesselType.Relay => (filter & VesselTypeFilter.Relay) != 0, + VesselType.Rover => (filter & VesselTypeFilter.Rovers) != 0, + VesselType.Lander => (filter & VesselTypeFilter.Landers) != 0, + VesselType.Ship => (filter & VesselTypeFilter.Ships) != 0, + VesselType.Plane => (filter & VesselTypeFilter.Plane) != 0, + VesselType.Station => (filter & VesselTypeFilter.Stations) != 0, + VesselType.Base => (filter & VesselTypeFilter.Bases) != 0, + VesselType.EVA => (filter & VesselTypeFilter.EVAs) != 0, + VesselType.Flag => (filter & VesselTypeFilter.Flags) != 0, + VesselType.DeployedScienceController => (filter & VesselTypeFilter.DeployedScienceController) != 0, + VesselType.DeployedSciencePart => false, + _ => (filter & VesselTypeFilter.Unknown) != 0, + }; + } + #endregion + + #region Gather Station Info + internal struct RawStationInfo + { + public SatelliteState state; + public NodeKind kind; + public Color32 markColor; + } + + internal struct RecordGroundStationsJob : IJob + { + public ObjectHandle> stations; + public NativeArray states; + + public void Execute() + { + using var guard = stations; + var satellites = stations.Target.Values; + + int index = 0; + foreach (var sat in satellites) + { + states[index++] = new() + { + state = sat.GetState(), + kind = sat.isVessel + ? NodeKind.Vessel + : NodeKind.GroundStation, + markColor = sat.MarkColor + }; + } + } + } + + internal static void ComputeGroundStationInfo( + NativeArray infos, + NativeArray nodes, + int offset, + NativeHashMap bodymap) + { + for (int i = 0; i < infos.Length; ++i) + { + var info = infos[i]; + var state = info.state; + + nodes[i + offset] = new() + { + guid = state.Guid, + position = state.Position.ToDouble3(), + bodyIndex = bodymap.TryGetValue(state.Body, out var bodyIndex) + ? bodyIndex + : 0, + kind = info.kind, + flags = state.GetFlags(), + }; + } + } + #endregion + + #region Gather Antenna Info + internal static unsafe void ComputeAntennaInfo( + NativeArray nodes, + NativeList antennas, + StateManager.NativePointerArray datas, + NativeHashMap mapping) + { + NativeMultiHashMap amap = new(datas.Length, Allocator.Temp); + antennas.Capacity = datas.Length; + + for (int i = 0; i < datas.Length; ++i) + { + var data = datas[i]; + var ran = SatelliteRecordUtil.BuildAntenna(in *data); + + if (!mapping.TryGetValue(ran.target, out int target)) + target = 0; + + ran.antenna.target = target; + + amap.Add(data->Guid, ran.antenna); + } + + for (int i = 0; i < nodes.Length; ++i) + { + int start = antennas.Length; + ref var node = ref nodes.GetElement(i); + + if (amap.TryGetFirstValue(node.guid, out var ant, out var it)) + { + do + { + ant.nodeIndex = i; + antennas.Add(ant); + } while (amap.TryGetNextValue(out ant, ref it)); + } + + node.antennas = new(start, antennas.Length - start); + } + } + + internal static void ComputeAntennaDirections( + NativeArray nodes, + NativeArray bodies, + NativeArray antennas, + NativeArray directions) + { + for (int i = 0; i < antennas.Length; ++i) + { + var antenna = antennas[i]; + var origin = nodes[antenna.nodeIndex].position; + + double3 target; + if (antenna.target == 0) + target = origin; + else if (antenna.target > 0) + target = nodes[antenna.target - 1].position; + else + target = bodies[-antenna.target - 1].position; + + double3 rel = target - origin; + double magSq = math.lengthsq(rel); + directions[i] = magSq > 0.0 ? rel * math.rsqrt(magSq) : double3.zero; + } + } + #endregion + + #region Compute Cone Candidates + internal static void ComputeConeCandidates( + NativeArray antennas, + NativeList candidates) + { + foreach (var a in antennas) + { + if (!a.Powered || !a.CanTarget || a.target == 0) + continue; + + candidates.Add(new ConeCandidate + { + nodeIndex = a.nodeIndex, + target = a.target, + cosAngle = a.cosAngle, + dishRange = a.dish, + }); + } + } + #endregion + + #region Compute Mark Candidates + internal static void ComputeMarkCandidates( + NativeArray vessels, + NativeArray stations, + NativeList candidates) + { + for (int i = 0; i < vessels.Length; ++i) + { + var v = vessels[i]; + if (!v.state.IsCommandStation) + continue; + + candidates.Add(new SatelliteMarkCandidate + { + nodeIndex = i, + color = v.markColor, + alwaysShow = v.kind == NodeKind.Vessel && !v.landed, + }); + } + + for (int i = 0; i < stations.Length; ++i) + { + candidates.Add(new SatelliteMarkCandidate + { + nodeIndex = vessels.Length + i, + color = stations[i].markColor, + alwaysShow = false, + }); + } + } + #endregion + + #region Compute Max Vessel Range + internal static void ComputeVesselMaxRanges( + in JobConfig config, + NativeArray nodes, + NativeArray antennas, + NativeArray ranges) + { + for (int i = 0; i < nodes.Length; ++i) + { + var node = nodes[i]; + + double maxOmni = 0.0; + double maxDish = 0.0; + double sumOmni = 0.0; + foreach (int j in node.antennas) + { + var a = antennas[j]; + if (a.omni > maxOmni) maxOmni = a.omni; + if (a.dish > maxDish) maxDish = a.dish; + sumOmni += a.omni; + } + + double bonus = config.multipleAntennaMultiplier > 0.0 + ? (sumOmni - maxOmni) * config.multipleAntennaMultiplier + : 0.0; + double effOmni = (maxOmni + bonus) * config.omniClamp; + double effDish = maxDish * config.dishClamp; + ranges[i] = math.max(effOmni, effDish); + } + } + #endregion + + #region Compute Mapping + internal static NativeHashMap ComputeOverallMapping( + NativeHashMap cbmap, + NativeHashMap satmap) + { + var mapping = new NativeHashMap( + cbmap.Capacity + satmap.Capacity, + Allocator.Temp); + + foreach (var (id, index) in cbmap.GetKeyValueArrays(Allocator.Temp)) + mapping.Add(id, -(index + 1)); + foreach (var (id, index) in satmap.GetKeyValueArrays(Allocator.Temp)) + mapping.Add(id, index + 1); + + return mapping; + } + #endregion + + #region Compute Adjacency + public static void ComputeAdjacencyLists( + NativeArray nodes, + NativeArray edges, + NativeArray ranges, + NativeList adjacency, + NativeList distances) + { + var matrix = new NativeArray(nodes.Length * nodes.Length, Allocator.Temp); + var matrixDist = new NativeArray(nodes.Length * nodes.Length, Allocator.Temp); + var cursors = new NativeArray(nodes.Length, Allocator.Temp); + + for (int i = 0; i < cursors.Length; ++i) + cursors[i] = i * nodes.Length; + + int total = 0; + for (int i = 0; i < edges.Length; ++i) + { + var edge = edges[i]; + if (!edge.valid) + continue; + if (!nodes[edge.aIdx].Powered || !nodes[edge.bIdx].Powered) + continue; + + var idxA = cursors[edge.aIdx]++; + matrix[idxA] = edge.bIdx; + matrixDist[idxA] = edge.distance; + + var idxB = cursors[edge.bIdx]++; + matrix[idxB] = edge.aIdx; + matrixDist[idxB] = edge.distance; + + total += 2; + } + + adjacency.Capacity = total; + distances.Capacity = total; + + int offset = 0; + for (int i = 0; i < nodes.Length; ++i) + { + int count = cursors[i] - i * nodes.Length; + ranges[i] = new(offset, count); + offset += count; + + var indices = matrix.GetSubArray(i * nodes.Length, count); + adjacency.AddRange(indices); + + var dists = matrixDist.GetSubArray(i * nodes.Length, count); + distances.AddRange(dists); + } + } + + /// + /// Whether a node may carry a signal through it (serve as an + /// intermediate hop). The two ends of a route never need this — only transit + /// nodes do — so it gates path expansion, not edge existence. + /// + internal static bool CanTransit(in JobConfig config, JobNode node) => + !config.signalRelayEnabled || node.CanRelay; + + public static void ComputeCanTransit( + in JobConfig config, + NativeArray nodes, + NativeBitArray canTransit) + { + for (int i = 0; i < nodes.Length; ++i) + canTransit.Set(i, CanTransit(in config, nodes[i])); + } + #endregion + + #region Compute Vessel Connection Info + public static void ComputeVesselConnected( + NativeArray ranges, + NativeBitArray connected) + { + for (int i = 0; i < ranges.Length; ++i) + { + if (!ranges[i].IsEmpty) + connected.Set(i, true); + } + } + #endregion + + #region Compute Roots + public static void ComputeNetworkRoots( + NativeArray nodes, + NativeList commandStations, + NativeList groundStations) + { + commandStations.Capacity = nodes.Length; + groundStations.Capacity = nodes.Length; + + for (int i = 0; i < nodes.Length; ++i) + { + if (nodes[i].IsCommandStation || nodes[i].kind == NodeKind.GroundStation) + commandStations.Add(i); + if (nodes[i].kind == NodeKind.GroundStation) + groundStations.Add(i); + } + } + #endregion + + #region Compute Route Fingerprints + internal static void ComputeRouteFingerprints( + NativeArray guids, + NativeArray originCs, + NativeArray parentCs, + NativeArray originGs, + NativeArray parentGs, + NativeHashMap prevSatmap, + NativeArray prevFingerprints, + NativeArray fingerprints, + NativeList changed) + { + var path = new NativeList(16, Allocator.Temp); + + for (int index = 0; index < guids.Length; ++index) + { + Hash128 hash = default; + HashPath(guids, originCs, parentCs, index, path, ref hash); + HashPath(guids, originGs, parentGs, index, path, ref hash); + + fingerprints[index] = hash; + + if (!prevSatmap.TryGetValue(guids[index], out int prevIndex) || prevFingerprints[prevIndex] != hash) + changed.Add(index); + } + } + + static unsafe void HashPath( + NativeArray guids, + NativeArray origins, + NativeArray parents, + int node, + NativeList path, + ref Hash128 hash) + { + int origin = origins[node]; + int state = origin < 0 ? 0 : (origin == node ? 1 : 2); + SpookyHash.Hash128(&state, sizeof(int), ref hash); + + path.Clear(); + int cur = node; + int guard = parents.Length + 1; + while (guard-- > 0) + { + int next = parents[cur]; + if (next < 0) + break; + + path.Add(guids[next]); + cur = next; + } + + if (path.Length > 0) + { + var size = path.Length * UnsafeUtility.SizeOf(); + SpookyHash.Hash128(NativeListUnsafeUtility.GetUnsafePtr(path), size, ref hash); + } + } + #endregion + + internal static NodeFlags GetFlags(in this SatelliteState state) + { + var flags = NodeFlags.None; + if (state.Powered) flags |= NodeFlags.Powered; + if (state.CanRelaySignal) flags |= NodeFlags.CanRelay; + if (state.IsInRadioBlackout) flags |= NodeFlags.InBlackout; + if (state.IsCommandStation) flags |= NodeFlags.IsCommandStation; + return flags; + } +} + +[BurstCompile] +struct NetworkUpdateJob : IJob +{ + public JobConfig config; + public NetworkUpdate.VisibilityState visibility; + + [ReadOnly] + [DeallocateOnJobCompletion] + public NativeArray vessels; + [ReadOnly] + [DeallocateOnJobCompletion] + public NativeArray cbs; + [ReadOnly] + [DeallocateOnJobCompletion] + public NativeArray stations; + public StateManager.NativePointerArray antennaData; + + public NativeArray nodes; + [WriteOnly] public NativeArray guids; + public NativeArray bodies; + public NativeList antennas; + public NativeList directions; + public NativeArray ranges; + public NativeHashMap satmap; + public NativeList coneCandidates; + public NativeList markCandidates; + + public void Execute() + { + using var _guard1 = antennaData; + + var cbmap = new NativeHashMap(bodies.Length, Allocator.Temp); + + NetworkUpdate.ComputeBodyInfo(cbs, bodies, cbmap); + NetworkUpdate.ComputeVesselInfo(vessels, nodes, satmap, cbmap); + NetworkUpdate.ComputeVesselVisibility(visibility, vessels, nodes); + NetworkUpdate.ComputeGroundStationInfo(stations, nodes, vessels.Length, cbmap); + + var mapping = NetworkUpdate.ComputeOverallMapping(cbmap, satmap); + NetworkUpdate.ComputeAntennaInfo(nodes, antennas, antennaData, mapping); + + directions.ResizeUninitialized(antennas.Length); + NetworkUpdate.ComputeAntennaDirections(nodes, bodies, antennas, directions); + NetworkUpdate.ComputeVesselMaxRanges(in config, nodes, antennas, ranges); + NetworkUpdate.ComputeConeCandidates(antennas.AsArray(), coneCandidates); + NetworkUpdate.ComputeMarkCandidates(vessels, stations, markCandidates); + + for (int i = 0; i < nodes.Length; ++i) + guids[i] = nodes[i].guid; + } +} + +[BurstCompile] +struct NetworkAdjacencyJob : IJob +{ + public JobConfig config; + + public NativeArray nodes; + public NativeArray edges; + + public NativeList ranges; + public NativeList adjacency; + public NativeList distances; + public NativeBitArray connected; + public NativeBitArray canTransit; + + public NativeList commandStations; + public NativeList groundStations; + + public void Execute() + { + ranges.ResizeUninitialized(nodes.Length); + + NetworkUpdate.ComputeAdjacencyLists( + nodes, + edges, + ranges, + adjacency, + distances); + NetworkUpdate.ComputeVesselConnected(ranges, connected); + NetworkUpdate.ComputeCanTransit(in config, nodes, canTransit); + NetworkUpdate.ComputeNetworkRoots(nodes, commandStations, groundStations); + } +} + +[BurstCompile] +struct NetworkFingerprintJob : IJob +{ + [ReadOnly] + [DeallocateOnJobCompletion] + public NativeArray guids; + [ReadOnly] public NativeArray originCs; + [ReadOnly] public NativeArray parentCs; + [ReadOnly] public NativeArray originGs; + [ReadOnly] public NativeArray parentGs; + + [ReadOnly] public NativeHashMap prevSatmap; + [ReadOnly] public NativeArray prevFingerprints; + + public NativeList fingerprints; + public NativeList changed; + + public void Execute() + { + fingerprints.ResizeUninitialized(guids.Length); + + NetworkUpdate.ComputeRouteFingerprints( + guids, + originCs, + parentCs, + originGs, + parentGs, + prevSatmap, + prevFingerprints, + fingerprints.AsArray(), + changed); + } +} diff --git a/src/RemoteTech/Network/NetworkUpdateMath.cs b/src/RemoteTech/Network/NetworkUpdateMath.cs new file mode 100644 index 000000000..2aa18f781 --- /dev/null +++ b/src/RemoteTech/Network/NetworkUpdateMath.cs @@ -0,0 +1,125 @@ +using RemoteTech.Collections; +using Unity.Collections; +using Unity.Mathematics; + +namespace RemoteTech.Network; + +internal static class NetworkUpdateMath +{ + public const double MIN_HEIGHT = 5.0; + + /// + /// Do the satellites at and + /// have a line of sight between each other? This checks against the bodies + /// specified in . + /// + public static bool HasLineOfSight( + double3 posA, + double3 posB, + NativeArray bodies, + IntRange range) + { + double3 bFromA = posB - posA; + double bFromALenSq = math.lengthsq(bFromA); + if (bFromALenSq <= 0.0) + return true; + + double bFromALen = math.sqrt(bFromALenSq); + double3 bFromANorm = bFromA / bFromALen; + + foreach (int i in range) + { + JobBody body = bodies[i]; + double3 bodyFromA = body.position - posA; + + // Is body at least roughly between A and B? + double dot = math.dot(bodyFromA, bFromA); + if (dot <= 0.0) + continue; + + double projLen = math.dot(bodyFromA, bFromANorm); + if (projLen >= bFromALen) + continue; + + double3 lateral = bodyFromA - projLen * bFromANorm; + double threshold = body.radius - MIN_HEIGHT; + if (math.lengthsq(lateral) < threshold * threshold) + return false; + } + + return true; + } + + /// + /// Reproduces AbstractRangeModel.CheckRange: the smallest of + /// (range-model joint range, r1 boosted by clamp1, r2 boosted by clamp2). + /// + public static double CheckRange( + in TRange model, + double r1, + double clamp1, + double r2, + double clamp2) + where TRange : struct, IRangeModel + { + return math.min(math.min(model.MaxDistance(r1, r2), r1 * clamp1), r2 * clamp2); + } + + /// + /// Multiple-antenna omni bonus: when more than one omni is mounted, the + /// non-best ones each contribute multiplier times their own omni. + /// Mirrors AbstractRangeModel.GetMultipleAntennaBonus. + /// + public static double GetMultipleAntennaBonus( + NativeArray antennas, + IntRange range, + double maxOmni, + double multiplier) + { + if (multiplier <= 0.0) + return 0.0; + double total = 0.0; + foreach (int i in range) + total += antennas[i].omni; + return (total - maxOmni) * multiplier; + } + + /// + /// Cheap upper-bound reachability test. If the sum of each vessel's + /// effective range cap is less than the distance between them, no link + /// is possible under either range model. Lets BuildEdgesJob skip the + /// LOS sweep for the long tail of far-apart pairs. + /// + public static bool CouldPossiblyLink(double maxRangeA, double maxRangeB, double distance) + { + return maxRangeA + maxRangeB >= distance; + } + + /// + /// Decodes a flat triangular-pair index into (i, j) with + /// 0 <= i < j. Pair index k corresponds to (i, j) via + /// k = j*(j-1)/2 + i. + /// + public static void DecodePairIndex(int pairIdx, out int i, out int j) + { + // Closed-form starting point; finish with a tiny correction loop to + // guard against floating-point drift on edge values. + j = (int)((1.0 + math.sqrt(1.0 + 8.0 * pairIdx)) * 0.5); + while (j * (j - 1) / 2 > pairIdx) j--; + while ((j + 1) * j / 2 <= pairIdx) j++; + i = pairIdx - j * (j - 1) / 2; + } + + /// + /// Inverse of : the flat pair index for the + /// unordered pair (a, b). + /// + public static int EncodePairIndex(int a, int b) + { + int lo = math.min(a, b); + int hi = math.max(a, b); + return hi * (hi - 1) / 2 + lo; + } + + public static int PairCount(int nodeCount) => nodeCount * (nodeCount - 1) / 2; +} diff --git a/src/RemoteTech/Network/RangeModels.cs b/src/RemoteTech/Network/RangeModels.cs new file mode 100644 index 000000000..f76d901f0 --- /dev/null +++ b/src/RemoteTech/Network/RangeModels.cs @@ -0,0 +1,21 @@ +using Unity.Mathematics; + +namespace RemoteTech.Network; + +/// +/// Interface used for range models in jobs. +/// +internal interface IRangeModel +{ + double MaxDistance(double r1, double r2); +} + +internal readonly struct StandardRangeModel : IRangeModel +{ + public double MaxDistance(double r1, double r2) => math.min(r1, r2); +} + +internal readonly struct AdditiveRangeModel : IRangeModel +{ + public double MaxDistance(double r1, double r2) => math.min(r1, r2) + math.sqrt(r1 * r2); +} diff --git a/src/RemoteTech/Network/SatelliteMarkJob.cs b/src/RemoteTech/Network/SatelliteMarkJob.cs new file mode 100644 index 000000000..2d9de8d92 --- /dev/null +++ b/src/RemoteTech/Network/SatelliteMarkJob.cs @@ -0,0 +1,115 @@ +using System.Runtime.InteropServices; +using Unity.Burst; +using Unity.Collections; +using Unity.Jobs; +using Unity.Mathematics; +using UnityEngine; + +namespace RemoteTech.Network; + +/// +/// A satellite whose map-view mark should be considered for drawing (every ground +/// station plus every command-station vessel), gathered once per tick by +/// . +/// +internal struct SatelliteMarkCandidate +{ + public int nodeIndex; + public Color32 color; + + // Orbiting vessels are always shown, so they skip the occlusion/distance filters. + [MarshalAs(UnmanagedType.U1)] + public bool alwaysShow; +} + +/// +/// Camera/ScaledSpace snapshot plus the ground-station hiding settings for +/// . +/// +internal struct SatelliteMarkViewParams +{ + public double invScale; + public double3 totalOffset; + public double3 camPosLocal; + public float4x4 view, proj; + public float pixelWidth, pixelHeight, screenHeight; + [MarshalAs(UnmanagedType.U1)] + public bool hideBehindBody; + [MarshalAs(UnmanagedType.U1)] + public bool hideOnDistance; + public float distanceThreshold; +} + +/// +/// A mark the renderer should draw: the node it belongs to (for main-thread +/// mouse-over lookup), its GUI-space centre and its colour. +/// +internal struct SatelliteMark +{ + public float2 screenPos; + public int nodeIndex; + public Color32 color; +} + +/// +/// Filters the mark candidates down to those visible this frame and projects +/// each survivor to its GUI-space screen position. +/// +[BurstCompile] +internal struct SatelliteMarkJob : IJob +{ + [ReadOnly] public NativeArray candidates; + [ReadOnly] public NativeArray nodes; + [ReadOnly] public NativeArray bodies; + public SatelliteMarkViewParams p; + + public NativeList marks; + + public void Execute() + { + marks.Clear(); + for (int i = 0; i < candidates.Length; i++) + { + SatelliteMarkCandidate c = candidates[i]; + JobNode node = nodes[c.nodeIndex]; + + float3 scaled = LineMeshCore.ToScaled(node.position, p.invScale, p.totalOffset); + float3 screen = LineMeshCore.WorldToScreen(scaled, p.view, p.proj, p.pixelWidth, p.pixelHeight); + if (screen.z < 0f) + continue; + + if (!c.alwaysShow) + { + bool occluded = IsOccluded(node.position, bodies[node.bodyIndex].position, p.camPosLocal); + if (p.hideBehindBody && occluded) + continue; + if (p.hideOnDistance && !occluded + && math.distance((float3)p.camPosLocal, (float3)node.position) >= p.distanceThreshold) + continue; + } + + marks.Add(new SatelliteMark + { + nodeIndex = c.nodeIndex, + screenPos = new float2(screen.x, p.screenHeight - screen.y), + color = c.color, + }); + } + } + + // Behind its body when the camera and the body sit on the same side of the mark. + private static bool IsOccluded(double3 loc, double3 bodyPos, double3 camPos) + => math.dot(camPos - loc, bodyPos - loc) >= 0.0; +} + +internal struct SatelliteMarkData +{ + public NativeList marks; + public JobHandle handle; + + public void Dispose() + { + if (marks.IsCreated) + marks.Dispose(); + } +} diff --git a/src/RemoteTech/Network/VesselInfo.cs b/src/RemoteTech/Network/VesselInfo.cs new file mode 100644 index 000000000..530025123 --- /dev/null +++ b/src/RemoteTech/Network/VesselInfo.cs @@ -0,0 +1,109 @@ +using System; +using RemoteTech.Collections; +using RemoteTech.SimpleTypes; +using Unity.Mathematics; + +namespace RemoteTech.Network; + +internal enum NodeKind : byte +{ + Vessel = 0, + GroundStation = 1, +} + +[Flags] +internal enum NodeFlags : byte +{ + None = 0, + Powered = 1 << 0, + CanRelay = 1 << 1, + InBlackout = 1 << 2, + IsCommandStation = 1 << 3, + + // Set by NetworkUpdate.ComputeVesselVisibility, for line-render filtering. + Visible = 1 << 4, +} + +[Flags] +internal enum AntennaFlags : byte +{ + None = 0, + Activated = 1 << 0, + Powered = 1 << 1, + CanTarget = 1 << 2, +} + +internal struct JobNode +{ + public Guid guid; + public double3 position; + public IntRange antennas; + public int bodyIndex; + public NodeKind kind; + public NodeFlags flags; + + public bool Powered => (flags & NodeFlags.Powered) != 0; + public bool CanRelay => (flags & NodeFlags.CanRelay) != 0; + public bool InBlackout => (flags & NodeFlags.InBlackout) != 0; + public bool IsCommandStation => (flags & NodeFlags.IsCommandStation) != 0; +} + +/// +/// Snapshot of an antenna for the per-tick network update jobs. +/// +internal struct JobAntenna +{ + public double omni; + public double dish; + public double cosAngle; + public int nodeIndex; + + /// + /// Target encoded as a signed int: + /// - 0: no target + /// - n > 0: node at index n-1 + /// - n < 0: body at index -n-1 + /// + public int target; + + public AntennaFlags flags; + + public bool Activated => (flags & AntennaFlags.Activated) != 0; + public bool Powered => (flags & AntennaFlags.Powered) != 0; + public bool CanTarget => (flags & AntennaFlags.CanTarget) != 0; +} + +/// +/// A paired with its still-unresolved target guid; +/// resolves the guid to a +/// node or body index. +/// +internal struct RawAntenna +{ + public JobAntenna antenna; + public Guid target; +} + +internal static class SatelliteRecordUtil +{ + public static RawAntenna BuildAntenna(in AntennaData antenna) + { + var flags = AntennaFlags.None; + if (antenna.Activated) flags |= AntennaFlags.Activated; + if (antenna.Powered) flags |= AntennaFlags.Powered; + if (antenna.CanTarget) flags |= AntennaFlags.CanTarget; + + return new() + { + antenna = new JobAntenna + { + omni = antenna.Omni, + dish = antenna.Dish, + cosAngle = antenna.CosAngle, + flags = flags + }, + target = antenna.Target + }; + } +} + diff --git a/src/RemoteTech/NetworkFeedback.cs b/src/RemoteTech/NetworkFeedback.cs index a0f59f7a2..914abe0e1 100644 --- a/src/RemoteTech/NetworkFeedback.cs +++ b/src/RemoteTech/NetworkFeedback.cs @@ -7,7 +7,9 @@ namespace RemoteTech { public class NetworkFeedback { - /// Returns the position of a target identified only by its Guid + /// + /// Returns the position of a target identified only by its Guid + /// /// An absolute world coordinate position. /// The item whose position is desired. May be either a satellite /// or a celestial body @@ -29,7 +31,9 @@ public static Vector3d targetPosition(Guid target) { throw new System.ArgumentException("No such Guid found", "target"); } - /// Returns the SoI of a target identified only by its Guid + /// + /// Returns the SoI of a target identified only by its Guid + /// /// If target refers to a celestial body, returns that body. If target refers /// to an ISatellite, returns the body whose SoI currently contains the ISatellite. /// The item whose position is desired. May be either a satellite @@ -52,7 +56,9 @@ public static CelestialBody targetBody(Guid target) { throw new System.ArgumentException("No such Guid found", "target"); } - /// Counts the number of ISatellites that are in the antenna cone and in range. + /// + /// Counts the number of ISatellites that are in the antenna cone and in range. + /// /// The number of reachable satellites. /// If antenna does not have a cone, returns 0. /// The antenna whose cone must be tested. @@ -85,7 +91,9 @@ public static int countInCone(IAntenna antenna, Guid target) { } } - /// Tests whether an antenna can connect to a target + /// + /// Tests whether an antenna can connect to a target + /// /// The range to the target, or a diagnostic error message. Returns the /// empty string if target is invalid. /// The antenna attempting to make a connection. diff --git a/src/RemoteTech/NetworkManager.cs b/src/RemoteTech/NetworkManager.cs index 499e74b98..50ef90cee 100644 --- a/src/RemoteTech/NetworkManager.cs +++ b/src/RemoteTech/NetworkManager.cs @@ -3,10 +3,12 @@ using System.Collections.Generic; using System.Linq; using RemoteTech.Modules; +using RemoteTech.Network; using RemoteTech.RangeModel; using RemoteTech.SimpleTypes; using UnityEngine; using KSP.Localization; +using RemoteTech.Collections; namespace RemoteTech { @@ -16,16 +18,10 @@ namespace RemoteTech /// public partial class NetworkManager : IEnumerable { - public event Action> OnLinkAdd = delegate { }; - public event Action> OnLinkRemove = delegate { }; + public ArrayMap Planets { get; private set; } = new(); + public ArrayMap GroundStations { get; private set; } = new(); - public Dictionary Planets { get; private set; } - public Dictionary GroundStations { get; private set; } - public Dictionary>> Graph { get; private set; } - - public int Count { get { return RTCore.Instance.Satellites.Count + GroundStations.Count; } } - - public static Guid ActiveVesselGuid = new Guid(RTSettings.Instance.ActiveVesselGuid); + public static Guid ActiveVesselGuid => RTSettings.Instance.ActiveVesselGuidParsed; public ISatellite this[Guid guid] { @@ -46,40 +42,97 @@ public List> this[ISatellite sat] { get { - if (sat == null) return new List>(); - return mConnectionCache.ContainsKey(sat) ? mConnectionCache[sat] : new List>(); + if (sat == null || current == null) return new List>(); + // Cache is cleared whenever _current changes (see OnPhysicsUpdate). + if (mConnectionCache.TryGetValue(sat, out var cached)) return cached; + var built = current.BuildConnections(sat); + mConnectionCache[sat] = built; + return built; } } - private const int REFRESH_TICKS = 50; + private readonly Dictionary>> mConnectionCache = new Dictionary>>(); + + private NetworkState current; + private NetworkState next; + + private static readonly List> EmptyLinks = []; - private int mTick; - private int mTickIndex; - private Dictionary>> mConnectionCache = new Dictionary>>(); + // --- Read seam ------------------------------------------------------ + // All consumers go through these accessors rather than touching Graph / + // the connection cache directly, so the backing representation can be + // swapped (toward native/burst-array storage) without touching callers. + + /// + /// Direct links out of (its adjacency row). + /// + public IReadOnlyList> GetLinks(ISatellite sat) => current?.GetLinks(sat) ?? EmptyLinks; + + /// + /// Whether is an interface on any current link + /// of its owning satellite — i.e. the antenna's "connected" state. + /// + public bool IsAntennaConnected(IAntenna antenna) => current?.IsAntennaConnected(antenna) ?? false; + + /// + /// O(1) "does have a working connection" check + /// (optionally restricted to ground-station routes), served from the + /// current state without materializing any route. Equivalent to + /// this[sat].Any(). + /// + public bool IsConnected(ISatellite sat, bool groundOnly = false) => current?.GetRouteExists(sat, groundOnly) ?? false; + + /// + /// O(1) shortest signal delay for (optionally to a + /// ground station). +inf if unreachable, 0 if signal delay is disabled. + /// Equivalent to this[sat].Min().Delay. + /// + public double ShortestDelay(ISatellite sat, bool groundOnly = false) => current?.ShortestDelay(sat, groundOnly) ?? double.PositiveInfinity; + + /// + /// This tick's route from to its controlling station + /// (optionally restricted to ground stations), ordered from the satellite + /// outward. Null if there is no such route. + /// + public IReadOnlyList> GetRoute(ISatellite sat, bool groundOnly = false) => current?.GetRoute(sat, groundOnly); + + /// + /// Shortest signal delay between two specific satellites (a point-to-point + /// query), as opposed to which routes to the + /// nearest station. +inf if unreachable, 0 if signal delay is disabled. + /// + public double ShortestDelayBetween(ISatellite a, ISatellite b) => current?.ShortestDelayBetween(a, b) ?? double.PositiveInfinity; + + /// + /// Enumerates the whole adjacency graph (inspection/debug seam). + /// + internal IEnumerable>>> EnumerateLinks() => + current?.EnumerateLinks() ?? Enumerable.Empty>>>(); + + /// + /// This tick's state — freshest positions, but not yet completed; callers pay for if it's still running. + /// + internal NetworkState Next => next; public NetworkManager() { - Graph = new Dictionary>>(); - // Load all planets into a dictionary; - Planets = new Dictionary(); foreach (CelestialBody cb in FlightGlobals.Bodies) - { - Planets[cb.Guid()] = cb; - } + Planets.Add(cb.Guid(), cb); // Load all ground stations into a dictionary; - GroundStations = new Dictionary(); - foreach (ISatellite sat in RTSettings.Instance.GroundStations) + foreach (MissionControlSatellite station in RTSettings.Instance.GroundStations) { try { + ISatellite sat = station; GroundStations.Add(sat.Guid, sat); OnSatelliteRegister(sat); + station.RegisterAntennaStates(); } catch (Exception e) // Already exists. { - RTLog.Notify("A ground station cannot be loaded: " + e.Message, RTLogLevel.LVL1); + RTLog.Notify("A ground station cannot be loaded: " + e.Message, RTLogLevel.LVL1); } } @@ -96,54 +149,16 @@ public void Dispose() RTCore.Instance.Satellites.OnRegister -= OnSatelliteRegister; RTCore.Instance.Satellites.OnUnregister -= OnSatelliteUnregister; } - } - - public void FindPath(ISatellite start, IEnumerable commandStations) - { - var paths = new List>(); - foreach (ISatellite root in commandStations.Concat(GroundStations.Values).Where(r => r != start)) - { - paths.Add(NetworkPathfinder.Solve(start, root, FindNeighbors, RangeModelExtensions.DistanceTo, RangeModelExtensions.DistanceTo)); - } - mConnectionCache[start] = paths.Where(p => p.Exists).ToList(); - mConnectionCache[start].Sort((a, b) => a.Length.CompareTo(b.Length)); - start.OnConnectionRefresh(this[start]); - } - - public IEnumerable> FindNeighbors(ISatellite s) - { - if (!Graph.ContainsKey(s.Guid) || !s.Powered) return Enumerable.Empty>(); - if (RTSettings.Instance.SignalRelayEnabled) - { - return Graph[s.Guid].Where(l => l.Target.Powered && l.Target.CanRelaySignal); - } - else + foreach (ISatellite sat in GroundStations.Values) { - return Graph[s.Guid].Where(l => l.Target.Powered); + if (sat is MissionControlSatellite station) + station.UnregisterAntennaStates(); } + current?.Dispose(); + next?.Dispose(); } - private void UpdateGraph(ISatellite a) - { - var result = this.Select(b => GetLink(a, b)).Where(link => link != null).ToList(); - - // Send events for removed edges - foreach (var link in Graph[a.Guid].Except(result)) - { - OnLinkRemove(a, link); - } - - Graph[a.Guid].Clear(); - - // Input new edges - foreach (var link in result) - { - Graph[a.Guid].Add(link); - OnLinkAdd(a, link); - } - } - - public static NetworkLink GetLink(ISatellite sat_a, ISatellite sat_b) + public static NetworkLink? GetLink(ISatellite sat_a, ISatellite sat_b) { if (sat_a == null || sat_b == null || sat_a == sat_b) return null; if (sat_a.IsInRadioBlackout || sat_b.IsInRadioBlackout) return null; @@ -161,48 +176,46 @@ public static NetworkLink GetLink(ISatellite sat_a, ISatellite sat_b public void OnPhysicsUpdate() { - var count = RTCore.Instance.Satellites.Count; - if (count == 0) return; - int baseline = (count / REFRESH_TICKS); - int takeCount = baseline + (((mTick++ % REFRESH_TICKS) < (count - baseline * REFRESH_TICKS)) ? 1 : 0); - IEnumerable commandStations = RTCore.Instance.Satellites.FindCommandStations(); - foreach (VesselSatellite s in RTCore.Instance.Satellites.Concat(RTCore.Instance.Satellites).Skip(mTickIndex).Take(takeCount)) - { - UpdateGraph(s); - //("{0} [ E: {1} ]", s.ToString(), Graph[s.Guid].ToDebugString()); + if (RTCore.Instance.Satellites.Count == 0) return; + if (HighLogic.LoadedScene != GameScenes.TRACKSTATION && + HighLogic.LoadedScene != GameScenes.FLIGHT && + !(HighLogic.LoadedScene == GameScenes.SPACECENTER && API.API.enabledInSPC)) + return; - // amend this optimisation due to inconsistent connectivity on non-active vessels (eg showing no connection when 3rd-party mods query) - //if (s.SignalProcessor.VesselLoaded || HighLogic.LoadedScene == GameScenes.TRACKSTATION || RTCore.Instance.Renderer.ShowMultiPath) - if (HighLogic.LoadedScene == GameScenes.TRACKSTATION || HighLogic.LoadedScene == GameScenes.FLIGHT || - (HighLogic.LoadedScene == GameScenes.SPACECENTER && API.API.enabledInSPC)) - { - FindPath(s, commandStations); - } + foreach (ISatellite sat in GroundStations.Values) + { + if (sat is MissionControlSatellite station) + station.UpdateAntennaStates(); } - mTickIndex += takeCount; - mTickIndex = mTickIndex % RTCore.Instance.Satellites.Count; + + // Complete() runs last so _current's job has had a full tick to run in the background. + current?.Dispose(); + current = next; + mConnectionCache.Clear(); + next = NetworkState.ScheduleNetworkUpdate(this, current); + current?.Complete(); + current?.FireConnectionRefresh(this); } private void OnSatelliteUnregister(ISatellite s) { RTLog.Notify("NetworkManager: SatelliteUnregister({0})", s); - Graph.Remove(s.Guid); - foreach (var list in Graph.Values) - { - list.RemoveAll(l => l.Target == s); - } + // The graph/routes are rebuilt wholesale each tick from the store, so + // we only need to drop the satellite's lazily-cached route list. The + // renderer clears its own edges via its OnSatelliteUnregister handler. mConnectionCache.Remove(s); } private void OnSatelliteRegister(ISatellite s) { RTLog.Notify("NetworkManager: SatelliteRegister({0})", s); - Graph[s.Guid] = new List>(); } public IEnumerator GetEnumerator() { - return RTCore.Instance.Satellites.Cast().Concat(GroundStations.Values).GetEnumerator(); + return RTCore.Instance.Satellites.Cast() + .Concat(GroundStations.Values.ToArray()) + .GetEnumerator(); } IEnumerator IEnumerable.GetEnumerator() @@ -210,7 +223,9 @@ IEnumerator IEnumerable.GetEnumerator() return GetEnumerator(); } - /// Gets the position of a RemoteTech target from its id + /// + /// Gets the position of a RemoteTech target from its id + /// /// The absolute position or null if is neither /// a satellite nor a celestial body. /// The id of the satellite or celestial body whose position is @@ -232,17 +247,17 @@ IEnumerator IEnumerable.GetEnumerator() } } - public sealed class MissionControlSatellite : ISatellite, IPersistenceLoad + public sealed class MissionControlSatellite : ISatellite, IConfigNode { /* Config Node parameters */ - [Persistent] private String Guid = new Guid("5105f5a9d62841c6ad4b21154e8fc488").ToString(); - [Persistent] private String Name = Localizer.Format("#RT_MissionControl");//"Mission Control" - [Persistent] private double Latitude = -0.1313315f; - [Persistent] private double Longitude = -74.59484f; - [Persistent] private double Height = 75.0f; - [Persistent] private int Body = 1; - [Persistent] private Color MarkColor = new Color(0.996078f, 0, 0, 1); - [Persistent(collectionIndex = "ANTENNA")] private MissionControlAntenna[] Antennas = { new MissionControlAntenna() }; + private String Guid = new Guid("5105f5a9d62841c6ad4b21154e8fc488").ToString(); + private String Name = Localizer.Format("#RT_MissionControl");//"Mission Control" + private double Latitude = -0.1313315f; + private double Longitude = -74.59484f; + private double Height = 75.0f; + private int Body = 1; + private Color MarkColor = new Color(0.996078f, 0, 0, 1); + private MissionControlAntenna[] Antennas = { new MissionControlAntenna() }; private bool AntennaActivated = true; @@ -257,7 +272,7 @@ public sealed class MissionControlSatellite : ISatellite, IPersistenceLoad Vessel ISatellite.parentVessel { get { return null; } } CelestialBody ISatellite.Body { get { return FlightGlobals.Bodies[Body]; } } Color ISatellite.MarkColor { get { return MarkColor; } } - IEnumerable ISatellite.Antennas { get { return Antennas; } } + IReadOnlyList ISatellite.Antennas { get { return Antennas; } } bool ISatellite.CanRelaySignal { get { return true; } } //not sure if should relay signal. Mission Control can "do" everything isnt it? public Guid mGuid { get; private set; } @@ -267,9 +282,52 @@ public sealed class MissionControlSatellite : ISatellite, IPersistenceLoad void ISatellite.OnConnectionRefresh(List> route) { } + SatelliteState ISatellite.GetState() + { + var self = (ISatellite)this; + return new SatelliteState + { + Guid = mGuid, + Body = RTUtil.Guid(self.Body), + Position = self.Position, + Powered = self.Powered, + IsCommandStation = true, + CanRelaySignal = true, + IsInRadioBlackout = IsInRadioBlackout, + }; + } + public MissionControlSatellite() { this.mGuid = new Guid(Guid); + foreach (var antenna in Antennas) + { + antenna.Parent = this; + } + } + + internal void RegisterAntennaStates() + { + foreach (var antenna in Antennas) + { + antenna.RegisterState(); + } + } + + internal void UnregisterAntennaStates() + { + foreach (var antenna in Antennas) + { + antenna.UnregisterState(); + } + } + + internal void UpdateAntennaStates() + { + foreach (var antenna in Antennas) + { + antenna.UpdateState(); + } } public void reloadUpgradeableAntennas(int techlvl = 0) @@ -314,15 +372,48 @@ public void SetBodyIndex(int index) this.Body = index; } - void IPersistenceLoad.PersistenceLoad() + public void Load(ConfigNode node) { - foreach (var antenna in Antennas) + node.TryGetValue("Guid", ref Guid); + node.TryGetValue("Name", ref Name); + node.TryGetValue("Latitude", ref Latitude); + node.TryGetValue("Longitude", ref Longitude); + node.TryGetValue("Height", ref Height); + node.TryGetValue("Body", ref Body); + node.TryGetValue("MarkColor", ref MarkColor); + + var antennas = node.GetNode("Antennas"); + if (antennas != null) { - antenna.Parent = this; + var antennaNodes = antennas.GetNodes("ANTENNA"); + Antennas = new MissionControlAntenna[antennaNodes.Length]; + for (int i = 0; i < antennaNodes.Length; i++) + { + Antennas[i] = new MissionControlAntenna { Parent = this }; + Antennas[i].Load(antennaNodes[i]); + } } + mGuid = new Guid(Guid); } + public void Save(ConfigNode node) + { + node.AddValue("Guid", Guid); + node.AddValue("Name", Name); + node.AddValue("Latitude", Latitude); + node.AddValue("Longitude", Longitude); + node.AddValue("Height", Height); + node.AddValue("Body", Body); + node.AddValue("MarkColor", MarkColor); + + var antennas = node.AddNode("Antennas"); + foreach (var antenna in Antennas) + { + antenna.Save(antennas.AddNode("ANTENNA")); + } + } + public override String ToString() { return Name; diff --git a/src/RemoteTech/NetworkPathfinder.cs b/src/RemoteTech/NetworkPathfinder.cs deleted file mode 100644 index 9fd37cc74..000000000 --- a/src/RemoteTech/NetworkPathfinder.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System; -using System.Collections.Generic; -using RemoteTech.SimpleTypes; - -namespace RemoteTech -{ - public static class NetworkPathfinder - { - // All sorting related data is immutable. - - public static NetworkRoute Solve(T start, T goal, - Func>> neighborsFunction, - Func, double> costFunction, - Func heuristicFunction) where T : class - { - var nodeMap = new Dictionary>>(); - var priorityQueue = new PriorityQueue>>(); - - var nStart = new Node>(new NetworkLink(start, null, LinkType.None), 0, heuristicFunction.Invoke(start, goal), null, false); - nodeMap[start] = nStart; - priorityQueue.Enqueue(nStart); - - while (priorityQueue.Count > 0) - { - var current = priorityQueue.Dequeue(); - if (current.Closed) continue; - current.Closed = true; - if (current.Item.Target.Equals(goal)) - { - // Return path and cost - var reversePath = new List>(); - for (var node = current; node.From != null; node = node.From) - { - reversePath.Add(node.Item); - } - reversePath.Reverse(); - return new NetworkRoute(start, reversePath, current.Cost); - } - - foreach (var link in neighborsFunction.Invoke(current.Item.Target)) - { - double new_cost = current.Cost + costFunction.Invoke(current.Item.Target, link); - - // Todo: Dennis: i use the fix from Taste83 #139 and i'll look closer if i'm more related to the Pathfinder - if (new_cost == current.Cost) - { - continue; - } - - // If the item has a node, it will either be in the closedSet, or the openSet - if (nodeMap.ContainsKey(link.Target)) - { - Node> n = nodeMap[link.Target]; - if (new_cost <= n.Cost) - { - // Cost via current is better than the old one, discard old node, queue new one. - var new_node = new Node>(n.Item, new_cost, n.Heuristic, current, false); - n.Closed = true; - nodeMap[link.Target] = new_node; - priorityQueue.Enqueue(new_node); - } - } - else - { - // It is not in the openSet, create a node and add it - var new_node = new Node>(link, new_cost, - heuristicFunction.Invoke(link.Target, goal), current, - false); - priorityQueue.Enqueue(new_node); - nodeMap[link.Target] = new_node; - } - } - } - return NetworkRoute.Empty(start); - } - - private class Node : IComparable> - { - public Node From { get; set; } - public bool Closed { get; set; } - - public int CompareTo(Node node) - { - return (Cost + Heuristic).CompareTo(node.Cost + node.Heuristic); - } - - public readonly double Cost; - public readonly double Heuristic; - public readonly T Item; - - public Node(T item, double cost, double heuristic, Node from, bool closed) - { - Item = item; - Cost = cost; - Heuristic = heuristic; - From = from; - Closed = closed; - } - } - } -} \ No newline at end of file diff --git a/src/RemoteTech/NetworkRenderer.cs b/src/RemoteTech/NetworkRenderer.cs index 0d7939ec8..485c79c79 100644 --- a/src/RemoteTech/NetworkRenderer.cs +++ b/src/RemoteTech/NetworkRenderer.cs @@ -1,394 +1,389 @@ using System; -using System.Linq; -using System.Collections.Generic; +using RemoteTech.Collections; +using RemoteTech.Network; using RemoteTech.SimpleTypes; -using RemoteTech.UI; +using Unity.Jobs; +using Unity.Mathematics; using UnityEngine; +using UnityEngine.Rendering; -using Debug = System.Diagnostics.Debug; using KSP.Localization; +using System.Text; -namespace RemoteTech +namespace RemoteTech; + +[Flags] +public enum MapFilter +{ + None = 0, + Omni = 1, + Dish = 2, + Sphere = 4, + Cone = 8, + Planet = 8, // For backward compatibility with RemoteTech 1.4 and earlier + // Cone should be first, so that it's the one that appears in settings file + Path = 16, + MultiPath = 32 +} + +/// +/// RemoteTech UI network render in charre of drawing connection links in tracking station or flight map scenes. +/// +// ScaledSpace.LateUpdate runs at execution order 9000, we need to run after that. +[DefaultExecutionOrder(30000)] +public class NetworkRenderer : MonoBehaviour { - [Flags] - public enum MapFilter + public MapFilter Filter { - None = 0, - Omni = 1, - Dish = 2, - Sphere = 4, - Cone = 8, - Planet = 8, // For backward compatibility with RemoteTech 1.4 and earlier - // Cone should be first, so that it's the one that appears in settings file - Path = 16, - MultiPath = 32 + get => RTSettings.Instance.MapFilter; + set + { + RTSettings.Instance.MapFilter = value; + RTSettings.Instance.Save(); + } } - /// - /// RemoteTech UI network render in charre of drawing connection links in tracking station or flight map scenes. - /// - public class NetworkRenderer : MonoBehaviour - { - public MapFilter Filter { - get - { - return RTSettings.Instance.MapFilter; - } - set - { - RTSettings.Instance.MapFilter = value; - RTSettings.Instance.Save(); - } - } + private static readonly Texture2D mTexMark; + private static float mLineWidth = 1f; - private static readonly Texture2D mTexMark; - private readonly HashSet> mEdges = new HashSet>(); - private readonly List mLines = new List(); - private readonly List mCones = new List(); - private static float mLineWidth = 1f; + // Connection lines and dish cones are each one dynamic mesh, built by a job + // chain in LateUpdate and drawn with a single Graphics.DrawMesh on the + // scaled-space camera in OnPreCull. + private static Material mLineMaterial; + private VertexAttributeDescriptor[] mVertexLayout; + private LineMesh mLine; + private ConeMesh mCone; - public bool ShowOmni { get { return (Filter & MapFilter.Omni) == MapFilter.Omni; } } - public bool ShowDish { get { return (Filter & MapFilter.Dish) == MapFilter.Dish; } } - public bool ShowPath { get { return (Filter & MapFilter.Path) == MapFilter.Path; } } - public bool ShowMultiPath { get { return (Filter & MapFilter.MultiPath) == MapFilter.MultiPath; } } - public bool ShowRange { get { return (Filter & MapFilter.Sphere) == MapFilter.Sphere; } } - public bool ShowCone { get { return (Filter & MapFilter.Cone) == MapFilter.Cone; } } + // Satellite marks are filtered and projected by a job in LateUpdate and drawn + // as GUI textures in OnGUI. + private SatelliteMarks mMarks; - public GUIStyle smallStationText; - public GUIStyle smallStationHead; + public bool ShowOmni { get { return (Filter & MapFilter.Omni) == MapFilter.Omni; } } + public bool ShowDish { get { return (Filter & MapFilter.Dish) == MapFilter.Dish; } } + public bool ShowPath { get { return (Filter & MapFilter.Path) == MapFilter.Path; } } + public bool ShowMultiPath { get { return (Filter & MapFilter.MultiPath) == MapFilter.MultiPath; } } + public bool ShowRange { get { return (Filter & MapFilter.Sphere) == MapFilter.Sphere; } } + public bool ShowCone { get { return (Filter & MapFilter.Cone) == MapFilter.Cone; } } - static NetworkRenderer() - { - RTUtil.LoadImage(out mTexMark, "mark"); + public GUIStyle smallStationText; + public GUIStyle smallStationHead; - if(Versioning.version_major == 1) - { - switch(Versioning.version_minor) - { - case 4: - mLineWidth = 1f; //1f is matching to CommNet's line width - break; - default: - mLineWidth = 3f; - break; - } - } - } + static NetworkRenderer() + { + RTUtil.LoadImage(out mTexMark, "mark"); - public static NetworkRenderer CreateAndAttach() + if(Versioning.version_major == 1) { - var renderer = MapView.MapCamera.gameObject.GetComponent(); - if (renderer) + switch(Versioning.version_minor) { - Destroy(renderer); + case 4: + mLineWidth = 1f; //1f is matching to CommNet's line width + break; + default: + mLineWidth = 3f; + break; } - - renderer = MapView.MapCamera.gameObject.AddComponent(); - RTCore.Instance.Network.OnLinkAdd += renderer.OnLinkAdd; - RTCore.Instance.Network.OnLinkRemove += renderer.OnLinkRemove; - RTCore.Instance.Satellites.OnUnregister += renderer.OnSatelliteUnregister; - - renderer.smallStationHead = new GUIStyle(HighLogic.Skin.label) - { - fontSize = 12 - }; - - renderer.smallStationText = new GUIStyle(HighLogic.Skin.label) - { - fontSize = 10, - normal = { textColor = Color.white } - }; - - return renderer; } + } - public void OnPreCull() + public static NetworkRenderer CreateAndAttach() + { + var renderer = MapView.MapCamera.gameObject.GetComponent(); + if (renderer) { - if (MapView.MapIsEnabled) - { - UpdateNetworkEdges(); - UpdateNetworkCones(); - } + Destroy(renderer); } - public void OnGUI() + renderer = MapView.MapCamera.gameObject.AddComponent(); + + renderer.smallStationHead = new GUIStyle(HighLogic.Skin.label) { - if (Event.current.type == EventType.Repaint && MapView.MapIsEnabled) - { - foreach (ISatellite s in RTCore.Instance.Satellites.FindCommandStations().Concat(RTCore.Instance.Network.GroundStations.Values)) - { - bool showOnMapview = true; - var worldPos = ScaledSpace.LocalToScaledSpace(s.Position); - if (MapView.MapCamera.transform.InverseTransformPoint(worldPos).z < 0f) continue; - Vector3 pos = PlanetariumCamera.Camera.WorldToScreenPoint(worldPos); - var screenRect = new Rect((pos.x - 8), (Screen.height - pos.y) - 8, 16, 16); - - // Hide the current ISatellite if it is behind its body - if (RTSettings.Instance.HideGroundStationsBehindBody && IsOccluded(s.Position, s.Body)) - showOnMapview = false; - - if (RTSettings.Instance.HideGroundStationsOnDistance && !IsOccluded(s.Position, s.Body) && this.IsCamDistanceToWide(s.Position)) - showOnMapview = false; - - // orbiting remote stations are always shown - if(s.isVessel && !s.parentVessel.Landed) - showOnMapview = true; - - if (showOnMapview) - { - Color pushColor = GUI.color; - // tint the white mark.png into the defined color - GUI.color = s.MarkColor; - // draw the mark.png - GUI.DrawTexture(screenRect, mTexMark, ScaleMode.ScaleToFit, true); - GUI.color = pushColor; - - // Show Mouse over informations to the ground station - if (RTSettings.Instance.ShowMouseOverInfoGroundStations && s is MissionControlSatellite && screenRect.ContainsMouse()) - { - Rect headline = screenRect; - Vector2 nameDim = this.smallStationHead.CalcSize(new GUIContent(s.Name)); - - headline.x -= nameDim.x + 10; - headline.y -= 3; - headline.width = nameDim.x; - headline.height = 14; - // draw headline of the station - GUI.Label(headline, s.Name, this.smallStationHead); - - // loop antennas - String antennaRanges = String.Empty; - foreach (var antenna in s.Antennas) - { - if(antenna.Omni > 0) - { - antennaRanges += Localizer.Format("#RT_NetworkFB_Omni") + RTUtil.FormatSI(antenna.Omni,"m") + Environment.NewLine;//"Omni: " - } - if (antenna.Dish > 0) - { - antennaRanges += Localizer.Format("#RT_NetworkFB_Dish") + RTUtil.FormatSI(antenna.Dish, "m") + Environment.NewLine;//"Dish: " - } - } - - if(!antennaRanges.Equals(String.Empty)) - { - Rect antennas = screenRect; - GUIContent content = new GUIContent(antennaRanges); - - Vector2 antennaDim = this.smallStationText.CalcSize(content); - float maxHeight = this.smallStationText.CalcHeight(content, antennaDim.x); - - antennas.y += headline.height - 3; - antennas.x -= antennaDim.x + 10; - antennas.width = antennaDim.x; - antennas.height = maxHeight; - - // draw antenna infos of the station - GUI.Label(antennas, antennaRanges, this.smallStationText); - } - } - } - } - } - } + fontSize = 12 + }; - /// - /// Checks whether the location is behind the body - /// Original code by regex from https://github.com/NathanKell/RealSolarSystem/blob/master/Source/KSCSwitcher.cs - /// - private bool IsOccluded(Vector3d loc, CelestialBody body) + renderer.smallStationText = new GUIStyle(HighLogic.Skin.label) { - Vector3d camPos = ScaledSpace.ScaledToLocalSpace(PlanetariumCamera.Camera.transform.position); + fontSize = 10, + normal = { textColor = Color.white } + }; - if (Vector3d.Angle(camPos - loc, body.position - loc) > 90) { return false; } - return true; - } + return renderer; + } - /// - /// Calculates the distance between the camera position and the ground station, and - /// returns true if the distance is >= DistanceToHideGroundStations from the settings file. - /// - /// Position of the ground station - /// True if the distance is to wide, otherwise false - private bool IsCamDistanceToWide(Vector3d loc) + public void LateUpdate() + { + // Defensive: a prior frame scheduled but OnPreCull/OnGUI never harvested it. + mLine.Drop(); + mCone.Drop(); + mMarks.Drop(); + + if (!MapView.MapIsEnabled && HighLogic.LoadedScene != GameScenes.TRACKSTATION) return; + + // Next, not Current, to avoid a full physics tick of lag. + var state = RTCore.Instance.Network.Next; + if (state == null) return; + state.Complete(); + + EnsureMeshes(); + + if (state.NodeCount >= 2 && state.PairCount >= 1) { - Vector3d camPos = ScaledSpace.ScaledToLocalSpace(PlanetariumCamera.Camera.transform.position); - float distance = Vector3.Distance(camPos, loc); - - // distance to wide? - if(distance >= RTSettings.Instance.DistanceToHideGroundStations) - return true; - - return false; + var p = CaptureDrawParams(state, state.NodeCount, state.PairCount); + var view = CaptureViewParams(); + mLine.SetFrame(state.ScheduleLineMesh(p, view)); } - private void UpdateNetworkCones() + if (MapView.MapIsEnabled && ShowCone && state.ConeCandidateCount > 0) { - List antennas = (ShowCone ? RTCore.Instance.Antennas.Where( - ant => ant.Powered && ant.CanTarget && RTCore.Instance.Satellites[ant.Guid] != null - && ant.Target != Guid.Empty) - : Enumerable.Empty()).ToList(); - int oldLength = mCones.Count; - int newLength = antennas.Count; - - // Free any unused lines - for (int i = newLength; i < oldLength; i++) - { - GameObject.Destroy(mCones[i]); - mCones[i] = null; - } - mCones.RemoveRange(Math.Min(oldLength, newLength), Math.Max(oldLength - newLength, 0)); - mCones.AddRange(Enumerable.Repeat((NetworkCone) null, Math.Max(newLength - oldLength, 0))); - - for (int i = 0; i < newLength; i++) - { - var center = RTCore.Instance.Network.GetPositionFromGuid(antennas[i].Target); - Debug.Assert(center != null, - "center != null", - String.Format("GetPositionFromGuid returned a null value for the target {0}", - antennas[i].Target) - ); - - if (!center.HasValue) continue; - - mCones[i] = mCones[i] ?? NetworkCone.Instantiate(); - mCones[i].LineWidth = mLineWidth; - mCones[i].Antenna = antennas[i]; - mCones[i].Color = Color.gray; - mCones[i].Active = ShowCone; - mCones[i].Center = center.Value; - } + var cp = CaptureConeViewParams(); + mCone.SetFrame(state.ScheduleConeMesh(cp)); } - private void UpdateNetworkEdges() - { - var edges = mEdges.Where(CheckVisibility).ToList(); - int oldLength = mLines.Count; - int newLength = edges.Count; + if (MapView.MapIsEnabled && state.MarkCandidateCount > 0) + mMarks.SetFrame(state.ScheduleSatelliteMarks(CaptureMarkViewParams()), state); - // Free any unused lines - for (int i = newLength; i < oldLength; i++) - { - Destroy(mLines[i]); - mLines[i] = null; - } - mLines.RemoveRange(Math.Min(oldLength, newLength), Math.Max(oldLength - newLength, 0)); - mLines.AddRange(Enumerable.Repeat(null, Math.Max(newLength - oldLength, 0))); + JobHandle.ScheduleBatchedJobs(); + } - // Iterate over all satellites, updating or creating new lines. - var it = edges.GetEnumerator(); - for (int i = 0; i < newLength; i++) + public void OnPreCull() + { + if (mLine.hasFrame) + { + if (MapView.MapIsEnabled || HighLogic.LoadedScene == GameScenes.TRACKSTATION) { - it.MoveNext(); - mLines[i] = mLines[i] ?? NetworkLine.Instantiate(); - mLines[i].LineWidth = mLineWidth; - mLines[i].Edge = it.Current; - mLines[i].Color = CheckColor(it.Current); - mLines[i].Active = true; + float3 delta = (float3)(mLine.frame.builtOffset - CurrentTotalOffset()); + mLine.CompleteAndDraw(mVertexLayout, mLineMaterial, PlanetariumCamera.Camera, delta); } + else + mLine.Drop(); } - private bool CheckVisibility(BidirectionalEdge edge) + if (mCone.hasFrame) { - var vessel = PlanetariumCamera.fetch.target.vessel; - var satellite = RTCore.Instance.Satellites[vessel]; - if (satellite != null && ShowPath) - { - var connections = RTCore.Instance.Network[satellite]; - if (connections.Any() && connections[0].Contains(edge)) - return true; - } - if (ShowMultiPath && edge.A.Visible && edge.B.Visible) // purpose of edge-visibility condition is to prevent unnecessary performance off-screen + if (MapView.MapIsEnabled) { - var satellites = RTCore.Instance.Network.ToArray(); - for (int i = 0; i < satellites.Length; i++) - { - var connections = RTCore.Instance.Network[satellites[i]]; // get the working-connection path of every satellite - if (connections.Any() && connections[0].Contains(edge)) - return true; - } + float3 delta = (float3)(mCone.frame.builtOffset - CurrentTotalOffset()); + mCone.CompleteAndDraw(mVertexLayout, mLineMaterial, PlanetariumCamera.Camera, delta); } - if (edge.Type == LinkType.Omni && !ShowOmni) - return false; - if (edge.Type == LinkType.Dish && !ShowDish) - return false; - if (!edge.A.Visible || !edge.B.Visible) - return false; - return true; + else + mCone.Drop(); } + } - private Color CheckColor(BidirectionalEdge edge) - { - var vessel = PlanetariumCamera.fetch.target.vessel; - var satellite = RTCore.Instance.Satellites[vessel]; - if (satellite != null && ShowPath) - { - var connections = RTCore.Instance.Network[satellite]; - if (connections.Any() && connections[0].Contains(edge)) - return RTSettings.Instance.ActiveConnectionColor; - } - if (ShowMultiPath && edge.A.Visible && edge.B.Visible) // purpose of edge-visibility condition is to prevent unnecessary performance off-screen - { - var satellites = RTCore.Instance.Network.ToArray(); - for (int i = 0; i < satellites.Length; i++) - { - var connections = RTCore.Instance.Network[satellites[i]]; // get the working-connection path of every satellite - if (connections.Any() && connections[0].Contains(edge)) - return RTSettings.Instance.ActiveConnectionColor; - } - } + private void EnsureMeshes() + { + if (mLineMaterial == null) + mLineMaterial = Resources.Load("Telemetry/TelemetryMaterial"); + mLine.Ensure(); + mCone.Ensure(); + mVertexLayout ??= + [ + new VertexAttributeDescriptor(VertexAttribute.Position, VertexAttributeFormat.Float32, 3, 0), + new VertexAttributeDescriptor(VertexAttribute.Color, VertexAttributeFormat.UNorm8, 4, 0), + new VertexAttributeDescriptor(VertexAttribute.TexCoord0, VertexAttributeFormat.Float32, 2, 0), + ]; + } - if (RTSettings.Instance.SignalRelayEnabled) - { - var satA = RTCore.Instance.Satellites[edge.A.Guid]; - var satB = RTCore.Instance.Satellites[edge.B.Guid]; - if ((satA != null && !satA.CanRelaySignal) || (satB != null && !satB.CanRelaySignal)) - return RTSettings.Instance.DirectConnectionColor; - } + private DrawEdgeParams CaptureDrawParams(NetworkState state, int nodeCount, int pairCount) + { + var settings = RTSettings.Instance; + Vessel target = PlanetariumCamera.fetch != null && PlanetariumCamera.fetch.target != null + ? PlanetariumCamera.fetch.target.vessel : null; + ISatellite targetSat = target != null ? RTCore.Instance.Satellites[target] : null; + int targetNode = ShowPath && targetSat != null && state.TryGetNode(targetSat, out int tn) ? tn : -1; - if (edge.Type == LinkType.Omni) - return RTSettings.Instance.OmniConnectionColor; - if (edge.Type == LinkType.Dish) - return RTSettings.Instance.DishConnectionColor; + return new DrawEdgeParams + { + targetNode = targetNode, + nodeCount = nodeCount, + pairCount = pairCount, + showOmni = (byte)(ShowOmni ? 1 : 0), + showDish = (byte)(ShowDish ? 1 : 0), + showPath = (byte)(ShowPath ? 1 : 0), + showMultiPath = (byte)(ShowMultiPath ? 1 : 0), + signalRelay = (byte)(settings.SignalRelayEnabled ? 1 : 0), + active = settings.ActiveConnectionColor, + direct = settings.DirectConnectionColor, + omni = settings.OmniConnectionColor, + dish = settings.DishConnectionColor, + grey = (Color32)XKCDColors.Grey, + }; + } - return XKCDColors.Grey; - } + // LocalToScaledSpace(p) = p*InverseScaleFactor - totalOffset (the exact float scale KSP uses). + private static double3 CurrentTotalOffset() + { + Vector3d originScaled = ScaledSpace.LocalToScaledSpace(Vector3d.zero); // == -totalOffset + return new double3(-originScaled.x, -originScaled.y, -originScaled.z); + } - private void OnSatelliteUnregister(ISatellite s) + private LineMeshViewParams CaptureViewParams() + { + Camera cam = PlanetariumCamera.Camera; + return new LineMeshViewParams { - mEdges.RemoveWhere(e => e.A == s || e.B == s); - } + invScale = ScaledSpace.InverseScaleFactor, + totalOffset = CurrentTotalOffset(), + view = ToFloat4x4(cam.worldToCameraMatrix), + proj = ToFloat4x4(cam.projectionMatrix), + invView = ToFloat4x4(cam.worldToCameraMatrix.inverse), + invProj = ToFloat4x4(cam.projectionMatrix.inverse), + pixelWidth = cam.pixelWidth, + pixelHeight = cam.pixelHeight, + halfWidth = mLineWidth * 0.5f, + mode = MapView.Draw3DLines ? 0 : 1, + }; + } + + private ConeViewParams CaptureConeViewParams() + { + Camera cam = PlanetariumCamera.Camera; + CelestialBody refFrame = MapView.MapCamera.target.vessel != null + ? MapView.MapCamera.target.vessel.mainBody + : MapView.MapCamera.target.celestialBody; + Vector3 up = refFrame != null ? refFrame.transform.up : Vector3.up; - private void OnLinkAdd(ISatellite a, NetworkLink link) + return new ConeViewParams { - mEdges.Add(new BidirectionalEdge(a, link.Target, link.Port)); - } + invScale = ScaledSpace.InverseScaleFactor, + totalOffset = CurrentTotalOffset(), + refUp = up, + view = ToFloat4x4(cam.worldToCameraMatrix), + proj = ToFloat4x4(cam.projectionMatrix), + invView = ToFloat4x4(cam.worldToCameraMatrix.inverse), + invProj = ToFloat4x4(cam.projectionMatrix.inverse), + pixelWidth = cam.pixelWidth, + pixelHeight = cam.pixelHeight, + halfWidth = mLineWidth * 0.5f, + mode = MapView.Draw3DLines ? 0 : 1, + color = Color.gray, + }; + } - private void OnLinkRemove(ISatellite a, NetworkLink link) + private SatelliteMarkViewParams CaptureMarkViewParams() + { + Camera cam = PlanetariumCamera.Camera; + var settings = RTSettings.Instance; + Vector3d camLocal = ScaledSpace.ScaledToLocalSpace(cam.transform.position); + + return new SatelliteMarkViewParams { - mEdges.Remove(new BidirectionalEdge(a, link.Target, link.Port)); - } + invScale = ScaledSpace.InverseScaleFactor, + totalOffset = CurrentTotalOffset(), + camPosLocal = new double3(camLocal.x, camLocal.y, camLocal.z), + view = ToFloat4x4(cam.worldToCameraMatrix), + proj = ToFloat4x4(cam.projectionMatrix), + pixelWidth = cam.pixelWidth, + pixelHeight = cam.pixelHeight, + screenHeight = Screen.height, + hideBehindBody = settings.HideGroundStationsBehindBody, + hideOnDistance = settings.HideGroundStationsOnDistance, + distanceThreshold = settings.DistanceToHideGroundStations, + }; + } + + private static float4x4 ToFloat4x4(Matrix4x4 m) => new float4x4( + m.m00, m.m01, m.m02, m.m03, + m.m10, m.m11, m.m12, m.m13, + m.m20, m.m21, m.m22, m.m23, + m.m30, m.m31, m.m32, m.m33); + + public void OnGUI() + { + if (Event.current.type != EventType.Repaint || !MapView.MapIsEnabled || !mMarks.hasFrame) + return; + + var marks = mMarks.Complete(); + for (int i = 0; i < marks.Length; i++) + DrawSatelliteMark(marks[i]); + } - public void Detach() + private static readonly string NetworkFBOmni = Localizer.Format("#RT_NetworkFB_Omni"); + private static readonly string NetworkFBDish = Localizer.Format("#RT_NetworkFB_Dish"); + private void DrawSatelliteMark(in SatelliteMark mark) + { + var screenRect = new Rect(mark.screenPos.x - 8, mark.screenPos.y - 8, 16, 16); + + Color pushColor = GUI.color; + // tint the white mark.png into the defined color + GUI.color = mark.color; + // draw the mark.png + GUI.DrawTexture(screenRect, mTexMark, ScaleMode.ScaleToFit, true); + GUI.color = pushColor; + + if (!RTSettings.Instance.ShowMouseOverInfoGroundStations) + return; + + ISatellite s = mMarks.state.SatAt(mark.nodeIndex); + if (s is not MissionControlSatellite || !screenRect.ContainsMouse()) + return; + + // Show Mouse over informations to the ground station + Rect headline = screenRect; + Vector2 nameDim = this.smallStationHead.CalcSize(new GUIContent(s.Name)); + + headline.x -= nameDim.x + 10; + headline.y -= 3; + headline.width = nameDim.x; + headline.height = 14; + // draw headline of the station + GUI.Label(headline, s.Name, this.smallStationHead); + + // loop antennas + var satelliteMarkBuilder = StringBuilderCache.Acquire(); + foreach (var antenna in s.Antennas) { - for (int i = 0; i < mLines.Count; i++) + if(antenna.Omni > 0) { - GameObject.DestroyImmediate(mLines[i]); + // Omni: + satelliteMarkBuilder.AppendFormat( + "{0}{1}{2}", + NetworkFBOmni, + RTUtil.FormatSI(antenna.Omni, "m"), + Environment.NewLine + ); } - mLines.Clear(); - for (int i = 0; i < mCones.Count; i++) + + if (antenna.Dish > 0) { - GameObject.DestroyImmediate(mCones[i]); + // Dish: = + satelliteMarkBuilder.AppendFormat( + "{0}{1}{2}", + NetworkFBDish, + RTUtil.FormatSI(antenna.Dish, "m"), + Environment.NewLine + ); } - mCones.Clear(); - DestroyImmediate(this); } - public void OnDestroy() - { - RTCore.Instance.Network.OnLinkAdd -= OnLinkAdd; - RTCore.Instance.Network.OnLinkRemove -= OnLinkRemove; - RTCore.Instance.Satellites.OnUnregister -= OnSatelliteUnregister; - } + var antennaRanges = satelliteMarkBuilder.ToStringAndRelease(); + if (string.IsNullOrEmpty(antennaRanges)) + return; + + Rect antennas = screenRect; + var content = new GUIContent(antennaRanges); + + Vector2 antennaDim = smallStationText.CalcSize(content); + float maxHeight = smallStationText.CalcHeight(content, antennaDim.x); + + antennas.y += headline.height - 3; + antennas.x -= antennaDim.x + 10; + antennas.width = antennaDim.x; + antennas.height = maxHeight; + + // draw antenna infos of the station + GUI.Label(antennas, antennaRanges, smallStationText); + } + + public void Detach() + { + Destroy(this); + } + + public void OnDestroy() + { + mLine.Destroy(); + mCone.Destroy(); + mMarks.Destroy(); } } \ No newline at end of file diff --git a/src/RemoteTech/RTDebugUnit.cs b/src/RemoteTech/RTDebugUnit.cs index b39040c5d..1c19d55e4 100644 --- a/src/RemoteTech/RTDebugUnit.cs +++ b/src/RemoteTech/RTDebugUnit.cs @@ -70,7 +70,7 @@ public string[] DumpEdges() { var data = new List {"NetworkManager.Graph contents: "}; var i = 0; - foreach (var edge in RTCore.Instance.Network.Graph) + foreach (var edge in RTCore.Instance.Network.EnumerateLinks()) { data.Add($" {i++}: {edge.Key}"); var j = 0; diff --git a/src/RemoteTech/RTLog.cs b/src/RemoteTech/RTLog.cs index 74a4db2ca..0d7a380cf 100644 --- a/src/RemoteTech/RTLog.cs +++ b/src/RemoteTech/RTLog.cs @@ -20,9 +20,13 @@ public enum RTLogLevel public static class RTLog { - /// On true the verbose-Methods will notify their messages + /// + /// On true the verbose-Methods will notify their messages + /// private static readonly bool verboseLogging; - /// debug log list + /// + /// debug log list + /// public static readonly Dictionary> RTLogList = new Dictionary>(); static RTLog() diff --git a/src/RemoteTech/RTSettings.cs b/src/RemoteTech/RTSettings.cs index bf55f8ab9..d1293a2a8 100644 --- a/src/RemoteTech/RTSettings.cs +++ b/src/RemoteTech/RTSettings.cs @@ -36,63 +36,107 @@ public static void ReloadSettings(Settings previousSettings, string presetCfgUrl } } - public class Settings + public class Settings : IConfigNode { // Global settings of the RemoteTech add-on, whose default values are to be read from Default_Settings.cfg // Note: do not rename any of those fields here except if you change the name in the configuration file; be careful though: this will render all previous saves incompatible!!! - [Persistent] public bool RemoteTechEnabled; - [Persistent] public bool CommNetEnabled; - [Persistent] public float ConsumptionMultiplier; - [Persistent] public float RangeMultiplier; - [Persistent] public float MissionControlRangeMultiplier; - [Persistent] public double OmniRangeClampFactor; - [Persistent] public double DishRangeClampFactor; - [Persistent] public string ActiveVesselGuid; - [Persistent] public string NoTargetGuid; - [Persistent] public float SpeedOfLight; - [Persistent] public MapFilter MapFilter; - [Persistent] public bool EnableSignalDelay; - [Persistent] public RangeModel.RangeModel RangeModelType; - [Persistent] public double MultipleAntennaMultiplier; - [Persistent] public bool ThrottleTimeWarp; - [Persistent] public bool ThrottleZeroOnNoConnection; - [Persistent] public bool StopTimeWrapOnReConnection; - [Persistent] public bool HideGroundStationsBehindBody; - [Persistent] public bool ControlAntennaWithoutConnection; - [Persistent] public bool UpgradeableMissionControlAntennas; - [Persistent] public bool HideGroundStationsOnDistance; - [Persistent] public bool ShowMouseOverInfoGroundStations; - [Persistent] public bool AutoInsertKaCAlerts; - [Persistent] public int FCLeadTime; - [Persistent] public bool FCOffAfterExecute; - [Persistent] public float DistanceToHideGroundStations; - [Persistent] public Color DishConnectionColor; - [Persistent] public Color OmniConnectionColor; - [Persistent] public Color ActiveConnectionColor; - [Persistent] public Color RemoteStationColorDot; - [Persistent] public Color DirectConnectionColor; - [Persistent] public bool SignalRelayEnabled; - [Persistent] public bool IgnoreLineOfSight; - [Persistent] public float FCWinPosX; - [Persistent] public float FCWinPosY; - [Persistent] public double FlightTermP; - [Persistent] public double FlightTermI; - [Persistent] public double FlightTermD; - [Persistent(collectionIndex = "STATION")] public List GroundStations; - [Persistent(collectionIndex = "PRESETS")] public List PreSets; + public bool RemoteTechEnabled; + public bool CommNetEnabled; + public float ConsumptionMultiplier; + public float RangeMultiplier; + public float MissionControlRangeMultiplier; + public double OmniRangeClampFactor; + public double DishRangeClampFactor; + public string ActiveVesselGuid; + public string NoTargetGuid; + public float SpeedOfLight; + public MapFilter MapFilter; + public bool EnableSignalDelay; + public RangeModel.RangeModel RangeModelType; + public double MultipleAntennaMultiplier; + public bool ThrottleTimeWarp; + public bool ThrottleZeroOnNoConnection; + public bool StopTimeWrapOnReConnection; + public bool HideGroundStationsBehindBody; + public bool ControlAntennaWithoutConnection; + public bool UpgradeableMissionControlAntennas; + public bool HideGroundStationsOnDistance; + public bool ShowMouseOverInfoGroundStations; + public bool AutoInsertKaCAlerts; + public int FCLeadTime; + public bool FCOffAfterExecute; + public float DistanceToHideGroundStations; + public Color DishConnectionColor; + public Color OmniConnectionColor; + public Color ActiveConnectionColor; + public Color RemoteStationColorDot; + public Color DirectConnectionColor; + public bool SignalRelayEnabled; + public bool IgnoreLineOfSight; + public float FCWinPosX; + public float FCWinPosY; + public double FlightTermP; + public double FlightTermI; + public double FlightTermD; + public List GroundStations = new List(); + public List PreSets = new List(); public const string SaveFileName = "RemoteTech_Settings.cfg"; public static readonly string DefaultSettingCfgURL = AssemblyLoader.loadedAssemblies.FirstOrDefault(a => a.assembly.GetName().Name.Equals("RemoteTech")).url.Replace("/Plugins", "") + "/Default_Settings/RemoteTechSettings"; - /// Trigger to force a reloading of the settings if a selected save is running. + /// + /// Trigger to force a reloading of the settings if a selected save is running. + /// public bool SettingsLoaded; - /// True if its the first start of RemoteTech for this save, false otherwise. + /// + /// True if its the first start of RemoteTech for this save, false otherwise. + /// public bool FirstStart; - /// Temp Variable for all the Window Positions for each instance. + /// + /// Temp Variable for all the Window Positions for each instance. + /// public Dictionary SavedWindowPositions = new Dictionary(); + private string _activeVesselGuidCacheSource; + private Guid _activeVesselGuidCache; + + /// + /// Cached parse of , re-parsed only when the string changes. + /// + public Guid ActiveVesselGuidParsed + { + get + { + if (_activeVesselGuidCacheSource != ActiveVesselGuid) + { + _activeVesselGuidCacheSource = ActiveVesselGuid; + _activeVesselGuidCache = new Guid(ActiveVesselGuid); + } + return _activeVesselGuidCache; + } + } + + private string _noTargetGuidCacheSource; + private Guid _noTargetGuidCache; + + /// + /// Cached parse of , re-parsed only when the string changes. + /// + public Guid NoTargetGuidParsed + { + get + { + if (_noTargetGuidCacheSource != NoTargetGuid) + { + _noTargetGuidCacheSource = NoTargetGuid; + _noTargetGuidCache = new Guid(NoTargetGuid); + } + return _noTargetGuidCache; + } + } + /// /// Returns the current RemoteTech_Settings of an existing save full path. The path will be empty /// if no save is loaded or the game is a training mission @@ -120,7 +164,7 @@ public void Save() return; var details = new ConfigNode("RemoteTechSettings"); - ConfigNode.CreateConfigFromObject(this, 0, details); + Save(details); var save = new ConfigNode(); save.AddNode(details); save.Save(SaveSettingFile); @@ -133,6 +177,136 @@ public void Save() } } + public void Load(ConfigNode node) + { + node.TryGetValue("RemoteTechEnabled", ref RemoteTechEnabled); + node.TryGetValue("CommNetEnabled", ref CommNetEnabled); + node.TryGetValue("ConsumptionMultiplier", ref ConsumptionMultiplier); + node.TryGetValue("RangeMultiplier", ref RangeMultiplier); + node.TryGetValue("MissionControlRangeMultiplier", ref MissionControlRangeMultiplier); + node.TryGetValue("OmniRangeClampFactor", ref OmniRangeClampFactor); + node.TryGetValue("DishRangeClampFactor", ref DishRangeClampFactor); + node.TryGetValue("ActiveVesselGuid", ref ActiveVesselGuid); + node.TryGetValue("NoTargetGuid", ref NoTargetGuid); + node.TryGetValue("SpeedOfLight", ref SpeedOfLight); + node.TryGetEnum("MapFilter", ref MapFilter, MapFilter); + node.TryGetValue("EnableSignalDelay", ref EnableSignalDelay); + node.TryGetEnum("RangeModelType", ref RangeModelType, RangeModelType); + node.TryGetValue("MultipleAntennaMultiplier", ref MultipleAntennaMultiplier); + node.TryGetValue("ThrottleTimeWarp", ref ThrottleTimeWarp); + node.TryGetValue("ThrottleZeroOnNoConnection", ref ThrottleZeroOnNoConnection); + node.TryGetValue("StopTimeWrapOnReConnection", ref StopTimeWrapOnReConnection); + node.TryGetValue("HideGroundStationsBehindBody", ref HideGroundStationsBehindBody); + node.TryGetValue("ControlAntennaWithoutConnection", ref ControlAntennaWithoutConnection); + node.TryGetValue("UpgradeableMissionControlAntennas", ref UpgradeableMissionControlAntennas); + node.TryGetValue("HideGroundStationsOnDistance", ref HideGroundStationsOnDistance); + node.TryGetValue("ShowMouseOverInfoGroundStations", ref ShowMouseOverInfoGroundStations); + node.TryGetValue("AutoInsertKaCAlerts", ref AutoInsertKaCAlerts); + node.TryGetValue("FCLeadTime", ref FCLeadTime); + node.TryGetValue("FCOffAfterExecute", ref FCOffAfterExecute); + node.TryGetValue("DistanceToHideGroundStations", ref DistanceToHideGroundStations); + node.TryGetValue("DishConnectionColor", ref DishConnectionColor); + node.TryGetValue("OmniConnectionColor", ref OmniConnectionColor); + node.TryGetValue("ActiveConnectionColor", ref ActiveConnectionColor); + node.TryGetValue("RemoteStationColorDot", ref RemoteStationColorDot); + node.TryGetValue("DirectConnectionColor", ref DirectConnectionColor); + node.TryGetValue("SignalRelayEnabled", ref SignalRelayEnabled); + node.TryGetValue("IgnoreLineOfSight", ref IgnoreLineOfSight); + node.TryGetValue("FCWinPosX", ref FCWinPosX); + node.TryGetValue("FCWinPosY", ref FCWinPosY); + node.TryGetValue("FlightTermP", ref FlightTermP); + node.TryGetValue("FlightTermI", ref FlightTermI); + node.TryGetValue("FlightTermD", ref FlightTermD); + LoadGroundStations(node); + LoadPreSets(node); + } + + public void Save(ConfigNode node) + { + node.AddValue("RemoteTechEnabled", RemoteTechEnabled); + node.AddValue("CommNetEnabled", CommNetEnabled); + node.AddValue("ConsumptionMultiplier", ConsumptionMultiplier); + node.AddValue("RangeMultiplier", RangeMultiplier); + node.AddValue("MissionControlRangeMultiplier", MissionControlRangeMultiplier); + node.AddValue("OmniRangeClampFactor", OmniRangeClampFactor); + node.AddValue("DishRangeClampFactor", DishRangeClampFactor); + node.AddValue("ActiveVesselGuid", ActiveVesselGuid); + node.AddValue("NoTargetGuid", NoTargetGuid); + node.AddValue("SpeedOfLight", SpeedOfLight); + node.AddValue("MapFilter", MapFilter); + node.AddValue("EnableSignalDelay", EnableSignalDelay); + node.AddValue("RangeModelType", RangeModelType); + node.AddValue("MultipleAntennaMultiplier", MultipleAntennaMultiplier); + node.AddValue("ThrottleTimeWarp", ThrottleTimeWarp); + node.AddValue("ThrottleZeroOnNoConnection", ThrottleZeroOnNoConnection); + node.AddValue("StopTimeWrapOnReConnection", StopTimeWrapOnReConnection); + node.AddValue("HideGroundStationsBehindBody", HideGroundStationsBehindBody); + node.AddValue("ControlAntennaWithoutConnection", ControlAntennaWithoutConnection); + node.AddValue("UpgradeableMissionControlAntennas", UpgradeableMissionControlAntennas); + node.AddValue("HideGroundStationsOnDistance", HideGroundStationsOnDistance); + node.AddValue("ShowMouseOverInfoGroundStations", ShowMouseOverInfoGroundStations); + node.AddValue("AutoInsertKaCAlerts", AutoInsertKaCAlerts); + node.AddValue("FCLeadTime", FCLeadTime); + node.AddValue("FCOffAfterExecute", FCOffAfterExecute); + node.AddValue("DistanceToHideGroundStations", DistanceToHideGroundStations); + node.AddValue("DishConnectionColor", DishConnectionColor); + node.AddValue("OmniConnectionColor", OmniConnectionColor); + node.AddValue("ActiveConnectionColor", ActiveConnectionColor); + node.AddValue("RemoteStationColorDot", RemoteStationColorDot); + node.AddValue("DirectConnectionColor", DirectConnectionColor); + node.AddValue("SignalRelayEnabled", SignalRelayEnabled); + node.AddValue("IgnoreLineOfSight", IgnoreLineOfSight); + node.AddValue("FCWinPosX", FCWinPosX); + node.AddValue("FCWinPosY", FCWinPosY); + node.AddValue("FlightTermP", FlightTermP); + node.AddValue("FlightTermI", FlightTermI); + node.AddValue("FlightTermD", FlightTermD); + SaveGroundStations(node); + SavePreSets(node); + } + + private void SaveGroundStations(ConfigNode node) + { + var stations = node.AddNode("GroundStations"); + foreach (var station in GroundStations) + { + station.Save(stations.AddNode("STATION")); + } + } + + private void LoadGroundStations(ConfigNode node) + { + var stations = node.GetNode("GroundStations"); + if (stations == null) + return; + + GroundStations = new List(); + foreach (var stationNode in stations.GetNodes("STATION")) + { + var station = new MissionControlSatellite(); + station.Load(stationNode); + GroundStations.Add(station); + } + } + + private void SavePreSets(ConfigNode node) + { + var preSets = node.AddNode("PreSets"); + foreach (var preSet in PreSets) + { + preSets.AddValue("PRESETS", preSet); + } + } + + private void LoadPreSets(ConfigNode node) + { + var preSets = node.GetNode("PreSets"); + if (preSets == null) + return; + + PreSets = preSets.GetValues("PRESETS").ToList(); + } + /// /// Utilise KSP's GameDatabase to get a list of cfgs, included our Default_Settings.cfg, contained the 'RemoteTechSettings' /// node and process each cfg accordingly @@ -153,8 +327,9 @@ public static Settings Load() { if(cfgs[i].url.Equals(DefaultSettingCfgURL)) { - defaultSuccess = ConfigNode.LoadObjectFromConfig(settings, cfgs[i].config); - RTLog.Notify("Load default settings into object with {0}: LOADED {1}", cfgs[i].config, defaultSuccess ? "OK" : "FAIL"); + settings.Load(cfgs[i].config); + defaultSuccess = true; + RTLog.Notify("Load default settings into object with {0}: LOADED OK", cfgs[i].config); break; } } @@ -196,8 +371,8 @@ public static Settings Load() load = load.GetNode("RemoteTechSettings"); // replace the default settings with save-setting file - var success = ConfigNode.LoadObjectFromConfig(settings, load); - RTLog.Notify("Found and load save settings into object with {0}: LOADED {1}", load, success ? "OK" : "FAIL"); + settings.Load(load); + RTLog.Notify("Found and load save settings into object with {0}: LOADED OK", load); } // find third-party mods' RemoteTech settings @@ -273,11 +448,12 @@ public static Settings LoadPreset(Settings previousSettings, string presetCfgUrl importantInfoNode.AddValue("ActiveVesselGuid", previousSettings.ActiveVesselGuid); importantInfoNode.AddValue("NoTargetGuid", previousSettings.NoTargetGuid); - successLoadPreSet = ConfigNode.LoadObjectFromConfig(newPreSetSettings, rtSettingCfg.config); - RTLog.Notify("Load the preset cfg into object with {0}: LOADED {1}", newPreSetSettings, successLoadPreSet ? "OK" : "FAIL"); + newPreSetSettings.Load(rtSettingCfg.config); + successLoadPreSet = true; + RTLog.Notify("Load the preset cfg into object with {0}: LOADED OK", newPreSetSettings); // Restore backups - ConfigNode.LoadObjectFromConfig(newPreSetSettings, importantInfoNode); + newPreSetSettings.Load(importantInfoNode); break; } @@ -314,7 +490,9 @@ public Guid AddGroundStation(string name, double latitude, double longitude, dou return newGroundStation.mGuid; } - /// Removes a ground station from the list by its unique . + /// + /// Removes a ground station from the list by its unique . + /// /// Unique ground station id /// Returns true for a successful removed station, otherwise false. public bool RemoveGroundStation(Guid stationid) diff --git a/src/RemoteTech/RTSpaceCentre.cs b/src/RemoteTech/RTSpaceCentre.cs index e9d939bf6..ad0356cc2 100644 --- a/src/RemoteTech/RTSpaceCentre.cs +++ b/src/RemoteTech/RTSpaceCentre.cs @@ -9,12 +9,18 @@ namespace RemoteTech [KSPAddon(KSPAddon.Startup.SpaceCentre, false)] public class RTSpaceCentre : MonoBehaviour { - /// Button for KSP Stock Tool bar + /// + /// Button for KSP Stock Tool bar + /// public static ApplicationLauncherButton LauncherButton = null; - /// OptionWindow + /// + /// OptionWindow + /// private OptionWindow _optionWindow; - /// Texture for the KSP Stock Tool-bar Button + /// + /// Texture for the KSP Stock Tool-bar Button + /// private Texture2D _rtOptionBtn; /// diff --git a/src/RemoteTech/RTUtil.cs b/src/RemoteTech/RTUtil.cs index a1b4fb8d1..98b33cd51 100644 --- a/src/RemoteTech/RTUtil.cs +++ b/src/RemoteTech/RTUtil.cs @@ -14,7 +14,9 @@ namespace RemoteTech public static partial class RTUtil { public static double GameTime { get { return Planetarium.GetUniversalTime(); } } - /// This time member is needed to debounce the RepeatButton + /// + /// This time member is needed to debounce the RepeatButton + /// private static double TimeDebouncer = (HighLogic.LoadedSceneHasPlanetarium) ? RTUtil.GameTime : 0; /// @@ -224,15 +226,29 @@ public static String TargetName(Guid guid) return Localizer.Format("#RT_ModuleUI_UnknownTarget");//"Unknown Target" } - public static Guid Guid(this CelestialBody cb) + public static unsafe Guid Guid(this CelestialBody cb) { - char[] name = cb.GetName().ToCharArray(); - var s = new StringBuilder(); - for (int i = 0; i < 16; i++) + var name = cb.GetName(); + var length = name.Length; + if (length == 0) + return default; + + byte* bytes = stackalloc byte[16]; + fixed (char* pname = name) { - s.Append(((short)name[i % name.Length]).ToString("x")); + for (int i = 0, j = 0; i < 16; ++i, ++j) + { + if (j >= length) + j = 0; + + bytes[i] = (byte)pname[j]; + } } - return new Guid(s.ToString()); + + int a = (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]; + short b = (short)((bytes[4] << 8) | bytes[5]); + short c = (short)((bytes[6] << 8) | bytes[7]); + return new Guid(a, b, c, bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]); } public static bool HasValue(this ProtoPartModuleSnapshot ppms, String name) @@ -246,7 +262,9 @@ public static bool GetBool(this ProtoPartModuleSnapshot ppms, String value) return Boolean.TryParse(ppms.moduleValues.GetValue(value) ?? "False", out result) && result; } - /// Searches a ProtoPartModuleSnapshot for an integer field. + /// + /// Searches a ProtoPartModuleSnapshot for an integer field. + /// /// True if the member exists, false otherwise. /// The to query. /// The name of a member in the ProtoPartModuleSnapshot. @@ -495,15 +513,14 @@ public static IEnumerable FindTransformsWithCollider(Transform input) } } - public static T CachePerFrame(ref CachedField cachedField, Func getter) + internal static bool ShouldUpdateCache(ref CachedField field) { - if (cachedField.Frame == Time.frameCount) - { - return cachedField.Field; - } + var frame = Time.frameCount; + if (field.Frame == frame) + return false; - cachedField.Frame = Time.frameCount; - return cachedField.Field = getter(); + field.Frame = frame; + return true; } diff --git a/src/RemoteTech/RangeModel/AbstractRangeModel.cs b/src/RemoteTech/RangeModel/AbstractRangeModel.cs index d4c65f0f4..61c5e7203 100644 --- a/src/RemoteTech/RangeModel/AbstractRangeModel.cs +++ b/src/RemoteTech/RangeModel/AbstractRangeModel.cs @@ -7,20 +7,28 @@ namespace RemoteTech.RangeModel { public static class AbstractRangeModel { - /// Can't boost the range of an omni antenna by more than this factor, no matter what. + /// + /// Can't boost the range of an omni antenna by more than this factor, no matter what. + /// private static readonly double omniClamp = RTSettings.Instance.OmniRangeClampFactor; - /// Can't boost the range of a dish antenna by more than this factor, no matter what. + /// + /// Can't boost the range of a dish antenna by more than this factor, no matter what. + /// private static readonly double dishClamp = RTSettings.Instance.DishRangeClampFactor; - /// Finds the maximum range between an antenna and a specific target. + /// + /// Finds the maximum range between an antenna and a specific target. + /// /// The maximum distance at which the two spacecraft could communicate. /// The antenna attempting to target. /// The satellite being targeted by . /// The satellite on which is mounted. - /// A function that computes the maximum range between two - /// satellites, given their individual ranges. - public static double GetRangeInContext(IAntenna antenna, ISatellite target, ISatellite antennaSat, - Func rangeFunc) { + /// The range model to evaluate joint ranges with. + public static double GetRangeInContext(IAntenna antenna, ISatellite target, ISatellite antennaSat) + where TRange : struct, IRangeModel + { + TRange model = default; + // Which antennas on the other craft are capable of communication? IEnumerable omnisB = GetOmnis(target); IEnumerable dishesB = GetDishesThatSee(target, antennaSat); @@ -37,10 +45,10 @@ public static double GetRangeInContext(IAntenna antenna, ISatellite target, ISat // What is the range? // Note: IAntenna.Omni and IAntenna.Dish are zero for antennas of the other type - double maxOmni = Math.Max(CheckRange(rangeFunc, antenna.Omni + bonusA, omniClamp, maxOmniB + bonusB, omniClamp), - CheckRange(rangeFunc, antenna.Omni + bonusA, omniClamp, maxDishB , dishClamp)); - double maxDish = Math.Max(CheckRange(rangeFunc, antenna.Dish , dishClamp, maxOmniB + bonusB, omniClamp), - CheckRange(rangeFunc, antenna.Dish , dishClamp, maxDishB , dishClamp)); + double maxOmni = Math.Max(CheckRange(model, antenna.Omni + bonusA, omniClamp, maxOmniB + bonusB, omniClamp), + CheckRange(model, antenna.Omni + bonusA, omniClamp, maxDishB , dishClamp)); + double maxDish = Math.Max(CheckRange(model, antenna.Dish , dishClamp, maxOmniB + bonusB, omniClamp), + CheckRange(model, antenna.Dish , dishClamp, maxDishB , dishClamp)); if (Double.IsNaN(maxOmni)) { maxOmni = 0.0; } if (Double.IsNaN(maxDish)) { maxDish = 0.0; } @@ -48,12 +56,34 @@ public static double GetRangeInContext(IAntenna antenna, ISatellite target, ISat return Math.Max(maxOmni, maxDish); } - /// Constructs a link between two satellites, if one is possible. + /// + /// Constructs a link between two satellites, if one is possible. + /// /// The new link, or null if the two satellites cannot connect. - /// A function that computes the maximum range between two - /// satellites, given their individual ranges. - public static NetworkLink GetLink(ISatellite satA, ISatellite satB, - Func rangeFunc) { + /// The range model to evaluate joint ranges with. + public static NetworkLink? GetLink(ISatellite satA, ISatellite satB) + where TRange : struct, IRangeModel { + return TryFindConnectionCandidates(satA, satB, out _, out var type) + ? new NetworkLink(satB, type) + : null; + } + + /// The antennas on individually capable of reaching + /// — a superset of the one antenna + /// picks. Empty if the two satellites cannot connect. + /// The range model to evaluate joint ranges with. + public static List GetLinkInterfaces(ISatellite satA, ISatellite satB) + where TRange : struct, IRangeModel { + return TryFindConnectionCandidates(satA, satB, out var candidatesA, out _) + ? candidatesA + : []; + } + + private static bool TryFindConnectionCandidates(ISatellite satA, ISatellite satB, + out List candidatesA, out LinkType type) + where TRange : struct, IRangeModel { + TRange model = default; + // Which antennas on either craft are capable of communication? IEnumerable omnisA = GetOmnis(satA); IEnumerable omnisB = GetOmnis(satB); @@ -72,18 +102,18 @@ public static NetworkLink GetLink(ISatellite satA, ISatellite satB, double distance = satA.DistanceTo(satB); // Which antennas have the range to reach at least one antenna on the other satellite?? - omnisA = omnisA.Where(ant => - CheckRange(rangeFunc, ant.Omni + bonusA, omniClamp, maxOmniB + bonusB, omniClamp) >= distance - || CheckRange(rangeFunc, ant.Omni + bonusA, omniClamp, maxDishB , dishClamp) >= distance); - dishesA = dishesA.Where(ant => - CheckRange(rangeFunc, ant.Dish , dishClamp, maxOmniB + bonusB, omniClamp) >= distance - || CheckRange(rangeFunc, ant.Dish , dishClamp, maxDishB , dishClamp) >= distance); - omnisB = omnisB.Where(ant => - CheckRange(rangeFunc, ant.Omni + bonusB, omniClamp, maxOmniA + bonusA, omniClamp) >= distance - || CheckRange(rangeFunc, ant.Omni + bonusB, omniClamp, maxDishA , dishClamp) >= distance); - dishesB = dishesB.Where(ant => - CheckRange(rangeFunc, ant.Dish , dishClamp, maxOmniA + bonusA, omniClamp) >= distance - || CheckRange(rangeFunc, ant.Dish , dishClamp, maxDishA , dishClamp) >= distance); + omnisA = omnisA.Where(ant => + CheckRange(model, ant.Omni + bonusA, omniClamp, maxOmniB + bonusB, omniClamp) >= distance + || CheckRange(model, ant.Omni + bonusA, omniClamp, maxDishB , dishClamp) >= distance); + dishesA = dishesA.Where(ant => + CheckRange(model, ant.Dish , dishClamp, maxOmniB + bonusB, omniClamp) >= distance + || CheckRange(model, ant.Dish , dishClamp, maxDishB , dishClamp) >= distance); + omnisB = omnisB.Where(ant => + CheckRange(model, ant.Omni + bonusB, omniClamp, maxOmniA + bonusA, omniClamp) >= distance + || CheckRange(model, ant.Omni + bonusB, omniClamp, maxDishA , dishClamp) >= distance); + dishesB = dishesB.Where(ant => + CheckRange(model, ant.Dish , dishClamp, maxOmniA + bonusA, omniClamp) >= distance + || CheckRange(model, ant.Dish , dishClamp, maxDishA , dishClamp) >= distance); // Just because an antenna is in `omnisA.Concat(dishesA)` doesn't mean it can connect to *any* // antenna in `omnisB.Concat(dishesB)`, and vice versa. Pick the max to be safe. @@ -92,33 +122,38 @@ public static NetworkLink GetLink(ISatellite satA, ISatellite satB, IAntenna selectedAntennaB = omnisB.Concat(dishesB) .OrderByDescending(ant => Math.Max(ant.Omni, ant.Dish)).FirstOrDefault(); - if (selectedAntennaA != null && selectedAntennaB != null) + if (selectedAntennaA == null || selectedAntennaB == null) { - List interfaces = omnisA.Concat(dishesA).ToList(); - - LinkType type = (dishesA.Contains(selectedAntennaA) || dishesB.Contains(selectedAntennaB) - ? LinkType.Dish : LinkType.Omni); - - return new NetworkLink(satB, interfaces, type); + candidatesA = null; + type = LinkType.None; + return false; } - return null; + candidatesA = omnisA.Concat(dishesA).ToList(); + type = dishesA.Contains(selectedAntennaA) || dishesB.Contains(selectedAntennaB) + ? LinkType.Dish : LinkType.Omni; + return true; } - /// Checks the maximum range achievable by two satellites. + /// + /// Checks the maximum range achievable by two satellites. + /// /// The maximum range, including sanity limits. - /// A function that takes two antenna ranges and returns a joint range. + /// The range model to evaluate the joint range with. /// The range of the first satellite. /// The range of the second satellite. /// The maximum factor by which the first range can be boosted. /// The maximum factor by which the second range can be boosted. - private static double CheckRange(Func rangeFunc, - double range1, double clamp1, - double range2, double clamp2) { - return Math.Min(Math.Min(rangeFunc.Invoke(range1, range2), range1*clamp1), range2*clamp2); + private static double CheckRange(in TRange model, + double range1, double clamp1, + double range2, double clamp2) + where TRange : struct, IRangeModel { + return Math.Min(Math.Min(model.MaxDistance(range1, range2), range1*clamp1), range2*clamp2); } - /// Returns the bonus from having multiple antennas + /// + /// Returns the bonus from having multiple antennas + /// /// The boost to all omni antenna ranges, if MultipleAntennaMultiplier is enabled; /// otherwise zero. private static double GetMultipleAntennaBonus(IEnumerable omniList, double maxOmni) { @@ -130,7 +165,9 @@ private static double GetMultipleAntennaBonus(IEnumerable omniList, do } } - /// Returns all omnidirectional antennas on a satellite. + /// + /// Returns all omnidirectional antennas on a satellite. + /// /// A possibly empty collection of omnis. private static IEnumerable GetOmnis(ISatellite sat) { diff --git a/src/RemoteTech/RangeModel/IRangeModel.cs b/src/RemoteTech/RangeModel/IRangeModel.cs new file mode 100644 index 000000000..e2d715802 --- /dev/null +++ b/src/RemoteTech/RangeModel/IRangeModel.cs @@ -0,0 +1,34 @@ +using System; + +namespace RemoteTech.RangeModel +{ + /// + /// Picks the maximum communication distance between two antennas given + /// their individual ranges. A stateless struct rather than a delegate, so + /// 's generic methods dispatch to it + /// without allocating a closure per call. + /// + public interface IRangeModel + { + /// + /// Finds the maximum distance between two satellites with ranges r1 and r2. + /// + double MaxDistance(double r1, double r2); + } + + /// + /// The stock KSP range model: the smaller of the two ranges. + /// + public readonly struct StandardRangeModel : IRangeModel + { + public double MaxDistance(double r1, double r2) => Math.Min(r1, r2); + } + + /// + /// NathanKell's additive range model. + /// + public readonly struct AdditiveRangeModel : IRangeModel + { + public double MaxDistance(double r1, double r2) => Math.Min(r1, r2) + Math.Sqrt(r1 * r2); + } +} diff --git a/src/RemoteTech/RangeModel/RangeModelExtensions.cs b/src/RemoteTech/RangeModel/RangeModelExtensions.cs index 0c57ff096..5b4a7a1d0 100644 --- a/src/RemoteTech/RangeModel/RangeModelExtensions.cs +++ b/src/RemoteTech/RangeModel/RangeModelExtensions.cs @@ -5,7 +5,9 @@ namespace RemoteTech.RangeModel { public static class RangeModelExtensions { - /// Determines if an antenna has a specific satellite as its target. + /// + /// Determines if an antenna has a specific satellite as its target. + /// /// true if a's target is set to ; false otherwise. /// The antenna being queried. /// The satellite being tested for being the antenna target. @@ -14,7 +16,9 @@ public static bool IsTargetingDirectly(this IAntenna dish, ISatellite target) return dish.Target == target.Guid; } - /// Determines if an antenna can connect to a target through active vessel targeting. + /// + /// Determines if an antenna can connect to a target through active vessel targeting. + /// /// true if a's target is set to "Active Vessel" and is active; false otherwise. /// The antenna being queried. /// The satellite being tested for being the antenna target. @@ -30,7 +34,9 @@ public static bool IsTargetingActiveVessel(this IAntenna dish, ISatellite target && activeVessel != null && target.Guid == activeVessel.id; } - /// Determines if an antenna can connect to a target indirectly, using a cone. + /// + /// Determines if an antenna can connect to a target indirectly, using a cone. + /// /// true if lies within the cone of ; /// otherwise, false. /// The antenna being queried. @@ -57,14 +63,18 @@ public static bool IsInFieldOfView(this IAntenna dish, ISatellite target, ISatel return false; } - /// Finds the distance between two ISatellites + /// + /// Finds the distance between two ISatellites + /// /// The distance in meters. public static double DistanceTo(this ISatellite a, ISatellite b) { return Vector3d.Distance(a.Position, b.Position); } - /// Finds the distance between an ISatellite and the target of a connection + /// + /// Finds the distance between an ISatellite and the target of a connection + /// /// The distance in meters. /// The satellite from which the distance is to be measured. /// The network node to whose destination the distance is to be measured. @@ -73,7 +83,9 @@ public static double DistanceTo(this ISatellite sat, NetworkLink lin return Vector3d.Distance(sat.Position, link.Target.Position); } - /// Tests whether two satellites have line of sight to each other + /// + /// Tests whether two satellites have line of sight to each other + /// /// true if a straight line from a to b is not blocked by any celestial body; /// otherwise, false. public static bool HasLineOfSightWith(this ISatellite satA, ISatellite satB) diff --git a/src/RemoteTech/RangeModel/RangeModelRoot.cs b/src/RemoteTech/RangeModel/RangeModelRoot.cs index 5757176d2..76cb8aa5f 100644 --- a/src/RemoteTech/RangeModel/RangeModelRoot.cs +++ b/src/RemoteTech/RangeModel/RangeModelRoot.cs @@ -1,30 +1,34 @@ -using System; +using System.Collections.Generic; using RemoteTech.SimpleTypes; namespace RemoteTech.RangeModel { public static class RangeModelRoot { - /// Finds the maximum distance between two satellites with ranges r1 and r2. - /// The maximum range at which the two satellites can communicate. - private static double MaxDistance(double r1, double r2) - { - return Math.Min(r1, r2) + Math.Sqrt(r1 * r2); - } - - /// Finds the maximum range between an antenna and a potential target. + /// + /// Finds the maximum range between an antenna and a potential target. + /// /// The maximum distance at which the two spacecraft could communicate. /// The antenna attempting to target. /// The satellite being targeted by . /// The satellite on which is mounted. public static double GetRangeInContext(IAntenna antenna, ISatellite target, ISatellite antennaSat) { - return AbstractRangeModel.GetRangeInContext(antenna, target, antennaSat, MaxDistance); + return AbstractRangeModel.GetRangeInContext(antenna, target, antennaSat); } - /// Constructs a link between two satellites, if one is possible. + /// + /// Constructs a link between two satellites, if one is possible. + /// /// The new link, or null if the two satellites cannot connect. - public static NetworkLink GetLink(ISatellite satA, ISatellite satB) { - return AbstractRangeModel.GetLink(satA, satB, MaxDistance); + public static NetworkLink? GetLink(ISatellite satA, ISatellite satB) { + return AbstractRangeModel.GetLink(satA, satB); + } + + /// + /// The antennas on individually capable of reaching . + /// + public static List GetLinkInterfaces(ISatellite satA, ISatellite satB) { + return AbstractRangeModel.GetLinkInterfaces(satA, satB); } } } diff --git a/src/RemoteTech/RangeModel/RangeModelStandard.cs b/src/RemoteTech/RangeModel/RangeModelStandard.cs index f5f657b6c..ff164f5c2 100644 --- a/src/RemoteTech/RangeModel/RangeModelStandard.cs +++ b/src/RemoteTech/RangeModel/RangeModelStandard.cs @@ -1,30 +1,34 @@ -using System; +using System.Collections.Generic; using RemoteTech.SimpleTypes; namespace RemoteTech.RangeModel { public static class RangeModelStandard { - /// Finds the maximum distance between two satellites with ranges r1 and r2. - /// The maximum range at which the two satellites can communicate. - private static double MaxDistance(double r1, double r2) - { - return Math.Min(r1, r2); - } - - /// Finds the maximum range between an antenna and a potential target. + /// + /// Finds the maximum range between an antenna and a potential target. + /// /// The maximum distance at which the two spacecraft could communicate. /// The antenna attempting to target. /// The satellite being targeted by . /// The satellite on which is mounted. public static double GetRangeInContext(IAntenna antenna, ISatellite target, ISatellite antennaSat) { - return AbstractRangeModel.GetRangeInContext(antenna, target, antennaSat, MaxDistance); + return AbstractRangeModel.GetRangeInContext(antenna, target, antennaSat); } - /// Constructs a link between two satellites, if one is possible. + /// + /// Constructs a link between two satellites, if one is possible. + /// /// The new link, or null if the two satellites cannot connect. - public static NetworkLink GetLink(ISatellite satA, ISatellite satB) { - return AbstractRangeModel.GetLink(satA, satB, MaxDistance); + public static NetworkLink? GetLink(ISatellite satA, ISatellite satB) { + return AbstractRangeModel.GetLink(satA, satB); + } + + /// + /// The antennas on individually capable of reaching . + /// + public static List GetLinkInterfaces(ISatellite satA, ISatellite satB) { + return AbstractRangeModel.GetLinkInterfaces(satA, satB); } } } diff --git a/src/RemoteTech/RemoteTech.csproj b/src/RemoteTech/RemoteTech.csproj index e1e6f2c0e..7716f39f8 100644 --- a/src/RemoteTech/RemoteTech.csproj +++ b/src/RemoteTech/RemoteTech.csproj @@ -12,7 +12,7 @@ $(ProjectRootDir)GameData\RemoteTech Plugins - 12 + 13 portable true Debug;Release @@ -21,16 +21,67 @@ 1.9.12 1.9.0.0 + + + false + true + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + false + SerializationFix + SerializationFix + 1.0 + true + + + + false + KSPBurst-Lite + KSPBurst + 1.7 + true + + + false + + + false + + + false + + + false + + + + + + + + $(KSPBT_ModRoot)/Versioning/RemoteTech.version diff --git a/src/RemoteTech/SatelliteManager.cs b/src/RemoteTech/SatelliteManager.cs index 221d41166..640f05f6e 100644 --- a/src/RemoteTech/SatelliteManager.cs +++ b/src/RemoteTech/SatelliteManager.cs @@ -3,6 +3,7 @@ using System.Collections; using System.Collections.Generic; using RemoteTech.Modules; +using RemoteTech.Collections; namespace RemoteTech { @@ -15,14 +16,12 @@ public class SatelliteManager : IEnumerable, IDisposable public event Action OnRegister = delegate { }; public event Action OnUnregister = delegate { }; - public int Count => _satelliteCache.Count; + public int Count => SatelliteCache.Count; public VesselSatellite this[Guid g] => GetSatelliteById(g); public VesselSatellite this[Vessel v] => v == null ? null : GetSatelliteById(v.id); - private readonly Dictionary> _loadedSpuCache = - new Dictionary>(); - private readonly Dictionary _satelliteCache = - new Dictionary(); + internal readonly Dictionary> LoadedSpuCache = []; + internal readonly ArrayMap SatelliteCache = new(); public SatelliteManager() { @@ -45,24 +44,24 @@ public Guid Register(Vessel vessel, ISignalProcessor spu) RTLog.Notify("SatelliteManager: Register({0})", spu); var key = vessel.id; - if (!_loadedSpuCache.ContainsKey(key)) + if (!LoadedSpuCache.ContainsKey(key)) { UnregisterProto(vessel.id); - _loadedSpuCache[key] = new List(); + LoadedSpuCache[key] = []; } // Add if non duplicate - var signalProcessor = _loadedSpuCache[key].Find(x => x == spu); + var signalProcessor = LoadedSpuCache[key].Find(x => x == spu); if (signalProcessor != null) return key; - _loadedSpuCache[key].Add(spu); + LoadedSpuCache[key].Add(spu); // Create a new satellite if it's the only loaded signal processor. - if (_loadedSpuCache[key].Count != 1) + if (LoadedSpuCache[key].Count != 1) return key; - _satelliteCache[key] = new VesselSatellite(_loadedSpuCache[key]); - OnRegister(_satelliteCache[key]); + SatelliteCache[key] = new VesselSatellite(vessel, LoadedSpuCache[key]); + OnRegister(SatelliteCache[key]); return key; } @@ -76,23 +75,23 @@ public void Unregister(Guid key, ISignalProcessor spu) { RTLog.Notify("SatelliteManager: Unregister({0})", spu); // Return if nothing to unregister. - if (!_loadedSpuCache.ContainsKey(key)) return; + if (!LoadedSpuCache.ContainsKey(key)) return; // Find instance of the signal processor. - var instanceId = _loadedSpuCache[key].FindIndex(x => x == spu); + var instanceId = LoadedSpuCache[key].FindIndex(x => x == spu); if (instanceId == -1) return; // Remove satellite if no signal processors remain. - if (_loadedSpuCache[key].Count == 1) + if (LoadedSpuCache[key].Count == 1) { - if (_satelliteCache.ContainsKey(key)) + if (SatelliteCache.ContainsKey(key)) { - VesselSatellite sat = _satelliteCache[key]; + VesselSatellite sat = SatelliteCache[key]; OnUnregister(sat); - _satelliteCache.Remove(key); + SatelliteCache.Remove(key); } - _loadedSpuCache[key].RemoveAt(instanceId); - _loadedSpuCache.Remove(key); + LoadedSpuCache[key].RemoveAt(instanceId); + LoadedSpuCache.Remove(key); // search vessel by id var vessel = RTUtil.GetVesselById(key); @@ -105,7 +104,7 @@ public void Unregister(Guid key, ISignalProcessor spu) } else { - _loadedSpuCache[key].RemoveAt(instanceId); + LoadedSpuCache[key].RemoveAt(instanceId); } } @@ -118,17 +117,15 @@ public void RegisterProto(Vessel vessel) Guid key = vessel.protoVessel.vesselID; RTLog.Notify("SatelliteManager: RegisterProto({0}, {1})", vessel.vesselName, key); // Return if there are still signal processors loaded. - if (_loadedSpuCache.ContainsKey(vessel.id)) { - _loadedSpuCache.Remove(vessel.id); - } + if (LoadedSpuCache.ContainsKey(vessel.id)) + LoadedSpuCache.Remove(vessel.id); var spu = vessel.GetSignalProcessor(); if (spu == null) return; - var protos = new List {spu}; - _satelliteCache[key] = new VesselSatellite(protos); - OnRegister(_satelliteCache[key]); + SatelliteCache[key] = new VesselSatellite(vessel, [spu]); + OnRegister(SatelliteCache[key]); } /// @@ -139,26 +136,35 @@ public void UnregisterProto(Guid key) RTLog.Notify("SatelliteManager: UnregisterProto({0})", key); // Return if there are still signal processors loaded. - if (_loadedSpuCache.ContainsKey(key)) + if (LoadedSpuCache.ContainsKey(key)) return; // Unregister satellite if it exists. - if (!_satelliteCache.ContainsKey(key)) + if (!SatelliteCache.ContainsKey(key)) return; - OnUnregister(_satelliteCache[key]); - _satelliteCache.Remove(key); + OnUnregister(SatelliteCache[key]); + SatelliteCache.Remove(key); } private VesselSatellite GetSatelliteById(Guid key) { VesselSatellite result; - return _satelliteCache.TryGetValue(key, out result) ? result : null; + return SatelliteCache.TryGetValue(key, out result) ? result : null; } - public IEnumerable FindCommandStations() + public List FindCommandStations() { - return _satelliteCache.Values.Where(vs => vs.IsCommandStation).Cast(); + var values = SatelliteCache.Values; + var stations = new List(values.Length); + + foreach (var sat in values) + { + if (sat.IsCommandStation) + stations.Add(sat); + } + + return stations; } private void OnVesselOnRails(Vessel v) @@ -187,14 +193,20 @@ public void Dispose() GameEvents.onVesselGoOnRails.Remove(OnVesselOnRails); } - public IEnumerator GetEnumerator() + public SpanEnumerator GetEnumerator() => + SatelliteCache.Values.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => + GetArrayEnumerator([.. SatelliteCache.Values]); + IEnumerator IEnumerable.GetEnumerator() { - return _satelliteCache.Values.GetEnumerator(); + VesselSatellite[] array = [..SatelliteCache.Values]; + return array.GetEnumerator(); } - IEnumerator IEnumerable.GetEnumerator() + static IEnumerator GetArrayEnumerator(A array) + where A : IEnumerable { - return GetEnumerator(); + return array.GetEnumerator(); } } diff --git a/src/RemoteTech/SimpleTypes/AddOn.cs b/src/RemoteTech/SimpleTypes/AddOn.cs index cdfe541ab..6b4266b6b 100644 --- a/src/RemoteTech/SimpleTypes/AddOn.cs +++ b/src/RemoteTech/SimpleTypes/AddOn.cs @@ -7,14 +7,22 @@ namespace RemoteTech.SimpleTypes { public abstract class AddOn { - /// Holds the current assembly type + /// + /// Holds the current assembly type + /// protected Type AssemblyType; - /// Binding flags for invoking the methods + /// + /// Binding flags for invoking the methods + /// protected BindingFlags BindFlags = BindingFlags.InvokeMethod | BindingFlags.Public | BindingFlags.Static; - /// Instance object for invoking instance methods + /// + /// Instance object for invoking instance methods + /// protected object Instance; - /// Assembly loaded? + /// + /// Assembly loaded? + /// public bool AssemblyLoaded { get; } diff --git a/src/RemoteTech/SimpleTypes/AntennaState.cs b/src/RemoteTech/SimpleTypes/AntennaState.cs new file mode 100644 index 000000000..8a00503b2 --- /dev/null +++ b/src/RemoteTech/SimpleTypes/AntennaState.cs @@ -0,0 +1,83 @@ +using System; +using Unity.Collections.LowLevel.Unsafe; +using UnityEngine; + +namespace RemoteTech.SimpleTypes; + +[Serializable] +internal struct AntennaData() : StateManager.IStateItem +{ + public Guid Guid; + public Guid Target; + public bool Activated; + public bool Powered; + public bool Connected; + public bool CanTarget; + public float Dish; + public double CosAngle = 1.0; + public float Omni; + public float Consumption; + + public int? Index { get; set; } +} + +/// +/// Antenna properties needed by the network update. +/// +/// +/// +/// +/// This class allows the network update to read your antenna state using +/// unmanaged code. You don't need to do anything special for this, just make +/// sure to call in OnStart and +/// or in OnDestroy. +/// +/// +/// +/// You can even do this in OnEnable and OnDisable. It is safe to +/// call these multiple times as long as you don't try to register the same +/// instance multiple times at once. +/// +/// +[Serializable] +public sealed class AntennaState : IDisposable +{ + [SerializeField] + AntennaData data; + ulong gcHandle; + + public Guid Guid { get => data.Guid; set => data.Guid = value; } + public Guid Target { get => data.Target; set => data.Target = value; } + public bool Activated { get => data.Activated; set => data.Activated = value; } + public bool Powered { get => data.Powered; set => data.Powered = value; } + public bool Connected { get => data.Connected; set => data.Connected = value; } + public bool CanTarget { get => data.CanTarget; set => data.CanTarget = value; } + public float Dish { get => data.Dish; set => data.Dish = value; } + public double CosAngle { get => data.CosAngle; set => data.CosAngle = value; } + public float Omni { get => data.Omni; set => data.Omni = value; } + public float Consumption { get => data.Consumption; set => data.Consumption = value; } + + public unsafe void Register() + { + if (gcHandle != 0) + throw new InvalidOperationException("this state object is already registered"); + + // Pin so &data stays put for the lifetime of the registration; the + // network jobs read it through the raw pointer handed to StateManager. + UnsafeUtility.PinGCObjectAndGetAddress(this, out gcHandle); + fixed (AntennaData* data = &this.data) + StateManager.Antennas.Register(data); + } + + public unsafe void Unregister() + { + if (gcHandle == 0) + return; + + fixed (AntennaData* data = &this.data) + StateManager.Antennas.Unregister(data, gcHandle); + gcHandle = 0; + } + + public void Dispose() => Unregister(); +} diff --git a/src/RemoteTech/SimpleTypes/BidirectionalEdge.cs b/src/RemoteTech/SimpleTypes/BidirectionalEdge.cs deleted file mode 100644 index 55cadb05b..000000000 --- a/src/RemoteTech/SimpleTypes/BidirectionalEdge.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; - -namespace RemoteTech.SimpleTypes -{ - public enum LinkType - { - None, - Dish, - Omni, - } - - public class BidirectionalEdge : IEquatable> - { - public bool Equals(BidirectionalEdge other) - { - return (A.Equals(other.A) || A.Equals(other.B)) && - (B.Equals(other.A) || B.Equals(other.B)); - } - - public readonly T A; - public readonly T B; - public readonly LinkType Type; - - public BidirectionalEdge(T a, T b, LinkType type) - { - A = a; - B = b; - Type = type; - } - - public override int GetHashCode() - { - return A.GetHashCode() + B.GetHashCode(); - } - - public override string ToString() - { - return String.Format("BidirectionalEdge(A: {0}, B: {1}, Type {2})", A, B, Type.ToString()); - } - } -} \ No newline at end of file diff --git a/src/RemoteTech/SimpleTypes/Dish.cs b/src/RemoteTech/SimpleTypes/Dish.cs deleted file mode 100644 index ffcadaa70..000000000 --- a/src/RemoteTech/SimpleTypes/Dish.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; - -namespace RemoteTech.SimpleTypes -{ - public class Dish - { - public readonly float Range; - public readonly double Radians; - public readonly Guid Target; - - public Dish(Guid target, double radians, float distance) - { - Target = target; - Radians = radians; - Range = distance; - } - - public override String ToString() - { - return String.Format("Dish(Range: {0}, Radians: {1}, Target: {2}", - Range.ToString("F2"), - (Radians / Math.PI * 180).ToString("F2") + "deg", - String.Format("{0} ({1})", Target, RTCore.Instance.Satellites[Target] != null - ? RTCore.Instance.Satellites[Target].ToString() - : "Unknown")); - } - } -} \ No newline at end of file diff --git a/src/RemoteTech/SimpleTypes/LinkType.cs b/src/RemoteTech/SimpleTypes/LinkType.cs new file mode 100644 index 000000000..fd7b99933 --- /dev/null +++ b/src/RemoteTech/SimpleTypes/LinkType.cs @@ -0,0 +1,8 @@ +namespace RemoteTech.SimpleTypes; + +public enum LinkType : byte +{ + None, + Dish, + Omni, +} diff --git a/src/RemoteTech/SimpleTypes/NetworkLink.cs b/src/RemoteTech/SimpleTypes/NetworkLink.cs index cf0a3a385..04ecab045 100644 --- a/src/RemoteTech/SimpleTypes/NetworkLink.cs +++ b/src/RemoteTech/SimpleTypes/NetworkLink.cs @@ -1,31 +1,37 @@ -using System; +using System; using System.Collections.Generic; namespace RemoteTech.SimpleTypes { - public class NetworkLink : IEquatable> + /// + /// A single directed hop in the network: the satellite reached + /// () and the port type. A value-type view — it carries + /// no state of its own beyond these, so handing one out costs no heap + /// allocation. + /// + public readonly struct NetworkLink : IEquatable> { public readonly T Target; - public readonly List Interfaces; public readonly LinkType Port; - public NetworkLink(T sat, List ant, LinkType port) + public NetworkLink(T sat, LinkType port) { Target = sat; - Interfaces = ant; Port = port; } public bool Equals(NetworkLink o) { - if (o == null) return false; - if (!Target.Equals(o.Target)) return false; - return true; + return EqualityComparer.Default.Equals(Target, o.Target); } + public override bool Equals(object obj) => obj is NetworkLink o && Equals(o); + + public override int GetHashCode() => Target == null ? 0 : Target.GetHashCode(); + public override string ToString() { - return String.Format("NetworkLink(T: {0}, I: {1}, P: {2})", Target, Interfaces.ToDebugString(), Port); + return String.Format("NetworkLink(T: {0}, P: {1})", Target, Port); } } -} \ No newline at end of file +} diff --git a/src/RemoteTech/SimpleTypes/NetworkRoute.cs b/src/RemoteTech/SimpleTypes/NetworkRoute.cs index e799da6fd..ca45867ef 100644 --- a/src/RemoteTech/SimpleTypes/NetworkRoute.cs +++ b/src/RemoteTech/SimpleTypes/NetworkRoute.cs @@ -1,61 +1,53 @@ -using System; +using System; using System.Collections.Generic; -using System.Linq; +using RemoteTech.Network; namespace RemoteTech.SimpleTypes { - public class NetworkRoute : IComparable> + /// + /// A connection route, as a value-type view: a lightweight handle into the + /// persistent . // + /// are answered O(1) from the Dijkstra forests, and the + /// hop list is materialized lazily — and cached per tick by the state — only + /// when is actually read. + /// + public readonly struct NetworkRoute : IComparable> { - public T Goal { get { return Exists ? Links[Links.Count - 1].Target : default(T); } } - public T Start { get; private set; } - public bool Exists { get { return Links.Count > 0; } } + private readonly NetworkState _state; + private readonly int _node; + private readonly bool _groundOnly; - public double Length { get; private set; } - public double Delay { get { return RTSettings.Instance.EnableSignalDelay - ? Length / RTSettings.Instance.SpeedOfLight - : 0.0; } } - public List> Links { get; private set; } - - public NetworkRoute(T start, List> links, double dist) + internal NetworkRoute(NetworkState state, int node, bool groundOnly) { - if (start == null) throw new ArgumentNullException("start"); - if (links == null) links = new List>(); - Start = start; - Links = links; - Length = dist; + _state = state; + _node = node; + _groundOnly = groundOnly; } - public bool Contains(BidirectionalEdge edge) - { - if (Links.Count == 0) return false; - if ((Start.Equals(edge.A) && Links[0].Target.Equals(edge.B)) || - (Start.Equals(edge.B) && Links[0].Target.Equals(edge.A))) return true; - for (int i = 0; i < Links.Count - 1; i++) - { - if (Links[i].Target.Equals(edge.A) && Links[i+1].Target.Equals(edge.B)) return true; - if (Links[i].Target.Equals(edge.B) && Links[i+1].Target.Equals(edge.A)) return true; - } - return false; - } + public T Start => (T)(object)_state.SatAt(_node); - public int CompareTo(NetworkRoute other) - { - return Length.CompareTo(other.Length); - } + public double Length => _state.RouteLength(_node, _groundOnly); - public override string ToString() - { - return String.Format("NetworkRoute(Route: {{0}}, Length: {1})", - String.Join("→", Links.Select(t => t.ToString()).ToArray()), - Length.ToString("F2") + "m"); - } - } + public bool Exists => _state.RouteExists(_node, _groundOnly); - public class NetworkRoute - { - public static NetworkRoute Empty(T start) + public double Delay => RTSettings.Instance.EnableSignalDelay + ? Length / RTSettings.Instance.SpeedOfLight + : 0.0; + + public IReadOnlyList> Links => + (IReadOnlyList>)(object)_state.RouteHops(_node, _groundOnly); + + public T Goal => Exists ? (T)(object)_state.RouteGoalSat(_node, _groundOnly) : default; + + public int CompareTo(NetworkRoute other) => Length.CompareTo(other.Length); + + public override string ToString() { - return new NetworkRoute(start, null, Single.PositiveInfinity); + var links = Links; + var parts = new string[links.Count]; + for (int i = 0; i < links.Count; i++) parts[i] = links[i].ToString(); + return String.Format("NetworkRoute(Route: {0}, Length: {1})", + String.Join("→", parts), Length.ToString("F2") + "m"); } } -} \ No newline at end of file +} diff --git a/src/RemoteTech/StateManager.cs b/src/RemoteTech/StateManager.cs new file mode 100644 index 000000000..dec402f86 --- /dev/null +++ b/src/RemoteTech/StateManager.cs @@ -0,0 +1,117 @@ +using System; +using RemoteTech.SimpleTypes; +using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; +using Unity.Jobs; + +namespace RemoteTech; + +internal static unsafe class StateManager +{ + public interface IStateItem + { + /// + /// The index of this item within a , + /// or null while unregistered. + /// + public int? Index { get; set; } + } + + public struct NativePointerArray(NativeArray array) : IDisposable + where T : unmanaged + { + NativeArray array = array; + + public int Length => array.Length; + public T* this[int index] => (T*)array[index]; + + public void Dispose() => array.Dispose(); + public JobHandle Dispose(JobHandle dependsOn) => array.Dispose(dependsOn); + } + + public unsafe class PinnedStateList() + where TData : unmanaged, IStateItem + { + JobHandle handle; + NativeList state = new(Allocator.Persistent); + + /// + /// Add a pinned state object to this list. + /// + /// + public void Register(TData* data) + { + if (data is null) + throw new ArgumentNullException(nameof(data)); + if (data->Index is not null) + throw new ArgumentException("state is already registered"); + + data->Index = state.Length; + state.Add((IntPtr)data); + } + + /// + /// Remove a pinned state object from the list and release its GC handle + /// once all current users are finished. + /// + public void Unregister(TData* data, ulong gcHandle) + { + if (data == null) + throw new ArgumentNullException(nameof(data)); + + if (data->Index is not int index + || index < 0 + || index >= state.Length + || state[index] != (IntPtr)data) + { + throw new InvalidOperationException("data object was not contained within this PinnedStateList"); + } + + new ReleaseGCObjectJob(gcHandle).Schedule(handle); + state.RemoveAtSwapBack(index); + if (index < state.Length) + ((TData*)state[index])->Index = index; + data->Index = null; + } + + /// + /// Get a snapshot of the current state array. You must call + /// with your job handle if you want to + /// use this in a job. + /// + /// The allocator to use for the copy array. + /// + public NativePointerArray GetStateArray(Allocator allocator) + { + var array = new NativeArray( + state.Length, allocator, NativeArrayOptions.UninitializedMemory); + array.CopyFrom(state); + return new(array); + } + + /// + /// Add a that must complete before state items + /// can be deallocated. + /// + /// + public void AddUseHandle(JobHandle dependsOn) + { + if (!handle.IsCompleted) + dependsOn = JobHandle.CombineDependencies(handle, dependsOn); + handle = dependsOn; + } + } + + /// + /// Releases a GC handle pinned via + /// once the jobs still reading its pinned memory have completed. + /// + struct ReleaseGCObjectJob(ulong gcHandle) : IJob + { + public ulong gcHandle = gcHandle; + + public void Execute() => UnsafeUtility.ReleaseGCObject(gcHandle); + } + + public static readonly PinnedStateList Antennas = new(); +} \ No newline at end of file diff --git a/src/RemoteTech/UI/AbstractWindow.cs b/src/RemoteTech/UI/AbstractWindow.cs index ef42e2120..9d9a8ec47 100644 --- a/src/RemoteTech/UI/AbstractWindow.cs +++ b/src/RemoteTech/UI/AbstractWindow.cs @@ -28,14 +28,22 @@ public abstract class AbstractWindow private double mTooltipTimer; private readonly Guid mGuid; public static Dictionary Windows = new Dictionary(); - /// The initial width of this window + /// + /// The initial width of this window + /// public float mInitialWidth; - /// The initial height of this window + /// + /// The initial height of this window + /// public float mInitialHeight; - /// Callback trigger for the change in the posistion + /// + /// Callback trigger for the change in the posistion + /// public Action onPositionChanged = delegate { }; public Rect backupPosition; - /// todo + /// + /// todo + /// protected bool mCloseButton = true; static AbstractWindow() diff --git a/src/RemoteTech/UI/AntennaFragment.cs b/src/RemoteTech/UI/AntennaFragment.cs index a0a628296..a33f4e2c6 100644 --- a/src/RemoteTech/UI/AntennaFragment.cs +++ b/src/RemoteTech/UI/AntennaFragment.cs @@ -29,23 +29,39 @@ public IAntenna Antenna { } private IAntenna mAntenna; private Vector2 mScrollPosition = Vector2.zero; - /// The tree of (real or virtual) targets displayed in this fragment. + /// + /// The tree of (real or virtual) targets displayed in this fragment. + /// /// No Entry object appears in the tree pointed to by mRootEntry more than once. private Entry mRootEntry = new Entry(); - /// The Entry corresponding to the currently selected target, if any. + /// + /// The Entry corresponding to the currently selected target, if any. + /// private Entry mSelection; - /// The Entry corresponding to the currently selected target, if any. + /// + /// The Entry corresponding to the currently selected target, if any. + /// private Entry mCurrentMouseOverEntry = null; - /// Callback trigger for mouse over a list entry + /// + /// Callback trigger for mouse over a list entry + /// public Action onMouseOverListEntry = delegate { }; - /// Callback trigger for mouse out of a list entry + /// + /// Callback trigger for mouse out of a list entry + /// public Action onMouseOutListEntry = delegate { }; - /// Current entry of the mouse + /// + /// Current entry of the mouse + /// public Entry mouseOverEntry { get { return mCurrentMouseOverEntry; } private set { mCurrentMouseOverEntry = value; } } - /// Flag to trigger the onMouseover event + /// + /// Flag to trigger the onMouseover event + /// public bool triggerMouseOverListEntry = false; - /// The Entries corresponding to loaded celestial bodies. + /// + /// The Entries corresponding to loaded celestial bodies. + /// private Dictionary mEntries; // Current planet list private int refreshCounter = 0; @@ -157,12 +173,16 @@ public void Draw() } public void Refresh(IAntenna sat) { if (sat == Antenna) { Antenna = null; } } - /// Rebuilds list of target vessels + /// + /// Rebuilds list of target vessels + /// /// Rebuilds the list of target vessels, preserving the rest of the target list state. Does /// not alter planets or special targets, call RefreshPlanets() for that. /// The satellite whose status has just changed. public void Refresh(ISatellite sat) { Refresh(); } - /// Rebuilds list of target vessels + /// + /// Rebuilds list of target vessels + /// /// Rebuilds the list of target vessels, preserving the rest of the target list state. Does /// not alter planets or special targets, call RefreshPlanets() for that. public void Refresh() @@ -213,7 +233,9 @@ public void Refresh() } } - /// Full refresh of target list + /// + /// Full refresh of target list + /// /// Rebuilds the list of target buttons from scratch, including special targets and /// planets. Does not build vessel list, call Refresh() for that. /// Calling this function wipes all information about which submenus were open or closed. @@ -224,7 +246,7 @@ private void RefreshPlanets() { mSelection = new Entry() { Text = Localizer.Format("#RT_ModuleUI_NoTarget"),//"No Target" - Guid = new Guid(RTSettings.Instance.NoTargetGuid), + Guid = RTSettings.Instance.NoTargetGuidParsed, Color = Color.white, Depth = 0, }; diff --git a/src/RemoteTech/UI/AntennaWindowStandalone.cs b/src/RemoteTech/UI/AntennaWindowStandalone.cs index 9c89d8190..00de508a8 100644 --- a/src/RemoteTech/UI/AntennaWindowStandalone.cs +++ b/src/RemoteTech/UI/AntennaWindowStandalone.cs @@ -126,7 +126,7 @@ private void AddDefaultTargets() selectedEntry = new Entry() // selected entry by default { Text = Localizer.Format("#RT_ModuleUI_NoTarget"),//"No Target" - Guid = new Guid(RTSettings.Instance.NoTargetGuid), + Guid = RTSettings.Instance.NoTargetGuidParsed, Color = Color.white, Depth = 0, }; diff --git a/src/RemoteTech/UI/DebugWindow.cs b/src/RemoteTech/UI/DebugWindow.cs index 43250d230..4f21ec6c0 100644 --- a/src/RemoteTech/UI/DebugWindow.cs +++ b/src/RemoteTech/UI/DebugWindow.cs @@ -24,15 +24,25 @@ public override void Hide() #endregion #region Member - /// Scroll position of the debug log textarea + /// + /// Scroll position of the debug log textarea + /// private Vector2 debugLogScrollPosition; - /// Scroll position of the content area + /// + /// Scroll position of the content area + /// private Vector2 contentScrollPosition; - /// Current selected log level + /// + /// Current selected log level + /// private RTLogLevel currentLogLevel = RTLogLevel.LVL1; - /// Current selected menue item + /// + /// Current selected menue item + /// private int currentDebugMenue = 0; - /// List of all menue items + /// + /// List of all menue items + /// private List debugMenueItems = new List(); private int deactivatedMissionControls = 0; diff --git a/src/RemoteTech/UI/NetworkCone.cs b/src/RemoteTech/UI/NetworkCone.cs deleted file mode 100644 index 92a7a10d5..000000000 --- a/src/RemoteTech/UI/NetworkCone.cs +++ /dev/null @@ -1,170 +0,0 @@ -using System; -using System.Linq; -using UnityEngine; - -namespace RemoteTech.UI -{ - public class NetworkCone : MonoBehaviour - { - private static Material CommNetMaterial = null; - - public Vector3d Center - { - set - { - UpdateMesh(value, Antenna); - } - } - - public IAntenna Antenna { get; set; } - - public float LineWidth { get; set; } - - public Material Material - { - set - { - mRenderer.material = value; - } - } - - public Color Color - { - set - { - mMeshFilter.mesh.colors = Enumerable.Repeat(value, 8).ToArray(); - } - } - - public bool Active - { - set - { - mRenderer.enabled = value; - gameObject.SetActive(value); - } - } - - private MeshFilter mMeshFilter; - private MeshRenderer mRenderer; - private Vector3[] mPoints2D = new Vector3[8]; - private Vector3[] mPoints3D = new Vector3[8]; - - public static NetworkCone Instantiate() - { - return new GameObject("NetworkCone", typeof(NetworkCone)).GetComponent(); - } - - public void Awake() - { - if (CommNetMaterial == null) { CommNetMaterial = Resources.Load("Telemetry/TelemetryMaterial"); } - - SetupMesh(); - gameObject.layer = 31; - LineWidth = 1.0f; - Color = Color.white; - Material = CommNetMaterial; - } - - private void UpdateMesh(Vector3d center, IAntenna dish) - { - var camera = PlanetariumCamera.Camera; - - Vector3d antennaPos = ScaledSpace.LocalToScaledSpace(RTCore.Instance.Network[dish.Guid].Position); - Vector3d planetPos = ScaledSpace.LocalToScaledSpace(center); - - CelestialBody refFrame = (MapView.MapCamera.target.vessel != null - ? MapView.MapCamera.target.vessel.mainBody - : MapView.MapCamera.target.celestialBody); - Vector3 up = (refFrame != null ? refFrame.transform.up : Vector3.up); - - Vector3 space = Vector3.Cross(planetPos - antennaPos, up).normalized - * Vector3.Distance(antennaPos, planetPos) - * (float)Math.Tan(Math.Acos(dish.CosAngle)); - Vector3d end1 = antennaPos + (planetPos + space - antennaPos).normalized - * Math.Min(dish.Dish / ScaledSpace.ScaleFactor, Vector3.Distance(antennaPos, planetPos)); - Vector3d end2 = antennaPos + (planetPos - space - antennaPos).normalized - * Math.Min(dish.Dish / ScaledSpace.ScaleFactor, Vector3.Distance(antennaPos, planetPos)); - - Vector3 lineStart = camera.WorldToScreenPoint(antennaPos); - Vector3 lineEnd1 = camera.WorldToScreenPoint(end1); - Vector3 lineEnd2 = camera.WorldToScreenPoint(end2); - var segment1 = new Vector3(lineEnd1.y - lineStart.y, lineStart.x - lineEnd1.x, 0).normalized * (LineWidth / 2); - var segment2 = new Vector3(lineEnd2.y - lineStart.y, lineStart.x - lineEnd2.x, 0).normalized * (LineWidth / 2); - - if (!MapView.Draw3DLines) - { - //if position is behind camera - if (lineStart.z < 0) - { - Vector3 coneCenter = camera.WorldToScreenPoint(planetPos); - lineStart = NetworkLine.FlipDirection(lineStart, coneCenter); - } - else if (lineEnd1.z < 0 || lineEnd2.z < 0) - { - lineEnd1 = NetworkLine.FlipDirection(lineEnd1, lineStart); - lineEnd2 = NetworkLine.FlipDirection(lineEnd2, lineStart); - } - - int dist = Screen.height / 2; - lineStart.z = lineStart.z > 0 ? dist : -dist; - lineEnd1.z = lineEnd1.z > 0 ? dist : -dist; - lineEnd2.z = lineEnd2.z > 0 ? dist : -dist; - - mPoints2D[0] = (lineStart - segment1); - mPoints2D[1] = (lineStart + segment1); - mPoints2D[2] = (lineEnd1 - segment1); - mPoints2D[3] = (lineEnd1 + segment1); - mPoints2D[4] = (lineStart - segment2); - mPoints2D[5] = (lineStart + segment2); - mPoints2D[6] = (lineEnd2 - segment2); - mPoints2D[7] = (lineEnd2 + segment2); - } - else - { - mPoints3D[0] = camera.ScreenToWorldPoint(lineStart - segment1); - mPoints3D[1] = camera.ScreenToWorldPoint(lineStart + segment1); - mPoints3D[2] = camera.ScreenToWorldPoint(lineEnd1 - segment1); - mPoints3D[3] = camera.ScreenToWorldPoint(lineEnd1 + segment1); - mPoints3D[4] = camera.ScreenToWorldPoint(lineStart - segment2); - mPoints3D[5] = camera.ScreenToWorldPoint(lineStart + segment2); - mPoints3D[6] = camera.ScreenToWorldPoint(lineEnd2 - segment2); - mPoints3D[7] = camera.ScreenToWorldPoint(lineEnd2 + segment2); - } - - mMeshFilter.mesh.vertices = MapView.Draw3DLines ? mPoints3D : mPoints2D; - - if (!MapView.Draw3DLines) - { - var bounds = new Bounds(); - bounds.center = new Vector3(Screen.width / 2, Screen.height / 2, Screen.height / 2); - bounds.extents = new Vector3(Screen.width * 100, Screen.height * 100, 0.1f); - mMeshFilter.mesh.bounds = bounds; - } - else - { - mMeshFilter.mesh.RecalculateBounds(); - } - } - - private void SetupMesh() - { - mMeshFilter = gameObject.AddComponent(); - mMeshFilter.mesh = new Mesh(); - mRenderer = gameObject.AddComponent(); - mMeshFilter.mesh.name = "NetworkLine"; - mMeshFilter.mesh.vertices = new Vector3[8]; - mMeshFilter.mesh.uv = new Vector2[8] { new Vector2(0, 1), new Vector2(0, 0), new Vector2(1, 1), new Vector2(1, 0), new Vector2(0, 1), new Vector2(0, 0), new Vector2(1, 1), new Vector2(1, 0) }; - mMeshFilter.mesh.SetIndices(new int[] { 0, 2, 1, 2, 3, 1, 4, 6, 5, 6, 7, 5}, MeshTopology.Triangles, 0); - mMeshFilter.mesh.MarkDynamic(); - Active = false; - } - - public void OnDestroy() - { - Active = false; - Destroy(mMeshFilter); - Destroy(mRenderer); - } - } -} \ No newline at end of file diff --git a/src/RemoteTech/UI/NetworkLine.cs b/src/RemoteTech/UI/NetworkLine.cs deleted file mode 100644 index 68d1514bb..000000000 --- a/src/RemoteTech/UI/NetworkLine.cs +++ /dev/null @@ -1,134 +0,0 @@ -using RemoteTech.SimpleTypes; -using System.Linq; -using UnityEngine; - -namespace RemoteTech.UI -{ - public class NetworkLine : MonoBehaviour - { - private static Material CommNetMaterial = null; - - public BidirectionalEdge Edge - { - set - { - UpdateMesh(value); - } - } - - public float LineWidth { get; set; } - - public Material Material - { - set - { - mRenderer.material = value; - } - } - - public Color Color - { - set - { - mMeshFilter.mesh.colors = Enumerable.Repeat(value, 4).ToArray(); - } - } - - public bool Active - { - set - { - mRenderer.enabled = value; - gameObject.SetActive(value); - } - } - - private MeshFilter mMeshFilter; - private MeshRenderer mRenderer; - private Vector3[] mPoints2D = new Vector3[4]; - private Vector3[] mPoints3D = new Vector3[4]; - - public static NetworkLine Instantiate() - { - return new GameObject("NetworkLine", typeof(NetworkLine)).GetComponent(); - } - - public void Awake() - { - if (CommNetMaterial == null) { CommNetMaterial = Resources.Load("Telemetry/TelemetryMaterial"); } - - SetupMesh(); - gameObject.layer = 31; - LineWidth = 1.0f; - Color = Color.white; - Material = CommNetMaterial; - } - - private void UpdateMesh(BidirectionalEdge edge) - { - var camera = PlanetariumCamera.Camera; - - var start = camera.WorldToScreenPoint(ScaledSpace.LocalToScaledSpace(edge.A.Position)); - var end = camera.WorldToScreenPoint(ScaledSpace.LocalToScaledSpace(edge.B.Position)); - - var segment = new Vector3(end.y - start.y, start.x - end.x, 0).normalized * (LineWidth / 2); - - if (!MapView.Draw3DLines) - { - //if position is behind camera - if (start.z < 0) { start = NetworkLine.FlipDirection(start, end); } - else if (end.z < 0) { end = NetworkLine.FlipDirection(end, start); } - - var dist = Screen.height / 2 + 0.01f; - start.z = start.z >= 0.15f ? dist : -dist; - end.z = end.z >= 0.15f ? dist : -dist; - - mPoints2D[0] = (start - segment); - mPoints2D[1] = (start + segment); - mPoints2D[2] = (end - segment); - mPoints2D[3] = (end + segment); - } - else - { - mPoints3D[0] = camera.ScreenToWorldPoint(start - segment); - mPoints3D[1] = camera.ScreenToWorldPoint(start + segment); - mPoints3D[2] = camera.ScreenToWorldPoint(end - segment); - mPoints3D[3] = camera.ScreenToWorldPoint(end + segment); - } - - mMeshFilter.mesh.vertices = MapView.Draw3DLines ? mPoints3D : mPoints2D; - mMeshFilter.mesh.RecalculateBounds(); - mMeshFilter.mesh.MarkDynamic(); - } - - private void SetupMesh() - { - mMeshFilter = gameObject.AddComponent(); - mMeshFilter.mesh = new Mesh(); - mRenderer = gameObject.AddComponent(); - mMeshFilter.mesh.name = "NetworkLine"; - mMeshFilter.mesh.vertices = new Vector3[4]; - mMeshFilter.mesh.uv = new Vector2[4] { new Vector2(0, 1), new Vector2(0, 0), new Vector2(1, 1), new Vector2(1, 0) }; - mMeshFilter.mesh.SetIndices(new int[] { 0, 2, 1, 2, 3, 1 }, MeshTopology.Triangles, 0); - Active = false; - } - - public void OnDestroy() - { - Active = false; - Destroy(mMeshFilter); - Destroy(mRenderer); - } - - public static Vector3 FlipDirection(Vector3 point, Vector3 pivot) - { - point -= pivot; //translate to origin - point.x *= -1; //flip - point.y *= -1; - point.z *= -1; - point += pivot; //undo translate - - return point; - } - } -} \ No newline at end of file diff --git a/src/RemoteTech/UI/OptionWindow.cs b/src/RemoteTech/UI/OptionWindow.cs index babcb9957..a45114ffa 100644 --- a/src/RemoteTech/UI/OptionWindow.cs +++ b/src/RemoteTech/UI/OptionWindow.cs @@ -10,12 +10,18 @@ namespace RemoteTech.UI class OptionWindow : AbstractWindow { #region Member - /// Defines the option window width + /// + /// Defines the option window width + /// const uint WINDOW_WIDTH = 430; - /// Defines the option window height + /// + /// Defines the option window height + /// const uint WINDOW_HEIGHT = 320; - /// Option menu items + /// + /// Option menu items + /// public enum OPTION_MENUS { Start = 0, @@ -27,37 +33,69 @@ public enum OPTION_MENUS Cheats } - /// Small gray hint text color + /// + /// Small gray hint text color + /// private GUIStyle mGuiHintText; - /// Small white running text color + /// + /// Small white running text color + /// private GUIStyle mGuiRunningText; - /// Textstyle for list entrys + /// + /// Textstyle for list entrys + /// private GUIStyle mGuiListText; - /// Button style for list entrys + /// + /// Button style for list entrys + /// private GUIStyle mGuiListButton; - /// Texture to represent the dish color + /// + /// Texture to represent the dish color + /// private Texture2D mVSColorDish; - /// Texture to represent the omni color + /// + /// Texture to represent the omni color + /// private Texture2D mVSColorOmni; - /// Texture to represent the active color + /// + /// Texture to represent the active color + /// private Texture2D mVSColorActive; - /// Texture to represent the remote station color + /// + /// Texture to represent the remote station color + /// private Texture2D mVSColorRemoteStation; - /// Toggles the color slider for the dish color + /// + /// Toggles the color slider for the dish color + /// private bool dishSlider = false; - /// Toggles the color slider for the omni color + /// + /// Toggles the color slider for the omni color + /// private bool omniSlider = false; - /// Toggles the color slider for the active color + /// + /// Toggles the color slider for the active color + /// private bool activeSlider = false; - /// Toggles the color slider for the remote station color + /// + /// Toggles the color slider for the remote station color + /// private bool remoteStationSlider = false; - /// HeadlineImage + /// + /// HeadlineImage + /// private Texture2D mTexHeadline; - /// Positionvector for the content scroller + /// + /// Positionvector for the content scroller + /// private Vector2 mOptionScrollPosition; - /// Reference to the RTSettings + /// + /// Reference to the RTSettings + /// private Settings mSettings { get { return RTSettings.Instance; } } - /// Current selected menu item + /// + /// Current selected menu item + /// private int mMenuValue; #endregion diff --git a/src/RemoteTech/UI/TargetInfoFragment.cs b/src/RemoteTech/UI/TargetInfoFragment.cs index 298d3c878..ba6232b2d 100644 --- a/src/RemoteTech/UI/TargetInfoFragment.cs +++ b/src/RemoteTech/UI/TargetInfoFragment.cs @@ -25,11 +25,17 @@ public KeyValuePair TargetInfos } } - /// Current target infos + /// + /// Current target infos + /// private Target target; - /// Style set for each row on the target pop-up + /// + /// Style set for each row on the target pop-up + /// private GUIStyle guiTableRow; - /// Style set for the headline of the target pop-up + /// + /// Style set for the headline of the target pop-up + /// private GUIStyle guiHeadline; /// diff --git a/src/RemoteTech/UI/TargetInfoWindow.cs b/src/RemoteTech/UI/TargetInfoWindow.cs index 1bd31ddfd..2a09bc889 100644 --- a/src/RemoteTech/UI/TargetInfoWindow.cs +++ b/src/RemoteTech/UI/TargetInfoWindow.cs @@ -5,19 +5,31 @@ namespace RemoteTech.UI { public class TargetInfoWindow : AbstractWindow { - /// Initial Window width of the targetInfowWindow + /// + /// Initial Window width of the targetInfowWindow + /// private const float WINDOW_WIDTH = 180; - /// Initial Window width of the targetInfowWindow + /// + /// Initial Window width of the targetInfowWindow + /// private const float WINDOW_HEIGHT = 10; public static Guid Guid = new Guid("c6ba7467-7ecd-dcc4-5861-46bcc25d5f45"); - /// The rearranged position based on the parent window or a fixed rect + /// + /// The rearranged position based on the parent window or a fixed rect + /// private Rect parentPos; - /// Holds the parent window to always get the current position of it + /// + /// Holds the parent window to always get the current position of it + /// public AbstractWindow ParentWindow { get; set; } - /// The alignment of this window + /// + /// The alignment of this window + /// private WindowAlign PopupAlignment { get; set; } - /// Trigger to get the position from the parent window or not + /// + /// Trigger to get the position from the parent window or not + /// private bool FixPosition { get; set; } //////////////////////////// TargetInfoFragment tif; diff --git a/src/RemoteTech/UI/TimeWarpDecorator.cs b/src/RemoteTech/UI/TimeWarpDecorator.cs index 17786dfda..ef7d8c7ea 100644 --- a/src/RemoteTech/UI/TimeWarpDecorator.cs +++ b/src/RemoteTech/UI/TimeWarpDecorator.cs @@ -55,11 +55,11 @@ private String DisplayText { return Localizer.Format("#RT_ConnectionStatus2");//"Local Control" } - else if (vs.Connections.Any()) + else if (RTCore.Instance.Network.IsConnected(vs)) { if (RTSettings.Instance.EnableSignalDelay) { - return Localizer.Format("#RT_ConnectionStatus3",vs.Connections[0].Delay.ToString("F5"));//"D+ " + + "s" + return Localizer.Format("#RT_ConnectionStatus3",RTCore.Instance.Network.ShortestDelay(vs).ToString("F5"));//"D+ " + + "s" } else { @@ -83,7 +83,7 @@ private GUIStyle ButtonStyle return mFlightButtonRed; } - else if (vs.Connections.Any()) + else if (RTCore.Instance.Network.IsConnected(vs)) { if (vs.HasLocalControl) return mFlightButtonYellow; @@ -167,7 +167,7 @@ public void Draw() // get color for the delay-text mTextStyle.normal.textColor = new Color(0.56078f, 0.10196f, 0.07450f); - if (this.mVessel != null && this.mVessel.Connections.Any()) + if (this.mVessel != null && RTCore.Instance.Network.IsConnected(this.mVessel)) { mTextStyle.normal.textColor = XKCDColors.GreenApple; } diff --git a/src/RemoteTech/VesselSatellite.cs b/src/RemoteTech/VesselSatellite.cs index f491428cb..56fd7ed46 100644 --- a/src/RemoteTech/VesselSatellite.cs +++ b/src/RemoteTech/VesselSatellite.cs @@ -12,96 +12,186 @@ namespace RemoteTech /// public class VesselSatellite : ISatellite { - /// Gets whether or not the satellite if visible in the Tracking station or the Flight Map view. - public bool Visible => SignalProcessor.Visible; + public Vessel Vessel { get; private set; } - /// Gets or sets the name of the satellite. + /// + /// Gets whether or not the satellite is visible in the Tracking station or the Flight Map view. + /// + public bool Visible => MapViewFiltering.CheckAgainstFilter(Vessel); + + /// + /// Gets or sets the name of the satellite. + /// public string Name { - get { return SignalProcessor.VesselName; } - set { SignalProcessor.VesselName = value; } + get => Vessel.vesselName; + set => Vessel.vesselName = value; } - /// Gets the satellite id. - public Guid Guid => SignalProcessor.VesselId; + /// + /// Gets the satellite id. + /// + public Guid Guid => Vessel.id; - /// Get a double precision vector for the vessel's world space position. - public Vector3d Position => SignalProcessor.Position; + /// + /// Get a double precision vector for the vessel's world space position. + /// + public Vector3d Position => Vessel.GetWorldPos3D(); - /// Gets the celestial body around which the satellite is orbiting. - public CelestialBody Body => SignalProcessor.Body; + /// + /// Gets the celestial body around which the satellite is orbiting. + /// + public CelestialBody Body => Vessel.mainBody; - /// Gets the color of the ground station mark in Tracking station or Flight map view. + /// + /// Gets the color of the ground station mark in Tracking station or Flight map view. + /// public Color MarkColor => RTSettings.Instance.RemoteStationColorDot; - /// Gets or sets the list of signal processor () for the satellite. + /// + /// Gets or sets the list of signal processor () for the satellite. + /// public List SignalProcessors { get; set; } - /// Gets if the satellite is actually powered or not. + /// + /// Gets if the satellite is actually powered or not. + /// public bool Powered { - get { return (PowerShutdownFlag)? false : SignalProcessors.Any(s => s.Powered); } + get + { + if (PowerShutdownFlag) + return false; + + foreach (var s in SignalProcessors) + { + if (s.Powered) + return true; + } + + return false; + } } - /// Gets if the satellite is capable to forward other signals. + /// + /// Gets if the satellite is capable to forward other signals. + /// public bool CanRelaySignal { - get { return RTSettings.Instance.SignalRelayEnabled ? SignalProcessors.Any(s => s.CanRelaySignal && !(s is ModuleSPUPassive)) : true; } + get + { + if (!RTSettings.Instance.SignalRelayEnabled) + return true; + + foreach (var s in SignalProcessors) + { + if (s is ModuleSPUPassive) + continue; + + if (s.CanRelaySignal) + return true; + } + + return false; + } } - /// Indicates whether the satellite is in radio blackout. + /// + /// Indicates whether the satellite is in radio blackout. + /// public bool IsInRadioBlackout { get; set; } - /// Indicates whether the manual power override is engaged. + /// + /// Indicates whether the manual power override is engaged. + /// public bool PowerShutdownFlag { get; set; } - /// Gets if the satellite is a RemoteTech command station. + /// + /// Gets if the satellite is a RemoteTech command station. + /// public bool IsCommandStation { - get { return SignalProcessors.Any(s => s.IsCommandStation); } + get + { + foreach (var s in SignalProcessors) + { + if (s.IsCommandStation) + return true; + } + + return false; + } } - /// Gets a signal processor. + /// + /// Gets a signal processor. + /// public ISignalProcessor SignalProcessor { get { - return SignalProcessors.FirstOrDefault(s => s.FlightComputer != null) ?? SignalProcessors[0]; + foreach (var s in SignalProcessors) + { + if (s.FlightComputer is not null) + return s; + } + + if (SignalProcessors.Count != 0) + return SignalProcessors[0]; + + return null; } } - /// Gets whether the satellite has local control or not (that is, if it is locally controlled or not). + /// + /// Local control cache variable. + /// + private CachedField _localControl; + + /// + /// Gets whether the satellite has local control or not (that is, if it is locally controlled or not). + /// public bool HasLocalControl { get { - return RTUtil.CachePerFrame(ref _localControl, () => SignalProcessor.Vessel.HasLocalControl()); + if (RTUtil.ShouldUpdateCache(ref _localControl)) + _localControl.Field = Vessel.HasLocalControl(); + + return _localControl.Field; } } - /// Indicates whether the ISatellite corresponds to a vessel. + /// + /// Indicates whether the ISatellite corresponds to a vessel. + /// /// true if satellite is vessel or asteroid; otherwise (e.g. a ground station), false. /// Implementation note: always return true for a . public bool isVessel => true; - /// The vessel hosting the satellite. + /// + /// The vessel hosting the satellite. + /// /// The vessel corresponding to this ISatellite. Returns null if !isVessel. - public Vessel parentVessel => SignalProcessor.Vessel; + public Vessel parentVessel => Vessel; - /// Gets a list of antennas for this satellite. - public IEnumerable Antennas => RTCore.Instance.Antennas[this]; + /// + /// Gets a list of antennas for this satellite. + /// + public IReadOnlyList Antennas => RTCore.Instance.Antennas[this]; - /// Gets the flight computer for this satellite. + /// + /// Gets the flight computer for this satellite. + /// public FlightComputer.FlightComputer FlightComputer => SignalProcessor.FlightComputer; /* * Helpers */ - /// List of network routes for the satellite. - public List> Connections => RTCore.Instance.Network[this]; - - /// Called on connection refresh to update the connections. + /// + /// Called on connection refresh to update the connections. + /// /// List of network routes. public void OnConnectionRefresh(List> routes) { @@ -111,16 +201,15 @@ public void OnConnectionRefresh(List> routes) } } - /// Local control cache variable. - private CachedField _localControl; - /* * Methods */ - /// Build a new instance of VesselSatellite. + /// + /// Build a new instance of VesselSatellite. + /// /// List of signal processor for this satellites. Can't be null. - public VesselSatellite(List signalProcessors) + public VesselSatellite(Vessel vessel, List signalProcessors) { if (signalProcessors == null) { @@ -128,9 +217,24 @@ public VesselSatellite(List signalProcessors) throw new ArgumentNullException(); } + Vessel = vessel; SignalProcessors = signalProcessors; } + public SatelliteState GetState() + { + return new SatelliteState + { + Guid = Guid, + Body = RTUtil.Guid(Body), + Position = Vessel.CoMD, + Powered = Powered, + IsCommandStation = IsCommandStation, + CanRelaySignal = CanRelaySignal, + IsInRadioBlackout = IsInRadioBlackout, + }; + } + public override string ToString() { return $"VesselSatellite({Name}, {Guid})"; diff --git a/tests/RemoteTech.InGameTests/Assert.cs b/tests/RemoteTech.InGameTests/Assert.cs new file mode 100644 index 000000000..c45c23b51 --- /dev/null +++ b/tests/RemoteTech.InGameTests/Assert.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace RemoteTech.InGameTests; + +/// +/// MSTest-compatible assertion shim that throws on failure. KSP's test +/// framework () treats a thrown exception +/// as a failed test. Adapted from the sibling BackgroundResourceProcessing +/// test harness. +/// +public static class Assert +{ + public static void AreEqual(T expected, T actual) + { + if (!EqualityComparer.Default.Equals(expected, actual)) + throw new Exception($"Assert.AreEqual failed. Expected:<{expected}>. Actual:<{actual}>."); + } + + public static void AreEqual(T expected, T actual, string message) + { + if (!EqualityComparer.Default.Equals(expected, actual)) + throw new Exception($"Assert.AreEqual failed. Expected:<{expected}>. Actual:<{actual}>. {message}"); + } + + public static void AreEqual(double expected, double actual, double delta) + { + if (double.IsNaN(expected) || double.IsNaN(actual) || Math.Abs(expected - actual) > delta) + throw new Exception($"Assert.AreEqual failed. Expected:<{expected}>. Actual:<{actual}>. Delta:<{delta}>."); + } + + public static void AreEqual(double expected, double actual, double delta, string message) + { + if (double.IsNaN(expected) || double.IsNaN(actual) || Math.Abs(expected - actual) > delta) + throw new Exception($"Assert.AreEqual failed. Expected:<{expected}>. Actual:<{actual}>. Delta:<{delta}>. {message}"); + } + + public static void AreNotEqual(T expected, T actual) + { + if (EqualityComparer.Default.Equals(expected, actual)) + throw new Exception($"Assert.AreNotEqual failed. Expected any value except:<{expected}>."); + } + + public static void IsTrue(bool condition) + { + if (!condition) throw new Exception("Assert.IsTrue failed."); + } + + public static void IsTrue(bool condition, string message) + { + if (!condition) throw new Exception($"Assert.IsTrue failed. {message}"); + } + + public static void IsFalse(bool condition) + { + if (condition) throw new Exception("Assert.IsFalse failed."); + } + + public static void IsFalse(bool condition, string message) + { + if (condition) throw new Exception($"Assert.IsFalse failed. {message}"); + } + + public static T ThrowsException(Action action) + where T : Exception + { + try + { + action(); + } + catch (T ex) + { + return ex; + } + catch (Exception ex) + { + throw new Exception($"Assert.ThrowsException failed. Expected:<{typeof(T).Name}>. Actual:<{ex.GetType().Name}>."); + } + throw new Exception($"Assert.ThrowsException failed. Expected:<{typeof(T).Name}>. No exception was thrown."); + } + + public static void Fail(string message) + { + throw new Exception($"Assert.Fail. {message}"); + } + + public static void IsNull(object value) + { + if (value != null) throw new Exception($"Assert.IsNull failed. Actual:<{value}>."); + } + + public static void IsNotNull(object value) + { + if (value == null) throw new Exception("Assert.IsNotNull failed."); + } + + public static void IsNotNull(object value, string message) + { + if (value == null) throw new Exception($"Assert.IsNotNull failed. {message}"); + } + + public static void AreSame(object expected, object actual) + { + if (!ReferenceEquals(expected, actual)) + throw new Exception($"Assert.AreSame failed. Expected:<{expected}>. Actual:<{actual}>."); + } +} + +public static class CollectionAssert +{ + public static void AreEqual(ICollection expected, ICollection actual) + { + if (expected == null && actual == null) return; + if (expected == null || actual == null) + throw new Exception("CollectionAssert.AreEqual failed. One collection is null."); + if (expected.Count != actual.Count) + throw new Exception($"CollectionAssert.AreEqual failed. Expected count:<{expected.Count}>. Actual count:<{actual.Count}>."); + + var e = expected.GetEnumerator(); + var a = actual.GetEnumerator(); + int index = 0; + while (e.MoveNext() && a.MoveNext()) + { + if (!Equals(e.Current, a.Current)) + throw new Exception($"CollectionAssert.AreEqual failed at index {index}. Expected:<{e.Current}>. Actual:<{a.Current}>."); + index++; + } + } +} diff --git a/tests/RemoteTech.InGameTests/Collections/ArrayMapTests.cs b/tests/RemoteTech.InGameTests/Collections/ArrayMapTests.cs new file mode 100644 index 000000000..2d5998490 --- /dev/null +++ b/tests/RemoteTech.InGameTests/Collections/ArrayMapTests.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using KSP.Testing; +using RemoteTech.Collections; + +namespace RemoteTech.InGameTests.Collections; + +public class ArrayMapTests : RTTestBase +{ + [TestInfo("ArrayMapTests_ListAsSpan_ReturnsOccupiedElements_NotUnusedCapacity")] + public void ListAsSpan_ReturnsOccupiedElements_NotUnusedCapacity() + { + var list = new List(16) { 1, 2, 3 }; + var span = list.AsSpan(); + + Assert.AreEqual(3, span.Length); + Assert.AreEqual(1, span[0]); + Assert.AreEqual(2, span[1]); + Assert.AreEqual(3, span[2]); + } + + [TestInfo("ArrayMapTests_Values_AfterRemove_ContainsOnlyLiveEntries")] + public void Values_AfterRemove_ContainsOnlyLiveEntries() + { + var map = new ArrayMap(); + var a = Guid.NewGuid(); + var b = Guid.NewGuid(); + var c = Guid.NewGuid(); + + map[a] = "a"; + map[b] = "b"; + map[c] = "c"; + map.Remove(b); + + var values = map.Values; + Assert.AreEqual(2, values.Length); + foreach (var value in values) + Assert.IsNotNull(value); + } +} diff --git a/tests/RemoteTech.InGameTests/Collections/SpookyHashTests.cs b/tests/RemoteTech.InGameTests/Collections/SpookyHashTests.cs new file mode 100644 index 000000000..a1460fd3c --- /dev/null +++ b/tests/RemoteTech.InGameTests/Collections/SpookyHashTests.cs @@ -0,0 +1,94 @@ +using System; +using KSP.Testing; +using RemoteTech.Collections; +using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; +using UnityEngine; + +namespace RemoteTech.InGameTests.Collections; + +public unsafe class SpookyHashTests : RTTestBase +{ + [TestInfo("SpookyHashTests_MatchesUnity_ForInt")] + public void MatchesUnity_ForInt() + { + foreach (int value in new[] { 0, 1, -1, 12345, int.MinValue, int.MaxValue }) + { + int v = value; + Hash128 expected = default; + HashUtilities.ComputeHash128(ref v, ref expected); + + Hash128 actual = default; + SpookyHash.Hash128(&v, sizeof(int), ref actual); + + Assert.AreEqual(expected, actual, $"int {value}"); + } + } + + [TestInfo("SpookyHashTests_MatchesUnity_ForGuidArrays")] + public void MatchesUnity_ForGuidArrays() + { + foreach (int count in new[] { 1, 2, 3, 4, 5, 8, 13 }) + { + var arena = new NativeArray(count, Allocator.Temp); + for (int i = 0; i < count; i++) + arena[i] = Guid.NewGuid(); + + int size = count * UnsafeUtility.SizeOf(); + + Hash128 expected = default; + HashUnsafeUtilities.ComputeHash128(NativeArrayUnsafeUtility.GetUnsafePtr(arena), (ulong)size, &expected); + + Hash128 actual = default; + SpookyHash.Hash128(NativeArrayUnsafeUtility.GetUnsafePtr(arena), size, ref actual); + + Assert.AreEqual(expected, actual, $"{count} guids"); + } + } + + [TestInfo("SpookyHashTests_MatchesUnity_ForLongBuffer")] + public void MatchesUnity_ForLongBuffer() + { + // Exercises the >=192-byte block (Mix/End) path, not just Short. + // + // We'd normally use a length of 191, but unity's implementation has a bug there that + // makes it incorrect so we use 188. + foreach (int length in new[] { 188, 192, 193, 300, 500 }) + { + var buf = new NativeArray(length, Allocator.Temp); + var rng = new System.Random(length); + for (int i = 0; i < length; i++) + buf[i] = (byte)rng.Next(256); + + Hash128 expected = default; + HashUnsafeUtilities.ComputeHash128(NativeArrayUnsafeUtility.GetUnsafePtr(buf), (ulong)length, &expected); + + Hash128 actual = default; + SpookyHash.Hash128(NativeArrayUnsafeUtility.GetUnsafePtr(buf), length, ref actual); + + Assert.AreEqual(expected, actual, $"length {length}"); + } + } + + [TestInfo("SpookyHashTests_MatchesUnity_ForChainedSeed")] + public void MatchesUnity_ForChainedSeed() + { + // Mirrors NetworkUpdate.HashPath: hash a small int, then + // feed a guid array using the running hash as the seed for the next call. + int state = 2; + var guids = new NativeArray(3, Allocator.Temp); + for (int i = 0; i < guids.Length; i++) + guids[i] = Guid.NewGuid(); + int size = guids.Length * UnsafeUtility.SizeOf(); + + Hash128 expected = default; + HashUtilities.ComputeHash128(ref state, ref expected); + HashUnsafeUtilities.ComputeHash128(NativeArrayUnsafeUtility.GetUnsafePtr(guids), (ulong)size, &expected); + + Hash128 actual = default; + SpookyHash.Hash128(&state, sizeof(int), ref actual); + SpookyHash.Hash128(NativeArrayUnsafeUtility.GetUnsafePtr(guids), size, ref actual); + + Assert.AreEqual(expected, actual); + } +} diff --git a/tests/RemoteTech.InGameTests/Network/BuildAdjacencyJobTests.cs b/tests/RemoteTech.InGameTests/Network/BuildAdjacencyJobTests.cs new file mode 100644 index 000000000..b3be1d201 --- /dev/null +++ b/tests/RemoteTech.InGameTests/Network/BuildAdjacencyJobTests.cs @@ -0,0 +1,130 @@ +using System.Collections.Generic; +using KSP.Testing; +using RemoteTech.Collections; +using RemoteTech.Network; +using RemoteTech.SimpleTypes; +using Unity.Collections; + +namespace RemoteTech.InGameTests.Network; + +public class BuildAdjacencyJobTests : RTTestBase +{ + private static NetworkEdge Edge(int a, int b, double dist, bool valid = true) + => new NetworkEdge { aIdx = a, bIdx = b, distance = dist, linkType = valid ? LinkType.Omni : LinkType.None }; + + private static JobNode Node(NodeFlags flags) => new JobNode { flags = flags }; + + private sealed class Csr + { + public NativeList Ranges; + public NativeList Adjacency; + public NativeList Distances; + + public List<(int target, double cost)> Row(int node) + { + var list = new List<(int, double)>(); + foreach (int e in Ranges[node]) + list.Add((Adjacency[e], Distances[e])); + return list; + } + } + + private static Csr Build(JobNode[] nodes, NetworkEdge[] edges) + { + var nodeArr = new NativeArray(nodes, Allocator.Temp); + var edgeArr = new NativeArray(edges, Allocator.Temp); + + var ranges = new NativeList(nodes.Length, Allocator.Temp); + ranges.ResizeUninitialized(nodes.Length); + var adjacency = new NativeList(0, Allocator.Temp); + var distances = new NativeList(0, Allocator.Temp); + + NetworkUpdate.ComputeAdjacencyLists(nodeArr, edgeArr, ranges, adjacency, distances); + + return new Csr { Ranges = ranges, Adjacency = adjacency, Distances = distances }; + } + + [TestInfo("BuildAdjacencyJobTests_UndirectedEdge_AppearsOnBothRows")] + public void UndirectedEdge_AppearsOnBothRows() + { + var powered = NodeFlags.Powered | NodeFlags.CanRelay; + var csr = Build( + new[] { Node(powered), Node(powered), Node(powered) }, + new[] { Edge(0, 1, 10.0), Edge(1, 2, 20.0) }); + + CollectionAssert.AreEqual(new[] { (1, 10.0) }, csr.Row(0)); + CollectionAssert.AreEqual(new[] { (0, 10.0), (2, 20.0) }, csr.Row(1)); + CollectionAssert.AreEqual(new[] { (1, 20.0) }, csr.Row(2)); + int total = csr.Ranges[0].Length + csr.Ranges[1].Length + csr.Ranges[2].Length; + Assert.AreEqual(4, total); // two undirected edges -> four directed entries + } + + [TestInfo("BuildAdjacencyJobTests_InvalidEdges_AreSkipped")] + public void InvalidEdges_AreSkipped() + { + var powered = NodeFlags.Powered | NodeFlags.CanRelay; + var csr = Build( + new[] { Node(powered), Node(powered) }, + new[] { Edge(0, 1, 10.0, valid: false) }); + + Assert.AreEqual(0, csr.Row(0).Count); + Assert.AreEqual(0, csr.Row(1).Count); + } + + [TestInfo("BuildAdjacencyJobTests_UnpoweredEndpoint_MakesEdgeNonTraversable")] + public void UnpoweredEndpoint_MakesEdgeNonTraversable() + { + var csr = Build( + new[] { Node(NodeFlags.Powered), Node(NodeFlags.None) }, + new[] { Edge(0, 1, 10.0) }); + + Assert.AreEqual(0, csr.Row(0).Count); + Assert.AreEqual(0, csr.Row(1).Count); + } + + [TestInfo("BuildAdjacencyJobTests_RelayCapability_DoesNotGateAdjacency")] + public void RelayCapability_DoesNotGateAdjacency() + { + // Node 1 is powered but not relay-capable. The edge still exists — relay + // gates transit (in the Dijkstra), not whether the link is present. + var csr = Build( + new[] { Node(NodeFlags.Powered | NodeFlags.CanRelay), Node(NodeFlags.Powered) }, + new[] { Edge(0, 1, 10.0) }); + + Assert.AreEqual(1, csr.Row(0).Count); + Assert.AreEqual(1, csr.Row(1).Count); + } + + [TestInfo("BuildAdjacencyJobTests_CanTransit_GatesOnRelayCapabilityWhenEnabled")] + public void CanTransit_GatesOnRelayCapabilityWhenEnabled() + { + var nodes = new NativeArray(new[] + { + Node(NodeFlags.Powered | NodeFlags.CanRelay), + Node(NodeFlags.Powered), + }, Allocator.Temp); + + var off = new NativeBitArray(2, Allocator.Temp); + NetworkUpdate.ComputeCanTransit(new JobConfig { signalRelayEnabled = false }, nodes, off); + Assert.IsTrue(off.IsSet(0)); + Assert.IsTrue(off.IsSet(1)); // relay disabled -> everyone can transit + + var on = new NativeBitArray(2, Allocator.Temp); + NetworkUpdate.ComputeCanTransit(new JobConfig { signalRelayEnabled = true }, nodes, on); + Assert.IsTrue(on.IsSet(0)); + Assert.IsFalse(on.IsSet(1)); // relay enabled -> non-relay node cannot transit + } + + [TestInfo("BuildAdjacencyJobTests_VesselConnected_FlagsOnlyNonEmptyRanges")] + public void VesselConnected_FlagsOnlyNonEmptyRanges() + { + var ranges = new NativeArray(new[] { new IntRange(0, 1), new IntRange(1, 0), new IntRange(1, 2) }, Allocator.Temp); + var connected = new NativeBitArray(3, Allocator.Temp); + + NetworkUpdate.ComputeVesselConnected(ranges, connected); + + Assert.IsTrue(connected.IsSet(0)); + Assert.IsFalse(connected.IsSet(1)); + Assert.IsTrue(connected.IsSet(2)); + } +} diff --git a/tests/RemoteTech.InGameTests/Network/BuildEdgesJobTests.cs b/tests/RemoteTech.InGameTests/Network/BuildEdgesJobTests.cs new file mode 100644 index 000000000..b04b88330 --- /dev/null +++ b/tests/RemoteTech.InGameTests/Network/BuildEdgesJobTests.cs @@ -0,0 +1,269 @@ +using System.Collections.Generic; +using KSP.Testing; +using RemoteTech.Collections; +using RemoteTech.Network; +using RemoteTech.SimpleTypes; +using Unity.Collections; +using Unity.Jobs; +using Unity.Mathematics; + +using RModel = RemoteTech.RangeModel.RangeModel; + +namespace RemoteTech.InGameTests.Network; + +/// +/// Two nodes a distance apart, joined (or not) by a single edge — exercises BuildEdgesJob's reject ladder and link typing. +/// +public class BuildEdgesJobTests : RTTestBase +{ + [TestInfo("BuildEdgesJobTests_TwoOmnis_WithinRange_FormOmniLink")] + public void TwoOmnis_WithinRange_FormOmniLink() + { + var scene = new EdgeScene { IgnoreLos = true }; + scene.AddNode(new double3(0, 0, 0), maxRange: 1e9, AntSpec.Omni(1000)); + scene.AddNode(new double3(100, 0, 0), maxRange: 1e9, AntSpec.Omni(1000)); + + NetworkEdge e = scene.Run(RModel.Standard); + + Assert.IsTrue(e.valid); + Assert.AreEqual(LinkType.Omni, e.linkType); + Assert.AreEqual(100.0, e.distance, 1e-6); + } + + [TestInfo("BuildEdgesJobTests_TwoOmnis_OutOfRange_NoLink")] + public void TwoOmnis_OutOfRange_NoLink() + { + // Prefilter passes (maxRange large) but the omni range itself is too short. + var scene = new EdgeScene { IgnoreLos = true }; + scene.AddNode(new double3(0, 0, 0), maxRange: 1e9, AntSpec.Omni(50)); + scene.AddNode(new double3(100, 0, 0), maxRange: 1e9, AntSpec.Omni(50)); + + NetworkEdge e = scene.Run(RModel.Standard); + + Assert.IsFalse(e.valid); + } + + [TestInfo("BuildEdgesJobTests_Blackout_ShortCircuits_NoLink")] + public void Blackout_ShortCircuits_NoLink() + { + var scene = new EdgeScene { IgnoreLos = true }; + scene.AddNode(new double3(0, 0, 0), maxRange: 1e9, NodeFlags.Powered | NodeFlags.InBlackout, AntSpec.Omni(1000)); + scene.AddNode(new double3(100, 0, 0), maxRange: 1e9, AntSpec.Omni(1000)); + + NetworkEdge e = scene.Run(RModel.Standard); + + Assert.IsFalse(e.valid); + } + + [TestInfo("BuildEdgesJobTests_RangePrefilter_RejectsBeforeRangeModel")] + public void RangePrefilter_RejectsBeforeRangeModel() + { + // Huge antennas, but the per-vessel max-range cap is below the distance, + // so the cheap prefilter rejects before the range model runs. + var scene = new EdgeScene { IgnoreLos = true }; + scene.AddNode(new double3(0, 0, 0), maxRange: 10, AntSpec.Omni(1e9)); + scene.AddNode(new double3(100, 0, 0), maxRange: 10, AntSpec.Omni(1e9)); + + NetworkEdge e = scene.Run(RModel.Standard); + + Assert.IsFalse(e.valid); + } + + [TestInfo("BuildEdgesJobTests_Occluder_BetweenNodes_BlocksLink")] + public void Occluder_BetweenNodes_BlocksLink() + { + var scene = new EdgeScene { IgnoreLos = false }; + scene.AddBody(new double3(50, 0, 0), radius: 10); + scene.AddNode(new double3(0, 0, 0), maxRange: 1e9, AntSpec.Omni(1000)); + scene.AddNode(new double3(100, 0, 0), maxRange: 1e9, AntSpec.Omni(1000)); + + NetworkEdge e = scene.Run(RModel.Standard); + + Assert.IsFalse(e.valid); + } + + [TestInfo("BuildEdgesJobTests_Occluder_OffAxis_LeavesLinkClear")] + public void Occluder_OffAxis_LeavesLinkClear() + { + var scene = new EdgeScene { IgnoreLos = false }; + scene.AddBody(new double3(50, 100, 0), radius: 10); + scene.AddNode(new double3(0, 0, 0), maxRange: 1e9, AntSpec.Omni(1000)); + scene.AddNode(new double3(100, 0, 0), maxRange: 1e9, AntSpec.Omni(1000)); + + NetworkEdge e = scene.Run(RModel.Standard); + + Assert.IsTrue(e.valid); + Assert.AreEqual(LinkType.Omni, e.linkType); + } + + [TestInfo("BuildEdgesJobTests_TwoDishes_DirectlyTargeting_FormDishLink")] + public void TwoDishes_DirectlyTargeting_FormDishLink() + { + var scene = new EdgeScene { IgnoreLos = true }; + // node 0 dish -> node 1 (encoded target = otherIndex + 1 = 2) + scene.AddNode(new double3(0, 0, 0), maxRange: 1e9, AntSpec.Dish(1000, target: 2)); + // node 1 dish -> node 0 (encoded target = 1) + scene.AddNode(new double3(100, 0, 0), maxRange: 1e9, AntSpec.Dish(1000, target: 1)); + + NetworkEdge e = scene.Run(RModel.Standard); + + Assert.IsTrue(e.valid); + Assert.AreEqual(LinkType.Dish, e.linkType); + } + + [TestInfo("BuildEdgesJobTests_Dish_SeesTargetWithinCone_FormsLink")] + public void Dish_SeesTargetWithinCone_FormsLink() + { + var scene = new EdgeScene { IgnoreLos = true }; + // node 0 dish points down +X (toward node 1) with a wide cone, but is not + // directly targeting node 1 (target encodes "some body"). The cone test + // should still see node 1. + scene.AddNode(new double3(0, 0, 0), maxRange: 1e9, + AntSpec.Dish(1000, target: -1, cosAngle: 0.5, dir: new double3(1, 0, 0))); + // node 1 answers with a plain omni. + scene.AddNode(new double3(100, 0, 0), maxRange: 1e9, AntSpec.Omni(1000)); + + NetworkEdge e = scene.Run(RModel.Standard); + + Assert.IsTrue(e.valid); + Assert.AreEqual(LinkType.Dish, e.linkType); + } + + [TestInfo("BuildEdgesJobTests_Dish_TargetOutsideCone_NoLink")] + public void Dish_TargetOutsideCone_NoLink() + { + // Dish points away (-X) from node 1, narrow cone -> cannot see it; no omni to fall back on. + var scene = new EdgeScene { IgnoreLos = true }; + scene.AddNode(new double3(0, 0, 0), maxRange: 1e9, + AntSpec.Dish(1000, target: -1, cosAngle: 0.9, dir: new double3(-1, 0, 0))); + scene.AddNode(new double3(100, 0, 0), maxRange: 1e9, AntSpec.Omni(1000)); + + NetworkEdge e = scene.Run(RModel.Standard); + + Assert.IsFalse(e.valid); + } +} + +/// +/// Antenna spec for . +/// +internal struct AntSpec +{ + public double OmniRange; + public double DishRange; + public double CosAngle; + public int Target; + public double3 Dir; + + public static AntSpec Omni(double range) => new AntSpec { OmniRange = range }; + + public static AntSpec Dish(double range, int target, double cosAngle = 0.0, double3 dir = default) + => new AntSpec { DishRange = range, Target = target, CosAngle = cosAngle, Dir = dir }; +} + +/// +/// Builds a real BuildEdgesJob run over a 2-node pair. Body 0 is the universal +/// nearest-common-ancestor whose subtree spans every body, so any added body is +/// an occlusion candidate. +/// +internal sealed class EdgeScene +{ + private readonly List _nodes = new(); + private readonly List _antennas = new(); + private readonly List _dirs = new(); + private readonly List _bodies = new(); + private readonly List _maxRange = new(); + + public bool IgnoreLos = true; + public double OmniClamp = 1.0; + public double DishClamp = 1.0; + public double MultipleAntennaMultiplier = 0.0; + + public void AddBody(double3 pos, double radius) + { + _bodies.Add(new JobBody { position = pos, radius = radius }); + } + + public int AddNode(double3 pos, double maxRange, params AntSpec[] ants) + => AddNode(pos, maxRange, NodeFlags.Powered | NodeFlags.CanRelay, ants); + + public int AddNode(double3 pos, double maxRange, NodeFlags flags, params AntSpec[] ants) + { + int start = _antennas.Count; + int nodeIdx = _nodes.Count; + foreach (var a in ants) + { + _antennas.Add(new JobAntenna + { + nodeIndex = nodeIdx, + omni = a.OmniRange, + dish = a.DishRange, + cosAngle = a.CosAngle, + target = a.Target, + flags = AntennaFlags.Activated | AntennaFlags.Powered, + }); + _dirs.Add(a.Dir); + } + _nodes.Add(new JobNode + { + position = pos, + bodyIndex = 0, + antennas = new IntRange(start, ants.Length), + kind = NodeKind.Vessel, + flags = flags, + }); + _maxRange.Add(maxRange); + return nodeIdx; + } + + public NetworkEdge Run(RModel model) + { + int bodyCount = System.Math.Max(1, _bodies.Count); + var bodyArr = new JobBody[bodyCount]; + for (int i = 0; i < _bodies.Count; i++) bodyArr[i] = _bodies[i]; + // Body 0 is the universal NCA; its subtree spans all bodies so the LOS + // sweep considers every occluder we added. + var b0 = bodyArr[0]; + b0.subtree = new IntRange(0, bodyCount); + bodyArr[0] = b0; + + var nodes = new NativeArray(_nodes.ToArray(), Allocator.TempJob); + var antennas = new NativeArray(_antennas.ToArray(), Allocator.TempJob); + var dirs = new NativeArray(_dirs.ToArray(), Allocator.TempJob); + var bodies = new NativeArray(bodyArr, Allocator.TempJob); + var maxRange = new NativeArray(_maxRange.ToArray(), Allocator.TempJob); + var edges = new NativeList(1, Allocator.TempJob); + edges.ResizeUninitialized(1); + + var config = new JobConfig + { + omniClamp = OmniClamp, + dishClamp = DishClamp, + multipleAntennaMultiplier = MultipleAntennaMultiplier, + ignoreLineOfSight = IgnoreLos, + }; + + new BuildEdgesJob + { + config = config, + model = model, + nodes = nodes, + antennas = antennas, + bodies = bodies, + vesselMaxRange = maxRange, + antennaDirections = dirs, + edges = edges.AsArray(), + }.Schedule(edges.Length, 64).Complete(); + + NetworkEdge result = edges[0]; + + nodes.Dispose(); + antennas.Dispose(); + dirs.Dispose(); + bodies.Dispose(); + maxRange.Dispose(); + edges.Dispose(); + + return result; + } +} diff --git a/tests/RemoteTech.InGameTests/Network/ConeMeshJobTests.cs b/tests/RemoteTech.InGameTests/Network/ConeMeshJobTests.cs new file mode 100644 index 000000000..81cfe6e67 --- /dev/null +++ b/tests/RemoteTech.InGameTests/Network/ConeMeshJobTests.cs @@ -0,0 +1,85 @@ +using KSP.Testing; +using RemoteTech.Network; +using Unity.Collections; + +namespace RemoteTech.InGameTests.Network; + +/// +/// Exercises NetworkUpdate.ComputeConeCandidates's eligibility filter +/// (Powered, CanTarget, has a target). +/// +public class GatherConeCandidatesJobTests : RTTestBase +{ + private static JobAntenna Antenna(AntennaFlags flags, int target, int nodeIndex = 0, double cosAngle = 0.5, double dish = 1000.0) + => new JobAntenna { nodeIndex = nodeIndex, target = target, cosAngle = cosAngle, dish = dish, flags = flags }; + + private static NativeList Gather(params JobAntenna[] antennas) + { + var arr = new NativeArray(antennas, Allocator.Temp); + var candidates = new NativeList(antennas.Length, Allocator.Temp); + + NetworkUpdate.ComputeConeCandidates(arr, candidates); + + return candidates; + } + + [TestInfo("GatherConeCandidatesJobTests_PoweredCanTargetWithTarget_IsIncluded")] + public void PoweredCanTargetWithTarget_IsIncluded() + { + var ant = Antenna(AntennaFlags.Powered | AntennaFlags.CanTarget, target: 5, nodeIndex: 3, cosAngle: 0.9, dish: 1234.0); + var candidates = Gather(ant); + + Assert.AreEqual(1, candidates.Length); + Assert.AreEqual(3, candidates[0].nodeIndex); + Assert.AreEqual(5, candidates[0].target); + Assert.AreEqual(0.9, candidates[0].cosAngle, 1e-9); + Assert.AreEqual(1234.0, candidates[0].dishRange, 1e-9); + } + + [TestInfo("GatherConeCandidatesJobTests_NotPowered_IsExcluded")] + public void NotPowered_IsExcluded() + { + var ant = Antenna(AntennaFlags.CanTarget, target: 5); + Assert.AreEqual(0, Gather(ant).Length); + } + + [TestInfo("GatherConeCandidatesJobTests_CannotTarget_IsExcluded")] + public void CannotTarget_IsExcluded() + { + var ant = Antenna(AntennaFlags.Powered, target: 5); + Assert.AreEqual(0, Gather(ant).Length); + } + + [TestInfo("GatherConeCandidatesJobTests_NoTarget_IsExcluded")] + public void NoTarget_IsExcluded() + { + var ant = Antenna(AntennaFlags.Powered | AntennaFlags.CanTarget, target: 0); + Assert.AreEqual(0, Gather(ant).Length); + } + + [TestInfo("GatherConeCandidatesJobTests_TargetsABody_IsIncluded")] + public void TargetsABody_IsIncluded() + { + // Negative target encodes a celestial body rather than a node. + var ant = Antenna(AntennaFlags.Powered | AntennaFlags.CanTarget, target: -2); + var candidates = Gather(ant); + + Assert.AreEqual(1, candidates.Length); + Assert.AreEqual(-2, candidates[0].target); + } + + [TestInfo("GatherConeCandidatesJobTests_MixedAntennas_OnlyQualifyingSurvive")] + public void MixedAntennas_OnlyQualifyingSurvive() + { + var candidates = Gather( + Antenna(AntennaFlags.Powered | AntennaFlags.CanTarget, target: 1, nodeIndex: 0), // qualifies + Antenna(AntennaFlags.CanTarget, target: 1, nodeIndex: 1), // not powered + Antenna(AntennaFlags.Powered, target: 1, nodeIndex: 2), // can't target + Antenna(AntennaFlags.Powered | AntennaFlags.CanTarget, target: 0, nodeIndex: 3), // no target + Antenna(AntennaFlags.Powered | AntennaFlags.CanTarget, target: 2, nodeIndex: 4)); // qualifies + + Assert.AreEqual(2, candidates.Length); + Assert.AreEqual(0, candidates[0].nodeIndex); + Assert.AreEqual(4, candidates[1].nodeIndex); + } +} diff --git a/tests/RemoteTech.InGameTests/Network/DijkstraJobTests.cs b/tests/RemoteTech.InGameTests/Network/DijkstraJobTests.cs new file mode 100644 index 000000000..f996a10f4 --- /dev/null +++ b/tests/RemoteTech.InGameTests/Network/DijkstraJobTests.cs @@ -0,0 +1,156 @@ +using KSP.Testing; +using RemoteTech.Collections; +using RemoteTech.Network; +using Unity.Collections; +using Unity.Jobs; + +namespace RemoteTech.InGameTests.Network; + +public class DijkstraJobTests : RTTestBase +{ + private static (NativeArray ranges, NativeArray adjacency, NativeArray distances) + BuildCsr(int nodeCount, params (int a, int b, double w)[] edges) + { + var degree = new int[nodeCount]; + foreach (var (a, b, _) in edges) { degree[a]++; degree[b]++; } + + var ranges = new NativeArray(nodeCount, Allocator.Temp); + int offset = 0; + for (int i = 0; i < nodeCount; i++) + { + ranges[i] = new IntRange(offset, degree[i]); + offset += degree[i]; + } + + var adjacency = new NativeArray(offset, Allocator.Temp); + var distances = new NativeArray(offset, Allocator.Temp); + + var cursor = new int[nodeCount]; + for (int i = 0; i < nodeCount; i++) cursor[i] = ranges[i].Start; + foreach (var (a, b, w) in edges) + { + int ia = cursor[a]++; adjacency[ia] = b; distances[ia] = w; + int ib = cursor[b]++; adjacency[ib] = a; distances[ib] = w; + } + + return (ranges, adjacency, distances); + } + + private struct Result + { + public NativeList Scores; + public NativeList Parents; + public NativeList Origins; + } + + private static Result Run(int nodeCount, (int a, int b, double w)[] edges, int[] roots, bool[] canTransit = null) + { + var (ranges, adjacency, distances) = BuildCsr(nodeCount, edges); + var rootArr = new NativeArray(roots, Allocator.Temp); + + var transit = new NativeBitArray(nodeCount, Allocator.Temp); + for (int i = 0; i < nodeCount; i++) + transit.Set(i, canTransit == null || canTransit[i]); + + // The job writes one entry per node, so the outputs must already be sized to + // nodeCount (as NetworkState's nodeCount-length NativeArrays are). + var scores = new NativeList(nodeCount, Allocator.Temp); + var parents = new NativeList(nodeCount, Allocator.Temp); + var origins = new NativeList(nodeCount, Allocator.Temp); + scores.ResizeUninitialized(nodeCount); + parents.ResizeUninitialized(nodeCount); + origins.ResizeUninitialized(nodeCount); + + new MultiSourceDijkstraJob + { + ranges = ranges, + adjacency = adjacency, + distances = distances, + roots = rootArr, + canTransit = transit, + scores = scores, + parents = parents, + origins = origins, + }.Run(); + + return new Result { Scores = scores, Parents = parents, Origins = origins }; + } + + [TestInfo("DijkstraJobTests_Line_SingleSource_AccumulatesDistance")] + public void Line_SingleSource_AccumulatesDistance() + { + var r = Run(4, new[] { (0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0) }, new[] { 0 }); + + Assert.AreEqual(0.0, r.Scores[0], 1e-9); + Assert.AreEqual(1.0, r.Scores[1], 1e-9); + Assert.AreEqual(2.0, r.Scores[2], 1e-9); + Assert.AreEqual(3.0, r.Scores[3], 1e-9); + Assert.AreEqual(-1, r.Parents[0]); + Assert.AreEqual(0, r.Parents[1]); + Assert.AreEqual(1, r.Parents[2]); + Assert.AreEqual(2, r.Parents[3]); + } + + [TestInfo("DijkstraJobTests_Diamond_PicksShorterPath")] + public void Diamond_PicksShorterPath() + { + // 0->1 (1), 0->2 (5), 1->3 (1), 2->3 (1): node 3 should route via 1 (cost 2). + var r = Run(4, new[] { (0, 1, 1.0), (0, 2, 5.0), (1, 3, 1.0), (2, 3, 1.0) }, new[] { 0 }); + + Assert.AreEqual(2.0, r.Scores[3], 1e-9); + Assert.AreEqual(1, r.Parents[3]); + } + + [TestInfo("DijkstraJobTests_MultiSource_EachNodeBindsToNearestRoot")] + public void MultiSource_EachNodeBindsToNearestRoot() + { + // Line 0-1-2-3, roots at both ends. Midpoints split to their nearer root. + var r = Run(4, new[] { (0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0) }, new[] { 0, 3 }); + + Assert.AreEqual(0.0, r.Scores[0], 1e-9); + Assert.AreEqual(1.0, r.Scores[1], 1e-9); + Assert.AreEqual(1.0, r.Scores[2], 1e-9); + Assert.AreEqual(0.0, r.Scores[3], 1e-9); + Assert.AreEqual(0, r.Origins[1]); + Assert.AreEqual(3, r.Origins[2]); + Assert.AreEqual(0, r.Parents[1]); + Assert.AreEqual(3, r.Parents[2]); + } + + [TestInfo("DijkstraJobTests_UnreachableNode_StaysAtInfinity")] + public void UnreachableNode_StaysAtInfinity() + { + // Node 2 is isolated. + var r = Run(3, new[] { (0, 1, 1.0) }, new[] { 0 }); + + Assert.IsTrue(double.IsPositiveInfinity(r.Scores[2])); + Assert.AreEqual(-1, r.Parents[2]); + Assert.AreEqual(-1, r.Origins[2]); + } + + [TestInfo("DijkstraJobTests_NoRoots_LeavesEverythingUnreachable")] + public void NoRoots_LeavesEverythingUnreachable() + { + var r = Run(3, new[] { (0, 1, 1.0), (1, 2, 1.0) }, System.Array.Empty()); + + for (int i = 0; i < 3; i++) + { + Assert.IsTrue(double.IsPositiveInfinity(r.Scores[i])); + Assert.AreEqual(-1, r.Origins[i]); + } + } + + [TestInfo("DijkstraJobTests_NonTransitNode_ReachableButBlocksDownstream")] + public void NonTransitNode_ReachableButBlocksDownstream() + { + // Line 0-1-2-3 rooted at 0; node 2 cannot transit -> it is still reached as + // an endpoint, but node 3 (only reachable through 2) is not. + var r = Run(4, new[] { (0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0) }, new[] { 0 }, + canTransit: new[] { true, true, false, true }); + + Assert.AreEqual(2.0, r.Scores[2], 1e-9); + Assert.AreEqual(1, r.Parents[2]); + Assert.IsTrue(double.IsPositiveInfinity(r.Scores[3])); + Assert.AreEqual(-1, r.Parents[3]); + } +} diff --git a/tests/RemoteTech.InGameTests/Network/GatherMarkCandidatesJobTests.cs b/tests/RemoteTech.InGameTests/Network/GatherMarkCandidatesJobTests.cs new file mode 100644 index 000000000..619e482d1 --- /dev/null +++ b/tests/RemoteTech.InGameTests/Network/GatherMarkCandidatesJobTests.cs @@ -0,0 +1,102 @@ +using KSP.Testing; +using RemoteTech.Network; +using Unity.Collections; +using UnityEngine; + +namespace RemoteTech.InGameTests.Network; + +/// +/// Exercises NetworkUpdate.ComputeMarkCandidates's selection (command-station +/// vessels plus every ground station) and its alwaysShow / colour plumbing. +/// +public class GatherMarkCandidatesJobTests : RTTestBase +{ + private static NetworkUpdate.RawVesselInfo Vessel(bool commandStation, bool landed, Color32 color) + => new NetworkUpdate.RawVesselInfo + { + state = new SatelliteState { IsCommandStation = commandStation }, + kind = NodeKind.Vessel, + landed = landed, + markColor = color, + }; + + private static NetworkUpdate.RawStationInfo Station(Color32 color) + => new NetworkUpdate.RawStationInfo + { + state = new SatelliteState(), + kind = NodeKind.GroundStation, + markColor = color, + }; + + private static NativeList Gather( + NetworkUpdate.RawVesselInfo[] vessels, + NetworkUpdate.RawStationInfo[] stations) + { + var v = new NativeArray(vessels, Allocator.Temp); + var s = new NativeArray(stations, Allocator.Temp); + var candidates = new NativeList(vessels.Length + stations.Length, Allocator.Temp); + + NetworkUpdate.ComputeMarkCandidates(v, s, candidates); + + return candidates; + } + + [TestInfo("GatherMarkCandidatesJobTests_NonCommandVessel_IsExcluded")] + public void NonCommandVessel_IsExcluded() + { + var candidates = Gather( + new[] { Vessel(commandStation: false, landed: true, Color.white) }, + new NetworkUpdate.RawStationInfo[0]); + + Assert.AreEqual(0, candidates.Length); + } + + [TestInfo("GatherMarkCandidatesJobTests_OrbitingCommandStation_AlwaysShown")] + public void OrbitingCommandStation_AlwaysShown() + { + var candidates = Gather( + new[] { Vessel(commandStation: true, landed: false, Color.green) }, + new NetworkUpdate.RawStationInfo[0]); + + Assert.AreEqual(1, candidates.Length); + Assert.AreEqual(0, candidates[0].nodeIndex); + Assert.IsTrue(candidates[0].alwaysShow); + } + + [TestInfo("GatherMarkCandidatesJobTests_LandedCommandStation_NotAlwaysShown")] + public void LandedCommandStation_NotAlwaysShown() + { + var candidates = Gather( + new[] { Vessel(commandStation: true, landed: true, Color.green) }, + new NetworkUpdate.RawStationInfo[0]); + + Assert.AreEqual(1, candidates.Length); + Assert.IsFalse(candidates[0].alwaysShow); + } + + [TestInfo("GatherMarkCandidatesJobTests_GroundStationsOffsetPastVessels")] + public void GroundStationsOffsetPastVessels() + { + // Two vessels (one non-command) precede the stations; a station's node + // index must count every vessel slot, not just the emitted marks. + Color32 stationColor = Color.red; + var candidates = Gather( + new[] + { + Vessel(commandStation: true, landed: true, Color.green), + Vessel(commandStation: false, landed: true, Color.white), + }, + new[] { Station(stationColor) }); + + Assert.AreEqual(2, candidates.Length); + Assert.AreEqual(0, candidates[0].nodeIndex); + Assert.AreEqual(2, candidates[1].nodeIndex); + Assert.IsFalse(candidates[1].alwaysShow); + + Color32 got = candidates[1].color; + Assert.AreEqual(stationColor.r, got.r); + Assert.AreEqual(stationColor.g, got.g); + Assert.AreEqual(stationColor.b, got.b); + Assert.AreEqual(stationColor.a, got.a); + } +} diff --git a/tests/RemoteTech.InGameTests/Network/NetworkPathfindJobTests.cs b/tests/RemoteTech.InGameTests/Network/NetworkPathfindJobTests.cs new file mode 100644 index 000000000..eeaf7428e --- /dev/null +++ b/tests/RemoteTech.InGameTests/Network/NetworkPathfindJobTests.cs @@ -0,0 +1,152 @@ +using System; +using KSP.Testing; +using RemoteTech.Network; +using RemoteTech.SimpleTypes; +using Unity.Collections; +using Unity.Jobs; + +namespace RemoteTech.InGameTests.Network; + +/// +/// Drives NetworkPathfindJob over a hand-built node/edge graph — the on-demand +/// A-to-B query behind API.GetSignalDelayToSatellite. Verifies shortest-path +/// selection and the powered/relay hop gating. +/// +public class NetworkPathfindJobTests : RTTestBase +{ + private static unsafe double Solve( + int nodeCount, + NodeFlags[] flags, + bool signalRelay, + int source, + int target, + params (int a, int b, double dist)[] edges) + { + var nodes = new NativeArray(nodeCount, Allocator.Temp); + for (int i = 0; i < nodeCount; i++) + nodes[i] = new JobNode { flags = flags[i] }; + + var table = new NativeArray(NetworkUpdateMath.PairCount(nodeCount), Allocator.Temp); + foreach (var (a, b, dist) in edges) + { + table[NetworkUpdateMath.EncodePairIndex(a, b)] = new NetworkEdge + { + aIdx = Math.Min(a, b), + bIdx = Math.Max(a, b), + distance = dist, + maxRange = dist, + linkType = LinkType.Omni, + }; + } + + double length = double.PositiveInfinity; + new NetworkPathfindJob + { + config = new JobConfig { signalRelayEnabled = signalRelay }, + source = source, + target = target, + nodes = nodes, + edges = table, + length = &length, + }.Run(); + + return length; + } + + private static NodeFlags[] AllRelay(int n) + { + var flags = new NodeFlags[n]; + for (int i = 0; i < n; i++) + flags[i] = NodeFlags.Powered | NodeFlags.CanRelay; + return flags; + } + + [TestInfo("NetworkPathfindJobTests_Diamond_PicksShorterPath")] + public void Diamond_PicksShorterPath() + { + // 0-1 (1), 0-2 (5), 1-3 (1), 2-3 (1): 0->3 routes via 1 at cost 2. + double d = Solve(4, AllRelay(4), signalRelay: false, source: 0, target: 3, + (0, 1, 1.0), (0, 2, 5.0), (1, 3, 1.0), (2, 3, 1.0)); + + Assert.AreEqual(2.0, d, 1e-9); + } + + [TestInfo("NetworkPathfindJobTests_SameNode_IsZero")] + public void SameNode_IsZero() + { + double d = Solve(3, AllRelay(3), signalRelay: false, source: 1, target: 1, + (0, 1, 1.0), (1, 2, 1.0)); + + Assert.AreEqual(0.0, d, 1e-9); + } + + [TestInfo("NetworkPathfindJobTests_Disconnected_IsInfinite")] + public void Disconnected_IsInfinite() + { + // 0-1 linked, node 2 isolated. + double d = Solve(3, AllRelay(3), signalRelay: false, source: 0, target: 2, + (0, 1, 1.0)); + + Assert.IsTrue(double.IsPositiveInfinity(d)); + } + + [TestInfo("NetworkPathfindJobTests_UnpoweredIntermediate_BlocksPath")] + public void UnpoweredIntermediate_BlocksPath() + { + // Only route is 0-1-2, but node 1 is unpowered -> its edges are untraversable. + var flags = AllRelay(3); + flags[1] = NodeFlags.CanRelay; // powered bit cleared + double d = Solve(3, flags, signalRelay: false, source: 0, target: 2, + (0, 1, 1.0), (1, 2, 1.0)); + + Assert.IsTrue(double.IsPositiveInfinity(d)); + } + + [TestInfo("NetworkPathfindJobTests_RelayOff_RoutesThroughNonRelay")] + public void RelayOff_RoutesThroughNonRelay() + { + // Intermediate 1 cannot relay, but with signal relay disabled that is ignored. + var flags = AllRelay(3); + flags[1] = NodeFlags.Powered; // relay bit cleared + double d = Solve(3, flags, signalRelay: false, source: 0, target: 2, + (0, 1, 1.0), (1, 2, 1.0)); + + Assert.AreEqual(2.0, d, 1e-9); + } + + [TestInfo("NetworkPathfindJobTests_RelayOn_NonRelayIntermediateBlocks")] + public void RelayOn_NonRelayIntermediateBlocks() + { + // Relay enabled: the non-relay intermediate cannot forward, so no route survives. + var flags = AllRelay(3); + flags[1] = NodeFlags.Powered; // relay bit cleared + double d = Solve(3, flags, signalRelay: true, source: 0, target: 2, + (0, 1, 1.0), (1, 2, 1.0)); + + Assert.IsTrue(double.IsPositiveInfinity(d)); + } + + [TestInfo("NetworkPathfindJobTests_RelayOn_NonRelaySource_StillConnects")] + public void RelayOn_NonRelaySource_StillConnects() + { + // Source is a route endpoint: it never needs to relay, even with relay on. + var flags = AllRelay(3); + flags[0] = NodeFlags.Powered; // relay bit cleared on the source + double d = Solve(3, flags, signalRelay: true, source: 0, target: 2, + (0, 1, 1.0), (1, 2, 1.0)); + + Assert.AreEqual(2.0, d, 1e-9); + } + + [TestInfo("NetworkPathfindJobTests_RelayOn_NonRelayTarget_StillConnects")] + public void RelayOn_NonRelayTarget_StillConnects() + { + // Target is a route endpoint: it never needs to relay, even with relay on. + var flags = AllRelay(3); + flags[2] = NodeFlags.Powered; // relay bit cleared on the target + double d = Solve(3, flags, signalRelay: true, source: 0, target: 2, + (0, 1, 1.0), (1, 2, 1.0)); + + Assert.AreEqual(2.0, d, 1e-9); + } +} diff --git a/tests/RemoteTech.InGameTests/Network/NetworkUpdateMathTests.cs b/tests/RemoteTech.InGameTests/Network/NetworkUpdateMathTests.cs new file mode 100644 index 000000000..725a94be7 --- /dev/null +++ b/tests/RemoteTech.InGameTests/Network/NetworkUpdateMathTests.cs @@ -0,0 +1,136 @@ +using KSP.Testing; +using RemoteTech.Collections; +using RemoteTech.Network; +using Unity.Collections; +using Unity.Mathematics; + +namespace RemoteTech.InGameTests.Network; + +public class NetworkUpdateMathTests : RTTestBase +{ + [TestInfo("NetworkUpdateMathTests_PairCount_IsTriangularNumber")] + public void PairCount_IsTriangularNumber() + { + Assert.AreEqual(0, NetworkUpdateMath.PairCount(0)); + Assert.AreEqual(0, NetworkUpdateMath.PairCount(1)); + Assert.AreEqual(1, NetworkUpdateMath.PairCount(2)); + Assert.AreEqual(3, NetworkUpdateMath.PairCount(3)); + Assert.AreEqual(6, NetworkUpdateMath.PairCount(4)); + Assert.AreEqual(45, NetworkUpdateMath.PairCount(10)); + } + + [TestInfo("NetworkUpdateMathTests_DecodePairIndex_RoundTripsEveryPair")] + public void DecodePairIndex_RoundTripsEveryPair() + { + // For every node count, walking pair indices 0..PairCount must reproduce + // the (i, j) with 0 <= i < j used to encode them: k = j*(j-1)/2 + i. + for (int n = 2; n <= 32; n++) + { + int k = 0; + for (int j = 1; j < n; j++) + { + for (int i = 0; i < j; i++, k++) + { + NetworkUpdateMath.DecodePairIndex(k, out int di, out int dj); + Assert.AreEqual(i, di); + Assert.AreEqual(j, dj); + } + } + Assert.AreEqual(NetworkUpdateMath.PairCount(n), k); + } + } + + [TestInfo("NetworkUpdateMathTests_CouldPossiblyLink_ComparesRangeSumToDistance")] + public void CouldPossiblyLink_ComparesRangeSumToDistance() + { + Assert.IsTrue(NetworkUpdateMath.CouldPossiblyLink(100, 100, 200)); // sum exactly meets distance + Assert.IsFalse(NetworkUpdateMath.CouldPossiblyLink(100, 99, 200)); // just short + Assert.IsTrue(NetworkUpdateMath.CouldPossiblyLink(0, 0, 0)); // coincident + } + + [TestInfo("NetworkUpdateMathTests_CheckRange_Standard_TakesMinOfJointAndPerNodeClamps")] + public void CheckRange_Standard_TakesMinOfJointAndPerNodeClamps() + { + var model = default(StandardRangeModel); + // joint = min(100,200)=100; clamps wide open -> 100 + Assert.AreEqual(100.0, NetworkUpdateMath.CheckRange(in model, 100, 1.0, 200, 1.0), 1e-9); + // r1 clamp bites: min(100, 100*0.5, 200*1) = 50 + Assert.AreEqual(50.0, NetworkUpdateMath.CheckRange(in model, 100, 0.5, 200, 1.0), 1e-9); + } + + [TestInfo("NetworkUpdateMathTests_CheckRange_Additive_AddsSqrtProductThenClamps")] + public void CheckRange_Additive_AddsSqrtProductThenClamps() + { + var model = default(AdditiveRangeModel); + // joint = min(100,100) + sqrt(100*100) = 200, but capped by r*clamp=100 + Assert.AreEqual(100.0, NetworkUpdateMath.CheckRange(in model, 100, 1.0, 100, 1.0), 1e-9); + // wide clamps let the additive joint through + Assert.AreEqual(200.0, NetworkUpdateMath.CheckRange(in model, 100, 10.0, 100, 10.0), 1e-9); + } + + [TestInfo("NetworkUpdateMathTests_GetMultipleAntennaBonus_SumsNonBestOmni")] + public void GetMultipleAntennaBonus_SumsNonBestOmni() + { + var antennas = new NativeArray(new[] + { + new JobAntenna { omni = 10 }, + new JobAntenna { omni = 20 }, + new JobAntenna { omni = 30 }, + }, Allocator.Temp); + var range = new IntRange(0, 3); + + Assert.AreEqual(15.0, NetworkUpdateMath.GetMultipleAntennaBonus(antennas, range, maxOmni: 30, 0.5), 1e-9); // (10+20+30-30)*0.5 + Assert.AreEqual(0.0, NetworkUpdateMath.GetMultipleAntennaBonus(antennas, range, maxOmni: 30, 0.0), 1e-9); // multiplier off -> no bonus + } + + // --- Line of sight ----------------------------------------------------- + + private static NativeArray SingleBody(double3 pos, double radius) => + new(new[] { new JobBody { position = pos, radius = radius } }, Allocator.Temp); + + [TestInfo("NetworkUpdateMathTests_HasLineOfSight_BodyDirectlyBetween_Occludes")] + public void HasLineOfSight_BodyDirectlyBetween_Occludes() + { + var a = new double3(0, 0, 0); + var b = new double3(100, 0, 0); + var bodies = SingleBody(new double3(50, 0, 0), radius: 10); + Assert.IsFalse(NetworkUpdateMath.HasLineOfSight(a, b, bodies, new IntRange(0, 1))); + } + + [TestInfo("NetworkUpdateMathTests_HasLineOfSight_BodyOffToTheSide_Clear")] + public void HasLineOfSight_BodyOffToTheSide_Clear() + { + var a = new double3(0, 0, 0); + var b = new double3(100, 0, 0); + var bodies = SingleBody(new double3(50, 100, 0), radius: 10); + Assert.IsTrue(NetworkUpdateMath.HasLineOfSight(a, b, bodies, new IntRange(0, 1))); + } + + [TestInfo("NetworkUpdateMathTests_HasLineOfSight_BodyBehindEndpoint_Ignored")] + public void HasLineOfSight_BodyBehindEndpoint_Ignored() + { + var a = new double3(0, 0, 0); + var b = new double3(100, 0, 0); + // Behind A (negative projection) and beyond B (projection past the segment) + var behind = SingleBody(new double3(-50, 0, 0), radius: 40); + var beyond = SingleBody(new double3(150, 0, 0), radius: 40); + Assert.IsTrue(NetworkUpdateMath.HasLineOfSight(a, b, behind, new IntRange(0, 1))); + Assert.IsTrue(NetworkUpdateMath.HasLineOfSight(a, b, beyond, new IntRange(0, 1))); + } + + [TestInfo("NetworkUpdateMathTests_HasLineOfSight_GrazingWithinMinHeight_DoesNotOcclude")] + public void HasLineOfSight_GrazingWithinMinHeight_DoesNotOcclude() + { + var a = new double3(0, 0, 0); + var b = new double3(100, 0, 0); + // Surface passes within MIN_HEIGHT (5m) of the segment: radius - MIN_HEIGHT + // is the effective occlusion radius. Body centered 5m off-axis with radius 10 + // -> threshold 5, lateral 5 -> 25 < 25 is false -> clear. + var bodies = SingleBody(new double3(50, 5, 0), radius: 10); + Assert.IsTrue(NetworkUpdateMath.HasLineOfSight(a, b, bodies, new IntRange(0, 1))); + + // Drop the off-axis distance below the threshold -> occluded. + var closer = SingleBody(new double3(50, 4, 0), radius: 10); + Assert.IsFalse(NetworkUpdateMath.HasLineOfSight(a, b, closer, new IntRange(0, 1))); + } +} diff --git a/tests/RemoteTech.InGameTests/RTTestBase.cs b/tests/RemoteTech.InGameTests/RTTestBase.cs new file mode 100644 index 000000000..75d4b54f9 --- /dev/null +++ b/tests/RemoteTech.InGameTests/RTTestBase.cs @@ -0,0 +1,14 @@ +using KSP.Testing; + +namespace RemoteTech.InGameTests; + +/// +/// Base class for RemoteTech's in-game unit tests. Subclasses live in this +/// assembly and tag test methods with [TestInfo("...")]; KSP's stock +/// discovers every , and +/// runs just the RemoteTech ones from a main-menu +/// button. Unlike the headless RemoteTech.Tests project, these run inside the +/// KSP process, so the real native allocators (Temp/TempJob/Persistent), +/// NativeList, and scheduled Burst jobs are all available. +/// +public abstract class RTTestBase : UnitTest { } diff --git a/tests/RemoteTech.InGameTests/RemoteTech.InGameTests.csproj b/tests/RemoteTech.InGameTests/RemoteTech.InGameTests.csproj new file mode 100644 index 000000000..f4159b772 --- /dev/null +++ b/tests/RemoteTech.InGameTests/RemoteTech.InGameTests.csproj @@ -0,0 +1,50 @@ + + + RemoteTech.InGameTests + RemoteTech.InGameTests + net481 + 12 + enable + portable + true + Debug;Release + false + + + $(ProjectRootDir)GameData\RemoteTech.Tests + Plugins + + + + + + + + false + KSPBurst + 1.7 + true + + + false + + + false + + + false + + + + + + false + RemoteTech + 1.9 + + + diff --git a/tests/RemoteTech.InGameTests/TestRunnerUI.cs b/tests/RemoteTech.InGameTests/TestRunnerUI.cs new file mode 100644 index 000000000..bcab3007b --- /dev/null +++ b/tests/RemoteTech.InGameTests/TestRunnerUI.cs @@ -0,0 +1,193 @@ +using System.Collections; +using System.IO; +using System.Reflection; +using System.Text; +using KSP.Testing; +using KSP.UI.Screens; +using UnityEngine; + +namespace RemoteTech.InGameTests; + +/// +/// Main-menu app-launcher button that runs RemoteTech's in-game unit tests and +/// shows a pass/fail window. Adapted from the sibling BackgroundResourceProcessing +/// harness. +/// +[KSPAddon(KSPAddon.Startup.MainMenu, false)] +public class TestRunnerUI : MonoBehaviour +{ + ApplicationLauncherButton button; + TestResults results; + bool showWindow; + Vector2 scroll; + Rect windowRect = new Rect(100, 100, 600, 700); + int passCount; + int failCount; + + static Texture2D buttonTexture; + + void Start() + { + if (buttonTexture == null) + buttonTexture = RTUtil.LoadImage("gitpagessat"); + + GameEvents.onGUIApplicationLauncherReady.Add(OnAppLauncherReady); + if (ApplicationLauncher.Ready) + OnAppLauncherReady(); + } + + void OnDestroy() + { + GameEvents.onGUIApplicationLauncherReady.Remove(OnAppLauncherReady); + if (button != null) + ApplicationLauncher.Instance.RemoveModApplication(button); + } + + void OnAppLauncherReady() + { + if (button != null) return; + button = ApplicationLauncher.Instance.AddModApplication( + OnButtonTrue, OnButtonFalse, null, null, null, null, + ApplicationLauncher.AppScenes.MAINMENU, buttonTexture); + } + + void OnButtonTrue() + { + RunTests(); + showWindow = true; + } + + void OnButtonFalse() => showWindow = false; + + // KSP's TestManager registers an instance of every UnitTest subclass across + // all loaded mods. Reach into that list and run only the RemoteTech ones + // (deriving from RTTestBase) rather than every mod's tests. + static readonly FieldInfo TestsField = typeof(TestManager).GetField( + "tests", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + static readonly MethodInfo PerformTestMethod = typeof(UnitTest).GetMethod( + "PerformTest", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + + static TestResults RunRTTests() + { + var results = new TestResults(); + var tests = (IEnumerable)TestsField.GetValue(null); + foreach (UnitTest test in tests) + { + if (test == null || test is not RTTestBase) + continue; + var states = (IEnumerable)PerformTestMethod.Invoke(test, null); + foreach (TestState state in states) + { + if (state.Succeeded) results.success++; + else results.failed++; + results.states.Add(state); + } + } + return results; + } + + void RunTests() + { + results = RunRTTests(); + passCount = 0; + failCount = 0; + foreach (var state in results.states) + { + if (state.Succeeded) passCount++; + else failCount++; + } + + Debug.Log($"[RT.Test] {passCount} passed, {failCount} failed ({results.states.Count} total)"); + foreach (var state in results.states) + { + if (state.Succeeded) continue; + var name = state.Info?.Name ?? "(unnamed)"; + Debug.LogError($"[RT.Test] FAIL: {name}\n Reason: {state.Reason}\n Details: {state.Details}"); + } + WriteLogFile(); + } + + void WriteLogFile() + { + var logPath = Path.Combine(KSPUtil.ApplicationRootPath, "Logs", "RemoteTech.InGameTests.log"); + Directory.CreateDirectory(Path.GetDirectoryName(logPath)); + + var sb = new StringBuilder(); + sb.AppendLine($"RemoteTech in-game test results - {System.DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine($"{passCount} passed, {failCount} failed ({results.states.Count} total)"); + sb.AppendLine(); + foreach (var state in results.states) + { + var name = state.Info?.Name ?? "(unnamed)"; + sb.AppendLine($"[{(state.Succeeded ? "PASS" : "FAIL")}] {name}"); + if (!state.Succeeded) + { + if (!string.IsNullOrEmpty(state.Reason)) sb.AppendLine($" Reason: {state.Reason}"); + if (!string.IsNullOrEmpty(state.Details)) sb.AppendLine($" Details: {state.Details}"); + } + } + File.WriteAllText(logPath, sb.ToString()); + Debug.Log($"[RT.Test] Results written to {logPath}"); + } + + void OnGUI() + { + if (!showWindow || results == null) return; + GUI.skin = HighLogic.Skin; + Styles.Init(); + windowRect = GUILayout.Window(GetInstanceID(), windowRect, DrawWindow, "RemoteTech Test Results"); + } + + void DrawWindow(int id) + { + GUILayout.BeginHorizontal(); + GUILayout.Label($"{passCount} passed, {failCount} failed", + failCount > 0 ? Styles.failLabel : Styles.passLabel); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("Re-run")) RunTests(); + if (GUILayout.Button("Close")) { showWindow = false; button?.SetFalse(false); } + GUILayout.EndHorizontal(); + + GUILayout.Space(4); + scroll = GUILayout.BeginScrollView(scroll); + foreach (var state in results.states) + { + GUILayout.BeginHorizontal(); + GUILayout.Label(state.Succeeded ? "PASS" : "FAIL", + state.Succeeded ? Styles.passLabel : Styles.failLabel, GUILayout.Width(40)); + GUILayout.Label(state.Info?.Name ?? "(unnamed)"); + GUILayout.EndHorizontal(); + if (!state.Succeeded) + { + if (!string.IsNullOrEmpty(state.Reason)) + GUILayout.Label(" Reason: " + state.Reason, Styles.detailLabel); + if (!string.IsNullOrEmpty(state.Details)) + GUILayout.Label(" Details: " + state.Details, Styles.detailLabel); + } + } + GUILayout.EndScrollView(); + GUI.DragWindow(); + } + + static class Styles + { + static bool initialized; + internal static GUIStyle passLabel; + internal static GUIStyle failLabel; + internal static GUIStyle detailLabel; + + internal static void Init() + { + if (initialized) return; + initialized = true; + passLabel = new GUIStyle(HighLogic.Skin.label) { normal = { textColor = Color.green } }; + failLabel = new GUIStyle(HighLogic.Skin.label) { normal = { textColor = Color.red } }; + detailLabel = new GUIStyle(HighLogic.Skin.label) + { + normal = { textColor = new Color(1f, 0.8f, 0.4f) }, + wordWrap = true, + fontSize = 11, + }; + } + } +} diff --git a/tests/RemoteTech.Tests/NativeArena.cs b/tests/RemoteTech.Tests/NativeArena.cs new file mode 100644 index 000000000..8ea1d1792 --- /dev/null +++ b/tests/RemoteTech.Tests/NativeArena.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; + +namespace RemoteTech.Tests; + +/// +/// Hands out instances backed by +/// so the real Burst-path code can run in a +/// plain test process. +/// +/// Outside the Unity player, UnsafeUtility.Malloc is a native ECall that +/// throws (SecurityException: ECall methods must be packaged into a system +/// module), so new NativeArray<T>(len, Allocator.Temp/Persistent) +/// and NativeList<T> cannot be constructed here. Wrapping our own +/// HGlobal block with +/// (allocator ) sidesteps that — element access and +/// Length work, and we own the lifetime. +/// +/// Use one arena per test and dispose it (the helpers/fixtures below do) to free +/// every block at once. +/// +internal sealed class NativeArena : IDisposable +{ + private readonly List _blocks = new List(); + + /// + /// Zero-initialized native array of elements. + /// + public unsafe NativeArray Alloc(int length) where T : unmanaged + { + if (length < 0) throw new ArgumentOutOfRangeException(nameof(length)); + // Always allocate at least one byte so the pointer is valid; a zero-length + // NativeArray over that pointer simply never dereferences it. + int bytes = Math.Max(1, length * sizeof(T)); + IntPtr ptr = Marshal.AllocHGlobal(bytes); + Unsafe_ZeroMemory((byte*)ptr, bytes); + _blocks.Add(ptr); + return NativeArrayUnsafeUtility.ConvertExistingDataToNativeArray((void*)ptr, length, Allocator.None); + } + + /// + /// Native array initialized from . + /// + public NativeArray Of(params T[] values) where T : unmanaged + { + var array = Alloc(values.Length); + for (int i = 0; i < values.Length; i++) array[i] = values[i]; + return array; + } + + private static unsafe void Unsafe_ZeroMemory(byte* p, int bytes) + { + for (int i = 0; i < bytes; i++) p[i] = 0; + } + + public void Dispose() + { + foreach (var ptr in _blocks) Marshal.FreeHGlobal(ptr); + _blocks.Clear(); + } +} diff --git a/tests/RemoteTech.Tests/NetworkUpdateMathTests.cs b/tests/RemoteTech.Tests/NetworkUpdateMathTests.cs new file mode 100644 index 000000000..fed19d930 --- /dev/null +++ b/tests/RemoteTech.Tests/NetworkUpdateMathTests.cs @@ -0,0 +1,167 @@ +using RemoteTech.Collections; +using RemoteTech.Network; +using Unity.Collections; +using Unity.Mathematics; +using Xunit; + +namespace RemoteTech.Tests; + +public class NetworkUpdateMathTests +{ + [Theory] + [InlineData(0, 0)] + [InlineData(1, 0)] + [InlineData(2, 1)] + [InlineData(3, 3)] + [InlineData(4, 6)] + [InlineData(10, 45)] + public void PairCount_IsTriangularNumber(int nodeCount, int expected) + { + Assert.Equal(expected, NetworkUpdateMath.PairCount(nodeCount)); + } + + [Fact] + public void DecodePairIndex_RoundTripsEveryPair() + { + // For every node count, walking pair indices 0..PairCount must reproduce + // the (i, j) with 0 <= i < j used to encode them: k = j*(j-1)/2 + i. + for (int n = 2; n <= 32; n++) + { + int k = 0; + for (int j = 1; j < n; j++) + { + for (int i = 0; i < j; i++, k++) + { + NetworkUpdateMath.DecodePairIndex(k, out int di, out int dj); + Assert.Equal(i, di); + Assert.Equal(j, dj); + } + } + Assert.Equal(NetworkUpdateMath.PairCount(n), k); + } + } + + [Fact] + public void EncodePairIndex_InvertsDecodeAndIsOrderIndependent() + { + for (int n = 2; n <= 32; n++) + { + int k = 0; + for (int j = 1; j < n; j++) + { + for (int i = 0; i < j; i++, k++) + { + Assert.Equal(k, NetworkUpdateMath.EncodePairIndex(i, j)); + Assert.Equal(k, NetworkUpdateMath.EncodePairIndex(j, i)); + } + } + } + } + + [Theory] + [InlineData(100, 100, 200, true)] // sum exactly meets distance + [InlineData(100, 99, 200, false)] // just short + [InlineData(0, 0, 0, true)] // coincident + public void CouldPossiblyLink_ComparesRangeSumToDistance(double rA, double rB, double dist, bool expected) + { + Assert.Equal(expected, NetworkUpdateMath.CouldPossiblyLink(rA, rB, dist)); + } + + [Fact] + public void CheckRange_Standard_TakesMinOfJointAndPerNodeClamps() + { + var model = default(StandardRangeModel); + // joint = min(100,200)=100; clamps wide open -> 100 + Assert.Equal(100.0, NetworkUpdateMath.CheckRange(in model, 100, 1.0, 200, 1.0), 9); + // r1 clamp bites: min(100, 100*0.5, 200*1) = 50 + Assert.Equal(50.0, NetworkUpdateMath.CheckRange(in model, 100, 0.5, 200, 1.0), 9); + } + + [Fact] + public void CheckRange_Additive_AddsSqrtProductThenClamps() + { + var model = default(AdditiveRangeModel); + // joint = min(100,100) + sqrt(100*100) = 200, but capped by r*clamp=100 + Assert.Equal(100.0, NetworkUpdateMath.CheckRange(in model, 100, 1.0, 100, 1.0), 9); + // wide clamps let the additive joint through + Assert.Equal(200.0, NetworkUpdateMath.CheckRange(in model, 100, 10.0, 100, 10.0), 9); + } + + [Theory] + [InlineData(0.5, 15.0)] // (10+20+30 - 30) * 0.5 + [InlineData(0.0, 0.0)] // multiplier off -> no bonus + public void GetMultipleAntennaBonus_SumsNonBestOmni(double multiplier, double expected) + { + using var arena = new NativeArena(); + var antennas = arena.Of( + new JobAntenna { omni = 10 }, + new JobAntenna { omni = 20 }, + new JobAntenna { omni = 30 }); + var range = new IntRange(0, 3); + + double bonus = NetworkUpdateMath.GetMultipleAntennaBonus(antennas, range, maxOmni: 30, multiplier); + Assert.Equal(expected, bonus, 9); + } + + // --- Line of sight ----------------------------------------------------- + + private static NativeArray SingleBody(NativeArena arena, double3 pos, double radius) + { + return arena.Of(new JobBody + { + position = pos, + radius = radius, + subtree = new IntRange(0, 1), + }); + } + + [Fact] + public void HasLineOfSight_BodyDirectlyBetween_Occludes() + { + using var arena = new NativeArena(); + var a = new double3(0, 0, 0); + var b = new double3(100, 0, 0); + var bodies = SingleBody(arena, new double3(50, 0, 0), radius: 10); + Assert.False(NetworkUpdateMath.HasLineOfSight(a, b, bodies, new IntRange(0, 1))); + } + + [Fact] + public void HasLineOfSight_BodyOffToTheSide_Clear() + { + using var arena = new NativeArena(); + var a = new double3(0, 0, 0); + var b = new double3(100, 0, 0); + var bodies = SingleBody(arena, new double3(50, 100, 0), radius: 10); + Assert.True(NetworkUpdateMath.HasLineOfSight(a, b, bodies, new IntRange(0, 1))); + } + + [Fact] + public void HasLineOfSight_BodyBehindEndpoint_Ignored() + { + using var arena = new NativeArena(); + var a = new double3(0, 0, 0); + var b = new double3(100, 0, 0); + // Behind A (negative projection) and beyond B (projection past the segment) + var behind = SingleBody(arena, new double3(-50, 0, 0), radius: 40); + var beyond = SingleBody(arena, new double3(150, 0, 0), radius: 40); + Assert.True(NetworkUpdateMath.HasLineOfSight(a, b, behind, new IntRange(0, 1))); + Assert.True(NetworkUpdateMath.HasLineOfSight(a, b, beyond, new IntRange(0, 1))); + } + + [Fact] + public void HasLineOfSight_GrazingWithinMinHeight_DoesNotOcclude() + { + using var arena = new NativeArena(); + var a = new double3(0, 0, 0); + var b = new double3(100, 0, 0); + // Surface passes within MIN_HEIGHT (5m) of the segment: radius - MIN_HEIGHT + // is the effective occlusion radius. Body centered 5m off-axis with radius 10 + // -> threshold 5, lateral 5 -> 25 < 25 is false -> clear. + var bodies = SingleBody(arena, new double3(50, 5, 0), radius: 10); + Assert.True(NetworkUpdateMath.HasLineOfSight(a, b, bodies, new IntRange(0, 1))); + + // Drop the off-axis distance below the threshold -> occluded. + var closer = SingleBody(arena, new double3(50, 4, 0), radius: 10); + Assert.False(NetworkUpdateMath.HasLineOfSight(a, b, closer, new IntRange(0, 1))); + } +} diff --git a/tests/RemoteTech.Tests/RemoteTech.Tests.csproj b/tests/RemoteTech.Tests/RemoteTech.Tests.csproj new file mode 100644 index 000000000..fe4bac4e5 --- /dev/null +++ b/tests/RemoteTech.Tests/RemoteTech.Tests.csproj @@ -0,0 +1,62 @@ + + + + + + + net481 + 12 + disable + false + true + + + false + false + false + false + false + false + false + false + false + + + + + + + + + + + + + + + + + $(KSPBT_ManagedPath)/UnityEngine.CoreModule.dll + true + + + $(KSPBT_GameRoot)/GameData/000_KSPBurst/Plugins/Unity.Collections.dll + true + + + $(KSPBT_GameRoot)/GameData/000_KSPBurst/Plugins/Unity.Mathematics.dll + true + + + $(KSPBT_GameRoot)/GameData/000_KSPBurst/Plugins/Unity.Burst.dll + true + + + +