From 4f58225a95e68b06ec0ab763f9603a5950390f3d Mon Sep 17 00:00:00 2001 From: "Diffuin[bot]" Date: Sun, 16 Aug 2026 04:17:03 +0000 Subject: [PATCH 1/2] chore(diffuin): address #237 --- .../TemperatureApiCompileFixture.cs | 39 ++++ S1API/Temperature/TemperatureAlgorithm.cs | 55 ++++++ S1API/Temperature/TemperatureEmitter.cs | 179 ++++++++++++++++++ S1API/Temperature/TemperatureEmitterInfo.cs | 38 ++++ S1API/Temperature/TemperatureUtility.cs | 59 ++++++ 5 files changed, 370 insertions(+) create mode 100644 S1API.Tests/Temperature/TemperatureApiCompileFixture.cs create mode 100644 S1API/Temperature/TemperatureAlgorithm.cs create mode 100644 S1API/Temperature/TemperatureEmitter.cs create mode 100644 S1API/Temperature/TemperatureEmitterInfo.cs create mode 100644 S1API/Temperature/TemperatureUtility.cs diff --git a/S1API.Tests/Temperature/TemperatureApiCompileFixture.cs b/S1API.Tests/Temperature/TemperatureApiCompileFixture.cs new file mode 100644 index 00000000..fd88df9e --- /dev/null +++ b/S1API.Tests/Temperature/TemperatureApiCompileFixture.cs @@ -0,0 +1,39 @@ +using System; +using S1API.Temperature; +using UnityEngine; + +namespace S1API.Tests.Temperature; + +internal static class TemperatureApiCompileFixture +{ + internal static void ConfigureAndQuery(GameObject gameObject) + { + TemperatureEmitter emitter = TemperatureEmitter.GetOrAddComponent(gameObject); + emitter.SetTemperature(20f); + emitter.SetRange(5f); + emitter.SetPosition(Vector3.zero); + + Action changed = () => { }; + emitter.OnChanged += changed; + emitter.OnChanged -= changed; + + TemperatureEmitter? existing = TemperatureEmitter.FromGameObject(gameObject); + TemperatureEmitterInfo[] emitters = + { + new TemperatureEmitterInfo(emitter.Temperature, emitter.Range * emitter.Range, emitter.EmissionPoint) + }; + float temperature = TemperatureAlgorithm.GetTemperatureAtPoint( + ambientTemperature: 20f, + originPoint: Vector3.zero, + point: Vector3.one, + emitters: emitters); + + _ = existing; + _ = temperature; + _ = TemperatureUtility.ToFahrenheit(20f); + _ = TemperatureUtility.FormatCelsiusTemperature(20f, decimalPoints: 1); + _ = TemperatureUtility.FormatFahrenheitTemperature(68f, decimalPoints: 1); + _ = TemperatureUtility.FormatTemperatureWithAppropriateUnit(20f); + _ = TemperatureUtility.NormalizeTemperature(20f); + } +} diff --git a/S1API/Temperature/TemperatureAlgorithm.cs b/S1API/Temperature/TemperatureAlgorithm.cs new file mode 100644 index 00000000..30187c7f --- /dev/null +++ b/S1API/Temperature/TemperatureAlgorithm.cs @@ -0,0 +1,55 @@ +#if IL2CPPMELON +using Il2CppInterop.Runtime.InteropTypes.Arrays; +using S1Temperature = Il2CppScheduleOne.Temperature; +#elif MONOMELON +using S1Temperature = ScheduleOne.Temperature; +#endif + +using UnityEngine; + +namespace S1API.Temperature +{ + /// + /// Queries temperatures with the game's native temperature algorithm. + /// + public static class TemperatureAlgorithm + { + /// + /// Calculates the temperature at a world position from an ambient temperature and emitter snapshots. + /// + /// The ambient temperature in the game's native temperature scale. + /// The origin used by the game's temperature calculation. + /// The world position to query. + /// The emitter snapshots to include in the calculation. + /// The temperature at in the game's native temperature scale. + /// + /// This method does not discover scene emitters or register the supplied snapshots with a grid. + /// + public static float GetTemperatureAtPoint( + float ambientTemperature, + Vector3 originPoint, + Vector3 point, + TemperatureEmitterInfo[] emitters) + { +#if IL2CPPMELON + var nativeEmitters = new Il2CppStructArray(emitters.Length); +#else + var nativeEmitters = new S1Temperature.TemperatureEmitterInfo[emitters.Length]; +#endif + for (int i = 0; i < emitters.Length; i++) + { + TemperatureEmitterInfo emitter = emitters[i]; + nativeEmitters[i] = new S1Temperature.TemperatureEmitterInfo( + emitter.Temperature, + emitter.SqrRange, + emitter.Position); + } + + return S1Temperature.TemperatureAlgorithm.GetTemperatureAtPoint( + ambientTemperature, + originPoint, + point, + nativeEmitters); + } + } +} diff --git a/S1API/Temperature/TemperatureEmitter.cs b/S1API/Temperature/TemperatureEmitter.cs new file mode 100644 index 00000000..2c100304 --- /dev/null +++ b/S1API/Temperature/TemperatureEmitter.cs @@ -0,0 +1,179 @@ +#if IL2CPPMELON +using Il2CppInterop.Runtime; +using NativeEmitterChangedAction = Il2CppSystem.Action; +using S1Temperature = Il2CppScheduleOne.Temperature; +#elif MONOMELON +using NativeEmitterChangedAction = System.Action; +using S1Temperature = ScheduleOne.Temperature; +#endif + +using System; +using S1API.Internal.Utils; +using UnityEngine; + +namespace S1API.Temperature +{ + /// + /// Wraps a native temperature emitter attached to a mod-owned game object. + /// + /// + /// Adding this component does not register it with a native grid, persist its configuration, or synchronize it over the network. + /// + public sealed class TemperatureEmitter + { + /// + /// INTERNAL: The native temperature emitter component. + /// + internal readonly S1Temperature.TemperatureEmitter S1TemperatureEmitter; + + private readonly ManagedEventRegistrationTracker _changedRegistrations = + new ManagedEventRegistrationTracker(); + + /// + /// INTERNAL: Creates a wrapper around a native temperature emitter component. + /// + /// The native temperature emitter component. + internal TemperatureEmitter(S1Temperature.TemperatureEmitter temperatureEmitter) + { + S1TemperatureEmitter = temperatureEmitter; + } + + /// + /// Gets the native temperature emitter attached to a game object. + /// + /// The game object to inspect. + /// A temperature-emitter wrapper, or null when the game object has no emitter component. + public static TemperatureEmitter? FromGameObject(GameObject gameObject) + { + S1Temperature.TemperatureEmitter? emitter = + gameObject.GetComponent(); + return emitter == null ? null : new TemperatureEmitter(emitter); + } + + /// + /// Gets the first native temperature emitter on a game object, or adds one when none exists. + /// + /// The game object that owns the emitter component. + /// A wrapper around the existing or newly added emitter. + /// + /// When a game object has multiple native emitter components, this method returns the first component selected by Unity. + /// + public static TemperatureEmitter GetOrAddComponent(GameObject gameObject) + { + S1Temperature.TemperatureEmitter? emitter = + gameObject.GetComponent(); + return new TemperatureEmitter( + emitter ?? gameObject.AddComponent()); + } + + /// + /// Gets the emitter temperature in the game's native temperature scale. + /// + public float Temperature => + S1TemperatureEmitter.Temperature; + + /// + /// Gets the emitter range in world units. + /// + public float Range => + S1TemperatureEmitter.Range; + + /// + /// Gets the emitter position in world space. + /// + public Vector3 EmissionPoint => + S1TemperatureEmitter.EmissionPoint; + + /// + /// Occurs when the native emitter reports a change. + /// + public event Action OnChanged + { + add + { + if (value == null) + return; + + NativeEmitterChangedAction nativeHandler = CreateNativeChangedHandler(value); + Subscribe(nativeHandler); + _changedRegistrations.Add(value, nativeHandler); + } + remove + { + if (value == null || !_changedRegistrations.TryTakeLast(value, out NativeEmitterChangedAction nativeHandler)) + return; + + try + { + Unsubscribe(nativeHandler); + } + catch + { + _changedRegistrations.Add(value, nativeHandler); + throw; + } + } + } + + /// + /// Updates the emitter position in world space. + /// + /// The new emitter position in world space. + public void SetPosition(Vector3 position) => + S1TemperatureEmitter.SetPosition(position); + + /// + /// Updates the emitter temperature. + /// + /// The new temperature in the game's native temperature scale. + public void SetTemperature(float temperature) => + S1TemperatureEmitter.SetTemperature(temperature); + + /// + /// Updates the emitter range in world units. + /// + /// The new range in world units. + public void SetRange(float range) => + S1TemperatureEmitter.SetRange(range); + + /// + /// Informs native listeners that the emitter changed. + /// + public void NotifyChanged() => + S1TemperatureEmitter.NotifyChanged(); + + private static NativeEmitterChangedAction CreateNativeChangedHandler(Action handler) + { +#if IL2CPPMELON + return DelegateSupport.ConvertDelegate(handler) + ?? throw new InvalidOperationException("Could not create the native temperature-emitter delegate."); +#else + return handler; +#endif + } + + private void Subscribe(NativeEmitterChangedAction handler) + { +#if IL2CPPMELON + S1TemperatureEmitter.OnEmitterChanged = S1TemperatureEmitter.OnEmitterChanged == null + ? handler + : Il2CppSystem.Delegate.Combine(S1TemperatureEmitter.OnEmitterChanged, handler) + .Cast(); +#else + S1TemperatureEmitter.OnEmitterChanged += handler; +#endif + } + + private void Unsubscribe(NativeEmitterChangedAction handler) + { +#if IL2CPPMELON + Il2CppSystem.Delegate? remaining = Il2CppSystem.Delegate.Remove( + S1TemperatureEmitter.OnEmitterChanged, + handler); + S1TemperatureEmitter.OnEmitterChanged = remaining?.Cast(); +#else + S1TemperatureEmitter.OnEmitterChanged -= handler; +#endif + } + } +} diff --git a/S1API/Temperature/TemperatureEmitterInfo.cs b/S1API/Temperature/TemperatureEmitterInfo.cs new file mode 100644 index 00000000..4f40c17a --- /dev/null +++ b/S1API/Temperature/TemperatureEmitterInfo.cs @@ -0,0 +1,38 @@ +using UnityEngine; + +namespace S1API.Temperature +{ + /// + /// Describes one temperature emitter for a point-temperature query. + /// + public readonly struct TemperatureEmitterInfo + { + /// + /// Creates a temperature-emitter snapshot. + /// + /// The emitter temperature in the game's native temperature scale. + /// The emitter range squared, in world units squared. + /// The emitter position in world space. + public TemperatureEmitterInfo(float temperature, float sqrRange, Vector3 position) + { + Temperature = temperature; + SqrRange = sqrRange; + Position = position; + } + + /// + /// Gets the emitter temperature in the game's native temperature scale. + /// + public float Temperature { get; } + + /// + /// Gets the emitter range squared, in world units squared. + /// + public float SqrRange { get; } + + /// + /// Gets the emitter position in world space. + /// + public Vector3 Position { get; } + } +} diff --git a/S1API/Temperature/TemperatureUtility.cs b/S1API/Temperature/TemperatureUtility.cs new file mode 100644 index 00000000..351573b8 --- /dev/null +++ b/S1API/Temperature/TemperatureUtility.cs @@ -0,0 +1,59 @@ +#if IL2CPPMELON +using S1Temperature = Il2CppScheduleOne.Temperature; +#elif MONOMELON +using S1Temperature = ScheduleOne.Temperature; +#endif + +namespace S1API.Temperature +{ + /// + /// Formats and converts temperatures with the game's native temperature helpers. + /// + public static class TemperatureUtility + { + /// + /// Converts a Celsius temperature to Fahrenheit. + /// + /// The temperature in degrees Celsius. + /// The temperature in degrees Fahrenheit. + public static float ToFahrenheit(float celsius) => + S1Temperature.TemperatureUtility.ToFahrenheit(celsius); + + /// + /// Formats a Celsius temperature with the game's Celsius unit display. + /// + /// The temperature in degrees Celsius. + /// The number of decimal places to display. + /// The formatted Celsius temperature. + public static string FormatCelsiusTemperature(float celsius, int decimalPoints) => + S1Temperature.TemperatureUtility.FormatCelsiusTemperature(celsius, decimalPoints); + + /// + /// Formats a Fahrenheit temperature with the game's Fahrenheit unit display. + /// + /// The temperature in degrees Fahrenheit. + /// The number of decimal places to display. + /// The formatted Fahrenheit temperature. + public static string FormatFahrenheitTemperature(float fahrenheit, int decimalPoints) => + S1Temperature.TemperatureUtility.FormatFahrenheitTemperature(fahrenheit, decimalPoints); + + /// + /// Formats a Celsius temperature with the unit selected by the game. + /// + /// The temperature in degrees Celsius. + /// The number of decimal places to display. + /// The formatted temperature. + public static string FormatTemperatureWithAppropriateUnit( + float celsius, + int decimalPoints = 1) => + S1Temperature.TemperatureUtility.FormatTemperatureWithAppropriateUnit(celsius, decimalPoints); + + /// + /// Normalizes a Celsius temperature with the game's native temperature range. + /// + /// The temperature in degrees Celsius. + /// The normalized temperature. + public static float NormalizeTemperature(float celsius) => + S1Temperature.TemperatureUtility.NormalizeTemperature(celsius); + } +} From 2ccc3c599510c93b749951c51bc3e8328f300c98 Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 15 Aug 2026 21:54:52 -0700 Subject: [PATCH 2/2] fix(temperature): harden public API contracts --- .../ManagedEventRegistrationTrackerTests.cs | 4 + .../TemperatureApiCompileFixture.cs | 6 +- .../Temperature/TemperatureContractTests.cs | 128 ++++++++++++++ .../Temperature/TemperatureValidation.cs | 45 +++++ .../Utils/ManagedEventRegistrationTracker.cs | 3 + S1API/Temperature/TemperatureAlgorithm.cs | 32 +++- S1API/Temperature/TemperatureEmitter.cs | 165 ++++++++++++++++-- S1API/Temperature/TemperatureEmitterInfo.cs | 24 ++- S1API/Temperature/TemperatureUtility.cs | 6 + 9 files changed, 380 insertions(+), 33 deletions(-) create mode 100644 S1API.Tests/Temperature/TemperatureContractTests.cs create mode 100644 S1API/Internal/Temperature/TemperatureValidation.cs diff --git a/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs b/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs index 3040f28f..cee6d1cf 100644 --- a/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs +++ b/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs @@ -12,12 +12,14 @@ public void DuplicateAddsAreRemovedOneAtATimeInReverseRegistrationOrder() tracker.Add(handler, "first"); tracker.Add(handler, "second"); + Assert.False(tracker.IsEmpty); Assert.True(tracker.TryTakeLast(handler, out string? second)); Assert.Equal("second", second); Assert.True(tracker.TryTakeLast(handler, out string? first)); Assert.Equal("first", first); Assert.False(tracker.TryTakeLast(handler, out _)); + Assert.True(tracker.IsEmpty); } [Fact] @@ -30,6 +32,7 @@ public void TakeAllReturnsEveryRegistrationAndClearsTheTracker() tracker.Add(firstHandler, "first"); tracker.Add(firstHandler, "second"); tracker.Add(secondHandler, "third"); + Assert.False(tracker.IsEmpty); var registrations = tracker.TakeAll(); @@ -42,5 +45,6 @@ public void TakeAllReturnsEveryRegistrationAndClearsTheTracker() registration.ManagedHandler.Equals(secondHandler) && registration.NativeHandler == "third"); Assert.False(tracker.TryTakeLast(firstHandler, out _)); Assert.False(tracker.TryTakeLast(secondHandler, out _)); + Assert.True(tracker.IsEmpty); } } diff --git a/S1API.Tests/Temperature/TemperatureApiCompileFixture.cs b/S1API.Tests/Temperature/TemperatureApiCompileFixture.cs index fd88df9e..548db2b2 100644 --- a/S1API.Tests/Temperature/TemperatureApiCompileFixture.cs +++ b/S1API.Tests/Temperature/TemperatureApiCompileFixture.cs @@ -18,10 +18,7 @@ internal static void ConfigureAndQuery(GameObject gameObject) emitter.OnChanged -= changed; TemperatureEmitter? existing = TemperatureEmitter.FromGameObject(gameObject); - TemperatureEmitterInfo[] emitters = - { - new TemperatureEmitterInfo(emitter.Temperature, emitter.Range * emitter.Range, emitter.EmissionPoint) - }; + TemperatureEmitterInfo[] emitters = { emitter.ToInfo() }; float temperature = TemperatureAlgorithm.GetTemperatureAtPoint( ambientTemperature: 20f, originPoint: Vector3.zero, @@ -30,6 +27,7 @@ internal static void ConfigureAndQuery(GameObject gameObject) _ = existing; _ = temperature; + _ = TemperatureUtility.TemperatureSystemEnabled; _ = TemperatureUtility.ToFahrenheit(20f); _ = TemperatureUtility.FormatCelsiusTemperature(20f, decimalPoints: 1); _ = TemperatureUtility.FormatFahrenheitTemperature(68f, decimalPoints: 1); diff --git a/S1API.Tests/Temperature/TemperatureContractTests.cs b/S1API.Tests/Temperature/TemperatureContractTests.cs new file mode 100644 index 00000000..01685252 --- /dev/null +++ b/S1API.Tests/Temperature/TemperatureContractTests.cs @@ -0,0 +1,128 @@ +using System.Reflection; +using S1API.Internal.Temperature; +using S1API.Temperature; +using UnityEngine; + +namespace S1API.Tests.Temperature; + +public sealed class TemperatureContractTests +{ +#if MONOMELON + [Fact] + public void SnapshotUsesLinearRangeAndClampsToNativeEmitterBounds() + { + var minimum = new TemperatureEmitterInfo(-10f, -5f, Vector3.zero); + var maximum = new TemperatureEmitterInfo(50f, 200f, Vector3.one); + + Assert.Equal(TemperatureEmitter.MinTemperature, minimum.Temperature); + Assert.Equal(TemperatureEmitter.MinRange, minimum.Range); + Assert.Equal(TemperatureEmitter.MaxTemperature, maximum.Temperature); + Assert.Equal(TemperatureEmitter.MaxRange, maximum.Range); + Assert.Null(typeof(TemperatureEmitterInfo).GetProperty("SqrRange", BindingFlags.Instance | BindingFlags.Public)); + } + + [Fact] + public void SnapshotRejectsNonFiniteValues() + { + foreach (float value in new[] { float.NaN, float.NegativeInfinity, float.PositiveInfinity }) + { + Assert.Throws(() => + new TemperatureEmitterInfo(value, TemperatureEmitter.DefaultRange, Vector3.zero)); + Assert.Throws(() => + new TemperatureEmitterInfo(TemperatureEmitter.DefaultAmbientTemperature, value, Vector3.zero)); + } + + Assert.Throws(() => + new TemperatureEmitterInfo( + TemperatureEmitter.DefaultAmbientTemperature, + TemperatureEmitter.DefaultRange, + new Vector3(float.NaN, 0f, 0f))); + } + + [Fact] + public void QueryRejectsNullSnapshotsBeforeCallingTheNativeRuntime() + { + Assert.Throws(() => TemperatureAlgorithm.GetTemperatureAtPoint( + TemperatureEmitter.DefaultAmbientTemperature, + Vector3.zero, + Vector3.zero, + null!)); + } +#endif + + [Fact] + public void ManagedScalarValidationMatchesNativeEmitterBounds() + { + Assert.Equal( + TemperatureValidation.MinTemperature, + TemperatureValidation.ClampTemperature(-10f, "temperature")); + Assert.Equal( + TemperatureValidation.MaxTemperature, + TemperatureValidation.ClampTemperature(50f, "temperature")); + Assert.Equal( + TemperatureValidation.MinRange, + TemperatureValidation.ClampRange(-5f, "range")); + Assert.Equal( + TemperatureValidation.MaxRange, + TemperatureValidation.ClampRange(200f, "range")); + + foreach (float value in new[] { float.NaN, float.NegativeInfinity, float.PositiveInfinity }) + { + Assert.Throws(() => + TemperatureValidation.ClampTemperature(value, "temperature")); + Assert.Throws(() => + TemperatureValidation.ClampRange(value, "range")); + } + } + + [Fact] + public void PublicSurfaceExposesManagedTemperatureContractsOnly() + { + Assert.NotNull(typeof(TemperatureUtility).GetProperty( + nameof(TemperatureUtility.TemperatureSystemEnabled), + BindingFlags.Public | BindingFlags.Static)); + Assert.NotNull(typeof(TemperatureEmitter).GetMethod( + nameof(TemperatureEmitter.ToInfo), + BindingFlags.Public | BindingFlags.Instance)); + + Type[] publicTypes = + { + typeof(TemperatureEmitter), + typeof(TemperatureEmitterInfo), + typeof(TemperatureAlgorithm), + typeof(TemperatureUtility) + }; + foreach (Type type in publicTypes) + { + foreach (MemberInfo member in type.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) + { + foreach (Type exposedType in GetExposedTypes(member)) + { + Assert.DoesNotContain("ScheduleOne", exposedType.Namespace ?? string.Empty, StringComparison.Ordinal); + Assert.DoesNotContain("Il2Cpp", exposedType.FullName ?? string.Empty, StringComparison.Ordinal); + } + } + } + } + + private static IEnumerable GetExposedTypes(MemberInfo member) + { + switch (member) + { + case MethodInfo method: + yield return method.ReturnType; + foreach (ParameterInfo parameter in method.GetParameters()) + yield return parameter.ParameterType; + break; + case PropertyInfo property: + yield return property.PropertyType; + break; + case FieldInfo field: + yield return field.FieldType; + break; + case EventInfo eventInfo: + yield return eventInfo.EventHandlerType!; + break; + } + } +} diff --git a/S1API/Internal/Temperature/TemperatureValidation.cs b/S1API/Internal/Temperature/TemperatureValidation.cs new file mode 100644 index 00000000..c9790e5c --- /dev/null +++ b/S1API/Internal/Temperature/TemperatureValidation.cs @@ -0,0 +1,45 @@ +using System; +using UnityEngine; + +namespace S1API.Internal.Temperature +{ + internal static class TemperatureValidation + { + internal const float DefaultAmbientTemperature = 20f; + internal const float MinTemperature = 0f; + internal const float MaxTemperature = 40f; + internal const float DefaultRange = 5f; + internal const float MinRange = 0.1f; + internal const float MaxRange = 100f; + + internal static float ClampTemperature(float temperature, string parameterName) + { + EnsureFinite(temperature, parameterName); + return Clamp(temperature, MinTemperature, MaxTemperature); + } + + internal static float ClampRange(float range, string parameterName) + { + EnsureFinite(range, parameterName); + return Clamp(range, MinRange, MaxRange); + } + + internal static void EnsureFinite(Vector3 position, string parameterName) + { + if (!IsFinite(position.x) || !IsFinite(position.y) || !IsFinite(position.z)) + throw new ArgumentOutOfRangeException(parameterName, "Vector components must be finite."); + } + + internal static void EnsureFinite(float value, string parameterName) + { + if (!IsFinite(value)) + throw new ArgumentOutOfRangeException(parameterName, "Value must be finite."); + } + + private static float Clamp(float value, float minimum, float maximum) => + value < minimum ? minimum : value > maximum ? maximum : value; + + private static bool IsFinite(float value) => + !float.IsNaN(value) && !float.IsInfinity(value); + } +} diff --git a/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs b/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs index 09c8926e..97aed17f 100644 --- a/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs +++ b/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs @@ -11,6 +11,9 @@ internal sealed class ManagedEventRegistrationTracker { private readonly Dictionary> _registrations = new Dictionary>(); + internal bool IsEmpty => + _registrations.Count == 0; + internal void Add(Delegate managedHandler, TNativeHandler nativeHandler) { if (!_registrations.TryGetValue(managedHandler, out var nativeHandlers)) diff --git a/S1API/Temperature/TemperatureAlgorithm.cs b/S1API/Temperature/TemperatureAlgorithm.cs index 30187c7f..43548e9a 100644 --- a/S1API/Temperature/TemperatureAlgorithm.cs +++ b/S1API/Temperature/TemperatureAlgorithm.cs @@ -5,6 +5,8 @@ using S1Temperature = ScheduleOne.Temperature; #endif +using System; +using S1API.Internal.Temperature; using UnityEngine; namespace S1API.Temperature @@ -17,11 +19,16 @@ public static class TemperatureAlgorithm /// /// Calculates the temperature at a world position from an ambient temperature and emitter snapshots. /// - /// The ambient temperature in the game's native temperature scale. - /// The origin used by the game's temperature calculation. + /// The ambient temperature in degrees Celsius. + /// + /// The world origin forwarded to the native API for signature compatibility. The current native implementation + /// evaluates world-space emitter and query positions directly and does not otherwise use this value. + /// /// The world position to query. /// The emitter snapshots to include in the calculation. - /// The temperature at in the game's native temperature scale. + /// The temperature at in degrees Celsius. + /// is null. + /// A scalar or vector input is not finite. /// /// This method does not discover scene emitters or register the supplied snapshots with a grid. /// @@ -31,6 +38,12 @@ public static float GetTemperatureAtPoint( Vector3 point, TemperatureEmitterInfo[] emitters) { + if (emitters == null) + throw new ArgumentNullException(nameof(emitters)); + + TemperatureValidation.EnsureFinite(ambientTemperature, nameof(ambientTemperature)); + TemperatureValidation.EnsureFinite(originPoint, nameof(originPoint)); + TemperatureValidation.EnsureFinite(point, nameof(point)); #if IL2CPPMELON var nativeEmitters = new Il2CppStructArray(emitters.Length); #else @@ -39,9 +52,18 @@ public static float GetTemperatureAtPoint( for (int i = 0; i < emitters.Length; i++) { TemperatureEmitterInfo emitter = emitters[i]; - nativeEmitters[i] = new S1Temperature.TemperatureEmitterInfo( + float temperature = TemperatureValidation.ClampTemperature( emitter.Temperature, - emitter.SqrRange, + $"{nameof(emitters)}[{i}].{nameof(TemperatureEmitterInfo.Temperature)}"); + float range = TemperatureValidation.ClampRange( + emitter.Range, + $"{nameof(emitters)}[{i}].{nameof(TemperatureEmitterInfo.Range)}"); + TemperatureValidation.EnsureFinite( + emitter.Position, + $"{nameof(emitters)}[{i}].{nameof(TemperatureEmitterInfo.Position)}"); + nativeEmitters[i] = new S1Temperature.TemperatureEmitterInfo( + temperature, + range * range, emitter.Position); } diff --git a/S1API/Temperature/TemperatureEmitter.cs b/S1API/Temperature/TemperatureEmitter.cs index 2c100304..b0cf4fe9 100644 --- a/S1API/Temperature/TemperatureEmitter.cs +++ b/S1API/Temperature/TemperatureEmitter.cs @@ -8,6 +8,8 @@ #endif using System; +using System.Collections.Generic; +using S1API.Internal.Temperature; using S1API.Internal.Utils; using UnityEngine; @@ -26,9 +28,6 @@ public sealed class TemperatureEmitter /// internal readonly S1Temperature.TemperatureEmitter S1TemperatureEmitter; - private readonly ManagedEventRegistrationTracker _changedRegistrations = - new ManagedEventRegistrationTracker(); - /// /// INTERNAL: Creates a wrapper around a native temperature emitter component. /// @@ -38,13 +37,48 @@ internal TemperatureEmitter(S1Temperature.TemperatureEmitter temperatureEmitter) S1TemperatureEmitter = temperatureEmitter; } + /// + /// Gets the default ambient temperature, in degrees Celsius. + /// + public const float DefaultAmbientTemperature = TemperatureValidation.DefaultAmbientTemperature; + + /// + /// Gets the minimum emitter temperature, in degrees Celsius. + /// + public const float MinTemperature = TemperatureValidation.MinTemperature; + + /// + /// Gets the maximum emitter temperature, in degrees Celsius. + /// + public const float MaxTemperature = TemperatureValidation.MaxTemperature; + + /// + /// Gets the default emitter range, in world units. + /// + public const float DefaultRange = TemperatureValidation.DefaultRange; + + /// + /// Gets the minimum emitter range, in world units. + /// + public const float MinRange = TemperatureValidation.MinRange; + + /// + /// Gets the maximum emitter range, in world units. + /// + public const float MaxRange = TemperatureValidation.MaxRange; + /// /// Gets the native temperature emitter attached to a game object. /// /// The game object to inspect. /// A temperature-emitter wrapper, or null when the game object has no emitter component. + /// is null or destroyed. public static TemperatureEmitter? FromGameObject(GameObject gameObject) { + if (gameObject == null) + throw new ArgumentNullException(nameof(gameObject)); + + PruneDestroyedRegistrationStates(); S1Temperature.TemperatureEmitter? emitter = gameObject.GetComponent(); return emitter == null ? null : new TemperatureEmitter(emitter); @@ -55,11 +89,16 @@ internal TemperatureEmitter(S1Temperature.TemperatureEmitter temperatureEmitter) /// /// The game object that owns the emitter component. /// A wrapper around the existing or newly added emitter. + /// is null or destroyed. /// /// When a game object has multiple native emitter components, this method returns the first component selected by Unity. /// public static TemperatureEmitter GetOrAddComponent(GameObject gameObject) { + if (gameObject == null) + throw new ArgumentNullException(nameof(gameObject)); + + PruneDestroyedRegistrationStates(); S1Temperature.TemperatureEmitter? emitter = gameObject.GetComponent(); return new TemperatureEmitter( @@ -67,7 +106,7 @@ public static TemperatureEmitter GetOrAddComponent(GameObject gameObject) } /// - /// Gets the emitter temperature in the game's native temperature scale. + /// Gets the emitter temperature in degrees Celsius. /// public float Temperature => S1TemperatureEmitter.Temperature; @@ -84,6 +123,13 @@ public static TemperatureEmitter GetOrAddComponent(GameObject gameObject) public Vector3 EmissionPoint => S1TemperatureEmitter.EmissionPoint; + /// + /// Creates an immutable managed snapshot of the emitter's current values. + /// + /// A snapshot suitable for . + public TemperatureEmitterInfo ToInfo() => + new TemperatureEmitterInfo(Temperature, Range, EmissionPoint); + /// /// Occurs when the native emitter reports a change. /// @@ -96,11 +142,12 @@ public event Action OnChanged NativeEmitterChangedAction nativeHandler = CreateNativeChangedHandler(value); Subscribe(nativeHandler); - _changedRegistrations.Add(value, nativeHandler); + GetChangedRegistrationState().Registrations.Add(value, nativeHandler); } remove { - if (value == null || !_changedRegistrations.TryTakeLast(value, out NativeEmitterChangedAction nativeHandler)) + if (value == null || !TryTakeChangedRegistration(value, out ChangedRegistrationState state, + out NativeEmitterChangedAction nativeHandler)) return; try @@ -109,32 +156,53 @@ public event Action OnChanged } catch { - _changedRegistrations.Add(value, nativeHandler); + state.Registrations.Add(value, nativeHandler); throw; } + + if (state.Registrations.IsEmpty) + ChangedRegistrations.Remove(S1TemperatureEmitter.GetInstanceID()); } } /// /// Updates the emitter position in world space. /// - /// The new emitter position in world space. - public void SetPosition(Vector3 position) => + /// The new emitter position in world space. Every component must be finite. + /// A position component is not finite. + public void SetPosition(Vector3 position) + { + TemperatureValidation.EnsureFinite(position, nameof(position)); S1TemperatureEmitter.SetPosition(position); + } /// /// Updates the emitter temperature. /// - /// The new temperature in the game's native temperature scale. - public void SetTemperature(float temperature) => - S1TemperatureEmitter.SetTemperature(temperature); + /// + /// The new temperature in degrees Celsius. Finite values are clamped to + /// through . + /// + /// is not finite. + public void SetTemperature(float temperature) + { + float clampedTemperature = TemperatureValidation.ClampTemperature(temperature, nameof(temperature)); + S1TemperatureEmitter.SetTemperature(clampedTemperature); + } /// /// Updates the emitter range in world units. /// - /// The new range in world units. - public void SetRange(float range) => - S1TemperatureEmitter.SetRange(range); + /// + /// The new range in world units. Finite values are clamped to through + /// . + /// + /// is not finite. + public void SetRange(float range) + { + float clampedRange = TemperatureValidation.ClampRange(range, nameof(range)); + S1TemperatureEmitter.SetRange(clampedRange); + } /// /// Informs native listeners that the emitter changed. @@ -142,6 +210,9 @@ public void SetRange(float range) => public void NotifyChanged() => S1TemperatureEmitter.NotifyChanged(); + private static readonly Dictionary ChangedRegistrations = + new Dictionary(); + private static NativeEmitterChangedAction CreateNativeChangedHandler(Action handler) { #if IL2CPPMELON @@ -152,6 +223,57 @@ private static NativeEmitterChangedAction CreateNativeChangedHandler(Action hand #endif } + private ChangedRegistrationState GetChangedRegistrationState() + { + PruneDestroyedRegistrationStates(); + int instanceId = S1TemperatureEmitter.GetInstanceID(); + if (ChangedRegistrations.TryGetValue(instanceId, out ChangedRegistrationState? state)) + return state; + + state = new ChangedRegistrationState(S1TemperatureEmitter); + ChangedRegistrations.Add(instanceId, state); + return state; + } + + private bool TryTakeChangedRegistration( + Action managedHandler, + out ChangedRegistrationState state, + out NativeEmitterChangedAction nativeHandler) + { + PruneDestroyedRegistrationStates(); + if (ChangedRegistrations.TryGetValue( + S1TemperatureEmitter.GetInstanceID(), + out ChangedRegistrationState? registrationState) + && registrationState.Registrations.TryTakeLast(managedHandler, out nativeHandler)) + { + state = registrationState; + return true; + } + + state = null!; + nativeHandler = default!; + return false; + } + + private static void PruneDestroyedRegistrationStates() + { + List? destroyedIds = null; + foreach (KeyValuePair registration in ChangedRegistrations) + { + if (registration.Value.S1TemperatureEmitter != null) + continue; + + destroyedIds ??= new List(); + destroyedIds.Add(registration.Key); + } + + if (destroyedIds == null) + return; + + foreach (int destroyedId in destroyedIds) + ChangedRegistrations.Remove(destroyedId); + } + private void Subscribe(NativeEmitterChangedAction handler) { #if IL2CPPMELON @@ -175,5 +297,18 @@ private void Unsubscribe(NativeEmitterChangedAction handler) S1TemperatureEmitter.OnEmitterChanged -= handler; #endif } + + private sealed class ChangedRegistrationState + { + internal S1Temperature.TemperatureEmitter S1TemperatureEmitter { get; } + + internal ManagedEventRegistrationTracker Registrations { get; } = + new ManagedEventRegistrationTracker(); + + internal ChangedRegistrationState(S1Temperature.TemperatureEmitter temperatureEmitter) + { + S1TemperatureEmitter = temperatureEmitter; + } + } } } diff --git a/S1API/Temperature/TemperatureEmitterInfo.cs b/S1API/Temperature/TemperatureEmitterInfo.cs index 4f40c17a..b81f141a 100644 --- a/S1API/Temperature/TemperatureEmitterInfo.cs +++ b/S1API/Temperature/TemperatureEmitterInfo.cs @@ -1,3 +1,5 @@ +using System; +using S1API.Internal.Temperature; using UnityEngine; namespace S1API.Temperature @@ -10,25 +12,29 @@ public readonly struct TemperatureEmitterInfo /// /// Creates a temperature-emitter snapshot. /// - /// The emitter temperature in the game's native temperature scale. - /// The emitter range squared, in world units squared. - /// The emitter position in world space. - public TemperatureEmitterInfo(float temperature, float sqrRange, Vector3 position) + /// + /// The emitter temperature in degrees Celsius. Finite values are clamped to the game's supported range. + /// + /// The emitter range in world units. Finite values are clamped to the game's supported range. + /// The emitter position in world space. Every component must be finite. + /// An argument is not finite. + public TemperatureEmitterInfo(float temperature, float range, Vector3 position) { - Temperature = temperature; - SqrRange = sqrRange; + Temperature = TemperatureValidation.ClampTemperature(temperature, nameof(temperature)); + Range = TemperatureValidation.ClampRange(range, nameof(range)); + TemperatureValidation.EnsureFinite(position, nameof(position)); Position = position; } /// - /// Gets the emitter temperature in the game's native temperature scale. + /// Gets the emitter temperature in degrees Celsius. /// public float Temperature { get; } /// - /// Gets the emitter range squared, in world units squared. + /// Gets the emitter range in world units. /// - public float SqrRange { get; } + public float Range { get; } /// /// Gets the emitter position in world space. diff --git a/S1API/Temperature/TemperatureUtility.cs b/S1API/Temperature/TemperatureUtility.cs index 351573b8..f40ba45d 100644 --- a/S1API/Temperature/TemperatureUtility.cs +++ b/S1API/Temperature/TemperatureUtility.cs @@ -11,6 +11,12 @@ namespace S1API.Temperature /// public static class TemperatureUtility { + /// + /// Gets whether the game's temperature system is currently enabled. + /// + public static bool TemperatureSystemEnabled => + S1Temperature.TemperatureUtility.TemperatureSystemEnabled; + /// /// Converts a Celsius temperature to Fahrenheit. ///