From 985450a093974ea2fd39de0f0ebc37f1e01dea3e Mon Sep 17 00:00:00 2001 From: ifBars Date: Sun, 18 Oct 2026 20:06:52 -0700 Subject: [PATCH 1/2] feat(vehicles): expose managed seat metadata and occupancy --- S1API.Tests/Vehicles/VehicleSeatApiTests.cs | 103 ++++++++++++++ S1API/Internal/Patches/LandVehiclePatches.cs | 1 + S1API/Internal/Patches/VehicleSeatPatches.cs | 33 +++++ S1API/Internal/Vehicles/VehicleSeats.cs | 82 +++++++++++ S1API/S1API.cs | 3 + S1API/Vehicles/LandVehicle.cs | 11 ++ S1API/Vehicles/VehicleSeatInfo.cs | 141 +++++++++++++++++++ 7 files changed, 374 insertions(+) create mode 100644 S1API.Tests/Vehicles/VehicleSeatApiTests.cs create mode 100644 S1API/Internal/Patches/VehicleSeatPatches.cs create mode 100644 S1API/Internal/Vehicles/VehicleSeats.cs create mode 100644 S1API/Vehicles/VehicleSeatInfo.cs diff --git a/S1API.Tests/Vehicles/VehicleSeatApiTests.cs b/S1API.Tests/Vehicles/VehicleSeatApiTests.cs new file mode 100644 index 00000000..cc308ea0 --- /dev/null +++ b/S1API.Tests/Vehicles/VehicleSeatApiTests.cs @@ -0,0 +1,103 @@ +using System.Reflection; +using S1API.Entities; +using S1API.Internal.Patches; +using S1API.Internal.Vehicles; +using S1API.Vehicles; + +namespace S1API.Tests.Vehicles; + +public sealed class VehicleSeatApiTests +{ + [Fact] + public void AssignmentHookSelectsTheGeneratedMutationInBothRuntimes() + { + var target = Assert.IsAssignableFrom(VehicleSeatPatches.TargetMethod()); + Assert.StartsWith("RpcLogic___SetSeatOccupant_", target.Name); + Assert.Equal(typeof(int), target.GetParameters()[1].ParameterType); + Assert.Equal(3, target.GetParameters().Length); + } + + [Fact] + public void SeatSurfaceIsReadOnlyAndUsesManagedTypes() + { + Assert.True(typeof(VehicleSeatInfo).IsSealed); + Assert.Empty(typeof(VehicleSeatInfo).GetConstructors()); + var expected = new Dictionary + { + [nameof(VehicleSeatInfo.Index)] = typeof(int), + [nameof(VehicleSeatInfo.IsDriverSeat)] = typeof(bool), + [nameof(VehicleSeatInfo.IsOccupied)] = typeof(bool), + [nameof(VehicleSeatInfo.Occupant)] = typeof(Player) + }; + foreach (var property in typeof(VehicleSeatInfo).GetProperties()) + { + Assert.Equal(expected[property.Name], property.PropertyType); + Assert.Null(property.SetMethod); + } + Assert.Equal(typeof(Action), + typeof(VehicleSeatInfo).GetEvent(nameof(VehicleSeatInfo.OccupantChanged))!.EventHandlerType); + Assert.Equal(typeof(IReadOnlyList), + typeof(LandVehicle).GetProperty(nameof(LandVehicle.Seats))!.PropertyType); + // The legacy writable property is a public compatibility contract. + Assert.True(typeof(LandVehicle).GetProperty(nameof(LandVehicle.IsOccupied))!.CanWrite); + } + + [Fact] + public void MissingSeatRetainsIndexAndDoesNotEmitNoOpNotifications() + { + var seat = new VehicleSeatInfo(3, null); + int calls = 0; + seat.OccupantChanged += (_, _) => calls++; + seat.Refresh(); + seat.Refresh(); + Assert.Equal(3, seat.Index); + Assert.False(seat.IsDriverSeat); + Assert.False(seat.IsOccupied); + Assert.Null(seat.Occupant); + Assert.Equal(0, calls); + } + + [Fact] + public void UnavailableWrappersDoNotSuppressNativeTransitionNotifications() + { + var seat = new VehicleSeatInfo(0, null); + int calls = 0; + seat.OccupantChanged += (_, _) => throw new InvalidOperationException("expected subscriber failure"); + seat.OccupantChanged += (previous, current) => + { + Assert.Null(previous); + Assert.Null(current); + calls++; + }; + seat.NotifyOccupantChanged(null, null); + Assert.Equal(1, calls); + } + + [Fact] + public void InvalidationReleasesSubscribersAndIsIdempotent() + { + var seat = new VehicleSeatInfo(2, null); + int calls = 0; + Action handler = (_, _) => calls++; + seat.OccupantChanged += handler; + seat.OccupantChanged -= handler; + seat.NotifyOccupantChanged(null, null); + Assert.Equal(0, calls); + seat.OccupantChanged += handler; + seat.Invalidate(); + seat.Invalidate(); + seat.NotifyOccupantChanged(null, null); + Assert.Equal(0, calls); + Assert.Equal(2, seat.Index); + Assert.False(seat.IsOccupied); + Assert.Null(seat.Occupant); + } + + [Fact] + public void UnavailableVehicleReturnsAnImmutableEmptyCollection() + { + var seats = VehicleSeats.Get(null); + Assert.Empty(seats); + Assert.True(Assert.IsAssignableFrom>(seats).IsReadOnly); + } +} diff --git a/S1API/Internal/Patches/LandVehiclePatches.cs b/S1API/Internal/Patches/LandVehiclePatches.cs index aa188f84..39da4d8b 100644 --- a/S1API/Internal/Patches/LandVehiclePatches.cs +++ b/S1API/Internal/Patches/LandVehiclePatches.cs @@ -16,6 +16,7 @@ internal class LandVehiclePatches [HarmonyPatch("OnDestroy")] [HarmonyPostfix] public static void OnDestroy(S1Vehicles.LandVehicle __instance) { + Internal.Vehicles.VehicleSeats.Remove(__instance); try { if (__instance != null) diff --git a/S1API/Internal/Patches/VehicleSeatPatches.cs b/S1API/Internal/Patches/VehicleSeatPatches.cs new file mode 100644 index 00000000..6371ceb7 --- /dev/null +++ b/S1API/Internal/Patches/VehicleSeatPatches.cs @@ -0,0 +1,33 @@ +using System; +using System.Linq; +using System.Reflection; +using HarmonyLib; +using S1API.Internal.Vehicles; + +#if IL2CPPMELON +using S1Vehicles = Il2CppScheduleOne.Vehicles; +using S1Connection = Il2CppFishNet.Connection.NetworkConnection; +#elif MONOMELON +using S1Vehicles = ScheduleOne.Vehicles; +using S1Connection = FishNet.Connection.NetworkConnection; +#endif + +namespace S1API.Internal.Patches +{ + [HarmonyPatch] + internal static class VehicleSeatPatches + { + internal static MethodBase TargetMethod() => + typeof(S1Vehicles.LandVehicle) + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Single(method => + method.Name.StartsWith("RpcLogic___SetSeatOccupant_", StringComparison.Ordinal) + && method.ReturnType == typeof(void) + && method.GetParameters().Select(parameter => parameter.ParameterType) + .SequenceEqual(new[] { typeof(S1Connection), typeof(int), typeof(S1Connection) })); + + [HarmonyPostfix] + private static void Postfix(S1Vehicles.LandVehicle __instance, int __1) => + VehicleSeats.Refresh(__instance, __1); + } +} diff --git a/S1API/Internal/Vehicles/VehicleSeats.cs b/S1API/Internal/Vehicles/VehicleSeats.cs new file mode 100644 index 00000000..2d3bef7a --- /dev/null +++ b/S1API/Internal/Vehicles/VehicleSeats.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using S1API.Lifecycle; +using S1API.Vehicles; + +#if IL2CPPMELON +using S1Vehicles = Il2CppScheduleOne.Vehicles; +#elif MONOMELON +using S1Vehicles = ScheduleOne.Vehicles; +#endif + +namespace S1API.Internal.Vehicles +{ + internal static class VehicleSeats + { + private static readonly Dictionary> Cache = + new Dictionary>(); + private static readonly IReadOnlyList Empty = Array.AsReadOnly(Array.Empty()); + private static bool _lifecycleHooked; + + internal static IReadOnlyList Get(S1Vehicles.LandVehicle? vehicle) + { + if (ReferenceEquals(vehicle, null) || vehicle == null) + return Empty; + if (Cache.TryGetValue(vehicle, out var seats)) + return seats; + + var native = vehicle.Seats; + if (native == null) + return Empty; + var result = new VehicleSeatInfo[native.Length]; + for (int i = 0; i < native.Length; i++) + result[i] = new VehicleSeatInfo(i, native[i]); + seats = Array.AsReadOnly(result); + Cache.Add(vehicle, seats); + if (!_lifecycleHooked) + { + GameLifecycle.OnPreSceneChange += Clear; + _lifecycleHooked = true; + } + return seats; + } + + internal static void Refresh(S1Vehicles.LandVehicle vehicle, int index) + { + if (Cache.TryGetValue(vehicle, out var seats) && index >= 0 && index < seats.Count) + seats[index].Refresh(); + } + + internal static void Tick() + { + if (Cache.Count == 0) + return; + // Callbacks may destroy vehicles or resolve more seat collections. + foreach (var entry in new List>>(Cache)) + { + if (entry.Key == null) + Remove(entry.Key!); + else + foreach (var seat in entry.Value) + seat.Refresh(); + } + } + + internal static void Remove(S1Vehicles.LandVehicle vehicle) + { + if (!Cache.TryGetValue(vehicle, out var seats)) + return; + Cache.Remove(vehicle); + foreach (var seat in seats) + seat.Invalidate(); + } + + internal static void Clear() + { + foreach (var seats in Cache.Values) + foreach (var seat in seats) + seat.Invalidate(); + Cache.Clear(); + } + } +} diff --git a/S1API/S1API.cs b/S1API/S1API.cs index d999bd18..52d27a03 100644 --- a/S1API/S1API.cs +++ b/S1API/S1API.cs @@ -37,6 +37,7 @@ public override void OnInitializeMelon() public override void OnDeinitializeMelon() { + Internal.Vehicles.VehicleSeats.Clear(); Internal.Compatibility.AvatarCompatibility.ResetRendering(); Rendering.IconFactory.ResetAccessoryIconState(); WeatherRuntime.ResetBindings(); @@ -50,6 +51,7 @@ public override void OnDeinitializeMelon() public override void OnUpdate() { + Internal.Vehicles.VehicleSeats.Tick(); WeatherRuntime.Tick(); PresentationWorkbenchRuntime.Tick(); CutsceneManager.Tick(UnityEngine.Time.unscaledDeltaTime); @@ -71,6 +73,7 @@ public override void OnSceneWasLoaded(int buildIndex, string sceneName) public override void OnSceneWasUnloaded(int buildIndex, string sceneName) { + Internal.Vehicles.VehicleSeats.Clear(); NPCWorkbenchRuntime.Close(); PresentationWorkbenchRuntime.Close(); CutsceneManager.CleanupForSceneChange(); diff --git a/S1API/Vehicles/LandVehicle.cs b/S1API/Vehicles/LandVehicle.cs index 22af8ba7..4aa7b4c6 100644 --- a/S1API/Vehicles/LandVehicle.cs +++ b/S1API/Vehicles/LandVehicle.cs @@ -11,6 +11,7 @@ using Guid = System.Guid; #endif using System; +using System.Collections.Generic; using System.Reflection; using UnityEngine; using S1API.Internal.Utils; @@ -24,6 +25,16 @@ namespace S1API.Vehicles /// public class LandVehicle { + /// + /// Gets read-only player seats in native array order, sharing seat objects across vehicle lookups. + /// + /// + /// Resolve again after a scene change. Seat indices are native indices, including empty slots. + /// This API does not include NPC occupants or control entry, exit, or reservation. + /// + public IReadOnlyList Seats => + Internal.Vehicles.VehicleSeats.Get(S1LandVehicle); + // Public members intended to be used by modders #region Public Members /// diff --git a/S1API/Vehicles/VehicleSeatInfo.cs b/S1API/Vehicles/VehicleSeatInfo.cs new file mode 100644 index 00000000..7a8d3b3a --- /dev/null +++ b/S1API/Vehicles/VehicleSeatInfo.cs @@ -0,0 +1,141 @@ +using System; +using S1API.Entities; +using S1API.Logging; + +#if IL2CPPMELON +using S1Vehicles = Il2CppScheduleOne.Vehicles; +using S1Player = Il2CppScheduleOne.PlayerScripts.Player; +#elif MONOMELON +using S1Vehicles = ScheduleOne.Vehicles; +using S1Player = ScheduleOne.PlayerScripts.Player; +#endif + +namespace S1API.Vehicles +{ + /// + /// Read-only metadata and live player occupancy for one native vehicle seat. + /// + /// + /// Instances remain stable for the loaded vehicle. Destruction or scene cleanup clears + /// subscriptions and invalidates occupancy without synthesizing exit events. + /// + public sealed class VehicleSeatInfo + { + private S1Vehicles.VehicleSeat? _seat; + private S1Player? _observedOccupant; + private Player? _observedPlayer; + + internal VehicleSeatInfo(int index, S1Vehicles.VehicleSeat? seat) + { + Index = index; + _seat = seat; + IsDriverSeat = !ReferenceEquals(seat, null) && seat != null && seat.isDriverSeat; + _observedOccupant = ReadOccupant(); + _observedPlayer = ResolvePlayer(_observedOccupant); + } + + /// Gets the seat's stable index in the native vehicle seat array. + public int Index { get; } + + /// Gets whether the native seat is configured as a driver seat. + public bool IsDriverSeat { get; } + + /// Gets whether a live native player occupies this seat. + public bool IsOccupied => !ReferenceEquals(ReadOccupant(), null); + + /// + /// Gets the occupant from , or null if empty or no managed player is available. + /// + public Player? Occupant => ResolvePlayer(ReadOccupant()); + + /// Raised with the previous and current managed occupants after native occupancy changes. + /// + /// Native assignments notify synchronously after mutation on the observing peer. + /// Player destruction without an assignment is observed on the next API update. + /// Either argument may be null when its managed wrapper is unavailable; native identity + /// changes still notify when both arguments are null. Repeated assignments do not notify. + /// Subscriber exceptions are isolated. No network messages are added. + /// + public event Action? OccupantChanged; + + internal void Refresh() + { + var current = ReadOccupant(); + var player = ResolvePlayer(current); + if (SameIdentity(_observedOccupant, current)) + { + _observedPlayer = player; + return; + } + + var previous = _observedPlayer ?? ResolvePlayer(_observedOccupant); + _observedOccupant = current; + _observedPlayer = player; + NotifyOccupantChanged(previous, player); + } + + internal void Invalidate() + { + _seat = null; + _observedOccupant = null; + _observedPlayer = null; + OccupantChanged = null; + } + + internal void NotifyOccupantChanged(Player? previous, Player? current) + { + var handlers = OccupantChanged; + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try + { + handler(previous, current); + } + catch (Exception ex) + { + try + { + new Log("VehicleSeatInfo").Warning($"An OccupantChanged subscriber failed: {ex.Message}"); + } + catch + { + // A logging failure must not prevent the remaining callbacks. + } + } + } + } + + private S1Player? ReadOccupant() + { + if (ReferenceEquals(_seat, null) || _seat == null) + return null; + var occupant = _seat.Occupant; + return ReferenceEquals(occupant, null) || occupant == null ? null : occupant; + } + + private static bool SameIdentity(S1Player? left, S1Player? right) + { + // Unity equality treats a destroyed player as null, which would hide disconnects. +#if IL2CPPMELON + return (left?.Pointer ?? IntPtr.Zero) == (right?.Pointer ?? IntPtr.Zero); +#else + return ReferenceEquals(left, right); +#endif + } + + private static Player? ResolvePlayer(S1Player? native) + { + if (ReferenceEquals(native, null)) + return null; + foreach (var player in Player.All) + { + if (SameIdentity(player.S1Player, native)) + return player; + } + return null; + } + } +} From eedd4e7907eca12f93d33889fb311a2e028f87ab Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:17:58 +0000 Subject: [PATCH 2/2] docs(vehicles): document seat occupancy tracking and lifecycle cleanup --- S1API/Internal/Patches/LandVehiclePatches.cs | 2 ++ S1API/Internal/Patches/VehicleSeatPatches.cs | 6 ++++++ S1API/Internal/Vehicles/VehicleSeats.cs | 15 +++++++++++++ S1API/S1API.cs | 5 +++++ S1API/Vehicles/VehicleSeatInfo.cs | 22 ++++++++++++++++++++ 5 files changed, 50 insertions(+) diff --git a/S1API/Internal/Patches/LandVehiclePatches.cs b/S1API/Internal/Patches/LandVehiclePatches.cs index 39da4d8b..360ede7a 100644 --- a/S1API/Internal/Patches/LandVehiclePatches.cs +++ b/S1API/Internal/Patches/LandVehiclePatches.cs @@ -13,6 +13,8 @@ namespace S1API.Internal.Patches [HarmonyPatch(typeof(S1Vehicles.LandVehicle))] internal class LandVehiclePatches { + /// Invalidates cached seats and attempts to remove the destroyed vehicle from vehicle tracking. + /// The vehicle whose native destruction callback has completed. [HarmonyPatch("OnDestroy")] [HarmonyPostfix] public static void OnDestroy(S1Vehicles.LandVehicle __instance) { diff --git a/S1API/Internal/Patches/VehicleSeatPatches.cs b/S1API/Internal/Patches/VehicleSeatPatches.cs index 6371ceb7..64722ed4 100644 --- a/S1API/Internal/Patches/VehicleSeatPatches.cs +++ b/S1API/Internal/Patches/VehicleSeatPatches.cs @@ -17,6 +17,9 @@ namespace S1API.Internal.Patches [HarmonyPatch] internal static class VehicleSeatPatches { + /// Selects the generated seat-occupant mutation method patched by Harmony. + /// The unique method matching the expected name prefix and signature. + /// No method or more than one method matches. internal static MethodBase TargetMethod() => typeof(S1Vehicles.LandVehicle) .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) @@ -26,6 +29,9 @@ internal static MethodBase TargetMethod() => && method.GetParameters().Select(parameter => parameter.ParameterType) .SequenceEqual(new[] { typeof(S1Connection), typeof(int), typeof(S1Connection) })); + /// Refreshes an already cached seat after the native occupant mutation completes. + /// The vehicle whose seat was updated. + /// The native seat-array index passed as the mutation's second argument. [HarmonyPostfix] private static void Postfix(S1Vehicles.LandVehicle __instance, int __1) => VehicleSeats.Refresh(__instance, __1); diff --git a/S1API/Internal/Vehicles/VehicleSeats.cs b/S1API/Internal/Vehicles/VehicleSeats.cs index 2d3bef7a..3742d49a 100644 --- a/S1API/Internal/Vehicles/VehicleSeats.cs +++ b/S1API/Internal/Vehicles/VehicleSeats.cs @@ -18,6 +18,12 @@ internal static class VehicleSeats private static readonly IReadOnlyList Empty = Array.AsReadOnly(Array.Empty()); private static bool _lifecycleHooked; + /// Gets or creates shared seat wrappers and registers scene cleanup on first caching. + /// The native vehicle, which may be null or destroyed. + /// + /// The cached read-only collection in native array order, including wrappers for null slots, + /// or an empty collection if the vehicle or its seat array is unavailable. + /// internal static IReadOnlyList Get(S1Vehicles.LandVehicle? vehicle) { if (ReferenceEquals(vehicle, null) || vehicle == null) @@ -41,12 +47,18 @@ internal static IReadOnlyList Get(S1Vehicles.LandVehicle? vehic return seats; } + /// Refreshes one cached seat, notifying subscribers if its native occupant changed. + /// The vehicle whose cached seat should be refreshed. + /// The native seat-array index; out-of-range indices are ignored. + /// Does nothing if the vehicle has no cached seat collection. internal static void Refresh(S1Vehicles.LandVehicle vehicle, int index) { if (Cache.TryGetValue(vehicle, out var seats) && index >= 0 && index < seats.Count) seats[index].Refresh(); } + /// Refreshes cached seats and removes destroyed vehicles during an API update. + /// Occupant changes can notify subscribers; removing a vehicle does not emit exits. internal static void Tick() { if (Cache.Count == 0) @@ -62,6 +74,8 @@ internal static void Tick() } } + /// Removes cached seats and releases their subscriptions without emitting exit events. + /// The vehicle to remove; an uncached vehicle is ignored. internal static void Remove(S1Vehicles.LandVehicle vehicle) { if (!Cache.TryGetValue(vehicle, out var seats)) @@ -71,6 +85,7 @@ internal static void Remove(S1Vehicles.LandVehicle vehicle) seat.Invalidate(); } + /// Invalidates all cached seats and releases their subscriptions without emitting exit events. internal static void Clear() { foreach (var seats in Cache.Values) diff --git a/S1API/S1API.cs b/S1API/S1API.cs index 52d27a03..e798c0c3 100644 --- a/S1API/S1API.cs +++ b/S1API/S1API.cs @@ -35,6 +35,7 @@ public override void OnInitializeMelon() } } + /// Releases tracked seats and resets internal runtime systems when the mod shuts down. public override void OnDeinitializeMelon() { Internal.Vehicles.VehicleSeats.Clear(); @@ -49,6 +50,7 @@ public override void OnDeinitializeMelon() UnityExceptionTraceHook.Remove(); } + /// Refreshes tracked seat occupancy and advances the API's runtime systems each frame. public override void OnUpdate() { Internal.Vehicles.VehicleSeats.Tick(); @@ -71,6 +73,9 @@ public override void OnSceneWasLoaded(int buildIndex, string sceneName) } } + /// Clears tracked seats and scene state, resetting the game lifecycle when Main unloads. + /// The unloaded scene's build index; unused by this callback. + /// The unloaded scene's name; Main and Tutorial receive additional cleanup. public override void OnSceneWasUnloaded(int buildIndex, string sceneName) { Internal.Vehicles.VehicleSeats.Clear(); diff --git a/S1API/Vehicles/VehicleSeatInfo.cs b/S1API/Vehicles/VehicleSeatInfo.cs index 7a8d3b3a..0646c5ba 100644 --- a/S1API/Vehicles/VehicleSeatInfo.cs +++ b/S1API/Vehicles/VehicleSeatInfo.cs @@ -25,6 +25,9 @@ public sealed class VehicleSeatInfo private S1Player? _observedOccupant; private Player? _observedPlayer; + /// Captures seat metadata and initial occupancy without emitting a change event. + /// The seat's index in the native vehicle seat array. + /// The native seat, or null for an unavailable array slot. internal VehicleSeatInfo(int index, S1Vehicles.VehicleSeat? seat) { Index = index; @@ -58,6 +61,11 @@ internal VehicleSeatInfo(int index, S1Vehicles.VehicleSeat? seat) /// public event Action? OccupantChanged; + /// Updates observed occupancy and notifies subscribers when native player identity changes. + /// + /// Updates the observed state before notifying. A managed-wrapper change alone does not notify; + /// a native identity change still notifies when both managed occupants are unavailable. + /// internal void Refresh() { var current = ReadOccupant(); @@ -74,6 +82,7 @@ internal void Refresh() NotifyOccupantChanged(previous, player); } + /// Clears occupancy and subscriptions without notifying, retaining the seat's index and driver metadata. internal void Invalidate() { _seat = null; @@ -82,6 +91,10 @@ internal void Invalidate() OccupantChanged = null; } + /// Invokes the current occupant-change subscribers, isolating each subscriber's exceptions. + /// The previous managed occupant, or null if empty or unavailable. + /// The current managed occupant, or null if empty or unavailable. + /// Both arguments may be null; this method does not suppress duplicate notifications. internal void NotifyOccupantChanged(Player? previous, Player? current) { var handlers = OccupantChanged; @@ -108,6 +121,8 @@ internal void NotifyOccupantChanged(Player? previous, Player? current) } } + /// Reads the live native occupant. + /// Null if the seat or occupant is missing or destroyed; otherwise the native player. private S1Player? ReadOccupant() { if (ReferenceEquals(_seat, null) || _seat == null) @@ -116,6 +131,10 @@ internal void NotifyOccupantChanged(Player? previous, Player? current) return ReferenceEquals(occupant, null) || occupant == null ? null : occupant; } + /// Compares player identity without treating a destroyed Unity object as null. + /// The first native player, or null. + /// The second native player, or null. + /// Whether native pointers match on IL2CPP or managed references match on Mono. private static bool SameIdentity(S1Player? left, S1Player? right) { // Unity equality treats a destroyed player as null, which would hide disconnects. @@ -126,6 +145,9 @@ private static bool SameIdentity(S1Player? left, S1Player? right) #endif } + /// Finds the first managed player with the supplied native identity in . + /// The native player to resolve, or null. + /// The matching managed player, or null if the input is null or no match exists. private static Player? ResolvePlayer(S1Player? native) { if (ReferenceEquals(native, null))