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..360ede7a 100644 --- a/S1API/Internal/Patches/LandVehiclePatches.cs +++ b/S1API/Internal/Patches/LandVehiclePatches.cs @@ -13,9 +13,12 @@ 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) { + 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..64722ed4 --- /dev/null +++ b/S1API/Internal/Patches/VehicleSeatPatches.cs @@ -0,0 +1,39 @@ +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 + { + /// 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) + .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) })); + + /// 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 new file mode 100644 index 00000000..3742d49a --- /dev/null +++ b/S1API/Internal/Vehicles/VehicleSeats.cs @@ -0,0 +1,97 @@ +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; + + /// 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) + 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; + } + + /// 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) + 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(); + } + } + + /// 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)) + return; + Cache.Remove(vehicle); + foreach (var seat in seats) + seat.Invalidate(); + } + + /// Invalidates all cached seats and releases their subscriptions without emitting exit events. + 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..e798c0c3 100644 --- a/S1API/S1API.cs +++ b/S1API/S1API.cs @@ -35,8 +35,10 @@ 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(); Internal.Compatibility.AvatarCompatibility.ResetRendering(); Rendering.IconFactory.ResetAccessoryIconState(); WeatherRuntime.ResetBindings(); @@ -48,8 +50,10 @@ 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(); WeatherRuntime.Tick(); PresentationWorkbenchRuntime.Tick(); CutsceneManager.Tick(UnityEngine.Time.unscaledDeltaTime); @@ -69,8 +73,12 @@ 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(); 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..0646c5ba --- /dev/null +++ b/S1API/Vehicles/VehicleSeatInfo.cs @@ -0,0 +1,163 @@ +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; + + /// 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; + _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; + + /// 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(); + var player = ResolvePlayer(current); + if (SameIdentity(_observedOccupant, current)) + { + _observedPlayer = player; + return; + } + + var previous = _observedPlayer ?? ResolvePlayer(_observedOccupant); + _observedOccupant = current; + _observedPlayer = player; + NotifyOccupantChanged(previous, player); + } + + /// Clears occupancy and subscriptions without notifying, retaining the seat's index and driver metadata. + internal void Invalidate() + { + _seat = null; + _observedOccupant = null; + _observedPlayer = null; + 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; + 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. + } + } + } + } + + /// 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) + return null; + var occupant = _seat.Occupant; + 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. +#if IL2CPPMELON + return (left?.Pointer ?? IntPtr.Zero) == (right?.Pointer ?? IntPtr.Zero); +#else + return ReferenceEquals(left, 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)) + return null; + foreach (var player in Player.All) + { + if (SameIdentity(player.S1Player, native)) + return player; + } + return null; + } + } +}