diff --git a/S1API/Dialogues/DialogueChoiceListener.cs b/S1API/Dialogues/DialogueChoiceListener.cs
index 323a18d5..7bc5583f 100644
--- a/S1API/Dialogues/DialogueChoiceListener.cs
+++ b/S1API/Dialogues/DialogueChoiceListener.cs
@@ -30,6 +30,15 @@ public static class DialogueChoiceListener
///
private static Action? _callback;
+ ///
+ /// Resets static state so the listener works correctly across save loads.
+ ///
+ internal static void ResetState()
+ {
+ _expectedChoiceLabel = null;
+ _callback = null;
+ }
+
/// Registers a specific dialogue choice with a callback to be invoked when the choice is selected.
/// The reference to the DialogueHandler that manages dialogue choices.
/// The label identifying the specific dialogue choice to be registered.
diff --git a/S1API/Dialogues/DialogueInjector.cs b/S1API/Dialogues/DialogueInjector.cs
index 98545182..626bf3cf 100644
--- a/S1API/Dialogues/DialogueInjector.cs
+++ b/S1API/Dialogues/DialogueInjector.cs
@@ -59,6 +59,15 @@ public static void Register(DialogueInjection injection)
/// This method prevents multiple hookups by checking if the injection system is already active using an internal flag.
/// If not already hooked, the method initializes a coroutine that processes and injects queued dialogue data into the corresponding NPCs.
///
+ ///
+ /// Resets static state so the injector works correctly across save loads.
+ ///
+ internal static void ResetState()
+ {
+ _pendingInjections.Clear();
+ _isHooked = false;
+ }
+
private static void HookUpdateLoop()
{
if (_isHooked)
diff --git a/S1API/Entities/NPCDealer.cs b/S1API/Entities/NPCDealer.cs
index 134540ca..3e01397c 100644
--- a/S1API/Entities/NPCDealer.cs
+++ b/S1API/Entities/NPCDealer.cs
@@ -74,6 +74,20 @@ internal NPCDealer(NPC npc)
// Do not assume Dealer exists; prefab may omit it by design
}
+ ///
+ /// Clears stale delegates from the static Dealer.onDealerRecruited field.
+ /// Must be called on scene change to prevent dead wrapper delegates from accumulating.
+ ///
+ internal static void ClearStaticDelegates()
+ {
+ try
+ {
+ if (DealerRecruitedField != null)
+ DealerRecruitedField.SetValue(null, null);
+ }
+ catch { }
+ }
+
///
/// Returns whether this NPC currently has dealer functionality.
///
@@ -659,6 +673,23 @@ public Map.Building? Home
// Set the Home field/property on the Dealer component
Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(Component, "Home", homeBuilding);
+
+ // Sync HomeEvent.Building — Dealer.Awake() does this but runs before
+ // Home is set for S1API NPCs, so we must do it here too.
+ try
+ {
+#if MONOMELON
+ var homeEventField = typeof(S1Economy.Dealer).GetField("HomeEvent", BindingFlags.Public | BindingFlags.Instance);
+#else
+ var homeEventField = typeof(S1Economy.Dealer).GetProperty("HomeEvent", BindingFlags.Public | BindingFlags.Instance);
+#endif
+ var homeEvent = homeEventField?.GetValue(Component);
+ if (homeEvent != null)
+ {
+ Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(homeEvent, "Building", homeBuilding);
+ }
+ }
+ catch { }
}
catch (Exception ex)
{
diff --git a/S1API/Entities/Schedule/ActionSpecs/DriveToCarParkSpec.cs b/S1API/Entities/Schedule/ActionSpecs/DriveToCarParkSpec.cs
index a2de7de8..77ffb18e 100644
--- a/S1API/Entities/Schedule/ActionSpecs/DriveToCarParkSpec.cs
+++ b/S1API/Entities/Schedule/ActionSpecs/DriveToCarParkSpec.cs
@@ -20,6 +20,7 @@
using UnityEngine;
using S1API.Map;
using S1API.Vehicles;
+using S1API.Logging;
using S1API.Internal.Utils;
namespace S1API.Entities.Schedule
@@ -34,6 +35,8 @@ namespace S1API.Entities.Schedule
///
public sealed class DriveToCarParkSpec : IScheduleActionSpec
{
+ private static readonly Log Logger = new Log("DriveToCarParkSpec");
+
///
/// Gets or sets the time when this action should start, in minutes from midnight.
///
@@ -177,23 +180,41 @@ void IScheduleActionSpec.ApplyTo(NPCSchedule schedule)
{
// Resolve lot
object lotObj = null;
+ S1Map.ParkingLot gameLot = null;
if (ParkingLot != null)
{
- lotObj = ParkingLot.ResolveGameLot();
+ gameLot = ParkingLot.ResolveGameLot();
+ lotObj = gameLot;
}
else if (!string.IsNullOrEmpty(ParkingLotName))
{
var lotWrap = ParkingLotRegistry.GetByName(ParkingLotName);
- lotObj = lotWrap?.ResolveGameLot();
+ gameLot = lotWrap?.ResolveGameLot();
+ lotObj = gameLot;
}
else if (!string.IsNullOrEmpty(ParkingLotGUID))
{
var lotWrap = ParkingLotRegistry.GetByGUID(ParkingLotGUID);
- lotObj = lotWrap?.ResolveGameLot();
+ gameLot = lotWrap?.ResolveGameLot();
+ lotObj = gameLot;
+ }
+
+ if (lotObj == null)
+ {
+ Logger.Warning($"DriveToCarPark: Parking lot could not be resolved (Name='{ParkingLotName}', GUID='{ParkingLotGUID}'). Action will not function correctly.");
}
- if (lotObj != null)
+ else
+ {
ReflectionUtils.TrySetFieldOrProperty(action, "ParkingLot", lotObj);
+ // Warn if the lot has no parking spots - vehicles parked here will be hidden by the game
+ if (gameLot != null && (gameLot.ParkingSpots == null || gameLot.ParkingSpots.Count == 0))
+ {
+ Logger.Warning($"DriveToCarPark: Parking lot '{gameLot.gameObject.name}' has no parking spots. " +
+ "The game will hide (SetVisible=false) any vehicle parked here. Choose a lot with ParkingSpot children.");
+ }
+ }
+
// Resolve vehicle
object vehObj = null;
if (Vehicle != null && Vehicle.S1LandVehicle != null)
@@ -213,8 +234,15 @@ void IScheduleActionSpec.ApplyTo(NPCSchedule schedule)
else if (!string.IsNullOrEmpty(VehicleCode))
{
var v = VehicleRegistry.CreateVehicle(VehicleCode);
- vehObj = v?.S1LandVehicle;
-
+ if (v == null)
+ {
+ Logger.Error($"DriveToCarPark: Failed to create vehicle with code '{VehicleCode}'. Verify the code is valid.");
+ }
+ else
+ {
+ vehObj = v.S1LandVehicle;
+ }
+
// If we're on the server and the vehicle needs spawning, spawn it now
if (vehObj != null)
{
@@ -228,24 +256,39 @@ void IScheduleActionSpec.ApplyTo(NPCSchedule schedule)
#else
var nm = Il2CppFishNet.InstanceFinder.NetworkManager;
#endif
-
+
if (nm != null && nm.IsServer)
{
- // Spawn the vehicle at the specified location and rotation
var spawnRot = VehicleSpawnRotation ?? Quaternion.identity;
wrapper.Spawn(VehicleSpawnPosition, spawnRot);
}
}
catch (Exception ex)
{
- UnityEngine.Debug.LogWarning($"[S1API] Failed to spawn created vehicle: {ex.Message}");
+ Logger.Error($"DriveToCarPark: Failed to spawn created vehicle '{VehicleCode}': {ex.Message}");
+ Logger.Error($"DriveToCarPark: Stack trace: {ex.StackTrace}");
}
}
}
}
- if (vehObj != null)
+
+ if (vehObj == null)
+ {
+ Logger.Warning("DriveToCarPark: Vehicle could not be resolved. The NPC will not be able to drive.");
+ }
+ else
+ {
ReflectionUtils.TrySetFieldOrProperty(action, "Vehicle", vehObj);
+ // Track vehicles created for lots with no spots so the patch can prevent hiding
+ if (gameLot != null && (gameLot.ParkingSpots == null || gameLot.ParkingSpots.Count == 0))
+ {
+ var gameVeh = vehObj as S1Vehicles.LandVehicle;
+ if (gameVeh != null)
+ VehiclesAtNoSpotLots.Add(gameVeh);
+ }
+ }
+
// Flags
if (OverrideParkingType.HasValue)
ReflectionUtils.TrySetFieldOrProperty(action, "OverrideParkingType", OverrideParkingType.Value);
@@ -261,7 +304,18 @@ void IScheduleActionSpec.ApplyTo(NPCSchedule schedule)
ReflectionUtils.TrySetFieldOrProperty(action, "ParkingType", boxed);
}
}
- catch { }
+ catch (Exception ex)
+ {
+ Logger.Error($"DriveToCarPark: Unexpected error during ApplyTo: {ex.Message}");
+ Logger.Error($"DriveToCarPark: Stack trace: {ex.StackTrace}");
+ }
}
+
+ ///
+ /// INTERNAL: Tracks vehicles assigned to parking lots with no spots.
+ /// Used by the LandVehicle.Park patch to prevent the game from hiding these vehicles.
+ ///
+ internal static readonly System.Collections.Generic.HashSet VehiclesAtNoSpotLots =
+ new System.Collections.Generic.HashSet();
}
}
diff --git a/S1API/Internal/Entities/NPCPrefabIdentity.cs b/S1API/Internal/Entities/NPCPrefabIdentity.cs
index e2368de2..8b801041 100644
--- a/S1API/Internal/Entities/NPCPrefabIdentity.cs
+++ b/S1API/Internal/Entities/NPCPrefabIdentity.cs
@@ -662,6 +662,29 @@ private void ApplyDealerHomeBuilding(S1NPCs.NPC npc, string buildingName = null)
{
Logger.Warning($"[Dealer Home] Failed to set Home property on Dealer component for NPC {npc?.ID ?? ""}");
}
+ else
+ {
+ // Dealer.Awake() runs HomeEvent.Building = Home, but for S1API NPCs
+ // Home is null at Awake-time (resolved later). We must sync HomeEvent.Building
+ // so the schedule system knows where to send the dealer.
+ try
+ {
+#if MONOMELON
+ var homeEventField = typeof(S1Economy.Dealer).GetField("HomeEvent", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
+#else
+ var homeEventField = typeof(S1Economy.Dealer).GetProperty("HomeEvent", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
+#endif
+ var homeEvent = homeEventField?.GetValue(dealerComponent);
+ if (homeEvent != null)
+ {
+ ReflectionUtils.TrySetFieldOrProperty(homeEvent, "Building", gameBuilding);
+ }
+ }
+ catch (Exception homeEventEx)
+ {
+ Logger.Warning($"[Dealer Home] Failed to sync HomeEvent.Building for NPC {npc?.ID ?? ""}: {homeEventEx.Message}");
+ }
+ }
}
catch (Exception ex)
{
diff --git a/S1API/Internal/Lifecycle/SceneStateCleaner.cs b/S1API/Internal/Lifecycle/SceneStateCleaner.cs
index 8be240ac..bfa05786 100644
--- a/S1API/Internal/Lifecycle/SceneStateCleaner.cs
+++ b/S1API/Internal/Lifecycle/SceneStateCleaner.cs
@@ -1,4 +1,5 @@
using System;
+using S1API.Dialogues;
using S1API.Entities;
using S1API.Avatar;
using S1API.Logging;
@@ -100,6 +101,22 @@ internal static void ResetForSceneChange(string sceneName, bool afterUnload)
// Reset loading screen patch state to prevent stuck flags
LoadingScreenPatches.ResetState();
+
+ // Reset dialogue system static state to prevent stale injections/callbacks
+ DialogueInjector.ResetState();
+ DialogueChoiceListener.ResetState();
+
+ // Reset contacts app state so it re-initializes properly on next load
+ ContactsAppPatches.ResetState();
+
+ // Clear stale NPC patch state (dangling Customer references, loading guards)
+ NPCPatches.ResetState();
+
+ // Clear stale delegates from Dealer.onDealerRecruited static field
+ NPCDealer.ClearStaticDelegates();
+
+ // Clear accumulated shim delegates to prevent stale references on next load
+ TimeManagerShim.Instance.ResetDelegates();
}
else
{
diff --git a/S1API/Internal/Lifecycle/TimeManagerShim.cs b/S1API/Internal/Lifecycle/TimeManagerShim.cs
index b3978214..19367868 100644
--- a/S1API/Internal/Lifecycle/TimeManagerShim.cs
+++ b/S1API/Internal/Lifecycle/TimeManagerShim.cs
@@ -31,6 +31,21 @@ internal class TimeManagerShim
private TimeManagerShim() { }
+ ///
+ /// Clears accumulated delegates so stale references from a previous load don't leak into the next session.
+ ///
+ internal void ResetDelegates()
+ {
+ onSleepStart = delegate { };
+ onHourPass = delegate { };
+#if IL2CPPMELON
+ il2cppOnSleepStart = null;
+ il2cppOnHourPass = null;
+ _addedSleepStart.Clear();
+ _addedHourPass.Clear();
+#endif
+ }
+
internal void AddDelegatesToReal()
{
try
diff --git a/S1API/Internal/Patches/ContactsAppPatches.cs b/S1API/Internal/Patches/ContactsAppPatches.cs
index bbc438b4..dcb0e02d 100644
--- a/S1API/Internal/Patches/ContactsAppPatches.cs
+++ b/S1API/Internal/Patches/ContactsAppPatches.cs
@@ -39,6 +39,15 @@ internal class ContactsAppPatches
private static bool _startCalled;
private static bool? _hasCustomNpcTypesCache;
+ ///
+ /// Resets static state so the contacts app initializes correctly across save loads.
+ ///
+ internal static void ResetState()
+ {
+ _startCalled = false;
+ _hasCustomNpcTypesCache = null;
+ }
+
///
/// Checks if any custom NPC types exist (excluding S1API internal types).
/// Caches the result to avoid repeated reflection calls.
diff --git a/S1API/Internal/Patches/DealerManagementAppPatches.cs b/S1API/Internal/Patches/DealerManagementAppPatches.cs
new file mode 100644
index 00000000..847f1d68
--- /dev/null
+++ b/S1API/Internal/Patches/DealerManagementAppPatches.cs
@@ -0,0 +1,134 @@
+#if (IL2CPPMELON)
+using S1DealerManagementApp = Il2CppScheduleOne.UI.Phone.Messages.DealerManagementApp;
+using S1Economy = Il2CppScheduleOne.Economy;
+#elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX)
+using S1DealerManagementApp = ScheduleOne.UI.Phone.Messages.DealerManagementApp;
+using S1Economy = ScheduleOne.Economy;
+#endif
+
+using System;
+using System.Reflection;
+using HarmonyLib;
+using S1API.Internal.Utils;
+
+namespace S1API.Internal.Patches
+{
+ ///
+ /// INTERNAL: Patches DealerManagementApp to refresh the dropdown when opened.
+ /// Fixes stale mugshot sprites and region text for custom S1API dealers whose
+ /// properties are set in OnCreated (after the dropdown was initially populated).
+ ///
+ [HarmonyPatch]
+ internal static class DealerManagementAppPatches
+ {
+ private static readonly Logging.Log Logger = new Logging.Log("DealerManagementAppPatches");
+
+ #region Private Implementation
+
+ ///
+ /// Calls the private RefreshDropdown method and restores the selected dealer index.
+ ///
+ private static void RefreshAndSyncDropdown(S1DealerManagementApp instance)
+ {
+ var selectedDealer = instance.SelectedDealer;
+
+#if (IL2CPPMELON)
+ try
+ {
+ instance.RefreshDropdown();
+ }
+ catch
+ {
+ var method = typeof(S1DealerManagementApp).GetMethod("RefreshDropdown",
+ BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance);
+ method?.Invoke(instance, null);
+ }
+#elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX)
+ var method = typeof(S1DealerManagementApp).GetMethod("RefreshDropdown",
+ BindingFlags.NonPublic | BindingFlags.Instance);
+ if (method == null)
+ return;
+ method.Invoke(instance, null);
+#endif
+
+ if (selectedDealer == null)
+ return;
+
+#if (IL2CPPMELON)
+ var dealers = instance.dealers;
+ if (dealers == null)
+ return;
+
+ int index = -1;
+ for (int i = 0; i < dealers.Count; i++)
+ {
+ if (dealers[i] == selectedDealer)
+ {
+ index = i;
+ break;
+ }
+ }
+
+ if (index >= 0)
+ instance._dropdown?.SetValueWithoutNotify(index);
+#elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX)
+ var dealersObj = ReflectionUtils.TryGetFieldOrProperty(instance, "dealers");
+ var dealers = dealersObj as System.Collections.Generic.List;
+ if (dealers == null)
+ return;
+
+ int index = dealers.IndexOf(selectedDealer);
+ if (index < 0)
+ return;
+
+ var dropdownObj = ReflectionUtils.TryGetFieldOrProperty(instance, "_dropdown");
+ var dropdown = dropdownObj as UnityEngine.UI.Dropdown;
+ if (dropdown != null)
+ dropdown.SetValueWithoutNotify(index);
+#endif
+ }
+
+ #endregion
+
+ #region Harmony Patches
+
+ ///
+ /// After SetOpen(true), rebuild the dropdown so it reflects current MugshotSprite and Region values.
+ ///
+ [HarmonyPatch(typeof(S1DealerManagementApp), nameof(S1DealerManagementApp.SetOpen))]
+ [HarmonyPostfix]
+ private static void SetOpen_Postfix(S1DealerManagementApp __instance, bool open)
+ {
+ if (!open)
+ return;
+
+ try
+ {
+ RefreshAndSyncDropdown(__instance);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning($"Failed to refresh dealer dropdown on open: {ex.Message}");
+ }
+ }
+
+ ///
+ /// After Refresh(), rebuild the dropdown so it reflects current values when the phone is reopened.
+ ///
+ [HarmonyPatch(typeof(S1DealerManagementApp), nameof(S1DealerManagementApp.Refresh))]
+ [HarmonyPostfix]
+ private static void Refresh_Postfix(S1DealerManagementApp __instance)
+ {
+ try
+ {
+ RefreshAndSyncDropdown(__instance);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning($"Failed to refresh dealer dropdown on refresh: {ex.Message}");
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/S1API/Internal/Patches/LandVehiclePatches.cs b/S1API/Internal/Patches/LandVehiclePatches.cs
index 6d79ab82..e38c4543 100644
--- a/S1API/Internal/Patches/LandVehiclePatches.cs
+++ b/S1API/Internal/Patches/LandVehiclePatches.cs
@@ -1,7 +1,8 @@
using HarmonyLib;
using S1API.Vehicles;
+using S1API.Entities.Schedule;
-#if (IL2CPPMELON || Il2CppBepInEx)
+#if (IL2CPPMELON || IL2CPPBEPINEX)
using S1Vehicles = Il2CppScheduleOne.Vehicles;
#else
using S1Vehicles = ScheduleOne.Vehicles;
@@ -18,7 +19,10 @@ public static void OnDestroy(S1Vehicles.LandVehicle __instance) {
try
{
if (__instance != null)
+ {
VehicleRegistry.RemoveVehicle(__instance.GUID.ToString());
+ DriveToCarParkSpec.VehiclesAtNoSpotLots.Remove(__instance);
+ }
}
catch
{
@@ -26,5 +30,28 @@ public static void OnDestroy(S1Vehicles.LandVehicle __instance) {
}
}
+ ///
+ /// Prevents the game from hiding vehicles that were assigned to parking lots with no
+ /// parking spots. When spotIndex is invalid, the game calls SetVisible(false). This
+ /// prefix intercepts that call and keeps the vehicle visible instead, leaving it
+ /// wherever it currently is (e.g. where the NPC drove it).
+ ///
+ [HarmonyPatch(nameof(S1Vehicles.LandVehicle.SetVisible))]
+ [HarmonyPrefix]
+ public static bool SetVisible_Prefix(S1Vehicles.LandVehicle __instance, ref bool vis)
+ {
+ try
+ {
+ if (!vis && __instance != null && DriveToCarParkSpec.VehiclesAtNoSpotLots.Contains(__instance))
+ {
+ vis = true;
+ }
+ }
+ catch
+ {
+ // Don't break game visibility if our fix-up fails
+ }
+ return true;
+ }
}
}
diff --git a/S1API/Internal/Patches/NPCPatches.cs b/S1API/Internal/Patches/NPCPatches.cs
index 8e58dc55..61964d84 100644
--- a/S1API/Internal/Patches/NPCPatches.cs
+++ b/S1API/Internal/Patches/NPCPatches.cs
@@ -74,6 +74,15 @@ internal class NPCPatches
private static readonly System.Collections.Generic.Dictionary _savedCurrentAddiction
= new System.Collections.Generic.Dictionary();
+ ///
+ /// Resets static state that can leak across save loads.
+ ///
+ internal static void ResetState()
+ {
+ _savedCurrentAddiction.Clear();
+ _loadingDealers.Clear();
+ }
+
///
/// Comparison function for sorting NPCAction by StartTime, with signals coming before non-signals when times are equal.
///
diff --git a/S1API/Internal/Patches/StoragePatches.cs b/S1API/Internal/Patches/StoragePatches.cs
index 7c609036..069fe600 100644
--- a/S1API/Internal/Patches/StoragePatches.cs
+++ b/S1API/Internal/Patches/StoragePatches.cs
@@ -49,6 +49,41 @@ private class StorageSlotMeta : S1Persistence.SaveData
public string ItemId;
}
+ ///
+ /// Manually parse the custom name from RenamableConfigurationData JSON.
+ /// Handles both compact and pretty-printed formats.
+ ///
+ private static string ParseConfigurationName(string json)
+ {
+ if (string.IsNullOrEmpty(json))
+ return null;
+
+ try
+ {
+ int valueKeyIdx = json.IndexOf("\"Value\"");
+ if (valueKeyIdx < 0)
+ return null;
+
+ int colonIdx = json.IndexOf(':', valueKeyIdx + 7);
+ if (colonIdx < 0)
+ return null;
+
+ int openQuote = json.IndexOf('"', colonIdx + 1);
+ if (openQuote < 0)
+ return null;
+
+ int closeQuote = json.IndexOf('"', openQuote + 1);
+ if (closeQuote < 0)
+ return null;
+
+ return json.Substring(openQuote + 1, closeQuote - openQuote - 1);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
///
/// Manually parse StorageSlotMeta from JSON to avoid IL2CPP generic method issues.
///
@@ -335,6 +370,25 @@ private static bool PlaceableStorageEntityLoader_Load_Prefix(S1PersistenceLoader
wrapper.SetSlotCount(targetSlots);
storageData.Contents.LoadTo(placeableStorage.StorageEntity.ItemSlots);
+
+ // Load the Configuration (custom name) from save data.
+ // The original loader does this deferred via onLoadComplete, but we apply it
+ // directly since Configuration is already initialized after LoadAndCreate.
+ if (data.TryGetData("Configuration", out string configJson) && !string.IsNullOrEmpty(configJson))
+ {
+ try
+ {
+ var configName = ParseConfigurationName(configJson);
+ if (!string.IsNullOrEmpty(configName) && placeableStorage.Configuration?.Name != null)
+ {
+ placeableStorage.Configuration.Name.SetValue(configName, true);
+ }
+ }
+ catch (Exception configEx)
+ {
+ Logger.Warning($"Failed to load storage configuration name: {configEx.Message}");
+ }
+ }
}
catch (Exception ex)
{