From 82ad615b570165882c8c65393ccca1f444d2ed77 Mon Sep 17 00:00:00 2001 From: Phantomical Date: Mon, 6 Jul 2026 21:58:03 -0700 Subject: [PATCH 01/11] Add the Burst toolchain and project plumbing Reference Unity.Burst, Unity.Collections, Unity.Mathematics and Unity.Jobs from KSPBurst so the network model can be rewritten against the job system, and pull in Krafs.Publicizer to expose UnityEngine.Object.m_CachedPtr (for thread-safe null checks) and List._items (for an AsSpan helper). Expose internals to the RemoteTech.Tests and RemoteTech.InGameTests assemblies, bump the language version to 13, and replace the .slnx with a plain .sln. --- .gitignore | 3 +++ RemoteTech.sln | 40 +++++++++++++++++++++++++++ RemoteTech.slnx | 12 --------- src/RemoteTech/RemoteTech.csproj | 46 +++++++++++++++++++++++++++++++- 4 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 RemoteTech.sln delete mode 100644 RemoteTech.slnx diff --git a/.gitignore b/.gitignore index 68d76586..f80f3b36 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 00000000..119211d1 --- /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 54c1e38a..00000000 --- a/RemoteTech.slnx +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/src/RemoteTech/RemoteTech.csproj b/src/RemoteTech/RemoteTech.csproj index e1e6f2c0..a27af9d0 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 @@ -23,14 +23,58 @@ 1.9.0.0 + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + false + SerializationFix + 1.0 + true + + + + false + KSPBurst + 1.7 + true + + + false + + + false + + + false + + + false + + + + + + + + $(KSPBT_ModRoot)/Versioning/RemoteTech.version From ca664f88a2c1668a6218ee7d5ea3aabefd7d0aac Mon Sep 17 00:00:00 2001 From: Phantomical Date: Mon, 6 Jul 2026 21:58:23 -0700 Subject: [PATCH 02/11] Add the RemoteTech.Collections helpers Later work is going to need a number of utilities. This commit adds: * an ArrayMap class that stores keys/values in arrays internally, * an ArrayMinHeap struct that provides a burst-compatible min-heap, * a number of extension helper methods for various types * an ObjectHandle struct for passing managed types to burst jobs * a IJobParallelForBatchDefer interface and extensions --- src/RemoteTech/Collections/ArrayMap.cs | 146 ++++++++++++++ src/RemoteTech/Collections/ArrayMinHeap.cs | 106 ++++++++++ src/RemoteTech/Collections/Extensions.cs | 159 +++++++++++++++ .../Collections/IJobParallelForBatchDefer.cs | 73 +++++++ src/RemoteTech/Collections/IntRange.cs | 37 ++++ src/RemoteTech/Collections/ObjectExt.cs | 27 +++ src/RemoteTech/Collections/ObjectHandle.cs | 41 ++++ src/RemoteTech/Collections/SpookyHash.cs | 187 ++++++++++++++++++ 8 files changed, 776 insertions(+) create mode 100644 src/RemoteTech/Collections/ArrayMap.cs create mode 100644 src/RemoteTech/Collections/ArrayMinHeap.cs create mode 100644 src/RemoteTech/Collections/Extensions.cs create mode 100644 src/RemoteTech/Collections/IJobParallelForBatchDefer.cs create mode 100644 src/RemoteTech/Collections/IntRange.cs create mode 100644 src/RemoteTech/Collections/ObjectExt.cs create mode 100644 src/RemoteTech/Collections/ObjectHandle.cs create mode 100644 src/RemoteTech/Collections/SpookyHash.cs diff --git a/src/RemoteTech/Collections/ArrayMap.cs b/src/RemoteTech/Collections/ArrayMap.cs new file mode 100644 index 00000000..dd8c02a2 --- /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 00000000..0ac9148e --- /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 00000000..9b1e6ed4 --- /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 00000000..fb624179 --- /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 00000000..7eef094e --- /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 00000000..fe5129ff --- /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 00000000..e01d59bc --- /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 00000000..56dbd30c --- /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]; + } +} From 8f747104be7b5975a357ebca1fe0f6fba798766d Mon Sep 17 00:00:00 2001 From: Phantomical Date: Mon, 6 Jul 2026 22:00:28 -0700 Subject: [PATCH 03/11] Rewrite the network model as a Burst job pipeline Replace the managed graph/route computation with an unmanaged pipeline built on the Unity job system. Satellite and antenna state is projected into native arrays (NetworkState) once per update, and connectivity, routing and the render geometry are computed by Burst jobs (NetworkUpdate, Edges, NetworkUpdateMath, and the line/cone/mark mesh jobs). Antennas publish their state through a pinned AntennaData registered with StateManager, so the jobs can read them without touching managed memory. ISatellite gains a flat SatelliteState snapshot and Antennas becomes an IReadOnlyList; NetworkLink/NetworkRoute become readonly struct views over the store rather than heap-allocated objects. The range models are unified behind IRangeModel so the job path and the managed path share one implementation. NetworkManager drives the pipeline and NetworkRenderer draws the computed meshes directly, replacing the per-frame NetworkCone/NetworkLine GameObjects. --- src/RemoteTech/API/API.cs | 59 +- src/RemoteTech/AddOns/Kerbalism.cs | 8 +- src/RemoteTech/AntennaManager.cs | 39 +- .../Commands/ExternalAPICommand.cs | 44 +- .../Commands/ManeuverCommand.cs | 4 +- .../FlightComputer/FlightComputer.cs | 231 +++-- .../FlightComputer/UIPartActionMenuPatcher.cs | 4 +- src/RemoteTech/ISatellite.cs | 83 +- .../Modules/MissionControlAntenna.cs | 94 +- src/RemoteTech/Modules/ModuleRTAntenna.cs | 57 +- .../Modules/ModuleRTAntennaPassive.cs | 49 +- .../Modules/ModuleRTDataTransmitter.cs | 4 +- src/RemoteTech/Modules/ModuleSPU.cs | 2 +- src/RemoteTech/Modules/ProtoAntenna.cs | 75 +- src/RemoteTech/Network/CelestialBodyInfo.cs | 13 + src/RemoteTech/Network/ConeMeshJob.cs | 135 +++ src/RemoteTech/Network/DrawableMesh.cs | 203 +++++ src/RemoteTech/Network/Edges.cs | 185 ++++ src/RemoteTech/Network/Jobs.cs | 98 ++ src/RemoteTech/Network/LineMeshJob.cs | 283 ++++++ src/RemoteTech/Network/NetworkState.cs | 845 ++++++++++++++++++ src/RemoteTech/Network/NetworkUpdate.cs | 790 ++++++++++++++++ src/RemoteTech/Network/NetworkUpdateMath.cs | 114 +++ src/RemoteTech/Network/RangeModels.cs | 21 + src/RemoteTech/Network/SatelliteMarkJob.cs | 115 +++ src/RemoteTech/Network/VesselInfo.cs | 109 +++ src/RemoteTech/NetworkFeedback.cs | 16 +- src/RemoteTech/NetworkManager.cs | 297 +++--- src/RemoteTech/NetworkPathfinder.cs | 2 +- src/RemoteTech/NetworkRenderer.cs | 653 +++++++------- src/RemoteTech/RTDebugUnit.cs | 2 +- src/RemoteTech/RTLog.cs | 8 +- src/RemoteTech/RTSettings.cs | 284 ++++-- src/RemoteTech/RTSpaceCentre.cs | 12 +- src/RemoteTech/RTUtil.cs | 47 +- .../RangeModel/AbstractRangeModel.cs | 125 ++- src/RemoteTech/RangeModel/IRangeModel.cs | 34 + .../RangeModel/RangeModelExtensions.cs | 24 +- src/RemoteTech/RangeModel/RangeModelRoot.cs | 30 +- .../RangeModel/RangeModelStandard.cs | 30 +- src/RemoteTech/SatelliteManager.cs | 88 +- src/RemoteTech/SimpleTypes/AddOn.cs | 16 +- src/RemoteTech/SimpleTypes/AntennaState.cs | 83 ++ .../SimpleTypes/BidirectionalEdge.cs | 15 +- src/RemoteTech/SimpleTypes/NetworkLink.cs | 26 +- src/RemoteTech/SimpleTypes/NetworkRoute.cs | 113 ++- src/RemoteTech/StateManager.cs | 117 +++ src/RemoteTech/UI/AbstractWindow.cs | 16 +- src/RemoteTech/UI/AntennaFragment.cs | 46 +- src/RemoteTech/UI/AntennaWindowStandalone.cs | 2 +- src/RemoteTech/UI/DebugWindow.cs | 20 +- src/RemoteTech/UI/NetworkCone.cs | 170 ---- src/RemoteTech/UI/NetworkLine.cs | 134 --- src/RemoteTech/UI/OptionWindow.cs | 76 +- src/RemoteTech/UI/TargetInfoFragment.cs | 12 +- src/RemoteTech/UI/TargetInfoWindow.cs | 24 +- src/RemoteTech/UI/TimeWarpDecorator.cs | 8 +- src/RemoteTech/VesselSatellite.cs | 185 +++- 58 files changed, 5105 insertions(+), 1274 deletions(-) create mode 100644 src/RemoteTech/Network/CelestialBodyInfo.cs create mode 100644 src/RemoteTech/Network/ConeMeshJob.cs create mode 100644 src/RemoteTech/Network/DrawableMesh.cs create mode 100644 src/RemoteTech/Network/Edges.cs create mode 100644 src/RemoteTech/Network/Jobs.cs create mode 100644 src/RemoteTech/Network/LineMeshJob.cs create mode 100644 src/RemoteTech/Network/NetworkState.cs create mode 100644 src/RemoteTech/Network/NetworkUpdate.cs create mode 100644 src/RemoteTech/Network/NetworkUpdateMath.cs create mode 100644 src/RemoteTech/Network/RangeModels.cs create mode 100644 src/RemoteTech/Network/SatelliteMarkJob.cs create mode 100644 src/RemoteTech/Network/VesselInfo.cs create mode 100644 src/RemoteTech/RangeModel/IRangeModel.cs create mode 100644 src/RemoteTech/SimpleTypes/AntennaState.cs create mode 100644 src/RemoteTech/StateManager.cs delete mode 100644 src/RemoteTech/UI/NetworkCone.cs delete mode 100644 src/RemoteTech/UI/NetworkLine.cs diff --git a/src/RemoteTech/API/API.cs b/src/RemoteTech/API/API.cs index 909a7122..284ec945 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() @@ -87,7 +89,7 @@ public static bool HasAnyConnection(Guid id) var satellite = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(id)).FirstOrDefault(); 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; } @@ -98,12 +100,14 @@ public static bool HasConnectionToKSC(Guid id) var satellite = RTCore.Instance.Satellites.Where(sat => sat.Guid.Equals(id)).FirstOrDefault(); 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) @@ -117,7 +121,9 @@ public static bool HasDirectGroundStation(Guid id) 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) @@ -132,7 +138,9 @@ public static string GetClosestDirectGroundStation(Guid id) 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) @@ -166,7 +174,9 @@ 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) @@ -178,7 +188,7 @@ public static Guid[] GetControlPath(Guid id) if (!RTCore.Instance.Network[satellite].Any(r => RTCore.Instance.Network.GroundStations.ContainsKey(r.Goal.Guid))) return new Guid[] { }; - List> bestRouter = RTCore.Instance.Network[satellite].Where(r => RTCore.Instance.Network.GroundStations.ContainsKey(r.Goal.Guid)).Min().Links; + IReadOnlyList> bestRouter = RTCore.Instance.Network[satellite].Where(r => RTCore.Instance.Network.GroundStations.ContainsKey(r.Goal.Guid)).Min().Links; Guid[] guids = new Guid[bestRouter.Count]; // Get all satellites till the ground station @@ -218,7 +228,9 @@ 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) @@ -238,11 +250,11 @@ 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) @@ -252,8 +264,7 @@ public static double GetShortestSignalDelay(Guid 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; } @@ -265,8 +276,7 @@ public static double GetSignalDelayToKSC(Guid 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; } @@ -481,30 +491,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); diff --git a/src/RemoteTech/AddOns/Kerbalism.cs b/src/RemoteTech/AddOns/Kerbalism.cs index 4d442702..11546def 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 4d4d296e..2213a9be 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/FlightComputer/Commands/ExternalAPICommand.cs b/src/RemoteTech/FlightComputer/Commands/ExternalAPICommand.cs index 3ce05d51..19859e5b 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 8482e900..db78a0e4 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 385d34b2..723899a1 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 28350244..24cc8f6e 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 c492aa54..fe595653 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 e636be54..3cdb83e7 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 41b5a2fa..e7500ea3 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 7e240d30..d754dc88 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 819a42e0..44c3a5fa 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 9b51e9a0..89524344 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 033cc7de..c55087d5 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 00000000..5a903ee7 --- /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 00000000..c916152b --- /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 00000000..9ed96da0 --- /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 00000000..fb03f7ae --- /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 00000000..45adae76 --- /dev/null +++ b/src/RemoteTech/Network/Jobs.cs @@ -0,0 +1,98 @@ +using System; +using System.Runtime.InteropServices; +using RemoteTech.Collections; +using Unity.Burst; +using Unity.Collections; +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; + + 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]; + + 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); + } +} diff --git a/src/RemoteTech/Network/LineMeshJob.cs b/src/RemoteTech/Network/LineMeshJob.cs new file mode 100644 index 00000000..996f2bba --- /dev/null +++ b/src/RemoteTech/Network/LineMeshJob.cs @@ -0,0 +1,283 @@ +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); + } + + 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 = (p.showPath != 0 && tree && onPath.IsSet(r.aIdx) && onPath.IsSet(r.bIdx)) + || (p.showMultiPath != 0 && tree); + + 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 00000000..9be98340 --- /dev/null +++ b/src/RemoteTech/Network/NetworkState.cs @@ -0,0 +1,845 @@ +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; + + // 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 update2 = new NetworkAdjacencyJob + { + config = config, + nodes = nodes, + edges = edges, + + ranges = adjranges, + adjacency = adjacency, + distances = distances, + connected = connected, + + 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(), + + scores = scoreCs, + parents = parentCs, + origins = originCs, + }.Schedule(dijkstraDeps); + var dijkstraGs = new MultiSourceDijkstraJob + { + ranges = adjranges.AsDeferredJobArray(), + adjacency = adjacency.AsDeferredJobArray(), + distances = distances.AsDeferredJobArray(), + roots = groundStations.AsDeferredJobArray(), + + 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, + }.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, + + 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 }; + } + + /// + /// Get the distance between the requested satellite and its control point. + /// + /// + /// Only consider ground stations, not vessels. + /// The length of network route, in meters. + public double GetRouteLength(ISatellite sat, bool groundOnly) + { + if (!satmap.TryGetValue(sat.Guid, out int index)) + return double.PositiveInfinity; + + return RouteLength(index, groundOnly); + } + + 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; + } + + /// + /// Get the control station that is currently controlling this satellite. + /// + /// + /// Only consider ground stations, not vessels. + public ISatellite GetRouteOrigin(ISatellite sat, bool groundOnly) + { + if (!satmap.TryGetValue(sat.Guid, out int index)) + return null; + + return RouteGoalSat(index, groundOnly); + } + + 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; + } + + /// + /// 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 void Execute() + { + antennas.Dispose(); + directions.Dispose(); + ranges.Dispose(); + adjranges.Dispose(); + adjacency.Dispose(); + distances.Dispose(); + commandStations.Dispose(); + groundStations.Dispose(); + } + } +} + diff --git a/src/RemoteTech/Network/NetworkUpdate.cs b/src/RemoteTech/Network/NetworkUpdate.cs new file mode 100644 index 00000000..b98da2d9 --- /dev/null +++ b/src/RemoteTech/Network/NetworkUpdate.cs @@ -0,0 +1,790 @@ +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( + in JobConfig config, + 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 (!IsEdgeTraversable(in config, nodes, edge.aIdx, edge.bIdx)) + 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); + } + } + + static bool IsEdgeTraversable( + in JobConfig config, + NativeArray nodes, + int a, + int b) + { + JobNode na = nodes[a]; + JobNode nb = nodes[b]; + + if (!na.Powered || !nb.Powered) + return false; + if (config.signalRelayEnabled && (!na.CanRelay || !nb.CanRelay)) + return false; + return true; + } + #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 NativeList commandStations; + public NativeList groundStations; + + public void Execute() + { + ranges.ResizeUninitialized(nodes.Length); + + NetworkUpdate.ComputeAdjacencyLists( + in config, + nodes, + edges, + ranges, + adjacency, + distances); + NetworkUpdate.ComputeVesselConnected(ranges, connected); + 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 00000000..95f2a9c6 --- /dev/null +++ b/src/RemoteTech/Network/NetworkUpdateMath.cs @@ -0,0 +1,114 @@ +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; + } + + 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 00000000..f76d901f --- /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 00000000..2d9de8d9 --- /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 00000000..53002512 --- /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 a0f59f7a..914abe0e 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 499e74b9..47a7b3a6 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,12 @@ 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 => RTCore.Instance.Satellites.Count + GroundStations.Count; - 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 +44,85 @@ 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 = []; + + // --- 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; - private int mTick; - private int mTickIndex; - private Dictionary>> mConnectionCache = new Dictionary>>(); + /// + /// 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; + + /// + /// Enumerates the whole adjacency graph (inspection/debug seam). + /// + internal IEnumerable>>> EnumerateLinks() => + current?.EnumerateLinks() ?? Enumerable.Empty>>>(); + + internal Dictionary>> ConnectionCache => mConnectionCache; + + /// + /// 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 +139,30 @@ 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)) + foreach (ISatellite sat in GroundStations.Values) { - paths.Add(NetworkPathfinder.Solve(start, root, FindNeighbors, RangeModelExtensions.DistanceTo, RangeModelExtensions.DistanceTo)); + if (sat is MissionControlSatellite station) + station.UnregisterAntennaStates(); } - mConnectionCache[start] = paths.Where(p => p.Exists).ToList(); - mConnectionCache[start].Sort((a, b) => a.Length.CompareTo(b.Length)); - start.OnConnectionRefresh(this[start]); + current?.Dispose(); + next?.Dispose(); } + /// + /// Powered (and, when signal relay is on, relay-capable) neighbours of + /// , resolved from the current state's adjacency. Used + /// by the on-demand A* path query exposed through the public API. + /// public IEnumerable> FindNeighbors(ISatellite s) { - if (!Graph.ContainsKey(s.Guid) || !s.Powered) return Enumerable.Empty>(); + if (s == null || !s.Powered || current == null) return Enumerable.Empty>(); + var links = current.GetLinks(s); if (RTSettings.Instance.SignalRelayEnabled) - { - return Graph[s.Guid].Where(l => l.Target.Powered && l.Target.CanRelaySignal); - } - else - { - return Graph[s.Guid].Where(l => l.Target.Powered); - } + return links.Where(l => l.Target.Powered && l.Target.CanRelaySignal); + return links.Where(l => l.Target.Powered); } - 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 +180,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 +227,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 +251,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 +276,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 +286,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 +376,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 index 9fd37cc7..e88f110e 100644 --- a/src/RemoteTech/NetworkPathfinder.cs +++ b/src/RemoteTech/NetworkPathfinder.cs @@ -16,7 +16,7 @@ public static NetworkRoute Solve(T start, T goal, 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); + var nStart = new Node>(new NetworkLink(start, LinkType.None), 0, heuristicFunction.Invoke(start, goal), null, false); nodeMap[start] = nStart; priorityQueue.Enqueue(nStart); diff --git a/src/RemoteTech/NetworkRenderer.cs b/src/RemoteTech/NetworkRenderer.cs index 0d7939ec..485c79c7 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 b39040c5..1c19d55e 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 74a4db2c..0d7a380c 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 bf55f8ab..d1293a2a 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 e9d939bf..ad0356cc 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 a1b4fb8d..98b33cd5 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 d4c65f0f..61c5e720 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 00000000..e2d71580 --- /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 0c57ff09..5b4a7a1d 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 5757176d..76cb8aa5 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 f5f657b6..ff164f5c 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/SatelliteManager.cs b/src/RemoteTech/SatelliteManager.cs index 221d4116..640f05f6 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 cdfe541a..6b4266b6 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 00000000..8a00503b --- /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 index 55cadb05..9fc6d7c2 100644 --- a/src/RemoteTech/SimpleTypes/BidirectionalEdge.cs +++ b/src/RemoteTech/SimpleTypes/BidirectionalEdge.cs @@ -2,20 +2,15 @@ namespace RemoteTech.SimpleTypes { - public enum LinkType + public enum LinkType : byte { None, Dish, Omni, } - public class BidirectionalEdge : IEquatable> + public readonly struct 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; @@ -28,6 +23,12 @@ public BidirectionalEdge(T a, T b, LinkType type) Type = type; } + public bool Equals(BidirectionalEdge other) + { + return (A.Equals(other.A) || A.Equals(other.B)) && + (B.Equals(other.A) || B.Equals(other.B)); + } + public override int GetHashCode() { return A.GetHashCode() + B.GetHashCode(); diff --git a/src/RemoteTech/SimpleTypes/NetworkLink.cs b/src/RemoteTech/SimpleTypes/NetworkLink.cs index cf0a3a38..04ecab04 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 e799da6f..13794d7e 100644 --- a/src/RemoteTech/SimpleTypes/NetworkRoute.cs +++ b/src/RemoteTech/SimpleTypes/NetworkRoute.cs @@ -1,53 +1,104 @@ -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. In view mode the route is + /// a lightweight handle into the persistent + /// (_state != null): // + /// 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. In explicit mode + /// (_state == null) it wraps a freestanding path, e.g. the result of the + /// A* or . + /// + 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; } } + // View mode. + 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; } + // Explicit mode. + private readonly T _start; + private readonly IReadOnlyList> _links; + private readonly double _length; - public NetworkRoute(T start, List> links, double dist) + private static readonly NetworkLink[] NoLinks = new NetworkLink[0]; + + public NetworkRoute(T start, IReadOnlyList> links, double dist) { - if (start == null) throw new ArgumentNullException("start"); - if (links == null) links = new List>(); - Start = start; - Links = links; - Length = dist; + if (start == null) throw new ArgumentNullException(nameof(start)); + _state = null; + _node = -1; + _groundOnly = false; + _start = start; + _links = links; + _length = dist; } - public bool Contains(BidirectionalEdge edge) + internal NetworkRoute(NetworkState state, int node, bool groundOnly) + { + _state = state; + _node = node; + _groundOnly = groundOnly; + _start = default; + _links = null; + _length = 0.0; + } + + public T Start => _state != null ? (T)(object)_state.SatAt(_node) : _start; + + public double Length => _state != null ? _state.RouteLength(_node, _groundOnly) : _length; + + public bool Exists => _state != null + ? _state.RouteExists(_node, _groundOnly) + : _links != null && _links.Count > 0; + + public double Delay => RTSettings.Instance.EnableSignalDelay + ? Length / RTSettings.Instance.SpeedOfLight + : 0.0; + + public IReadOnlyList> Links => _state != null + ? (IReadOnlyList>)(object)_state.RouteHops(_node, _groundOnly) + : _links ?? NoLinks; + + public T Goal { - 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++) + get { - 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; + if (!Exists) return default; + if (_state != null) return (T)(object)_state.RouteGoalSat(_node, _groundOnly); + return _links[_links.Count - 1].Target; } - return false; } - public int CompareTo(NetworkRoute other) + public bool Contains(BidirectionalEdge edge) { - return Length.CompareTo(other.Length); + var links = Links; + if (links.Count == 0) return false; + T start = Start; + 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 int CompareTo(NetworkRoute other) => Length.CompareTo(other.Length); + public override string ToString() { - return String.Format("NetworkRoute(Route: {{0}}, Length: {1})", - String.Join("→", Links.Select(t => t.ToString()).ToArray()), - Length.ToString("F2") + "m"); + 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"); } } @@ -58,4 +109,4 @@ public static NetworkRoute Empty(T start) return new NetworkRoute(start, null, Single.PositiveInfinity); } } -} \ No newline at end of file +} diff --git a/src/RemoteTech/StateManager.cs b/src/RemoteTech/StateManager.cs new file mode 100644 index 00000000..dec402f8 --- /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 ef42e212..9d9a8ec4 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 a0a62829..a33f4e2c 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 9c89d819..00de508a 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 43250d23..4f21ec6c 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 92a7a10d..00000000 --- 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 68d1514b..00000000 --- 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 babcb995..a45114ff 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 298d3c87..ba6232b2 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 1bd31ddf..2a09bc88 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 17786dfd..ef7d8c7e 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 f491428c..296ad31a 100644 --- a/src/RemoteTech/VesselSatellite.cs +++ b/src/RemoteTech/VesselSatellite.cs @@ -12,96 +12,191 @@ 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. + /// + /// 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 +206,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 +222,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})"; From e10d890348e4a7bb5f44d078aace420331229c47 Mon Sep 17 00:00:00 2001 From: Phantomical Date: Mon, 6 Jul 2026 22:01:08 -0700 Subject: [PATCH 04/11] Add test harnesses for the network jobs RemoteTech.Tests runs the Burst/NativeArray math headlessly under xunit by backing native arrays with Marshal.AllocHGlobal (NativeArena), covering the range/geometry math. RemoteTech.InGameTests is a KSP.Testing plugin with an in-game runner UI that exercises the jobs against real Unity Collections (adjacency, edges, Dijkstra, mark/cone geometry, hashing). --- tests/RemoteTech.InGameTests/Assert.cs | 130 +++++++++ .../Collections/ArrayMapTests.cs | 40 +++ .../Collections/SpookyHashTests.cs | 94 ++++++ .../Network/BuildAdjacencyJobTests.cs | 117 ++++++++ .../Network/BuildEdgesJobTests.cs | 269 ++++++++++++++++++ .../Network/ConeMeshJobTests.cs | 85 ++++++ .../Network/DijkstraJobTests.cs | 137 +++++++++ .../Network/GatherMarkCandidatesJobTests.cs | 102 +++++++ .../Network/NetworkUpdateMathTests.cs | 136 +++++++++ tests/RemoteTech.InGameTests/RTTestBase.cs | 14 + .../RemoteTech.InGameTests.csproj | 50 ++++ tests/RemoteTech.InGameTests/TestRunnerUI.cs | 193 +++++++++++++ tests/RemoteTech.Tests/NativeArena.cs | 64 +++++ .../NetworkUpdateMathTests.cs | 150 ++++++++++ .../RemoteTech.Tests/RemoteTech.Tests.csproj | 56 ++++ 15 files changed, 1637 insertions(+) create mode 100644 tests/RemoteTech.InGameTests/Assert.cs create mode 100644 tests/RemoteTech.InGameTests/Collections/ArrayMapTests.cs create mode 100644 tests/RemoteTech.InGameTests/Collections/SpookyHashTests.cs create mode 100644 tests/RemoteTech.InGameTests/Network/BuildAdjacencyJobTests.cs create mode 100644 tests/RemoteTech.InGameTests/Network/BuildEdgesJobTests.cs create mode 100644 tests/RemoteTech.InGameTests/Network/ConeMeshJobTests.cs create mode 100644 tests/RemoteTech.InGameTests/Network/DijkstraJobTests.cs create mode 100644 tests/RemoteTech.InGameTests/Network/GatherMarkCandidatesJobTests.cs create mode 100644 tests/RemoteTech.InGameTests/Network/NetworkUpdateMathTests.cs create mode 100644 tests/RemoteTech.InGameTests/RTTestBase.cs create mode 100644 tests/RemoteTech.InGameTests/RemoteTech.InGameTests.csproj create mode 100644 tests/RemoteTech.InGameTests/TestRunnerUI.cs create mode 100644 tests/RemoteTech.Tests/NativeArena.cs create mode 100644 tests/RemoteTech.Tests/NetworkUpdateMathTests.cs create mode 100644 tests/RemoteTech.Tests/RemoteTech.Tests.csproj diff --git a/tests/RemoteTech.InGameTests/Assert.cs b/tests/RemoteTech.InGameTests/Assert.cs new file mode 100644 index 00000000..c45c23b5 --- /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 00000000..2d599849 --- /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 00000000..a1460fd3 --- /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 00000000..9049bcc6 --- /dev/null +++ b/tests/RemoteTech.InGameTests/Network/BuildAdjacencyJobTests.cs @@ -0,0 +1,117 @@ +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, bool relay = false) + { + 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); + + var config = new JobConfig { signalRelayEnabled = relay }; + NetworkUpdate.ComputeAdjacencyLists(in config, 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_RelayEnabled_RequiresBothEndpointsRelayCapable")] + public void RelayEnabled_RequiresBothEndpointsRelayCapable() + { + // Node 1 is powered but not relay-capable. + var nodes = new[] + { + Node(NodeFlags.Powered | NodeFlags.CanRelay), + Node(NodeFlags.Powered), + }; + var edges = new[] { Edge(0, 1, 10.0) }; + + var off = Build(nodes, edges, relay: false); + Assert.AreEqual(1, off.Row(0).Count); + + var on = Build(nodes, edges, relay: true); + Assert.AreEqual(0, on.Row(0).Count); + Assert.AreEqual(0, on.Row(1).Count); + } + + [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 00000000..b04b8833 --- /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 00000000..81cfe6e6 --- /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 00000000..3ae1a09a --- /dev/null +++ b/tests/RemoteTech.InGameTests/Network/DijkstraJobTests.cs @@ -0,0 +1,137 @@ +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) + { + var (ranges, adjacency, distances) = BuildCsr(nodeCount, edges); + var rootArr = new NativeArray(roots, Allocator.Temp); + + // 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, + 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]); + } + } +} diff --git a/tests/RemoteTech.InGameTests/Network/GatherMarkCandidatesJobTests.cs b/tests/RemoteTech.InGameTests/Network/GatherMarkCandidatesJobTests.cs new file mode 100644 index 00000000..619e482d --- /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/NetworkUpdateMathTests.cs b/tests/RemoteTech.InGameTests/Network/NetworkUpdateMathTests.cs new file mode 100644 index 00000000..725a94be --- /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 00000000..75d4b54f --- /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 00000000..f4159b77 --- /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 00000000..bcab3007 --- /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 00000000..8ea1d179 --- /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 00000000..6354724b --- /dev/null +++ b/tests/RemoteTech.Tests/NetworkUpdateMathTests.cs @@ -0,0 +1,150 @@ +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); + } + } + + [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 00000000..9dae2f8e --- /dev/null +++ b/tests/RemoteTech.Tests/RemoteTech.Tests.csproj @@ -0,0 +1,56 @@ + + + + + + + net481 + 12 + disable + false + true + + true + + $(ReferencePath) + $(KSPGameRoot)\GameData\000_KSPBurst\Plugins + $(KSPGameRoot)\KSP_x64_Data\Managed + + + + + + + + + + + + + + + + $(KSPManaged)\UnityEngine.CoreModule.dll + true + + + $(KSPBurstPlugins)\Unity.Collections.dll + true + + + $(KSPBurstPlugins)\Unity.Mathematics.dll + true + + + $(KSPBurstPlugins)\Unity.Burst.dll + true + + + + From 461cfa83952ab80bec43c8f70c11eac08bcf216c Mon Sep 17 00:00:00 2001 From: Phantomical Date: Tue, 7 Jul 2026 00:32:53 -0700 Subject: [PATCH 05/11] Migrate the API onto the NetworkState read seam Route the public API methods through the precomputed NetworkState instead of the old on-demand LINQ/route materialization: - Add a GetRoute passthrough to the NetworkManager read seam. - GetFirstHopToKSC / GetControlPath now read the groundOnly route directly via GetRoute rather than filtering + Min()-ing over BuildConnections. - HasDirectGroundStation / GetClosestDirectGroundStation now test the satellite's direct adjacency row (GetLinks) for a ground-station target. This treats any direct link to a ground station as qualifying (not only first hops of a shortest route) and drops the empty/self-route NRE and the empty-sequence Min() throw. - Replace the O(n) Satellites.Where(...).FirstOrDefault() guid scans with the O(1) Satellites[id] indexer; GetName / GetRangeDistance use the Network[id] indexer so ground stations still resolve. GetSignalDelayToSatellite's arbitrary A->B pathfind is left on NetworkPathfinder; NetworkState has no point-to-point equivalent yet. --- src/RemoteTech/API/API.cs | 73 ++++++++++++++++++++------------ src/RemoteTech/NetworkManager.cs | 7 +++ 2 files changed, 52 insertions(+), 28 deletions(-) diff --git a/src/RemoteTech/API/API.cs b/src/RemoteTech/API/API.cs index 284ec945..839338ac 100644 --- a/src/RemoteTech/API/API.cs +++ b/src/RemoteTech/API/API.cs @@ -45,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; @@ -57,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) @@ -72,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) @@ -86,7 +86,7 @@ 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.IsConnected(satellite); @@ -97,7 +97,7 @@ 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.IsConnected(satellite, groundOnly: true); @@ -113,10 +113,11 @@ public static bool HasConnectionToKSC(Guid id) 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; } @@ -129,11 +130,25 @@ public static bool HasDirectGroundStation(Guid id) 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; } @@ -146,11 +161,13 @@ public static string GetClosestDirectGroundStation(Guid id) 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; } @@ -183,18 +200,18 @@ 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[] { }; - IReadOnlyList> 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; @@ -236,7 +253,7 @@ public static Guid GetGroundStationGuid(String name) 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; @@ -260,7 +277,7 @@ public static Guid GetActiveVesselGuid() { 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; @@ -272,7 +289,7 @@ 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; @@ -420,7 +437,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; @@ -435,7 +452,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; @@ -450,7 +467,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; @@ -465,7 +482,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; @@ -537,8 +554,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/NetworkManager.cs b/src/RemoteTech/NetworkManager.cs index 47a7b3a6..41a58fce 100644 --- a/src/RemoteTech/NetworkManager.cs +++ b/src/RemoteTech/NetworkManager.cs @@ -91,6 +91,13 @@ public List> this[ISatellite sat] /// 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); + /// /// Enumerates the whole adjacency graph (inspection/debug seam). /// From 1e6feb461918a656afb343a3bbc491c542fdc34d Mon Sep 17 00:00:00 2001 From: Phantomical Date: Tue, 7 Jul 2026 01:46:21 -0700 Subject: [PATCH 06/11] Gate signal relay on transit rather than link existence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relay capability now determines whether a node may forward a signal (transit), not whether a link exists. IsEdgeTraversable previously required both endpoints of every edge to be relay-capable, which wrongly disconnected powered non-relay endpoints (e.g. passive-SPU vessels) — a route's own endpoints never forward, so they should not need to relay. ComputeAdjacencyLists now includes an edge whenever both endpoints are powered. The relay check moves into a per-node CanTransit bitmask: the multi-source Dijkstra records a node's score but only expands out of it when it can transit, and roots (route endpoints) always expand. --- src/RemoteTech/Network/Jobs.cs | 9 ++++ src/RemoteTech/Network/NetworkState.cs | 7 ++++ src/RemoteTech/Network/NetworkUpdate.cs | 29 ++++++------- .../Network/BuildAdjacencyJobTests.cs | 41 ++++++++++++------- .../Network/DijkstraJobTests.cs | 21 +++++++++- 5 files changed, 78 insertions(+), 29 deletions(-) diff --git a/src/RemoteTech/Network/Jobs.cs b/src/RemoteTech/Network/Jobs.cs index 45adae76..a5436d47 100644 --- a/src/RemoteTech/Network/Jobs.cs +++ b/src/RemoteTech/Network/Jobs.cs @@ -30,6 +30,10 @@ internal struct MultiSourceDijkstraJob : IJob [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; @@ -70,6 +74,11 @@ public void Execute() 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]; diff --git a/src/RemoteTech/Network/NetworkState.cs b/src/RemoteTech/Network/NetworkState.cs index 9be98340..d7c92c67 100644 --- a/src/RemoteTech/Network/NetworkState.cs +++ b/src/RemoteTech/Network/NetworkState.cs @@ -234,6 +234,7 @@ public static NetworkState ScheduleNetworkUpdate(NetworkManager manager, Network var commandStations = new NativeList(Allocator.TempJob); var groundStations = new NativeList(Allocator.TempJob); + var canTransit = new NativeBitArray(nodeCount, Allocator.TempJob); var update2 = new NetworkAdjacencyJob { @@ -245,6 +246,7 @@ public static NetworkState ScheduleNetworkUpdate(NetworkManager manager, Network adjacency = adjacency, distances = distances, connected = connected, + canTransit = canTransit, commandStations = commandStations, groundStations = groundStations, @@ -265,6 +267,7 @@ public static NetworkState ScheduleNetworkUpdate(NetworkManager manager, Network adjacency = adjacency.AsDeferredJobArray(), distances = distances.AsDeferredJobArray(), roots = commandStations.AsDeferredJobArray(), + canTransit = canTransit, scores = scoreCs, parents = parentCs, @@ -276,6 +279,7 @@ public static NetworkState ScheduleNetworkUpdate(NetworkManager manager, Network adjacency = adjacency.AsDeferredJobArray(), distances = distances.AsDeferredJobArray(), roots = groundStations.AsDeferredJobArray(), + canTransit = canTransit, scores = scoreGs, parents = parentGs, @@ -293,6 +297,7 @@ public static NetworkState ScheduleNetworkUpdate(NetworkManager manager, Network distances = distances, commandStations = commandStations, groundStations = groundStations, + canTransit = canTransit, }.Schedule(dijkstraAll)); var fingerprints = new NativeList(0, Allocator.Persistent); @@ -828,6 +833,7 @@ struct DisposeTempContainersJob : IJob public NativeList distances; public NativeList commandStations; public NativeList groundStations; + public NativeBitArray canTransit; public void Execute() { @@ -839,6 +845,7 @@ public void Execute() distances.Dispose(); commandStations.Dispose(); groundStations.Dispose(); + canTransit.Dispose(); } } } diff --git a/src/RemoteTech/Network/NetworkUpdate.cs b/src/RemoteTech/Network/NetworkUpdate.cs index b98da2d9..0e1a3243 100644 --- a/src/RemoteTech/Network/NetworkUpdate.cs +++ b/src/RemoteTech/Network/NetworkUpdate.cs @@ -499,7 +499,6 @@ internal static NativeHashMap ComputeOverallMapping( #region Compute Adjacency public static void ComputeAdjacencyLists( - in JobConfig config, NativeArray nodes, NativeArray edges, NativeArray ranges, @@ -519,7 +518,7 @@ public static void ComputeAdjacencyLists( var edge = edges[i]; if (!edge.valid) continue; - if (!IsEdgeTraversable(in config, nodes, edge.aIdx, edge.bIdx)) + if (!nodes[edge.aIdx].Powered || !nodes[edge.bIdx].Powered) continue; var idxA = cursors[edge.aIdx]++; @@ -551,20 +550,21 @@ public static void ComputeAdjacencyLists( } } - static bool IsEdgeTraversable( + /// + /// 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, - int a, - int b) + NativeBitArray canTransit) { - JobNode na = nodes[a]; - JobNode nb = nodes[b]; - - if (!na.Powered || !nb.Powered) - return false; - if (config.signalRelayEnabled && (!na.CanRelay || !nb.CanRelay)) - return false; - return true; + for (int i = 0; i < nodes.Length; ++i) + canTransit.Set(i, CanTransit(in config, nodes[i])); } #endregion @@ -735,6 +735,7 @@ struct NetworkAdjacencyJob : IJob public NativeList adjacency; public NativeList distances; public NativeBitArray connected; + public NativeBitArray canTransit; public NativeList commandStations; public NativeList groundStations; @@ -744,13 +745,13 @@ public void Execute() ranges.ResizeUninitialized(nodes.Length); NetworkUpdate.ComputeAdjacencyLists( - in config, nodes, edges, ranges, adjacency, distances); NetworkUpdate.ComputeVesselConnected(ranges, connected); + NetworkUpdate.ComputeCanTransit(in config, nodes, canTransit); NetworkUpdate.ComputeNetworkRoots(nodes, commandStations, groundStations); } } diff --git a/tests/RemoteTech.InGameTests/Network/BuildAdjacencyJobTests.cs b/tests/RemoteTech.InGameTests/Network/BuildAdjacencyJobTests.cs index 9049bcc6..b3be1d20 100644 --- a/tests/RemoteTech.InGameTests/Network/BuildAdjacencyJobTests.cs +++ b/tests/RemoteTech.InGameTests/Network/BuildAdjacencyJobTests.cs @@ -29,7 +29,7 @@ private sealed class Csr } } - private static Csr Build(JobNode[] nodes, NetworkEdge[] edges, bool relay = false) + private static Csr Build(JobNode[] nodes, NetworkEdge[] edges) { var nodeArr = new NativeArray(nodes, Allocator.Temp); var edgeArr = new NativeArray(edges, Allocator.Temp); @@ -39,8 +39,7 @@ private static Csr Build(JobNode[] nodes, NetworkEdge[] edges, bool relay = fals var adjacency = new NativeList(0, Allocator.Temp); var distances = new NativeList(0, Allocator.Temp); - var config = new JobConfig { signalRelayEnabled = relay }; - NetworkUpdate.ComputeAdjacencyLists(in config, nodeArr, edgeArr, ranges, adjacency, distances); + NetworkUpdate.ComputeAdjacencyLists(nodeArr, edgeArr, ranges, adjacency, distances); return new Csr { Ranges = ranges, Adjacency = adjacency, Distances = distances }; } @@ -83,23 +82,37 @@ public void UnpoweredEndpoint_MakesEdgeNonTraversable() Assert.AreEqual(0, csr.Row(1).Count); } - [TestInfo("BuildAdjacencyJobTests_RelayEnabled_RequiresBothEndpointsRelayCapable")] - public void RelayEnabled_RequiresBothEndpointsRelayCapable() + [TestInfo("BuildAdjacencyJobTests_RelayCapability_DoesNotGateAdjacency")] + public void RelayCapability_DoesNotGateAdjacency() { - // Node 1 is powered but not relay-capable. - var nodes = new[] + // 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), - }; - var edges = new[] { Edge(0, 1, 10.0) }; + }, Allocator.Temp); - var off = Build(nodes, edges, relay: false); - Assert.AreEqual(1, off.Row(0).Count); + 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 = Build(nodes, edges, relay: true); - Assert.AreEqual(0, on.Row(0).Count); - Assert.AreEqual(0, on.Row(1).Count); + 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")] diff --git a/tests/RemoteTech.InGameTests/Network/DijkstraJobTests.cs b/tests/RemoteTech.InGameTests/Network/DijkstraJobTests.cs index 3ae1a09a..f996a10f 100644 --- a/tests/RemoteTech.InGameTests/Network/DijkstraJobTests.cs +++ b/tests/RemoteTech.InGameTests/Network/DijkstraJobTests.cs @@ -43,11 +43,15 @@ private struct Result public NativeList Origins; } - private static Result Run(int nodeCount, (int a, int b, double w)[] edges, int[] roots) + 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); @@ -63,6 +67,7 @@ private static Result Run(int nodeCount, (int a, int b, double w)[] edges, int[] adjacency = adjacency, distances = distances, roots = rootArr, + canTransit = transit, scores = scores, parents = parents, origins = origins, @@ -134,4 +139,18 @@ public void NoRoots_LeavesEverythingUnreachable() 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]); + } } From 8562f2baa9a267fe495aef8835a14e4b6fe25cdd Mon Sep 17 00:00:00 2001 From: Phantomical Date: Tue, 7 Jul 2026 01:48:04 -0700 Subject: [PATCH 07/11] Add a point-to-point signal delay query to the API Replace GetSignalDelayToSatellite's on-demand A* (NetworkPathfinder) with a Burst NetworkPathfindJob run over the persisted NetworkState edge table, exposed as NetworkState.ShortestDelayBetween plus a NetworkManager passthrough. Add the NetworkUpdateMath.EncodePairIndex helper the job uses to index the triangular edge table. --- src/RemoteTech/API/API.cs | 11 +- src/RemoteTech/Network/Jobs.cs | 89 ++++++++++ src/RemoteTech/Network/NetworkState.cs | 46 ++++++ src/RemoteTech/Network/NetworkUpdateMath.cs | 11 ++ src/RemoteTech/NetworkManager.cs | 7 + .../Network/NetworkPathfindJobTests.cs | 152 ++++++++++++++++++ .../NetworkUpdateMathTests.cs | 17 ++ 7 files changed, 325 insertions(+), 8 deletions(-) create mode 100644 tests/RemoteTech.InGameTests/Network/NetworkPathfindJobTests.cs diff --git a/src/RemoteTech/API/API.cs b/src/RemoteTech/API/API.cs index 839338ac..f74021b8 100644 --- a/src/RemoteTech/API/API.cs +++ b/src/RemoteTech/API/API.cs @@ -301,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; } diff --git a/src/RemoteTech/Network/Jobs.cs b/src/RemoteTech/Network/Jobs.cs index a5436d47..3913841e 100644 --- a/src/RemoteTech/Network/Jobs.cs +++ b/src/RemoteTech/Network/Jobs.cs @@ -3,6 +3,7 @@ using RemoteTech.Collections; using Unity.Burst; using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; using Unity.Jobs; namespace RemoteTech.Network; @@ -105,3 +106,91 @@ struct HeapNode : IComparable 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/NetworkState.cs b/src/RemoteTech/Network/NetworkState.cs index d7c92c67..234f7f08 100644 --- a/src/RemoteTech/Network/NetworkState.cs +++ b/src/RemoteTech/Network/NetworkState.cs @@ -23,6 +23,10 @@ internal class NetworkState : IDisposable 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; @@ -346,6 +350,7 @@ public static NetworkState ScheduleNetworkUpdate(NetworkManager manager, Network { handle = JobHandle.CombineDependencies(handles.AsArray()), satellites = satellites, + config = config, satmap = satmap, nodes = nodes, @@ -753,6 +758,47 @@ public double ShortestDelay(ISatellite sat, bool groundOnly = false) 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 diff --git a/src/RemoteTech/Network/NetworkUpdateMath.cs b/src/RemoteTech/Network/NetworkUpdateMath.cs index 95f2a9c6..2aa18f78 100644 --- a/src/RemoteTech/Network/NetworkUpdateMath.cs +++ b/src/RemoteTech/Network/NetworkUpdateMath.cs @@ -110,5 +110,16 @@ public static void DecodePairIndex(int pairIdx, out int i, out int 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/NetworkManager.cs b/src/RemoteTech/NetworkManager.cs index 41a58fce..55df26cf 100644 --- a/src/RemoteTech/NetworkManager.cs +++ b/src/RemoteTech/NetworkManager.cs @@ -98,6 +98,13 @@ public List> this[ISatellite sat] /// 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). /// diff --git a/tests/RemoteTech.InGameTests/Network/NetworkPathfindJobTests.cs b/tests/RemoteTech.InGameTests/Network/NetworkPathfindJobTests.cs new file mode 100644 index 00000000..eeaf7428 --- /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.Tests/NetworkUpdateMathTests.cs b/tests/RemoteTech.Tests/NetworkUpdateMathTests.cs index 6354724b..fed19d93 100644 --- a/tests/RemoteTech.Tests/NetworkUpdateMathTests.cs +++ b/tests/RemoteTech.Tests/NetworkUpdateMathTests.cs @@ -41,6 +41,23 @@ public void DecodePairIndex_RoundTripsEveryPair() } } + [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 From ea5d5728517cbbf7588fb80a57b0636445606fac Mon Sep 17 00:00:00 2001 From: Phantomical Date: Tue, 7 Jul 2026 02:08:05 -0700 Subject: [PATCH 08/11] Hide command-station routes rooted at filtered-out vessels The multi-path overlay drew every tree edge in the command-station forest, so a vessel hidden by the map view filter still showed its green route back to control. Seed the on-path set by walking the predecessor forest from each visible vessel instead, so a route is drawn only when a visible vessel sits at its far end. --- src/RemoteTech/Network/LineMeshJob.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/RemoteTech/Network/LineMeshJob.cs b/src/RemoteTech/Network/LineMeshJob.cs index 996f2bba..6fa9df46 100644 --- a/src/RemoteTech/Network/LineMeshJob.cs +++ b/src/RemoteTech/Network/LineMeshJob.cs @@ -74,6 +74,19 @@ public void Execute() 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(); @@ -84,8 +97,7 @@ public void Execute() continue; bool tree = parent[r.aIdx] == r.bIdx || parent[r.bIdx] == r.aIdx; - bool onP = (p.showPath != 0 && tree && onPath.IsSet(r.aIdx) && onPath.IsSet(r.bIdx)) - || (p.showMultiPath != 0 && tree); + 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; From 8b5b71d3e87968f20fd1c5a7a022364181dd0dbc Mon Sep 17 00:00:00 2001 From: Phantomical Date: Tue, 7 Jul 2026 02:13:59 -0700 Subject: [PATCH 09/11] Remove dead network-model code superseded by NetworkState The migration onto the Burst NetworkState left the old managed routing orphaned. Delete the A* NetworkPathfinder and the explicit-mode half of NetworkRoute it fed (the freestanding-path ctor, Empty, Contains, and the BidirectionalEdge it compared against), leaving the live value-type view into the Dijkstra forests. Drop the unused NetworkManager.FindNeighbors / ConnectionCache / Count, the NetworkState.GetRouteLength / GetRouteOrigin guid wrappers (callers use the internal index versions), the unused VesselSatellite.Connections seam, and the never-referenced Dish type. --- src/RemoteTech/Network/NetworkState.cs | 27 ----- src/RemoteTech/NetworkManager.cs | 18 ---- src/RemoteTech/NetworkPathfinder.cs | 101 ------------------ .../SimpleTypes/BidirectionalEdge.cs | 42 -------- src/RemoteTech/SimpleTypes/Dish.cs | 28 ----- src/RemoteTech/SimpleTypes/LinkType.cs | 8 ++ src/RemoteTech/SimpleTypes/NetworkRoute.cs | 77 ++----------- src/RemoteTech/VesselSatellite.cs | 5 - 8 files changed, 17 insertions(+), 289 deletions(-) delete mode 100644 src/RemoteTech/NetworkPathfinder.cs delete mode 100644 src/RemoteTech/SimpleTypes/BidirectionalEdge.cs delete mode 100644 src/RemoteTech/SimpleTypes/Dish.cs create mode 100644 src/RemoteTech/SimpleTypes/LinkType.cs diff --git a/src/RemoteTech/Network/NetworkState.cs b/src/RemoteTech/Network/NetworkState.cs index 234f7f08..0f18d6fc 100644 --- a/src/RemoteTech/Network/NetworkState.cs +++ b/src/RemoteTech/Network/NetworkState.cs @@ -554,20 +554,6 @@ internal SatelliteMarkData ScheduleSatelliteMarks(in SatelliteMarkViewParams p) return new SatelliteMarkData { marks = marks, handle = h }; } - /// - /// Get the distance between the requested satellite and its control point. - /// - /// - /// Only consider ground stations, not vessels. - /// The length of network route, in meters. - public double GetRouteLength(ISatellite sat, bool groundOnly) - { - if (!satmap.TryGetValue(sat.Guid, out int index)) - return double.PositiveInfinity; - - return RouteLength(index, groundOnly); - } - internal double RouteLength(int node, bool groundOnly) { var origin = GetOrigin(node, groundOnly); @@ -601,19 +587,6 @@ internal bool RouteExists(int node, bool groundOnly) return origin >= 0 && origin != node; } - /// - /// Get the control station that is currently controlling this satellite. - /// - /// - /// Only consider ground stations, not vessels. - public ISatellite GetRouteOrigin(ISatellite sat, bool groundOnly) - { - if (!satmap.TryGetValue(sat.Guid, out int index)) - return null; - - return RouteGoalSat(index, groundOnly); - } - internal ISatellite RouteGoalSat(int node, bool groundOnly) { var origin = GetOrigin(node, groundOnly); diff --git a/src/RemoteTech/NetworkManager.cs b/src/RemoteTech/NetworkManager.cs index 55df26cf..50ef90ce 100644 --- a/src/RemoteTech/NetworkManager.cs +++ b/src/RemoteTech/NetworkManager.cs @@ -21,8 +21,6 @@ public partial class NetworkManager : IEnumerable public ArrayMap Planets { get; private set; } = new(); public ArrayMap GroundStations { get; private set; } = new(); - public int Count => RTCore.Instance.Satellites.Count + GroundStations.Count; - public static Guid ActiveVesselGuid => RTSettings.Instance.ActiveVesselGuidParsed; public ISatellite this[Guid guid] @@ -111,8 +109,6 @@ public List> this[ISatellite sat] internal IEnumerable>>> EnumerateLinks() => current?.EnumerateLinks() ?? Enumerable.Empty>>>(); - internal Dictionary>> ConnectionCache => mConnectionCache; - /// /// This tick's state — freshest positions, but not yet completed; callers pay for if it's still running. /// @@ -162,20 +158,6 @@ public void Dispose() next?.Dispose(); } - /// - /// Powered (and, when signal relay is on, relay-capable) neighbours of - /// , resolved from the current state's adjacency. Used - /// by the on-demand A* path query exposed through the public API. - /// - public IEnumerable> FindNeighbors(ISatellite s) - { - if (s == null || !s.Powered || current == null) return Enumerable.Empty>(); - var links = current.GetLinks(s); - if (RTSettings.Instance.SignalRelayEnabled) - return links.Where(l => l.Target.Powered && l.Target.CanRelaySignal); - return links.Where(l => l.Target.Powered); - } - public static NetworkLink? GetLink(ISatellite sat_a, ISatellite sat_b) { if (sat_a == null || sat_b == null || sat_a == sat_b) return null; diff --git a/src/RemoteTech/NetworkPathfinder.cs b/src/RemoteTech/NetworkPathfinder.cs deleted file mode 100644 index e88f110e..00000000 --- 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, 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/SimpleTypes/BidirectionalEdge.cs b/src/RemoteTech/SimpleTypes/BidirectionalEdge.cs deleted file mode 100644 index 9fc6d7c2..00000000 --- a/src/RemoteTech/SimpleTypes/BidirectionalEdge.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; - -namespace RemoteTech.SimpleTypes -{ - public enum LinkType : byte - { - None, - Dish, - Omni, - } - - public readonly struct BidirectionalEdge : IEquatable> - { - - 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 bool Equals(BidirectionalEdge other) - { - return (A.Equals(other.A) || A.Equals(other.B)) && - (B.Equals(other.A) || B.Equals(other.B)); - } - - 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 ffcadaa7..00000000 --- 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 00000000..fd7b9993 --- /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/NetworkRoute.cs b/src/RemoteTech/SimpleTypes/NetworkRoute.cs index 13794d7e..ca45867e 100644 --- a/src/RemoteTech/SimpleTypes/NetworkRoute.cs +++ b/src/RemoteTech/SimpleTypes/NetworkRoute.cs @@ -5,90 +5,39 @@ namespace RemoteTech.SimpleTypes { /// - /// A connection route, as a value-type view. In view mode the route is - /// a lightweight handle into the persistent - /// (_state != null): // + /// 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. In explicit mode - /// (_state == null) it wraps a freestanding path, e.g. the result of the - /// A* or . + /// when is actually read. /// public readonly struct NetworkRoute : IComparable> { - // View mode. private readonly NetworkState _state; private readonly int _node; private readonly bool _groundOnly; - // Explicit mode. - private readonly T _start; - private readonly IReadOnlyList> _links; - private readonly double _length; - - private static readonly NetworkLink[] NoLinks = new NetworkLink[0]; - - public NetworkRoute(T start, IReadOnlyList> links, double dist) - { - if (start == null) throw new ArgumentNullException(nameof(start)); - _state = null; - _node = -1; - _groundOnly = false; - _start = start; - _links = links; - _length = dist; - } - internal NetworkRoute(NetworkState state, int node, bool groundOnly) { _state = state; _node = node; _groundOnly = groundOnly; - _start = default; - _links = null; - _length = 0.0; } - public T Start => _state != null ? (T)(object)_state.SatAt(_node) : _start; + public T Start => (T)(object)_state.SatAt(_node); - public double Length => _state != null ? _state.RouteLength(_node, _groundOnly) : _length; + public double Length => _state.RouteLength(_node, _groundOnly); - public bool Exists => _state != null - ? _state.RouteExists(_node, _groundOnly) - : _links != null && _links.Count > 0; + public bool Exists => _state.RouteExists(_node, _groundOnly); public double Delay => RTSettings.Instance.EnableSignalDelay ? Length / RTSettings.Instance.SpeedOfLight : 0.0; - public IReadOnlyList> Links => _state != null - ? (IReadOnlyList>)(object)_state.RouteHops(_node, _groundOnly) - : _links ?? NoLinks; - - public T Goal - { - get - { - if (!Exists) return default; - if (_state != null) return (T)(object)_state.RouteGoalSat(_node, _groundOnly); - return _links[_links.Count - 1].Target; - } - } + public IReadOnlyList> Links => + (IReadOnlyList>)(object)_state.RouteHops(_node, _groundOnly); - public bool Contains(BidirectionalEdge edge) - { - var links = Links; - if (links.Count == 0) return false; - T start = Start; - 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 Goal => Exists ? (T)(object)_state.RouteGoalSat(_node, _groundOnly) : default; public int CompareTo(NetworkRoute other) => Length.CompareTo(other.Length); @@ -101,12 +50,4 @@ public override string ToString() String.Join("→", parts), Length.ToString("F2") + "m"); } } - - public class NetworkRoute - { - public static NetworkRoute Empty(T start) - { - return new NetworkRoute(start, null, Single.PositiveInfinity); - } - } } diff --git a/src/RemoteTech/VesselSatellite.cs b/src/RemoteTech/VesselSatellite.cs index 296ad31a..56fd7ed4 100644 --- a/src/RemoteTech/VesselSatellite.cs +++ b/src/RemoteTech/VesselSatellite.cs @@ -189,11 +189,6 @@ public bool HasLocalControl * Helpers */ - /// - /// List of network routes for the satellite. - /// - public List> Connections => RTCore.Instance.Network[this]; - /// /// Called on connection refresh to update the connections. /// From 078e55676f9d1f163235365074b9b8e316973b65 Mon Sep 17 00:00:00 2001 From: Phantomical Date: Tue, 7 Jul 2026 03:05:29 -0700 Subject: [PATCH 10/11] Properly install CKAN deps in CI --- src/RemoteTech/RemoteTech.csproj | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/RemoteTech/RemoteTech.csproj b/src/RemoteTech/RemoteTech.csproj index a27af9d0..7716f39f 100644 --- a/src/RemoteTech/RemoteTech.csproj +++ b/src/RemoteTech/RemoteTech.csproj @@ -21,6 +21,11 @@ 1.9.12 1.9.0.0 + + + false + true @@ -39,6 +44,7 @@ false + SerializationFix SerializationFix 1.0 true @@ -46,6 +52,7 @@ false + KSPBurst-Lite KSPBurst 1.7 true From 447ac5c5084989abf7601b0bd3ee76e2190dceef Mon Sep 17 00:00:00 2001 From: Phantomical Date: Tue, 7 Jul 2026 11:47:59 -0700 Subject: [PATCH 11/11] Use KSPBT for the tests project --- .../RemoteTech.Tests/RemoteTech.Tests.csproj | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/RemoteTech.Tests/RemoteTech.Tests.csproj b/tests/RemoteTech.Tests/RemoteTech.Tests.csproj index 9dae2f8e..fe4bac4e 100644 --- a/tests/RemoteTech.Tests/RemoteTech.Tests.csproj +++ b/tests/RemoteTech.Tests/RemoteTech.Tests.csproj @@ -1,6 +1,6 @@ - + @@ -9,15 +9,21 @@ disable false true - - true - $(ReferencePath) - $(KSPGameRoot)\GameData\000_KSPBurst\Plugins - $(KSPGameRoot)\KSP_x64_Data\Managed + + false + false + false + false + false + false + false + false + false + @@ -36,19 +42,19 @@ --> - $(KSPManaged)\UnityEngine.CoreModule.dll + $(KSPBT_ManagedPath)/UnityEngine.CoreModule.dll true - $(KSPBurstPlugins)\Unity.Collections.dll + $(KSPBT_GameRoot)/GameData/000_KSPBurst/Plugins/Unity.Collections.dll true - $(KSPBurstPlugins)\Unity.Mathematics.dll + $(KSPBT_GameRoot)/GameData/000_KSPBurst/Plugins/Unity.Mathematics.dll true - $(KSPBurstPlugins)\Unity.Burst.dll + $(KSPBT_GameRoot)/GameData/000_KSPBurst/Plugins/Unity.Burst.dll true