From 8a7d86d8bffbd1d7d0fb09f6fc8016f26d30439b Mon Sep 17 00:00:00 2001 From: HazDS Date: Tue, 24 Feb 2026 16:58:31 +0000 Subject: [PATCH 1/5] Restore storage configuration name from save Add a small JSON parser (ParseConfigurationName) to extract the custom name from RenamableConfigurationData JSON (handles compact and pretty-printed forms). After loading storage contents, parse and apply the Configuration.Name directly to placeableStorage so renames are restored from save data instead of relying on deferred onLoadComplete. Parsing and assignment are wrapped in try/catch with a logged warning on failure. --- S1API/Internal/Patches/StoragePatches.cs | 54 ++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/S1API/Internal/Patches/StoragePatches.cs b/S1API/Internal/Patches/StoragePatches.cs index 7c60903..069fe60 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) { From 2124d1a4a86dbfcba7288570ee7dab76b74836f5 Mon Sep 17 00:00:00 2001 From: HazDS Date: Tue, 24 Feb 2026 19:12:03 +0000 Subject: [PATCH 2/5] Fix dealers home location Ensure Dealer.HomeEvent.Building is updated when setting NPC dealer Home so the schedule system knows where to send the dealer. Adds reflection-based logic (field or property access depending on MONOMELON) in both NPCDealer.cs and NPCPrefabIdentity.cs to retrieve the HomeEvent object and set its Building member after Home is resolved. Wraps the operations in try/catch; one location logs a warning on failure, the other silently ignores exceptions. This fixes timing issues where Dealer.Awake() runs before S1API sets Home. --- S1API/Entities/NPCDealer.cs | 17 +++++++++++++++ S1API/Internal/Entities/NPCPrefabIdentity.cs | 23 ++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/S1API/Entities/NPCDealer.cs b/S1API/Entities/NPCDealer.cs index 134540c..13e20bb 100644 --- a/S1API/Entities/NPCDealer.cs +++ b/S1API/Entities/NPCDealer.cs @@ -659,6 +659,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/Internal/Entities/NPCPrefabIdentity.cs b/S1API/Internal/Entities/NPCPrefabIdentity.cs index e2368de..8b80104 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) { From a181c153b55331629e371b946e9729566e10ed7d Mon Sep 17 00:00:00 2001 From: HazDS Date: Tue, 24 Feb 2026 19:40:18 +0000 Subject: [PATCH 3/5] Patch DealerManagementApp dropdown Add Harmony patches to refresh the DealerManagementApp dropdown when the app is opened or refreshed. This calls the internal RefreshDropdown (with reflection fallbacks for different build targets) and restores the selected dealer index to avoid stale mugshot sprites and region text for custom dealers whose properties are set after initial population. Includes platform-specific handling (IL2CPP vs Mono/BepInEx) and logs failures. --- .../Patches/DealerManagementAppPatches.cs | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 S1API/Internal/Patches/DealerManagementAppPatches.cs diff --git a/S1API/Internal/Patches/DealerManagementAppPatches.cs b/S1API/Internal/Patches/DealerManagementAppPatches.cs new file mode 100644 index 0000000..847f1d6 --- /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 + } +} From e12bc0030a274f29be39c63049103188a26cb52e Mon Sep 17 00:00:00 2001 From: HazDS Date: Tue, 24 Feb 2026 21:00:29 +0000 Subject: [PATCH 4/5] Improve DriveToCarPark logging and visibility fix Add detailed logging and error handling to DriveToCarParkSpec: log unresolved parking lots, warn about lots with no ParkingSpot children, log vehicle creation/spawn failures and exceptions, and catch unexpected errors. Introduce VehiclesAtNoSpotLots HashSet to track vehicles assigned to lots with no spots and remove entries when vehicles are removed. Add a Harmony prefix patch on LandVehicle.SetVisible to prevent the game from hiding tracked vehicles (keeps them visible when the game would call SetVisible(false)). Also include a small build-define fix and include the schedule namespace in the land-vehicle patches. --- .../ActionSpecs/DriveToCarParkSpec.cs | 76 ++++++++++++++++--- S1API/Internal/Patches/LandVehiclePatches.cs | 29 ++++++- 2 files changed, 93 insertions(+), 12 deletions(-) diff --git a/S1API/Entities/Schedule/ActionSpecs/DriveToCarParkSpec.cs b/S1API/Entities/Schedule/ActionSpecs/DriveToCarParkSpec.cs index a2de7de..77ffb18 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/Patches/LandVehiclePatches.cs b/S1API/Internal/Patches/LandVehiclePatches.cs index 6d79ab8..e38c454 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; + } } } From 4cddc8ddd92d9d02b482b798d8697a064e7b365f Mon Sep 17 00:00:00 2001 From: HazDS Date: Tue, 24 Feb 2026 21:22:52 +0000 Subject: [PATCH 5/5] Reset static state on scene save/reload Add reset/cleanup routines to prevent stale static state and leaked delegates across save loads and scene changes. SceneStateCleaner now invokes these resets. Changes include: - DialogueChoiceListener: added ResetState to clear expected choice and callback. - DialogueInjector: added ResetState to clear pending injections and hooked flag. - NPCDealer: added ClearStaticDelegates to clear Dealer.onDealerRecruited static field. - TimeManagerShim: added ResetDelegates to clear accumulated delegates and IL2CPP lists. - ContactsAppPatches: added ResetState to clear cached initialization flags. - NPCPatches: added ResetState to clear saved addiction map and loading dealers list. These updates prevent dead wrapper delegates, duplicated hooks, stale caches, and other cross-load leaks so systems reinitialize correctly on next load. --- S1API/Dialogues/DialogueChoiceListener.cs | 9 +++++++++ S1API/Dialogues/DialogueInjector.cs | 9 +++++++++ S1API/Entities/NPCDealer.cs | 14 ++++++++++++++ S1API/Internal/Lifecycle/SceneStateCleaner.cs | 17 +++++++++++++++++ S1API/Internal/Lifecycle/TimeManagerShim.cs | 15 +++++++++++++++ S1API/Internal/Patches/ContactsAppPatches.cs | 9 +++++++++ S1API/Internal/Patches/NPCPatches.cs | 9 +++++++++ 7 files changed, 82 insertions(+) diff --git a/S1API/Dialogues/DialogueChoiceListener.cs b/S1API/Dialogues/DialogueChoiceListener.cs index 323a18d..7bc5583 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 9854518..626bf3c 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 13e20bb..3e01397 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. /// diff --git a/S1API/Internal/Lifecycle/SceneStateCleaner.cs b/S1API/Internal/Lifecycle/SceneStateCleaner.cs index 8be240a..bfa0578 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 b397821..1936786 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 bbc438b..dcb0e02 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/NPCPatches.cs b/S1API/Internal/Patches/NPCPatches.cs index 8e58dc5..61964d8 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. ///