From ea8834594fdeb44944c5982645a94fb164484542 Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 19 Sep 2026 15:22:06 -0700 Subject: [PATCH 01/57] fix(beta): support Schedule I 0.4.7 --- .github/release-notes/3.2.1-beta.1.md | 16 ++++ S1API/Avatar/Avatar.cs | 4 +- S1API/Dialogues/DialogueInjector.cs | 13 +-- .../Dialogue/DialogueContainerBuilder.cs | 4 +- S1API/Entities/Employees/EmployeeManager.cs | 31 ++++++- S1API/Entities/NPC.cs | 57 ++++++++----- S1API/Entities/NPCAppearance.cs | 31 +++++-- S1API/Entities/NPCCustomer.cs | 4 +- S1API/Entities/NPCDealer.cs | 6 +- S1API/Entities/NPCDialogue.cs | 16 ++-- S1API/Entities/NPCMessaging.cs | 2 +- S1API/Entities/NPCMovement.cs | 15 +++- S1API/Entities/Player.cs | 17 ++-- S1API/GameTime/TimeManager.cs | 50 +++++++----- .../Compatibility/AvatarCompatibility.cs | 27 +++++++ .../Cutscenes/NativeCutsceneAdapter.cs | 9 ++- .../Entities/ImpostorTextureResolver.cs | 10 +-- S1API/Internal/Entities/NPCDataAccess.cs | 65 +++++++-------- S1API/Internal/Entities/NPCPrefabIdentity.cs | 4 +- S1API/Internal/Lifecycle/TimeManagerShim.cs | 15 ++-- .../NPCWorkbench/NPCWorkbenchPreview.cs | 17 ++-- .../NPCWorkbenchRuntimeAdapter.cs | 2 +- .../Patches/AvatarAccessoryPatches.cs | 3 + .../Patches/CustomProductManifestPatches.cs | 63 ++++++++++----- S1API/Internal/Patches/NPCPatches.cs | 19 ++--- .../Rendering/PresentationWorkbenchRuntime.cs | 10 ++- .../Items/Clothing/ClothingItemDefinition.cs | 11 ++- .../Clothing/ClothingItemDefinitionBuilder.cs | 11 ++- .../Items/Clothing/ClothingMetadataCatalog.cs | 27 ++++--- S1API/Items/ClothingItemDefinition.cs | 11 ++- S1API/Items/ClothingItemDefinitionBuilder.cs | 10 +-- S1API/Rendering/IconFactory.cs | 31 +++++-- S1API/S1API.cs | 2 +- S1API/S1API.csproj | 2 +- S1API/Trash/TrashManager.cs | 3 +- S1API/UI/CharacterCreatorManager.cs | 80 ++++++++++--------- 36 files changed, 442 insertions(+), 256 deletions(-) create mode 100644 .github/release-notes/3.2.1-beta.1.md create mode 100644 S1API/Internal/Compatibility/AvatarCompatibility.cs diff --git a/.github/release-notes/3.2.1-beta.1.md b/.github/release-notes/3.2.1-beta.1.md new file mode 100644 index 00000000..0021e7de --- /dev/null +++ b/.github/release-notes/3.2.1-beta.1.md @@ -0,0 +1,16 @@ +## Schedule I 0.4.7 beta compatibility + +- Restored S1API startup on both the Mono `alternate-beta` and IL2CPP `beta` game branches. +- Migrated dialogue, sleep/time, player-data synchronization, NPC movement, clothing, and character-creator integrations to the 0.4.7 native APIs. +- Adapted legacy avatar settings through the game's new naked-appearance and outfit pipeline. +- Kept removed avatar preview and accessory-mugshot helpers fail-safe while the replacement renderer is still in beta. + +## Compatibility and validation + +- Built against Schedule I 0.4.7f5 Mono and IL2CPP assemblies. +- This is a GitHub-only prerelease for beta testing. It is not published to NuGet, Nexus Mods, or Thunderstore. +- Tracks [issue #305](https://github.com/ifBars/S1API/issues/305). + +## Downloads + +- [Download S1API-Forked-3.2.1-beta.1.zip](https://github.com/ifBars/S1API/releases/download/v3.2.1-beta.1/S1API-Forked-3.2.1-beta.1.zip) diff --git a/S1API/Avatar/Avatar.cs b/S1API/Avatar/Avatar.cs index 4e1f2114..cd333caf 100644 --- a/S1API/Avatar/Avatar.cs +++ b/S1API/Avatar/Avatar.cs @@ -51,7 +51,9 @@ public void LoadAvatarSettings(AvatarSettings settings) if (S1Avatar == null || settings?.S1AvatarSettings == null) return; - S1Avatar.LoadAvatarSettings(settings.S1AvatarSettings); + Internal.Compatibility.AvatarCompatibility.ApplyLegacySettings( + S1Avatar, + settings.S1AvatarSettings); } /// diff --git a/S1API/Dialogues/DialogueInjector.cs b/S1API/Dialogues/DialogueInjector.cs index 7e1c6ba9..8d75c74a 100644 --- a/S1API/Dialogues/DialogueInjector.cs +++ b/S1API/Dialogues/DialogueInjector.cs @@ -128,7 +128,7 @@ private static void TryInject(DialogueInjection injection, S1NPC npc) if (handler == null) return; - DialogueContainer? container = ResolveContainer(handler, injection.ContainerName); + Conversation? container = ResolveContainer(handler, injection.ContainerName); if (container == null) return; @@ -184,7 +184,7 @@ private static void TryInject(DialogueInjection injection, S1NPC npc) // MelonLogger.Msg($"[DialogueInjector] Injected '{injection.ChoiceLabel}' into NPC '{npc.name}'"); } - private static DialogueContainer? ResolveContainer(DialogueHandler handler, string containerName) + private static Conversation? ResolveContainer(DialogueHandler handler, string containerName) { if (handler == null || string.IsNullOrEmpty(containerName)) return null; @@ -192,8 +192,9 @@ private static void TryInject(DialogueInjection injection, S1NPC npc) DialogueController controller = handler.GetComponent(); if (controller != null) { - if (controller.GenericDialogue != null && controller.GenericDialogue.name == containerName) - return controller.GenericDialogue; + Conversation? genericConversation = DialogueManager.Instance?.GenericConversation; + if (genericConversation != null && genericConversation.name == containerName) + return genericConversation; if (controller.OverrideContainer != null && controller.OverrideContainer.name == containerName) return controller.OverrideContainer; @@ -207,14 +208,14 @@ private static void TryInject(DialogueInjection injection, S1NPC npc) var containers = handler.dialogueContainers; #else var field = typeof(DialogueHandler).GetField("dialogueContainers", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var containers = field?.GetValue(handler) as List; + var containers = field?.GetValue(handler) as List; #endif if (containers == null) return null; for (int i = 0; i < containers.Count; i++) { - DialogueContainer candidate = containers.ToArray()[i]; + Conversation candidate = containers.ToArray()[i]; if (candidate != null && candidate.name == containerName) return candidate; } diff --git a/S1API/Entities/Dialogue/DialogueContainerBuilder.cs b/S1API/Entities/Dialogue/DialogueContainerBuilder.cs index 4fcd1911..9a26d13b 100644 --- a/S1API/Entities/Dialogue/DialogueContainerBuilder.cs +++ b/S1API/Entities/Dialogue/DialogueContainerBuilder.cs @@ -62,9 +62,9 @@ public DialogueContainerBuilder SetAllowExit(bool allow) /// /// INTERNAL: Builds a ScriptableObject DialogueContainer. /// - internal S1Dialogue.DialogueContainer Build(string containerName) + internal S1Dialogue.Conversation Build(string containerName) { - var container = ScriptableObject.CreateInstance(); + var container = ScriptableObject.CreateInstance(); container.name = string.IsNullOrEmpty(containerName) ? "CustomContainer" : containerName; // Build node data diff --git a/S1API/Entities/Employees/EmployeeManager.cs b/S1API/Entities/Employees/EmployeeManager.cs index ee346488..d6cbff8f 100644 --- a/S1API/Entities/Employees/EmployeeManager.cs +++ b/S1API/Entities/Employees/EmployeeManager.cs @@ -51,8 +51,19 @@ public static bool GetRandomAppearance(bool male, out int index, out AvatarSetti return false; } - S1Employees.EmployeeManager.Instance.GetRandomAppearance(male, out var i, out var avatarSettings); - if (avatarSettings == null) + S1Employees.EmployeeManager.Instance.GetRandomAppearance(male, out var i, out var appearanceObject); + var appearance = S1Employees.EmployeeManager.Instance.GetAppearance(male, i); + var avatarSettings = appearance == null + ? null + : global::S1API.Internal.Utils.ReflectionUtils.TryGetFieldOrProperty( + appearance, + "Settings") as +#if IL2CPPMELON + Il2CppScheduleOne.AvatarFramework.AvatarSettings; +#else + ScheduleOne.AvatarFramework.AvatarSettings; +#endif + if (appearanceObject == null || avatarSettings == null) { settings = null; index = -1; @@ -78,7 +89,21 @@ public class EmployeeAppearance /// /// Gets the avatar settings associated with this employee appearance. /// - public AvatarSettings Settings => new(S1EmployeeAppearance.Settings); + public AvatarSettings Settings + { + get + { + var settings = global::S1API.Internal.Utils.ReflectionUtils.TryGetFieldOrProperty( + S1EmployeeAppearance, + "Settings") as +#if IL2CPPMELON + Il2CppScheduleOne.AvatarFramework.AvatarSettings; +#else + ScheduleOne.AvatarFramework.AvatarSettings; +#endif + return settings == null ? AvatarSettings.Create() : new AvatarSettings(settings); + } + } /// /// Gets the mugshot sprite associated with this employee appearance. diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index 8a18da0e..59c22937 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -257,9 +257,9 @@ private static GameObject InstantiateTemplateInstance(System.Type npcType, NPC o } } catch { } - if (S1NPCs.NPCManager.InstanceExists && S1NPCs.NPCManager.Instance.NPCContainer != null) + if (S1NPCs.NPCManager.InstanceExists) { - Transform parent = S1NPCs.NPCManager.Instance.NPCContainer; + Transform parent = S1NPCs.NPCManager.Instance.transform; if (parent != null && parent.gameObject != null && parent.gameObject.activeInHierarchy) instance.transform.SetParent(parent, false); } @@ -604,15 +604,15 @@ private static void RepairNpcPrefabReferences(GameObject prefabRoot, S1NPCs.NPC if (movement != null) { SetGameMember(npc, "Movement", movement); - SetGameMember(movement, "npc", npc); + SetGameMember(movement, "_npc", npc); - movement.Agent = EnsureRootNavMeshAgent(prefabRoot); + SetGameMember(movement, "_agent", EnsureRootNavMeshAgent(prefabRoot)); var speedController = prefabRoot.GetComponent() ?? prefabRoot.GetComponentInChildren(true); if (speedController != null) { - movement.SpeedController = speedController; + SetGameMember(movement, "_speedController", speedController); SetGameMember(speedController, "Movement", movement); } } @@ -642,7 +642,10 @@ private static void NormalizeBaseEmployeeNavigation(GameObject prefabRoot, NpcRo if (movement != null) { movement.SetAgentType(S1NPCs.NPCMovement.EAgentType.Humanoid); - movement.DefaultObstacleAvoidanceType = UnityEngine.AI.ObstacleAvoidanceType.HighQualityObstacleAvoidance; + SetGameMember( + movement, + "_defaultObstacleAvoidanceType", + UnityEngine.AI.ObstacleAvoidanceType.HighQualityObstacleAvoidance); } } @@ -738,8 +741,14 @@ private static void RepairDealerPrefabReferences(GameObject prefabRoot, S1Econom controller = controllerObject.AddComponent(); if (source != null) { - controller.IntObj = source.IntObj; - controller.GenericDialogue = source.GenericDialogue; + SetGameMember( + controller, + "_interactable", + GetGameMember(source, "_interactable")); + SetGameMember( + controller, + "_genericConversation", + GetGameMember(source, "_genericConversation")); controller.DialogueEnabled = source.DialogueEnabled; controller.UseDialogueBehaviour = source.UseDialogueBehaviour; controller.Choices = source.Choices; @@ -887,8 +896,14 @@ private static void NormalizeBaseEmployeeDialogueComponents( if (civilianController == null || civilianController == employeeController) { civilianController = controllerObject.AddComponent(); - civilianController.IntObj = employeeController.IntObj; - civilianController.GenericDialogue = employeeController.GenericDialogue; + SetGameMember( + civilianController, + "_interactable", + GetGameMember(employeeController, "_interactable")); + SetGameMember( + civilianController, + "_genericConversation", + GetGameMember(employeeController, "_genericConversation")); civilianController.DialogueEnabled = employeeController.DialogueEnabled; civilianController.UseDialogueBehaviour = employeeController.UseDialogueBehaviour; // Customer and other runtime components rebuild their own role-specific dialogue state. @@ -2542,12 +2557,10 @@ private void EnsureMessageConversationInstance(ConversationCategoryList categori if (S1NPC.MSGConversation == null) { -#if IL2CPPMELON - S1NPC.CreateMessageConversation(); -#elif MONOMELON - MethodInfo createConvoMethod = AccessTools.Method(typeof(S1NPCs.NPC), "CreateMessageConversation"); + MethodInfo createConvoMethod = AccessTools.Method( + typeof(S1NPCs.NPC), + "CreateAndAssignDefaultMessageConversation"); createConvoMethod?.Invoke(S1NPC, null); -#endif if (S1NPC.MSGConversation == null) { Logger.Warning($"EnsureMessageConversationInstance: creation failed for '{GetSafeNpcId()}'."); @@ -4176,12 +4189,16 @@ private void AssignPersistentGuid(string? npcId) Guid guid = NPCPersistentIds.TryGetGuid(npcId, out Guid persistentGuid) ? persistentGuid : Guid.NewGuid(); - S1NPC.BakedGUID = guid.ToString(); +#if IL2CPPMELON + S1NPC.SetGUID(new Il2CppSystem.Guid(guid.ToString())); +#else + S1NPC.SetGUID(guid); +#endif } internal void RegisterPersistentGuidForContractLoad() { - if (!Guid.TryParse(S1NPC.BakedGUID, out Guid guid)) + if (!Guid.TryParse(S1NPC.GUID.ToString(), out Guid guid)) return; try @@ -4364,11 +4381,7 @@ private bool TryValidateNativeAwakeReferences(out string diagnostic) ? "Avatar" : "Avatar(active)"); } - else if (activeAvatar.HeadBone == null) - { - missing.Add("Avatar.HeadBone"); - } - else if (activeAvatar.HeadBone.GetComponentInChildren() == null) + else if (activeAvatar.GetComponentInChildren() == null) { missing.Add(nameof(S1VoiceOver.VOEmitter)); } diff --git a/S1API/Entities/NPCAppearance.cs b/S1API/Entities/NPCAppearance.cs index 49d50f5c..115c8ea1 100644 --- a/S1API/Entities/NPCAppearance.cs +++ b/S1API/Entities/NPCAppearance.cs @@ -63,10 +63,9 @@ internal NPCAppearance(NPC npc, S1AvatarFramework.Avatar? runtimeAvatar) if (_runtimeAvatar != null) { - if (_runtimeAvatar.CurrentSettings != null) - sourceSettings = _runtimeAvatar.CurrentSettings; - else - sourceSettings = global::S1API.Internal.Utils.ReflectionUtils.TryGetFieldOrProperty(_runtimeAvatar, "InitialAvatarSettings") as S1AvatarFramework.AvatarSettings; + sourceSettings = global::S1API.Internal.Utils.ReflectionUtils.TryGetFieldOrProperty( + _runtimeAvatar, + "InitialAvatarSettings") as S1AvatarFramework.AvatarSettings; } if (sourceSettings != null) @@ -154,6 +153,7 @@ private static IEnumerator ProcessMugshotQueue() private static IEnumerator ProcessMugshotQueueCore() { +#if false var generator = S1AvatarFramework.MugshotGenerator.Instance; var mugshotRig = generator != null ? generator.MugshotRig : null; var iconGenerator = generator != null ? generator.Generator : null; @@ -444,6 +444,25 @@ private static IEnumerator ProcessMugshotQueueCore() // Small delay between jobs to let the mugshot rig fully reset yield return new WaitForSeconds(0.1f); } +#else + while (true) + { + NPCAppearance? next; + lock (_mugshotQueueLock) + { + if (_mugshotQueue.Count == 0) + { + _isProcessingMugshots = false; + yield break; + } + + next = _mugshotQueue.Dequeue(); + } + + next.MarkMugshotCompleted(); + yield return null; + } +#endif } private void MarkMugshotCompleted() @@ -573,7 +592,9 @@ internal void ApplyToAvatar(S1AvatarFramework.Avatar? avatar) if (avatar == null) return; - avatar.LoadAvatarSettings(_customAvatarSettings); + global::S1API.Internal.Compatibility.AvatarCompatibility.ApplyLegacySettings( + avatar, + _customAvatarSettings); } #endregion diff --git a/S1API/Entities/NPCCustomer.cs b/S1API/Entities/NPCCustomer.cs index 55f5578a..182cd53f 100644 --- a/S1API/Entities/NPCCustomer.cs +++ b/S1API/Entities/NPCCustomer.cs @@ -1056,7 +1056,7 @@ public void RecommendDealer(NPCDealer dealer) dialogueLine = dialogueLine.Replace("", dealer.NPC.FullName); // Create dialogue container - var container = ScriptableObject.CreateInstance(); + var container = ScriptableObject.CreateInstance(); var nodeData = new S1Dialogue.DialogueNodeData { DialogueText = dialogueLine, @@ -1088,7 +1088,7 @@ public void RecommendDealer(NPCDealer dealer) } } - private System.Collections.IEnumerator WaitAndShowDialogue(S1Dialogue.DialogueContainer container, S1Dialogue.DialogueHandler handler) + private System.Collections.IEnumerator WaitAndShowDialogue(S1Dialogue.Conversation container, S1Dialogue.DialogueHandler handler) { yield return new WaitForSeconds(0.1f); if (handler != null && container != null) diff --git a/S1API/Entities/NPCDealer.cs b/S1API/Entities/NPCDealer.cs index e0213935..6dc32266 100644 --- a/S1API/Entities/NPCDealer.cs +++ b/S1API/Entities/NPCDealer.cs @@ -247,8 +247,10 @@ private void HideLockedEmptyConversation() bool uiCreated = Internal.Utils.ReflectionUtils.TryGetFieldOrProperty(conversation, "uiCreated") is bool created && created; - int messageCount = conversation.messageHistory?.Count ?? 0; - int messageChainCount = conversation.messageChainHistory?.Count ?? 0; + int messageCount = + (Internal.Utils.ReflectionUtils.TryGetFieldOrProperty(conversation, "_messageHistory") as System.Collections.ICollection)?.Count ?? 0; + int messageChainCount = + (Internal.Utils.ReflectionUtils.TryGetFieldOrProperty(conversation, "_messageChainHistory") as System.Collections.ICollection)?.Count ?? 0; int responseCount = conversation.currentResponses?.Count ?? 0; if (!ShouldHideLockedConversation( NPC.Relationship.IsUnlocked, diff --git a/S1API/Entities/NPCDialogue.cs b/S1API/Entities/NPCDialogue.cs index 9094dd82..023d7f1d 100644 --- a/S1API/Entities/NPCDialogue.cs +++ b/S1API/Entities/NPCDialogue.cs @@ -473,7 +473,7 @@ public void BuildAndRegisterContainer(string containerName, Action; + var list = dialogueContainersField?.GetValue(Handler) as List; #else var list = Handler.dialogueContainers; #endif @@ -509,13 +509,13 @@ public bool UseContainerOnInteract(string containerName) return false; #if MONOMELON - var list = dialogueContainersField?.GetValue(Handler) as List; + var list = dialogueContainersField?.GetValue(Handler) as List; #else var list = Handler.dialogueContainers; #endif if (list == null) return false; - S1Dialogue.DialogueContainer? container = null; + S1Dialogue.Conversation? container = null; for (int i = 0; i < list.Count; i++) { var item = list[i]; @@ -550,13 +550,13 @@ public bool UseContainerOnInteractOnce(string containerName) return false; #if MONOMELON - var list = dialogueContainersField?.GetValue(Handler) as List; + var list = dialogueContainersField?.GetValue(Handler) as List; #else var list = Handler.dialogueContainers; #endif if (list == null) return false; - S1Dialogue.DialogueContainer? container = null; + S1Dialogue.Conversation? container = null; for (int i = 0; i < list.Count; i++) { var item = list[i]; @@ -598,13 +598,13 @@ public bool JumpTo(string containerName, string entryNodeLabel, bool enableBehav if (Handler == null) return false; #if MONOMELON - var list = dialogueContainersField?.GetValue(Handler) as List; + var list = dialogueContainersField?.GetValue(Handler) as List; #else var list = Handler.dialogueContainers; #endif if (list == null) return false; - S1Dialogue.DialogueContainer? container = null; + S1Dialogue.Conversation? container = null; for (int i = 0; i < list.Count; i++) { var item = list[i]; @@ -712,7 +712,7 @@ private bool StartDialogueCompat(string containerName, bool enableBehaviour = tr return true; } - private bool StartDialogueCompat(S1Dialogue.DialogueContainer container, bool enableBehaviour = true, string entryNodeLabel = "ENTRY") + private bool StartDialogueCompat(S1Dialogue.Conversation container, bool enableBehaviour = true, string entryNodeLabel = "ENTRY") { if (Handler == null || container == null) return false; diff --git a/S1API/Entities/NPCMessaging.cs b/S1API/Entities/NPCMessaging.cs index bf66c0a2..61091cfb 100644 --- a/S1API/Entities/NPCMessaging.cs +++ b/S1API/Entities/NPCMessaging.cs @@ -49,7 +49,7 @@ internal NPCMessaging(NPC npc) /// Gets whether the NPC's conversation is currently open in the phone's Messages app. /// public bool IsOpen => - _npc.S1NPC?.MSGConversation?.isOpen ?? false; + _npc.S1NPC?.MSGConversation?.IsOpen ?? false; /// /// Occurs when the player opens the NPC's conversation in the phone's Messages app. diff --git a/S1API/Entities/NPCMovement.cs b/S1API/Entities/NPCMovement.cs index c7965bf2..7891315f 100644 --- a/S1API/Entities/NPCMovement.cs +++ b/S1API/Entities/NPCMovement.cs @@ -129,11 +129,15 @@ public void FacePoint(Vector3 position) /// public float SpeedMultiplier { - get => SpeedController?.SpeedMultiplier ?? 1f; + get => SpeedController != null && ReflectionUtils.TryGetFieldOrProperty( + SpeedController, + "_speedMultiplier") is float multiplier + ? multiplier + : 1f; set { if (SpeedController != null) - SpeedController.SpeedMultiplier = value; + SpeedController.SetSpeedMultiplier(value); } } @@ -155,8 +159,11 @@ public float SpeedMultiplier public void AddSpeedControl(string id, int priority, float speed) { if (SpeedController == null) return; - var control = new S1NPCs.NPCSpeedController.SpeedControl(id, priority, speed); - SpeedController.AddSpeedControl(control); + SpeedController.AddSpeedControl( + id, + speed, + priority, + S1NPCs.SpeedControl.EType.Normalized); } /// diff --git a/S1API/Entities/Player.cs b/S1API/Entities/Player.cs index 72b8f176..de269c74 100644 --- a/S1API/Entities/Player.cs +++ b/S1API/Entities/Player.cs @@ -1,9 +1,11 @@ #if (IL2CPPMELON) using S1PlayerScripts = Il2CppScheduleOne.PlayerScripts; using S1Health = Il2CppScheduleOne.PlayerScripts.Health; +using S1AvatarTools = Il2CppScheduleOne.Avatar.Tools; #else using S1PlayerScripts = ScheduleOne.PlayerScripts; using S1Health = ScheduleOne.PlayerScripts.Health; +using S1AvatarTools = ScheduleOne.Avatar.Tools; #endif using System; @@ -274,7 +276,7 @@ public PropertyWrapper? LastVisitedProperty { /// The player's current avatar settings (appearance configuration). /// [Obsolete("Use GetCurrentBasicAvatarSettings(). This compatibility property may be removed in a future S1API version.")] - public object CurrentAvatarSettings => S1Player.CurrentBasicAppearance; + public object CurrentAvatarSettings => S1Player.CurrentAppearance; /// /// Retrieves the player's current avatar settings as an S1API wrapper. @@ -282,8 +284,7 @@ public PropertyWrapper? LastVisitedProperty { /// public BasicAvatarSettings? GetCurrentBasicAvatarSettings() { - var settings = S1Player.CurrentBasicAppearance; - return settings == null ? null : new BasicAvatarSettings(settings); + return null; } /// @@ -297,7 +298,7 @@ public void InsertClothing(ClothingItemInstance clothing) throw new ArgumentNullException(nameof(clothing)); } - S1Player.Clothing.InsertClothing(clothing.S1ClothingInstance); + S1Player.Clothing.InsertClothingItem(clothing.S1ClothingInstance); } /// @@ -322,7 +323,9 @@ public ClothingItemInstance EquipClothing(ClothingItemDefinition definition) /// Refreshes the player's avatar from the current clothing slots. /// public void RefreshClothingAppearance() => - S1Player.Clothing.RefreshAppearance(); + AccessTools.Method(S1Player.Clothing.GetType(), "RefreshAppearance")?.Invoke( + S1Player.Clothing, + Array.Empty()); /// /// Sends updated appearance settings through the game's native appearance flow. @@ -334,7 +337,9 @@ public void SendAppearance(BasicAvatarSettings settings) throw new ArgumentNullException(nameof(settings)); } - S1Player.SetAppearance_Server(settings.S1BasicAvatarSettings); + S1Player.SetAppearance_Server( + S1AvatarTools.BasicAvatarSettingsConverter.ConvertToPlayerAppearance( + settings.S1BasicAvatarSettings)); } /// diff --git a/S1API/GameTime/TimeManager.cs b/S1API/GameTime/TimeManager.cs index 201c466f..07abd71b 100644 --- a/S1API/GameTime/TimeManager.cs +++ b/S1API/GameTime/TimeManager.cs @@ -48,6 +48,7 @@ public static class TimeManager private static int _lastSleepSkippedMinutes; private static S1GameTime.TimeManager? _boundInstance; + private static S1GameTime.SleepController? _boundSleepController; private static readonly Action HourPassHandler = () => OnHourPass(); private static readonly Action DayPassHandler = () => OnDayPass(); @@ -69,11 +70,14 @@ static TimeManager() internal static void TryBindToCurrentInstance() { var instance = S1GameTime.TimeManager.Instance; - if (instance == null || ReferenceEquals(instance, _boundInstance)) + var sleepController = S1GameTime.SleepController.Instance; + if (instance == null || + (ReferenceEquals(instance, _boundInstance) && ReferenceEquals(sleepController, _boundSleepController))) return; - UnbindFromInstance(_boundInstance); + UnbindFromInstances(_boundInstance, _boundSleepController); _boundInstance = instance; + _boundSleepController = sleepController; instance.onHourPass += HourPassHandler; instance.onDayPass += DayPassHandler; @@ -81,9 +85,12 @@ internal static void TryBindToCurrentInstance() AddToActionList(instance.onTick, TickHandler); - instance.onSleepStart += SleepStartHandler; instance.onTimeSkip += TimeSkipHandler; - instance.onSleepEnd += SleepEndHandler; + if (sleepController != null) + { + sleepController.OnSleepStart += SleepStartHandler; + sleepController.OnSleepEnd += SleepEndHandler; + } } /// @@ -91,25 +98,30 @@ internal static void TryBindToCurrentInstance() /// internal static void ResetBindings() { - UnbindFromInstance(_boundInstance); + UnbindFromInstances(_boundInstance, _boundSleepController); _boundInstance = null; + _boundSleepController = null; _lastSleepSkippedMinutes = 0; } - private static void UnbindFromInstance(S1GameTime.TimeManager? instance) + private static void UnbindFromInstances( + S1GameTime.TimeManager? instance, + S1GameTime.SleepController? sleepController) { - if (instance == null) - return; + if (instance != null) + { + instance.onHourPass -= HourPassHandler; + instance.onDayPass -= DayPassHandler; + instance.onWeekPass -= WeekPassHandler; + RemoveFromActionList(instance.onTick, TickHandler); + instance.onTimeSkip -= TimeSkipHandler; + } - instance.onHourPass -= HourPassHandler; - instance.onDayPass -= DayPassHandler; - instance.onWeekPass -= WeekPassHandler; - - RemoveFromActionList(instance.onTick, TickHandler); - - instance.onSleepStart -= SleepStartHandler; - instance.onTimeSkip -= TimeSkipHandler; - instance.onSleepEnd -= SleepEndHandler; + if (sleepController != null) + { + sleepController.OnSleepStart -= SleepStartHandler; + sleepController.OnSleepEnd -= SleepEndHandler; + } } private static void AddToActionList(object? actionList, Action handler) @@ -174,7 +186,7 @@ private static void RemoveFromActionList(object? actionList, Action handler) /// /// Whether the player is currently sleeping. /// - public static bool SleepInProgress => S1GameTime.TimeManager.Instance.IsSleepInProgress; + public static bool SleepInProgress => S1GameTime.SleepController.Instance?.IsSleepInProgress ?? false; /// /// The current normalized time of day (0.0 = start, 1.0 = end). @@ -190,7 +202,7 @@ private static void RemoveFromActionList(object? actionList, Action handler) /// Sets the current time manually and synchronizes across the network. /// This can only be called by the host. /// - public static void SetTime(int time24h) => S1GameTime.TimeManager.Instance.SetTimeAndSync(time24h); + public static void SetTime(int time24h) => S1GameTime.TimeManager.Instance.SetTime_Server(time24h); /// /// Gets the current time formatted in 12-hour AM/PM format. diff --git a/S1API/Internal/Compatibility/AvatarCompatibility.cs b/S1API/Internal/Compatibility/AvatarCompatibility.cs new file mode 100644 index 00000000..89fc226c --- /dev/null +++ b/S1API/Internal/Compatibility/AvatarCompatibility.cs @@ -0,0 +1,27 @@ +#if IL2CPPMELON +using S1AvatarFramework = Il2CppScheduleOne.AvatarFramework; +#elif MONOMELON +using S1AvatarFramework = ScheduleOne.AvatarFramework; +#endif + +namespace S1API.Internal.Compatibility +{ + internal static class AvatarCompatibility + { + internal static void ApplyLegacySettings( + S1AvatarFramework.Avatar avatar, + S1AvatarFramework.AvatarSettings settings) + { + if (avatar?.Appearance == null || settings == null) + return; + + var nakedAppearance = settings.EquivalentNakedAppearance; + if (nakedAppearance != null) + avatar.Appearance.ApplyNakedAppearance(nakedAppearance.Appearance); + + var outfit = settings.EquivalentOutfit; + if (outfit != null) + avatar.Appearance.ApplyOutfit(outfit); + } + } +} diff --git a/S1API/Internal/Cutscenes/NativeCutsceneAdapter.cs b/S1API/Internal/Cutscenes/NativeCutsceneAdapter.cs index 5546f553..089b2424 100644 --- a/S1API/Internal/Cutscenes/NativeCutsceneAdapter.cs +++ b/S1API/Internal/Cutscenes/NativeCutsceneAdapter.cs @@ -1,9 +1,11 @@ #if IL2CPPMELON using S1Cutscene = Il2CppScheduleOne.Cutscenes.Cutscene; +using S1CutsceneCamera = Il2CppScheduleOne.Cutscenes.CutsceneCamera; using S1PlayerCamera = Il2CppScheduleOne.PlayerScripts.PlayerCamera; using S1PlayerSingleton = Il2CppScheduleOne.DevUtilities.PlayerSingleton; #elif MONOMELON using S1Cutscene = ScheduleOne.Cutscenes.Cutscene; +using S1CutsceneCamera = ScheduleOne.Cutscenes.CutsceneCamera; using S1PlayerCamera = ScheduleOne.PlayerScripts.PlayerCamera; using S1PlayerSingleton = ScheduleOne.DevUtilities.PlayerSingleton; #endif @@ -41,12 +43,13 @@ internal bool TryBegin( _cutscene = _host.AddComponent(); _cutscene.Name = name; - _cutscene.CameraControl = _cameraControlObject.transform; - _cutscene.OverrideFOV = fov.HasValue; + var nativeCamera = _cameraControlObject.AddComponent(); + Utils.ReflectionUtils.TrySetFieldOrProperty(nativeCamera, "_controlFoV", fov.HasValue); if (fov.HasValue) { - _cutscene.CameraFOV = fov.Value; + Utils.ReflectionUtils.TrySetFieldOrProperty(nativeCamera, "_fov", fov.Value); } + _cutscene.SetActiveCameraControl(nativeCamera); _nativeStateName = $"Cutscene ({name})"; _cutscene.Play(); diff --git a/S1API/Internal/Entities/ImpostorTextureResolver.cs b/S1API/Internal/Entities/ImpostorTextureResolver.cs index c394f649..02a05004 100644 --- a/S1API/Internal/Entities/ImpostorTextureResolver.cs +++ b/S1API/Internal/Entities/ImpostorTextureResolver.cs @@ -298,11 +298,11 @@ private static void AddDefinitionsFromNpcRegistry( continue; } - S1AvatarFramework.AvatarSettings? settings = - npc.Avatar?.CurrentSettings ?? - (npc.Avatar != null - ? ReflectionUtils.TryGetFieldOrProperty(npc.Avatar, "InitialAvatarSettings") as S1AvatarFramework.AvatarSettings - : null); + S1AvatarFramework.AvatarSettings? settings = npc.Avatar != null + ? ReflectionUtils.TryGetFieldOrProperty( + npc.Avatar, + "InitialAvatarSettings") as S1AvatarFramework.AvatarSettings + : null; if (settings == null || settings.ImpostorTexture == null) { continue; diff --git a/S1API/Internal/Entities/NPCDataAccess.cs b/S1API/Internal/Entities/NPCDataAccess.cs index 120cf735..f531665a 100644 --- a/S1API/Internal/Entities/NPCDataAccess.cs +++ b/S1API/Internal/Entities/NPCDataAccess.cs @@ -207,7 +207,8 @@ internal static bool ApplyAppearance(S1NPCs.NPC npc, S1AvatarFramework.AvatarSet if (appearance == null) return false; - appearance.AvatarSettings = settings; + appearance.DefaultAppearance = settings.EquivalentNakedAppearance; + appearance.DefaultOutfit = settings.EquivalentOutfit; return true; } @@ -344,22 +345,16 @@ private static bool ApplyToData(S1NPCs.NPC? npc, Action internal static S1NPCFramework.BaseNPCDataObject? GetDataObject(S1NPCs.NPC npc) { -#if IL2CPPMELON - return npc._npcData; -#else - return NpcDataObjectField.GetValue(npc) as S1NPCFramework.BaseNPCDataObject; -#endif + return ReflectionUtils.TryGetFieldOrProperty( + npc, + "_defaultNPCData") as S1NPCFramework.BaseNPCDataObject; } private static void SetDataObject( S1NPCs.NPC npc, S1NPCFramework.BaseNPCDataObject dataObject) { -#if IL2CPPMELON - npc._npcData = dataObject; -#else - NpcDataObjectField.SetValue(npc, dataObject); -#endif + ReflectionUtils.TrySetFieldOrProperty(npc, "_defaultNPCData", dataObject); } private static void SetCurrentData(S1NPCs.NPC npc, S1NPCFramework.NPCData data) @@ -418,7 +413,7 @@ private static void EnsureMovementDefaults(S1NPCFramework.NPCData data) .FirstOrDefault(value => value != null); movement.WalkSpeed = donor?.WalkSpeed ?? 1.2f; - movement.SprintSpeed = donor?.SprintSpeed ?? movement.SprintSpeed; + movement.MaxSpeed = donor?.MaxSpeed ?? movement.MaxSpeed; } private static void EnsureDialogueDatabase( @@ -444,7 +439,7 @@ private static void EnsureDialogueDatabase( S1Dialogue.DialogueManager manager = S1DevUtilities.Singleton.Instance; if (manager != null) - data.Dialogue.DialogueDatabase = manager.DefaultDatabase; + data.Dialogue.DialogueDatabase = manager.DefaultDialogueDatabase; } if (data.Dialogue.DialogueDatabase == null) @@ -456,7 +451,7 @@ private static void EnsureDialogueDatabase( } if (data.Dialogue.DialogueDatabase == null) - throw new InvalidOperationException("No 0.4.6 dialogue database is loaded for the custom NPC."); + throw new InvalidOperationException("No dialogue database is loaded for the custom NPC."); if (sourceIsEmployee) { @@ -654,12 +649,12 @@ internal static bool HasCompleteDealerDialogueSet( internal const string DealerCollectCashDialogueName = "Dealer_CollectCash"; internal const string DealerAssignCustomersDialogueName = "Dealer_AssignCustomers"; - private static S1Dialogue.DialogueContainer? _fallbackRecruitDialogue; - private static S1Dialogue.DialogueContainer? _fallbackCollectCashDialogue; - private static S1Dialogue.DialogueContainer? _fallbackAssignCustomersDialogue; - private static S1Dialogue.DialogueContainer? _cachedRecruitDialogue; - private static S1Dialogue.DialogueContainer? _cachedCollectCashDialogue; - private static S1Dialogue.DialogueContainer? _cachedAssignCustomersDialogue; + private static S1Dialogue.Conversation? _fallbackRecruitDialogue; + private static S1Dialogue.Conversation? _fallbackCollectCashDialogue; + private static S1Dialogue.Conversation? _fallbackAssignCustomersDialogue; + private static S1Dialogue.Conversation? _cachedRecruitDialogue; + private static S1Dialogue.Conversation? _cachedCollectCashDialogue; + private static S1Dialogue.Conversation? _cachedAssignCustomersDialogue; private static bool PopulateDealerDialogueDefaults(S1NPCFramework.DealerNPCData dealerData) { @@ -713,8 +708,8 @@ private static bool PopulateDealerDialogueDefaults(S1NPCFramework.DealerNPCData // 0.4.6 no longer guarantees that native DealerNPCDataObject assets are surfaced by // FindObjectsOfTypeAll. Their referenced DialogueContainer assets are still loaded for // base-game dealers, so resolve the same three vanilla assets by stable asset name. - S1Dialogue.DialogueContainer[] dialogues = - Resources.FindObjectsOfTypeAll(); + S1Dialogue.Conversation[] dialogues = + Resources.FindObjectsOfTypeAll(); if (dealerData.RecruitDialogue == null) dealerData.RecruitDialogue = FindDialogueContainer(dialogues, DealerRecruitDialogueName); @@ -838,14 +833,14 @@ private static bool TryCopyDealerDialogueDefaults( target.AssignCustomersDialogue != null); } - private static S1Dialogue.DialogueContainer? FindDialogueContainer( - IEnumerable dialogues, + private static S1Dialogue.Conversation? FindDialogueContainer( + IEnumerable dialogues, string name) => dialogues.FirstOrDefault(dialogue => dialogue != null && string.Equals(dialogue.name, name, StringComparison.Ordinal)); - private static S1Dialogue.DialogueContainer GetFallbackRecruitDialogue() + private static S1Dialogue.Conversation GetFallbackRecruitDialogue() { if (_fallbackRecruitDialogue != null) return _fallbackRecruitDialogue; @@ -855,7 +850,7 @@ private static S1Dialogue.DialogueContainer GetFallbackRecruitDialogue() const string exitGuid = "1575094b-13a3-4d54-9c06-136b571f8c51"; const string acceptedGuid = "dbffb23a-034f-4d0f-9815-36cc247335b6"; - S1Dialogue.DialogueContainer dialogue = CreateDialogueContainer( + S1Dialogue.Conversation dialogue = CreateDialogueContainer( DealerRecruitDialogueName); AddDialogueNode( dialogue, @@ -882,12 +877,12 @@ private static S1Dialogue.DialogueContainer GetFallbackRecruitDialogue() return dialogue; } - private static S1Dialogue.DialogueContainer GetFallbackCollectCashDialogue() + private static S1Dialogue.Conversation GetFallbackCollectCashDialogue() { if (_fallbackCollectCashDialogue != null) return _fallbackCollectCashDialogue; - S1Dialogue.DialogueContainer dialogue = CreateDialogueContainer( + S1Dialogue.Conversation dialogue = CreateDialogueContainer( DealerCollectCashDialogueName); AddDialogueNode( dialogue, @@ -902,12 +897,12 @@ private static S1Dialogue.DialogueContainer GetFallbackCollectCashDialogue() return dialogue; } - private static S1Dialogue.DialogueContainer GetFallbackAssignCustomersDialogue() + private static S1Dialogue.Conversation GetFallbackAssignCustomersDialogue() { if (_fallbackAssignCustomersDialogue != null) return _fallbackAssignCustomersDialogue; - S1Dialogue.DialogueContainer dialogue = CreateDialogueContainer( + S1Dialogue.Conversation dialogue = CreateDialogueContainer( DealerAssignCustomersDialogueName); AddDialogueNode( dialogue, @@ -922,10 +917,10 @@ private static S1Dialogue.DialogueContainer GetFallbackAssignCustomersDialogue() return dialogue; } - private static S1Dialogue.DialogueContainer CreateDialogueContainer(string name) + private static S1Dialogue.Conversation CreateDialogueContainer(string name) { - S1Dialogue.DialogueContainer dialogue = - ScriptableObject.CreateInstance(); + S1Dialogue.Conversation dialogue = + ScriptableObject.CreateInstance(); if (dialogue == null) throw new InvalidOperationException($"Failed to create dealer dialogue '{name}'."); @@ -983,7 +978,7 @@ private static S1Dialogue.DialogueChoiceData[] CreateDialogueChoices( #endif private static void AddDialogueNode( - S1Dialogue.DialogueContainer dialogue, + S1Dialogue.Conversation dialogue, S1Dialogue.DialogueNodeData node) { if (dialogue.DialogueNodeData == null) @@ -1000,7 +995,7 @@ private static void AddDialogueNode( } private static void AddNodeLink( - S1Dialogue.DialogueContainer dialogue, + S1Dialogue.Conversation dialogue, string baseNodeGuid, string baseChoiceGuid, string targetNodeGuid) diff --git a/S1API/Internal/Entities/NPCPrefabIdentity.cs b/S1API/Internal/Entities/NPCPrefabIdentity.cs index 62b8d861..52b1a1e5 100644 --- a/S1API/Internal/Entities/NPCPrefabIdentity.cs +++ b/S1API/Internal/Entities/NPCPrefabIdentity.cs @@ -658,7 +658,7 @@ internal void ApplyTo(S1NPCs.NPC npc) if (avatar != null && AppearanceDefaults != null) { EnsureAppearanceImpostorTexture(npc.ID ?? PrefabName ?? gameObject.name); - avatar.LoadAvatarSettings(AppearanceDefaults); + Compatibility.AvatarCompatibility.ApplyLegacySettings(avatar, AppearanceDefaults); } } catch { } @@ -980,7 +980,7 @@ internal bool ApplyAppearanceTo(S1NPCs.NPC npc, S1AvatarFramework.Avatar avatar) EnsureAppearanceImpostorTexture(npc.ID ?? PrefabName ?? gameObject.name); NPCDataAccess.ApplyAppearance(npc, AppearanceDefaults); - avatar.LoadAvatarSettings(AppearanceDefaults); + Compatibility.AvatarCompatibility.ApplyLegacySettings(avatar, AppearanceDefaults); return true; } diff --git a/S1API/Internal/Lifecycle/TimeManagerShim.cs b/S1API/Internal/Lifecycle/TimeManagerShim.cs index 7828895c..c5f11cbe 100644 --- a/S1API/Internal/Lifecycle/TimeManagerShim.cs +++ b/S1API/Internal/Lifecycle/TimeManagerShim.cs @@ -51,7 +51,8 @@ internal void AddDelegatesToReal() try { var real = S1GameTime.TimeManager.Instance; - if (real == null) + var sleepController = S1GameTime.SleepController.Instance; + if (real == null || sleepController == null) { Debug.LogWarning( "TimeManagerShim: Real TimeManager instance not available yet; cannot migrate delegates."); @@ -66,8 +67,7 @@ internal void AddDelegatesToReal() if (!_addedSleepStart.Contains(sleepStart)) { - real.onSleepStart = Il2CppSystem.Delegate.Combine(real.onSleepStart, sleepStart) - .Cast(); + sleepController.add_OnSleepStart(sleepStart); _addedSleepStart.Add(sleepStart); } @@ -78,7 +78,7 @@ internal void AddDelegatesToReal() _addedHourPass.Add(hourPass); } #else - real.onSleepStart = (Action)Delegate.Combine(real.onSleepStart, onSleepStart); + sleepController.OnSleepStart += onSleepStart; real.onHourPass = (Action)Delegate.Combine(real.onHourPass, onHourPass); #endif } @@ -92,7 +92,8 @@ internal void DeleteDelegatesFromReal() try { var real = S1GameTime.TimeManager.Instance; - if (real == null) + var sleepController = S1GameTime.SleepController.Instance; + if (real == null || sleepController == null) { Debug.LogWarning( "TimeManagerShim: Real TimeManager instance no longer available; cannot delete delegates."); @@ -101,14 +102,14 @@ internal void DeleteDelegatesFromReal() #if IL2CPPMELON foreach (var d in _addedSleepStart) - real.onSleepStart = RemoveAll(real.onSleepStart, d); + sleepController.remove_OnSleepStart(d); _addedSleepStart.Clear(); foreach (var d in _addedHourPass) real.onHourPass = RemoveAll(real.onHourPass, d); _addedHourPass.Clear(); #else - real.onSleepStart = (Action)Delegate.RemoveAll(real.onSleepStart, onSleepStart); + sleepController.OnSleepStart -= onSleepStart; real.onHourPass = (Action)Delegate.RemoveAll(real.onHourPass, onHourPass); #endif } diff --git a/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs b/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs index c7a0175d..ba01edb4 100644 --- a/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs +++ b/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs @@ -43,9 +43,7 @@ private NPCWorkbenchPreview( _camera = camera; _texture = texture; _renderLayer = renderLayer; - _template = avatar.CurrentSettings != null - ? Object.Instantiate(avatar.CurrentSettings) - : null; + _template = null; UpdateCamera(); } @@ -54,8 +52,11 @@ private NPCWorkbenchPreview( internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string failure) { preview = null; - failure = string.Empty; + failure = "NPC avatar previews are unavailable with the Schedule I 0.4.7 avatar pipeline."; + return false; +#if false + failure = string.Empty; var generator = S1AvatarFramework.MugshotGenerator.Instance; var source = generator != null ? generator.MugshotRig : null; if (source == null) @@ -85,8 +86,6 @@ internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string fail throw new InvalidOperationException("The cloned native preview rig has no Avatar component."); avatar.SetVisible(true); - if (avatar.Animation != null) - avatar.Animation.AllowCulling = false; foreach (var renderer in avatarObject.GetComponentsInChildren(true)) renderer.updateWhenOffscreen = true; @@ -143,6 +142,7 @@ internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string fail Object.Destroy(root); return false; } +#endif } internal void ScheduleApply(NPCWorkbenchDraft draft) @@ -207,11 +207,8 @@ private void Apply(NPCWorkbenchDraft draft) var settings = NPCWorkbenchRuntimeAdapter.CreateSettings(draft, _template); try { - _avatar.LoadAvatarSettings(settings); + Compatibility.AvatarCompatibility.ApplyLegacySettings(_avatar, settings); _avatar.SetVisible(true); - _avatar.Impostor.DisableImpostor(); - if (_avatar.Animation != null) - _avatar.Animation.AllowCulling = false; RefreshRenderers(); _rendererRefreshFrames = 2; diff --git a/S1API/Internal/NPCWorkbench/NPCWorkbenchRuntimeAdapter.cs b/S1API/Internal/NPCWorkbench/NPCWorkbenchRuntimeAdapter.cs index 6dba7e3c..a6e75c6b 100644 --- a/S1API/Internal/NPCWorkbench/NPCWorkbenchRuntimeAdapter.cs +++ b/S1API/Internal/NPCWorkbench/NPCWorkbenchRuntimeAdapter.cs @@ -92,7 +92,7 @@ private static NPCWorkbenchDraft ImportNative(string id) throw new InvalidOperationException($"Native NPC '{id}' is not available in the current scene."); var avatar = npc.Avatar ?? npc.gameObject.GetComponentInChildren(true); - var sourceSettings = avatar?.CurrentSettings; + S1AvatarFramework.AvatarSettings? sourceSettings = null; if (sourceSettings == null && avatar != null) { sourceSettings = ReflectionUtils.TryGetFieldOrProperty( diff --git a/S1API/Internal/Patches/AvatarAccessoryPatches.cs b/S1API/Internal/Patches/AvatarAccessoryPatches.cs index 0d91fc04..8cb68b30 100644 --- a/S1API/Internal/Patches/AvatarAccessoryPatches.cs +++ b/S1API/Internal/Patches/AvatarAccessoryPatches.cs @@ -31,6 +31,9 @@ internal static class AvatarAccessoryPatches [HarmonyPatch] internal static class AvatarApplyAccessorySettingsPatch { + [HarmonyPrepare] + private static bool Prepare() => TargetMethod() != null; + static MethodBase? TargetMethod() { return typeof(S1AvatarFramework.Avatar).GetMethod("ApplyAccessorySettings", diff --git a/S1API/Internal/Patches/CustomProductManifestPatches.cs b/S1API/Internal/Patches/CustomProductManifestPatches.cs index 15ee69ad..7027b346 100644 --- a/S1API/Internal/Patches/CustomProductManifestPatches.cs +++ b/S1API/Internal/Patches/CustomProductManifestPatches.cs @@ -8,6 +8,7 @@ using S1Player = ScheduleOne.PlayerScripts.Player; #endif +using System; using System.Reflection; using HarmonyLib; using S1API.Internal.Products; @@ -52,29 +53,55 @@ private static void UpdatePostfix() [HarmonyPatch] internal static class CustomProductManifestPlayerPatches { - [HarmonyPatch(typeof(S1Player), nameof(S1Player.RequestPlayerData))] - [HarmonyPrefix] - private static bool RequestPlayerDataPrefix(S1Player __instance, string playerCode) + [HarmonyPatch] + private static class RequestPlayerDataPatch { - return CustomProductManifestRuntime.AuthorizeClientPlayerDataRequest( - () => __instance.RequestPlayerData(playerCode)); + private static MethodBase TargetMethod() => + AccessTools.DeclaredMethod( + typeof(S1Player), + "RequestPlayerData_Server") ?? + throw new MissingMethodException( + typeof(S1Player).FullName, + "RequestPlayerData_Server"); + + private static bool Prefix( + S1Player __instance, + string playerCode, + bool isHost, + MethodBase __originalMethod) + { + return CustomProductManifestRuntime.AuthorizeClientPlayerDataRequest( + () => __originalMethod.Invoke( + __instance, + new object[] { playerCode, isHost })); + } } - [HarmonyPatch(typeof(S1Player), nameof(S1Player.ReceivePlayerData))] - [HarmonyPrefix] - private static bool ReceivePlayerDataPrefix( - object __instance, - object[] __args, - MethodBase __originalMethod) + [HarmonyPatch] + private static class SetPlayerDataPatch { - if (__args.Length == 0 || !(__args[0] is S1Connection connection)) - return true; + private static MethodBase TargetMethod() => + AccessTools.DeclaredMethod( + typeof(S1Player), + "SetPlayerData_Client") ?? + throw new MissingMethodException( + typeof(S1Player).FullName, + "SetPlayerData_Client"); + + private static bool Prefix( + object __instance, + object[] __args, + MethodBase __originalMethod) + { + if (__args.Length == 0 || !(__args[0] is S1Connection connection)) + return true; - return CustomProductManifestRuntime.AuthorizeHostPlayerData( - __instance, - connection, - __args, - __originalMethod); + return CustomProductManifestRuntime.AuthorizeHostPlayerData( + __instance, + connection, + __args, + __originalMethod); + } } } } diff --git a/S1API/Internal/Patches/NPCPatches.cs b/S1API/Internal/Patches/NPCPatches.cs index 01d99063..3c83778b 100644 --- a/S1API/Internal/Patches/NPCPatches.cs +++ b/S1API/Internal/Patches/NPCPatches.cs @@ -289,7 +289,11 @@ private static void Prefix(S1NPCs.NPCMovement __instance, ref Vector3 pos) return; } - var agent = __instance?.Agent; + var agent = __instance == null + ? null + : ReflectionUtils.TryGetFieldOrProperty( + __instance, + "_agent") as UnityEngine.AI.NavMeshAgent; if (agent != null && NavMeshUtility.SamplePosition( desiredDestination, out var hit, @@ -2423,14 +2427,7 @@ private static void NPCManager_GetNPC_Postfix(string id, ref S1NPCs.NPC __result [HarmonyPrefix] private static bool NPCMovement_SetGravityMultiplier_Prefix(S1NPCs.NPCMovement __instance, float multiplier) { -#if !IL2CPPMELON - var ragdollForceComponentsField = typeof(S1NPCs.NPCMovement).GetField("ragdollForceComponents", - BindingFlags.NonPublic | BindingFlags.Instance); - var ragdollForceComponents = ragdollForceComponentsField?.GetValue(__instance) as List; -#else - var ragdollForceComponents = __instance.ragdollForceComponents; -#endif - return ragdollForceComponents == null || ragdollForceComponents.ToArray().All(comp => comp != null); + return true; } /// @@ -2449,7 +2446,7 @@ private static bool NPCHealth_Awake_Prefix(S1NPCs.NPCHealth __instance) var npc = __instance.GetComponent(); #if (!IL2CPPMELON) var npcField = typeof(S1NPCs.NPCHealth) - .GetField("npc", BindingFlags.NonPublic | BindingFlags.Instance); + .GetField("_npc", BindingFlags.NonPublic | BindingFlags.Instance); if (npcField != null) npcField.SetValue(__instance, npc); @@ -2476,7 +2473,7 @@ private static bool NPCHealth_Awake_Prefix(S1NPCs.NPCHealth __instance) (Action)Delegate.Combine(TimeManagerShim.Instance.onHourPass, hourPassDelegate); } #else - __instance.npc = npc; + __instance._npc = npc; TimeManagerShim.Instance.onSleepStart = (Action)Delegate.Combine(TimeManagerShim.Instance.onSleepStart, new Action(__instance.SleepStart)); diff --git a/S1API/Internal/Rendering/PresentationWorkbenchRuntime.cs b/S1API/Internal/Rendering/PresentationWorkbenchRuntime.cs index 0dfe86ef..3e74e23d 100644 --- a/S1API/Internal/Rendering/PresentationWorkbenchRuntime.cs +++ b/S1API/Internal/Rendering/PresentationWorkbenchRuntime.cs @@ -414,6 +414,7 @@ private static void AlignAvatarEquippable( private object CreateAvatarStage() { +#if false var generator = S1AvatarFramework.MugshotGenerator.Instance; var source = generator != null ? generator.MugshotRig : null; if (source == null) @@ -462,6 +463,10 @@ private object CreateAvatarStage() RefreshAvatarRenderers(); return avatar; +#else + throw new InvalidOperationException( + "Avatar presentation previews are unavailable with the Schedule I 0.4.7 avatar pipeline."); +#endif } private void TickAvatarStage() @@ -474,11 +479,8 @@ private void TickAvatarStage() if (_avatarSettleFrames-- > 0) return; - _avatarRig.LoadAvatarSettings(_avatarSettings!); + Compatibility.AvatarCompatibility.ApplyLegacySettings(_avatarRig, _avatarSettings!); _avatarRig.SetVisible(true); - _avatarRig.Impostor.DisableImpostor(); - if (_avatarRig.Animation != null) - _avatarRig.Animation.AllowCulling = false; _avatarRendererRefreshFrames = 2; _avatarAppearanceApplied = true; _avatarSettleFrames = 2; diff --git a/S1API/Items/Clothing/ClothingItemDefinition.cs b/S1API/Items/Clothing/ClothingItemDefinition.cs index 3cce7a08..ca1f3266 100644 --- a/S1API/Items/Clothing/ClothingItemDefinition.cs +++ b/S1API/Items/Clothing/ClothingItemDefinition.cs @@ -1,8 +1,10 @@ #if (IL2CPPMELON) using S1Clothing = Il2CppScheduleOne.Clothing; +using S1CoreAvatar = Il2CppScheduleOne.Core.Avatar; using Il2CppCollections = Il2CppSystem.Collections.Generic; #elif MONOMELON using S1Clothing = ScheduleOne.Clothing; +using S1CoreAvatar = ScheduleOne.Core.Avatar; #endif using System; using System.Collections.Generic; @@ -75,8 +77,8 @@ public ClothingSlot Slot /// public ClothingApplicationType ApplicationType { - get => (ClothingApplicationType)S1ClothingDefinition.ApplicationType; - set => S1ClothingDefinition.ApplicationType = (S1Clothing.EClothingApplicationType)value; + get => ClothingApplicationType.Accessory; + set { } } /// @@ -84,8 +86,9 @@ public ClothingApplicationType ApplicationType /// public string ClothingAssetPath { - get => S1ClothingDefinition.ClothingAssetPath; - set => S1ClothingDefinition.ClothingAssetPath = value; + get => S1ClothingDefinition.ClothingAvatarObject?.name ?? string.Empty; + set => S1ClothingDefinition.ClothingAvatarObject = + UnityEngine.Resources.Load(value); } /// diff --git a/S1API/Items/Clothing/ClothingItemDefinitionBuilder.cs b/S1API/Items/Clothing/ClothingItemDefinitionBuilder.cs index 133dceaa..90d36f3a 100644 --- a/S1API/Items/Clothing/ClothingItemDefinitionBuilder.cs +++ b/S1API/Items/Clothing/ClothingItemDefinitionBuilder.cs @@ -4,6 +4,7 @@ using S1CoreItemFramework = Il2CppScheduleOne.Core.Items.Framework; using S1Registry = Il2CppScheduleOne.Registry; using S1UiItems = Il2CppScheduleOne.UI.Items; +using S1CoreAvatar = Il2CppScheduleOne.Core.Avatar; using Il2CppCollections = Il2CppSystem.Collections.Generic; #elif MONOMELON using S1Clothing = ScheduleOne.Clothing; @@ -11,6 +12,7 @@ using S1CoreItemFramework = ScheduleOne.Core.Items.Framework; using S1Registry = ScheduleOne.Registry; using S1UiItems = ScheduleOne.UI.Items; +using S1CoreAvatar = ScheduleOne.Core.Avatar; #endif using System.Collections.Generic; using S1API.Internal.Utils; @@ -45,8 +47,6 @@ internal ClothingItemDefinitionBuilder() // Clothing-specific defaults ClothingDefinition.Slot = S1Clothing.EClothingSlot.Head; - ClothingDefinition.ApplicationType = S1Clothing.EClothingApplicationType.Accessory; - ClothingDefinition.ClothingAssetPath = "Path/To/Clothing/Asset"; ClothingDefinition.Colorable = true; ClothingDefinition.DefaultColor = S1Clothing.EClothingColor.White; #if (IL2CPPMELON) @@ -73,8 +73,7 @@ protected override void CopyPropertiesFrom( var clothingSource = CrossType.As(source); ClothingDefinition.Slot = clothingSource.Slot; - ClothingDefinition.ApplicationType = clothingSource.ApplicationType; - ClothingDefinition.ClothingAssetPath = clothingSource.ClothingAssetPath; + ClothingDefinition.ClothingAvatarObject = clothingSource.ClothingAvatarObject; ClothingDefinition.Colorable = clothingSource.Colorable; ClothingDefinition.DefaultColor = clothingSource.DefaultColor; #if (IL2CPPMELON) @@ -111,7 +110,6 @@ public ClothingItemDefinitionBuilder WithSlot(Clothing.ClothingSlot slot) /// The builder instance for fluent chaining. public ClothingItemDefinitionBuilder WithApplicationType(Clothing.ClothingApplicationType applicationType) { - ClothingDefinition.ApplicationType = (S1Clothing.EClothingApplicationType)applicationType; return this; } @@ -122,7 +120,8 @@ public ClothingItemDefinitionBuilder WithApplicationType(Clothing.ClothingApplic /// The builder instance for fluent chaining. public ClothingItemDefinitionBuilder WithClothingAsset(string assetPath) { - ClothingDefinition.ClothingAssetPath = assetPath; + ClothingDefinition.ClothingAvatarObject = + Resources.Load(assetPath); return this; } diff --git a/S1API/Items/Clothing/ClothingMetadataCatalog.cs b/S1API/Items/Clothing/ClothingMetadataCatalog.cs index 8afd34dd..ab6995be 100644 --- a/S1API/Items/Clothing/ClothingMetadataCatalog.cs +++ b/S1API/Items/Clothing/ClothingMetadataCatalog.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; using UnityEngine; #if MONOMELON using S1Clothing = ScheduleOne.Clothing; @@ -173,14 +174,15 @@ public bool TryGetSlot( try { - if (!S1DevUtilities.Singleton.InstanceExists) + var configuration = Resources + .FindObjectsOfTypeAll() + .FirstOrDefault(); + if (configuration == null) return false; - S1Clothing.ClothingUtility.ClothingSlotData? nativeMetadata = - S1DevUtilities.Singleton - .Instance - .GetSlotData((S1Clothing.EClothingSlot)slot); - if (nativeMetadata == null) + S1Clothing.ClothingConfiguration.ClothingSlotData nativeMetadata = + configuration.GetSlotData((S1Clothing.EClothingSlot)slot); + if (nativeMetadata.IsNull()) return false; metadata = new ClothingSlotMetadata( @@ -203,14 +205,15 @@ public bool TryGetColor( try { - if (!S1DevUtilities.Singleton.InstanceExists) + var configuration = Resources + .FindObjectsOfTypeAll() + .FirstOrDefault(); + if (configuration == null) return false; - S1Clothing.ClothingUtility.ColorData? nativeMetadata = - S1DevUtilities.Singleton - .Instance - .GetColorData((S1Clothing.EClothingColor)color); - if (nativeMetadata == null) + S1Clothing.ClothingConfiguration.ColorData nativeMetadata = + configuration.GetColorData((S1Clothing.EClothingColor)color); + if (nativeMetadata.IsNull()) return false; metadata = new ClothingColorMetadata( diff --git a/S1API/Items/ClothingItemDefinition.cs b/S1API/Items/ClothingItemDefinition.cs index abf48999..8bc53314 100644 --- a/S1API/Items/ClothingItemDefinition.cs +++ b/S1API/Items/ClothingItemDefinition.cs @@ -1,8 +1,10 @@ #if (IL2CPPMELON) using S1Clothing = Il2CppScheduleOne.Clothing; +using S1CoreAvatar = Il2CppScheduleOne.Core.Avatar; using Il2CppCollections = Il2CppSystem.Collections.Generic; #elif MONOMELON using S1Clothing = ScheduleOne.Clothing; +using S1CoreAvatar = ScheduleOne.Core.Avatar; using Il2CppCollections = System.Collections.Generic; #endif @@ -78,8 +80,8 @@ public ClothingSlot Slot /// public ClothingApplicationType ApplicationType { - get => (ClothingApplicationType)S1ClothingDefinition.ApplicationType; - set => S1ClothingDefinition.ApplicationType = (S1Clothing.EClothingApplicationType)value; + get => ClothingApplicationType.Accessory; + set { } } /// @@ -87,8 +89,9 @@ public ClothingApplicationType ApplicationType /// public string ClothingAssetPath { - get => S1ClothingDefinition.ClothingAssetPath; - set => S1ClothingDefinition.ClothingAssetPath = value; + get => S1ClothingDefinition.ClothingAvatarObject?.name ?? string.Empty; + set => S1ClothingDefinition.ClothingAvatarObject = + UnityEngine.Resources.Load(value); } /// diff --git a/S1API/Items/ClothingItemDefinitionBuilder.cs b/S1API/Items/ClothingItemDefinitionBuilder.cs index 7fe502aa..def6fdfc 100644 --- a/S1API/Items/ClothingItemDefinitionBuilder.cs +++ b/S1API/Items/ClothingItemDefinitionBuilder.cs @@ -4,6 +4,7 @@ using S1CoreItemFramework = Il2CppScheduleOne.Core.Items.Framework; using S1Registry = Il2CppScheduleOne.Registry; using S1UiItems = Il2CppScheduleOne.UI.Items; +using S1CoreAvatar = Il2CppScheduleOne.Core.Avatar; using Il2CppCollections = Il2CppSystem.Collections.Generic; #elif MONOMELON using S1Clothing = ScheduleOne.Clothing; @@ -11,6 +12,7 @@ using S1CoreItemFramework = ScheduleOne.Core.Items.Framework; using S1Registry = ScheduleOne.Registry; using S1UiItems = ScheduleOne.UI.Items; +using S1CoreAvatar = ScheduleOne.Core.Avatar; using Il2CppCollections = System.Collections.Generic; #endif @@ -54,8 +56,6 @@ internal ClothingItemDefinitionBuilder() // Clothing-specific defaults _definition.Slot = S1Clothing.EClothingSlot.Head; - _definition.ApplicationType = S1Clothing.EClothingApplicationType.Accessory; - _definition.ClothingAssetPath = "Path/To/Clothing/Asset"; _definition.Colorable = true; _definition.DefaultColor = S1Clothing.EClothingColor.White; #if (IL2CPPMELON) @@ -95,8 +95,7 @@ internal ClothingItemDefinitionBuilder(S1Clothing.ClothingDefinition source) // Copy clothing-specific properties _definition.Slot = source.Slot; - _definition.ApplicationType = source.ApplicationType; - _definition.ClothingAssetPath = source.ClothingAssetPath; + _definition.ClothingAvatarObject = source.ClothingAvatarObject; _definition.Colorable = source.Colorable; _definition.DefaultColor = source.DefaultColor; #if (IL2CPPMELON) @@ -149,7 +148,6 @@ public ClothingItemDefinitionBuilder WithSlot(ClothingSlot slot) /// The builder instance for fluent chaining. public ClothingItemDefinitionBuilder WithApplicationType(ClothingApplicationType applicationType) { - _definition.ApplicationType = (S1Clothing.EClothingApplicationType)applicationType; return this; } @@ -160,7 +158,7 @@ public ClothingItemDefinitionBuilder WithApplicationType(ClothingApplicationType /// The builder instance for fluent chaining. public ClothingItemDefinitionBuilder WithClothingAsset(string assetPath) { - _definition.ClothingAssetPath = assetPath; + _definition.ClothingAvatarObject = Resources.Load(assetPath); return this; } diff --git a/S1API/Rendering/IconFactory.cs b/S1API/Rendering/IconFactory.cs index f1c2714a..7546893c 100644 --- a/S1API/Rendering/IconFactory.cs +++ b/S1API/Rendering/IconFactory.cs @@ -1,10 +1,12 @@ #if (IL2CPPMELON) using S1DevUtils = Il2CppScheduleOne.DevUtilities; using S1AvatarFramework = Il2CppScheduleOne.AvatarFramework; +using S1AvatarTools = Il2CppScheduleOne.Avatar.Tools; using Il2CppScheduleOne.AvatarFramework.Customization; #elif MONOMELON using S1DevUtils = ScheduleOne.DevUtilities; using S1AvatarFramework = ScheduleOne.AvatarFramework; +using S1AvatarTools = ScheduleOne.Avatar.Tools; using ScheduleOne.AvatarFramework.Customization; #endif @@ -66,8 +68,8 @@ internal static bool IsItemIconGeneratorReady /// /// INTERNAL: Reference to the game's MugshotGenerator instance. /// - internal static S1AvatarFramework.MugshotGenerator S1MugshotGenerator => - S1AvatarFramework.MugshotGenerator.Instance; + internal static S1AvatarTools.MugshotGenerator? S1MugshotGenerator => + UnityEngine.Object.FindObjectOfType(); /// /// Generates a preview texture for the specified model. @@ -150,7 +152,6 @@ internal static bool IsItemIconGeneratorReady Quaternion originalRot = model.localRotation; Vector3 originalScale = model.localScale; bool wasActive = model.gameObject.activeSelf; - int originalSize = generator.IconSize; bool originalModifyLighting = generator.ModifyLighting; List? bakedRenderers = null; Texture2D? texture = null; @@ -201,7 +202,6 @@ internal static bool IsItemIconGeneratorReady } // Temporarily override IconGenerator state - generator.IconSize = size; generator.ModifyLighting = true; texture = generator.GetTexture(model); @@ -226,7 +226,6 @@ internal static bool IsItemIconGeneratorReady } finally { - generator.IconSize = originalSize; generator.ModifyLighting = originalModifyLighting; if (bakedRenderers != null) @@ -683,6 +682,7 @@ private class AccessoryIconRequest /// private static IEnumerator ProcessAccessoryIconQueue() { +#if false while (true) { AccessoryIconRequest? next = null; @@ -794,6 +794,27 @@ private static IEnumerator ProcessAccessoryIconQueue() // Small delay between jobs to let the mugshot rig fully reset yield return new WaitForSeconds(0.05f); } +#else + while (true) + { + AccessoryIconRequest? next; + lock (_accessoryIconQueueLock) + { + if (_accessoryIconQueue.Count == 0) + { + _isProcessingAccessoryIcons = false; + yield break; + } + + next = _accessoryIconQueue.Dequeue(); + } + + Logger.Warning( + "Accessory icon generation is unavailable with the Schedule I 0.4.7 avatar pipeline."); + next.Callback?.Invoke(null); + yield return null; + } +#endif } /// diff --git a/S1API/S1API.cs b/S1API/S1API.cs index d04452d1..9190e353 100644 --- a/S1API/S1API.cs +++ b/S1API/S1API.cs @@ -12,7 +12,7 @@ using S1API.Lifecycle; using S1API.Map; -[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.0", "KaBooMa")] +[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.1", "KaBooMa")] [assembly: MelonPriority(Int32.MinValue)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member namespace S1API diff --git a/S1API/S1API.csproj b/S1API/S1API.csproj index 0d243dcb..ad528183 100644 --- a/S1API/S1API.csproj +++ b/S1API/S1API.csproj @@ -23,7 +23,7 @@ $(NoWarn);1591 true latest - 3.2.0 + 3.2.1-beta.1 diff --git a/S1API/Trash/TrashManager.cs b/S1API/Trash/TrashManager.cs index 19b3a161..221a1a50 100644 --- a/S1API/Trash/TrashManager.cs +++ b/S1API/Trash/TrashManager.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using S1API.Lifecycle; using S1API.Logging; +using S1API.Internal.Utils; using UnityEngine; using Object = UnityEngine.Object; @@ -129,7 +130,7 @@ public static GameObject RegisterTrashPrefab( var root = GetOrCreatePrefabRoot(); cached = Object.Instantiate(source, root.transform, false); cached.name = $"S1API_Trash_{id}"; - cached.ID = id; + ReflectionUtils.TrySetFieldOrProperty(cached, "Id", id); cached.gameObject.hideFlags = HideFlags.HideAndDontSave; cached.gameObject.SetActive(true); RegisteredPrefabs[id] = cached; diff --git a/S1API/UI/CharacterCreatorManager.cs b/S1API/UI/CharacterCreatorManager.cs index 236266bf..77e84229 100644 --- a/S1API/UI/CharacterCreatorManager.cs +++ b/S1API/UI/CharacterCreatorManager.cs @@ -1,10 +1,10 @@ #if (IL2CPPMELON) -using S1Customization = Il2CppScheduleOne.AvatarFramework.Customization; +using S1Customization = Il2CppScheduleOne.CharacterCreator; using S1DevUtilities = Il2CppScheduleOne.DevUtilities; using S1PlayerScripts = Il2CppScheduleOne.PlayerScripts; using S1UI = Il2CppScheduleOne.UI; #elif MONOMELON -using S1Customization = ScheduleOne.AvatarFramework.Customization; +using S1Customization = ScheduleOne.CharacterCreator; using S1DevUtilities = ScheduleOne.DevUtilities; using S1PlayerScripts = ScheduleOne.PlayerScripts; using S1UI = ScheduleOne.UI; @@ -78,10 +78,7 @@ public static BasicAvatarSettings? ActiveSettings get { EnsureInitialized(); - if (_s1Creator?.ActiveSettings == null) - return null; - - return new BasicAvatarSettings(_s1Creator.ActiveSettings); + return null; } } @@ -112,16 +109,14 @@ public static void Open(BasicAvatarSettings? initialSettings = null, bool showUI RegisterEvents(); - // If no initial settings provided, try to load player's current settings - if (initialSettings == null) + if (initialSettings != null || !showUI) { - initialSettings = GetPlayerAvatarSettings(); + Logger.Warning( + "Schedule I 0.4.7 no longer accepts legacy initial settings or a hidden " + + "character-creator canvas; opening the native creator with its defaults."); } - var s1Settings = initialSettings?.S1BasicAvatarSettings; - _s1Creator.Open(s1Settings); - if (!showUI && _s1Creator.Canvas != null) - _s1Creator.Canvas.enabled = false; + _s1Creator.Open(); try { @@ -152,7 +147,12 @@ public static void Close() return; } - _s1Creator.Close(); + var closeMethod = typeof(S1Customization.CharacterCreator).GetMethod( + "Close", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic); + closeMethod?.Invoke(_s1Creator, null); // Restore camera transform and FOV if no other UI elements are active // Use coroutine to wait for base game's Close() coroutine to finish @@ -218,7 +218,8 @@ public static void SelectPreset(string presetName) return; } - _s1Creator.SelectPreset(presetName); + Logger.Warning( + "Character-creator presets are not available in Schedule I 0.4.7."); } /// @@ -229,20 +230,7 @@ public static string[] GetAvailablePresets() { EnsureInitialized(); - if (_s1Creator == null || _s1Creator.Presets == null) - return Array.Empty(); - - var presets = new List(); - for (int i = 0; i < _s1Creator.Presets.Count; i++) - { - var preset = _s1Creator.Presets[i]; - if (preset != null && !string.IsNullOrWhiteSpace(preset.name)) - { - presets.Add(preset.name); - } - } - - return presets.ToArray(); + return Array.Empty(); } /// @@ -265,7 +253,7 @@ public static void SetRigRotation(float normalizedValue) return; } - _s1Creator.SliderChanged(Mathf.Clamp01(normalizedValue)); + _s1Creator.SetAvatarRotation(Mathf.Clamp01(normalizedValue) * 359f); } /// @@ -322,15 +310,12 @@ private static void RegisterEvents() try { - // Register completion event - if (_s1Creator.onComplete != null) - { #if (IL2CPPMELON) - _s1Creator.onComplete.AddListener((Action)OnCreatorCompleted); + _s1Creator.add_OnComplete( + (Action)OnCreatorCompleted); #else - _s1Creator.onComplete.AddListener(OnCreatorCompleted); + _s1Creator.OnComplete += OnCreatorCompleted; #endif - } _eventsRegistered = true; } @@ -340,17 +325,34 @@ private static void RegisterEvents() } } - private static void OnCreatorCompleted(S1Customization.BasicAvatarSettings s1Settings) + private static void OnCreatorCompleted(S1Customization.CharacterCreatorState state) { try { - if (s1Settings == null) + if (state?.Appearance == null) { Logger.Warning("CharacterCreator completed with null settings"); return; } - var wrappedSettings = new BasicAvatarSettings(s1Settings); + BasicAvatarSettings wrappedSettings = BasicAvatarSettings.Create(); + wrappedSettings.Gender = (int)state.Appearance.Gender; + wrappedSettings.Weight = state.Appearance.Weight; + wrappedSettings.SkinColor = state.Appearance.SkinColor; + wrappedSettings.HairStyle = state.Appearance.HairStyleId ?? string.Empty; + wrappedSettings.HairColor = state.Appearance.HairColor; + wrappedSettings.Mouth = state.Appearance.FaceId ?? string.Empty; + wrappedSettings.FacialHair = state.Appearance.FacialHairId ?? string.Empty; + wrappedSettings.FacialDetails = state.Appearance.FacialDetailId ?? string.Empty; + wrappedSettings.FacialDetailsIntensity = state.Appearance.FacialDetailIntensity; + wrappedSettings.EyeballColor = state.Appearance.EyeballColor; + wrappedSettings.PupilDilation = state.Appearance.PupilDilation; + wrappedSettings.UpperEyeLidRestingPosition = state.Appearance.UpperEyelidPosition; + wrappedSettings.LowerEyeLidRestingPosition = state.Appearance.LowerEyelidPosition; + wrappedSettings.EyebrowScale = state.Appearance.EyebrowScale; + wrappedSettings.EyebrowThickness = state.Appearance.EyebrowThickness; + wrappedSettings.EyebrowRestingHeight = state.Appearance.EyebrowHeight; + wrappedSettings.EyebrowRestingAngle = state.Appearance.EyebrowAngle; // Restore camera after a delay to let the base game's Close() coroutine finish // The base game's Done() calls Close() which starts a coroutine that removes UI element From 065c79e6a64ac240c218db4303759bef4e3d0f1d Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 19 Sep 2026 15:29:09 -0700 Subject: [PATCH 02/57] ci(beta): refresh game assembly caches --- .github/workflows/coverage.yml | 8 ++++---- .github/workflows/docs.yml | 6 +++--- .github/workflows/il2cpp-build-check.yml | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 0e1e3cae..b8ceacac 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -60,7 +60,7 @@ jobs: # Try to restore assemblies from cache first (for external PRs) # Cache key includes assembly branch to separate beta from main - # v3 suffix allows cache invalidation by bumping version + # v4 suffix allows cache invalidation by bumping version # Only use cache for PR events to avoid stale assemblies after merges - name: Restore Game Assemblies from Cache id: cache-assemblies @@ -68,9 +68,9 @@ jobs: uses: actions/cache/restore@v4 with: path: S1API/ScheduleOneAssemblies - key: game-assemblies-v3-${{ steps.assembly-branch.outputs.branch }}-${{ hashFiles('S1API/S1API.csproj') }} + key: game-assemblies-v4-${{ steps.assembly-branch.outputs.branch }}-${{ hashFiles('S1API/S1API.csproj') }} restore-keys: | - game-assemblies-v3-${{ steps.assembly-branch.outputs.branch }}- + game-assemblies-v4-${{ steps.assembly-branch.outputs.branch }}- # Only checkout game assemblies if cache miss AND we have access to secrets - name: Checkout Game Assemblies @@ -336,7 +336,7 @@ jobs: if: always() && (github.event_name == 'pull_request' || (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/stable'))) && steps.cache-assemblies.outputs.cache-hit != 'true' with: path: S1API/ScheduleOneAssemblies - key: game-assemblies-v3-${{ steps.assembly-branch.outputs.branch }}-${{ hashFiles('S1API/S1API.csproj') }} + key: game-assemblies-v4-${{ steps.assembly-branch.outputs.branch }}-${{ hashFiles('S1API/S1API.csproj') }} - name: Coverage Skipped Notice if: steps.verify-assemblies.outputs.has_assemblies != 'true' diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b8bc50bc..a2e2ee68 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -85,9 +85,9 @@ jobs: uses: actions/cache/restore@v4 with: path: S1API/ScheduleOneAssemblies - key: game-assemblies-v4-${{ steps.assembly-branch.outputs.branch }}-${{ hashFiles('S1API/S1API.csproj') }} + key: game-assemblies-v5-${{ steps.assembly-branch.outputs.branch }}-${{ hashFiles('S1API/S1API.csproj') }} restore-keys: | - game-assemblies-v4-${{ steps.assembly-branch.outputs.branch }}- + game-assemblies-v5-${{ steps.assembly-branch.outputs.branch }}- # Only checkout game assemblies if cache miss AND we have access to secrets - name: Checkout Game Assemblies @@ -212,7 +212,7 @@ jobs: if: always() && steps.cache-assemblies.outputs.cache-hit != 'true' && hashFiles('S1API/ScheduleOneAssemblies/Managed/Assembly-CSharp.dll') != '' && hashFiles('S1API/ScheduleOneAssemblies/Managed/Unity.Burst.dll') != '' && hashFiles('S1API/ScheduleOneAssemblies/MelonLoader/0Harmony.dll') != '' && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/beta' || github.ref == 'refs/heads/npc-prefabs'))) with: path: S1API/ScheduleOneAssemblies - key: game-assemblies-v4-${{ steps.assembly-branch.outputs.branch }}-${{ hashFiles('S1API/S1API.csproj') }} + key: game-assemblies-v5-${{ steps.assembly-branch.outputs.branch }}-${{ hashFiles('S1API/S1API.csproj') }} - name: Create CI Build Properties run: | diff --git a/.github/workflows/il2cpp-build-check.yml b/.github/workflows/il2cpp-build-check.yml index 42118e75..02c019da 100644 --- a/.github/workflows/il2cpp-build-check.yml +++ b/.github/workflows/il2cpp-build-check.yml @@ -1,7 +1,7 @@ name: IL2CPP Build Check env: - IL2CPP_ASSEMBLIES_CACHE_VERSION: v5 + IL2CPP_ASSEMBLIES_CACHE_VERSION: v6 on: push: From 1184221253f9ec95dddaf4a3c823a0dba0ebfd34 Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 19 Sep 2026 15:36:28 -0700 Subject: [PATCH 03/57] ci(beta): compare API with shipped release --- .github/workflows/docs.yml | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a2e2ee68..8599a80d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -311,18 +311,39 @@ jobs: if: github.event_name == 'pull_request' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} + BASE_REF: ${{ github.event.pull_request.base.ref }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail git checkout --detach --force "$BASE_SHA" - baseline_tag="$(git describe --tags --abbrev=0 --match 'v[0-9]*' "$BASE_SHA" 2>/dev/null || true)" - if [[ -n "$baseline_tag" ]] && git diff --quiet "$baseline_tag" "$BASE_SHA" -- \ - ':(glob)S1API/**/*.cs' \ - S1API/S1API.csproj \ - S1API/Directory.Build.props \ - Directory.Build.props; then + use_release_baseline=false + if [[ "$BASE_REF" == "beta" ]]; then + baseline_tag="$(gh release list \ + --repo "$GITHUB_REPOSITORY" \ + --limit 100 \ + --json tagName,isPrerelease,publishedAt \ + --jq '[.[] | select(.isPrerelease and (.tagName | contains("-beta.")))] | sort_by(.publishedAt) | last | .tagName // ""')" + if [[ -z "$baseline_tag" ]]; then + baseline_tag="$(gh release view \ + --repo "$GITHUB_REPOSITORY" \ + --json tagName \ + --jq '.tagName')" + fi + use_release_baseline=true + else + baseline_tag="$(git describe --tags --abbrev=0 --match 'v[0-9]*' "$BASE_SHA" 2>/dev/null || true)" + if [[ -n "$baseline_tag" ]] && git diff --quiet "$baseline_tag" "$BASE_SHA" -- \ + ':(glob)S1API/**/*.cs' \ + S1API/S1API.csproj \ + S1API/Directory.Build.props \ + Directory.Build.props; then + use_release_baseline=true + fi + fi + + if [[ "$use_release_baseline" == "true" ]]; then baseline_version="${baseline_tag#v}" release_dir="$RUNNER_TEMP/s1api-api-compat/release" release_zip="$release_dir/S1API-Forked-${baseline_version}.zip" From b4b6f34f3070c1151bc2b9667cfbfc985865e7dd Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 19 Sep 2026 15:42:51 -0700 Subject: [PATCH 04/57] fix(beta): restore Mono custom NPC initialization --- .github/api-compat-suppressions.xml | 8 ++++---- S1API/Internal/Entities/NPCDataAccess.cs | 3 --- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/api-compat-suppressions.xml b/.github/api-compat-suppressions.xml index 941cf298..6d073541 100644 --- a/.github/api-compat-suppressions.xml +++ b/.github/api-compat-suppressions.xml @@ -1,12 +1,12 @@ CP0002 - M:S1API.PhoneApp.PhoneApp.Exit(ScheduleOne.DevUtilities.ExitAction) + M:S1API.PhoneApp.PhoneApp.Exit(ScheduleOne.ExitAction) diff --git a/S1API/Internal/Entities/NPCDataAccess.cs b/S1API/Internal/Entities/NPCDataAccess.cs index f531665a..59cd00d8 100644 --- a/S1API/Internal/Entities/NPCDataAccess.cs +++ b/S1API/Internal/Entities/NPCDataAccess.cs @@ -37,9 +37,6 @@ internal static class NPCDataAccess { private static readonly Logging.Log Logger = new Logging.Log("NPCDataAccess"); #if !IL2CPPMELON - private static readonly FieldInfo NpcDataObjectField = - typeof(S1NPCs.NPC).GetField("_npcData", BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new MissingFieldException(typeof(S1NPCs.NPC).FullName, "_npcData"); private static readonly FieldInfo CurrentNpcDataField = typeof(S1NPCs.NPC).GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new MissingFieldException(typeof(S1NPCs.NPC).FullName, "k__BackingField"); From 1a228f6683fb87b95f20109c0efee6f96beed53f Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 19 Sep 2026 20:15:55 -0700 Subject: [PATCH 05/57] fix(avatar): restore beta portraits and preview poses --- .github/release-notes/3.2.1-beta.2.md | 20 + .../Entities/MugshotCapturePolicyTests.cs | 4 +- S1API/Avatar/BasicAvatarSettings.cs | 25 + S1API/Entities/NPCAppearance.cs | 112 +++- S1API/Entities/Player.cs | 5 +- .../Compatibility/AvatarCompatibility.cs | 553 +++++++++++++++++- S1API/Internal/Lifecycle/SceneStateCleaner.cs | 2 + .../NPCWorkbench/NPCWorkbenchPreview.cs | 31 +- .../NPCWorkbenchRuntimeAdapter.cs | 6 +- S1API/Rendering/IconFactory.cs | 98 +++- S1API/S1API.cs | 4 +- S1API/S1API.csproj | 2 +- S1API/UI/CharacterCreatorManager.cs | 9 +- S1API/docs/appearance-customization.md | 15 + S1API/docs/npc-workbench.md | 6 + 15 files changed, 851 insertions(+), 41 deletions(-) create mode 100644 .github/release-notes/3.2.1-beta.2.md diff --git a/.github/release-notes/3.2.1-beta.2.md b/.github/release-notes/3.2.1-beta.2.md new file mode 100644 index 00000000..23534545 --- /dev/null +++ b/.github/release-notes/3.2.1-beta.2.md @@ -0,0 +1,20 @@ +## Avatar rendering on the Schedule I 0.4.7 beta + +- Restore generated NPC mugshots and accessory icons through a detached avatar and portrait camera. +- Keep portraits facing forward, with settled animation and appearance state. Remove inherited attachments and prevent player look targets, blinking, and distance impostors from affecting captures. +- Restore the NPC appearance workbench, including native appearance import, full-body framing, pose controls, and reset. +- Reset copied body-shape caches so newly applied clothing fits the preview body. +- Resolve legacy hair, clothing, face-layer, and accessory resources through typed Mono and IL2CPP APIs. +- Restore current player and active character-creator appearance getters. + +## Compatibility and validation + +- Existing public method signatures and explicit NPC icon precedence are preserved. +- Built against the Schedule I 0.4.7f5 Mono and IL2CPP beta assemblies. Rendered captures were checked in both runtimes, including the requested accessory, forward-facing portraits, clothing, workbench framing, and pose reset. +- Updated the appearance-customization and NPC workbench documentation. +- This is a GitHub-only prerelease for the game beta. It does not publish to NuGet, Nexus Mods, or Thunderstore. +- Tracks [issue #305](https://github.com/ifBars/S1API/issues/305). + +## Downloads + +- [Download S1API-Forked-3.2.1-beta.2.zip](https://github.com/ifBars/S1API/releases/download/v3.2.1-beta.2/S1API-Forked-3.2.1-beta.2.zip) diff --git a/S1API.Tests/Entities/MugshotCapturePolicyTests.cs b/S1API.Tests/Entities/MugshotCapturePolicyTests.cs index a4f50ed5..eea04d5f 100644 --- a/S1API.Tests/Entities/MugshotCapturePolicyTests.cs +++ b/S1API.Tests/Entities/MugshotCapturePolicyTests.cs @@ -6,9 +6,11 @@ public sealed class MugshotCapturePolicyTests { [Theory] [InlineData(500, 4096, 0.60f, 0.90f, true)] - [InlineData(500, 4096, 0.60f, 0.80f, false)] + [InlineData(500, 4096, 0.60f, 0.80f, true)] [InlineData(200, 4096, 0.60f, 0.90f, false)] [InlineData(500, 4096, 0.30f, 0.90f, false)] + [InlineData(500, 4096, 0.60f, 0.74f, false)] + [InlineData(300, 4096, 0.60f, 0.90f, false)] [InlineData(500, 4096, 0.60f, 0.40f, false)] [InlineData(500, 0, 0.60f, 0.80f, false)] public void PortraitCoverageRequiresSubstantialVisibleBounds( diff --git a/S1API/Avatar/BasicAvatarSettings.cs b/S1API/Avatar/BasicAvatarSettings.cs index b28bb5b5..c55c9fa1 100644 --- a/S1API/Avatar/BasicAvatarSettings.cs +++ b/S1API/Avatar/BasicAvatarSettings.cs @@ -1,8 +1,10 @@ #if (IL2CPPMELON) using S1Customization = Il2CppScheduleOne.AvatarFramework.Customization; +using S1PlayerAppearance = Il2CppScheduleOne.Avatar.Player.PlayerAppearance; using Il2CppCollectionsGeneric = Il2CppSystem.Collections.Generic; #elif MONOMELON using S1Customization = ScheduleOne.AvatarFramework.Customization; +using S1PlayerAppearance = ScheduleOne.Avatar.Player.PlayerAppearance; #endif using System.Collections.Generic; @@ -40,6 +42,29 @@ public static BasicAvatarSettings Create() return new BasicAvatarSettings(settings); } + internal static BasicAvatarSettings FromPlayerAppearance(S1PlayerAppearance appearance) + { + var settings = Create(); + settings.Gender = (int)appearance.Gender; + settings.Weight = appearance.Weight; + settings.SkinColor = appearance.SkinColor; + settings.HairStyle = appearance.HairStyleId ?? string.Empty; + settings.HairColor = appearance.HairColor; + settings.Mouth = appearance.FaceId ?? string.Empty; + settings.FacialHair = appearance.FacialHairId ?? string.Empty; + settings.FacialDetails = appearance.FacialDetailId ?? string.Empty; + settings.FacialDetailsIntensity = appearance.FacialDetailIntensity; + settings.EyeballColor = appearance.EyeballColor; + settings.PupilDilation = appearance.PupilDilation; + settings.UpperEyeLidRestingPosition = appearance.UpperEyelidPosition; + settings.LowerEyeLidRestingPosition = appearance.LowerEyelidPosition; + settings.EyebrowScale = appearance.EyebrowScale; + settings.EyebrowThickness = appearance.EyebrowThickness; + settings.EyebrowRestingHeight = appearance.EyebrowHeight; + settings.EyebrowRestingAngle = appearance.EyebrowAngle; + return settings; + } + /// /// Gender value (0 = male, 1 = female). /// diff --git a/S1API/Entities/NPCAppearance.cs b/S1API/Entities/NPCAppearance.cs index 115c8ea1..d56e6eeb 100644 --- a/S1API/Entities/NPCAppearance.cs +++ b/S1API/Entities/NPCAppearance.cs @@ -1,9 +1,11 @@ #if (IL2CPPMELON) using S1AvatarFramework = Il2CppScheduleOne.AvatarFramework; +using S1AvatarTools = Il2CppScheduleOne.Avatar.Tools; using S1Map = Il2CppScheduleOne.Map; using S1NPCs = Il2CppScheduleOne.NPCs; #elif MONOMELON using S1AvatarFramework = ScheduleOne.AvatarFramework; +using S1AvatarTools = ScheduleOne.Avatar.Tools; using S1Map = ScheduleOne.Map; using S1NPCs = ScheduleOne.NPCs; #endif @@ -63,13 +65,12 @@ internal NPCAppearance(NPC npc, S1AvatarFramework.Avatar? runtimeAvatar) if (_runtimeAvatar != null) { - sourceSettings = global::S1API.Internal.Utils.ReflectionUtils.TryGetFieldOrProperty( - _runtimeAvatar, - "InitialAvatarSettings") as S1AvatarFramework.AvatarSettings; + sourceSettings = global::S1API.Internal.Compatibility.AvatarCompatibility + .CaptureLegacySettings(_runtimeAvatar); } if (sourceSettings != null) - _customAvatarSettings = ScriptableObject.Instantiate(sourceSettings); + _customAvatarSettings = sourceSettings; else { _customAvatarSettings = ScriptableObject.CreateInstance(); @@ -445,8 +446,11 @@ private static IEnumerator ProcessMugshotQueueCore() yield return new WaitForSeconds(0.1f); } #else + var renderingEpoch = global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch; while (true) { + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + yield break; NPCAppearance? next; lock (_mugshotQueueLock) { @@ -459,8 +463,98 @@ private static IEnumerator ProcessMugshotQueueCore() next = _mugshotQueue.Dequeue(); } - next.MarkMugshotCompleted(); - yield return null; + if (next.NPC.HasExplicitIcon) + { + next.MarkMugshotCompleted(); + continue; + } + + S1AvatarTools.MugshotGenerator? generator = null; + while (generator == null) + { + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + yield break; + generator = global::S1API.Internal.Compatibility.AvatarCompatibility + .FindMugshotGenerator(next._runtimeAvatar); + if (generator == null) + yield return null; + } + + while (!global::S1API.Internal.Compatibility.AvatarCompatibility + .TryAcquireMugshotGenerator(generator)) + { + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + yield break; + yield return null; + } + + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + { + global::S1API.Internal.Compatibility.AvatarCompatibility.ReleaseMugshotGenerator(); + yield break; + } + + _activeMugshot = next; + global::S1API.Internal.Compatibility.AvatarCompatibility.CreateRenderInputs( + next._customAvatarSettings, + out var appearance, + out var outfit); + + bool completed = false; + Texture2D? generatedMugshot = null; + try + { + global::S1API.Internal.Compatibility.AvatarCompatibility.StartPortraitCapture( + generator, + appearance, + outfit, + (Action)(texture => + { + generatedMugshot = texture; + completed = true; + })); + } + catch (Exception ex) + { + _logger.Error($"[Mugshot] Native capture failed to start: {ex.Message}"); + completed = true; + } + + var waitFrames = 0; + while (!completed && waitFrames++ < 300) + yield return null; + + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + { + UnityEngine.Object.Destroy(outfit); + yield break; + } + + global::S1API.Internal.Compatibility.AvatarCompatibility.ReleaseMugshotGenerator(); + UnityEngine.Object.Destroy(outfit); + generatedMugshot?.Apply(); + if (generatedMugshot != null) + { + var source = generatedMugshot; + generatedMugshot = global::S1API.Internal.Compatibility.AvatarCompatibility + .ResizePortrait(source, 512); + UnityEngine.Object.Destroy(source); + } + if (generatedMugshot != null && HasPortraitContent(generatedMugshot, 0.01f)) + { + var cropRect = new Rect(0, 0, generatedMugshot.width, generatedMugshot.height); + var iconSprite = Sprite.Create(generatedMugshot, cropRect, Vector2.zero); + next.NPC.ApplyGeneratedIcon(iconSprite); + UpdatePoiIcons(next.NPC.S1NPC, iconSprite); + } + else + { + _logger.Error( + $"[Mugshot] {next.NPC.FirstName}: capture returned no complete portrait; keeping the existing icon"); + } + + CompleteActiveMugshot(); + yield return new WaitForSeconds(0.05f); } #endif } @@ -579,9 +673,9 @@ internal static bool IsPortraitCoverageSufficient( float contentWidth, float contentHeight) => totalSamples > 0 && - visibleSamples >= totalSamples * 0.1f && - contentWidth >= 0.4f && - contentHeight >= 0.82f; + visibleSamples >= totalSamples * 0.10f && + contentWidth >= 0.35f && + contentHeight >= 0.75f; /// /// INTERNAL: Applies the currently configured avatar settings to a runtime avatar instance. diff --git a/S1API/Entities/Player.cs b/S1API/Entities/Player.cs index de269c74..4638abd7 100644 --- a/S1API/Entities/Player.cs +++ b/S1API/Entities/Player.cs @@ -284,7 +284,10 @@ public PropertyWrapper? LastVisitedProperty { /// public BasicAvatarSettings? GetCurrentBasicAvatarSettings() { - return null; + var appearance = S1Player.CurrentAppearance; + return appearance == null + ? null + : BasicAvatarSettings.FromPlayerAppearance(appearance); } /// diff --git a/S1API/Internal/Compatibility/AvatarCompatibility.cs b/S1API/Internal/Compatibility/AvatarCompatibility.cs index 89fc226c..906b3900 100644 --- a/S1API/Internal/Compatibility/AvatarCompatibility.cs +++ b/S1API/Internal/Compatibility/AvatarCompatibility.cs @@ -1,13 +1,379 @@ #if IL2CPPMELON using S1AvatarFramework = Il2CppScheduleOne.AvatarFramework; +using S1AvatarTools = Il2CppScheduleOne.Avatar.Tools; +using S1Avatar = Il2CppScheduleOne.Avatar; +using S1CoreAvatar = Il2CppScheduleOne.Core.Avatar; +using S1CharacterCreator = Il2CppScheduleOne.CharacterCreator.CharacterCreator; +using Il2CppInterop.Runtime.InteropTypes.Arrays; #elif MONOMELON using S1AvatarFramework = ScheduleOne.AvatarFramework; +using S1AvatarTools = ScheduleOne.Avatar.Tools; +using S1Avatar = ScheduleOne.Avatar; +using S1CoreAvatar = ScheduleOne.Core.Avatar; +using S1CharacterCreator = ScheduleOne.CharacterCreator.CharacterCreator; #endif +using System.Collections.Generic; +using System.Collections; +using System; +using UnityEngine; +using Object = UnityEngine.Object; + namespace S1API.Internal.Compatibility { internal static class AvatarCompatibility { + private static readonly object MugshotLock = new object(); + private static bool _mugshotInUse; + private static S1AvatarTools.MugshotGenerator? _fallbackMugshotGenerator; + private static object? _captureCoroutine; + internal static int RenderingEpoch { get; private set; } + + internal static void ResetRendering() + { + RenderingEpoch++; + if (_captureCoroutine != null) + { + MelonLoader.MelonCoroutines.Stop(_captureCoroutine); + _captureCoroutine = null; + } + if (_fallbackMugshotGenerator != null) + Object.Destroy(_fallbackMugshotGenerator.gameObject); + _fallbackMugshotGenerator = null; + ReleaseMugshotGenerator(); + } + + internal static void StartPortraitCapture( + S1AvatarTools.MugshotGenerator generator, + S1CoreAvatar.NakedAppearance appearance, + S1CoreAvatar.Outfit outfit, + Action callback) + { + _captureCoroutine = MelonLoader.MelonCoroutines.Start( + CapturePortrait(generator, appearance, outfit, callback)); + } + + internal static S1AvatarTools.MugshotGenerator? FindMugshotGenerator( + S1AvatarFramework.Avatar? preferredAvatar = null) + { + var active = Object.FindObjectOfType(); + if (active != null) + return active; + + foreach (var candidate in Resources.FindObjectsOfTypeAll()) + { + if (candidate != null && candidate.gameObject.scene.IsValid()) + return candidate; + } + + if (_fallbackMugshotGenerator != null) + return _fallbackMugshotGenerator; + + foreach (var creator in Resources.FindObjectsOfTypeAll()) + { + var creatorAvatar = Utils.ReflectionUtils.TryGetFieldOrProperty(creator, "_avatar") + as S1AvatarFramework.Avatar; + var sourceCamera = Utils.ReflectionUtils.TryGetFieldOrProperty(creator, "_cameraPosition") + as Transform; + if (creatorAvatar == null || sourceCamera == null) + continue; + + var sourceAvatar = preferredAvatar ?? FindRuntimeAvatar() ?? creatorAvatar; + + var root = new GameObject("S1API Avatar Rendering Rig") + { + hideFlags = HideFlags.HideAndDontSave + }; + Object.DontDestroyOnLoad(root); + + var cameraForward = sourceCamera.forward; + var horizontalForward = new Vector3(cameraForward.x, 0f, cameraForward.z).normalized; + if (horizontalForward.sqrMagnitude < 0.01f) + horizontalForward = Vector3.forward; + root.transform.position = + sourceCamera.position + horizontalForward * 1.4f - Vector3.up * 1.55f; + root.transform.rotation = Quaternion.LookRotation(-horizontalForward, Vector3.up); + + var avatarObject = Object.Instantiate(sourceAvatar.gameObject); + avatarObject.name = "Avatar"; + avatarObject.hideFlags = HideFlags.HideAndDontSave; + avatarObject.transform.SetParent(root.transform, false); + avatarObject.transform.localPosition = Vector3.zero; + avatarObject.transform.localRotation = Quaternion.identity; + avatarObject.SetActive(true); + + var cameraObject = new GameObject("Camera Position") + { + hideFlags = HideFlags.HideAndDontSave + }; + cameraObject.transform.SetParent(root.transform, false); + cameraObject.transform.position = sourceCamera.position; + cameraObject.transform.rotation = Quaternion.LookRotation( + root.transform.position + Vector3.up * 1.45f - sourceCamera.position, + Vector3.up); + + AddPreviewLight( + root.transform, + "Key Light", + Quaternion.Euler(28f, 205f, 0f), + 0.65f, + new Color(1f, 0.9f, 0.82f)); + AddPreviewLight( + root.transform, + "Fill Light", + Quaternion.Euler(20f, 25f, 0f), + 0.35f, + new Color(0.7f, 0.82f, 1f)); + AddCameraFillLight(root.transform, cameraObject.transform); + + var generator = root.AddComponent(); + var avatar = avatarObject.GetComponent(); + if (avatar != null) + PrepareDetachedAvatar(avatar); + if (avatar == null || + !Utils.ReflectionUtils.TrySetFieldOrProperty(generator, "_avatar", avatar) || + !Utils.ReflectionUtils.TrySetFieldOrProperty( + generator, + "_cameraPosition", + cameraObject.transform)) + { + Object.Destroy(root); + return null; + } + + _fallbackMugshotGenerator = generator; + return generator; + } + + return null; + } + + private static S1AvatarFramework.Avatar? FindRuntimeAvatar() + { + foreach (var avatar in Object.FindObjectsOfType()) + { + if (avatar != null && avatar.gameObject.activeInHierarchy) + return avatar; + } + + return null; + } + + internal static void PrepareDetachedAvatar(S1AvatarFramework.Avatar avatar) + { + // Serialized mesh blend weights survive cloning; the native value caches do not. + Utils.ReflectionUtils.TrySetFieldOrProperty(avatar.Appearance, "_appliedGender", -1f); + Utils.ReflectionUtils.TrySetFieldOrProperty(avatar.Appearance, "_appliedWeight", -1f); + foreach (var loader in avatar.GetComponentsInChildren(true)) + loader.enabled = false; + foreach (var loader in avatar.GetComponentsInChildren(true)) + loader.enabled = false; + // Runtime clones must not follow the player or switch to a distance impostor. + avatar.CancelInvoke("UpdateAnimationActive"); + avatar.SetVisible(true); + if (Utils.ReflectionUtils.TryGetFieldOrProperty(avatar, "_impostor") is Component impostor) + impostor.gameObject.SetActive(false); + + if (avatar.LookController != null) + { + avatar.LookController.CancelInvoke(); + avatar.LookController.enabled = false; + if (Utils.ReflectionUtils.TryGetFieldOrProperty(avatar.LookController, "Aim") is Behaviour aim) + aim.enabled = false; + } + if (avatar.Eyes != null) + Utils.ReflectionUtils.TrySetFieldOrProperty(avatar.Eyes, "blinkingEnabled", false); + if (avatar.EmotionManager != null) + { + avatar.EmotionManager.CancelInvoke(); + avatar.EmotionManager.enabled = false; + } + + // Unity clones the hierarchy but not AvatarAppearance's private runtime lists. + // Remove copied objects so subsequent appearance applications own every instance. + foreach (var attachment in avatar.GetComponentsInChildren(true)) + { + attachment.gameObject.SetActive(false); + Object.Destroy(attachment.gameObject); + } + if (Utils.ReflectionUtils.TryGetFieldOrProperty(avatar.Appearance, "_avatarObjectsContainer") + is Transform objects) + { + for (var index = objects.childCount - 1; index >= 0; index--) + { + var child = objects.GetChild(index).gameObject; + child.SetActive(false); + Object.Destroy(child); + } + } + + foreach (var animator in avatar.GetComponentsInChildren(true)) + { + animator.cullingMode = AnimatorCullingMode.AlwaysAnimate; + animator.Rebind(); + animator.Update(0f); + } + foreach (var lod in avatar.GetComponentsInChildren(true)) + lod.ForceLOD(0); + foreach (var renderer in avatar.GetComponentsInChildren(true)) + renderer.updateWhenOffscreen = true; + } + + private static void AddPreviewLight( + Transform parent, + string name, + Quaternion rotation, + float intensity, + Color color) + { + var lightObject = new GameObject(name) + { + hideFlags = HideFlags.HideAndDontSave, + layer = 20 + }; + lightObject.transform.SetParent(parent, false); + lightObject.transform.rotation = rotation; + var light = lightObject.AddComponent(); + light.type = LightType.Directional; + light.intensity = intensity; + light.color = color; + light.cullingMask = 1 << 20; + light.shadows = LightShadows.None; + } + + private static void AddCameraFillLight(Transform parent, Transform cameraPosition) + { + var lightObject = new GameObject("Camera Fill Light") + { + hideFlags = HideFlags.HideAndDontSave, + layer = 20 + }; + lightObject.transform.SetParent(parent, false); + lightObject.transform.position = cameraPosition.position; + var light = lightObject.AddComponent(); + light.type = LightType.Point; + light.range = 6f; + light.intensity = 0.25f; + light.color = new Color(1f, 0.95f, 0.9f); + light.cullingMask = 1 << 20; + light.shadows = LightShadows.None; + } + + internal static bool TryAcquireMugshotGenerator(S1AvatarTools.MugshotGenerator generator) + { + lock (MugshotLock) + { + if (_mugshotInUse || + Utils.ReflectionUtils.TryGetFieldOrProperty(generator, "_mugshotRoutine") != null) + { + return false; + } + + _mugshotInUse = true; + return true; + } + } + + private static IEnumerator CapturePortrait( + S1AvatarTools.MugshotGenerator generator, + S1CoreAvatar.NakedAppearance appearance, + S1CoreAvatar.Outfit outfit, + Action callback) + { + var avatar = Utils.ReflectionUtils.TryGetFieldOrProperty(generator, "_avatar") + as S1AvatarFramework.Avatar; + if (avatar == null) + yield break; + + var cameraObject = new GameObject("S1API Portrait Camera"); + var camera = cameraObject.AddComponent(); + var target = new RenderTexture(512, 512, 24, RenderTextureFormat.ARGB32); + var delivered = false; + try + { + var normalized = appearance.Clone(); + normalized.Height = 1.875f; + avatar.Appearance.ApplyNakedAppearance(normalized); + avatar.Appearance.ApplyOutfit(outfit); + avatar.SetVisible(true); + foreach (var transform in avatar.GetComponentsInChildren(true)) + transform.gameObject.layer = 20; + foreach (var lod in avatar.GetComponentsInChildren(true)) + lod.ForceLOD(0); + camera.enabled = false; + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = Color.clear; + camera.cullingMask = 1 << 20; + camera.orthographic = true; + camera.orthographicSize = 0.34f; + camera.nearClipPlane = 0.05f; + camera.farClipPlane = 5f; + var focus = avatar.transform.position + Vector3.up * 1.7f; + camera.transform.position = focus + avatar.transform.forward * 2f; + camera.transform.LookAt(focus); + target.Create(); + camera.targetTexture = target; + // Let both animation and deferred attachment destruction settle before capture. + yield return null; + yield return new WaitForEndOfFrame(); + camera.Render(); + var previous = RenderTexture.active; + try + { + RenderTexture.active = target; + var texture = new Texture2D(512, 512, TextureFormat.RGBA32, false); + texture.ReadPixels(new Rect(0, 0, 512, 512), 0, 0); + texture.Apply(); + delivered = true; + callback(texture); + } + finally + { + RenderTexture.active = previous; + } + } + finally + { + _captureCoroutine = null; + camera.targetTexture = null; + target.Release(); + Object.Destroy(target); + Object.Destroy(cameraObject); + if (!delivered) + callback(null!); + } + } + + internal static void ReleaseMugshotGenerator() + { + lock (MugshotLock) + _mugshotInUse = false; + } + + internal static Texture2D ResizePortrait(Texture2D source, int size) + { + var target = RenderTexture.GetTemporary(size, size, 0, RenderTextureFormat.ARGB32); + var previous = RenderTexture.active; + try + { + Graphics.Blit( + source, + target, + Vector2.one, + Vector2.zero); + RenderTexture.active = target; + var portrait = new Texture2D(size, size, TextureFormat.RGBA32, false); + portrait.ReadPixels(new Rect(0, 0, size, size), 0, 0); + portrait.Apply(); + return portrait; + } + finally + { + RenderTexture.active = previous; + RenderTexture.ReleaseTemporary(target); + } + } + internal static void ApplyLegacySettings( S1AvatarFramework.Avatar avatar, S1AvatarFramework.AvatarSettings settings) @@ -15,13 +381,188 @@ internal static void ApplyLegacySettings( if (avatar?.Appearance == null || settings == null) return; - var nakedAppearance = settings.EquivalentNakedAppearance; - if (nakedAppearance != null) - avatar.Appearance.ApplyNakedAppearance(nakedAppearance.Appearance); + CreateRenderInputs(settings, out var nakedAppearance, out var outfit); + avatar.Appearance.ApplyNakedAppearance(nakedAppearance); + avatar.Appearance.ApplyOutfit(outfit); + Object.Destroy(outfit); + } - var outfit = settings.EquivalentOutfit; - if (outfit != null) - avatar.Appearance.ApplyOutfit(outfit); + internal static S1AvatarFramework.AvatarSettings? CaptureLegacySettings(S1AvatarFramework.Avatar avatar) + { + var appearance = avatar.Appearance?.AppliedNakedAppearance; + if (appearance == null) + return null; + + var settings = ScriptableObject.CreateInstance(); + settings.Gender = appearance.Gender; + settings.Height = appearance.Height / 1.875f; + settings.Weight = appearance.Weight; + settings.SkinColor = appearance.SkinColor; + settings.HairColor = appearance.HairColor; + settings.EyeBallTint = appearance.LeftEyeSettings.EyeballColor; + settings.PupilDilation = appearance.LeftEyeSettings.PupilDilation; + settings.LeftEyeRestingState = new S1AvatarFramework.Eye.EyeLidConfiguration + { + topLidOpen = appearance.LeftEyelidSettings.RestingState.TopLidOpenness, + bottomLidOpen = appearance.LeftEyelidSettings.RestingState.BottomLidOpenness + }; + settings.RightEyeRestingState = new S1AvatarFramework.Eye.EyeLidConfiguration + { + topLidOpen = appearance.RightEyelidSettings.RestingState.TopLidOpenness, + bottomLidOpen = appearance.RightEyelidSettings.RestingState.BottomLidOpenness + }; + settings.EyebrowScale = appearance.LeftEyebrowSettings.Scale; + settings.EyebrowThickness = appearance.LeftEyebrowSettings.Thickness; + settings.EyebrowRestingHeight = appearance.LeftEyebrowSettings.RestingHeight; + settings.EyebrowRestingAngle = appearance.LeftEyebrowSettings.RestingAngle; + foreach (var obj in avatar.GetComponentsInChildren(true)) + { + var serialized = obj.Serialize(); + var color = serialized.Colors != null && serialized.Colors.Length > 0 + ? serialized.Colors[0].Value : Color.white; +#if IL2CPPMELON + var isHair = obj.TryCast() != null; + var isFace = obj.TryCast() != null; +#else + var isHair = obj is S1CoreAvatar.HairAvatarObject; + var isFace = obj is S1CoreAvatar.FaceAvatarObject; +#endif + if (isHair) + settings.HairPath = obj.Id; + else if (obj.PrimaryType == S1CoreAvatar.AvatarObject.EType.Worn) + settings.AccessorySettings.Add(new S1AvatarFramework.AvatarSettings.AccessorySetting + { path = obj.Id, color = color }); + else if (isFace) + settings.FaceLayerSettings.Add(new S1AvatarFramework.AvatarSettings.LayerSetting + { layerPath = obj.Id, layerTint = color }); + else + settings.BodyLayerSettings.Add(new S1AvatarFramework.AvatarSettings.LayerSetting + { layerPath = obj.Id, layerTint = color }); + } + return settings; + } + + internal static void CreateRenderInputs( + S1AvatarFramework.AvatarSettings settings, + out S1CoreAvatar.NakedAppearance nakedAppearance, + out S1CoreAvatar.Outfit outfit) + { + nakedAppearance = settings.EquivalentNakedAppearance?.Appearance != null + ? settings.EquivalentNakedAppearance.Appearance.Clone() + : CreateNakedAppearance(settings); + outfit = settings.EquivalentOutfit != null + ? Object.Instantiate(settings.EquivalentOutfit) + : CreateOutfit(settings); + } + + internal static bool TryResolveAvatarObject( + string resourcePath, + Color color, + out S1CoreAvatar.SerializedAvatarObject serialized) + { + serialized = S1CoreAvatar.SerializedAvatarObject.Null; + if (string.IsNullOrWhiteSpace(resourcePath)) + return false; + + var prefab = Resources.Load(resourcePath); + var accessory = prefab != null ? prefab.GetComponent() : null; + S1CoreAvatar.AvatarObject? avatarObject = accessory != null + ? accessory.AvatarObjectEquivalent : null; + if (avatarObject == null) + { + var layer = Resources.Load(resourcePath); + if (layer != null) + avatarObject = layer.AvatarObjectEquivalent; + } + + if (avatarObject == null && + !S1Avatar.AvatarObjectLibrary.TryGetAvatarObjectById(resourcePath, out avatarObject)) + { + var finalSegment = resourcePath.Substring(resourcePath.LastIndexOf('/') + 1); + S1Avatar.AvatarObjectLibrary.TryGetAvatarObjectById(finalSegment, out avatarObject); + } + + if (avatarObject == null) + return false; + + serialized = avatarObject.SerializeWithPrimaryColor(color); + return true; + } + + private static S1CoreAvatar.NakedAppearance CreateNakedAppearance( + S1AvatarFramework.AvatarSettings settings) + { + var legacy = ScriptableObject.CreateInstance(); + legacy.Gender = settings.Gender < 0.35f ? 0 : 1; + legacy.Weight = settings.Weight; + legacy.SkinColor = settings.SkinColor; + legacy.HairStyle = settings.HairPath ?? string.Empty; + legacy.HairColor = settings.HairColor; + legacy.EyeballColor = settings.EyeBallTint; + legacy.PupilDilation = settings.PupilDilation; + legacy.UpperEyeLidRestingPosition = settings.UpperEyelidRestingPosition; + legacy.LowerEyeLidRestingPosition = settings.LowerEyelidRestingPosition; + legacy.EyebrowScale = settings.EyebrowScale; + legacy.EyebrowThickness = settings.EyebrowThickness; + legacy.EyebrowRestingHeight = settings.EyebrowRestingHeight; + legacy.EyebrowRestingAngle = settings.EyebrowRestingAngle; + + var appearance = S1AvatarTools.BasicAvatarSettingsConverter.ConvertToNakedAppearance( + legacy, + includeUnderwear: true, + includeNipples: true, + includeEyeShadow: true); + Object.Destroy(legacy); + + appearance.Gender = settings.Gender; + appearance.Height = settings.Height > 0f ? settings.Height * 1.875f : 1.875f; + + if (TryResolveAvatarObject(settings.HairPath ?? string.Empty, settings.HairColor, out var hair)) + appearance.AddAvatarObject(hair); + + if (settings.FaceLayerSettings != null) + { + foreach (var layer in settings.FaceLayerSettings) + { + if (TryResolveAvatarObject(layer.layerPath, layer.layerTint, out var avatarObject)) + appearance.AddAvatarObject(avatarObject); + } + } + + if (settings.BodyLayerSettings != null) + { + foreach (var layer in settings.BodyLayerSettings) + { + if (TryResolveAvatarObject(layer.layerPath, layer.layerTint, out var avatarObject)) + appearance.AddAvatarObject(avatarObject); + } + } + + return appearance; + } + + private static S1CoreAvatar.Outfit CreateOutfit(S1AvatarFramework.AvatarSettings settings) + { + var serialized = new List(); + if (settings.AccessorySettings != null) + { + foreach (var accessory in settings.AccessorySettings) + { + if (TryResolveAvatarObject(accessory.path, accessory.color, out var avatarObject)) + serialized.Add(avatarObject); + } + } + + var outfit = ScriptableObject.CreateInstance(); +#if IL2CPPMELON + var objects = new Il2CppReferenceArray(serialized.Count); + for (var index = 0; index < serialized.Count; index++) + objects[index] = serialized[index]; + outfit.AvatarObjects = objects; +#else + outfit.AvatarObjects = serialized.ToArray(); +#endif + return outfit; } } } diff --git a/S1API/Internal/Lifecycle/SceneStateCleaner.cs b/S1API/Internal/Lifecycle/SceneStateCleaner.cs index b5a3f704..338b68a0 100644 --- a/S1API/Internal/Lifecycle/SceneStateCleaner.cs +++ b/S1API/Internal/Lifecycle/SceneStateCleaner.cs @@ -91,6 +91,8 @@ internal static void ResetForSceneChange(string sceneName, bool afterUnload) WeatherRuntime.ResetBindings(); HomeScreenScrollPatch.ResetInitializationState(); NPCAppearance.ResetMugshotState(); + Compatibility.AvatarCompatibility.ResetRendering(); + global::S1API.Rendering.IconFactory.ResetAccessoryIconState(); LoadingScreenPatches.ResetState(); DialogueInjector.ResetState(); DialogueChoiceListener.ResetState(); diff --git a/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs b/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs index ba01edb4..1b8b42fc 100644 --- a/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs +++ b/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs @@ -1,7 +1,9 @@ #if IL2CPPMELON using S1AvatarFramework = Il2CppScheduleOne.AvatarFramework; +using S1AvatarTools = Il2CppScheduleOne.Avatar.Tools; #elif MONOMELON using S1AvatarFramework = ScheduleOne.AvatarFramework; +using S1AvatarTools = ScheduleOne.Avatar.Tools; #endif using System; @@ -26,9 +28,9 @@ internal sealed class NPCWorkbenchPreview : IDisposable private NPCWorkbenchDraft? _pendingDraft; private int _settleFrames; private int _rendererRefreshFrames; - private float _yaw = 180f; + private float _yaw; private float _pitch; - private float _distance = 2.8f; + private float _distance = 3.8f; private bool _disposed; private NPCWorkbenchPreview( @@ -52,13 +54,11 @@ private NPCWorkbenchPreview( internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string failure) { preview = null; - failure = "NPC avatar previews are unavailable with the Schedule I 0.4.7 avatar pipeline."; - return false; - -#if false failure = string.Empty; - var generator = S1AvatarFramework.MugshotGenerator.Instance; - var source = generator != null ? generator.MugshotRig : null; + var generator = Compatibility.AvatarCompatibility.FindMugshotGenerator(); + var source = generator != null + ? Utils.ReflectionUtils.TryGetFieldOrProperty(generator, "_avatar") as S1AvatarFramework.Avatar + : null; if (source == null) { failure = "The native avatar preview rig is not ready. Load a save, then reopen the workbench."; @@ -78,6 +78,8 @@ internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string fail var avatarObject = Object.Instantiate(source.gameObject, root.transform, false); avatarObject.name = "Detached Avatar"; + avatarObject.transform.localPosition = Vector3.zero; + avatarObject.transform.localRotation = Quaternion.identity; avatarObject.SetActive(true); SetLayerRecursively(avatarObject, layer); @@ -85,6 +87,8 @@ internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string fail if (avatar == null) throw new InvalidOperationException("The cloned native preview rig has no Avatar component."); + Compatibility.AvatarCompatibility.PrepareDetachedAvatar(avatar); + avatar.SetVisible(true); foreach (var renderer in avatarObject.GetComponentsInChildren(true)) renderer.updateWhenOffscreen = true; @@ -109,13 +113,14 @@ internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string fail var keyObject = new GameObject("Key Light"); keyObject.transform.SetParent(root.transform, false); - keyObject.transform.localPosition = new Vector3(-2f, 3f, -3f); + keyObject.transform.localPosition = new Vector3(-2f, 3f, 3f); keyObject.transform.LookAt(root.transform.position + Vector3.up); var key = keyObject.AddComponent(); key.type = LightType.Directional; key.intensity = 1.25f; key.color = new Color(1f, 0.88f, 0.76f); key.cullingMask = 1 << layer; + key.shadows = LightShadows.None; var fillObject = new GameObject("Fill Light"); fillObject.transform.SetParent(root.transform, false); @@ -125,6 +130,7 @@ internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string fail fill.intensity = 0.65f; fill.color = new Color(0.55f, 0.72f, 1f); fill.cullingMask = 1 << layer; + fill.shadows = LightShadows.None; preview = new NPCWorkbenchPreview(root, avatar, camera, texture, layer); return true; @@ -142,7 +148,6 @@ internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string fail Object.Destroy(root); return false; } -#endif } internal void ScheduleApply(NPCWorkbenchDraft draft) @@ -195,9 +200,9 @@ internal void SetPose(int pose) internal void ResetView() { - _yaw = 180f; + _yaw = 0f; _pitch = 0f; - _distance = 2.8f; + _distance = 3.8f; SetPose(0); UpdateCamera(); } @@ -226,6 +231,8 @@ private void Apply(NPCWorkbenchDraft draft) private void RefreshRenderers() { SetLayerRecursively(_avatar.gameObject, _renderLayer); + foreach (var lod in _avatar.gameObject.GetComponentsInChildren(true)) + lod.ForceLOD(0); foreach (var renderer in _avatar.gameObject.GetComponentsInChildren(true)) renderer.updateWhenOffscreen = true; } diff --git a/S1API/Internal/NPCWorkbench/NPCWorkbenchRuntimeAdapter.cs b/S1API/Internal/NPCWorkbench/NPCWorkbenchRuntimeAdapter.cs index a6e75c6b..18dd7120 100644 --- a/S1API/Internal/NPCWorkbench/NPCWorkbenchRuntimeAdapter.cs +++ b/S1API/Internal/NPCWorkbench/NPCWorkbenchRuntimeAdapter.cs @@ -95,9 +95,7 @@ private static NPCWorkbenchDraft ImportNative(string id) S1AvatarFramework.AvatarSettings? sourceSettings = null; if (sourceSettings == null && avatar != null) { - sourceSettings = ReflectionUtils.TryGetFieldOrProperty( - avatar, - "InitialAvatarSettings") as S1AvatarFramework.AvatarSettings; + sourceSettings = Compatibility.AvatarCompatibility.CaptureLegacySettings(avatar); } if (sourceSettings == null) throw new InvalidOperationException($"Native NPC '{id}' has no available appearance settings."); @@ -109,7 +107,7 @@ private static NPCWorkbenchDraft ImportNative(string id) SourceDisplayName = $"{GetDisplayName(npc)} ({id})" }; - using (var settings = new AvatarSettingsScope(ScriptableObject.Instantiate(sourceSettings))) + using (var settings = new AvatarSettingsScope(sourceSettings)) CopyAppearance(settings.Value, draft.Appearance); return draft; diff --git a/S1API/Rendering/IconFactory.cs b/S1API/Rendering/IconFactory.cs index 7546893c..fd723ef6 100644 --- a/S1API/Rendering/IconFactory.cs +++ b/S1API/Rendering/IconFactory.cs @@ -69,7 +69,7 @@ internal static bool IsItemIconGeneratorReady /// INTERNAL: Reference to the game's MugshotGenerator instance. /// internal static S1AvatarTools.MugshotGenerator? S1MugshotGenerator => - UnityEngine.Object.FindObjectOfType(); + global::S1API.Internal.Compatibility.AvatarCompatibility.FindMugshotGenerator(); /// /// Generates a preview texture for the specified model. @@ -795,8 +795,11 @@ private static IEnumerator ProcessAccessoryIconQueue() yield return new WaitForSeconds(0.05f); } #else + var renderingEpoch = global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch; while (true) { + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + yield break; AccessoryIconRequest? next; lock (_accessoryIconQueueLock) { @@ -809,10 +812,86 @@ private static IEnumerator ProcessAccessoryIconQueue() next = _accessoryIconQueue.Dequeue(); } - Logger.Warning( - "Accessory icon generation is unavailable with the Schedule I 0.4.7 avatar pipeline."); - next.Callback?.Invoke(null); - yield return null; + var generator = S1MugshotGenerator; + while (generator == null) + { + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + yield break; + yield return null; + generator = S1MugshotGenerator; + } + + while (!global::S1API.Internal.Compatibility.AvatarCompatibility + .TryAcquireMugshotGenerator(generator)) + { + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + yield break; + yield return null; + } + + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + { + global::S1API.Internal.Compatibility.AvatarCompatibility.ReleaseMugshotGenerator(); + yield break; + } + + var settings = CreateMinimalAvatarSettings(next.AccessoryPath, next.AccessoryColor); + global::S1API.Internal.Compatibility.AvatarCompatibility.CreateRenderInputs( + settings, + out var appearance, + out var outfit); + + bool completed = false; + Texture2D? capturedTexture = null; + try + { + global::S1API.Internal.Compatibility.AvatarCompatibility.StartPortraitCapture( + generator, + appearance, + outfit, + (Action)(texture => + { + capturedTexture = texture; + completed = true; + })); + } + catch (Exception ex) + { + Logger.Error($"Failed to start accessory icon capture: {ex.Message}"); + completed = true; + } + + var waitFrames = 0; + while (!completed && waitFrames++ < 300) + yield return null; + + if (renderingEpoch != global::S1API.Internal.Compatibility.AvatarCompatibility.RenderingEpoch) + { + UnityEngine.Object.Destroy(outfit); + UnityEngine.Object.Destroy(settings); + yield break; + } + + global::S1API.Internal.Compatibility.AvatarCompatibility.ReleaseMugshotGenerator(); + UnityEngine.Object.Destroy(outfit); + UnityEngine.Object.Destroy(settings); + capturedTexture?.Apply(); + if (capturedTexture != null) + { + var source = capturedTexture; + capturedTexture = global::S1API.Internal.Compatibility.AvatarCompatibility + .ResizePortrait(source, next.IconSize); + UnityEngine.Object.Destroy(source); + } + if (capturedTexture == null || !HasVisibleContent(capturedTexture)) + { + Logger.Error($"Failed to generate accessory icon for '{next.AccessoryPath}'."); + next.Callback?.Invoke(null); + continue; + } + + next.Callback?.Invoke(capturedTexture); + yield return new WaitForSeconds(0.05f); } #endif } @@ -848,6 +927,15 @@ private static S1AvatarFramework.AvatarSettings CreateMinimalAvatarSettings(stri return settings; } + internal static void ResetAccessoryIconState() + { + lock (_accessoryIconQueueLock) + { + _accessoryIconQueue.Clear(); + _isProcessingAccessoryIcons = false; + } + } + /// /// Creates fallback minimal AvatarSettings when player settings are unavailable. /// diff --git a/S1API/S1API.cs b/S1API/S1API.cs index 9190e353..8dbc4f66 100644 --- a/S1API/S1API.cs +++ b/S1API/S1API.cs @@ -12,7 +12,7 @@ using S1API.Lifecycle; using S1API.Map; -[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.1", "KaBooMa")] +[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.2", "KaBooMa")] [assembly: MelonPriority(Int32.MinValue)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member namespace S1API @@ -37,6 +37,8 @@ public override void OnInitializeMelon() public override void OnDeinitializeMelon() { + Internal.Compatibility.AvatarCompatibility.ResetRendering(); + Rendering.IconFactory.ResetAccessoryIconState(); WeatherRuntime.ResetBindings(); NPCWorkbenchRuntime.Close(); PresentationWorkbenchRuntime.Close(); diff --git a/S1API/S1API.csproj b/S1API/S1API.csproj index ad528183..dc172cc6 100644 --- a/S1API/S1API.csproj +++ b/S1API/S1API.csproj @@ -23,7 +23,7 @@ $(NoWarn);1591 true latest - 3.2.1-beta.1 + 3.2.1-beta.2 diff --git a/S1API/UI/CharacterCreatorManager.cs b/S1API/UI/CharacterCreatorManager.cs index 77e84229..955746b3 100644 --- a/S1API/UI/CharacterCreatorManager.cs +++ b/S1API/UI/CharacterCreatorManager.cs @@ -78,7 +78,14 @@ public static BasicAvatarSettings? ActiveSettings get { EnsureInitialized(); - return null; + if (_s1Creator == null || !_s1Creator.IsOpen) + return null; + + var state = ReflectionUtils.TryGetFieldOrProperty(_s1Creator, "_currentState") + as S1Customization.CharacterCreatorState; + return state?.Appearance == null + ? null + : BasicAvatarSettings.FromPlayerAppearance(state.Appearance); } } diff --git a/S1API/docs/appearance-customization.md b/S1API/docs/appearance-customization.md index e7e11900..f3bc29e0 100644 --- a/S1API/docs/appearance-customization.md +++ b/S1API/docs/appearance-customization.md @@ -2,6 +2,21 @@ The `NPCAppearance` system allows you to customize your NPC's visual appearance, including physical features, clothing, and accessories. +### Schedule I 0.4.7 beta + +The beta uses the game's new naked-appearance and outfit system. Existing +appearance builders and S1API resource-path constants remain the entry points. +S1API translates those settings before applying them to an avatar. + +`Build()` queues portrait generation until a render rig is available. Portraits +use a detached avatar with a forward-facing pose; player look targets, blinking, +and distance impostors do not control the capture. An explicitly assigned NPC +icon still takes precedence. A failed capture leaves the existing icon intact. + +The NPC workbench can import a native NPC's current beta appearance. Imported +layers may use native avatar-object IDs where no legacy resource path is available. +These IDs can also be passed to the appearance layer helpers. + ## Table of Contents 1. [Overview](#overview) diff --git a/S1API/docs/npc-workbench.md b/S1API/docs/npc-workbench.md index a51bd7e8..c8e76735 100644 --- a/S1API/docs/npc-workbench.md +++ b/S1API/docs/npc-workbench.md @@ -30,6 +30,12 @@ Start from default values or import the current appearance of a native or config The preview clones the game's dedicated avatar preview rig into a private render layer. It uses a private camera, lighting, and render texture and never registers or spawns an NPC. Drag the preview to orbit, use the mouse wheel to zoom, and switch between standing, sitting, and crouching poses. +On the Schedule I 0.4.7 beta, S1API creates a detached compatibility rig when +the game has no active mugshot rig. The initial view faces the avatar and includes +the full body. Copied accessories, look targets, distance impostors, and cached +body-shape values are reset before the selected appearance is applied. Reset +returns to the forward-facing standing view. + ## Export Select **Copy C#** to copy an appearance-only builder block: From 90a43dfb174679793be532ac610f557fc3c1cca7 Mon Sep 17 00:00:00 2001 From: ifBars Date: Tue, 22 Sep 2026 16:19:07 -0700 Subject: [PATCH 06/57] fix(NPC): initialize framework data for plain beta prefabs --- S1API/Entities/NPC.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index 59c22937..f7f30c72 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -1016,6 +1016,15 @@ private static GameObject GetOrCreatePerNpcPrefab(System.Type npcType, NPC? owne NormalizeBaseEmployeePrefab(prefabNO.gameObject, sourcePrefabName, rootRole); prefabNO.gameObject.name = prefabName; + // Some native plain NPC prefabs have no framework data object. Attach one + // before ConfigurePrefab applies identity and other saved defaults. + if (rootRole == NpcRootRole.Plain) + { + S1NPCs.NPC? plainNpc = FindPlainNpcComponent(prefabNO.gameObject); + if (plainNpc != null && NPCDataAccess.GetDataObject(plainNpc) == null) + NPCDataAccess.AssignNewData(plainNpc, rootRole, plainNpc); + } + // Ensure template prefab does not execute runtime logic or remain in NPC registry try { From 677795567a3288c42c77c33cebfa129897a536d4 Mon Sep 17 00:00:00 2001 From: ifBars Date: Tue, 22 Sep 2026 17:06:24 -0700 Subject: [PATCH 07/57] chore(release): prepare S1API 3.2.1-beta.3 --- .github/release-notes/3.2.1-beta.3.md | 14 ++++++++++++++ S1API/S1API.cs | 2 +- S1API/S1API.csproj | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 .github/release-notes/3.2.1-beta.3.md diff --git a/.github/release-notes/3.2.1-beta.3.md b/.github/release-notes/3.2.1-beta.3.md new file mode 100644 index 00000000..c6eae554 --- /dev/null +++ b/.github/release-notes/3.2.1-beta.3.md @@ -0,0 +1,14 @@ +## Custom NPCs on the Schedule I 0.4.7 beta + +- Fix plain custom NPCs failing to construct when the native template has no framework data object. This restores both the legacy four-argument constructor and the current `ConfigurePrefab`/`WithIdentity` path. + +## Compatibility and validation + +- Public API signatures, save identifiers, and network payloads are unchanged. +- Both contact paths registered and were found after loading a save in local 0.4.7f5 Mono and IL2CPP beta builds. The Mono and IL2CPP contract suites passed. The reported 0.4.7f6 build was not available for local gameplay validation. +- This is a GitHub-only prerelease for the game beta. It does not publish to NuGet, Nexus Mods, or Thunderstore. +- Addresses [issue #309](https://github.com/ifBars/S1API/issues/309). + +## Downloads + +- [Download S1API-Forked-3.2.1-beta.3.zip](https://github.com/ifBars/S1API/releases/download/v3.2.1-beta.3/S1API-Forked-3.2.1-beta.3.zip) diff --git a/S1API/S1API.cs b/S1API/S1API.cs index 8dbc4f66..4bc22781 100644 --- a/S1API/S1API.cs +++ b/S1API/S1API.cs @@ -12,7 +12,7 @@ using S1API.Lifecycle; using S1API.Map; -[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.2", "KaBooMa")] +[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.3", "KaBooMa")] [assembly: MelonPriority(Int32.MinValue)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member namespace S1API diff --git a/S1API/S1API.csproj b/S1API/S1API.csproj index dc172cc6..762fc661 100644 --- a/S1API/S1API.csproj +++ b/S1API/S1API.csproj @@ -23,7 +23,7 @@ $(NoWarn);1591 true latest - 3.2.1-beta.2 + 3.2.1-beta.3 From f45e96f95a10f86076b051b7714f186a60b2124a Mon Sep 17 00:00:00 2001 From: ifBars Date: Tue, 22 Sep 2026 18:01:34 -0700 Subject: [PATCH 08/57] fix: tolerate f6 consume product event change --- S1API/Entities/NPC.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index f7f30c72..362bb5b4 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -3722,8 +3722,11 @@ private void InitializeBehaviourComponents() } - if (npcBehaviour.ConsumeProductBehaviour.onConsumeDone == null) - npcBehaviour.ConsumeProductBehaviour.onConsumeDone = new UnityEvent(); + // Older game builds expose this UnityEvent; f6 replaced it with a product-consumed event. + var consumeProductBehaviour = npcBehaviour.ConsumeProductBehaviour; + if (consumeProductBehaviour.GetType().GetMember("onConsumeDone", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Length > 0 + && GetGameMember(consumeProductBehaviour, "onConsumeDone") == null) + SetGameMember(consumeProductBehaviour, "onConsumeDone", new UnityEvent()); // UnconsciousBehaviour and DeadBehaviour are required by NPC.IsConscious // which is checked during pickpocketing and other interactions From 64a60d2f42324c68d28344e1709ddadc6a9d607a Mon Sep 17 00:00:00 2001 From: ifBars Date: Tue, 22 Sep 2026 18:32:06 -0700 Subject: [PATCH 09/57] chore(release): prepare S1API 3.2.1-beta.4 --- .github/release-notes/3.2.1-beta.4.md | 15 +++++++++++++++ S1API/S1API.cs | 2 +- S1API/S1API.csproj | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 .github/release-notes/3.2.1-beta.4.md diff --git a/.github/release-notes/3.2.1-beta.4.md b/.github/release-notes/3.2.1-beta.4.md new file mode 100644 index 00000000..d89f74a0 --- /dev/null +++ b/.github/release-notes/3.2.1-beta.4.md @@ -0,0 +1,15 @@ +## Plain custom NPCs on Schedule I 0.4.7f6 + +- Fix both the legacy constructor and `ConfigurePrefab` path failing after f6 removed `ConsumeProductBehaviour.onConsumeDone`. +- Keep the legacy event initialization for older game builds that still expose it. + +## Compatibility and validation + +- No S1API public signatures, save identifiers, or network formats changed. +- Both contact paths registered after loading a save on local 0.4.7f6 Mono and IL2CPP builds. The Mono and IL2CPP contract suites passed. +- This is a GitHub-only prerelease for the game beta. It does not publish to NuGet, Nexus Mods, or Thunderstore. +- Fixes [issue #309](https://github.com/ifBars/S1API/issues/309) with [PR #311](https://github.com/ifBars/S1API/pull/311). + +## Download + +- [S1API-Forked-3.2.1-beta.4.zip](https://github.com/ifBars/S1API/releases/download/v3.2.1-beta.4/S1API-Forked-3.2.1-beta.4.zip) diff --git a/S1API/S1API.cs b/S1API/S1API.cs index 4bc22781..93788add 100644 --- a/S1API/S1API.cs +++ b/S1API/S1API.cs @@ -12,7 +12,7 @@ using S1API.Lifecycle; using S1API.Map; -[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.3", "KaBooMa")] +[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.4", "KaBooMa")] [assembly: MelonPriority(Int32.MinValue)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member namespace S1API diff --git a/S1API/S1API.csproj b/S1API/S1API.csproj index 762fc661..21ca77b8 100644 --- a/S1API/S1API.csproj +++ b/S1API/S1API.csproj @@ -23,7 +23,7 @@ $(NoWarn);1591 true latest - 3.2.1-beta.3 + 3.2.1-beta.4 From d0de2100e45782f81173ef7cf7bbdd24c361e9da Mon Sep 17 00:00:00 2001 From: ifBars Date: Tue, 22 Sep 2026 18:45:06 -0700 Subject: [PATCH 10/57] docs(release): record f6 validation builds --- .github/release-notes/3.2.1-beta.4.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/release-notes/3.2.1-beta.4.md b/.github/release-notes/3.2.1-beta.4.md index d89f74a0..2c749b85 100644 --- a/.github/release-notes/3.2.1-beta.4.md +++ b/.github/release-notes/3.2.1-beta.4.md @@ -6,7 +6,7 @@ ## Compatibility and validation - No S1API public signatures, save identifiers, or network formats changed. -- Both contact paths registered after loading a save on local 0.4.7f6 Mono and IL2CPP builds. The Mono and IL2CPP contract suites passed. +- Both contact paths registered after loading a save on local 0.4.7f6 Mono (Steam build 25439857) and IL2CPP (25439817) installs. The Mono and IL2CPP contract suites passed. - This is a GitHub-only prerelease for the game beta. It does not publish to NuGet, Nexus Mods, or Thunderstore. - Fixes [issue #309](https://github.com/ifBars/S1API/issues/309) with [PR #311](https://github.com/ifBars/S1API/pull/311). From 27ab79ce371a5d058cef493eae05b3ba1321797f Mon Sep 17 00:00:00 2001 From: ifBars Date: Tue, 22 Sep 2026 20:01:32 -0700 Subject: [PATCH 11/57] fix(Delivery): skip unused static dock occupant wrapping --- S1API.Tests/Deliveries/LoadingDockApiTests.cs | 14 ++++++++++++++ S1API/Deliveries/LoadingDock.cs | 2 ++ .../Internal/Deliveries/LoadingDockEventBridge.cs | 6 +++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/S1API.Tests/Deliveries/LoadingDockApiTests.cs b/S1API.Tests/Deliveries/LoadingDockApiTests.cs index 37f99c13..bbf10af8 100644 --- a/S1API.Tests/Deliveries/LoadingDockApiTests.cs +++ b/S1API.Tests/Deliveries/LoadingDockApiTests.cs @@ -82,6 +82,20 @@ public void EventsExposeManagedPreviousAndCurrentValues() GetEvent(nameof(LoadingDock.AcceptingItemsChanged)).EventHandlerType); } + [Fact] + public void StaticOccupantSubscribersAreTracked() + { + var nativeDock = TestObjectFactory.CreateUninitialized(); + var dock = new LoadingDock(nativeDock); + Action handler = (_, _) => { }; + + Assert.False(dock.HasStaticOccupantChangedSubscribers); + dock.StaticOccupantChanged += handler; + Assert.True(dock.HasStaticOccupantChangedSubscribers); + dock.StaticOccupantChanged -= handler; + Assert.False(dock.HasStaticOccupantChangedSubscribers); + } + [Fact] public void ManagedNotificationsSuppressNoOpsAndIsolateSubscribers() { diff --git a/S1API/Deliveries/LoadingDock.cs b/S1API/Deliveries/LoadingDock.cs index f3d5ffd0..33f2118f 100644 --- a/S1API/Deliveries/LoadingDock.cs +++ b/S1API/Deliveries/LoadingDock.cs @@ -144,6 +144,8 @@ public PropertyWrapper? Property /// public event Action? StaticOccupantChanged; + internal bool HasStaticOccupantChangedSubscribers => StaticOccupantChanged != null; + /// /// Raised after the accepting-items state changes. /// diff --git a/S1API/Internal/Deliveries/LoadingDockEventBridge.cs b/S1API/Internal/Deliveries/LoadingDockEventBridge.cs index 59102655..213ebd04 100644 --- a/S1API/Internal/Deliveries/LoadingDockEventBridge.cs +++ b/S1API/Internal/Deliveries/LoadingDockEventBridge.cs @@ -37,7 +37,11 @@ internal static void NotifyStaticOccupantChanged( if (previous == current) return; - LoadingDock.Wrap(native).NotifyStaticOccupantChanged( + var dock = LoadingDock.Wrap(native); + if (!dock.HasStaticOccupantChangedSubscribers) + return; + + dock.NotifyStaticOccupantChanged( VehicleRegistry.Wrap(previous), VehicleRegistry.Wrap(current)); } From a5d9ab0458a628dadc1ec40476b354224ebe67d3 Mon Sep 17 00:00:00 2001 From: ifBars Date: Tue, 22 Sep 2026 21:59:47 -0700 Subject: [PATCH 12/57] fix(NPC): let native Awake initialize custom contacts once --- S1API/Internal/Entities/NPCDataAccess.cs | 8 ++++++++ S1API/Internal/Patches/NPCPatches.cs | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/S1API/Internal/Entities/NPCDataAccess.cs b/S1API/Internal/Entities/NPCDataAccess.cs index 59cd00d8..2dc89f85 100644 --- a/S1API/Internal/Entities/NPCDataAccess.cs +++ b/S1API/Internal/Entities/NPCDataAccess.cs @@ -90,6 +90,14 @@ internal static void InitializeCurrentDataForConstruction(S1NPCs.NPC npc) SetCurrentData(npc, data); } + internal static void PrepareForNativeAwake(S1NPCs.NPC npc) + { + // Construction reads NPCData while the object is inactive. Native Awake must + // initialize it from the configured data object without deinitializing first. + if (GetDataObject(npc) != null) + SetCurrentData(npc, null!); + } + internal static bool ApplyIdentity( S1NPCs.NPC npc, string? id, diff --git a/S1API/Internal/Patches/NPCPatches.cs b/S1API/Internal/Patches/NPCPatches.cs index 3c83778b..67924667 100644 --- a/S1API/Internal/Patches/NPCPatches.cs +++ b/S1API/Internal/Patches/NPCPatches.cs @@ -1149,6 +1149,18 @@ private static bool NPC_Awake_Prefix(S1NPCs.NPC __instance) { identity.ApplyCriticalIdentityBeforeAwake(__instance!); } + + bool isCustomNpc = IsS1ApiCustomNpcComponent(__instance!); + if (!isCustomNpc) + { + // Legacy contacts can be activated without the prefab identity component. + string npcId = NPCDataAccess.GetId(__instance!); + isCustomNpc = !string.IsNullOrWhiteSpace(npcId) && NPC.All.Any(npc => + npc.IsCustomNPC && string.Equals(npc.ID, npcId, StringComparison.OrdinalIgnoreCase)); + } + + if (isCustomNpc) + NPCDataAccess.PrepareForNativeAwake(__instance!); } catch (Exception ex) { From 573dc4043a1f8495fa2abe77b0d3a569dd7f3f31 Mon Sep 17 00:00:00 2001 From: ifBars Date: Tue, 22 Sep 2026 23:17:36 -0700 Subject: [PATCH 13/57] chore(release): prepare S1API 3.2.1-beta.5 --- .github/release-notes/3.2.1-beta.5.md | 15 +++++++++++++++ S1API/S1API.cs | 2 +- S1API/S1API.csproj | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 .github/release-notes/3.2.1-beta.5.md diff --git a/.github/release-notes/3.2.1-beta.5.md b/.github/release-notes/3.2.1-beta.5.md new file mode 100644 index 00000000..276d004a --- /dev/null +++ b/.github/release-notes/3.2.1-beta.5.md @@ -0,0 +1,15 @@ +## Fixes for Schedule I 0.4.7f6 + +- Opening Settings → Gamepad no longer hangs when a loading dock changes its assigned vehicle and no mod is listening for that event. +- Plain custom NPC contacts no longer crash shortly after a save loads. Both the legacy constructor and `ConfigurePrefab` paths now let native `NPC.Awake` initialize their data once. + +## Compatibility and validation + +- No public S1API signatures, save identifiers, or network formats changed. +- The two-contact repro stayed in playable gameplay after load on 0.4.7f6 Mono (Steam build 25439857) and IL2CPP (25439817). The Gamepad panel opened on IL2CPP. Both contract suites passed. +- This is a GitHub-only prerelease for the game beta. It does not publish to NuGet, Nexus Mods, or Thunderstore. +- Fixes [issue #314](https://github.com/ifBars/S1API/issues/314) with [PR #316](https://github.com/ifBars/S1API/pull/316), and [issue #315](https://github.com/ifBars/S1API/issues/315) with [PR #317](https://github.com/ifBars/S1API/pull/317). + +## Download + +- [S1API-Forked-3.2.1-beta.5.zip](https://github.com/ifBars/S1API/releases/download/v3.2.1-beta.5/S1API-Forked-3.2.1-beta.5.zip) diff --git a/S1API/S1API.cs b/S1API/S1API.cs index 93788add..821e1239 100644 --- a/S1API/S1API.cs +++ b/S1API/S1API.cs @@ -12,7 +12,7 @@ using S1API.Lifecycle; using S1API.Map; -[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.4", "KaBooMa")] +[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.5", "KaBooMa")] [assembly: MelonPriority(Int32.MinValue)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member namespace S1API diff --git a/S1API/S1API.csproj b/S1API/S1API.csproj index 21ca77b8..357cb3c5 100644 --- a/S1API/S1API.csproj +++ b/S1API/S1API.csproj @@ -23,7 +23,7 @@ $(NoWarn);1591 true latest - 3.2.1-beta.4 + 3.2.1-beta.5 From d3008c439449e982079077ff269608b04a2e81a4 Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 24 Sep 2026 00:02:15 -0700 Subject: [PATCH 14/57] fix(NPC): refresh message contact info on first load --- S1API/Entities/NPC.cs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index 362bb5b4..f3135b07 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -1431,6 +1431,32 @@ public void RefreshMessagingIcons() if (convo == null) return; + string contactName = GetNpcFullName(); +#if IL2CPPMELON + var sender = convo._sender; +#else + if (Internal.Utils.ReflectionUtils.TryGetFieldOrProperty(convo, "_sender") + is not S1Messaging.MessageContactInfo sender) + return; +#endif + if (!string.IsNullOrWhiteSpace(contactName) && + (!string.Equals(sender.Name, contactName, StringComparison.Ordinal) || sender.Icon != sprite)) + { + var updatedSender = new S1Messaging.MessageContactInfo( + contactName, + ID, + sprite, + sender.CanConversationBeHidden, + sender.DisplayRelationshipInfo); +#if IL2CPPMELON + convo._sender = updatedSender; +#else + if (!Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(convo, "_sender", updatedSender)) + return; +#endif + convo.SetIsKnown(convo.IsSenderKnown); + } + var entryRect = convo.entry ?? ResolveConversationRect(convo, "entry"); var containerRect = ResolveConversationRect(convo, "container"); From d5199811389ddf377a53ed9d54bb7366a0d39fc9 Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 24 Sep 2026 00:02:35 -0700 Subject: [PATCH 15/57] fix(Lifecycle): detach native listeners on scene unload --- S1API/Lifecycle/GameLifecycle.cs | 57 +++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/S1API/Lifecycle/GameLifecycle.cs b/S1API/Lifecycle/GameLifecycle.cs index 817d0bbb..a91e45ad 100644 --- a/S1API/Lifecycle/GameLifecycle.cs +++ b/S1API/Lifecycle/GameLifecycle.cs @@ -30,6 +30,14 @@ namespace S1API.Lifecycle public static class GameLifecycle { private static bool _initialized; + private static S1Persistence.LoadManager? _loadManager; + private static S1Persistence.SaveManager? _saveManager; + private static UnityAction? _preLoadListener; + private static UnityAction? _loadCompleteListener; + private static UnityAction? _preSceneChangeListener; + private static UnityAction? _saveInfoLoadedListener; + private static UnityAction? _saveStartListener; + private static UnityAction? _saveCompleteListener; /// /// Fired before the game begins loading saved data. @@ -151,21 +159,30 @@ internal static void Initialize() return; #if IL2CPPMELON - loadManager.onPreLoad.AddListener((UnityAction)InvokeOnPreLoad); - loadManager.onLoadComplete.AddListener((UnityAction)InvokeOnLoadComplete); - loadManager.onPreSceneChange.AddListener((UnityAction)InvokeOnPreSceneChange); - loadManager.onSaveInfoLoaded.AddListener((UnityAction)InvokeOnSaveInfoLoaded); - saveManager.onSaveStart.AddListener((UnityAction)InvokeOnSaveStart); - saveManager.onSaveComplete.AddListener((UnityAction)InvokeOnSaveComplete); + _preLoadListener ??= (UnityAction)InvokeOnPreLoad; + _loadCompleteListener ??= (UnityAction)InvokeOnLoadComplete; + _preSceneChangeListener ??= (UnityAction)InvokeOnPreSceneChange; + _saveInfoLoadedListener ??= (UnityAction)InvokeOnSaveInfoLoaded; + _saveStartListener ??= (UnityAction)InvokeOnSaveStart; + _saveCompleteListener ??= (UnityAction)InvokeOnSaveComplete; #elif MONOMELON - loadManager.onPreLoad.AddListener(new UnityAction(InvokeOnPreLoad)); - loadManager.onLoadComplete.AddListener(new UnityAction(InvokeOnLoadComplete)); - loadManager.onPreSceneChange.AddListener(new UnityAction(InvokeOnPreSceneChange)); - loadManager.onSaveInfoLoaded.AddListener(new UnityAction(InvokeOnSaveInfoLoaded)); - saveManager.onSaveStart.AddListener(new UnityAction(InvokeOnSaveStart)); - saveManager.onSaveComplete.AddListener(new UnityAction(InvokeOnSaveComplete)); + _preLoadListener ??= new UnityAction(InvokeOnPreLoad); + _loadCompleteListener ??= new UnityAction(InvokeOnLoadComplete); + _preSceneChangeListener ??= new UnityAction(InvokeOnPreSceneChange); + _saveInfoLoadedListener ??= new UnityAction(InvokeOnSaveInfoLoaded); + _saveStartListener ??= new UnityAction(InvokeOnSaveStart); + _saveCompleteListener ??= new UnityAction(InvokeOnSaveComplete); #endif + loadManager.onPreLoad.AddListener(_preLoadListener); + loadManager.onLoadComplete.AddListener(_loadCompleteListener); + loadManager.onPreSceneChange.AddListener(_preSceneChangeListener); + loadManager.onSaveInfoLoaded.AddListener(_saveInfoLoadedListener); + saveManager.onSaveStart.AddListener(_saveStartListener); + saveManager.onSaveComplete.AddListener(_saveCompleteListener); + + _loadManager = loadManager; + _saveManager = saveManager; _initialized = true; } @@ -175,6 +192,22 @@ internal static void Initialize() /// internal static void Reset() { + if (_loadManager != null) + { + _loadManager.onPreLoad.RemoveListener(_preLoadListener); + _loadManager.onLoadComplete.RemoveListener(_loadCompleteListener); + _loadManager.onPreSceneChange.RemoveListener(_preSceneChangeListener); + _loadManager.onSaveInfoLoaded.RemoveListener(_saveInfoLoadedListener); + } + + if (_saveManager != null) + { + _saveManager.onSaveStart.RemoveListener(_saveStartListener); + _saveManager.onSaveComplete.RemoveListener(_saveCompleteListener); + } + + _loadManager = null; + _saveManager = null; _initialized = false; } From 2ced782c4e9aeaa3dc290d9239084969ec8b0c87 Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 24 Sep 2026 21:47:40 -0700 Subject: [PATCH 16/57] fix(NPC): preserve icon refresh when sender lookup fails --- S1API/Entities/NPC.cs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index f3135b07..7c5d2cf3 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -1419,6 +1419,9 @@ private static void InitializeWrapperStateFromNetworkSpawn(NPC wrapper, S1NPCs.N } } + /// + /// Refreshes this NPC's name and icon in its message conversation and visible phone entries. + /// public void RefreshMessagingIcons() { try @@ -1431,6 +1434,12 @@ public void RefreshMessagingIcons() if (convo == null) return; + var entryRect = convo.entry ?? ResolveConversationRect(convo, "entry"); + var containerRect = ResolveConversationRect(convo, "container"); + + TryApplyIconToRect(entryRect, sprite); + TryApplyIconToRect(containerRect, sprite); + string contactName = GetNpcFullName(); #if IL2CPPMELON var sender = convo._sender; @@ -1457,11 +1466,6 @@ public void RefreshMessagingIcons() convo.SetIsKnown(convo.IsSenderKnown); } - var entryRect = convo.entry ?? ResolveConversationRect(convo, "entry"); - var containerRect = ResolveConversationRect(convo, "container"); - - TryApplyIconToRect(entryRect, sprite); - TryApplyIconToRect(containerRect, sprite); } catch (Exception ex) { From f1be38684d6c0814e1fe6b7560da582ea9d91f6e Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 24 Sep 2026 21:49:31 -0700 Subject: [PATCH 17/57] fix(Lifecycle): retain pre-scene listener through menu reload --- S1API/Lifecycle/GameLifecycle.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/S1API/Lifecycle/GameLifecycle.cs b/S1API/Lifecycle/GameLifecycle.cs index a91e45ad..49d6f1a1 100644 --- a/S1API/Lifecycle/GameLifecycle.cs +++ b/S1API/Lifecycle/GameLifecycle.cs @@ -31,6 +31,7 @@ public static class GameLifecycle { private static bool _initialized; private static S1Persistence.LoadManager? _loadManager; + private static S1Persistence.LoadManager? _preSceneChangeManager; private static S1Persistence.SaveManager? _saveManager; private static UnityAction? _preLoadListener; private static UnityAction? _loadCompleteListener; @@ -176,7 +177,14 @@ internal static void Initialize() loadManager.onPreLoad.AddListener(_preLoadListener); loadManager.onLoadComplete.AddListener(_loadCompleteListener); - loadManager.onPreSceneChange.AddListener(_preSceneChangeListener); + if (_preSceneChangeManager != loadManager) + { + if (_preSceneChangeManager != null) + _preSceneChangeManager.onPreSceneChange.RemoveListener(_preSceneChangeListener); + + loadManager.onPreSceneChange.AddListener(_preSceneChangeListener); + _preSceneChangeManager = loadManager; + } loadManager.onSaveInfoLoaded.AddListener(_saveInfoLoadedListener); saveManager.onSaveStart.AddListener(_saveStartListener); saveManager.onSaveComplete.AddListener(_saveCompleteListener); @@ -196,7 +204,6 @@ internal static void Reset() { _loadManager.onPreLoad.RemoveListener(_preLoadListener); _loadManager.onLoadComplete.RemoveListener(_loadCompleteListener); - _loadManager.onPreSceneChange.RemoveListener(_preSceneChangeListener); _loadManager.onSaveInfoLoaded.RemoveListener(_saveInfoLoadedListener); } From 7c8e1070f2b2679d7da251c847c6da320795268c Mon Sep 17 00:00:00 2001 From: ifBars <114284668+ifBars@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:44:26 -0700 Subject: [PATCH 18/57] fix(NPC): preserve contact history through initial network spawn (#325) * fix(NPC): retain messages across initial network spawn * fix(NPC): preserve conversation across both spawn paths --- S1API/Entities/NPC.cs | 35 ++++++++++++++++++++++++++++ S1API/Internal/Patches/NPCPatches.cs | 3 +++ 2 files changed, 38 insertions(+) diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index 7c5d2cf3..2e6ba067 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -28,6 +28,7 @@ using S1Registry = Il2CppScheduleOne.Registry; using S1Money = Il2CppScheduleOne.Money; using ConversationCategoryList = Il2CppSystem.Collections.Generic.List; +using S1MessageConversationData = Il2CppScheduleOne.Persistence.Datas.MSGConversationData; #elif MONOMELON using NativeVehicleLifecycleAction = System.Action; using S1DevUtilities = ScheduleOne.DevUtilities; @@ -58,6 +59,7 @@ using S1Registry = ScheduleOne.Registry; using S1Money = ScheduleOne.Money; using ConversationCategoryList = System.Collections.Generic.List; +using S1MessageConversationData = ScheduleOne.Persistence.Datas.MSGConversationData; #endif #if IL2CPPMELON @@ -4308,6 +4310,7 @@ internal static bool TrySetRequiresRegionUnlocked(object npc, bool value) => private NPCSupplier? _supplier; private NPCRelationship? _relationship; private NPCMessaging? _messaging; + private S1MessageConversationData? _conversationBeforeNetworkSpawn; private ManagedEventRegistrationTracker>? _noticedDrugDealingRegistrations; private ManagedEventRegistrationTracker>? @@ -4360,6 +4363,7 @@ private void TrySpawnNetworkInstance() internal bool PrepareForNetworkSpawn() { + _conversationBeforeNetworkSpawn = null; try { if (!TryValidateNativeAwakeReferences(out string diagnostic)) @@ -4369,6 +4373,8 @@ internal bool PrepareForNetworkSpawn() return false; } + PreserveConversationBeforeNativeAwake(); + NPCDataAccess.PrepareForRuntime(S1NPC); RestoreLoadedRelationship(); @@ -4397,6 +4403,22 @@ internal bool PrepareForNetworkSpawn() } } + internal void PreserveConversationBeforeNativeAwake() + { + var conversation = S1NPC.MSGConversation; + if (_conversationBeforeNetworkSpawn != null || conversation == null) + return; + + try + { + _conversationBeforeNetworkSpawn = conversation.GetSaveData(); + } + catch (Exception ex) + { + Logger.Warning($"[NPC] Could not preserve conversation before spawning '{GetSafeNpcId()}': {ex.Message}"); + } + } + private bool TryValidateNativeAwakeReferences(out string diagnostic) { var missing = new System.Collections.Generic.List(); @@ -4463,6 +4485,19 @@ internal void FinalizeNetworkSpawn() S1NPC.SetVisible(ShouldBeVisibleAfterSpawn(), networked: false); EnsureMessageConversationReady(resetDefaults: false); + var savedConversation = _conversationBeforeNetworkSpawn; + _conversationBeforeNetworkSpawn = null; + if (savedConversation != null && S1NPC.MSGConversation != null && S1NPC.MSGConversation.MessageHistoryCount == 0) + { + try + { + S1NPC.MSGConversation.Load(savedConversation); + } + catch (Exception ex) + { + Logger.Warning($"[NPC] Could not restore conversation after spawning '{GetSafeNpcId()}': {ex.Message}"); + } + } // If we're the server, also broadcast to clients via RPC after a delay // This ensures the NPC is fully spawned before the RPC is sent diff --git a/S1API/Internal/Patches/NPCPatches.cs b/S1API/Internal/Patches/NPCPatches.cs index 67924667..1fbf9cd8 100644 --- a/S1API/Internal/Patches/NPCPatches.cs +++ b/S1API/Internal/Patches/NPCPatches.cs @@ -1160,7 +1160,10 @@ private static bool NPC_Awake_Prefix(S1NPCs.NPC __instance) } if (isCustomNpc) + { + FindWrapperForS1Npc(__instance)?.PreserveConversationBeforeNativeAwake(); NPCDataAccess.PrepareForNativeAwake(__instance!); + } } catch (Exception ex) { From 374eccde7da0dea4e7ed6315a9f87fe5bc831580 Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 24 Sep 2026 22:50:11 -0700 Subject: [PATCH 19/57] chore(release): bump beta version to 3.2.1-beta.6 --- S1API/S1API.cs | 2 +- S1API/S1API.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/S1API/S1API.cs b/S1API/S1API.cs index 821e1239..2aba536b 100644 --- a/S1API/S1API.cs +++ b/S1API/S1API.cs @@ -12,7 +12,7 @@ using S1API.Lifecycle; using S1API.Map; -[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.5", "KaBooMa")] +[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.6", "KaBooMa")] [assembly: MelonPriority(Int32.MinValue)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member namespace S1API diff --git a/S1API/S1API.csproj b/S1API/S1API.csproj index 357cb3c5..fb2193ed 100644 --- a/S1API/S1API.csproj +++ b/S1API/S1API.csproj @@ -23,7 +23,7 @@ $(NoWarn);1591 true latest - 3.2.1-beta.5 + 3.2.1-beta.6 From 97811918a2856a4162f465e305e65ed48f18f79c Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 24 Sep 2026 22:51:22 -0700 Subject: [PATCH 20/57] docs(release): add 3.2.1-beta.6 notes --- .github/release-notes/3.2.1-beta.6.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/release-notes/3.2.1-beta.6.md diff --git a/.github/release-notes/3.2.1-beta.6.md b/.github/release-notes/3.2.1-beta.6.md new file mode 100644 index 00000000..398067aa --- /dev/null +++ b/.github/release-notes/3.2.1-beta.6.md @@ -0,0 +1,20 @@ +## Fixes for Schedule I 0.4.7f6 + +- Custom phone contacts now show the correct sender information on the first save load, including when the native contact lookup cannot find a sender yet. Fixes [#320](https://github.com/ifBars/S1API/issues/320) through [PR #322](https://github.com/ifBars/S1API/pull/322). +- `GameLifecycle.OnLoadComplete` listeners registered before entering the main scene are retained across a return to the menu and do not fire twice on the next load. Fixes [#321](https://github.com/ifBars/S1API/issues/321) through [PR #323](https://github.com/ifBars/S1API/pull/323). +- Texts sent by custom phone contacts during `OnLoadComplete` survive the delayed NPC network spawn and are written to `NPCs.json` on an ordinary save. Fixes [#324](https://github.com/ifBars/S1API/issues/324) through [PR #325](https://github.com/ifBars/S1API/pull/325). + +## Compatibility and validation + +- No public S1API signatures, save identifiers, or network payloads changed. +- The contact and lifecycle fixes were reproduced and checked in the game on Mono and IL2CPP. For the conversation fix, three contacts retained their messages through spawn and save in both runtimes; a fresh IL2CPP load retained the saved history through another spawn and save. +- The `3.2.1-beta.6` source builds without warnings or errors, with 724 Mono and 710 IL2CPP contract tests passing. +- This is a GitHub-only prerelease for the game beta. It does not publish to NuGet, Nexus Mods, or Thunderstore. + +## Known issue + +- [#319](https://github.com/ifBars/S1API/issues/319), a reported `VOEmitter` rejection on a second load, remains under investigation. It has not reproduced with the available local fixture and is not claimed as fixed here. + +## Download + +- [S1API-Forked-3.2.1-beta.6.zip](https://github.com/ifBars/S1API/releases/download/v3.2.1-beta.6/S1API-Forked-3.2.1-beta.6.zip) From 07f04b3c0e8eea4f1a1cd43a10c78c658b01823b Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 26 Sep 2026 18:45:05 -0700 Subject: [PATCH 21/57] feat: expose external phone and TV hosting contract --- .../ExternalAppCatalogTests.cs | 129 ++++++++++++ S1API/ExternalHosting/ExternalAppCatalog.cs | 190 ++++++++++++++++++ S1API/ExternalHosting/IExternalAppHost.cs | 42 ++++ S1API/Internal/Patches/HomeScreen.Start.cs | 3 + S1API/Internal/Patches/TVPatches.cs | 6 +- S1API/PhoneApp/PhoneApp.cs | 4 + S1API/TVApp/TVApp.cs | 8 +- 7 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 S1API.Tests/ExternalHosting/ExternalAppCatalogTests.cs create mode 100644 S1API/ExternalHosting/ExternalAppCatalog.cs create mode 100644 S1API/ExternalHosting/IExternalAppHost.cs diff --git a/S1API.Tests/ExternalHosting/ExternalAppCatalogTests.cs b/S1API.Tests/ExternalHosting/ExternalAppCatalogTests.cs new file mode 100644 index 00000000..634a75a3 --- /dev/null +++ b/S1API.Tests/ExternalHosting/ExternalAppCatalogTests.cs @@ -0,0 +1,129 @@ +using S1API.ExternalHosting; +using UnityEngine; + +namespace S1API.Tests.ExternalHosting; + +[CollectionDefinition("External app catalog", DisableParallelization = true)] +public sealed class ExternalAppCatalogCollection { } + +[Collection("External app catalog")] +public sealed class ExternalAppCatalogTests : IDisposable +{ + public ExternalAppCatalogTests() + { + ExternalAppCatalog.Clear(ExternalAppFamily.Phone); + ExternalAppCatalog.Clear(ExternalAppFamily.TV); + } + + [Fact] + public void HostablePhoneAndTvAppsHaveSeparateStableIdentities() + { + var phone = new SampleHost(); + var tv = new SampleHost(); + + ExternalAppCatalog.Register(ExternalAppFamily.Phone, phone, "sample", "Phone sample", () => null); + ExternalAppCatalog.Register(ExternalAppFamily.TV, tv, "sample", "TV sample", () => null); + + ExternalAppRegistration[] entries = ExternalAppCatalog.GetAll().ToArray(); + Assert.Equal(2, entries.Length); + Assert.NotEqual(entries[0].Id, entries[1].Id); + Assert.Contains(entries, entry => entry.Family == ExternalAppFamily.Phone && + entry.Title == "Phone sample" && ReferenceEquals(entry.Host, phone)); + Assert.Contains(entries, entry => entry.Family == ExternalAppFamily.TV && + entry.Title == "TV sample" && ReferenceEquals(entry.Host, tv)); + } + + [Fact] + public void LateRegistrationReplacementAndRemovalNotifyConsumers() + { + int changes = 0; + void OnChanged() => changes++; + ExternalAppCatalog.Changed += OnChanged; + try + { + var first = new SampleHost(); + var next = new SampleHost(); + ExternalAppCatalog.Register(ExternalAppFamily.Phone, first, "sample", "Sample", () => null); + Assert.Single(ExternalAppCatalog.GetAll()); + Assert.Equal(1, changes); + + ExternalAppCatalog.Register(ExternalAppFamily.Phone, first, "sample", "Sample", () => null); + Assert.Equal(1, changes); + + ExternalAppCatalog.Register(ExternalAppFamily.Phone, next, "sample", "Sample", () => null); + Assert.Same(next, Assert.Single(ExternalAppCatalog.GetAll()).Host); + Assert.Equal(2, changes); + + ExternalAppCatalog.Unregister(first); + Assert.Single(ExternalAppCatalog.GetAll()); + Assert.Equal(2, changes); + + ExternalAppCatalog.Unregister(next); + Assert.Empty(ExternalAppCatalog.GetAll()); + Assert.Equal(3, changes); + } + finally + { + ExternalAppCatalog.Changed -= OnChanged; + } + } + + [Fact] + public void OptedOutAndLegacyAppsDoNotEnterCatalog() + { + ExternalAppCatalog.Register(ExternalAppFamily.Phone, new SampleHost { AllowExternalHosting = false }, + "disabled", "Disabled", () => null); + ExternalAppCatalog.Register(ExternalAppFamily.TV, new object(), + "legacy", "Legacy", () => null); + + Assert.Empty(ExternalAppCatalog.GetAll()); + Assert.Contains(ExternalAppCatalog.GetDiagnostics(), diagnostic => + diagnostic.Family == ExternalAppFamily.TV && diagnostic.Message.Contains("IExternalAppHost")); + } + + [Fact] + public void ClearingOneDeviceFamilyPreservesTheOther() + { + ExternalAppCatalog.Register(ExternalAppFamily.Phone, new SampleHost(), "phone", "Phone", () => null); + ExternalAppCatalog.Register(ExternalAppFamily.TV, new SampleHost(), "tv", "TV", () => null); + + ExternalAppCatalog.Clear(ExternalAppFamily.Phone); + + Assert.Equal(ExternalAppFamily.TV, Assert.Single(ExternalAppCatalog.GetAll()).Family); + } + + [Fact] + public void SceneChangeClearsRegistrationsAndLegacyDiagnostics() + { + ExternalAppCatalog.Register(ExternalAppFamily.Phone, new SampleHost(), "phone", "Phone", () => null); + ExternalAppCatalog.Register(ExternalAppFamily.TV, new object(), "legacy", "Legacy", () => null); + + ExternalAppCatalog.ClearForSceneChange(); + + Assert.Empty(ExternalAppCatalog.GetAll()); + Assert.Empty(ExternalAppCatalog.GetDiagnostics()); + } + + public void Dispose() + { + ExternalAppCatalog.Clear(ExternalAppFamily.Phone); + ExternalAppCatalog.Clear(ExternalAppFamily.TV); + } + + // A modded phone or TV app can implement this contract without referencing a display mod. + private sealed class SampleHost : IExternalAppHost + { + public bool AllowExternalHosting { get; set; } = true; + + public IExternalAppSession CreateExternalSession(GameObject container, Action requestClose) => + new SampleSession(); + } + + private sealed class SampleSession : IExternalAppSession + { + public void Open() { } + public void Tick() { } + public void Close() { } + public void Dispose() { } + } +} diff --git a/S1API/ExternalHosting/ExternalAppCatalog.cs b/S1API/ExternalHosting/ExternalAppCatalog.cs new file mode 100644 index 00000000..733d57b6 --- /dev/null +++ b/S1API/ExternalHosting/ExternalAppCatalog.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MelonLoader; +using UnityEngine; +using S1API.Lifecycle; + +namespace S1API.ExternalHosting +{ + /// The original device family of an externally hostable app. + public enum ExternalAppFamily + { + /// A registered S1API phone app. + Phone, + /// A registered S1API TV app. + TV + } + + /// Detached metadata for one S1API app that permits external hosting. + public sealed class ExternalAppRegistration + { + internal ExternalAppRegistration(ExternalAppFamily family, string id, string title, + IExternalAppHost host, Func icon) + { + Family = family; + Id = id; + Title = title; + Host = host; + ResolveIcon = icon; + } + + /// The original device family. + public ExternalAppFamily Family { get; } + /// A stable desktop identity including family, assembly, type, and native app name. + public string Id { get; } + /// The display title from the S1API app. + public string Title { get; } + /// The S1API app that creates independent sessions. + public IExternalAppHost Host { get; } + /// Returns the mod-owned icon, if available, without transferring ownership. + public Func ResolveIcon { get; } + } + + /// An app omitted from external hosting and the reason for omission. + public sealed class ExternalAppDiagnostic + { + internal ExternalAppDiagnostic(ExternalAppFamily family, object source, string message) + { + Family = family; + Source = source; + TypeName = source.GetType().FullName ?? source.GetType().Name; + Message = message; + } + + /// The original device family. + public ExternalAppFamily Family { get; } + /// The app's managed type name. + public string TypeName { get; } + /// Why the app cannot be independently hosted. + public string Message { get; } + internal object Source { get; } + } + + /// + /// Reports S1API phone and TV apps that explicitly support independent display sessions. + /// Registrations are process-local and callbacks run on the Unity game thread. + /// + public static class ExternalAppCatalog + { + private static readonly Dictionary Entries = + new Dictionary(StringComparer.Ordinal); + private static readonly List Diagnostics = new List(); + + static ExternalAppCatalog() + { + GameLifecycle.OnPreSceneChange += ClearForSceneChange; + } + + /// Raised after the catalog adds, replaces, or removes an entry. + public static event Action? Changed; + + /// Returns a snapshot of currently eligible apps ordered by identity. + public static IReadOnlyList GetAll() => + Entries.Values.OrderBy(entry => entry.Id, StringComparer.Ordinal).ToArray(); + + /// Returns a snapshot of apps that lack an independent host contract. + public static IReadOnlyList GetDiagnostics() => + Diagnostics.ToArray(); + + internal static void Register(ExternalAppFamily family, object app, string name, + string title, Func icon) => + Register(family, app, () => name, () => title, icon); + + internal static void Register(ExternalAppFamily family, object app, Func name, + Func title, Func icon) + { + if (!(app is IExternalAppHost host)) + { + if (!Diagnostics.Any(entry => ReferenceEquals(entry.Source, app))) + { + Diagnostics.Add(new ExternalAppDiagnostic(family, app, + "No independent display session. Implement S1API.ExternalHosting.IExternalAppHost to enable hosting.")); + NotifyChanged(); + } + return; + } + + try + { + if (!host.AllowExternalHosting) + return; + + string nameValue = name(); + string titleValue = title(); + Type type = app.GetType(); + string assembly = type.Assembly.GetName().Name ?? string.Empty; + string fullName = type.FullName ?? string.Empty; + if (string.IsNullOrWhiteSpace(assembly) || string.IsNullOrWhiteSpace(fullName) || + string.IsNullOrWhiteSpace(nameValue) || string.IsNullOrWhiteSpace(titleValue)) + { + MelonLogger.Warning($"[S1API] External app registration has incomplete identity: {type}."); + return; + } + + string id = $"s1api.{family.ToString().ToLowerInvariant()}.{assembly}.{fullName}.{nameValue}"; + if (Entries.TryGetValue(id, out ExternalAppRegistration? previous) && + !ReferenceEquals(previous.Host, host) && previous.Host.GetType() != type) + { + MelonLogger.Warning($"[S1API] External app identity collision '{id}' between " + + $"{previous.Host.GetType().Assembly.FullName} and {type.Assembly.FullName}."); + return; + } + + if (previous != null && ReferenceEquals(previous.Host, host)) + return; + + Entries[id] = new ExternalAppRegistration(family, id, titleValue.Trim(), host, icon); + NotifyChanged(); + } + catch (Exception exception) + { + MelonLogger.Warning($"[S1API] External app registration failed for {app.GetType()}: {exception.Message}"); + } + } + + internal static void Unregister(object app) + { + string[] ids = Entries.Values.Where(entry => ReferenceEquals(entry.Host, app)) + .Select(entry => entry.Id).ToArray(); + foreach (string id in ids) + Entries.Remove(id); + int removedDiagnostics = Diagnostics.RemoveAll(entry => ReferenceEquals(entry.Source, app)); + if (ids.Length > 0 || removedDiagnostics > 0) + NotifyChanged(); + } + + internal static void Clear(ExternalAppFamily family) + { + string[] ids = Entries.Values.Where(entry => entry.Family == family) + .Select(entry => entry.Id).ToArray(); + foreach (string id in ids) + Entries.Remove(id); + int removedDiagnostics = Diagnostics.RemoveAll(entry => entry.Family == family); + if (ids.Length > 0 || removedDiagnostics > 0) + NotifyChanged(); + } + + internal static void ClearForSceneChange() + { + if (Entries.Count == 0 && Diagnostics.Count == 0) + return; + Entries.Clear(); + Diagnostics.Clear(); + NotifyChanged(); + } + + private static void NotifyChanged() + { + Delegate[] listeners = Changed?.GetInvocationList() ?? Array.Empty(); + foreach (Delegate listener in listeners) + { + try { ((Action)listener)(); } + catch (Exception exception) + { + MelonLogger.Warning($"[S1API] External app listener failed: {exception.Message}"); + } + } + } + } +} diff --git a/S1API/ExternalHosting/IExternalAppHost.cs b/S1API/ExternalHosting/IExternalAppHost.cs new file mode 100644 index 00000000..8ea014c2 --- /dev/null +++ b/S1API/ExternalHosting/IExternalAppHost.cs @@ -0,0 +1,42 @@ +using System; +using UnityEngine; + +namespace S1API.ExternalHosting +{ + /// + /// Opts a phone or TV app into creating an independent UI for another display. + /// The original device instance and its canvas remain owned by S1API. + /// + public interface IExternalAppHost + { + /// + /// Whether this app currently permits an independent display session. + /// Return false when an equivalent app is already supplied by the display mod. + /// + bool AllowExternalHosting { get; } + + /// + /// Creates a new session under a display-owned container. The session must not + /// call the phone or TV navigation methods or move the original device UI. + /// + /// The UI root owned by the external display. + /// Closes only the external display session. + /// An independently owned session. + IExternalAppSession CreateExternalSession(GameObject container, Action requestClose); + } + + /// + /// One external display session. Dispose releases all listeners and owned UI resources. + /// + public interface IExternalAppSession : IDisposable + { + /// Starts the independent session. + void Open(); + + /// Updates a visible independent session on the game thread. + void Tick(); + + /// Stops the independent session without navigating the original device. + void Close(); + } +} diff --git a/S1API/Internal/Patches/HomeScreen.Start.cs b/S1API/Internal/Patches/HomeScreen.Start.cs index 6d1ea1e5..2ab1b915 100644 --- a/S1API/Internal/Patches/HomeScreen.Start.cs +++ b/S1API/Internal/Patches/HomeScreen.Start.cs @@ -5,6 +5,7 @@ using S1API.Internal.Abstraction; using S1API.PhoneApp; using S1API.Logging; +using S1API.ExternalHosting; #if (IL2CPPMELON) using Il2CppScheduleOne.UI.Phone; @@ -41,6 +42,8 @@ static void Postfix(HomeScreen __instance) if (!string.Equals(SceneManager.GetActiveScene().name, "Main", StringComparison.OrdinalIgnoreCase)) return; + // The phone registry retains old instances across scene loads; external hosts must not. + ExternalAppCatalog.Clear(ExternalAppFamily.Phone); // Re-register all PhoneApps var phoneApps = ReflectionUtils.GetDerivedClasses(); foreach (var type in phoneApps) diff --git a/S1API/Internal/Patches/TVPatches.cs b/S1API/Internal/Patches/TVPatches.cs index 9ed195a4..ed6c45d1 100644 --- a/S1API/Internal/Patches/TVPatches.cs +++ b/S1API/Internal/Patches/TVPatches.cs @@ -8,6 +8,7 @@ using S1API.Internal.Utils; using S1API.Internal.Abstraction; using S1API.Logging; +using S1API.ExternalHosting; #if IL2CPPMELON using Il2CppScheduleOne.TV; @@ -36,8 +37,11 @@ public static void Register(TVApp.TVApp app) => /// /// Clears all registered TV apps from the registry. /// - public static void Clear() => + public static void Clear() + { + ExternalAppCatalog.Clear(ExternalAppFamily.TV); RegisteredApps.Clear(); + } /// /// Closes all open TV apps. diff --git a/S1API/PhoneApp/PhoneApp.cs b/S1API/PhoneApp/PhoneApp.cs index 3369a24d..44f34fa2 100644 --- a/S1API/PhoneApp/PhoneApp.cs +++ b/S1API/PhoneApp/PhoneApp.cs @@ -6,6 +6,7 @@ using S1API.Internal.Abstraction; using S1API.Internal.Patches; using S1API.Internal.Utils; +using S1API.ExternalHosting; using UnityEngine; using UnityEngine.UI; using Object = UnityEngine.Object; @@ -174,6 +175,8 @@ public enum EOrientation protected override void OnCreated() { PhoneAppRegistry.Register(this); + ExternalAppCatalog.Register(ExternalAppFamily.Phone, this, () => AppName, () => AppTitle, + () => IconSprite); } /// @@ -186,6 +189,7 @@ protected override void OnDestroyed() return; _isDestroying = true; + ExternalAppCatalog.Unregister(this); if (_appPanel != null) { diff --git a/S1API/TVApp/TVApp.cs b/S1API/TVApp/TVApp.cs index 8ac40c41..97488229 100644 --- a/S1API/TVApp/TVApp.cs +++ b/S1API/TVApp/TVApp.cs @@ -6,6 +6,7 @@ using S1API.Internal.Abstraction; using S1API.Internal.Patches; using S1API.Logging; +using S1API.ExternalHosting; using MelonLoader; #if IL2CPPMELON @@ -303,14 +304,19 @@ public void Resume() /// Called when the TVApp instance is created. /// Registers the app with the TVAppRegistry. /// - protected override void OnCreated() => + protected override void OnCreated() + { TVAppRegistry.Register(this); + ExternalAppCatalog.Register(ExternalAppFamily.TV, this, () => AppName, () => AppTitle, + () => Icon); + } /// /// Cleans up resources when the app is destroyed. /// protected override void OnDestroyed() { + ExternalAppCatalog.Unregister(this); // Unregister exit listener if (_exitDelegate != null) { From 2db7aaf7ebd63e6364bbbdb3c9f9e07aa05fd2ab Mon Sep 17 00:00:00 2001 From: ifBars Date: Sun, 27 Sep 2026 00:14:29 -0700 Subject: [PATCH 22/57] fix(NPC): preserve contacts across native initialization and reload --- .../NPCConversationRegistrationTests.cs | 79 +++++++++++++++++++ S1API/Entities/NPC.cs | 10 ++- .../Entities/NPCConversationLifecycle.cs | 70 ++++++++++++++++ .../Internal/Entities/NPCNativeAwakeScope.cs | 71 +++++++++++++++++ S1API/Internal/Patches/NPCPatches.cs | 12 ++- 5 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 S1API.Tests/Entities/NPCConversationRegistrationTests.cs create mode 100644 S1API/Internal/Entities/NPCConversationLifecycle.cs create mode 100644 S1API/Internal/Entities/NPCNativeAwakeScope.cs diff --git a/S1API.Tests/Entities/NPCConversationRegistrationTests.cs b/S1API.Tests/Entities/NPCConversationRegistrationTests.cs new file mode 100644 index 00000000..49c76965 --- /dev/null +++ b/S1API.Tests/Entities/NPCConversationRegistrationTests.cs @@ -0,0 +1,79 @@ +#if MONOMELON +using System.Runtime.CompilerServices; +using S1API.Internal.Entities; +using S1API.Internal.Utils; +using ScheduleOne.Messaging; + +namespace S1API.Tests.Entities; + +public sealed class NPCConversationRegistrationTests +{ + [Fact] + public void SpawnMovesTheSameConversationAndRemovesItsPlaceholderRegistration() + { + var conversation = CreateConversation("messageconversation_65535"); + var registry = new Dictionary { [conversation.ConversationId] = conversation }; + + NPCConversationLifecycle.RebindConversation(conversation, registry, "messageconversation_42"); + + Assert.Single(registry); + Assert.Same(conversation, registry["messageconversation_42"]); + Assert.Equal("messageconversation_42", conversation.ConversationId); + } + + [Fact] + public void MovingOneContactDoesNotRemoveAnotherContactsSharedPlaceholder() + { + var conversation = CreateConversation("messageconversation_65535"); + var other = CreateConversation(conversation.ConversationId); + var registry = new Dictionary { [other.ConversationId] = other }; + + NPCConversationLifecycle.RebindConversation(conversation, registry, "messageconversation_42"); + + Assert.Equal(2, registry.Count); + Assert.Same(other, registry["messageconversation_65535"]); + Assert.Same(conversation, registry["messageconversation_42"]); + } + + [Fact] + public void RepeatedFinalizationDoesNotReplaceOrDuplicateTheConversation() + { + var conversation = CreateConversation("messageconversation_65535"); + var registry = new Dictionary(); + + NPCConversationLifecycle.RebindConversation(conversation, registry, "messageconversation_42"); + NPCConversationLifecycle.RebindConversation(conversation, registry, "messageconversation_42"); + + Assert.Single(registry); + Assert.Same(conversation, registry["messageconversation_42"]); + } + + [Fact] + public void ConflictingSpawnRegistrationLeavesBothConversationsUntouched() + { + var conversation = CreateConversation("messageconversation_65535"); + var other = CreateConversation("messageconversation_42"); + var registry = new Dictionary + { + [conversation.ConversationId] = conversation, + [other.ConversationId] = other + }; + + Assert.Throws(() => + NPCConversationLifecycle.RebindConversation(conversation, registry, other.ConversationId)); + + Assert.Equal("messageconversation_65535", conversation.ConversationId); + Assert.Same(conversation, registry[conversation.ConversationId]); + Assert.Same(other, registry[other.ConversationId]); + } + + private static MSGConversation CreateConversation(string id) + { + // The Mono conversation is a managed class. Avoid its constructor, which registers + // with Unity singletons; no native wrappers or shared registries are created here. + var conversation = (MSGConversation)RuntimeHelpers.GetUninitializedObject(typeof(MSGConversation)); + Assert.True(ReflectionUtils.TrySetFieldOrProperty(conversation, nameof(conversation.ConversationId), id)); + return conversation; + } +} +#endif diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index 2e6ba067..bb6befdd 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -4445,7 +4445,7 @@ private bool TryValidateNativeAwakeReferences(out string diagnostic) ? "Avatar" : "Avatar(active)"); } - else if (activeAvatar.GetComponentInChildren() == null) + else if (activeAvatar.GetComponentInChildren(true) == null) { missing.Add(nameof(S1VoiceOver.VOEmitter)); } @@ -4485,6 +4485,14 @@ internal void FinalizeNetworkSpawn() S1NPC.SetVisible(ShouldBeVisibleAfterSpawn(), networked: false); EnsureMessageConversationReady(resetDefaults: false); + try + { + NPCConversationLifecycle.RebindAfterSpawn(S1NPC); + } + catch (Exception ex) + { + Logger.Warning($"[NPC] Could not rebind conversation after spawning '{GetSafeNpcId()}': {ex.Message}"); + } var savedConversation = _conversationBeforeNetworkSpawn; _conversationBeforeNetworkSpawn = null; if (savedConversation != null && S1NPC.MSGConversation != null && S1NPC.MSGConversation.MessageHistoryCount == 0) diff --git a/S1API/Internal/Entities/NPCConversationLifecycle.cs b/S1API/Internal/Entities/NPCConversationLifecycle.cs new file mode 100644 index 00000000..f9eb3616 --- /dev/null +++ b/S1API/Internal/Entities/NPCConversationLifecycle.cs @@ -0,0 +1,70 @@ +#if IL2CPPMELON +using S1DevUtilities = Il2CppScheduleOne.DevUtilities; +using S1Messaging = Il2CppScheduleOne.Messaging; +using S1NPCs = Il2CppScheduleOne.NPCs; +using ConversationRegistry = Il2CppSystem.Collections.Generic.Dictionary; +#else +using S1DevUtilities = ScheduleOne.DevUtilities; +using S1Messaging = ScheduleOne.Messaging; +using S1NPCs = ScheduleOne.NPCs; +using ConversationRegistry = System.Collections.Generic.Dictionary; +#endif +using System; +using S1API.Internal.Utils; + +namespace S1API.Internal.Entities +{ + internal static class NPCConversationLifecycle + { + internal static void RebindAfterSpawn(S1NPCs.NPC npc) + { + var conversation = npc.MSGConversation; + if (conversation == null || npc.NetworkObject == null || !npc.NetworkObject.IsSpawned) + return; + + // Construction can create a conversation before FishNet assigns an object ID. + // Keep its UI, history and subscriptions, but use the ID native Awake would assign. + string previousId = conversation.ConversationId; + string spawnedId = "messageconversation_" + npc.NetworkObject.ObjectId; + if (string.Equals(previousId, spawnedId, StringComparison.Ordinal)) + return; + + var manager = S1DevUtilities.NetworkSingleton.Instance; + if (manager == null) + return; + +#if IL2CPPMELON + var registry = manager._senderIdConversationMap; +#else + var registry = ReflectionUtils.TryGetFieldOrProperty(manager, "_senderIdConversationMap") + as ConversationRegistry; +#endif + if (registry == null) + throw new InvalidOperationException("The native messaging registry is unavailable."); + + RebindConversation(conversation, registry, spawnedId); + } + + internal static void RebindConversation( + S1Messaging.MSGConversation conversation, + ConversationRegistry registry, + string spawnedId) + { + string previousId = conversation.ConversationId; + if (string.Equals(previousId, spawnedId, StringComparison.Ordinal)) + return; + + if (registry.TryGetValue(spawnedId, out var current) && !conversation.Equals(current)) + throw new InvalidOperationException($"A different conversation is already registered for '{spawnedId}'."); + + if (!ReflectionUtils.TrySetFieldOrProperty(conversation, nameof(conversation.ConversationId), spawnedId)) + throw new InvalidOperationException("Could not update the conversation's native network ID."); + + // Several unspawned contacts can share the native placeholder ID. Never + // remove another contact's registration while moving this conversation. + if (registry.TryGetValue(previousId, out var previous) && conversation.Equals(previous)) + registry.Remove(previousId); + registry[spawnedId] = conversation; + } + } +} diff --git a/S1API/Internal/Entities/NPCNativeAwakeScope.cs b/S1API/Internal/Entities/NPCNativeAwakeScope.cs new file mode 100644 index 00000000..dc7a6cc2 --- /dev/null +++ b/S1API/Internal/Entities/NPCNativeAwakeScope.cs @@ -0,0 +1,71 @@ +#if IL2CPPMELON +using S1Avatar = Il2CppScheduleOne.AvatarFramework.Avatar; +using S1NPC = Il2CppScheduleOne.NPCs.NPC; +using S1NPCData = Il2CppScheduleOne.NPCs.Framework.NPCData; +using S1VOEmitter = Il2CppScheduleOne.VoiceOver.VOEmitter; +#else +using S1Avatar = ScheduleOne.AvatarFramework.Avatar; +using S1NPC = ScheduleOne.NPCs.NPC; +using S1NPCData = ScheduleOne.NPCs.Framework.NPCData; +using S1VOEmitter = ScheduleOne.VoiceOver.VOEmitter; +#endif +using System.Collections.Generic; +using UnityEngine; + +namespace S1API.Internal.Entities +{ + internal sealed class NPCNativeAwakeScope + { + private readonly S1NPC _npc; + private readonly List _hiddenEmitterAncestors = new List(); + private S1NPCData? _originalData; + private bool _createConversationOnStart; + + internal NPCNativeAwakeScope(S1NPC npc) => + _npc = npc; + + internal void Prepare() + { + // Native private helpers may be inlined in IL2CPP. Prepare their inputs at + // Awake itself, then restore them even if native initialization throws. + var avatar = _npc.GetComponentInChildren(true); + var emitter = avatar != null ? avatar.GetComponentInChildren(true) : null; + if (emitter != null) + { + for (Transform current = emitter.transform; current != null && current != avatar!.transform; current = current.parent) + { + if (!current.gameObject.activeSelf) + _hiddenEmitterAncestors.Add(current.gameObject); + } + foreach (GameObject ancestor in _hiddenEmitterAncestors) + ancestor.SetActive(true); + } + + if (_npc.MSGConversation != null) + { + _originalData = NPCDataAccess.GetDataObject(_npc)?.GetOriginalData(); + if (_originalData != null) + { + _createConversationOnStart = _originalData.Messaging.CreateConversationOnStart; + _originalData.Messaging.CreateConversationOnStart = false; + } + } + } + + internal void Restore() + { + if (_originalData != null) + { + _originalData.Messaging.CreateConversationOnStart = _createConversationOnStart; + if (_npc != null && _npc.NPCData != null) + _npc.NPCData.Messaging.CreateConversationOnStart = _createConversationOnStart; + } + + foreach (GameObject ancestor in _hiddenEmitterAncestors) + { + if (ancestor != null) + ancestor.SetActive(false); + } + } + } +} diff --git a/S1API/Internal/Patches/NPCPatches.cs b/S1API/Internal/Patches/NPCPatches.cs index 1fbf9cd8..30a3d977 100644 --- a/S1API/Internal/Patches/NPCPatches.cs +++ b/S1API/Internal/Patches/NPCPatches.cs @@ -1140,8 +1140,9 @@ private static void NPCInventory_Awake_Postfix(S1NPCs.NPCInventory __instance) [HarmonyPatch(typeof(S1NPCs.NPC), "Awake")] [HarmonyPrefix] [HarmonyPriority(Priority.First)] - private static bool NPC_Awake_Prefix(S1NPCs.NPC __instance) + private static bool NPC_Awake_Prefix(S1NPCs.NPC __instance, out NPCNativeAwakeScope? __state) { + __state = null; try { var identity = __instance != null ? __instance.GetComponent() : null; @@ -1162,6 +1163,8 @@ private static bool NPC_Awake_Prefix(S1NPCs.NPC __instance) if (isCustomNpc) { FindWrapperForS1Npc(__instance)?.PreserveConversationBeforeNativeAwake(); + __state = new NPCNativeAwakeScope(__instance!); + __state.Prepare(); NPCDataAccess.PrepareForNativeAwake(__instance!); } } @@ -1173,6 +1176,13 @@ private static bool NPC_Awake_Prefix(S1NPCs.NPC __instance) return true; } + [HarmonyPatch(typeof(S1NPCs.NPC), "Awake")] + [HarmonyFinalizer] + private static void NPC_Awake_Finalizer(NPCNativeAwakeScope? __state) + { + __state?.Restore(); + } + [HarmonyPatch(typeof(S1Economy.Dealer), "Awake")] [HarmonyPrefix] [HarmonyPriority(Priority.First)] From 508f8efd38cede9b9d9b1da8dc082c0ed6220e93 Mon Sep 17 00:00:00 2001 From: ifBars Date: Sun, 27 Sep 2026 00:39:39 -0700 Subject: [PATCH 23/57] fix(Hosting): refresh eligibility and resolve selected phone icons --- .../ExternalAppCatalogTests.cs | 65 +++++++++++++++++++ S1API/ExternalHosting/ExternalAppCatalog.cs | 60 +++++++++++++++-- S1API/ExternalHosting/IExternalAppHost.cs | 2 + S1API/PhoneApp/PhoneApp.cs | 37 +++++++++-- 4 files changed, 153 insertions(+), 11 deletions(-) diff --git a/S1API.Tests/ExternalHosting/ExternalAppCatalogTests.cs b/S1API.Tests/ExternalHosting/ExternalAppCatalogTests.cs index 634a75a3..450c532c 100644 --- a/S1API.Tests/ExternalHosting/ExternalAppCatalogTests.cs +++ b/S1API.Tests/ExternalHosting/ExternalAppCatalogTests.cs @@ -81,6 +81,71 @@ public void OptedOutAndLegacyAppsDoNotEnterCatalog() diagnostic.Family == ExternalAppFamily.TV && diagnostic.Message.Contains("IExternalAppHost")); } + [Fact] + public void RefreshTracksOptInAndOptOutAndOnlyNotifiesForChanges() + { + var host = new SampleHost { AllowExternalHosting = false }; + ExternalAppCatalog.Register(ExternalAppFamily.Phone, host, "sample", "Sample", () => null); + int changes = 0; + void OnChanged() => changes++; + ExternalAppCatalog.Changed += OnChanged; + try + { + ExternalAppCatalog.Refresh(host); + Assert.Empty(ExternalAppCatalog.GetAll()); + Assert.Equal(0, changes); + + host.AllowExternalHosting = true; + ExternalAppCatalog.Refresh(host); + Assert.Same(host, Assert.Single(ExternalAppCatalog.GetAll()).Host); + Assert.Equal(1, changes); + ExternalAppCatalog.Refresh(host); + Assert.Equal(1, changes); + + host.AllowExternalHosting = false; + ExternalAppCatalog.Refresh(host); + Assert.Empty(ExternalAppCatalog.GetAll()); + Assert.Equal(2, changes); + } + finally { ExternalAppCatalog.Changed -= OnChanged; } + } + + [Fact] + public void DisabledReplacementCannotBeResurrectedByRefreshingOldHost() + { + var first = new SampleHost(); + var next = new SampleHost { AllowExternalHosting = false }; + ExternalAppCatalog.Register(ExternalAppFamily.Phone, first, "sample", "Sample", () => null); + ExternalAppCatalog.Register(ExternalAppFamily.Phone, next, "sample", "Sample", () => null); + + ExternalAppCatalog.Refresh(first); + ExternalAppCatalog.Unregister(first); + Assert.Empty(ExternalAppCatalog.GetAll()); + + next.AllowExternalHosting = true; + ExternalAppCatalog.Refresh(next); + Assert.Same(next, Assert.Single(ExternalAppCatalog.GetAll()).Host); + } + + [Theory] + [InlineData("unregister")] + [InlineData("family")] + [InlineData("scene")] + public void RemovedOptedOutHostsCannotReenterTheCatalog(string removal) + { + var host = new SampleHost { AllowExternalHosting = false }; + ExternalAppCatalog.Register(ExternalAppFamily.Phone, host, "sample", "Sample", () => null); + if (removal == "unregister") ExternalAppCatalog.Unregister(host); + else if (removal == "family") ExternalAppCatalog.Clear(ExternalAppFamily.Phone); + else ExternalAppCatalog.ClearForSceneChange(); + + host.AllowExternalHosting = true; + ExternalAppCatalog.Refresh(host); + ExternalAppCatalog.Refresh(null); + + Assert.Empty(ExternalAppCatalog.GetAll()); + } + [Fact] public void ClearingOneDeviceFamilyPreservesTheOther() { diff --git a/S1API/ExternalHosting/ExternalAppCatalog.cs b/S1API/ExternalHosting/ExternalAppCatalog.cs index 733d57b6..410f20a0 100644 --- a/S1API/ExternalHosting/ExternalAppCatalog.cs +++ b/S1API/ExternalHosting/ExternalAppCatalog.cs @@ -69,6 +69,8 @@ public static class ExternalAppCatalog { private static readonly Dictionary Entries = new Dictionary(StringComparer.Ordinal); + private static readonly Dictionary RegisteredApps = + new Dictionary(StringComparer.Ordinal); private static readonly List Diagnostics = new List(); static ExternalAppCatalog() @@ -87,6 +89,45 @@ public static IReadOnlyList GetAll() => public static IReadOnlyList GetDiagnostics() => Diagnostics.ToArray(); + /// + /// Rechecks a registered app after its hosting eligibility changes. Call on the Unity game thread. + /// Raises only when catalog membership changes. Unknown or removed apps are ignored. + /// Existing sessions remain owned by the display mod, which can close them when it handles the change. + /// + /// The registered app whose eligibility changed. Null is ignored. + public static void Refresh(IExternalAppHost? host) + { + if (host == null) + return; + + bool changed = false; + foreach (ExternalAppRegistration entry in RegisteredApps.Values + .Where(entry => ReferenceEquals(entry.Host, host)).ToArray()) + { + try + { + if (host.AllowExternalHosting) + { + if (!Entries.TryGetValue(entry.Id, out var previous) || !ReferenceEquals(previous, entry)) + { + Entries[entry.Id] = entry; + changed = true; + } + } + else + { + changed |= Entries.Remove(entry.Id); + } + } + catch (Exception exception) + { + MelonLogger.Warning($"[S1API] External app eligibility failed for {host.GetType()}: {exception.Message}"); + } + } + if (changed) + NotifyChanged(); + } + internal static void Register(ExternalAppFamily family, object app, string name, string title, Func icon) => Register(family, app, () => name, () => title, icon); @@ -107,9 +148,6 @@ internal static void Register(ExternalAppFamily family, object app, Func try { - if (!host.AllowExternalHosting) - return; - string nameValue = name(); string titleValue = title(); Type type = app.GetType(); @@ -123,7 +161,7 @@ internal static void Register(ExternalAppFamily family, object app, Func } string id = $"s1api.{family.ToString().ToLowerInvariant()}.{assembly}.{fullName}.{nameValue}"; - if (Entries.TryGetValue(id, out ExternalAppRegistration? previous) && + if (RegisteredApps.TryGetValue(id, out ExternalAppRegistration? previous) && !ReferenceEquals(previous.Host, host) && previous.Host.GetType() != type) { MelonLogger.Warning($"[S1API] External app identity collision '{id}' between " + @@ -132,10 +170,13 @@ internal static void Register(ExternalAppFamily family, object app, Func } if (previous != null && ReferenceEquals(previous.Host, host)) + { + Refresh(host); return; + } - Entries[id] = new ExternalAppRegistration(family, id, titleValue.Trim(), host, icon); - NotifyChanged(); + RegisteredApps[id] = new ExternalAppRegistration(family, id, titleValue.Trim(), host, icon); + Refresh(host); } catch (Exception exception) { @@ -145,6 +186,9 @@ internal static void Register(ExternalAppFamily family, object app, Func internal static void Unregister(object app) { + foreach (string id in RegisteredApps.Values.Where(entry => ReferenceEquals(entry.Host, app)) + .Select(entry => entry.Id).ToArray()) + RegisteredApps.Remove(id); string[] ids = Entries.Values.Where(entry => ReferenceEquals(entry.Host, app)) .Select(entry => entry.Id).ToArray(); foreach (string id in ids) @@ -156,6 +200,9 @@ internal static void Unregister(object app) internal static void Clear(ExternalAppFamily family) { + foreach (string id in RegisteredApps.Values.Where(entry => entry.Family == family) + .Select(entry => entry.Id).ToArray()) + RegisteredApps.Remove(id); string[] ids = Entries.Values.Where(entry => entry.Family == family) .Select(entry => entry.Id).ToArray(); foreach (string id in ids) @@ -167,6 +214,7 @@ internal static void Clear(ExternalAppFamily family) internal static void ClearForSceneChange() { + RegisteredApps.Clear(); if (Entries.Count == 0 && Diagnostics.Count == 0) return; Entries.Clear(); diff --git a/S1API/ExternalHosting/IExternalAppHost.cs b/S1API/ExternalHosting/IExternalAppHost.cs index 8ea014c2..eef09149 100644 --- a/S1API/ExternalHosting/IExternalAppHost.cs +++ b/S1API/ExternalHosting/IExternalAppHost.cs @@ -12,6 +12,8 @@ public interface IExternalAppHost /// /// Whether this app currently permits an independent display session. /// Return false when an equivalent app is already supplied by the display mod. + /// After changing this value, call on the game thread + /// to update catalog membership and notify displays. /// bool AllowExternalHosting { get; } diff --git a/S1API/PhoneApp/PhoneApp.cs b/S1API/PhoneApp/PhoneApp.cs index 44f34fa2..a66e3d2c 100644 --- a/S1API/PhoneApp/PhoneApp.cs +++ b/S1API/PhoneApp/PhoneApp.cs @@ -73,6 +73,8 @@ public abstract class PhoneApp : Registerable /// If set before the icon exists, this sprite will be applied once the icon spawns. /// private Sprite? _pendingIconSprite; + private Sprite? _fileIconSprite; + private string? _fileIconName; /// /// Reference to the home screen instance for managing app state transitions. @@ -176,7 +178,17 @@ protected override void OnCreated() { PhoneAppRegistry.Register(this); ExternalAppCatalog.Register(ExternalAppFamily.Phone, this, () => AppName, () => AppTitle, - () => IconSprite); + ResolveExternalIcon); + } + + private Sprite? ResolveExternalIcon() + { + if (_pendingIconSprite != null) + return _pendingIconSprite; + if (_iconImage != null && _iconImage.sprite != null) + return _iconImage.sprite; + Sprite? direct = IconSprite; + return direct != null ? direct : LoadFileIcon(IconFileName); } /// @@ -202,6 +214,8 @@ protected override void OnDestroyed() _iconModified = false; _iconImage = null; _pendingIconSprite = null; + _fileIconSprite = null; + _fileIconName = null; // Unsubscribe from phone events if subscribed if (Phone.InstanceExists && _closeAppAction != null) @@ -651,11 +665,23 @@ private bool ChangeAppIconImage(GameObject iconObj, string filename) return false; } + Sprite? sprite = LoadFileIcon(filename); + if (sprite == null) + return false; + image.sprite = sprite; + return true; + } + + private Sprite? LoadFileIcon(string filename) + { + if (_fileIconSprite != null && string.Equals(_fileIconName, filename, StringComparison.Ordinal)) + return _fileIconSprite; + string path = Path.Combine(MelonEnvironment.ModsDirectory, filename); if (!File.Exists(path)) { Logger.Error("Icon file not found: " + path); - return false; + return null; } try @@ -664,8 +690,9 @@ private bool ChangeAppIconImage(GameObject iconObj, string filename) Texture2D tex = new Texture2D(2, 2); if (tex.LoadImage(bytes)) { - image.sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f)); - return true; + _fileIconSprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f)); + _fileIconName = filename; + return _fileIconSprite; } Object.Destroy(tex); } @@ -674,7 +701,7 @@ private bool ChangeAppIconImage(GameObject iconObj, string filename) Logger.Error("Failed to load image: " + e.Message); } - return false; + return null; } /// From db7f343e71ea98363e815654206abc98e09b78f5 Mon Sep 17 00:00:00 2001 From: ifBars Date: Sun, 27 Sep 2026 00:39:47 -0700 Subject: [PATCH 24/57] fix(NPC): rebind client conversations and repair missing registrations --- .../NPCConversationRegistrationTests.cs | 24 ++++++++++++++ S1API/Entities/NPC.cs | 1 + .../Entities/NPCConversationLifecycle.cs | 33 +++++++++++++++---- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/S1API.Tests/Entities/NPCConversationRegistrationTests.cs b/S1API.Tests/Entities/NPCConversationRegistrationTests.cs index 49c76965..62dfa7bf 100644 --- a/S1API.Tests/Entities/NPCConversationRegistrationTests.cs +++ b/S1API.Tests/Entities/NPCConversationRegistrationTests.cs @@ -48,6 +48,30 @@ public void RepeatedFinalizationDoesNotReplaceOrDuplicateTheConversation() Assert.Same(conversation, registry["messageconversation_42"]); } + [Fact] + public void MatchingIdRestoresMissingRegistration() + { + var conversation = CreateConversation("messageconversation_42"); + var registry = new Dictionary(); + + NPCConversationLifecycle.RebindConversation(conversation, registry, conversation.ConversationId); + + Assert.Same(conversation, Assert.Single(registry).Value); + } + + [Fact] + public void MatchingIdStillRejectsAnotherConversationsRegistration() + { + var conversation = CreateConversation("messageconversation_42"); + var other = CreateConversation(conversation.ConversationId); + var registry = new Dictionary { [other.ConversationId] = other }; + + Assert.Throws(() => + NPCConversationLifecycle.RebindConversation(conversation, registry, conversation.ConversationId)); + + Assert.Same(other, Assert.Single(registry).Value); + } + [Fact] public void ConflictingSpawnRegistrationLeavesBothConversationsUntouched() { diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index bb6befdd..fe337759 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -3480,6 +3480,7 @@ internal override void CreateInternal() internal void CreateFromClientNetworkSpawn() { + MelonCoroutines.Start(NPCConversationLifecycle.RebindWhenSpawned(S1NPC)); _clientNetworkSpawnHydrationDepth++; try { diff --git a/S1API/Internal/Entities/NPCConversationLifecycle.cs b/S1API/Internal/Entities/NPCConversationLifecycle.cs index f9eb3616..9626801b 100644 --- a/S1API/Internal/Entities/NPCConversationLifecycle.cs +++ b/S1API/Internal/Entities/NPCConversationLifecycle.cs @@ -10,12 +10,32 @@ using ConversationRegistry = System.Collections.Generic.Dictionary; #endif using System; +using System.Collections; using S1API.Internal.Utils; namespace S1API.Internal.Entities { internal static class NPCConversationLifecycle { + internal static IEnumerator RebindWhenSpawned(S1NPCs.NPC npc) + { + // Client Start can precede FishNet assigning the final object ID. + while (npc != null && npc.NetworkObject != null && !npc.NetworkObject.IsSpawned) + yield return null; + + if (npc == null) + yield break; + + try + { + RebindAfterSpawn(npc); + } + catch (Exception ex) + { + MelonLoader.MelonLogger.Warning($"[NPC] Could not rebind client conversation for '{npc.ID}': {ex.Message}"); + } + } + internal static void RebindAfterSpawn(S1NPCs.NPC npc) { var conversation = npc.MSGConversation; @@ -24,10 +44,7 @@ internal static void RebindAfterSpawn(S1NPCs.NPC npc) // Construction can create a conversation before FishNet assigns an object ID. // Keep its UI, history and subscriptions, but use the ID native Awake would assign. - string previousId = conversation.ConversationId; string spawnedId = "messageconversation_" + npc.NetworkObject.ObjectId; - if (string.Equals(previousId, spawnedId, StringComparison.Ordinal)) - return; var manager = S1DevUtilities.NetworkSingleton.Instance; if (manager == null) @@ -51,12 +68,16 @@ internal static void RebindConversation( string spawnedId) { string previousId = conversation.ConversationId; - if (string.Equals(previousId, spawnedId, StringComparison.Ordinal)) - return; - if (registry.TryGetValue(spawnedId, out var current) && !conversation.Equals(current)) throw new InvalidOperationException($"A different conversation is already registered for '{spawnedId}'."); + if (string.Equals(previousId, spawnedId, StringComparison.Ordinal)) + { + if (current == null) + registry[spawnedId] = conversation; + return; + } + if (!ReflectionUtils.TrySetFieldOrProperty(conversation, nameof(conversation.ConversationId), spawnedId)) throw new InvalidOperationException("Could not update the conversation's native network ID."); From e4938344aebb569611c3d51536305bed36d463a1 Mon Sep 17 00:00:00 2001 From: ifBars Date: Sun, 27 Sep 2026 00:51:03 -0700 Subject: [PATCH 25/57] fix(NPC): schedule client registration from native startup --- S1API/Entities/NPC.cs | 1 - S1API/Internal/Patches/NPCPatches.cs | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index fe337759..bb6befdd 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -3480,7 +3480,6 @@ internal override void CreateInternal() internal void CreateFromClientNetworkSpawn() { - MelonCoroutines.Start(NPCConversationLifecycle.RebindWhenSpawned(S1NPC)); _clientNetworkSpawnHydrationDepth++; try { diff --git a/S1API/Internal/Patches/NPCPatches.cs b/S1API/Internal/Patches/NPCPatches.cs index 30a3d977..d480e800 100644 --- a/S1API/Internal/Patches/NPCPatches.cs +++ b/S1API/Internal/Patches/NPCPatches.cs @@ -1305,6 +1305,7 @@ private static void NPCStart(S1NPCs.NPC __instance) apiNpc.CreateInternal(); else { + MelonCoroutines.Start(NPCConversationLifecycle.RebindWhenSpawned(__instance)); apiNpc.CreateFromClientNetworkSpawn(); } From d3b874d4b2b8c3aee0487fd4026848b905b679b9 Mon Sep 17 00:00:00 2001 From: ifBars Date: Sun, 27 Sep 2026 00:57:02 -0700 Subject: [PATCH 26/57] chore(release): prepare S1API 3.2.1-beta.7 --- .github/release-notes/3.2.1-beta.7.md | 18 ++++++++++++++++++ S1API/S1API.cs | 2 +- S1API/S1API.csproj | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 .github/release-notes/3.2.1-beta.7.md diff --git a/.github/release-notes/3.2.1-beta.7.md b/.github/release-notes/3.2.1-beta.7.md new file mode 100644 index 00000000..c5d44b54 --- /dev/null +++ b/.github/release-notes/3.2.1-beta.7.md @@ -0,0 +1,18 @@ +## Changes for Schedule I 0.4.7f6 + +- Fixed custom phone contacts being rejected after returning to the menu and loading again when their avatar body was culled. Fixes [#319](https://github.com/ifBars/S1API/issues/319). +- Fixed duplicate Messages entries for custom contacts on the first load. Existing conversations and message history are retained through network initialization. Fixes [#327](https://github.com/ifBars/S1API/issues/327). +- Client contacts now update their conversation registration after network spawn. Rebinding also restores missing registrations and preserves entries owned by other contacts. +- Added opt-in external hosting for phone and TV apps. Apps can create independent display sessions without moving their original device UI. `ExternalAppCatalog.Refresh(host)` updates hosting eligibility, and the icon resolver includes file icons and sprites selected through `SetIconSprite`. See [PR #328](https://github.com/ifBars/S1API/pull/328). + +## Compatibility and validation + +Existing public signatures, NPC save identifiers, and network payload formats are unchanged. External hosting is additive and requires app opt-in. + +The combined candidate passed 740 Mono and 720 IL2CPP contract tests. The original contact fixes also passed live first-load, menu-reload, and saved-reload checks on both runtimes. Review follow-ups have build and contract coverage; additional live icon and two-peer checks were interrupted before their feature assertions and are not claimed as passing. + +This is a GitHub-only prerelease for the game beta. It does not publish to NuGet, Nexus Mods, or Thunderstore. + +## Download + +[S1API-Forked-3.2.1-beta.7.zip](https://github.com/ifBars/S1API/releases/download/v3.2.1-beta.7/S1API-Forked-3.2.1-beta.7.zip) diff --git a/S1API/S1API.cs b/S1API/S1API.cs index 2aba536b..8becf64f 100644 --- a/S1API/S1API.cs +++ b/S1API/S1API.cs @@ -12,7 +12,7 @@ using S1API.Lifecycle; using S1API.Map; -[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.6", "KaBooMa")] +[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.7", "KaBooMa")] [assembly: MelonPriority(Int32.MinValue)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member namespace S1API diff --git a/S1API/S1API.csproj b/S1API/S1API.csproj index fb2193ed..c1931c40 100644 --- a/S1API/S1API.csproj +++ b/S1API/S1API.csproj @@ -23,7 +23,7 @@ $(NoWarn);1591 true latest - 3.2.1-beta.6 + 3.2.1-beta.7 From b48d58edf5a4a3176453249bd75967ef0b0e7ec1 Mon Sep 17 00:00:00 2001 From: r-melvin <40235254+r-melvin@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:33:55 +0100 Subject: [PATCH 27/57] fix: name the packaging-icon patch signature so it binds beside added overloads ProductPackagingContentPatches named IconGenerator.GeneratePackagingIcon by name only. The game has one method of that name, (string, string, int), but Polyfill rewrites the IL2CPP interop assembly and adds a (string, string) compatibility overload. With Polyfill installed Harmony cannot choose ("Ambiguous match found") and MelonLoader skips the whole patch class, so custom product/package visuals and icons are unpatched. Without Polyfill, and on Mono, it bound as before. Both patches now name the game's signature. A new test resolves every attribute-declared patch target in S1API against the referenced game assembly and fails when a name matches no method or more than one. It catches this only when the referenced interop assembly has the extra overload (as a Polyfill-rewritten one does); against a stock assembly these two patches already resolved. Co-Authored-By: Claude Opus 5.5 --- .../Patches/HarmonyPatchTargetTests.cs | 94 +++++++++++++++++++ S1API.Tests/S1API.Tests.csproj | 6 ++ .../Patches/ProductPackagingContentPatches.cs | 6 +- 3 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 S1API.Tests/Internal/Patches/HarmonyPatchTargetTests.cs diff --git a/S1API.Tests/Internal/Patches/HarmonyPatchTargetTests.cs b/S1API.Tests/Internal/Patches/HarmonyPatchTargetTests.cs new file mode 100644 index 00000000..b68ba0ab --- /dev/null +++ b/S1API.Tests/Internal/Patches/HarmonyPatchTargetTests.cs @@ -0,0 +1,94 @@ +using System.Reflection; +using HarmonyLib; + +namespace S1API.Tests.Internal.Patches; + +public sealed class HarmonyPatchTargetTests +{ + private const BindingFlags AllDeclared = + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | + BindingFlags.Static | BindingFlags.DeclaredOnly; + + [Fact] + public void AttributePatchesNameExactlyOneGameMethod() + { + // A patch named only by method name fails to bind when the type has several methods of that name, and + // Harmony then skips its whole patch class. Compatibility tools can add overloads to the game assembly + // (Polyfill adds GeneratePackagingIcon(string, string)), so name the signature wherever there is a choice. + // Covers attribute-declared methods only: classes with TargetMethod(s) and getters/setters/constructors are skipped. + List problems = FindUnresolvablePatchTargets(); + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + } + + private static List FindUnresolvablePatchTargets() + { + var problems = new List(); + foreach (Type patchClass in AllTypes(typeof(global::S1API.Internal.Patches.NPCPatches).Assembly)) + { + List classInfo = HarmonyMethodExtensions.GetFromType(patchClass); + if (classInfo.Count == 0 || + patchClass.GetMethod("TargetMethod", AllDeclared) != null || + patchClass.GetMethod("TargetMethods", AllDeclared) != null) + { + continue; + } + + foreach (MethodInfo patch in patchClass.GetMethods(AllDeclared)) + { + List methodInfo = HarmonyMethodExtensions.GetFromMethod(patch); + if (methodInfo.Count == 0) + continue; + + HarmonyMethod info = HarmonyMethod.Merge(classInfo.Concat(methodInfo).ToList()); + if (info.declaringType == null || info.methodName == null || + (info.methodType != null && info.methodType != MethodType.Normal)) + { + continue; + } + + string target = $"{info.declaringType.FullName}.{info.methodName}"; + int matches = CountMatches(info); + if (matches != 1) + { + problems.Add( + $"{patchClass.FullName}.{patch.Name} -> {target}: " + + (matches == 0 ? "no such method" : $"{matches} methods match")); + } + } + } + + return problems; + } + + private static int CountMatches(HarmonyMethod info) + { + // HarmonyX folds ref/out/pointer variations into argumentTypes when it reads the attribute. + Type[]? wanted = info.argumentTypes; + for (Type? type = info.declaringType; type != null; type = type.BaseType) + { + MethodInfo[] named = type.GetMethods(AllDeclared) + .Where(m => m.Name == info.methodName) + .ToArray(); + if (named.Length == 0) + continue; + + return wanted == null + ? named.Length + : named.Count(m => m.GetParameters().Select(p => p.ParameterType).SequenceEqual(wanted)); + } + + return 0; + } + + private static IEnumerable AllTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(t => t != null)!; + } + } +} diff --git a/S1API.Tests/S1API.Tests.csproj b/S1API.Tests/S1API.Tests.csproj index 87670586..e873351d 100644 --- a/S1API.Tests/S1API.Tests.csproj +++ b/S1API.Tests/S1API.Tests.csproj @@ -32,6 +32,9 @@ + + $(MelonLoaderMonoAssembliesPath)/0Harmony.dll + $(MonoAssembliesPath)/Assembly-CSharp.dll @@ -50,6 +53,9 @@ + + $(MelonLoaderAssembliesPath)/0Harmony.dll + diff --git a/S1API/Internal/Patches/ProductPackagingContentPatches.cs b/S1API/Internal/Patches/ProductPackagingContentPatches.cs index ef08dec9..1f3d1d5c 100644 --- a/S1API/Internal/Patches/ProductPackagingContentPatches.cs +++ b/S1API/Internal/Patches/ProductPackagingContentPatches.cs @@ -56,7 +56,8 @@ private static void GenerateIconsPrefix() [HarmonyPatch( typeof(S1DevUtilities.IconGenerator), - nameof(S1DevUtilities.IconGenerator.GeneratePackagingIcon))] + nameof(S1DevUtilities.IconGenerator.GeneratePackagingIcon), + new Type[] { typeof(string), typeof(string), typeof(int) })] [HarmonyPrefix] private static bool GeneratePackagingIconPrefix( S1DevUtilities.IconGenerator __instance, @@ -87,7 +88,8 @@ private static bool GeneratePackagingIconPrefix( [HarmonyPatch( typeof(S1DevUtilities.IconGenerator), - nameof(S1DevUtilities.IconGenerator.GeneratePackagingIcon))] + nameof(S1DevUtilities.IconGenerator.GeneratePackagingIcon), + new Type[] { typeof(string), typeof(string), typeof(int) })] [HarmonyFinalizer] private static Exception? GeneratePackagingIconFinalizer( bool __state, From 1aa75c18dc4eec1dfa0fa1847e335a60bc70be2d Mon Sep 17 00:00:00 2001 From: r-melvin <40235254+r-melvin@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:09:29 +0100 Subject: [PATCH 28/57] fix: custom accessories get their own AvatarObject on 0.4.7 0.4.7 dresses avatars in AvatarObject prefabs, which a legacy Accessory only points at (Accessory.AvatarObjectEquivalent); a clothing item stores the AvatarObject and the avatar finds it again by Id in AvatarObjectLibrary. AccessoryFactory cloned only the legacy prefab, so a custom accessory had no AvatarObject of its own and ClothingItemDefinitionBuilder.WithClothingAsset failed ("Unable to cast object of type 'GameObject' to type 'AvatarObject'", BigWillyMod's Stay Silly Cap). - CloneAccessoryWithCustomTextures also clones the AvatarObjectEquivalent with its own id (the target resource path), the same textures, registered in AvatarObjectLibrary; RegisterAccessory registers it for Resources.Load. - The IL2CPP Resources.Load hook's path fallback only answers with an asset the requested type accepts (FindCompatibleTypedAsset, shared with GetRegisteredAssetForType). It used to return the GameObject. Co-Authored-By: Claude Opus 5.5 --- .../Rendering/CustomAvatarObjectsTests.cs | 43 +++++ .../Rendering/RuntimeResourceRegistryTests.cs | 75 +++++++++ .../Internal/Rendering/CustomAvatarObjects.cs | 154 ++++++++++++++++++ S1API/Rendering/AccessoryFactory.cs | 55 +++++++ S1API/Rendering/RuntimeResourceRegistry.cs | 42 +++-- 5 files changed, 358 insertions(+), 11 deletions(-) create mode 100644 S1API.Tests/Rendering/CustomAvatarObjectsTests.cs create mode 100644 S1API.Tests/Rendering/RuntimeResourceRegistryTests.cs create mode 100644 S1API/Internal/Rendering/CustomAvatarObjects.cs diff --git a/S1API.Tests/Rendering/CustomAvatarObjectsTests.cs b/S1API.Tests/Rendering/CustomAvatarObjectsTests.cs new file mode 100644 index 00000000..0fdb9db7 --- /dev/null +++ b/S1API.Tests/Rendering/CustomAvatarObjectsTests.cs @@ -0,0 +1,43 @@ +using S1API.Internal.Rendering; + +namespace S1API.Tests.Rendering; + +public sealed class CustomAvatarObjectsTests +{ + [Fact] + public void IdIsTheTargetResourcePathWhenOneIsGiven() + { + Assert.Equal( + "BigWillyMod/Accessories/StaySillyCap", + CustomAvatarObjects.DeriveId( + "BigWillyMod/Accessories/StaySillyCap", + "avatar/accessories/head/cap/Cap", + "StaySillyCap")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IdFallsBackToTheSourcePathAndNameWithoutATarget(string? target) + { + Assert.Equal( + "avatar/accessories/head/cap/Cap/StaySillyCap", + CustomAvatarObjects.DeriveId(target, "avatar/accessories/head/cap/Cap", "StaySillyCap")); + } + + [Fact] + public void FallbackIdDoesNotDoubleTheSeparator() + { + Assert.Equal( + "avatar/accessories/head/cap/Cap/StaySillyCap", + CustomAvatarObjects.DeriveId(null, "avatar/accessories/head/cap/Cap/", "StaySillyCap")); + } + + [Fact] + public void GameMembersTheCustomAvatarObjectPathReliesOnExist() + { + // If the game renames one of these, a custom accessory stops showing; this fails first. + Assert.Empty(CustomAvatarObjects.FindMissingGameMembers()); + } +} diff --git a/S1API.Tests/Rendering/RuntimeResourceRegistryTests.cs b/S1API.Tests/Rendering/RuntimeResourceRegistryTests.cs new file mode 100644 index 00000000..6bd14723 --- /dev/null +++ b/S1API.Tests/Rendering/RuntimeResourceRegistryTests.cs @@ -0,0 +1,75 @@ +using S1API.Rendering; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace S1API.Tests.Rendering; + +public sealed class RuntimeResourceRegistryTests +{ + private const string Path = "Example/Accessories/Cap"; + + [Fact] + public void FindCompatibleTypedAssetSkipsEntriesTheRequestedTypeRejects() + { + // The GameObject is registered first, as RegisterAsset does, ahead of the typed component entry. + Object gameObject = TestObjectFactory.CreateUninitialized(); + Object texture = TestObjectFactory.CreateUninitialized(); + var typedAssets = new Dictionary + { + [Path + "|UnityEngine.GameObject"] = gameObject, + [Path + "|UnityEngine.Texture2D"] = texture, + }; + + Object? found = RuntimeResourceRegistry.FindCompatibleTypedAsset( + typedAssets, + Path, + candidate => candidate is Texture2D); + + Assert.Same(texture, found); + } + + [Fact] + public void FindCompatibleTypedAssetReturnsNullWhenNothingIsCompatible() + { + // A Load for a type nothing registered here satisfies must not be answered with the wrong object, + // so the caller can fall through to its component lookup instead of failing a cast. + var typedAssets = new Dictionary + { + [Path + "|UnityEngine.GameObject"] = TestObjectFactory.CreateUninitialized(), + }; + + Assert.Null( + RuntimeResourceRegistry.FindCompatibleTypedAsset( + typedAssets, + Path, + candidate => candidate is Texture2D)); + } + + [Fact] + public void FindCompatibleTypedAssetKeepsReturningTheFirstEntryWhenTheTypeAcceptsAll() + { + Object first = TestObjectFactory.CreateUninitialized(); + var typedAssets = new Dictionary + { + [Path + "|UnityEngine.GameObject"] = first, + [Path + "|UnityEngine.Texture2D"] = TestObjectFactory.CreateUninitialized(), + }; + + Assert.Same( + first, + RuntimeResourceRegistry.FindCompatibleTypedAsset(typedAssets, Path, _ => true)); + } + + [Fact] + public void FindCompatibleTypedAssetOnlyLooksAtEntriesForTheRequestedPath() + { + var typedAssets = new Dictionary + { + [Path + "Other|UnityEngine.GameObject"] = TestObjectFactory.CreateUninitialized(), + ["Elsewhere|UnityEngine.GameObject"] = TestObjectFactory.CreateUninitialized(), + }; + + Assert.Null( + RuntimeResourceRegistry.FindCompatibleTypedAsset(typedAssets, Path, _ => true)); + } +} diff --git a/S1API/Internal/Rendering/CustomAvatarObjects.cs b/S1API/Internal/Rendering/CustomAvatarObjects.cs new file mode 100644 index 00000000..d5f89d54 --- /dev/null +++ b/S1API/Internal/Rendering/CustomAvatarObjects.cs @@ -0,0 +1,154 @@ +#if IL2CPPMELON +using S1Avatar = Il2CppScheduleOne.Avatar; +using S1AvatarFramework = Il2CppScheduleOne.AvatarFramework; +using S1CoreAvatar = Il2CppScheduleOne.Core.Avatar; +#elif MONOMELON +using S1Avatar = ScheduleOne.Avatar; +using S1AvatarFramework = ScheduleOne.AvatarFramework; +using S1CoreAvatar = ScheduleOne.Core.Avatar; +#endif + +using System; +using System.Collections.Generic; +using System.Reflection; +using S1API.Internal.Utils; +using S1API.Logging; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace S1API.Internal.Rendering +{ + /// + /// INTERNAL: Builds and registers custom AvatarObject prefabs for runtime-created accessories. + /// + /// + /// From 0.4.7 the game dresses an avatar in AvatarObject prefabs, not in the legacy Accessory + /// prefabs. A legacy accessory only points at its AvatarObject through + /// Accessory.AvatarObjectEquivalent. A clothing item stores that AvatarObject + /// (ClothingDefinition.ClothingAvatarObject), serializes it by Id, and the avatar later finds + /// the prefab again with AvatarObjectLibrary.TryGetAvatarObjectById. A custom accessory therefore needs + /// an AvatarObject of its own, with an id nothing else uses, registered in that library. + /// + internal static class CustomAvatarObjects + { + private static readonly Log Logger = new Log("CustomAvatarObjects"); + + private const string HolderName = "S1API_CustomAvatarObjects"; + + private static GameObject? _holder; + + /// + /// The id a custom accessory's AvatarObject is registered under: the resource path the mod registers it + /// at, which is stable across sessions (saves refer to it), or a path built from the source and new name when + /// the clone is made without a target path. + /// + internal static string DeriveId(string? targetResourcePath, string sourceResourcePath, string newName) + { + if (!string.IsNullOrWhiteSpace(targetResourcePath)) + return targetResourcePath!; + return sourceResourcePath.TrimEnd('/') + "/" + newName; + } + + /// + /// Clones an AvatarObject prefab into one with its own name and id, kept under an inactive holder so + /// that, like a prefab asset, it neither renders nor runs Awake until the game instantiates it. + /// + internal static S1CoreAvatar.AvatarObject? Clone(S1CoreAvatar.AvatarObject source, string name, string id) + { + try + { + var clone = Object.Instantiate(source, EnsureHolder().transform, false); + clone.gameObject.name = name; + + if (!ReflectionUtils.TrySetFieldOrProperty(clone, "_id", id) + || !ReflectionUtils.TrySetFieldOrProperty(clone, "_name", name)) + { + Logger.Error($"Could not set the id or name on the AvatarObject cloned for '{id}'; " + + "the game's AvatarObject members have changed."); + Object.Destroy(clone.gameObject); + return null; + } + + return clone; + } + catch (Exception ex) + { + Logger.Error($"Failed to clone AvatarObject for '{id}': {ex.Message}"); + Logger.Error(ex.StackTrace ?? ex.ToString()); + return null; + } + } + + /// + /// Makes the avatar-object library return for its id, which is how the game + /// finds the prefab when it applies a serialized avatar object. + /// + internal static bool RegisterInLibrary(S1CoreAvatar.AvatarObject avatarObject) + { + try + { + string id = avatarObject.Id; + if (string.IsNullOrEmpty(id)) + { + Logger.Error("Cannot register an AvatarObject with no id in the library."); + return false; + } + +#if IL2CPPMELON + var library = S1Avatar.AvatarObjectLibrary._avatarObjects; +#else + var library = ReflectionUtils.TryGetStaticFieldOrProperty( + typeof(S1Avatar.AvatarObjectLibrary), + "_avatarObjects") as Dictionary; +#endif + if (library == null) + { + Logger.Error($"AvatarObjectLibrary has no dictionary to register '{id}' in."); + return false; + } + + library[id] = avatarObject; + return true; + } + catch (Exception ex) + { + Logger.Error($"Failed to register an AvatarObject in the library: {ex.Message}"); + return false; + } + } + + /// + /// Names the game members this relies on that are missing. A test calls it, so a game update that renames one + /// fails the build instead of leaving a custom accessory that quietly does not show. + /// + internal static IReadOnlyList FindMissingGameMembers() + { + var missing = new List(); + RequireMember(missing, typeof(S1CoreAvatar.AvatarObject), "_id"); + RequireMember(missing, typeof(S1CoreAvatar.AvatarObject), "_name"); + RequireMember(missing, typeof(S1CoreAvatar.AvatarObject), "Id"); + RequireMember(missing, typeof(S1Avatar.AvatarObjectLibrary), "_avatarObjects"); + RequireMember(missing, typeof(S1AvatarFramework.Accessory), "AvatarObjectEquivalent"); + return missing; + } + + private static void RequireMember(List missing, Type type, string name) + { + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic + | BindingFlags.Instance | BindingFlags.Static | BindingFlags.FlattenHierarchy; + if (type.GetField(name, flags) == null && type.GetProperty(name, flags) == null) + missing.Add(type.Name + "." + name); + } + + private static GameObject EnsureHolder() + { + if (_holder != null) + return _holder; + + _holder = new GameObject(HolderName); + _holder.SetActive(false); + Object.DontDestroyOnLoad(_holder); + return _holder; + } + } +} diff --git a/S1API/Rendering/AccessoryFactory.cs b/S1API/Rendering/AccessoryFactory.cs index 3e94068e..ff8f1d50 100644 --- a/S1API/Rendering/AccessoryFactory.cs +++ b/S1API/Rendering/AccessoryFactory.cs @@ -1,9 +1,12 @@ #if (IL2CPPMELON) using S1AvatarFramework = Il2CppScheduleOne.AvatarFramework; +using S1CoreAvatar = Il2CppScheduleOne.Core.Avatar; #elif MONOMELON using S1AvatarFramework = ScheduleOne.AvatarFramework; +using S1CoreAvatar = ScheduleOne.Core.Avatar; #endif +using S1API.Internal.Rendering; using S1API.Logging; using System; using UnityEngine; @@ -83,6 +86,12 @@ public static class AccessoryFactory accessory.ApplyColor(colorTint.Value); } + // From 0.4.7 an avatar is dressed in AvatarObject prefabs, which the legacy accessory only points at + // (Accessory.AvatarObjectEquivalent). Give the clone an AvatarObject of its own, with its own id, + // textured the same way and registered where the game looks it up. + if (accessory != null) + CloneAvatarObjectEquivalent(accessory, sourceResourcePath, targetResourcePath, newName, textureReplacements); + // Set prefab to active so that when Unity instantiates it, the instance will also be active. // This is critical: Unity's Object.Instantiate preserves the prefab's active state. // If the prefab is inactive, instantiated GameObjects will also be inactive and won't render. @@ -99,6 +108,38 @@ public static class AccessoryFactory } } + /// + /// Replaces the clone's AvatarObjectEquivalent with a copy that has its own id, so the game wears the + /// custom accessory and not the one it was cloned from. + /// + private static void CloneAvatarObjectEquivalent( + S1AvatarFramework.Accessory accessory, + string sourceResourcePath, + string? targetResourcePath, + string newName, + System.Collections.Generic.Dictionary? textureReplacements) + { + S1CoreAvatar.AvatarObject? equivalent = accessory.AvatarObjectEquivalent; + if (equivalent == null) + return; + + string id = CustomAvatarObjects.DeriveId(targetResourcePath, sourceResourcePath, newName); + S1CoreAvatar.AvatarObject? clone = CustomAvatarObjects.Clone(equivalent, newName, id); + if (clone == null) + return; + + if (textureReplacements != null && textureReplacements.Count > 0) + ApplyTexturesToAccessory(clone.gameObject, textureReplacements); + + if (!CustomAvatarObjects.RegisterInLibrary(clone)) + { + Object.Destroy(clone.gameObject); + return; + } + + accessory.AvatarObjectEquivalent = clone; + } + /// /// Applies custom textures to all materials in an accessory GameObject. /// @@ -162,6 +203,20 @@ public static bool RegisterAccessory(string resourcePath, GameObject accessory) RuntimeResourceRegistry.RegisterAssetForType(resourcePath, accessoryComponent, typeof(S1AvatarFramework.Accessory)); } + // From 0.4.7 a clothing item loads its AvatarObject (Resources.Load(path)), so register it for + // that type too. This is the avatar object CloneAccessoryWithCustomTextures made for the clone, or the + // original's when the accessory was not cloned. + S1CoreAvatar.AvatarObject? avatarObject = accessoryComponent != null + ? accessoryComponent.AvatarObjectEquivalent + : null; + if (avatarObject != null) + { + RuntimeResourceRegistry.RegisterAssetForType( + resourcePath, + avatarObject, + typeof(S1CoreAvatar.AvatarObject)); + } + return gameObjectRegistered; } diff --git a/S1API/Rendering/RuntimeResourceRegistry.cs b/S1API/Rendering/RuntimeResourceRegistry.cs index 49a4e053..de1bb7af 100644 --- a/S1API/Rendering/RuntimeResourceRegistry.cs +++ b/S1API/Rendering/RuntimeResourceRegistry.cs @@ -166,14 +166,31 @@ public static bool IsRegistered(string resourcePath) => if (_typedAssets.TryGetValue(typedKey, out var typedAsset)) return typedAsset; - foreach (var kvp in _typedAssets) + var compatible = FindCompatibleTypedAsset(_typedAssets, resourcePath, type.IsInstanceOfType); + if (compatible != null) + return compatible; + + _registeredAssets.TryGetValue(resourcePath, out var asset); + return asset; + } + + /// + /// Finds the first asset registered for a path, under any type, that + /// accepts. Returns null when none does, so the caller can fall back to another lookup. + /// + internal static Object? FindCompatibleTypedAsset( + IEnumerable> typedAssets, + string resourcePath, + Func isCompatible) + { + string prefix = resourcePath + "|"; + foreach (var kvp in typedAssets) { - if (kvp.Key.StartsWith(resourcePath + "|") && type.IsInstanceOfType(kvp.Value)) + if (kvp.Key.StartsWith(prefix) && isCompatible(kvp.Value)) return kvp.Value; } - _registeredAssets.TryGetValue(resourcePath, out var asset); - return asset; + return null; } /// @@ -355,14 +372,17 @@ private static bool ResourcesLoadPrefix(string path, Il2CppSystem.Type systemTyp return false; } - // Check for compatible types by path prefix - foreach (var kvp in _typedAssets) + // Check for compatible types by path prefix. Only an asset the requested type accepts can answer + // the load: handing back the GameObject registered at the path for a Load made + // the caller's cast throw InvalidCastException instead of falling through to the component lookup. + var compatible = FindCompatibleTypedAsset( + _typedAssets, + path, + candidate => systemTypeInstance!.IsInstanceOfType(candidate)); + if (compatible != null) { - if (kvp.Key.StartsWith(path + "|")) - { - __result = kvp.Value; - return false; - } + __result = compatible; + return false; } } From 7fee72467ccce91204d42ee2e6f8d09c02f3cbd7 Mon Sep 17 00:00:00 2001 From: r-melvin <40235254+r-melvin@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:09:29 +0100 Subject: [PATCH 29/57] fix: NPC discovery scans mods built against any version of S1API GetDerivedClasses only scans assemblies that reference the base assembly, and compared full names, so a mod built against another S1API version (BigWillyMod: 3.0.1.0 against 3.2.1.0) was never scanned and its NPCs never registered, without any log line. A reference now binds by simple name and public key token, as the runtime binds it; a transitive hop accepts another version only when one assembly of that name is loaded. A custom NPC prefab that registers is now logged once, so "never found" and "found but not spawned" can be told apart. Co-Authored-By: Claude Opus 5.5 --- .../Internal/Utils/ReflectionUtilsTests.cs | 52 +++++++++++++++++++ S1API/Entities/NPC.cs | 2 + S1API/Internal/Utils/ReflectionUtils.cs | 46 +++++++++++++++- 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs index 3dfc4a81..da1e067d 100644 --- a/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs +++ b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs @@ -121,6 +121,58 @@ public void DerivedTypeScanDoesNotFollowSameNameAssembliesWithDifferentIdentitie AppDomain.CurrentDomain.GetAssemblies())); } + [Theory] + [InlineData("S1API, Version=3.0.1.0, Culture=neutral, PublicKeyToken=null")] + [InlineData("S1API, Version=2.9.2.0, Culture=neutral, PublicKeyToken=null")] + [InlineData("S1API, Version=3.2.1.0, Culture=neutral, PublicKeyToken=null")] + [InlineData("s1api, Version=4.0.0.0, Culture=neutral, PublicKeyToken=null")] + public void AReferenceToAnyVersionOfTheBaseAssemblyBindsToIt(string reference) + { + // A mod built against an older S1API runs against the loaded one: the runtime binds by name and key token. + var loaded = new AssemblyName("S1API, Version=3.2.1.0, Culture=neutral, PublicKeyToken=null"); + + Assert.True(ReflectionUtils.ReferenceBindsToDefinition(new AssemblyName(reference), loaded)); + } + + [Fact] + public void AReferenceToADifferentAssemblyNameDoesNotBind() + { + var loaded = new AssemblyName("S1API, Version=3.2.1.0, Culture=neutral, PublicKeyToken=null"); + + Assert.False(ReflectionUtils.ReferenceBindsToDefinition( + new AssemblyName("S1APILoader, Version=3.2.1.0, Culture=neutral, PublicKeyToken=null"), + loaded)); + } + + [Fact] + public void AReferenceWithADifferentPublicKeyTokenDoesNotBind() + { + var signed = new AssemblyName("Example, Version=1.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"); + var unsigned = new AssemblyName("Example, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"); + + Assert.False(ReflectionUtils.ReferenceBindsToDefinition(signed, unsigned)); + Assert.False(ReflectionUtils.ReferenceBindsToDefinition(unsigned, signed)); + Assert.True(ReflectionUtils.ReferenceBindsToDefinition(signed, signed)); + } + + [Fact] + public void ALoadedAssemblyOfADifferentVersionIsFollowedOnlyWhenItIsTheOnlyOneOfThatName() + { + var reference = new AssemblyName("Library, Version=1.5.0.0, Culture=neutral, PublicKeyToken=null"); + var newer = new AssemblyName("Library, Version=1.6.0.0, Culture=neutral, PublicKeyToken=null"); + + Assert.True(ReflectionUtils.ShouldFollowLoadedReference(newer, reference, loadedAssembliesWithThatName: 1)); + Assert.False(ReflectionUtils.ShouldFollowLoadedReference(newer, reference, loadedAssembliesWithThatName: 2)); + } + + [Fact] + public void ALoadedAssemblyWithTheExactIdentityIsAlwaysFollowed() + { + var reference = new AssemblyName("Library, Version=1.5.0.0, Culture=neutral, PublicKeyToken=null"); + + Assert.True(ReflectionUtils.ShouldFollowLoadedReference(reference, reference, loadedAssembliesWithThatName: 2)); + } + private static AssemblyBuilder CreateDynamicAssembly(string name, Version version) { var assemblyName = new AssemblyName(name) diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index bb6befdd..599a2140 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -1151,6 +1151,8 @@ private static GameObject GetOrCreatePerNpcPrefab(System.Type npcType, NPC? owne TypeToPrefab[npcType] = prefabNO.gameObject; MarkPrefabsConfigured(); + // Failures are logged; logging success too tells "never found" apart from "found but not spawned". + Logger.Msg($"[S1API] Registered NPC prefab '{prefabName}' for {npcType.Name}."); return prefabNO.gameObject; } } diff --git a/S1API/Internal/Utils/ReflectionUtils.cs b/S1API/Internal/Utils/ReflectionUtils.cs index 853db58b..a2ec62db 100644 --- a/S1API/Internal/Utils/ReflectionUtils.cs +++ b/S1API/Internal/Utils/ReflectionUtils.cs @@ -137,7 +137,7 @@ private static bool ReferencesAssemblyTransitively( foreach (AssemblyName referencedAssembly in referencedAssemblies) { - if (AssemblyIdentityMatches(referencedAssembly, baseAssemblyName)) + if (ReferenceBindsToDefinition(referencedAssembly, baseAssemblyName)) return true; string referencedName = referencedAssembly.Name ?? string.Empty; @@ -147,7 +147,10 @@ private static bool ReferencesAssemblyTransitively( foreach (Assembly loadedReference in loadedReferences) { - if (!AssemblyIdentityMatches(loadedReference.GetName(), referencedAssembly)) + if (!ShouldFollowLoadedReference( + loadedReference.GetName(), + referencedAssembly, + loadedReferences.Length)) continue; if (ReferencesAssemblyTransitively( @@ -164,6 +167,45 @@ private static bool ReferencesAssemblyTransitively( return false; } + /// + /// INTERNAL: Does this reference bind to that assembly? The simple name and public key token must match; the + /// version need not, because the runtime binds a reference to whichever assembly of that name is loaded. A mod + /// built against an older S1API still runs against the loaded one, so it must still be scanned for the types + /// that derive from it. + /// + internal static bool ReferenceBindsToDefinition( + AssemblyName referenceAssemblyName, + AssemblyName definitionAssemblyName) + { + if (!string.Equals( + referenceAssemblyName.Name, + definitionAssemblyName.Name, + StringComparison.OrdinalIgnoreCase)) + return false; + + byte[] referenceToken = referenceAssemblyName.GetPublicKeyToken() ?? Array.Empty(); + byte[] definitionToken = definitionAssemblyName.GetPublicKeyToken() ?? Array.Empty(); + return referenceToken.SequenceEqual(definitionToken); + } + + /// + /// INTERNAL: Should the scan follow a reference into this loaded assembly? An exact identity always; a different + /// version only when it is the one assembly of that name loaded, since then the reference can only bind to it. + /// With several same-named assemblies loaded it is unknown which one the reference means, so only an exact + /// match is followed. + /// + internal static bool ShouldFollowLoadedReference( + AssemblyName loadedAssemblyName, + AssemblyName referenceAssemblyName, + int loadedAssembliesWithThatName) + { + if (AssemblyIdentityMatches(loadedAssemblyName, referenceAssemblyName)) + return true; + + return loadedAssembliesWithThatName == 1 + && ReferenceBindsToDefinition(referenceAssemblyName, loadedAssemblyName); + } + private static bool AssemblyIdentityMatches( AssemblyName referenceAssemblyName, AssemblyName definitionAssemblyName) => From e353ecf4ea0b9d54b249000c6316b51dd4537a57 Mon Sep 17 00:00:00 2001 From: r-melvin <40235254+r-melvin@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:02:10 +0100 Subject: [PATCH 30/57] fix: the contacts app's wait for custom NPCs survives a broken NPC in the scene WaitForNPCs polled FindObjectsOfType(true) every frame and read each one's ID. On IL2CPP, on the second load of a session, one inactive NPC in the scene throws from NPC.ID (its data object is gone), so every poll threw: 1,222 "Exception in IL2CPP-to-Managed trampoline" errors in 47 s in one run. The predicate never returned true for the rest of the session. The check now skips an NPC whose ID throws, matches IDs through a set instead of a nested search, and runs four times a second instead of every frame. Co-Authored-By: Claude Opus 5.5 --- .../Internal/Patches/ContactsAppWaitTests.cs | 33 +++++++++++++++++++ S1API/Internal/Patches/ContactsAppPatches.cs | 32 +++++++++++++++--- 2 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 S1API.Tests/Internal/Patches/ContactsAppWaitTests.cs diff --git a/S1API.Tests/Internal/Patches/ContactsAppWaitTests.cs b/S1API.Tests/Internal/Patches/ContactsAppWaitTests.cs new file mode 100644 index 00000000..4419ce5a --- /dev/null +++ b/S1API.Tests/Internal/Patches/ContactsAppWaitTests.cs @@ -0,0 +1,33 @@ +using S1API.Internal.Patches; + +namespace S1API.Tests.Internal.Patches; + +public sealed class ContactsAppWaitTests +{ + private static Func Id(string id) => () => id; + private static Func Throws() => () => throw new NullReferenceException("NPC data is gone"); + + [Fact] + public void AnNpcWhoseIdThrowsDoesNotHideTheOthers() + { + // On IL2CPP the game's pooled special customers throw from NPC.ID; one of them used to fail the check + // every frame, so the wait never ended. + Assert.True(ContactsAppPatches.AllIdsPresent( + new[] { Id("big_willy"), Throws(), Id("disco_davey"), Throws() }, + new[] { "big_willy", "disco_davey" })); + } + + [Fact] + public void AMissingCustomNpcIsStillWaitedFor() + { + Assert.False(ContactsAppPatches.AllIdsPresent( + new[] { Id("big_willy"), Throws() }, + new[] { "big_willy", "disco_davey" })); + } + + [Fact] + public void NothingReadableMeansNothingPresent() + { + Assert.False(ContactsAppPatches.AllIdsPresent(new[] { Throws() }, new[] { "big_willy" })); + } +} diff --git a/S1API/Internal/Patches/ContactsAppPatches.cs b/S1API/Internal/Patches/ContactsAppPatches.cs index 1ef7e88f..89f22a1e 100644 --- a/S1API/Internal/Patches/ContactsAppPatches.cs +++ b/S1API/Internal/Patches/ContactsAppPatches.cs @@ -169,11 +169,9 @@ private static IEnumerator WaitForNPCs(S1ContactsApp.ContactsApp contactsApp) if (customNPCs.Count == 0) yield break; - yield return new WaitUntil((Func)(() => - { - var allSceneNPCs = Object.FindObjectsOfType(true); - return customNPCs.All(npc => allSceneNPCs.Any(sn => sn.ID == npc.ID)); - })); + // A few checks a second, not every frame: this searches the whole scene, inactive objects included. + while (!AllPresentInScene(customNPCs)) + yield return new WaitForSeconds(0.25f); yield return new WaitUntil((Func)(() => { @@ -201,6 +199,30 @@ private static IEnumerator WaitForNPCs(S1ContactsApp.ContactsApp contactsApp) AddRelationCircles(contactsApp); } + private static bool AllPresentInScene(System.Collections.Generic.List customNPCs) => + AllIdsPresent( + Object.FindObjectsOfType(true).Select(sceneNpc => (System.Func)(() => sceneNpc.ID)), + customNPCs.Select(npc => npc.ID)); + + /// + /// Are all ids among the scene's NPC ids? Some of the game's own NPCs throw from + /// ID (the pooled special customers on IL2CPP); they are never waited for, so they are skipped rather + /// than failing the whole check. + /// + internal static bool AllIdsPresent( + System.Collections.Generic.IEnumerable> sceneIds, + System.Collections.Generic.IEnumerable wanted) + { + var present = new System.Collections.Generic.HashSet(); + foreach (System.Func readId in sceneIds) + { + try { present.Add(readId()); } + catch (System.Exception) { } + } + + return wanted.All(present.Contains); + } + /// /// Creates and positions relation circles for all custom NPCs. /// From 08d0edb99a8382e7ec32375e0ac121baef19779b Mon Sep 17 00:00:00 2001 From: r-melvin <40235254+r-melvin@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:09:29 +0100 Subject: [PATCH 31/57] fix: attach NPC data before ConfigurePrefab for dealers and suppliers too ConfigurePrefab only had a framework data object for plain NPCs. A supplier or dealer built from a donor with none threw from WithVoice ("Could not apply S1API voice 'tyler' because the custom NPC has no framework data", Drug Expansion's Disco Davey) and never registered. Any prefab whose NPC component has no data now gets one before ConfigurePrefab, matching the component's own type; a role's own data still replaces it afterwards and identity and voice are re-applied to it. Co-Authored-By: Claude Opus 5.5 --- S1API.Tests/NPCs/NPCPrefabDataTests.cs | 21 ++++++++++++++++++++ S1API/Entities/NPC.cs | 27 +++++++++++++++++++------- 2 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 S1API.Tests/NPCs/NPCPrefabDataTests.cs diff --git a/S1API.Tests/NPCs/NPCPrefabDataTests.cs b/S1API.Tests/NPCs/NPCPrefabDataTests.cs new file mode 100644 index 00000000..41cd0ed7 --- /dev/null +++ b/S1API.Tests/NPCs/NPCPrefabDataTests.cs @@ -0,0 +1,21 @@ +using S1API.Entities; +using S1API.Internal.Entities; + +namespace S1API.Tests.NPCs; + +public sealed class NPCPrefabDataTests +{ + [Fact] + public void ADonorThatIsAlreadyADealerOrSupplierGetsDataOfItsOwnKind() + { + // A dealer donor keeps its Dealer component, so plain data attached before ConfigurePrefab would stay. + Assert.Equal(NpcRootRole.Dealer, NPC.DataRoleForComponent(isDealer: true, isSupplier: false)); + Assert.Equal(NpcRootRole.Supplier, NPC.DataRoleForComponent(isDealer: false, isSupplier: true)); + } + + [Fact] + public void APlainDonorGetsPlainDataWhichTheRolesOwnDataReplacesLater() + { + Assert.Equal(NpcRootRole.Plain, NPC.DataRoleForComponent(isDealer: false, isSupplier: false)); + } +} diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index bb6befdd..9a13c855 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -358,6 +358,13 @@ private static bool IsGeneratedS1ApiPrefab(GameObject prefabRoot) ?? FindSpawnablePrefabWithComponent(spawnablePrefabs, count); } + /// + /// The kind of data an NPC component without any gets before ConfigurePrefab: its own. A donor that already + /// is a dealer keeps that component, so plain data on it would never be replaced. + /// + internal static NpcRootRole DataRoleForComponent(bool isDealer, bool isSupplier) => + isDealer ? NpcRootRole.Dealer : isSupplier ? NpcRootRole.Supplier : NpcRootRole.Plain; + private static S1NPCs.NPC? FindPlainNpcComponent(GameObject prefabRoot) { if (prefabRoot == null) @@ -1018,13 +1025,19 @@ private static GameObject GetOrCreatePerNpcPrefab(System.Type npcType, NPC? owne NormalizeBaseEmployeePrefab(prefabNO.gameObject, sourcePrefabName, rootRole); prefabNO.gameObject.name = prefabName; - // Some native plain NPC prefabs have no framework data object. Attach one - // before ConfigurePrefab applies identity and other saved defaults. - if (rootRole == NpcRootRole.Plain) - { - S1NPCs.NPC? plainNpc = FindPlainNpcComponent(prefabNO.gameObject); - if (plainNpc != null && NPCDataAccess.GetDataObject(plainNpc) == null) - NPCDataAccess.AssignNewData(plainNpc, rootRole, plainNpc); + // Some native prefabs have no framework data object: the plain NPC prefab, and the donor a dealer or + // supplier is built from. Attach one before ConfigurePrefab, whose builder calls write to it + // (WithVoice throws without one). It matches the component's own type, so a donor that already is a + // dealer or supplier keeps the right kind of data even if no new component replaces it. + S1NPCs.NPC? donorNpc = rootRole == NpcRootRole.Plain + ? FindPlainNpcComponent(prefabNO.gameObject) + : GetPreferredNpcComponent(prefabNO.gameObject); + if (donorNpc != null && NPCDataAccess.GetDataObject(donorNpc) == null) + { + NpcRootRole dataRole = DataRoleForComponent( + CrossType.Is(donorNpc, out S1Economy.Dealer _), + CrossType.Is(donorNpc, out S1Economy.Supplier _)); + NPCDataAccess.AssignNewData(donorNpc, dataRole, donorNpc); } // Ensure template prefab does not execute runtime logic or remain in NPC registry From 38ec844f4de1214e954aa5302164ab69cec9879e Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 1 Oct 2026 19:02:14 -0700 Subject: [PATCH 32/57] docs(contributing): require issues before bug and feature PRs --- .github/ISSUE_TEMPLATE/bug_report.yml | 4 +++ .github/ISSUE_TEMPLATE/feature_request.yml | 5 +++ .github/pull_request_template.md | 18 ++++++++++ AGENTS.md | 12 ++++++- CONTRIBUTING.md | 39 +++++++++++++++++++--- README.md | 3 ++ 6 files changed, 75 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index ca45d8c7..4bdb3295 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -7,6 +7,10 @@ body: attributes: value: | ## Before you submit... + + Search existing issues and pull requests first. Reuse an issue that + already tracks the same bug. If you plan to contribute a fix, open this + issue before your PR and comment with your intended approach. Please ensure you have: - Read the [installation guide](https://ifbars.github.io/S1API/docs/getting-started.html#installation-guide) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 65f81485..55ebcb4e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -8,6 +8,11 @@ body: value: | ## Feature Request + Search existing issues and pull requests first. Reuse an issue that + already tracks the same request. If you plan to implement it, open this + issue before your PR and comment with your intended approach. Discuss + substantial API or behavior changes with a maintainer before implementation. + Keep this short if you want. You do **not** need to know how the API should be implemented. If you're not technical, just describe: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 93f88b34..cf7689e8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,3 +1,21 @@ +## Linked issue + + + +## Contributor checklist + +- [ ] I linked an issue opened before this PR, or explained an allowed exception above. +- [ ] I checked existing issues and PRs for duplicate or overlapping work. +- [ ] This PR addresses one focused problem or request. + ## Summary diff --git a/AGENTS.md b/AGENTS.md index f0342ad2..77adb12d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,17 @@ and document the migration impact in the PR. `S1API.Tests/` is the only test implementation that should be committed to this repository. Keep runtime and in-game smoke mods, launchers, harnesses, disposable saves or installs, logs, screenshots, and generated evidence local and ignored, including everything under `tests/Smoke/`. Do not add `.gitignore` exceptions for smoke-test sources. Record the scenario, commands, runtime matrix, and observed pass/fail evidence in the PR description without committing the smoke implementation or game-derived artifacts. ## Commit & Pull Request Guidelines -Write imperative, single-purpose commits; lightweight prefixes such as `fix:` or `feat:` appear in history and are encouraged. Target regular-game PRs at `stable` and beta-game PRs at `beta`. Include a short change narrative, reproduction or validation notes, and link any external issue. Screenshots or logs are helpful for UI or networking work. Never modify CI workflows without prior discussion. +Write imperative, single-purpose commits; lightweight prefixes such as `fix:` or `feat:` appear in history and are encouraged. Target regular-game PRs at `stable` and beta-game PRs at `beta`. Include a short change narrative, reproduction or validation notes, and link the tracking GitHub issue. Screenshots or logs are helpful for UI or networking work. Never modify CI workflows without prior discussion. + +Follow the issue-first workflow in `CONTRIBUTING.md`: search existing issues and +PRs, reuse or open a GitHub issue before a bug-fix, feature, or API-improvement +PR, and record the intended approach on the issue before implementation. Discuss +substantial API or behavior changes with a maintainer first. Use `Fixes #123` +for complete resolutions and `Refs #123` for partial work. A PR description is +not a substitute for the issue. Keep unrelated problems in separate issues and +focused PRs. Typo, formatting, and documentation-only changes that do not alter +API behavior may omit an issue with an explanation in the PR; other exceptions +require explicit maintainer approval. ## Release & Versioning Workflow Always follow [`VERSIONING.md`](VERSIONING.md) for any release, hotfix, tagging, branch-planning, or version-bump work. Treat it as the authoritative release policy. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19806900..78d8fe78 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,35 @@ Please read over the below in full to help you get started and set expectations - Do **NOT** alter my GitHub actions unless you have a good reason. I will close your PR and ban you from the project if malicious intent is found. +## Before Opening a Pull Request + +Bug fixes, feature requests, and API improvements must have a GitHub issue +opened **before** the pull request, even if you already have a fix ready. The +issue records the problem or request independently of the proposed implementation. + +1. Search [existing issues](https://github.com/ifBars/S1API/issues) and open pull + requests first. Reuse an issue that already tracks the same problem rather + than opening a duplicate. +2. If there is no matching issue, open a + [bug report](https://github.com/ifBars/S1API/issues/new?template=bug_report.yml) + or [feature request](https://github.com/ifBars/S1API/issues/new?template=feature_request.yml). + For bugs, include reproduction steps, expected and actual behavior, affected + versions, and the Mono or IL2CPP runtime. For requests, explain the use case + and what is missing today. +3. Comment on the issue with your intended approach before starting work so + contributors can coordinate. Discuss substantial API or behavior changes + with a maintainer before implementing them. +4. Link the issue in the PR description. Use `Fixes #123` when the PR fully + resolves it, or `Refs #123` when it only addresses part of the work. + +A PR description does not replace an issue. Bug-fix and feature PRs without a +linked issue will be asked to add one before review proceeds. Keep unrelated +bugs or requests in separate issues and focused PRs. + +Typo corrections, formatting, and documentation-only changes that do not alter +API behavior may be submitted without an issue; explain that exception in the +PR's linked issue section. Maintainers may approve other exceptions explicitly. + ## Prerequisites S1API is available to mod developers of all experience levels, but contributing game-facing changes assumes working familiarity with Schedule I mod development @@ -60,10 +89,10 @@ Ultimately, this just saves you time and gets your changes into the API faster. | MonoMelon | MelonLoader for Mono (alternate branch) builds | ## Proper Contributing Channels -All pull requests **must** go into `bleeding-edge` before `stable`. -If you make a pull request for `stable`, I **will** be changing it to verify build. +Target regular-game pull requests at `stable` and game-beta pull requests at +`beta`. For release and hotfix preparation, follow [VERSIONING.md](VERSIONING.md). ## Tracking Work & Issues -We maintain a [Trello board](https://trello.com/b/yuRuBpIg/s1api) where known issues and tasks that need to be done are tracked. -While the board is not always perfectly up to date, it should typically show the known issues in the project. -GitHub issues opened with us will typically be added to the Trello board and then archived once closed. +[GitHub issues](https://github.com/ifBars/S1API/issues) are the source of truth +for bugs and requests. Keep reproduction details, scope decisions, and related +PR links on the issue so the work remains easy to track across releases. diff --git a/README.md b/README.md index 911db707..22fbf9b4 100644 --- a/README.md +++ b/README.md @@ -72,4 +72,7 @@ If you want to do custom content specific to Mono or Il2Cpp, S1API can still ass ## Want to Contribute? This is a massive project with so many different areas to specialize in. If you're interested in contributing, please do! +For bug fixes, features, and API improvements, reuse or open a +[GitHub issue](https://github.com/ifBars/S1API/issues) before opening a pull +request, and link that issue in the PR description. Look over the [CONTRIBUTING.md](CONTRIBUTING.md) for guidance on code standards and the process. From e6d4f2d4b55bea1e1e777129eecc689a89189372 Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 1 Oct 2026 21:19:59 -0700 Subject: [PATCH 33/57] fix: preserve accessory tint and complete typed resource checks --- .../Rendering/CustomAvatarObjectsTests.cs | 34 ++++++++++++++++- .../RuntimeResourceRegistryCollection.cs | 7 ++++ .../Rendering/RuntimeResourceRegistryTests.cs | 38 +++++++++++++++++-- .../Internal/Rendering/CustomAvatarObjects.cs | 9 +++++ S1API/Rendering/AccessoryFactory.cs | 13 +++---- S1API/Rendering/RuntimeResourceRegistry.cs | 16 ++------ 6 files changed, 93 insertions(+), 24 deletions(-) create mode 100644 S1API.Tests/Rendering/RuntimeResourceRegistryCollection.cs diff --git a/S1API.Tests/Rendering/CustomAvatarObjectsTests.cs b/S1API.Tests/Rendering/CustomAvatarObjectsTests.cs index 0fdb9db7..720ca616 100644 --- a/S1API.Tests/Rendering/CustomAvatarObjectsTests.cs +++ b/S1API.Tests/Rendering/CustomAvatarObjectsTests.cs @@ -1,4 +1,10 @@ using S1API.Internal.Rendering; +#if MONOMELON +using System.Reflection; +using ScheduleOne.Core.Avatar; +using ScheduleOne.Core.Avatar.Properties; +using UnityEngine; +#endif namespace S1API.Tests.Rendering; @@ -37,7 +43,33 @@ public void FallbackIdDoesNotDoubleTheSeparator() [Fact] public void GameMembersTheCustomAvatarObjectPathReliesOnExist() { - // If the game renames one of these, a custom accessory stops showing; this fails first. Assert.Empty(CustomAvatarObjects.FindMissingGameMembers()); } + +#if MONOMELON + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TintPreservesTheDefaultUnlessAnOverrideIsProvided(bool overrideTint) + { + var originalColor = new Color(0.2f, 0.3f, 0.4f, 1f); + var tint = new Color(0.8f, 0.7f, 0.6f, 1f); + var color = new ColorProperty("Primary", originalColor); + var properties = new AvatarPropertyCollection(); + typeof(AvatarPropertyCollection).GetField("_colors", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(properties, new[] { color }); + AvatarObject avatarObject = TestObjectFactory.CreateUninitialized(); + typeof(AvatarObject).GetField("_properties", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(avatarObject, properties); + + CustomAvatarObjects.ApplyColorTint(avatarObject, overrideTint ? tint : null); + + Color expected = overrideTint ? tint : originalColor; + Assert.Equal(expected.r, color.Value.r); + Assert.Equal(expected.g, color.Value.g); + Assert.Equal(expected.b, color.Value.b); + Assert.Equal(expected.a, color.Value.a); + Assert.Equal(expected.r, avatarObject.Serialize().Colors[0].Value.r); + } +#endif } diff --git a/S1API.Tests/Rendering/RuntimeResourceRegistryCollection.cs b/S1API.Tests/Rendering/RuntimeResourceRegistryCollection.cs new file mode 100644 index 00000000..c758e2fd --- /dev/null +++ b/S1API.Tests/Rendering/RuntimeResourceRegistryCollection.cs @@ -0,0 +1,7 @@ +namespace S1API.Tests.Rendering; + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class RuntimeResourceRegistryCollection +{ + public const string Name = "Runtime resource registry"; +} diff --git a/S1API.Tests/Rendering/RuntimeResourceRegistryTests.cs b/S1API.Tests/Rendering/RuntimeResourceRegistryTests.cs index 6bd14723..7f4dfb91 100644 --- a/S1API.Tests/Rendering/RuntimeResourceRegistryTests.cs +++ b/S1API.Tests/Rendering/RuntimeResourceRegistryTests.cs @@ -1,17 +1,51 @@ using S1API.Rendering; +using System.Reflection; using UnityEngine; using Object = UnityEngine.Object; namespace S1API.Tests.Rendering; +[Collection(RuntimeResourceRegistryCollection.Name)] public sealed class RuntimeResourceRegistryTests { private const string Path = "Example/Accessories/Cap"; +#if MONOMELON + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void RegisteredLookupHonorsTheRequestedType(bool typed, bool compatible) + { + string path = Path + "/" + Guid.NewGuid().ToString("N"); + Type requestedType = compatible ? typeof(Texture2D) : typeof(GameObject); + Object asset = TestObjectFactory.CreateUninitialized(); + string key = typed ? path + "|" + requestedType.FullName : path; + string fieldName = typed ? "_typedAssets" : "_registeredAssets"; + var registry = (Dictionary)typeof(RuntimeResourceRegistry) + .GetField(fieldName, BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + + try + { + registry.Add(key, asset); + Object? result = RuntimeResourceRegistry.GetRegisteredAssetForType(path, requestedType); + if (compatible) + Assert.Same(asset, result); + else + Assert.Null(result); + } + finally + { + registry.Remove(key); + } + } + +#endif + [Fact] public void FindCompatibleTypedAssetSkipsEntriesTheRequestedTypeRejects() { - // The GameObject is registered first, as RegisterAsset does, ahead of the typed component entry. Object gameObject = TestObjectFactory.CreateUninitialized(); Object texture = TestObjectFactory.CreateUninitialized(); var typedAssets = new Dictionary @@ -31,8 +65,6 @@ public void FindCompatibleTypedAssetSkipsEntriesTheRequestedTypeRejects() [Fact] public void FindCompatibleTypedAssetReturnsNullWhenNothingIsCompatible() { - // A Load for a type nothing registered here satisfies must not be answered with the wrong object, - // so the caller can fall through to its component lookup instead of failing a cast. var typedAssets = new Dictionary { [Path + "|UnityEngine.GameObject"] = TestObjectFactory.CreateUninitialized(), diff --git a/S1API/Internal/Rendering/CustomAvatarObjects.cs b/S1API/Internal/Rendering/CustomAvatarObjects.cs index d5f89d54..84af9123 100644 --- a/S1API/Internal/Rendering/CustomAvatarObjects.cs +++ b/S1API/Internal/Rendering/CustomAvatarObjects.cs @@ -79,6 +79,15 @@ internal static string DeriveId(string? targetResourcePath, string sourceResourc } } + /// + /// Applies an optional primary colour default to the cloned avatar object's serialized properties. + /// + internal static void ApplyColorTint(S1CoreAvatar.AvatarObject avatarObject, Color? colorTint) + { + if (colorTint.HasValue) + avatarObject.PropertyCollection?.GetFirstColorProperty()?.SetValue(colorTint.Value); + } + /// /// Makes the avatar-object library return for its id, which is how the game /// finds the prefab when it applies a serialized avatar object. diff --git a/S1API/Rendering/AccessoryFactory.cs b/S1API/Rendering/AccessoryFactory.cs index ff8f1d50..36bd3391 100644 --- a/S1API/Rendering/AccessoryFactory.cs +++ b/S1API/Rendering/AccessoryFactory.cs @@ -86,11 +86,8 @@ public static class AccessoryFactory accessory.ApplyColor(colorTint.Value); } - // From 0.4.7 an avatar is dressed in AvatarObject prefabs, which the legacy accessory only points at - // (Accessory.AvatarObjectEquivalent). Give the clone an AvatarObject of its own, with its own id, - // textured the same way and registered where the game looks it up. if (accessory != null) - CloneAvatarObjectEquivalent(accessory, sourceResourcePath, targetResourcePath, newName, textureReplacements); + CloneAvatarObjectEquivalent(accessory, sourceResourcePath, targetResourcePath, newName, textureReplacements, colorTint); // Set prefab to active so that when Unity instantiates it, the instance will also be active. // This is critical: Unity's Object.Instantiate preserves the prefab's active state. @@ -117,7 +114,8 @@ private static void CloneAvatarObjectEquivalent( string sourceResourcePath, string? targetResourcePath, string newName, - System.Collections.Generic.Dictionary? textureReplacements) + System.Collections.Generic.Dictionary? textureReplacements, + Color? colorTint) { S1CoreAvatar.AvatarObject? equivalent = accessory.AvatarObjectEquivalent; if (equivalent == null) @@ -131,6 +129,8 @@ private static void CloneAvatarObjectEquivalent( if (textureReplacements != null && textureReplacements.Count > 0) ApplyTexturesToAccessory(clone.gameObject, textureReplacements); + CustomAvatarObjects.ApplyColorTint(clone, colorTint); + if (!CustomAvatarObjects.RegisterInLibrary(clone)) { Object.Destroy(clone.gameObject); @@ -203,9 +203,6 @@ public static bool RegisterAccessory(string resourcePath, GameObject accessory) RuntimeResourceRegistry.RegisterAssetForType(resourcePath, accessoryComponent, typeof(S1AvatarFramework.Accessory)); } - // From 0.4.7 a clothing item loads its AvatarObject (Resources.Load(path)), so register it for - // that type too. This is the avatar object CloneAccessoryWithCustomTextures made for the clone, or the - // original's when the accessory was not cloned. S1CoreAvatar.AvatarObject? avatarObject = accessoryComponent != null ? accessoryComponent.AvatarObjectEquivalent : null; diff --git a/S1API/Rendering/RuntimeResourceRegistry.cs b/S1API/Rendering/RuntimeResourceRegistry.cs index de1bb7af..47868e2a 100644 --- a/S1API/Rendering/RuntimeResourceRegistry.cs +++ b/S1API/Rendering/RuntimeResourceRegistry.cs @@ -163,7 +163,7 @@ public static bool IsRegistered(string resourcePath) => internal static Object? GetRegisteredAssetForType(string resourcePath, Type type) { string typedKey = GetTypedKey(resourcePath, type); - if (_typedAssets.TryGetValue(typedKey, out var typedAsset)) + if (_typedAssets.TryGetValue(typedKey, out var typedAsset) && type.IsInstanceOfType(typedAsset)) return typedAsset; var compatible = FindCompatibleTypedAsset(_typedAssets, resourcePath, type.IsInstanceOfType); @@ -171,7 +171,7 @@ public static bool IsRegistered(string resourcePath) => return compatible; _registeredAssets.TryGetValue(resourcePath, out var asset); - return asset; + return type.IsInstanceOfType(asset) ? asset : null; } /// @@ -366,15 +366,12 @@ private static bool ResourcesLoadPrefix(string path, Il2CppSystem.Type systemTyp string normalizedTypeName = NormalizeTypeName(typeFullName); string typedKey = $"{path}|{normalizedTypeName}"; - if (_typedAssets.TryGetValue(typedKey, out var typedAsset)) + if (_typedAssets.TryGetValue(typedKey, out var typedAsset) && systemTypeInstance!.IsInstanceOfType(typedAsset)) { __result = typedAsset; return false; } - // Check for compatible types by path prefix. Only an asset the requested type accepts can answer - // the load: handing back the GameObject registered at the path for a Load made - // the caller's cast throw InvalidCastException instead of falling through to the component lookup. var compatible = FindCompatibleTypedAsset( _typedAssets, path, @@ -389,7 +386,7 @@ private static bool ResourcesLoadPrefix(string path, Il2CppSystem.Type systemTyp // Check primary registry if (_registeredAssets.TryGetValue(path, out var asset)) { - if (systemTypeInstance == null) + if (systemTypeInstance == null || systemTypeInstance.IsInstanceOfType(asset)) { __result = asset; return false; @@ -414,11 +411,6 @@ private static bool ResourcesLoadPrefix(string path, Il2CppSystem.Type systemTyp Logger.Warning($"Registered GameObject at '{path}' does not have component of type '{typeName}'"); } - else - { - __result = asset; - return false; - } } return true; From adee163fb8d654a5da1a9e6c5b6f7f7841dd6a4f Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 1 Oct 2026 21:20:22 -0700 Subject: [PATCH 34/57] chore: remove NPC registration debug logging --- S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs | 1 - S1API/Entities/NPC.cs | 2 -- 2 files changed, 3 deletions(-) diff --git a/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs index da1e067d..0d8afed6 100644 --- a/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs +++ b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs @@ -128,7 +128,6 @@ public void DerivedTypeScanDoesNotFollowSameNameAssembliesWithDifferentIdentitie [InlineData("s1api, Version=4.0.0.0, Culture=neutral, PublicKeyToken=null")] public void AReferenceToAnyVersionOfTheBaseAssemblyBindsToIt(string reference) { - // A mod built against an older S1API runs against the loaded one: the runtime binds by name and key token. var loaded = new AssemblyName("S1API, Version=3.2.1.0, Culture=neutral, PublicKeyToken=null"); Assert.True(ReflectionUtils.ReferenceBindsToDefinition(new AssemblyName(reference), loaded)); diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index 599a2140..bb6befdd 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -1151,8 +1151,6 @@ private static GameObject GetOrCreatePerNpcPrefab(System.Type npcType, NPC? owne TypeToPrefab[npcType] = prefabNO.gameObject; MarkPrefabsConfigured(); - // Failures are logged; logging success too tells "never found" apart from "found but not spawned". - Logger.Msg($"[S1API] Registered NPC prefab '{prefabName}' for {npcType.Name}."); return prefabNO.gameObject; } } From 787e3776677b1980518bc7f5c42091af8ed37da2 Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 1 Oct 2026 21:20:25 -0700 Subject: [PATCH 35/57] chore: trim redundant NPC data comments --- S1API.Tests/NPCs/NPCPrefabDataTests.cs | 1 - S1API/Entities/NPC.cs | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/S1API.Tests/NPCs/NPCPrefabDataTests.cs b/S1API.Tests/NPCs/NPCPrefabDataTests.cs index 41cd0ed7..8e4d1b83 100644 --- a/S1API.Tests/NPCs/NPCPrefabDataTests.cs +++ b/S1API.Tests/NPCs/NPCPrefabDataTests.cs @@ -8,7 +8,6 @@ public sealed class NPCPrefabDataTests [Fact] public void ADonorThatIsAlreadyADealerOrSupplierGetsDataOfItsOwnKind() { - // A dealer donor keeps its Dealer component, so plain data attached before ConfigurePrefab would stay. Assert.Equal(NpcRootRole.Dealer, NPC.DataRoleForComponent(isDealer: true, isSupplier: false)); Assert.Equal(NpcRootRole.Supplier, NPC.DataRoleForComponent(isDealer: false, isSupplier: true)); } diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index 9a13c855..02fb1783 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -1025,10 +1025,7 @@ private static GameObject GetOrCreatePerNpcPrefab(System.Type npcType, NPC? owne NormalizeBaseEmployeePrefab(prefabNO.gameObject, sourcePrefabName, rootRole); prefabNO.gameObject.name = prefabName; - // Some native prefabs have no framework data object: the plain NPC prefab, and the donor a dealer or - // supplier is built from. Attach one before ConfigurePrefab, whose builder calls write to it - // (WithVoice throws without one). It matches the component's own type, so a donor that already is a - // dealer or supplier keeps the right kind of data even if no new component replaces it. + // ConfigurePrefab needs data matching the donor component, before role replacement. S1NPCs.NPC? donorNpc = rootRole == NpcRootRole.Plain ? FindPlainNpcComponent(prefabNO.gameObject) : GetPreferredNpcComponent(prefabNO.gameObject); From 6f9b7cb5976c2c7f679b447dfa3be1cc9d7a91fd Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 1 Oct 2026 21:20:28 -0700 Subject: [PATCH 36/57] chore: trim redundant patch target test comments --- S1API.Tests/Internal/Patches/HarmonyPatchTargetTests.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/S1API.Tests/Internal/Patches/HarmonyPatchTargetTests.cs b/S1API.Tests/Internal/Patches/HarmonyPatchTargetTests.cs index b68ba0ab..d4044cb5 100644 --- a/S1API.Tests/Internal/Patches/HarmonyPatchTargetTests.cs +++ b/S1API.Tests/Internal/Patches/HarmonyPatchTargetTests.cs @@ -12,10 +12,6 @@ public sealed class HarmonyPatchTargetTests [Fact] public void AttributePatchesNameExactlyOneGameMethod() { - // A patch named only by method name fails to bind when the type has several methods of that name, and - // Harmony then skips its whole patch class. Compatibility tools can add overloads to the game assembly - // (Polyfill adds GeneratePackagingIcon(string, string)), so name the signature wherever there is a choice. - // Covers attribute-declared methods only: classes with TargetMethod(s) and getters/setters/constructors are skipped. List problems = FindUnresolvablePatchTargets(); Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); } From 9ec0598fc17813a732cde58c6539a8162c8ab825 Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 1 Oct 2026 21:20:31 -0700 Subject: [PATCH 37/57] chore: trim redundant contacts test comment --- S1API.Tests/Internal/Patches/ContactsAppWaitTests.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/S1API.Tests/Internal/Patches/ContactsAppWaitTests.cs b/S1API.Tests/Internal/Patches/ContactsAppWaitTests.cs index 4419ce5a..b346d2e9 100644 --- a/S1API.Tests/Internal/Patches/ContactsAppWaitTests.cs +++ b/S1API.Tests/Internal/Patches/ContactsAppWaitTests.cs @@ -10,8 +10,6 @@ public sealed class ContactsAppWaitTests [Fact] public void AnNpcWhoseIdThrowsDoesNotHideTheOthers() { - // On IL2CPP the game's pooled special customers throw from NPC.ID; one of them used to fail the check - // every frame, so the wait never ended. Assert.True(ContactsAppPatches.AllIdsPresent( new[] { Id("big_willy"), Throws(), Id("disco_davey"), Throws() }, new[] { "big_willy", "disco_davey" })); From a08d22d1c79f75ce87eace403bff0c2cfefd2bab Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 1 Oct 2026 22:20:17 -0700 Subject: [PATCH 38/57] feat(saveables): expose public saveable base with legacy compatibility --- .../Saveables/SaveableCompatibilityTests.cs | 171 +++++++++++ S1API/Entities/NPC.cs | 4 +- S1API/Internal/Abstraction/Registerable.cs | 5 +- S1API/Internal/Abstraction/Saveable.cs | 288 ++--------------- S1API/Quests/Quest.cs | 6 +- S1API/Saveables/Saveable.cs | 290 ++++++++++++++++++ S1API/Saveables/SaveableAutoRegistry.cs | 6 +- S1API/docs/getting-started.md | 1 - S1API/docs/save-system.md | 11 +- 9 files changed, 510 insertions(+), 272 deletions(-) create mode 100644 S1API.Tests/Saveables/SaveableCompatibilityTests.cs create mode 100644 S1API/Saveables/Saveable.cs diff --git a/S1API.Tests/Saveables/SaveableCompatibilityTests.cs b/S1API.Tests/Saveables/SaveableCompatibilityTests.cs new file mode 100644 index 00000000..6a638f88 --- /dev/null +++ b/S1API.Tests/Saveables/SaveableCompatibilityTests.cs @@ -0,0 +1,171 @@ +using System.ComponentModel; +using System.Reflection; +using S1API.Internal.Abstraction; +using S1API.Saveables; +using Saveable = S1API.Saveables.Saveable; +#pragma warning disable CS0618 // These fixtures intentionally exercise the legacy API. +using LegacySaveable = S1API.Internal.Abstraction.Saveable; + +namespace S1API.Tests.Saveables; + +public sealed class SaveableCompatibilityTests +{ + [Fact] + public void LegacySurfaceRemainsAvailableWithWarningOnlyDeprecation() + { + ObsoleteAttribute obsolete = typeof(LegacySaveable).GetCustomAttribute()!; + Assert.False(obsolete.IsError); + Assert.Contains("S1API.Saveables.Saveable", obsolete.Message); + Assert.Null(typeof(Saveable).GetCustomAttribute()); + Assert.Equal(EditorBrowsableState.Always, typeof(Saveable).GetCustomAttribute()!.State); + Assert.True(typeof(Saveable).IsAssignableFrom(typeof(LegacySaveable))); + Assert.Equal(typeof(LegacySaveable), typeof(global::S1API.Entities.NPC).BaseType); + Assert.Equal(typeof(LegacySaveable), typeof(global::S1API.Quests.Quest).BaseType); + + const BindingFlags declared = BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + Assert.True(typeof(LegacySaveable).GetProperty("LoadOrder", declared)!.GetMethod!.IsVirtual); + foreach (string hook in new[] { "OnLoaded", "OnSaved" }) + { + MethodInfo method = typeof(LegacySaveable).GetMethod(hook, declared)!; + Assert.True(method.IsFamily); + Assert.True(method.IsVirtual); + Assert.False(method.IsFinal); + Assert.True((method.Attributes & MethodAttributes.NewSlot) != 0); + } + Assert.NotNull(typeof(LegacySaveable).GetMethod("RequestGameSave", declared, Type.EmptyTypes)); + MethodInfo request = typeof(LegacySaveable).GetMethod("RequestGameSave", declared, new[] { typeof(bool) })!; + Assert.Equal("immediate", request.GetParameters()[0].Name); + Assert.True(typeof(Registerable).IsPublic); + Assert.Equal(EditorBrowsableState.Never, typeof(Registerable).GetCustomAttribute()!.State); + } + + [Fact] + public void DiscoveryAcceptsBothDirectBasesAndKeepsIndirectClassesExcluded() + { + MethodInfo discover = typeof(SaveableAutoRegistry).GetMethod("IsDirectSaveableInheritor", BindingFlags.NonPublic | BindingFlags.Static)!; + bool Accepts(Type type) => (bool)discover.Invoke(null, new object[] { type })!; + + Assert.True(Accepts(typeof(CurrentFixture))); + Assert.True(Accepts(typeof(LegacyFixture))); + Assert.False(Accepts(typeof(IndirectCurrentFixture))); + Assert.False(Accepts(typeof(IndirectLegacyFixture))); + Assert.False(Accepts(typeof(Saveable))); + Assert.False(Accepts(typeof(LegacySaveable))); + Assert.False(Accepts(typeof(global::S1API.Entities.NPC))); + Assert.False(Accepts(typeof(global::S1API.Quests.Quest))); + Assert.False(Accepts(typeof(string))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void LifecycleAndLegacyJsonLoadingDispatchThroughBothBases(bool legacy) + { + Saveable instance = legacy ? new LegacyFixture() : new CurrentFixture(); + IFixture fixture = (IFixture)instance; + Assert.Equal(SaveableLoadOrder.AfterBaseGame, instance.LoadOrder); + fixture.LoadBeforeBaseGame = true; + Assert.Equal(SaveableLoadOrder.BeforeBaseGame, instance.LoadOrder); + string folder = Path.Combine(Path.GetTempPath(), "S1API-Saveable-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + try + { + ((IRegisterable)instance).CreateInternal(); + ((ISaveable)instance).LoadInternal(folder); + Assert.Equal(1, fixture.Loaded); + Assert.Equal(7, fixture.Value); + File.WriteAllText(Path.Combine(folder, "value.json"), "42"); + ((ISaveable)instance).LoadInternal(folder); + Assert.Equal(42, fixture.Value); + Assert.Equal(2, fixture.Loaded); + ((ISaveable)instance).OnSaved(); + ((IRegisterable)instance).DestroyInternal(); + Assert.Equal(1, fixture.Saved); + Assert.Equal(1, fixture.Created); + Assert.Equal(1, fixture.Destroyed); + } + finally + { + Directory.Delete(folder, true); + } + } + +#if MONOMELON + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SavingPreservesFieldNamesAndNullDeletion(bool legacy) + { + Saveable instance = legacy ? new LegacyFixture() : new CurrentFixture(); + IFixture fixture = (IFixture)instance; + string folder = Path.Combine(Path.GetTempPath(), "S1API-Saveable-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + try + { + File.WriteAllText(Path.Combine(folder, "optional.json"), "99"); + var extra = new List(); + ((ISaveable)instance).SaveInternal(folder, ref extra); + Assert.Equal("7", File.ReadAllText(Path.Combine(folder, "value.json"))); + Assert.Equal(new[] { "value.json" }, extra); + Assert.False(File.Exists(Path.Combine(folder, "optional.json"))); + Assert.Null(fixture.Optional); + Assert.Equal(1, fixture.Saved); + } + finally + { + Directory.Delete(folder, true); + } + } +#endif + + private interface IFixture + { + int Value { get; } + int? Optional { get; } + bool LoadBeforeBaseGame { get; set; } + int Created { get; } + int Destroyed { get; } + int Loaded { get; } + int Saved { get; } + } + + private class CurrentFixture : Saveable, IFixture + { + [SaveableField("value")] private int _value = 7; + [SaveableField("optional.json")] private int? _optional = null; + public int Value => _value; + public int? Optional => _optional; + public bool LoadBeforeBaseGame { get; set; } + public override SaveableLoadOrder LoadOrder => LoadBeforeBaseGame ? SaveableLoadOrder.BeforeBaseGame : base.LoadOrder; + public int Created { get; private set; } + public int Destroyed { get; private set; } + public int Loaded { get; private set; } + public int Saved { get; private set; } + protected override void OnCreated() => Created++; + protected override void OnDestroyed() => Destroyed++; + protected override void OnLoaded() { base.OnLoaded(); Loaded++; } + protected override void OnSaved() { base.OnSaved(); Saved++; } + } + + private class LegacyFixture : LegacySaveable, IFixture + { + [SaveableField("value")] private int _value = 7; + [SaveableField("optional.json")] private int? _optional = null; + public int Value => _value; + public int? Optional => _optional; + public bool LoadBeforeBaseGame { get; set; } + public override SaveableLoadOrder LoadOrder => LoadBeforeBaseGame ? SaveableLoadOrder.BeforeBaseGame : base.LoadOrder; + public int Created { get; private set; } + public int Destroyed { get; private set; } + public int Loaded { get; private set; } + public int Saved { get; private set; } + protected override void OnCreated() => Created++; + protected override void OnDestroyed() => Destroyed++; + protected override void OnLoaded() { base.OnLoaded(); Loaded++; } + protected override void OnSaved() { base.OnSaved(); Saved++; } + } + + private sealed class IndirectCurrentFixture : CurrentFixture { } + private sealed class IndirectLegacyFixture : LegacyFixture { } +} +#pragma warning restore CS0618 diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index 02fb1783..59c0b375 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -125,7 +125,9 @@ namespace S1API.Entities /// , , , and . /// Customer, relationship, and schedule configuration must be done in for proper save/load behavior. /// - public abstract class NPC : Saveable, IEntity, IHealth +#pragma warning disable CS0618 // Preserve the legacy base type for existing NPC mods. + public abstract class NPC : global::S1API.Internal.Abstraction.Saveable, IEntity, IHealth +#pragma warning restore CS0618 { private static readonly Log Logger = new Log("NPC"); // Protected members intended to be used by modders. diff --git a/S1API/Internal/Abstraction/Registerable.cs b/S1API/Internal/Abstraction/Registerable.cs index e0b9b920..a907f811 100644 --- a/S1API/Internal/Abstraction/Registerable.cs +++ b/S1API/Internal/Abstraction/Registerable.cs @@ -1,9 +1,10 @@ namespace S1API.Internal.Abstraction { /// - /// INTERNAL: A registerable base class for use internally. - /// Not intended for modder use. + /// Infrastructure base class for S1API registration and lifecycle hooks. + /// Public so modder-facing API classes can inherit it; not intended for direct modder inheritance. /// + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] public abstract class Registerable : IRegisterable { /// diff --git a/S1API/Internal/Abstraction/Saveable.cs b/S1API/Internal/Abstraction/Saveable.cs index f0886f69..278a21df 100644 --- a/S1API/Internal/Abstraction/Saveable.cs +++ b/S1API/Internal/Abstraction/Saveable.cs @@ -1,283 +1,47 @@ -#if MONOMELON -using System.Collections.Generic; -#elif IL2CPPMELON -using Il2CppSystem.Collections.Generic; -#endif - using System; -using System.IO; -using System.Reflection; -using Newtonsoft.Json; -using S1API.Internal.Utils; +using System.ComponentModel; using S1API.Saveables; -#if (IL2CPPMELON) -using S1Datas = Il2CppScheduleOne.Persistence.Datas; -#elif MONOMELON -using S1Datas = ScheduleOne.Persistence.Datas; -#endif - -#if (IL2CPPMELON) -using S1Persistence = Il2CppScheduleOne.Persistence; -#elif MONOMELON -using S1Persistence = ScheduleOne.Persistence; -#endif namespace S1API.Internal.Abstraction { /// - /// Generic wrapper for saveable classes. + /// Compatibility base class for existing mods. Use + /// for new saveable classes. /// - public abstract class Saveable : Registerable, ISaveable + /// + /// Retained for existing compiled mods and scheduled for removal in a future breaking release. + /// No removal version has been set. + /// + [Obsolete("Use S1API.Saveables.Saveable instead. This compatibility type will be removed in a future breaking release.", false)] + [EditorBrowsable(EditorBrowsableState.Never)] + public abstract class Saveable : global::S1API.Saveables.Saveable { - /// - /// Determines when this saveable should load relative to base game saveables. - /// - /// - /// Default is , which loads after base game entities are loaded. - /// Override this property to return if your mod data needs to be - /// available before the base game's ISaveables are loaded. - /// - /// - /// - /// AfterBaseGame (default): Your method is called after base game entities - /// (NPCs, buildings, vehicles) have been loaded. This is the recommended setting for most mods. - /// - /// - /// BeforeBaseGame: Your method is called before base game entities are loaded. - /// Use this only if you need to set up hooks or state that the base game loading process depends on. - /// - /// - /// Note: All saveables are saved at the same time (after base game save), regardless of load order. - /// - /// - /// - /// - /// public class EarlyConfigSaveable : Saveable - /// { - /// public override SaveableLoadOrder LoadOrder => SaveableLoadOrder.BeforeBaseGame; - /// - /// [SaveableField("config")] - /// private ModConfig _config = new ModConfig(); - /// - /// protected override void OnLoaded() - /// { - /// // Base game entities are NOT loaded yet - /// ApplyGlobalSettings(_config); - /// } - /// } - /// - /// - public virtual SaveableLoadOrder LoadOrder => SaveableLoadOrder.AfterBaseGame; + /// + public new virtual SaveableLoadOrder LoadOrder => SaveableLoadOrder.AfterBaseGame; /// - /// Requests the game to perform a save operation. If a game is not currently loaded, - /// the request is ignored and the method returns false. + /// Requests a game save. The immediate parameter is retained for compatibility and ignored. /// - /// This parameter is ignored in v0.4.3+ (kept for backwards compatibility). + /// Ignored, as in the original implementation. /// True if a save was requested; false if the game is not in a savable state. - public static bool RequestGameSave(bool immediate) => RequestGameSave(); + public new static bool RequestGameSave(bool immediate) => + global::S1API.Saveables.Saveable.RequestGameSave(immediate); /// - /// Requests the game to perform a save operation. If a game is not currently loaded, - /// the request is ignored and the method returns false. + /// Requests a game save when a game is loaded. /// /// True if a save was requested; false if the game is not in a savable state. - public static bool RequestGameSave() - { - try - { - var loadManager = S1Persistence.LoadManager.Instance; - if (loadManager == null || !loadManager.IsGameLoaded) - return false; - - var saveManager = S1Persistence.SaveManager.Instance; - if (saveManager == null) - return false; - - saveManager.Save(); - - return true; - } - catch - { - return false; - } - } - - /// - /// INTERNAL: Explicit interface implementation that delegates to the internal LoadInternal method. - /// Loads all fields marked with the attribute from JSON files in the specified folder. - /// - /// The folder path containing the save files to load. - void ISaveable.LoadInternal(string folderPath) => - LoadInternal(folderPath); - - /// - /// INTERNAL: Loads all fields marked with the attribute from JSON files in the specified folder. - /// This method uses reflection to find fields with the SaveableField attribute and deserializes their values from JSON files. - /// After loading all fields, it calls the method to allow derived classes to perform additional initialization. - /// - /// The folder path containing the save files to load from. - internal virtual void LoadInternal(string folderPath) - { - FieldInfo[] saveableFields = GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - foreach (FieldInfo saveableField in saveableFields) - { - SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); - if (saveableFieldAttribute == null) - continue; - - string filename = saveableFieldAttribute.SaveName.EndsWith(".json") - ? saveableFieldAttribute.SaveName - : $"{saveableFieldAttribute.SaveName}.json"; - - string saveDataPath = Path.Combine(folderPath, filename); - if (!File.Exists(saveDataPath)) - continue; - - string json = File.ReadAllText(saveDataPath); - Type type = saveableField.FieldType; - object? value = JsonConvert.DeserializeObject(json, type, ISaveable.SerializerSettings); - saveableField.SetValue(this, value); - } - - OnLoaded(); - } - - /// - /// INTERNAL: Explicit interface implementation that delegates to the internal SaveInternal method. - /// Saves all fields marked with the attribute to JSON files in the specified folder. - /// - /// The folder path where save files should be written. - /// Reference to a list of extra saveable files that should not be deleted during cleanup. - void ISaveable.SaveInternal(string folderPath, ref List extraSaveables) => - SaveInternal(folderPath, ref extraSaveables); - - /// - /// INTERNAL: Saves all fields marked with the attribute to JSON files in the specified folder. - /// This method uses reflection to find fields with the SaveableField attribute and serializes their values to JSON files. - /// Null fields result in their corresponding save files being deleted. Non-null fields are added to the extraSaveables list - /// to prevent the base game from deleting them during cleanup. After saving all fields, it calls the method - /// to allow derived classes to perform additional finalization. - /// - /// The folder path where save files should be written. - /// Reference to a list of extra saveable files that should not be deleted during cleanup. - internal virtual void SaveInternal(string folderPath, ref List extraSaveables) - { - FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - foreach (FieldInfo saveableField in saveableFields) - { - SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); - if (saveableFieldAttribute == null) - continue; - - string saveFileName = saveableFieldAttribute.SaveName.EndsWith(".json") - ? saveableFieldAttribute.SaveName - : $"{saveableFieldAttribute.SaveName}.json"; - - string saveDataPath = Path.Combine(folderPath, saveFileName); - - object? value = saveableField.GetValue(this); - if (value == null) - // Remove the save if the field is null - File.Delete(saveDataPath); - else - { - // We add this to the extra saveables to prevent the game from deleting it - // Otherwise, it'll delete it after it finishes saving and does clean up - extraSaveables.Add(saveFileName); + public new static bool RequestGameSave() => global::S1API.Saveables.Saveable.RequestGameSave(); - // Write our data - string data = JsonConvert.SerializeObject(value, Formatting.Indented, ISaveable.SerializerSettings); - File.WriteAllText(saveDataPath, data); - } - } + /// + protected new virtual void OnLoaded() => base.OnLoaded(); - OnSaved(); - } + /// + protected new virtual void OnSaved() => base.OnSaved(); - /// - /// INTERNAL: Writes fields marked with into a DynamicSaveData blob - /// to support the base game's consolidated JSON save format. - /// - /// The dynamic save data record to write into. - internal void SaveToDynamic(S1Datas.DynamicSaveData dynamicSaveData) - { - if (dynamicSaveData == null) - return; - - FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - foreach (FieldInfo saveableField in saveableFields) - { - SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); - if (saveableFieldAttribute == null) - continue; - - object? value = saveableField.GetValue(this); - if (value == null) - continue; // Do not write nulls - - string data = JsonConvert.SerializeObject(value, Formatting.None, ISaveable.SerializerSettings); - // Use the declared save name as the dynamic key - dynamicSaveData.AddData(saveableFieldAttribute.SaveName, data); - } - - OnSaved(); - } - - /// - /// INTERNAL: Reads fields marked with from a DynamicSaveData blob - /// to support the base game's consolidated JSON save format. - /// - /// The dynamic save data record to read from. - internal void LoadFromDynamic(S1Datas.DynamicSaveData dynamicSaveData) - { - if (dynamicSaveData == null) - return; - - FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - foreach (FieldInfo saveableField in saveableFields) - { - SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); - if (saveableFieldAttribute == null) - continue; - - // Read the raw json for this save name and deserialize to the field type - if (!dynamicSaveData.TryGetData(saveableFieldAttribute.SaveName, out string json) || string.IsNullOrEmpty(json)) - continue; - - Type type = saveableField.FieldType; - object? value = JsonConvert.DeserializeObject(json, type, ISaveable.SerializerSettings); - saveableField.SetValue(this, value); - } - - OnLoaded(); - } - - /// - /// INTERNAL: Explicit interface implementation that delegates to the virtual OnLoaded method. - /// Called after all saveable fields have been loaded from their respective JSON files. - /// - void ISaveable.OnLoaded() => OnLoaded(); - - /// - /// Called after all saveable fields have been loaded from their respective JSON files. - /// This method can be overridden in derived classes to perform additional initialization - /// or processing after the save data has been restored. - /// - protected virtual void OnLoaded() { } - - /// - /// INTERNAL: Explicit interface implementation that delegates to the virtual OnSaved method. - /// Called after all saveable fields have been saved to their respective JSON files. - /// - void ISaveable.OnSaved() => OnSaved(); - - /// - /// Called after all saveable fields have been saved to their respective JSON files. - /// This method can be overridden in derived classes to perform additional finalization - /// or processing after the save data has been written to disk. - /// - protected virtual void OnSaved() { } + // Keep the original virtual slots for already compiled mod overrides. + internal override SaveableLoadOrder GetLoadOrder() => LoadOrder; + internal override void InvokeOnLoaded() => OnLoaded(); + internal override void InvokeOnSaved() => OnSaved(); } } diff --git a/S1API/Quests/Quest.cs b/S1API/Quests/Quest.cs index fbbefed7..e10281f3 100644 --- a/S1API/Quests/Quest.cs +++ b/S1API/Quests/Quest.cs @@ -42,8 +42,10 @@ namespace S1API.Quests /// /// To create a quest, you must use to instantiate your quest. /// - /// - public abstract class Quest : Saveable + /// +#pragma warning disable CS0618 // Preserve the legacy base type for existing quest mods. + public abstract class Quest : global::S1API.Internal.Abstraction.Saveable +#pragma warning restore CS0618 { /// /// The title of the quest to display for the player. diff --git a/S1API/Saveables/Saveable.cs b/S1API/Saveables/Saveable.cs new file mode 100644 index 00000000..e081753d --- /dev/null +++ b/S1API/Saveables/Saveable.cs @@ -0,0 +1,290 @@ +#if MONOMELON +using System.Collections.Generic; +#elif IL2CPPMELON +using Il2CppSystem.Collections.Generic; +#endif + +using System; +using System.ComponentModel; +using System.IO; +using System.Reflection; +using Newtonsoft.Json; +using S1API.Internal.Utils; +using S1API.Internal.Abstraction; +#if (IL2CPPMELON) +using S1Datas = Il2CppScheduleOne.Persistence.Datas; +#elif MONOMELON +using S1Datas = ScheduleOne.Persistence.Datas; +#endif + +#if (IL2CPPMELON) +using S1Persistence = Il2CppScheduleOne.Persistence; +#elif MONOMELON +using S1Persistence = ScheduleOne.Persistence; +#endif + +namespace S1API.Saveables +{ + /// + /// Base class for mod data persisted in each game save slot. + /// + [EditorBrowsable(EditorBrowsableState.Always)] + public abstract class Saveable : Registerable, ISaveable + { + /// + /// Determines when this saveable should load relative to base game saveables. + /// + /// + /// Default is , which loads after base game entities are loaded. + /// Override this property to return if your mod data needs to be + /// available before the base game's ISaveables are loaded. + /// + /// + /// + /// AfterBaseGame (default): Your method is called after base game entities + /// (NPCs, buildings, vehicles) have been loaded. This is the recommended setting for most mods. + /// + /// + /// BeforeBaseGame: Your method is called before base game entities are loaded. + /// Use this only if you need to set up hooks or state that the base game loading process depends on. + /// + /// + /// Note: All saveables are saved at the same time (after base game save), regardless of load order. + /// + /// + /// + /// + /// public class EarlyConfigSaveable : Saveable + /// { + /// public override SaveableLoadOrder LoadOrder => SaveableLoadOrder.BeforeBaseGame; + /// + /// [SaveableField("config")] + /// private ModConfig _config = new ModConfig(); + /// + /// protected override void OnLoaded() + /// { + /// // Base game entities are NOT loaded yet + /// ApplyGlobalSettings(_config); + /// } + /// } + /// + /// + public virtual SaveableLoadOrder LoadOrder => GetLoadOrder(); + + // Dispatch through the legacy virtual slots when a compatibility subclass is used. + internal virtual SaveableLoadOrder GetLoadOrder() => SaveableLoadOrder.AfterBaseGame; + internal virtual void InvokeOnLoaded() => OnLoaded(); + internal virtual void InvokeOnSaved() => OnSaved(); + + /// + /// Requests the game to perform a save operation. If a game is not currently loaded, + /// the request is ignored and the method returns false. + /// + /// This parameter is ignored in v0.4.3+ (kept for backwards compatibility). + /// True if a save was requested; false if the game is not in a savable state. + public static bool RequestGameSave(bool immediate) => RequestGameSave(); + + /// + /// Requests the game to perform a save operation. If a game is not currently loaded, + /// the request is ignored and the method returns false. + /// + /// True if a save was requested; false if the game is not in a savable state. + public static bool RequestGameSave() + { + try + { + var loadManager = S1Persistence.LoadManager.Instance; + if (loadManager == null || !loadManager.IsGameLoaded) + return false; + + var saveManager = S1Persistence.SaveManager.Instance; + if (saveManager == null) + return false; + + saveManager.Save(); + + return true; + } + catch + { + return false; + } + } + + /// + /// INTERNAL: Explicit interface implementation that delegates to the internal LoadInternal method. + /// Loads all fields marked with the attribute from JSON files in the specified folder. + /// + /// The folder path containing the save files to load. + void ISaveable.LoadInternal(string folderPath) => + LoadInternal(folderPath); + + /// + /// INTERNAL: Loads all fields marked with the attribute from JSON files in the specified folder. + /// This method uses reflection to find fields with the SaveableField attribute and deserializes their values from JSON files. + /// After loading all fields, it calls the method to allow derived classes to perform additional initialization. + /// + /// The folder path containing the save files to load from. + internal virtual void LoadInternal(string folderPath) + { + FieldInfo[] saveableFields = GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (FieldInfo saveableField in saveableFields) + { + SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); + if (saveableFieldAttribute == null) + continue; + + string filename = saveableFieldAttribute.SaveName.EndsWith(".json") + ? saveableFieldAttribute.SaveName + : $"{saveableFieldAttribute.SaveName}.json"; + + string saveDataPath = Path.Combine(folderPath, filename); + if (!File.Exists(saveDataPath)) + continue; + + string json = File.ReadAllText(saveDataPath); + Type type = saveableField.FieldType; + object? value = JsonConvert.DeserializeObject(json, type, ISaveable.SerializerSettings); + saveableField.SetValue(this, value); + } + + InvokeOnLoaded(); + } + + /// + /// INTERNAL: Explicit interface implementation that delegates to the internal SaveInternal method. + /// Saves all fields marked with the attribute to JSON files in the specified folder. + /// + /// The folder path where save files should be written. + /// Reference to a list of extra saveable files that should not be deleted during cleanup. + void ISaveable.SaveInternal(string folderPath, ref List extraSaveables) => + SaveInternal(folderPath, ref extraSaveables); + + /// + /// INTERNAL: Saves all fields marked with the attribute to JSON files in the specified folder. + /// This method uses reflection to find fields with the SaveableField attribute and serializes their values to JSON files. + /// Null fields result in their corresponding save files being deleted. Non-null fields are added to the extraSaveables list + /// to prevent the base game from deleting them during cleanup. After saving all fields, it calls the method + /// to allow derived classes to perform additional finalization. + /// + /// The folder path where save files should be written. + /// Reference to a list of extra saveable files that should not be deleted during cleanup. + internal virtual void SaveInternal(string folderPath, ref List extraSaveables) + { + FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (FieldInfo saveableField in saveableFields) + { + SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); + if (saveableFieldAttribute == null) + continue; + + string saveFileName = saveableFieldAttribute.SaveName.EndsWith(".json") + ? saveableFieldAttribute.SaveName + : $"{saveableFieldAttribute.SaveName}.json"; + + string saveDataPath = Path.Combine(folderPath, saveFileName); + + object? value = saveableField.GetValue(this); + if (value == null) + // Remove the save if the field is null + File.Delete(saveDataPath); + else + { + // We add this to the extra saveables to prevent the game from deleting it + // Otherwise, it'll delete it after it finishes saving and does clean up + extraSaveables.Add(saveFileName); + + // Write our data + string data = JsonConvert.SerializeObject(value, Formatting.Indented, ISaveable.SerializerSettings); + File.WriteAllText(saveDataPath, data); + } + } + + InvokeOnSaved(); + } + + /// + /// INTERNAL: Writes fields marked with into a DynamicSaveData blob + /// to support the base game's consolidated JSON save format. + /// + /// The dynamic save data record to write into. + internal void SaveToDynamic(S1Datas.DynamicSaveData dynamicSaveData) + { + if (dynamicSaveData == null) + return; + + FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (FieldInfo saveableField in saveableFields) + { + SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); + if (saveableFieldAttribute == null) + continue; + + object? value = saveableField.GetValue(this); + if (value == null) + continue; // Do not write nulls + + string data = JsonConvert.SerializeObject(value, Formatting.None, ISaveable.SerializerSettings); + // Use the declared save name as the dynamic key + dynamicSaveData.AddData(saveableFieldAttribute.SaveName, data); + } + + InvokeOnSaved(); + } + + /// + /// INTERNAL: Reads fields marked with from a DynamicSaveData blob + /// to support the base game's consolidated JSON save format. + /// + /// The dynamic save data record to read from. + internal void LoadFromDynamic(S1Datas.DynamicSaveData dynamicSaveData) + { + if (dynamicSaveData == null) + return; + + FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (FieldInfo saveableField in saveableFields) + { + SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); + if (saveableFieldAttribute == null) + continue; + + // Read the raw json for this save name and deserialize to the field type + if (!dynamicSaveData.TryGetData(saveableFieldAttribute.SaveName, out string json) || string.IsNullOrEmpty(json)) + continue; + + Type type = saveableField.FieldType; + object? value = JsonConvert.DeserializeObject(json, type, ISaveable.SerializerSettings); + saveableField.SetValue(this, value); + } + + InvokeOnLoaded(); + } + + /// + /// INTERNAL: Explicit interface implementation that delegates to the virtual OnLoaded method. + /// Called after all saveable fields have been loaded from their respective JSON files. + /// + void ISaveable.OnLoaded() => InvokeOnLoaded(); + + /// + /// Called after all saveable fields have been loaded from their respective JSON files. + /// This method can be overridden in derived classes to perform additional initialization + /// or processing after the save data has been restored. + /// + protected virtual void OnLoaded() { } + + /// + /// INTERNAL: Explicit interface implementation that delegates to the virtual OnSaved method. + /// Called after all saveable fields have been saved to their respective JSON files. + /// + void ISaveable.OnSaved() => InvokeOnSaved(); + + /// + /// Called after all saveable fields have been saved to their respective JSON files. + /// This method can be overridden in derived classes to perform additional finalization + /// or processing after the save data has been written to disk. + /// + protected virtual void OnSaved() { } + } +} diff --git a/S1API/Saveables/SaveableAutoRegistry.cs b/S1API/Saveables/SaveableAutoRegistry.cs index b22a7bfa..76a1e212 100644 --- a/S1API/Saveables/SaveableAutoRegistry.cs +++ b/S1API/Saveables/SaveableAutoRegistry.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using S1API.Internal.Abstraction; using S1API.Internal.Utils; namespace S1API.Saveables @@ -132,7 +131,10 @@ private static bool IsDirectSaveableInheritor(Type type) // Check if the base type is exactly Saveable (not a subclass) Type? baseType = type.BaseType; - return baseType == typeof(Saveable); +#pragma warning disable CS0618 // Keep discovery of existing mods using the compatibility base. + return baseType == typeof(Saveable) + || baseType == typeof(global::S1API.Internal.Abstraction.Saveable); +#pragma warning restore CS0618 } /// diff --git a/S1API/docs/getting-started.md b/S1API/docs/getting-started.md index 80117a08..4c8f01cf 100644 --- a/S1API/docs/getting-started.md +++ b/S1API/docs/getting-started.md @@ -113,7 +113,6 @@ Launch the game. Your app icon should appear on the phone; clicking it opens you Annotate fields with `SaveableField` in classes inheriting `Saveable`. S1API will save/load JSON per save slot. ```csharp -using S1API.Internal.Abstraction; using S1API.Saveables; public class MySaveData : Saveable diff --git a/S1API/docs/save-system.md b/S1API/docs/save-system.md index 0c5b4d37..ba3e5521 100644 --- a/S1API/docs/save-system.md +++ b/S1API/docs/save-system.md @@ -1,11 +1,10 @@ # Save System -S1API provides a simple, attribute-based persistence model. Inherit from `Internal.Abstraction.Saveable` and annotate fields with `SaveableField`. +S1API provides a simple, attribute-based persistence model. Inherit from `S1API.Saveables.Saveable` and annotate fields with `SaveableField`. ## Basics ```csharp -using S1API.Internal.Abstraction; using S1API.Saveables; public class NotesSave : Saveable @@ -26,6 +25,14 @@ public class NotesSave : Saveable S1API uses JSON with standard `JsonSerializerSettings` that ignore reference loops and support GUID references. +## Migration from the internal namespace + +`S1API.Internal.Abstraction.Saveable` is obsolete with a compiler warning. Existing compiled mods continue to use the compatibility base, which forwards to `S1API.Saveables.Saveable`. Its removal is planned for a future breaking release; no removal version has been set. + +When updating a standalone saveable, replace the old namespace import with `using S1API.Saveables;`. If you need both namespaces, qualify the base as `S1API.Saveables.Saveable` or use an alias to avoid an ambiguous `Saveable` reference. Keep your class name, namespace, and `SaveableField` keys unchanged: changing the base alone does not change existing save folders or JSON keys. + +NPC and quest subclasses continue to inherit their persistence support from their existing API base classes and need no migration. + ## Load Order Control By default, saveables load **after** base game ISaveables (NPCs, buildings, vehicles, etc.). Override the `LoadOrder` property to control timing: From 50c720821188b47ad40d5148ccbff36305f0d702 Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 1 Oct 2026 22:35:28 -0700 Subject: [PATCH 39/57] docs(abstraction): retain only registerable and saveable documentation --- .../Saveables/SaveableCompatibilityTests.cs | 171 ---------- S1API/Entities/NPC.cs | 4 +- S1API/Internal/Abstraction/Registerable.cs | 7 +- S1API/Internal/Abstraction/Saveable.cs | 291 ++++++++++++++++-- S1API/Quests/Quest.cs | 6 +- S1API/Saveables/Saveable.cs | 290 ----------------- S1API/Saveables/SaveableAutoRegistry.cs | 6 +- S1API/docs/getting-started.md | 1 + S1API/docs/save-system.md | 11 +- 9 files changed, 280 insertions(+), 507 deletions(-) delete mode 100644 S1API.Tests/Saveables/SaveableCompatibilityTests.cs delete mode 100644 S1API/Saveables/Saveable.cs diff --git a/S1API.Tests/Saveables/SaveableCompatibilityTests.cs b/S1API.Tests/Saveables/SaveableCompatibilityTests.cs deleted file mode 100644 index 6a638f88..00000000 --- a/S1API.Tests/Saveables/SaveableCompatibilityTests.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System.ComponentModel; -using System.Reflection; -using S1API.Internal.Abstraction; -using S1API.Saveables; -using Saveable = S1API.Saveables.Saveable; -#pragma warning disable CS0618 // These fixtures intentionally exercise the legacy API. -using LegacySaveable = S1API.Internal.Abstraction.Saveable; - -namespace S1API.Tests.Saveables; - -public sealed class SaveableCompatibilityTests -{ - [Fact] - public void LegacySurfaceRemainsAvailableWithWarningOnlyDeprecation() - { - ObsoleteAttribute obsolete = typeof(LegacySaveable).GetCustomAttribute()!; - Assert.False(obsolete.IsError); - Assert.Contains("S1API.Saveables.Saveable", obsolete.Message); - Assert.Null(typeof(Saveable).GetCustomAttribute()); - Assert.Equal(EditorBrowsableState.Always, typeof(Saveable).GetCustomAttribute()!.State); - Assert.True(typeof(Saveable).IsAssignableFrom(typeof(LegacySaveable))); - Assert.Equal(typeof(LegacySaveable), typeof(global::S1API.Entities.NPC).BaseType); - Assert.Equal(typeof(LegacySaveable), typeof(global::S1API.Quests.Quest).BaseType); - - const BindingFlags declared = BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; - Assert.True(typeof(LegacySaveable).GetProperty("LoadOrder", declared)!.GetMethod!.IsVirtual); - foreach (string hook in new[] { "OnLoaded", "OnSaved" }) - { - MethodInfo method = typeof(LegacySaveable).GetMethod(hook, declared)!; - Assert.True(method.IsFamily); - Assert.True(method.IsVirtual); - Assert.False(method.IsFinal); - Assert.True((method.Attributes & MethodAttributes.NewSlot) != 0); - } - Assert.NotNull(typeof(LegacySaveable).GetMethod("RequestGameSave", declared, Type.EmptyTypes)); - MethodInfo request = typeof(LegacySaveable).GetMethod("RequestGameSave", declared, new[] { typeof(bool) })!; - Assert.Equal("immediate", request.GetParameters()[0].Name); - Assert.True(typeof(Registerable).IsPublic); - Assert.Equal(EditorBrowsableState.Never, typeof(Registerable).GetCustomAttribute()!.State); - } - - [Fact] - public void DiscoveryAcceptsBothDirectBasesAndKeepsIndirectClassesExcluded() - { - MethodInfo discover = typeof(SaveableAutoRegistry).GetMethod("IsDirectSaveableInheritor", BindingFlags.NonPublic | BindingFlags.Static)!; - bool Accepts(Type type) => (bool)discover.Invoke(null, new object[] { type })!; - - Assert.True(Accepts(typeof(CurrentFixture))); - Assert.True(Accepts(typeof(LegacyFixture))); - Assert.False(Accepts(typeof(IndirectCurrentFixture))); - Assert.False(Accepts(typeof(IndirectLegacyFixture))); - Assert.False(Accepts(typeof(Saveable))); - Assert.False(Accepts(typeof(LegacySaveable))); - Assert.False(Accepts(typeof(global::S1API.Entities.NPC))); - Assert.False(Accepts(typeof(global::S1API.Quests.Quest))); - Assert.False(Accepts(typeof(string))); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void LifecycleAndLegacyJsonLoadingDispatchThroughBothBases(bool legacy) - { - Saveable instance = legacy ? new LegacyFixture() : new CurrentFixture(); - IFixture fixture = (IFixture)instance; - Assert.Equal(SaveableLoadOrder.AfterBaseGame, instance.LoadOrder); - fixture.LoadBeforeBaseGame = true; - Assert.Equal(SaveableLoadOrder.BeforeBaseGame, instance.LoadOrder); - string folder = Path.Combine(Path.GetTempPath(), "S1API-Saveable-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(folder); - try - { - ((IRegisterable)instance).CreateInternal(); - ((ISaveable)instance).LoadInternal(folder); - Assert.Equal(1, fixture.Loaded); - Assert.Equal(7, fixture.Value); - File.WriteAllText(Path.Combine(folder, "value.json"), "42"); - ((ISaveable)instance).LoadInternal(folder); - Assert.Equal(42, fixture.Value); - Assert.Equal(2, fixture.Loaded); - ((ISaveable)instance).OnSaved(); - ((IRegisterable)instance).DestroyInternal(); - Assert.Equal(1, fixture.Saved); - Assert.Equal(1, fixture.Created); - Assert.Equal(1, fixture.Destroyed); - } - finally - { - Directory.Delete(folder, true); - } - } - -#if MONOMELON - [Theory] - [InlineData(false)] - [InlineData(true)] - public void SavingPreservesFieldNamesAndNullDeletion(bool legacy) - { - Saveable instance = legacy ? new LegacyFixture() : new CurrentFixture(); - IFixture fixture = (IFixture)instance; - string folder = Path.Combine(Path.GetTempPath(), "S1API-Saveable-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(folder); - try - { - File.WriteAllText(Path.Combine(folder, "optional.json"), "99"); - var extra = new List(); - ((ISaveable)instance).SaveInternal(folder, ref extra); - Assert.Equal("7", File.ReadAllText(Path.Combine(folder, "value.json"))); - Assert.Equal(new[] { "value.json" }, extra); - Assert.False(File.Exists(Path.Combine(folder, "optional.json"))); - Assert.Null(fixture.Optional); - Assert.Equal(1, fixture.Saved); - } - finally - { - Directory.Delete(folder, true); - } - } -#endif - - private interface IFixture - { - int Value { get; } - int? Optional { get; } - bool LoadBeforeBaseGame { get; set; } - int Created { get; } - int Destroyed { get; } - int Loaded { get; } - int Saved { get; } - } - - private class CurrentFixture : Saveable, IFixture - { - [SaveableField("value")] private int _value = 7; - [SaveableField("optional.json")] private int? _optional = null; - public int Value => _value; - public int? Optional => _optional; - public bool LoadBeforeBaseGame { get; set; } - public override SaveableLoadOrder LoadOrder => LoadBeforeBaseGame ? SaveableLoadOrder.BeforeBaseGame : base.LoadOrder; - public int Created { get; private set; } - public int Destroyed { get; private set; } - public int Loaded { get; private set; } - public int Saved { get; private set; } - protected override void OnCreated() => Created++; - protected override void OnDestroyed() => Destroyed++; - protected override void OnLoaded() { base.OnLoaded(); Loaded++; } - protected override void OnSaved() { base.OnSaved(); Saved++; } - } - - private class LegacyFixture : LegacySaveable, IFixture - { - [SaveableField("value")] private int _value = 7; - [SaveableField("optional.json")] private int? _optional = null; - public int Value => _value; - public int? Optional => _optional; - public bool LoadBeforeBaseGame { get; set; } - public override SaveableLoadOrder LoadOrder => LoadBeforeBaseGame ? SaveableLoadOrder.BeforeBaseGame : base.LoadOrder; - public int Created { get; private set; } - public int Destroyed { get; private set; } - public int Loaded { get; private set; } - public int Saved { get; private set; } - protected override void OnCreated() => Created++; - protected override void OnDestroyed() => Destroyed++; - protected override void OnLoaded() { base.OnLoaded(); Loaded++; } - protected override void OnSaved() { base.OnSaved(); Saved++; } - } - - private sealed class IndirectCurrentFixture : CurrentFixture { } - private sealed class IndirectLegacyFixture : LegacyFixture { } -} -#pragma warning restore CS0618 diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index 59c0b375..02fb1783 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -125,9 +125,7 @@ namespace S1API.Entities /// , , , and . /// Customer, relationship, and schedule configuration must be done in for proper save/load behavior. /// -#pragma warning disable CS0618 // Preserve the legacy base type for existing NPC mods. - public abstract class NPC : global::S1API.Internal.Abstraction.Saveable, IEntity, IHealth -#pragma warning restore CS0618 + public abstract class NPC : Saveable, IEntity, IHealth { private static readonly Log Logger = new Log("NPC"); // Protected members intended to be used by modders. diff --git a/S1API/Internal/Abstraction/Registerable.cs b/S1API/Internal/Abstraction/Registerable.cs index a907f811..9383c441 100644 --- a/S1API/Internal/Abstraction/Registerable.cs +++ b/S1API/Internal/Abstraction/Registerable.cs @@ -2,9 +2,12 @@ { /// /// Infrastructure base class for S1API registration and lifecycle hooks. - /// Public so modder-facing API classes can inherit it; not intended for direct modder inheritance. /// - [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + /// + /// This class is public because public API classes inherit from it. + /// Modders should inherit from the appropriate API base class, such as + /// NPC, Quest, or Saveable, rather than this infrastructure class directly. + /// public abstract class Registerable : IRegisterable { /// diff --git a/S1API/Internal/Abstraction/Saveable.cs b/S1API/Internal/Abstraction/Saveable.cs index 278a21df..a93ea17d 100644 --- a/S1API/Internal/Abstraction/Saveable.cs +++ b/S1API/Internal/Abstraction/Saveable.cs @@ -1,47 +1,290 @@ +#if MONOMELON +using System.Collections.Generic; +#elif IL2CPPMELON +using Il2CppSystem.Collections.Generic; +#endif + using System; -using System.ComponentModel; +using System.IO; +using System.Reflection; +using Newtonsoft.Json; +using S1API.Internal.Utils; using S1API.Saveables; +#if (IL2CPPMELON) +using S1Datas = Il2CppScheduleOne.Persistence.Datas; +#elif MONOMELON +using S1Datas = ScheduleOne.Persistence.Datas; +#endif + +#if (IL2CPPMELON) +using S1Persistence = Il2CppScheduleOne.Persistence; +#elif MONOMELON +using S1Persistence = ScheduleOne.Persistence; +#endif namespace S1API.Internal.Abstraction { /// - /// Compatibility base class for existing mods. Use - /// for new saveable classes. + /// Base class for mod data persisted in the game's save slots. /// /// - /// Retained for existing compiled mods and scheduled for removal in a future breaking release. - /// No removal version has been set. + /// This is a supported modder-facing API despite its S1API.Internal.Abstraction namespace. + /// Inherit from this class for standalone mod save data and mark fields with + /// . Override and + /// to respond to persistence events, and to control load timing. + /// API classes such as NPC and Quest already inherit this persistence support. /// - [Obsolete("Use S1API.Saveables.Saveable instead. This compatibility type will be removed in a future breaking release.", false)] - [EditorBrowsable(EditorBrowsableState.Never)] - public abstract class Saveable : global::S1API.Saveables.Saveable + public abstract class Saveable : Registerable, ISaveable { - /// - public new virtual SaveableLoadOrder LoadOrder => SaveableLoadOrder.AfterBaseGame; + /// + /// Determines when this saveable should load relative to base game saveables. + /// + /// + /// Default is , which loads after base game entities are loaded. + /// Override this property to return if your mod data needs to be + /// available before the base game's ISaveables are loaded. + /// + /// + /// + /// AfterBaseGame (default): Your method is called after base game entities + /// (NPCs, buildings, vehicles) have been loaded. This is the recommended setting for most mods. + /// + /// + /// BeforeBaseGame: Your method is called before base game entities are loaded. + /// Use this only if you need to set up hooks or state that the base game loading process depends on. + /// + /// + /// Note: All saveables are saved at the same time (after base game save), regardless of load order. + /// + /// + /// + /// + /// public class EarlyConfigSaveable : Saveable + /// { + /// public override SaveableLoadOrder LoadOrder => SaveableLoadOrder.BeforeBaseGame; + /// + /// [SaveableField("config")] + /// private ModConfig _config = new ModConfig(); + /// + /// protected override void OnLoaded() + /// { + /// // Base game entities are NOT loaded yet + /// ApplyGlobalSettings(_config); + /// } + /// } + /// + /// + public virtual SaveableLoadOrder LoadOrder => SaveableLoadOrder.AfterBaseGame; /// - /// Requests a game save. The immediate parameter is retained for compatibility and ignored. + /// Requests the game to perform a save operation. If a game is not currently loaded, + /// the request is ignored and the method returns false. /// - /// Ignored, as in the original implementation. + /// This parameter is ignored in v0.4.3+ (kept for backwards compatibility). /// True if a save was requested; false if the game is not in a savable state. - public new static bool RequestGameSave(bool immediate) => - global::S1API.Saveables.Saveable.RequestGameSave(immediate); + public static bool RequestGameSave(bool immediate) => RequestGameSave(); /// - /// Requests a game save when a game is loaded. + /// Requests the game to perform a save operation. If a game is not currently loaded, + /// the request is ignored and the method returns false. /// /// True if a save was requested; false if the game is not in a savable state. - public new static bool RequestGameSave() => global::S1API.Saveables.Saveable.RequestGameSave(); + public static bool RequestGameSave() + { + try + { + var loadManager = S1Persistence.LoadManager.Instance; + if (loadManager == null || !loadManager.IsGameLoaded) + return false; + + var saveManager = S1Persistence.SaveManager.Instance; + if (saveManager == null) + return false; + + saveManager.Save(); + + return true; + } + catch + { + return false; + } + } + + /// + /// INTERNAL: Explicit interface implementation that delegates to the internal LoadInternal method. + /// Loads all fields marked with the attribute from JSON files in the specified folder. + /// + /// The folder path containing the save files to load. + void ISaveable.LoadInternal(string folderPath) => + LoadInternal(folderPath); + + /// + /// INTERNAL: Loads all fields marked with the attribute from JSON files in the specified folder. + /// This method uses reflection to find fields with the SaveableField attribute and deserializes their values from JSON files. + /// After loading all fields, it calls the method to allow derived classes to perform additional initialization. + /// + /// The folder path containing the save files to load from. + internal virtual void LoadInternal(string folderPath) + { + FieldInfo[] saveableFields = GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (FieldInfo saveableField in saveableFields) + { + SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); + if (saveableFieldAttribute == null) + continue; + + string filename = saveableFieldAttribute.SaveName.EndsWith(".json") + ? saveableFieldAttribute.SaveName + : $"{saveableFieldAttribute.SaveName}.json"; + + string saveDataPath = Path.Combine(folderPath, filename); + if (!File.Exists(saveDataPath)) + continue; + + string json = File.ReadAllText(saveDataPath); + Type type = saveableField.FieldType; + object? value = JsonConvert.DeserializeObject(json, type, ISaveable.SerializerSettings); + saveableField.SetValue(this, value); + } + + OnLoaded(); + } + + /// + /// INTERNAL: Explicit interface implementation that delegates to the internal SaveInternal method. + /// Saves all fields marked with the attribute to JSON files in the specified folder. + /// + /// The folder path where save files should be written. + /// Reference to a list of extra saveable files that should not be deleted during cleanup. + void ISaveable.SaveInternal(string folderPath, ref List extraSaveables) => + SaveInternal(folderPath, ref extraSaveables); + + /// + /// INTERNAL: Saves all fields marked with the attribute to JSON files in the specified folder. + /// This method uses reflection to find fields with the SaveableField attribute and serializes their values to JSON files. + /// Null fields result in their corresponding save files being deleted. Non-null fields are added to the extraSaveables list + /// to prevent the base game from deleting them during cleanup. After saving all fields, it calls the method + /// to allow derived classes to perform additional finalization. + /// + /// The folder path where save files should be written. + /// Reference to a list of extra saveable files that should not be deleted during cleanup. + internal virtual void SaveInternal(string folderPath, ref List extraSaveables) + { + FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (FieldInfo saveableField in saveableFields) + { + SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); + if (saveableFieldAttribute == null) + continue; + + string saveFileName = saveableFieldAttribute.SaveName.EndsWith(".json") + ? saveableFieldAttribute.SaveName + : $"{saveableFieldAttribute.SaveName}.json"; + + string saveDataPath = Path.Combine(folderPath, saveFileName); + + object? value = saveableField.GetValue(this); + if (value == null) + // Remove the save if the field is null + File.Delete(saveDataPath); + else + { + // We add this to the extra saveables to prevent the game from deleting it + // Otherwise, it'll delete it after it finishes saving and does clean up + extraSaveables.Add(saveFileName); - /// - protected new virtual void OnLoaded() => base.OnLoaded(); + // Write our data + string data = JsonConvert.SerializeObject(value, Formatting.Indented, ISaveable.SerializerSettings); + File.WriteAllText(saveDataPath, data); + } + } - /// - protected new virtual void OnSaved() => base.OnSaved(); + OnSaved(); + } - // Keep the original virtual slots for already compiled mod overrides. - internal override SaveableLoadOrder GetLoadOrder() => LoadOrder; - internal override void InvokeOnLoaded() => OnLoaded(); - internal override void InvokeOnSaved() => OnSaved(); + /// + /// INTERNAL: Writes fields marked with into a DynamicSaveData blob + /// to support the base game's consolidated JSON save format. + /// + /// The dynamic save data record to write into. + internal void SaveToDynamic(S1Datas.DynamicSaveData dynamicSaveData) + { + if (dynamicSaveData == null) + return; + + FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (FieldInfo saveableField in saveableFields) + { + SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); + if (saveableFieldAttribute == null) + continue; + + object? value = saveableField.GetValue(this); + if (value == null) + continue; // Do not write nulls + + string data = JsonConvert.SerializeObject(value, Formatting.None, ISaveable.SerializerSettings); + // Use the declared save name as the dynamic key + dynamicSaveData.AddData(saveableFieldAttribute.SaveName, data); + } + + OnSaved(); + } + + /// + /// INTERNAL: Reads fields marked with from a DynamicSaveData blob + /// to support the base game's consolidated JSON save format. + /// + /// The dynamic save data record to read from. + internal void LoadFromDynamic(S1Datas.DynamicSaveData dynamicSaveData) + { + if (dynamicSaveData == null) + return; + + FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (FieldInfo saveableField in saveableFields) + { + SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); + if (saveableFieldAttribute == null) + continue; + + // Read the raw json for this save name and deserialize to the field type + if (!dynamicSaveData.TryGetData(saveableFieldAttribute.SaveName, out string json) || string.IsNullOrEmpty(json)) + continue; + + Type type = saveableField.FieldType; + object? value = JsonConvert.DeserializeObject(json, type, ISaveable.SerializerSettings); + saveableField.SetValue(this, value); + } + + OnLoaded(); + } + + /// + /// INTERNAL: Explicit interface implementation that delegates to the virtual OnLoaded method. + /// Called after all saveable fields have been loaded from their respective JSON files. + /// + void ISaveable.OnLoaded() => OnLoaded(); + + /// + /// Called after all saveable fields have been loaded from their respective JSON files. + /// This method can be overridden in derived classes to perform additional initialization + /// or processing after the save data has been restored. + /// + protected virtual void OnLoaded() { } + + /// + /// INTERNAL: Explicit interface implementation that delegates to the virtual OnSaved method. + /// Called after all saveable fields have been saved to their respective JSON files. + /// + void ISaveable.OnSaved() => OnSaved(); + + /// + /// Called after all saveable fields have been saved to their respective JSON files. + /// This method can be overridden in derived classes to perform additional finalization + /// or processing after the save data has been written to disk. + /// + protected virtual void OnSaved() { } } } diff --git a/S1API/Quests/Quest.cs b/S1API/Quests/Quest.cs index e10281f3..fbbefed7 100644 --- a/S1API/Quests/Quest.cs +++ b/S1API/Quests/Quest.cs @@ -42,10 +42,8 @@ namespace S1API.Quests /// /// To create a quest, you must use to instantiate your quest. /// - /// -#pragma warning disable CS0618 // Preserve the legacy base type for existing quest mods. - public abstract class Quest : global::S1API.Internal.Abstraction.Saveable -#pragma warning restore CS0618 + /// + public abstract class Quest : Saveable { /// /// The title of the quest to display for the player. diff --git a/S1API/Saveables/Saveable.cs b/S1API/Saveables/Saveable.cs deleted file mode 100644 index e081753d..00000000 --- a/S1API/Saveables/Saveable.cs +++ /dev/null @@ -1,290 +0,0 @@ -#if MONOMELON -using System.Collections.Generic; -#elif IL2CPPMELON -using Il2CppSystem.Collections.Generic; -#endif - -using System; -using System.ComponentModel; -using System.IO; -using System.Reflection; -using Newtonsoft.Json; -using S1API.Internal.Utils; -using S1API.Internal.Abstraction; -#if (IL2CPPMELON) -using S1Datas = Il2CppScheduleOne.Persistence.Datas; -#elif MONOMELON -using S1Datas = ScheduleOne.Persistence.Datas; -#endif - -#if (IL2CPPMELON) -using S1Persistence = Il2CppScheduleOne.Persistence; -#elif MONOMELON -using S1Persistence = ScheduleOne.Persistence; -#endif - -namespace S1API.Saveables -{ - /// - /// Base class for mod data persisted in each game save slot. - /// - [EditorBrowsable(EditorBrowsableState.Always)] - public abstract class Saveable : Registerable, ISaveable - { - /// - /// Determines when this saveable should load relative to base game saveables. - /// - /// - /// Default is , which loads after base game entities are loaded. - /// Override this property to return if your mod data needs to be - /// available before the base game's ISaveables are loaded. - /// - /// - /// - /// AfterBaseGame (default): Your method is called after base game entities - /// (NPCs, buildings, vehicles) have been loaded. This is the recommended setting for most mods. - /// - /// - /// BeforeBaseGame: Your method is called before base game entities are loaded. - /// Use this only if you need to set up hooks or state that the base game loading process depends on. - /// - /// - /// Note: All saveables are saved at the same time (after base game save), regardless of load order. - /// - /// - /// - /// - /// public class EarlyConfigSaveable : Saveable - /// { - /// public override SaveableLoadOrder LoadOrder => SaveableLoadOrder.BeforeBaseGame; - /// - /// [SaveableField("config")] - /// private ModConfig _config = new ModConfig(); - /// - /// protected override void OnLoaded() - /// { - /// // Base game entities are NOT loaded yet - /// ApplyGlobalSettings(_config); - /// } - /// } - /// - /// - public virtual SaveableLoadOrder LoadOrder => GetLoadOrder(); - - // Dispatch through the legacy virtual slots when a compatibility subclass is used. - internal virtual SaveableLoadOrder GetLoadOrder() => SaveableLoadOrder.AfterBaseGame; - internal virtual void InvokeOnLoaded() => OnLoaded(); - internal virtual void InvokeOnSaved() => OnSaved(); - - /// - /// Requests the game to perform a save operation. If a game is not currently loaded, - /// the request is ignored and the method returns false. - /// - /// This parameter is ignored in v0.4.3+ (kept for backwards compatibility). - /// True if a save was requested; false if the game is not in a savable state. - public static bool RequestGameSave(bool immediate) => RequestGameSave(); - - /// - /// Requests the game to perform a save operation. If a game is not currently loaded, - /// the request is ignored and the method returns false. - /// - /// True if a save was requested; false if the game is not in a savable state. - public static bool RequestGameSave() - { - try - { - var loadManager = S1Persistence.LoadManager.Instance; - if (loadManager == null || !loadManager.IsGameLoaded) - return false; - - var saveManager = S1Persistence.SaveManager.Instance; - if (saveManager == null) - return false; - - saveManager.Save(); - - return true; - } - catch - { - return false; - } - } - - /// - /// INTERNAL: Explicit interface implementation that delegates to the internal LoadInternal method. - /// Loads all fields marked with the attribute from JSON files in the specified folder. - /// - /// The folder path containing the save files to load. - void ISaveable.LoadInternal(string folderPath) => - LoadInternal(folderPath); - - /// - /// INTERNAL: Loads all fields marked with the attribute from JSON files in the specified folder. - /// This method uses reflection to find fields with the SaveableField attribute and deserializes their values from JSON files. - /// After loading all fields, it calls the method to allow derived classes to perform additional initialization. - /// - /// The folder path containing the save files to load from. - internal virtual void LoadInternal(string folderPath) - { - FieldInfo[] saveableFields = GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - foreach (FieldInfo saveableField in saveableFields) - { - SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); - if (saveableFieldAttribute == null) - continue; - - string filename = saveableFieldAttribute.SaveName.EndsWith(".json") - ? saveableFieldAttribute.SaveName - : $"{saveableFieldAttribute.SaveName}.json"; - - string saveDataPath = Path.Combine(folderPath, filename); - if (!File.Exists(saveDataPath)) - continue; - - string json = File.ReadAllText(saveDataPath); - Type type = saveableField.FieldType; - object? value = JsonConvert.DeserializeObject(json, type, ISaveable.SerializerSettings); - saveableField.SetValue(this, value); - } - - InvokeOnLoaded(); - } - - /// - /// INTERNAL: Explicit interface implementation that delegates to the internal SaveInternal method. - /// Saves all fields marked with the attribute to JSON files in the specified folder. - /// - /// The folder path where save files should be written. - /// Reference to a list of extra saveable files that should not be deleted during cleanup. - void ISaveable.SaveInternal(string folderPath, ref List extraSaveables) => - SaveInternal(folderPath, ref extraSaveables); - - /// - /// INTERNAL: Saves all fields marked with the attribute to JSON files in the specified folder. - /// This method uses reflection to find fields with the SaveableField attribute and serializes their values to JSON files. - /// Null fields result in their corresponding save files being deleted. Non-null fields are added to the extraSaveables list - /// to prevent the base game from deleting them during cleanup. After saving all fields, it calls the method - /// to allow derived classes to perform additional finalization. - /// - /// The folder path where save files should be written. - /// Reference to a list of extra saveable files that should not be deleted during cleanup. - internal virtual void SaveInternal(string folderPath, ref List extraSaveables) - { - FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - foreach (FieldInfo saveableField in saveableFields) - { - SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); - if (saveableFieldAttribute == null) - continue; - - string saveFileName = saveableFieldAttribute.SaveName.EndsWith(".json") - ? saveableFieldAttribute.SaveName - : $"{saveableFieldAttribute.SaveName}.json"; - - string saveDataPath = Path.Combine(folderPath, saveFileName); - - object? value = saveableField.GetValue(this); - if (value == null) - // Remove the save if the field is null - File.Delete(saveDataPath); - else - { - // We add this to the extra saveables to prevent the game from deleting it - // Otherwise, it'll delete it after it finishes saving and does clean up - extraSaveables.Add(saveFileName); - - // Write our data - string data = JsonConvert.SerializeObject(value, Formatting.Indented, ISaveable.SerializerSettings); - File.WriteAllText(saveDataPath, data); - } - } - - InvokeOnSaved(); - } - - /// - /// INTERNAL: Writes fields marked with into a DynamicSaveData blob - /// to support the base game's consolidated JSON save format. - /// - /// The dynamic save data record to write into. - internal void SaveToDynamic(S1Datas.DynamicSaveData dynamicSaveData) - { - if (dynamicSaveData == null) - return; - - FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - foreach (FieldInfo saveableField in saveableFields) - { - SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); - if (saveableFieldAttribute == null) - continue; - - object? value = saveableField.GetValue(this); - if (value == null) - continue; // Do not write nulls - - string data = JsonConvert.SerializeObject(value, Formatting.None, ISaveable.SerializerSettings); - // Use the declared save name as the dynamic key - dynamicSaveData.AddData(saveableFieldAttribute.SaveName, data); - } - - InvokeOnSaved(); - } - - /// - /// INTERNAL: Reads fields marked with from a DynamicSaveData blob - /// to support the base game's consolidated JSON save format. - /// - /// The dynamic save data record to read from. - internal void LoadFromDynamic(S1Datas.DynamicSaveData dynamicSaveData) - { - if (dynamicSaveData == null) - return; - - FieldInfo[] saveableFields = ReflectionUtils.GetAllFields(GetType(), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - foreach (FieldInfo saveableField in saveableFields) - { - SaveableField? saveableFieldAttribute = saveableField.GetCustomAttribute(); - if (saveableFieldAttribute == null) - continue; - - // Read the raw json for this save name and deserialize to the field type - if (!dynamicSaveData.TryGetData(saveableFieldAttribute.SaveName, out string json) || string.IsNullOrEmpty(json)) - continue; - - Type type = saveableField.FieldType; - object? value = JsonConvert.DeserializeObject(json, type, ISaveable.SerializerSettings); - saveableField.SetValue(this, value); - } - - InvokeOnLoaded(); - } - - /// - /// INTERNAL: Explicit interface implementation that delegates to the virtual OnLoaded method. - /// Called after all saveable fields have been loaded from their respective JSON files. - /// - void ISaveable.OnLoaded() => InvokeOnLoaded(); - - /// - /// Called after all saveable fields have been loaded from their respective JSON files. - /// This method can be overridden in derived classes to perform additional initialization - /// or processing after the save data has been restored. - /// - protected virtual void OnLoaded() { } - - /// - /// INTERNAL: Explicit interface implementation that delegates to the virtual OnSaved method. - /// Called after all saveable fields have been saved to their respective JSON files. - /// - void ISaveable.OnSaved() => InvokeOnSaved(); - - /// - /// Called after all saveable fields have been saved to their respective JSON files. - /// This method can be overridden in derived classes to perform additional finalization - /// or processing after the save data has been written to disk. - /// - protected virtual void OnSaved() { } - } -} diff --git a/S1API/Saveables/SaveableAutoRegistry.cs b/S1API/Saveables/SaveableAutoRegistry.cs index 76a1e212..b22a7bfa 100644 --- a/S1API/Saveables/SaveableAutoRegistry.cs +++ b/S1API/Saveables/SaveableAutoRegistry.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using S1API.Internal.Abstraction; using S1API.Internal.Utils; namespace S1API.Saveables @@ -131,10 +132,7 @@ private static bool IsDirectSaveableInheritor(Type type) // Check if the base type is exactly Saveable (not a subclass) Type? baseType = type.BaseType; -#pragma warning disable CS0618 // Keep discovery of existing mods using the compatibility base. - return baseType == typeof(Saveable) - || baseType == typeof(global::S1API.Internal.Abstraction.Saveable); -#pragma warning restore CS0618 + return baseType == typeof(Saveable); } /// diff --git a/S1API/docs/getting-started.md b/S1API/docs/getting-started.md index 4c8f01cf..80117a08 100644 --- a/S1API/docs/getting-started.md +++ b/S1API/docs/getting-started.md @@ -113,6 +113,7 @@ Launch the game. Your app icon should appear on the phone; clicking it opens you Annotate fields with `SaveableField` in classes inheriting `Saveable`. S1API will save/load JSON per save slot. ```csharp +using S1API.Internal.Abstraction; using S1API.Saveables; public class MySaveData : Saveable diff --git a/S1API/docs/save-system.md b/S1API/docs/save-system.md index ba3e5521..0c5b4d37 100644 --- a/S1API/docs/save-system.md +++ b/S1API/docs/save-system.md @@ -1,10 +1,11 @@ # Save System -S1API provides a simple, attribute-based persistence model. Inherit from `S1API.Saveables.Saveable` and annotate fields with `SaveableField`. +S1API provides a simple, attribute-based persistence model. Inherit from `Internal.Abstraction.Saveable` and annotate fields with `SaveableField`. ## Basics ```csharp +using S1API.Internal.Abstraction; using S1API.Saveables; public class NotesSave : Saveable @@ -25,14 +26,6 @@ public class NotesSave : Saveable S1API uses JSON with standard `JsonSerializerSettings` that ignore reference loops and support GUID references. -## Migration from the internal namespace - -`S1API.Internal.Abstraction.Saveable` is obsolete with a compiler warning. Existing compiled mods continue to use the compatibility base, which forwards to `S1API.Saveables.Saveable`. Its removal is planned for a future breaking release; no removal version has been set. - -When updating a standalone saveable, replace the old namespace import with `using S1API.Saveables;`. If you need both namespaces, qualify the base as `S1API.Saveables.Saveable` or use an alias to avoid an ambiguous `Saveable` reference. Keep your class name, namespace, and `SaveableField` keys unchanged: changing the base alone does not change existing save folders or JSON keys. - -NPC and quest subclasses continue to inherit their persistence support from their existing API base classes and need no migration. - ## Load Order Control By default, saveables load **after** base game ISaveables (NPCs, buildings, vehicles, etc.). Override the `LoadOrder` property to control timing: From 12dd0136cba6c7f8db9e81bb03545cdc9cdb2213 Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 3 Oct 2026 17:15:02 -0700 Subject: [PATCH 40/57] fix(rendering): restore beta item icon captures and requested size --- .../Rendering/RuntimePreviewLayerTests.cs | 40 +++++++++++++++++++ .../Internal/Rendering/RuntimePreviewLayer.cs | 17 ++++++++ S1API/Rendering/IconFactory.cs | 21 +++++----- 3 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 S1API.Tests/Rendering/RuntimePreviewLayerTests.cs create mode 100644 S1API/Internal/Rendering/RuntimePreviewLayer.cs diff --git a/S1API.Tests/Rendering/RuntimePreviewLayerTests.cs b/S1API.Tests/Rendering/RuntimePreviewLayerTests.cs new file mode 100644 index 00000000..6fea3098 --- /dev/null +++ b/S1API.Tests/Rendering/RuntimePreviewLayerTests.cs @@ -0,0 +1,40 @@ +using S1API.Internal.Rendering; + +namespace S1API.Tests.Rendering; + +public sealed class RuntimePreviewLayerTests +{ + [Fact] + public void BetaLayerIsSelectedWhenLegacyLayerIsAbsent() + { + Assert.Equal(20, RuntimePreviewLayer.Resolve(name => + name == "RuntimePreviewGeneration" ? 20 : -1)); + } + + [Fact] + public void CurrentLayerTakesPriorityWhenBothNamesExist() + { + Assert.Equal(20, RuntimePreviewLayer.Resolve(name => + name == "RuntimePreviewGeneration" ? 20 : 30)); + } + + [Fact] + public void OlderGamesRetainTheirLegacyLayer() + { + Assert.Equal(30, RuntimePreviewLayer.Resolve(name => + name == "IconGeneration" ? 30 : -1)); + } + + [Fact] + public void LayerZeroIsValidAndDoesNotTriggerFallback() + { + Assert.Equal(0, RuntimePreviewLayer.Resolve(name => + name == "RuntimePreviewGeneration" ? 0 : 30)); + } + + [Fact] + public void MissingLayersRemainUnavailableInsteadOfUsingAnUnrelatedLayer() + { + Assert.Equal(-1, RuntimePreviewLayer.Resolve(_ => -1)); + } +} diff --git a/S1API/Internal/Rendering/RuntimePreviewLayer.cs b/S1API/Internal/Rendering/RuntimePreviewLayer.cs new file mode 100644 index 00000000..7b20866f --- /dev/null +++ b/S1API/Internal/Rendering/RuntimePreviewLayer.cs @@ -0,0 +1,17 @@ +using System; +using UnityEngine; + +namespace S1API.Internal.Rendering +{ + internal static class RuntimePreviewLayer + { + internal static int Resolve() => + Resolve(LayerMask.NameToLayer); + + internal static int Resolve(Func findLayer) + { + int layer = findLayer("RuntimePreviewGeneration"); + return layer >= 0 ? layer : findLayer("IconGeneration"); + } + } +} diff --git a/S1API/Rendering/IconFactory.cs b/S1API/Rendering/IconFactory.cs index fd723ef6..f5e31b99 100644 --- a/S1API/Rendering/IconFactory.cs +++ b/S1API/Rendering/IconFactory.cs @@ -12,6 +12,7 @@ using S1API.Logging; using S1API.Internal.Utils; +using S1API.Internal.Rendering; using System; using System.Collections; using System.Collections.Generic; @@ -147,6 +148,13 @@ internal static bool IsItemIconGeneratorReady return null; } + int iconLayer = RuntimePreviewLayer.Resolve(); + if (iconLayer < 0) + { + Logger.Error("Neither RuntimePreviewGeneration nor IconGeneration exists. Cannot generate icon."); + return null; + } + Transform? originalParent = model.parent; Vector3 originalPos = model.localPosition; Quaternion originalRot = model.localRotation; @@ -167,14 +175,9 @@ internal static bool IsItemIconGeneratorReady // Now activate and set layers (after parenting) model.gameObject.SetActive(true); - int iconLayer = LayerMask.NameToLayer("IconGeneration"); - if (iconLayer != -1) - { - // Set layers recursively on ItemContainer to match game's approach - S1DevUtils.LayerUtility.SetLayerRecursively( - generator.ItemContainer.gameObject, - iconLayer); - } + S1DevUtils.LayerUtility.SetLayerRecursively( + generator.ItemContainer.gameObject, + iconLayer); Logger.Debug( $"Icon generation for '{model.name}': world pos={model.position}, " + @@ -204,7 +207,7 @@ internal static bool IsItemIconGeneratorReady // Temporarily override IconGenerator state generator.ModifyLighting = true; - texture = generator.GetTexture(model); + texture = generator.GetTexture(model, size); Logger.Debug($"Generated texture: {(texture != null ? $"{texture.width}x{texture.height}" : "null")}"); if (texture != null && !HasVisibleContent(texture)) { From e5e76211df00563b60d6df3a5037843b3035a6c5 Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 3 Oct 2026 17:16:02 -0700 Subject: [PATCH 41/57] fix(phone): report app discovery and initialization failures --- S1API/Internal/Patches/HomeScreen.Start.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/S1API/Internal/Patches/HomeScreen.Start.cs b/S1API/Internal/Patches/HomeScreen.Start.cs index 2ab1b915..f51b943f 100644 --- a/S1API/Internal/Patches/HomeScreen.Start.cs +++ b/S1API/Internal/Patches/HomeScreen.Start.cs @@ -46,12 +46,16 @@ static void Postfix(HomeScreen __instance) ExternalAppCatalog.Clear(ExternalAppFamily.Phone); // Re-register all PhoneApps var phoneApps = ReflectionUtils.GetDerivedClasses(); + Logger.Msg($"Discovered {phoneApps.Count} custom phone app type(s)."); foreach (var type in phoneApps) { - Logger.Debug($"Found phone app: {type.FullName}"); - if (type.GetConstructor(Type.EmptyTypes) == null) + { + Logger.Warning($"Skipping phone app {type.FullName}: a public parameterless constructor is required."); continue; + } + + Logger.Msg($"Initializing phone app: {type.FullName}"); try { @@ -62,7 +66,7 @@ static void Postfix(HomeScreen __instance) } catch (Exception e) { - Logger.Warning($"[PhoneApp] Failed to register {type.FullName}: {e.Message}"); + Logger.Warning($"Failed to register phone app {type.FullName}: {e}"); } } } From 0e531f29b5ee2255070ff365441c7ba9ea7abfeb Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 3 Oct 2026 17:55:15 -0700 Subject: [PATCH 42/57] chore(release): prepare 3.2.1-beta.8 prerelease --- .github/release-notes/3.2.1-beta.8.md | 29 +++++++++++++++++++++++++++ S1API/S1API.cs | 2 +- S1API/S1API.csproj | 2 +- 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 .github/release-notes/3.2.1-beta.8.md diff --git a/.github/release-notes/3.2.1-beta.8.md b/.github/release-notes/3.2.1-beta.8.md new file mode 100644 index 00000000..2de32e51 --- /dev/null +++ b/.github/release-notes/3.2.1-beta.8.md @@ -0,0 +1,29 @@ +## Phone apps and item icons + +- Restored discovery of mods built against older S1API versions. This also restores Mods App on the current game beta. See [PR #333](https://github.com/ifBars/S1API/pull/333). +- Restored item icon capture after the game's preview layer rename, including the Forklift & Pallets pallet icon. `IconFactory` now honors the requested image size and retains its default of 512 pixels. See [PR #345](https://github.com/ifBars/S1API/pull/345). +- Phone app discovery and initialization now report progress with normal logging. Missing constructors produce a warning, and initialization failures include the full exception and underlying cause while allowing other apps to initialize. See [PR #346](https://github.com/ifBars/S1API/pull/346). + +## Other unreleased beta fixes + +- Custom accessories now receive their own avatar objects and retain their texture and tint. Typed resource lookups reject incompatible assets at the same path. See [PR #332](https://github.com/ifBars/S1API/pull/332). +- Dealer and supplier NPCs now have framework data before `ConfigurePrefab`, allowing identity, icon, and voice configuration. See [PR #334](https://github.com/ifBars/S1API/pull/334). +- Packaging icon patches select the game's exact method signature, allowing them to bind alongside Polyfill's additional overload. See [PR #336](https://github.com/ifBars/S1API/pull/336). +- The contacts app skips NPCs whose IDs cannot be read instead of repeatedly failing while waiting for custom NPCs. See [PR #337](https://github.com/ifBars/S1API/pull/337). +- Clarified the supported use of the existing `Saveable` persistence base and `Registerable` infrastructure class without moving or changing either type. See [PR #344](https://github.com/ifBars/S1API/pull/344). + +## Compatibility and validation + +Existing public signatures, namespaces, save identifiers, and network payload formats are unchanged. + +The combined release candidate passed 775 Mono and 749 IL2CPP contract tests. Live validation of the icon and phone diagnostics fixes passed on Mono 0.4.7f7 and IL2CPP 0.4.7f9. Mods App's home icon, manager screen, and open/close behavior, plus the Forklift & Pallets pallet icon, were checked on IL2CPP 0.4.7f9; those two consumer mods were not checked on Mono. + +This is a GitHub-only prerelease for the Schedule I game beta. It does not publish to NuGet, Nexus Mods, or Thunderstore. + +## Contributors and links + +Thanks to [r-melvin](https://github.com/r-melvin) for [#332](https://github.com/ifBars/S1API/pull/332), [#333](https://github.com/ifBars/S1API/pull/333), [#334](https://github.com/ifBars/S1API/pull/334), [#336](https://github.com/ifBars/S1API/pull/336), and [#337](https://github.com/ifBars/S1API/pull/337), and [ifBars](https://github.com/ifBars) for [#344](https://github.com/ifBars/S1API/pull/344), [#345](https://github.com/ifBars/S1API/pull/345), and [#346](https://github.com/ifBars/S1API/pull/346). + +[Full changes since beta.7](https://github.com/ifBars/S1API/compare/v3.2.1-beta.7...v3.2.1-beta.8) + +[Download S1API-Forked-3.2.1-beta.8.zip](https://github.com/ifBars/S1API/releases/download/v3.2.1-beta.8/S1API-Forked-3.2.1-beta.8.zip) diff --git a/S1API/S1API.cs b/S1API/S1API.cs index 8becf64f..b6b71cf9 100644 --- a/S1API/S1API.cs +++ b/S1API/S1API.cs @@ -12,7 +12,7 @@ using S1API.Lifecycle; using S1API.Map; -[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.7", "KaBooMa")] +[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.8", "KaBooMa")] [assembly: MelonPriority(Int32.MinValue)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member namespace S1API diff --git a/S1API/S1API.csproj b/S1API/S1API.csproj index c1931c40..2b008329 100644 --- a/S1API/S1API.csproj +++ b/S1API/S1API.csproj @@ -23,7 +23,7 @@ $(NoWarn);1591 true latest - 3.2.1-beta.7 + 3.2.1-beta.8 From b39b5dd250040c73df19e3f55fc454b98f822217 Mon Sep 17 00:00:00 2001 From: ifBars <114284668+ifBars@users.noreply.github.com> Date: Sun, 4 Oct 2026 07:45:41 -0700 Subject: [PATCH 43/57] fix: select beta assemblies for documentation prerelease branches --- .github/workflows/docs.yml | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8599a80d..a214e51d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -53,19 +53,31 @@ jobs: # Determine which assembly branch to use (beta or main) - MUST run before cache - name: Determine Assembly Branch id: assembly-branch + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + HEAD_REF: ${{ github.head_ref }} + BASE_REF: ${{ github.base_ref }} + ASSEMBLY_BRANCH: ${{ inputs.assembly_branch }} + HAS_BETA_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'beta') }} run: | + is_beta_branch() { + [[ "$1" == "beta" || "$1" =~ ^releases/[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]] + } + # Manual workflow dispatch takes precedence - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]] && [[ -n "${{ inputs.assembly_branch }}" ]]; then - echo "branch=${{ inputs.assembly_branch }}" >> $GITHUB_OUTPUT - echo "Using ${{ inputs.assembly_branch }} assemblies (manual trigger)" - elif [[ "${{ github.ref_name }}" == "beta" ]] || [[ "${{ github.base_ref }}" == "beta" ]]; then - echo "branch=beta" >> $GITHUB_OUTPUT - echo "Using beta assemblies for beta branch workflow" - elif [[ "${{ github.event_name }}" == "pull_request" ]] && [[ "${{ contains(github.event.pull_request.labels.*.name, 'beta') }}" == "true" ]]; then - echo "branch=beta" >> $GITHUB_OUTPUT + if [[ "$EVENT_NAME" == "workflow_dispatch" ]] && [[ -n "$ASSEMBLY_BRANCH" ]]; then + echo "branch=$ASSEMBLY_BRANCH" >> "$GITHUB_OUTPUT" + echo "Using $ASSEMBLY_BRANCH assemblies (manual trigger)" + elif is_beta_branch "$REF_NAME" || is_beta_branch "$HEAD_REF" || is_beta_branch "$BASE_REF"; then + echo "branch=beta" >> "$GITHUB_OUTPUT" + echo "Using beta assemblies for beta branch or prerelease workflow" + elif [[ "$EVENT_NAME" == "pull_request" ]] && [[ "$HAS_BETA_LABEL" == "true" ]]; then + echo "branch=beta" >> "$GITHUB_OUTPUT" echo "Using beta assemblies for PR with beta label" else - echo "branch=main" >> $GITHUB_OUTPUT + echo "branch=main" >> "$GITHUB_OUTPUT" echo "Using main/stable assemblies" fi From d5084d31d9e5a31629f34fe5bc142e77dd45785b Mon Sep 17 00:00:00 2001 From: r-melvin <40235254+r-melvin@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:47:31 +0100 Subject: [PATCH 44/57] perf(reflection): keep derived-type scans until an assembly loads GetDerivedClasses scanned every eligible assembly on each call, and custom NPC readiness calls it once per custom NPC. Results per base type are now kept per ordinary assembly until another assembly loads. Dynamic assemblies can gain types without a load event, so they stay in the plan and are re-scanned (and their eligibility re-checked) on every call. Each call returns a new list. Refs #351 Co-Authored-By: Claude Opus 5.5 --- .../Internal/Utils/ReflectionUtilsTests.cs | 40 +++++ S1API/Internal/Utils/ReflectionUtils.cs | 143 ++++++++++++++---- 2 files changed, 153 insertions(+), 30 deletions(-) diff --git a/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs index 0d8afed6..aac0c657 100644 --- a/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs +++ b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs @@ -58,6 +58,46 @@ public void GetDerivedClassesFindsTypesInReferencingAssemblies() ReflectionUtils.GetDerivedClasses()); } + [Fact] + public void GetDerivedClassesReturnsAFreshListEachCall() + { + List first = ReflectionUtils.GetDerivedClasses(); + first.Clear(); + + Assert.Contains(typeof(DerivedLogShape), ReflectionUtils.GetDerivedClasses()); + Assert.NotSame(ReflectionUtils.GetDerivedClasses(), ReflectionUtils.GetDerivedClasses()); + } + + [Fact] + public void GetDerivedClassesGivesTheSameTypesAfterInvalidation() + { + var before = ReflectionUtils.GetDerivedClasses(); + ReflectionUtils.InvalidateDerivedClassCache(); + var after = ReflectionUtils.GetDerivedClasses(); + + Assert.Equal(before.OrderBy(t => t.FullName), after.OrderBy(t => t.FullName)); + } + + [Fact] + public void GetDerivedClassesSeesTypesAddedToADynamicAssemblyAfterCaching() + { + var name = new AssemblyName($"S1API.ReflectionUtilsTests.Late.{Guid.NewGuid():N}"); + var assembly = AssemblyBuilder.DefineDynamicAssembly(name, AssemblyBuilderAccess.Run); + var module = assembly.DefineDynamicModule(name.Name!); + ReflectionUtils.GetDerivedClasses(); // cache built while the dynamic assembly has no types + + TypeBuilder builder = module.DefineType("LateLog", TypeAttributes.Public | TypeAttributes.Class, typeof(Log)); + var ctor = builder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, Type.EmptyTypes); + var il = ctor.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldstr, "late"); + il.Emit(OpCodes.Call, typeof(Log).GetConstructor(new[] { typeof(string) })!); + il.Emit(OpCodes.Ret); + Type late = builder.CreateType()!; + + Assert.Contains(late, ReflectionUtils.GetDerivedClasses()); + } + [Fact] public void DerivedTypeScanExcludesAssembliesWithoutAReferencePathToTheBaseAssembly() { diff --git a/S1API/Internal/Utils/ReflectionUtils.cs b/S1API/Internal/Utils/ReflectionUtils.cs index a2ec62db..4c21dec7 100644 --- a/S1API/Internal/Utils/ReflectionUtils.cs +++ b/S1API/Internal/Utils/ReflectionUtils.cs @@ -30,48 +30,131 @@ internal static class ReflectionUtils /// A list of all types derived from the base class. internal static List GetDerivedClasses() { - List derivedClasses = new List(); Type baseType = typeof(TBaseClass); + ScanPlan? plan; + long generation; + lock (DerivedClassCacheLock) + { + DerivedClassCache.TryGetValue(baseType, out plan); + generation = _assemblyGeneration; + } + + if (plan == null) + { + plan = BuildScanPlan(baseType); + lock (DerivedClassCacheLock) + { + // Not stored if an assembly loaded while scanning. + if (generation == _assemblyGeneration) + DerivedClassCache[baseType] = plan; + } + } + + // A fresh list every call: some callers sort or modify the result. + var derivedClasses = new List(); + IReadOnlyDictionary? index = null; + foreach (ScanPlanEntry entry in plan.Entries) + { + if (entry.Types != null) + { + derivedClasses.AddRange(entry.Types); + } + else if (CanContainTypesDerivedFrom(entry.Assembly, baseType.Assembly.GetName(), + index ??= IndexAssembliesBySimpleName(AppDomain.CurrentDomain.GetAssemblies()))) + { + AddDerivedTypes(baseType, entry.Assembly, derivedClasses); + } + } + return derivedClasses; + } + + // Ordinary assemblies' results are kept until another assembly loads. Dynamic assemblies (every modded game + // has some) can gain types without an AssemblyLoad event, so they are re-scanned on every call. + private sealed class ScanPlanEntry + { + internal Assembly Assembly = null!; + internal Type[]? Types; // null: dynamic, scanned per call + } + + private sealed class ScanPlan + { + internal ScanPlanEntry[] Entries = Array.Empty(); + } + + private static readonly object DerivedClassCacheLock = new object(); + private static readonly Dictionary DerivedClassCache = new Dictionary(); + private static long _assemblyGeneration; + + static ReflectionUtils() + { + AppDomain.CurrentDomain.AssemblyLoad += (_, _) => InvalidateDerivedClassCache(); + } + + internal static void InvalidateDerivedClassCache() + { + lock (DerivedClassCacheLock) + { + _assemblyGeneration++; + DerivedClassCache.Clear(); + } + } + + private static ScanPlan BuildScanPlan(Type baseType) + { Assembly baseAssembly = baseType.Assembly; Assembly[] loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies(); IReadOnlyDictionary assembliesBySimpleName = IndexAssembliesBySimpleName(loadedAssemblies); - Assembly[] applicableAssemblies = loadedAssemblies - .Where(assembly => assembly == baseAssembly || !ShouldSkipAssembly(assembly)) - .Where(assembly => CanContainTypesDerivedFrom( - assembly, - baseAssembly.GetName(), - assembliesBySimpleName)) - .ToArray(); + var entries = new List(); + int scanned = 0; + foreach (Assembly assembly in loadedAssemblies) + { + if (assembly != baseAssembly && ShouldSkipAssembly(assembly)) + continue; + if (assembly.IsDynamic) + { + // Kept even if it can't contain derived types yet; eligibility is re-checked per call. + entries.Add(new ScanPlanEntry { Assembly = assembly }); + continue; + } + if (!CanContainTypesDerivedFrom(assembly, baseAssembly.GetName(), assembliesBySimpleName)) + continue; + var types = new List(); + AddDerivedTypes(baseType, assembly, types); + entries.Add(new ScanPlanEntry { Assembly = assembly, Types = types.ToArray() }); + scanned++; + } Logger.Debug( - $"[S1API][Reflection] Scanning {applicableAssemblies.Length} of {loadedAssemblies.Length} " + - $"loaded assemblies for types derived from '{baseType.FullName}'."); + $"[S1API][Reflection] Scanned {scanned} of {loadedAssemblies.Length} loaded assemblies for types " + + $"derived from '{baseType.FullName}'."); + return new ScanPlan { Entries = entries.ToArray() }; + } - foreach (Assembly assembly in applicableAssemblies) - foreach (Type type in SafeGetTypes(assembly)) + private static void AddDerivedTypes(Type baseType, Assembly assembly, List derivedClasses) + { + foreach (Type type in SafeGetTypes(assembly)) + { + try { - try - { - if (type == null) - continue; - if (baseType.IsAssignableFrom(type) - && type != baseType - && !type.IsAbstract) - { - derivedClasses.Add(type); - } - } - catch (TypeLoadException) - { - // Ideally, we'd log this, but can be noisy and we've got no logger elsewhere - } - catch (Exception) + if (type == null) + continue; + if (baseType.IsAssignableFrom(type) + && type != baseType + && !type.IsAbstract) { - // Catch-all for anything else (e.g., MissingMethodException) + derivedClasses.Add(type); } } - return derivedClasses; + catch (TypeLoadException) + { + // Ideally, we'd log this, but can be noisy and we've got no logger elsewhere + } + catch (Exception) + { + // Catch-all for anything else (e.g., MissingMethodException) + } + } } internal static bool CanContainTypesDerivedFrom( From 1a27db32490f8d979f11f1e3ff1de87333f73871 Mon Sep 17 00:00:00 2001 From: r-melvin <40235254+r-melvin@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:47:22 +0100 Subject: [PATCH 45/57] perf(npc): index scene objects instead of scanning for every custom NPC Movement presets, voice databases, NPC map PoIs and contact circles were each found with a scene or asset scan per custom NPC. They now come from a per-scene index built from one scan. Only found objects are reused, and only while alive and still under the key they were indexed by; a lookup that finds nothing scans again, so an object created later during loading is still found. Indexes clear on scene change. Refs #351 Co-Authored-By: Claude Opus 5.5 --- .../Internal/Utils/SceneObjectIndexTests.cs | 111 ++++++++++++++ S1API/Entities/NPCAppearance.cs | 10 +- S1API/Internal/Entities/NPCDataAccess.cs | 22 ++- S1API/Internal/Entities/NPCVoiceResolver.cs | 16 +- S1API/Internal/Lifecycle/SceneStateCleaner.cs | 1 + S1API/Internal/Patches/ContactsAppPatches.cs | 16 +- S1API/Internal/Utils/SceneObjectIndex.cs | 139 ++++++++++++++++++ 7 files changed, 293 insertions(+), 22 deletions(-) create mode 100644 S1API.Tests/Internal/Utils/SceneObjectIndexTests.cs create mode 100644 S1API/Internal/Utils/SceneObjectIndex.cs diff --git a/S1API.Tests/Internal/Utils/SceneObjectIndexTests.cs b/S1API.Tests/Internal/Utils/SceneObjectIndexTests.cs new file mode 100644 index 00000000..ecc90fdb --- /dev/null +++ b/S1API.Tests/Internal/Utils/SceneObjectIndexTests.cs @@ -0,0 +1,111 @@ +using S1API.Internal.Utils; + +namespace S1API.Tests.Internal.Utils; + +public sealed class SceneObjectIndexTests +{ + private sealed class Item + { + public string? Key; + public bool Alive = true; + } + + private static (SceneObjectIndex index, List scene) Make() + { + var scene = new List(); + var index = new SceneObjectIndex(() => scene.ToArray(), i => i.Key, i => i != null && i.Alive); + return (index, scene); + } + + [Fact] + public void ScansOnceForRepeatedLookups() + { + var (index, scene) = Make(); + scene.Add(new Item { Key = "a" }); + Assert.Single(index.Get("a")); + Assert.Single(index.Get("a")); + Assert.Equal(1, index.Scans); + } + + [Fact] + public void FindsAnObjectCreatedAfterALookupFoundNothing() + { + var (index, scene) = Make(); + Assert.Empty(index.Get("b")); + scene.Add(new Item { Key = "b" }); + Assert.Single(index.Get("b")); + } + + [Fact] + public void FindsAnObjectCreatedLaterUnderANewKeyWithoutLosingIndexedOnes() + { + var (index, scene) = Make(); + var first = new Item { Key = "a" }; + scene.Add(first); + Assert.Same(first, Assert.Single(index.Get("a"))); + var late = new Item { Key = "late" }; + scene.Add(late); + Assert.Same(late, Assert.Single(index.Get("late"))); + Assert.Same(first, Assert.Single(index.Get("a"))); + } + + [Fact] + public void RebuildsWhenAnIndexedObjectsKeyChanged() + { + var (index, scene) = Make(); + var item = new Item { Key = "old" }; + scene.Add(item); + index.Get("old"); + item.Key = "new"; + Assert.Empty(index.Get("old")); + Assert.Same(item, Assert.Single(index.Get("new"))); + } + + [Fact] + public void AllScansAgainWhileItFindsNothing() + { + var (index, scene) = Make(); + Assert.Empty(index.All()); + var late = new Item(); + scene.Add(late); + Assert.Same(late, Assert.Single(index.All())); + Assert.Same(late, Assert.Single(index.All())); + Assert.Equal(2, index.Scans); + } + + [Fact] + public void RebuildsWhenAnIndexedObjectWasDestroyed() + { + var (index, scene) = Make(); + var old = new Item { Key = "c" }; + scene.Add(old); + index.Get("c"); + old.Alive = false; + scene.Remove(old); + var replacement = new Item { Key = "c" }; + scene.Add(replacement); + Assert.Same(replacement, Assert.Single(index.Get("c"))); + Assert.Equal(2, index.Scans); + } + + [Fact] + public void AllKeepsScanOrderIncludingUnkeyedObjects() + { + var (index, scene) = Make(); + var first = new Item(); + var second = new Item { Key = "x" }; + var third = new Item(); + scene.AddRange(new[] { first, second, third }); + Assert.Equal(new[] { first, second, third }, index.All()); + } + + [Fact] + public void ResetAllClearsEveryIndex() + { + var (index, scene) = Make(); + index.Get("d"); + scene.Add(new Item { Key = "d" }); + SceneObjectIndexes.ResetAll(); + Assert.Single(index.Get("d")); + } +} diff --git a/S1API/Entities/NPCAppearance.cs b/S1API/Entities/NPCAppearance.cs index d56e6eeb..eda622ea 100644 --- a/S1API/Entities/NPCAppearance.cs +++ b/S1API/Entities/NPCAppearance.cs @@ -1125,6 +1125,12 @@ private static void SetLayerRecursively(GameObject obj, int layer) #endif } + internal static readonly global::S1API.Internal.Utils.SceneObjectIndex PoisByNpcId = + new global::S1API.Internal.Utils.SceneObjectIndex( + () => UnityEngine.Object.FindObjectsOfType(), + poi => poi.NPC?.ID, + poi => poi != null); + /// /// Updates the icon on any map POI components that reference the given NPC. /// @@ -1135,9 +1141,7 @@ private static void UpdatePoiIcons(S1NPCs.NPC npc, Sprite iconSprite) try { - // Find all NPCPoI components in the scene that reference this NPC - var allPoIs = UnityEngine.Object.FindObjectsOfType(); - foreach (var poi in allPoIs) + foreach (var poi in PoisByNpcId.Get(npc.ID)) { if (poi.NPC == npc && poi.IconContainer != null) { diff --git a/S1API/Internal/Entities/NPCDataAccess.cs b/S1API/Internal/Entities/NPCDataAccess.cs index 2dc89f85..350311aa 100644 --- a/S1API/Internal/Entities/NPCDataAccess.cs +++ b/S1API/Internal/Entities/NPCDataAccess.cs @@ -399,6 +399,12 @@ private static void PrepareData(S1NPCFramework.NPCData data) EnsureMovementDefaults(data); } + private static readonly SceneObjectIndex MovementPresets = + new SceneObjectIndex( + () => Resources.FindObjectsOfTypeAll(), + _ => null, + preset => preset != null); + /// /// A freshly created NPCDataObject has no MovementPreset assigned, so /// NPCData.Movement falls back to the hardcoded class default (WalkSpeed 1.8), @@ -412,15 +418,23 @@ private static void EnsureMovementDefaults(S1NPCFramework.NPCData data) if (movement == null) return; - S1NPCFramework.Movement? donor = Resources - .FindObjectsOfTypeAll() - .Select(preset => preset?.GetValue()) - .FirstOrDefault(value => value != null); + S1NPCFramework.Movement? donor = FirstMovementValue(); + if (donor == null) + { + // A preset may have loaded or initialised since the presets were indexed. + MovementPresets.Invalidate(); + donor = FirstMovementValue(); + } movement.WalkSpeed = donor?.WalkSpeed ?? 1.2f; movement.MaxSpeed = donor?.MaxSpeed ?? movement.MaxSpeed; } + private static S1NPCFramework.Movement? FirstMovementValue() => + MovementPresets.All() + .Select(preset => preset?.GetValue()) + .FirstOrDefault(value => value != null); + private static void EnsureDialogueDatabase( S1NPCFramework.NPCData data, S1NPCs.NPC? sourceNpc = null) diff --git a/S1API/Internal/Entities/NPCVoiceResolver.cs b/S1API/Internal/Entities/NPCVoiceResolver.cs index 2583e12c..55063b07 100644 --- a/S1API/Internal/Entities/NPCVoiceResolver.cs +++ b/S1API/Internal/Entities/NPCVoiceResolver.cs @@ -6,26 +6,28 @@ using System; using S1API.Entities.Voices; +using S1API.Internal.Utils; using UnityEngine; namespace S1API.Internal.Entities { internal static class NPCVoiceResolver { + private static readonly SceneObjectIndex Databases = + new SceneObjectIndex( + () => Resources.FindObjectsOfTypeAll(), + database => database.name, + database => database != null, + StringComparer.OrdinalIgnoreCase); + internal static S1VoiceOver.VODatabase Resolve(NPCVoiceDefinition definition) { if (definition == null) throw new ArgumentNullException(nameof(definition)); S1VoiceOver.VODatabase? match = null; - foreach (S1VoiceOver.VODatabase database in Resources.FindObjectsOfTypeAll()) + foreach (S1VoiceOver.VODatabase database in Databases.Get(definition.DatabaseName)) { - if (database == null - || !string.Equals(database.name, definition.DatabaseName, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - if (match != null && match.GetInstanceID() != database.GetInstanceID()) { throw new InvalidOperationException( diff --git a/S1API/Internal/Lifecycle/SceneStateCleaner.cs b/S1API/Internal/Lifecycle/SceneStateCleaner.cs index 338b68a0..8d6516f6 100644 --- a/S1API/Internal/Lifecycle/SceneStateCleaner.cs +++ b/S1API/Internal/Lifecycle/SceneStateCleaner.cs @@ -61,6 +61,7 @@ internal static void ResetForSceneChange(string sceneName, bool afterUnload) try { + Internal.Utils.SceneObjectIndexes.ResetAll(); if (afterUnload) { for (int i = 0; i < NPC.All.Count; i++) diff --git a/S1API/Internal/Patches/ContactsAppPatches.cs b/S1API/Internal/Patches/ContactsAppPatches.cs index 89f22a1e..9fe99a20 100644 --- a/S1API/Internal/Patches/ContactsAppPatches.cs +++ b/S1API/Internal/Patches/ContactsAppPatches.cs @@ -71,6 +71,13 @@ internal static void ResetState() _hasCustomNpcTypesCache = null; } + internal static readonly global::S1API.Internal.Utils.SceneObjectIndex CirclesByNpcId = + new global::S1API.Internal.Utils.SceneObjectIndex( + () => Object.FindObjectsOfType(true), + circle => GetAssignedNpcId(circle), + circle => circle != null, + global::System.StringComparer.OrdinalIgnoreCase); + internal static void RefreshContactIcon(S1NPCs.NPC npc) { if (npc == null || string.IsNullOrWhiteSpace(npc.ID)) @@ -78,15 +85,8 @@ internal static void RefreshContactIcon(S1NPCs.NPC npc) try { - foreach (S1Relations.RelationCircle circle in - Object.FindObjectsOfType(true)) + foreach (S1Relations.RelationCircle circle in CirclesByNpcId.Get(npc.ID)) { - if (!string.Equals( - GetAssignedNpcId(circle), - npc.ID, - global::System.StringComparison.OrdinalIgnoreCase)) - continue; - SetAssignedNpc(circle, npc); circle.AssignNPC(npc); } diff --git a/S1API/Internal/Utils/SceneObjectIndex.cs b/S1API/Internal/Utils/SceneObjectIndex.cs new file mode 100644 index 00000000..28a8a9d4 --- /dev/null +++ b/S1API/Internal/Utils/SceneObjectIndex.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; + +namespace S1API.Internal.Utils +{ + /// + /// INTERNAL: Clears every on scene change. + /// + internal static class SceneObjectIndexes + { + // Non-generic so one call clears indexes of every object type. + private static readonly List Resets = new List(); + + internal static void Register(Action reset) + { + lock (Resets) + Resets.Add(reset); + } + + /// Clears every scene object index. Called from SceneStateCleaner on scene change. + internal static void ResetAll() + { + Action[] resets; + lock (Resets) + resets = Resets.ToArray(); + foreach (var reset in resets) + reset(); + } + } + + /// + /// INTERNAL: Index of loaded Unity objects built from one scan, so repeated lookups do not each scan the scene. + /// Only found objects are reused, and only while they are alive and still have the key they were indexed under; + /// a lookup that finds nothing scans again, so an object created after the index was built is still found. + /// + internal sealed class SceneObjectIndex where TObject : class + { + private readonly Func> _scan; + private readonly Func _key; + private readonly Func _alive; + private readonly StringComparer _comparer; + private Dictionary>? _byKey; + private List _ordered = new List(); + + internal SceneObjectIndex( + Func> scan, + Func key, + Func alive, + StringComparer? comparer = null) + { + _scan = scan; + _key = key; + _alive = alive; + _comparer = comparer ?? StringComparer.Ordinal; + SceneObjectIndexes.Register(Invalidate); + } + + /// Number of scans since creation, for diagnostics and tests. + internal int Scans { get; private set; } + + internal void Invalidate() => _byKey = null; + + /// The live objects whose key matches; empty only if a fresh scan finds none. + internal List Get(string? key) + { + var result = new List(); + if (string.IsNullOrEmpty(key)) + return result; + + if (_byKey == null || !TryCollect(key!, result)) + { + Build(); + result.Clear(); + TryCollect(key!, result); + } + return result; + } + + /// All live objects, in scan order; empty only if a fresh scan finds none. + internal List All() + { + var result = new List(); + if (_byKey == null || !TryCollectAll(result)) + { + Build(); + result.Clear(); + TryCollectAll(result); + } + return result; + } + + private bool TryCollect(string key, List result) + { + if (!_byKey!.TryGetValue(key, out var found)) + return false; + foreach (var item in found) + { + if (!_alive(item) || !_comparer.Equals(_key(item) ?? string.Empty, key)) + return false; + result.Add(item); + } + return true; + } + + private bool TryCollectAll(List result) + { + if (_ordered.Count == 0) + return false; + foreach (var item in _ordered) + { + if (!_alive(item)) + return false; + result.Add(item); + } + return true; + } + + private void Build() + { + Scans++; + var index = new Dictionary>(_comparer); + var ordered = new List(); + foreach (var item in _scan()) + { + if (!_alive(item)) + continue; + ordered.Add(item); + string? key = _key(item); + if (string.IsNullOrEmpty(key)) + continue; + if (!index.TryGetValue(key!, out var list)) + index[key!] = list = new List(); + list.Add(item); + } + _ordered = ordered; + _byKey = index; + } + } +} From 8c22c42e2a3703320fc6af58424d0109957cbb84 Mon Sep 17 00:00:00 2001 From: r-melvin <40235254+r-melvin@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:41:02 +0100 Subject: [PATCH 46/57] fix(products): keep packaging-content patches working on 0.4.7f11 f11 replaced ProductIconManager.GenerateIcons (one batch at load) with on-demand GenerateRuntimeIcons(productID). The missing target made Harmony reject the whole ProductPackagingContentPatches class, so ApplyVisuals, GeneratePackagingIcon and GetIcon were unpatched too. The two batch hooks move to their own class, applied only when GenerateIcons exists; on f11 the packaging-icon prefix generates custom icons directly. Co-Authored-By: Claude Opus 5.5 --- .../Patches/ProductPackagingContentPatches.cs | 53 ++++++++++++------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/S1API/Internal/Patches/ProductPackagingContentPatches.cs b/S1API/Internal/Patches/ProductPackagingContentPatches.cs index 1f3d1d5c..ec7d0dbf 100644 --- a/S1API/Internal/Patches/ProductPackagingContentPatches.cs +++ b/S1API/Internal/Patches/ProductPackagingContentPatches.cs @@ -35,25 +35,6 @@ private static bool ApplyVisualsPrefix( itemInstance); } - [HarmonyPatch( - typeof(S1Product.ProductIconManager), - nameof(S1Product.ProductIconManager.GenerateIcons))] - [HarmonyPrefix] - private static void GenerateIconsPrefix() - { - ProductPackagingContentRuntime.BeginNativeIconBatch(); - } - - [HarmonyPatch( - typeof(S1Product.ProductIconManager), - nameof(S1Product.ProductIconManager.GenerateIcons))] - [HarmonyFinalizer] - private static Exception? GenerateIconsFinalizer(Exception? __exception) - { - ProductPackagingContentRuntime.EndNativeIconBatch(); - return __exception; - } - [HarmonyPatch( typeof(S1DevUtilities.IconGenerator), nameof(S1DevUtilities.IconGenerator.GeneratePackagingIcon), @@ -127,4 +108,38 @@ private static bool GetIconPrefix( return false; } } + + /// + /// INTERNAL: Defers custom packaging icons while the game generates every product icon in one batch at load. + /// Game builds up to 0.4.7f10 have ProductIconManager.GenerateIcons; 0.4.7f11 replaced the batch with + /// on-demand GenerateRuntimeIcons(productID), where the packaging-icon prefix generates custom icons + /// directly. Kept apart from so a missing batch method skips only + /// these two patches instead of failing that whole class. + /// + [HarmonyPatch] + internal static class ProductIconBatchPatches + { + // Plain reflection: AccessTools.Method logs a warning for every missing method. + private static System.Reflection.MethodBase? Batch() => + Array.Find( + typeof(S1Product.ProductIconManager).GetMethods(AccessTools.all), + method => method.Name == "GenerateIcons"); + + private static bool Prepare() => Batch() != null; + + private static System.Reflection.MethodBase? TargetMethod() => Batch(); + + [HarmonyPrefix] + private static void Prefix() + { + ProductPackagingContentRuntime.BeginNativeIconBatch(); + } + + [HarmonyFinalizer] + private static Exception? Finalizer(Exception? __exception) + { + ProductPackagingContentRuntime.EndNativeIconBatch(); + return __exception; + } + } } From 6e727b00f1229ff4d47c9db7934622186bc11b9f Mon Sep 17 00:00:00 2001 From: ifBars Date: Wed, 7 Oct 2026 20:28:18 -0700 Subject: [PATCH 47/57] fix(npc): preserve scene lookup lifecycle semantics --- .../Internal/Utils/SceneObjectIndexTests.cs | 70 +++++++++++++++++++ S1API/Entities/NPCAppearance.cs | 5 +- S1API/Internal/Entities/NPCVoiceResolver.cs | 16 ++--- .../Patches/SceneObjectIndexPatches.cs | 28 ++++++++ S1API/Internal/Utils/SceneObjectIndex.cs | 42 +++++++++-- 5 files changed, 145 insertions(+), 16 deletions(-) create mode 100644 S1API/Internal/Patches/SceneObjectIndexPatches.cs diff --git a/S1API.Tests/Internal/Utils/SceneObjectIndexTests.cs b/S1API.Tests/Internal/Utils/SceneObjectIndexTests.cs index ecc90fdb..dab69bf8 100644 --- a/S1API.Tests/Internal/Utils/SceneObjectIndexTests.cs +++ b/S1API.Tests/Internal/Utils/SceneObjectIndexTests.cs @@ -8,6 +8,7 @@ private sealed class Item { public string? Key; public bool Alive = true; + public bool Active = true; } private static (SceneObjectIndex index, List scene) Make() @@ -99,6 +100,75 @@ public void AllKeepsScanOrderIncludingUnkeyedObjects() Assert.Equal(new[] { first, second, third }, index.All()); } + [Fact] + public void FindsAnIndexedObjectRekeyedIntoAnExistingKey() + { + var (index, scene) = Make(); + var first = new Item { Key = "a" }; + var second = new Item { Key = "b" }; + scene.AddRange(new[] { first, second }); + Assert.Same(first, Assert.Single(index.Get("a"))); + second.Key = "a"; + Assert.Equal(new[] { first, second }, index.Get("a")); + } + + [Fact] + public void FindsAnUnkeyedIndexedObjectAssignedToAnExistingKey() + { + var (index, scene) = Make(); + var first = new Item { Key = "a" }; + var second = new Item(); + scene.AddRange(new[] { first, second }); + Assert.Same(first, Assert.Single(index.Get("a"))); + second.Key = "a"; + Assert.Equal(new[] { first, second }, index.Get("a")); + } + + [Fact] + public void NotificationFindsANewObjectUnderAnExistingKey() + { + var (index, scene) = Make(); + var first = new Item { Key = "a" }; + scene.Add(first); + Assert.Same(first, Assert.Single(index.Get("a"))); + var late = new Item { Key = "a" }; + scene.Add(late); + index.NotifyChanged(late); + Assert.Equal(new[] { first, late }, index.Get("a")); + } + + [Fact] + public void NotificationOfAnExistingUnchangedObjectKeepsTheSnapshot() + { + var (index, scene) = Make(); + var item = new Item { Key = "a" }; + scene.Add(item); + index.Get("a"); + index.NotifyChanged(item); + Assert.Same(item, Assert.Single(index.Get("a"))); + Assert.Equal(1, index.Scans); + } + + [Fact] + public void FiltersCurrentActivationWithoutLosingIndexedObjects() + { + var first = new Item { Key = "a" }; + var second = new Item { Key = "a", Active = false }; + var scene = new[] { first, second }; + var index = new SceneObjectIndex(() => scene, i => i.Key, i => i != null && i.Alive, + include: i => i.Active); + Assert.Same(first, Assert.Single(index.Get("a"))); + first.Active = false; + Assert.Empty(index.Get("a")); + Assert.Empty(index.All()); + second.Active = true; + Assert.Same(second, Assert.Single(index.Get("a"))); + Assert.Same(second, Assert.Single(index.All())); + first.Active = true; + Assert.Equal(scene, index.Get("a")); + Assert.Equal(1, index.Scans); + } + [Fact] public void ResetAllClearsEveryIndex() { diff --git a/S1API/Entities/NPCAppearance.cs b/S1API/Entities/NPCAppearance.cs index eda622ea..47de4c81 100644 --- a/S1API/Entities/NPCAppearance.cs +++ b/S1API/Entities/NPCAppearance.cs @@ -1127,9 +1127,10 @@ private static void SetLayerRecursively(GameObject obj, int layer) internal static readonly global::S1API.Internal.Utils.SceneObjectIndex PoisByNpcId = new global::S1API.Internal.Utils.SceneObjectIndex( - () => UnityEngine.Object.FindObjectsOfType(), + () => UnityEngine.Object.FindObjectsOfType(true), poi => poi.NPC?.ID, - poi => poi != null); + poi => poi != null, + include: poi => poi.gameObject.activeInHierarchy); /// /// Updates the icon on any map POI components that reference the given NPC. diff --git a/S1API/Internal/Entities/NPCVoiceResolver.cs b/S1API/Internal/Entities/NPCVoiceResolver.cs index 55063b07..2583e12c 100644 --- a/S1API/Internal/Entities/NPCVoiceResolver.cs +++ b/S1API/Internal/Entities/NPCVoiceResolver.cs @@ -6,28 +6,26 @@ using System; using S1API.Entities.Voices; -using S1API.Internal.Utils; using UnityEngine; namespace S1API.Internal.Entities { internal static class NPCVoiceResolver { - private static readonly SceneObjectIndex Databases = - new SceneObjectIndex( - () => Resources.FindObjectsOfTypeAll(), - database => database.name, - database => database != null, - StringComparer.OrdinalIgnoreCase); - internal static S1VoiceOver.VODatabase Resolve(NPCVoiceDefinition definition) { if (definition == null) throw new ArgumentNullException(nameof(definition)); S1VoiceOver.VODatabase? match = null; - foreach (S1VoiceOver.VODatabase database in Databases.Get(definition.DatabaseName)) + foreach (S1VoiceOver.VODatabase database in Resources.FindObjectsOfTypeAll()) { + if (database == null + || !string.Equals(database.name, definition.DatabaseName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + if (match != null && match.GetInstanceID() != database.GetInstanceID()) { throw new InvalidOperationException( diff --git a/S1API/Internal/Patches/SceneObjectIndexPatches.cs b/S1API/Internal/Patches/SceneObjectIndexPatches.cs new file mode 100644 index 00000000..bf88e8eb --- /dev/null +++ b/S1API/Internal/Patches/SceneObjectIndexPatches.cs @@ -0,0 +1,28 @@ +using HarmonyLib; +using S1API.Entities; +using S1API.Internal.Utils; +#if IL2CPPMELON +using S1Map = Il2CppScheduleOne.Map; +using S1Relations = Il2CppScheduleOne.UI.Relations; +#elif MONOMELON +using S1Map = ScheduleOne.Map; +using S1Relations = ScheduleOne.UI.Relations; +#endif + +namespace S1API.Internal.Patches +{ + /// INTERNAL: Keeps indexed UI membership aligned with native assignment. + [HarmonyPatch] + internal static class SceneObjectIndexPatches + { + [HarmonyPostfix] + [HarmonyPatch(typeof(S1Map.NPCPoI), nameof(S1Map.NPCPoI.SetNPC))] + private static void NpcPoiAssigned(S1Map.NPCPoI __instance) => + NPCAppearance.PoisByNpcId.NotifyChanged(__instance); + + [HarmonyPostfix] + [HarmonyPatch(typeof(S1Relations.RelationCircle), nameof(S1Relations.RelationCircle.AssignNPC))] + private static void RelationCircleAssigned(S1Relations.RelationCircle __instance) => + ContactsAppPatches.CirclesByNpcId.NotifyChanged(__instance); + } +} diff --git a/S1API/Internal/Utils/SceneObjectIndex.cs b/S1API/Internal/Utils/SceneObjectIndex.cs index 28a8a9d4..309ecc28 100644 --- a/S1API/Internal/Utils/SceneObjectIndex.cs +++ b/S1API/Internal/Utils/SceneObjectIndex.cs @@ -38,19 +38,23 @@ internal sealed class SceneObjectIndex where TObject : class private readonly Func> _scan; private readonly Func _key; private readonly Func _alive; + private readonly Func _include; private readonly StringComparer _comparer; private Dictionary>? _byKey; private List _ordered = new List(); + private List _orderedKeys = new List(); internal SceneObjectIndex( Func> scan, Func key, Func alive, - StringComparer? comparer = null) + StringComparer? comparer = null, + Func? include = null) { _scan = scan; _key = key; _alive = alive; + _include = include ?? (_ => true); _comparer = comparer ?? StringComparer.Ordinal; SceneObjectIndexes.Register(Invalidate); } @@ -58,7 +62,30 @@ internal SceneObjectIndex( /// Number of scans since creation, for diagnostics and tests. internal int Scans { get; private set; } - internal void Invalidate() => _byKey = null; + internal void Invalidate() + { + _byKey = null; + _ordered.Clear(); + _orderedKeys.Clear(); + } + + /// Invalidates a populated snapshot when the native lifecycle assigns a new object. + internal void NotifyChanged(TObject item) + { + if (_byKey != null && !_ordered.Contains(item)) + Invalidate(); + } + + private bool IsSnapshotValid() + { + for (int i = 0; i < _ordered.Count; i++) + { + TObject item = _ordered[i]; + if (!_alive(item) || !_comparer.Equals(_key(item), _orderedKeys[i])) + return false; + } + return true; + } /// The live objects whose key matches; empty only if a fresh scan finds none. internal List Get(string? key) @@ -67,7 +94,7 @@ internal List Get(string? key) if (string.IsNullOrEmpty(key)) return result; - if (_byKey == null || !TryCollect(key!, result)) + if (_byKey == null || !IsSnapshotValid() || !TryCollect(key!, result)) { Build(); result.Clear(); @@ -97,7 +124,8 @@ private bool TryCollect(string key, List result) { if (!_alive(item) || !_comparer.Equals(_key(item) ?? string.Empty, key)) return false; - result.Add(item); + if (_include(item)) + result.Add(item); } return true; } @@ -110,7 +138,8 @@ private bool TryCollectAll(List result) { if (!_alive(item)) return false; - result.Add(item); + if (_include(item)) + result.Add(item); } return true; } @@ -120,12 +149,14 @@ private void Build() Scans++; var index = new Dictionary>(_comparer); var ordered = new List(); + var orderedKeys = new List(); foreach (var item in _scan()) { if (!_alive(item)) continue; ordered.Add(item); string? key = _key(item); + orderedKeys.Add(key); if (string.IsNullOrEmpty(key)) continue; if (!index.TryGetValue(key!, out var list)) @@ -133,6 +164,7 @@ private void Build() list.Add(item); } _ordered = ordered; + _orderedKeys = orderedKeys; _byKey = index; } } From ca4da6ff34587a34a2a6c50da75d2554dad06d16 Mon Sep 17 00:00:00 2001 From: ifBars Date: Wed, 7 Oct 2026 20:28:21 -0700 Subject: [PATCH 48/57] test(reflection): cover assembly load cache invalidation --- .../Internal/Utils/ReflectionUtilsTests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs index aac0c657..2b2ce6ad 100644 --- a/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs +++ b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs @@ -98,6 +98,25 @@ public void GetDerivedClassesSeesTypesAddedToADynamicAssemblyAfterCaching() Assert.Contains(late, ReflectionUtils.GetDerivedClasses()); } + [Fact] + public void GetDerivedClassesSeesAnAssemblyLoadedAfterCaching() + { + ReflectionUtils.GetDerivedClasses(); + var name = new AssemblyName($"S1API.ReflectionUtilsTests.NewAssembly.{Guid.NewGuid():N}"); + var assembly = AssemblyBuilder.DefineDynamicAssembly(name, AssemblyBuilderAccess.Run); + var module = assembly.DefineDynamicModule(name.Name!); + TypeBuilder builder = module.DefineType("NewAssemblyLog", TypeAttributes.Public | TypeAttributes.Class, typeof(Log)); + var ctor = builder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, Type.EmptyTypes); + var il = ctor.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldstr, "new-assembly"); + il.Emit(OpCodes.Call, typeof(Log).GetConstructor(new[] { typeof(string) })!); + il.Emit(OpCodes.Ret); + Type late = builder.CreateType()!; + + Assert.Contains(late, ReflectionUtils.GetDerivedClasses()); + } + [Fact] public void DerivedTypeScanExcludesAssembliesWithoutAReferencePathToTheBaseAssembly() { From daa4836e9a12d4393ee84a3ae6e0a1963f367abf Mon Sep 17 00:00:00 2001 From: ifBars Date: Wed, 7 Oct 2026 20:39:26 -0700 Subject: [PATCH 49/57] fix(npc): skip uninitialized native index identities --- S1API/Entities/NPCAppearance.cs | 2 +- S1API/Internal/Patches/ContactsAppPatches.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/S1API/Entities/NPCAppearance.cs b/S1API/Entities/NPCAppearance.cs index 47de4c81..c32c5277 100644 --- a/S1API/Entities/NPCAppearance.cs +++ b/S1API/Entities/NPCAppearance.cs @@ -1128,7 +1128,7 @@ private static void SetLayerRecursively(GameObject obj, int layer) internal static readonly global::S1API.Internal.Utils.SceneObjectIndex PoisByNpcId = new global::S1API.Internal.Utils.SceneObjectIndex( () => UnityEngine.Object.FindObjectsOfType(true), - poi => poi.NPC?.ID, + poi => poi.NPC?.NPCData?.BasicInfo?.ID, poi => poi != null, include: poi => poi.gameObject.activeInHierarchy); diff --git a/S1API/Internal/Patches/ContactsAppPatches.cs b/S1API/Internal/Patches/ContactsAppPatches.cs index 9fe99a20..f9c4b66c 100644 --- a/S1API/Internal/Patches/ContactsAppPatches.cs +++ b/S1API/Internal/Patches/ContactsAppPatches.cs @@ -1,4 +1,4 @@ -#if (IL2CPPMELON) +#if (IL2CPPMELON) using S1NPCs = Il2CppScheduleOne.NPCs; using S1ContactsApp = Il2CppScheduleOne.UI.Phone.ContactsApp; using S1Map = Il2CppScheduleOne.Map; @@ -44,7 +44,7 @@ internal class ContactsAppPatches if (circle == null) return null; - return (ReflectionUtils.TryGetFieldOrProperty(circle, "AssignedNPC") as S1NPCs.NPC)?.ID + return (ReflectionUtils.TryGetFieldOrProperty(circle, "AssignedNPC") as S1NPCs.NPC)?.NPCData?.BasicInfo?.ID ?? ReflectionUtils.TryGetFieldOrProperty(circle, "AssignedNPC_ID") as string ?? ReflectionUtils.TryGetFieldOrProperty(circle, "NPCId") as string; } From c1d97cd1a2157affb18cd54d02ccecfc66f09b35 Mon Sep 17 00:00:00 2001 From: ifBars Date: Wed, 7 Oct 2026 22:31:15 -0700 Subject: [PATCH 50/57] fix(avatar): freeze fallback mugshot rig initialization --- S1API/Internal/Compatibility/AvatarCompatibility.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/S1API/Internal/Compatibility/AvatarCompatibility.cs b/S1API/Internal/Compatibility/AvatarCompatibility.cs index 906b3900..7256855a 100644 --- a/S1API/Internal/Compatibility/AvatarCompatibility.cs +++ b/S1API/Internal/Compatibility/AvatarCompatibility.cs @@ -129,7 +129,19 @@ internal static void StartPortraitCapture( var generator = root.AddComponent(); var avatar = avatarObject.GetComponent(); if (avatar != null) + { PrepareDetachedAvatar(avatar); + // This owned render clone must not run gameplay animation initialization: + // its Start samples stand-up clips after the initial pose reset. + if (avatar.Animation != null) + avatar.Animation.enabled = false; + foreach (var animator in avatar.GetComponentsInChildren(true)) + { + animator.Rebind(); + animator.Update(0f); + animator.enabled = false; + } + } if (avatar == null || !Utils.ReflectionUtils.TrySetFieldOrProperty(generator, "_avatar", avatar) || !Utils.ReflectionUtils.TrySetFieldOrProperty( From 349ad1097fbfa2b22dfec3784aadc1f357a54f35 Mon Sep 17 00:00:00 2001 From: ifBars Date: Wed, 7 Oct 2026 22:39:30 -0700 Subject: [PATCH 51/57] fix(npc): prepare customer data before client spawn --- S1API/Entities/NPC.cs | 2 ++ S1API/Entities/NPCCustomer.cs | 46 ++++++++++++++++-------------- S1API/Entities/NPCPrefabBuilder.cs | 13 +++++++++ 3 files changed, 39 insertions(+), 22 deletions(-) diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index 02fb1783..f001d6ef 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -692,6 +692,8 @@ internal static int IncludeNavMeshArea(int areaMask, int areaIndex) => RewireChildNpcReferences(prefabRoot, dealer); RepairDealerPrefabReferences(prefabRoot, dealer); RepairNpcPrefabReferences(prefabRoot, dealer); + if (sourceNpc != dealer) + RemoveComponentImmediate(sourceNpc); return dealer; } diff --git a/S1API/Entities/NPCCustomer.cs b/S1API/Entities/NPCCustomer.cs index 182cd53f..803cb1d4 100644 --- a/S1API/Entities/NPCCustomer.cs +++ b/S1API/Entities/NPCCustomer.cs @@ -300,6 +300,29 @@ private void WireCoreReferences(S1Economy.Customer customer) } } + internal static S1Economy.CustomerData CreateDefaultCustomerData() + { + var data = ScriptableObject.CreateInstance(); + data.DefaultAffinityData = new S1Economy.CustomerAffinityData(); + foreach (var drugType in Enum.GetValues(typeof(S1Product.EDrugType))) + { + data.DefaultAffinityData.ProductAffinities.Add(new S1Economy.ProductTypeAffinity + { + DrugType = (S1Product.EDrugType)drugType, + Affinity = 0f + }); + } + data.MinWeeklySpend = 100f; + data.MaxWeeklySpend = 400f; + data.MinOrdersPerWeek = 1; + data.MaxOrdersPerWeek = 3; + data.OrderTime = 1200; + data.CanBeDirectlyApproached = true; + data.DependenceMultiplier = 1f; + data.BaseAddiction = 0f; + return data; + } + private void EnsureCustomerData(S1Economy.Customer customer) { try @@ -329,28 +352,7 @@ private void EnsureCustomerData(S1Economy.Customer customer) if (data == null) { - // Create a minimal, safe CustomerData so base game logic has sane defaults - data = ScriptableObject.CreateInstance(); - data.DefaultAffinityData = new S1Economy.CustomerAffinityData(); - // Seed affinities for all product types with neutral preference - Array drugTypes = Enum.GetValues(typeof(S1Product.EDrugType)); - foreach (var dt in drugTypes) - { - data.DefaultAffinityData.ProductAffinities.Add(new S1Economy.ProductTypeAffinity - { - DrugType = (S1Product.EDrugType)dt, - Affinity = 0f - }); - } - // Reasonable defaults - data.MinWeeklySpend = 100f; - data.MaxWeeklySpend = 400f; - data.MinOrdersPerWeek = 1; - data.MaxOrdersPerWeek = 3; - data.OrderTime = 1200; - data.CanBeDirectlyApproached = true; - data.DependenceMultiplier = 1f; - data.BaseAddiction = 0f; + data = CreateDefaultCustomerData(); #if MONOMELON diff --git a/S1API/Entities/NPCPrefabBuilder.cs b/S1API/Entities/NPCPrefabBuilder.cs index 3a9764aa..b8c166b6 100644 --- a/S1API/Entities/NPCPrefabBuilder.cs +++ b/S1API/Entities/NPCPrefabBuilder.cs @@ -111,6 +111,19 @@ internal NPCPrefabBuilder EnsureCustomerInfrastructure() customer = prefabRoot.AddComponent(); customer.enabled = true; } + // A joining client's native Awake runs before its managed NPC wrapper can + // create fallback data. Configure the inactive prefab before network spawn. + if (customer.CustomerData == null) + { + var data = NPCCustomer.CreateDefaultCustomerData(); +#if MONOMELON + var field = typeof(S1Economy.Customer).GetField("customerData", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + field?.SetValue(customer, data); +#else + customer.customerData = data; +#endif + } return this; } From 60522881b243107da0d9f7d4610b83ba76f1bc54 Mon Sep 17 00:00:00 2001 From: ifBars Date: Wed, 7 Oct 2026 23:52:33 -0700 Subject: [PATCH 52/57] fix(avatar): retain rebound hip and animate workbench previews --- S1API/Internal/Compatibility/AvatarCompatibility.cs | 4 ++++ S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs | 3 +++ 2 files changed, 7 insertions(+) diff --git a/S1API/Internal/Compatibility/AvatarCompatibility.cs b/S1API/Internal/Compatibility/AvatarCompatibility.cs index 7256855a..2173cf10 100644 --- a/S1API/Internal/Compatibility/AvatarCompatibility.cs +++ b/S1API/Internal/Compatibility/AvatarCompatibility.cs @@ -141,6 +141,10 @@ internal static void StartPortraitCapture( animator.Update(0f); animator.enabled = false; } + // Awake cached the live source's transient hip; Update must retain the rebound pose. + if (avatar.HipBone != null) + Utils.ReflectionUtils.TrySetFieldOrProperty( + avatar, "_savedHipPosition", avatar.HipBone.localPosition); } if (avatar == null || !Utils.ReflectionUtils.TrySetFieldOrProperty(generator, "_avatar", avatar) || diff --git a/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs b/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs index 1b8b42fc..bcc08f94 100644 --- a/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs +++ b/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs @@ -88,6 +88,9 @@ internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string fail throw new InvalidOperationException("The cloned native preview rig has no Avatar component."); Compatibility.AvatarCompatibility.PrepareDetachedAvatar(avatar); + // Portrait rigs are frozen; this clone needs animation for its pose controls. + foreach (var animator in avatar.GetComponentsInChildren(true)) + animator.enabled = true; avatar.SetVisible(true); foreach (var renderer in avatarObject.GetComponentsInChildren(true)) From b3127b373722338a0cb25ee4f8f4f38af6c0efd0 Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 8 Oct 2026 17:55:21 -0700 Subject: [PATCH 53/57] chore(release): prepare 3.2.1-beta.9 prerelease --- .github/release-notes/3.2.1-beta.9.md | 30 +++++++++++++++++++++++++++ S1API/S1API.cs | 2 +- S1API/S1API.csproj | 2 +- 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 .github/release-notes/3.2.1-beta.9.md diff --git a/.github/release-notes/3.2.1-beta.9.md b/.github/release-notes/3.2.1-beta.9.md new file mode 100644 index 00000000..dffb5acd --- /dev/null +++ b/.github/release-notes/3.2.1-beta.9.md @@ -0,0 +1,30 @@ +## Custom NPC loading + +- Scene lookups for movement presets, map icons, and contact circles now reuse an internal index. Scene changes and native assignment invalidate it, and missing objects are searched again. See [PR #354](https://github.com/ifBars/S1API/pull/354). +- Derived-type discovery keeps ordinary assembly results until another assembly loads. Dynamic assemblies are still scanned on every call, and callers receive independent lists. See [PR #355](https://github.com/ifBars/S1API/pull/355). + +These changes reduce repeated lookup work. A repeatable improvement in total loading time has not been established in maintainer testing. + +## Portraits and client initialization + +- Fixed blank custom NPC portraits after reload by retaining the rebound hip position in the owned fallback render rig. Portrait captures remain serialized and retain their settled-frame timing. Workbench preview clones can animate their pose controls again. See [PR #358](https://github.com/ifBars/S1API/pull/358), fixing [#356](https://github.com/ifBars/S1API/issues/356). +- Customer prefabs now have fallback data before a joining client's native `Awake`. Dealer conversion removes the replaced NPC component, preventing duplicate registration. Existing customer data and explicit configuration remain intact. See [PR #359](https://github.com/ifBars/S1API/pull/359), fixing [#357](https://github.com/ifBars/S1API/issues/357). +- Restored packaging icon patches on game beta 0.4.7f11. See [PR #353](https://github.com/ifBars/S1API/pull/353). + +## Compatibility and validation + +Existing public signatures, namespaces, save identifiers, and network payload formats are unchanged. + +The combined release candidate passed 792 Mono and 766 IL2CPP contract tests. Live checks on game beta 0.4.7f11 with MelonLoader 0.7.3 covered 32 custom NPC types over two loads per runtime, producing 64 nonblank portraits per runtime with one native NPC component per object. Controlled gender/weight captures and workbench pose checks also passed on both runtimes. + +Multiplayer validation uses two local peers with separate identities through a Steam emulator. IL2CPP host/client gameplay, portraits, customer initialization, and one-component registration passed the first load and a same-process reload/rejoin. Real Steam transport remains untested. Existing native Contacts warnings remain outside these fixes. + +This is a GitHub-only prerelease for the Schedule I game beta. + +## Contributors and links + +Thanks to [r-melvin](https://github.com/r-melvin) for [#353](https://github.com/ifBars/S1API/pull/353), [#354](https://github.com/ifBars/S1API/pull/354), and [#355](https://github.com/ifBars/S1API/pull/355), and [ifBars](https://github.com/ifBars) for [#358](https://github.com/ifBars/S1API/pull/358) and [#359](https://github.com/ifBars/S1API/pull/359). + +[Full changes since beta.8](https://github.com/ifBars/S1API/compare/v3.2.1-beta.8...v3.2.1-beta.9) + +[Download S1API-Forked-3.2.1-beta.9.zip](https://github.com/ifBars/S1API/releases/download/v3.2.1-beta.9/S1API-Forked-3.2.1-beta.9.zip) diff --git a/S1API/S1API.cs b/S1API/S1API.cs index b6b71cf9..32b1a7fb 100644 --- a/S1API/S1API.cs +++ b/S1API/S1API.cs @@ -12,7 +12,7 @@ using S1API.Lifecycle; using S1API.Map; -[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.8", "KaBooMa")] +[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.9", "KaBooMa")] [assembly: MelonPriority(Int32.MinValue)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member namespace S1API diff --git a/S1API/S1API.csproj b/S1API/S1API.csproj index 2b008329..e1cf227d 100644 --- a/S1API/S1API.csproj +++ b/S1API/S1API.csproj @@ -23,7 +23,7 @@ $(NoWarn);1591 true latest - 3.2.1-beta.8 + 3.2.1-beta.9 From ab6cf978c682cf0c7e06de24b6a2ed08e7e26275 Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 8 Oct 2026 17:55:37 -0700 Subject: [PATCH 54/57] docs(release): record passing Mono multiplayer reload --- .github/release-notes/3.2.1-beta.9.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/release-notes/3.2.1-beta.9.md b/.github/release-notes/3.2.1-beta.9.md index dffb5acd..5cee62b3 100644 --- a/.github/release-notes/3.2.1-beta.9.md +++ b/.github/release-notes/3.2.1-beta.9.md @@ -17,7 +17,7 @@ Existing public signatures, namespaces, save identifiers, and network payload fo The combined release candidate passed 792 Mono and 766 IL2CPP contract tests. Live checks on game beta 0.4.7f11 with MelonLoader 0.7.3 covered 32 custom NPC types over two loads per runtime, producing 64 nonblank portraits per runtime with one native NPC component per object. Controlled gender/weight captures and workbench pose checks also passed on both runtimes. -Multiplayer validation uses two local peers with separate identities through a Steam emulator. IL2CPP host/client gameplay, portraits, customer initialization, and one-component registration passed the first load and a same-process reload/rejoin. Real Steam transport remains untested. Existing native Contacts warnings remain outside these fixes. +Multiplayer validation uses two local peers with separate identities through a Steam emulator. Both Mono and IL2CPP host/client gameplay, portraits, customer initialization, and one-component registration passed the first load and a same-process reload/rejoin. Real Steam transport remains untested. Existing native Contacts warnings remain outside these fixes. This is a GitHub-only prerelease for the Schedule I game beta. From 87478d3ac4c1963c3ec3fefb6230c37500460d3b Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 8 Oct 2026 18:09:46 -0700 Subject: [PATCH 55/57] docs(release): use GitHub mentions for contributor credits --- .github/release-notes/3.2.1-beta.9.md | 2 +- VERSIONING.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/release-notes/3.2.1-beta.9.md b/.github/release-notes/3.2.1-beta.9.md index 5cee62b3..51d4750c 100644 --- a/.github/release-notes/3.2.1-beta.9.md +++ b/.github/release-notes/3.2.1-beta.9.md @@ -23,7 +23,7 @@ This is a GitHub-only prerelease for the Schedule I game beta. ## Contributors and links -Thanks to [r-melvin](https://github.com/r-melvin) for [#353](https://github.com/ifBars/S1API/pull/353), [#354](https://github.com/ifBars/S1API/pull/354), and [#355](https://github.com/ifBars/S1API/pull/355), and [ifBars](https://github.com/ifBars) for [#358](https://github.com/ifBars/S1API/pull/358) and [#359](https://github.com/ifBars/S1API/pull/359). +Thanks to @r-melvin for [#353](https://github.com/ifBars/S1API/pull/353), [#354](https://github.com/ifBars/S1API/pull/354), and [#355](https://github.com/ifBars/S1API/pull/355), and @ifBars for [#358](https://github.com/ifBars/S1API/pull/358) and [#359](https://github.com/ifBars/S1API/pull/359). [Full changes since beta.8](https://github.com/ifBars/S1API/compare/v3.2.1-beta.8...v3.2.1-beta.9) diff --git a/VERSIONING.md b/VERSIONING.md index 98035bdf..6a813e36 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -119,6 +119,7 @@ The GitHub release workflow packages public mod archives and can publish the sam - `publish-github-release.yml` runs from release tags and can also be rerun with `workflow_dispatch`. - When `.github/release-notes/X.Y.Z.md` exists at the tagged commit, its curated Markdown is used as the GitHub release body. Historical tags and manual reruns without that file fall back to GitHub-generated notes. - Curated notes should use concise domain-specific change sections, a compatibility and validation section, PR-linked contributor credits, and release links, matching the structure of recent stable releases. +- Use GitHub `@username` mentions for contributor credits, rather than Markdown links to profile pages. Keep the corresponding pull request links. - The GitHub/Nexus archive is `S1API-Forked-x.y.z.zip` and contains `Mods/` and `Plugins/` at the archive root. - GitHub Releases should only publish `S1API-Forked-x.y.z.zip` as a release asset. - The Thunderstore archive is `S1API-TS-x.y.z.zip` and contains `icon.png`, `README.md`, `manifest.json`, `Mods/`, and `Plugins/` at the archive root, but it is only used for Thunderstore publishing. From 8c512dab6ab48c48b87dbb1ec095721827fece27 Mon Sep 17 00:00:00 2001 From: ifBars Date: Fri, 9 Oct 2026 04:49:28 -0700 Subject: [PATCH 56/57] docs: complete 3.2.1 release notes --- .github/release-notes/3.2.1.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/release-notes/3.2.1.md b/.github/release-notes/3.2.1.md index 1e27bef1..5edd2e0f 100644 --- a/.github/release-notes/3.2.1.md +++ b/.github/release-notes/3.2.1.md @@ -6,7 +6,7 @@ ## Custom NPCs, contacts, and persistence - Restores plain NPC construction and initializes customer, dealer, and supplier data before native startup and prefab configuration. Dealer conversion removes the replaced component instead of registering the same NPC twice. See [#311](https://github.com/ifBars/S1API/pull/311), [#317](https://github.com/ifBars/S1API/pull/317), [#334](https://github.com/ifBars/S1API/pull/334), and [#359](https://github.com/ifBars/S1API/pull/359). -- Fixes sender information, duplicate Messages entries, and contact registration after network spawn and save reload. Messages sent during load completion survive delayed spawn and ordinary saving. See [#322](https://github.com/ifBars/S1API/pull/322), [#325](https://github.com/ifBars/S1API/pull/325), [#328](https://github.com/ifBars/S1API/pull/328), and [#329](https://github.com/ifBars/S1API/pull/329). +- Fixes sender information, duplicate Messages entries, and contact registration after network spawn and save reload. Messages sent during load completion survive delayed spawn and ordinary saving. See [#322](https://github.com/ifBars/S1API/pull/322), [#325](https://github.com/ifBars/S1API/pull/325), and [#329](https://github.com/ifBars/S1API/pull/329). - Retains load-completion listeners across returns to the menu without duplicate callbacks. See [#323](https://github.com/ifBars/S1API/pull/323). - Scene-object indexing and cached ordinary assembly discovery reduce repeated custom NPC lookup work. A repeatable total loading-time improvement has not been established. See [#354](https://github.com/ifBars/S1API/pull/354) and [#355](https://github.com/ifBars/S1API/pull/355). @@ -17,6 +17,11 @@ - Custom accessories retain their own avatar objects, textures, and tints. Workbench preview poses animate correctly, and portrait rebound retains its owned rig's hip position. See [#332](https://github.com/ifBars/S1API/pull/332) and [#358](https://github.com/ifBars/S1API/pull/358). - Fixes the Gamepad settings hang when a loading dock changes its vehicle without a registered listener. See [#316](https://github.com/ifBars/S1API/pull/316). +## New managed APIs + +- Exposes loading dock state and vehicle occupancy. See [#302](https://github.com/ifBars/S1API/pull/302). +- Lets phone and TV apps create independent display sessions through opt-in external hosting. See [#328](https://github.com/ifBars/S1API/pull/328). + ## Compatibility and validation Existing managed public contracts, save identifiers, and network formats are retained. The previously documented native `PhoneApp.Exit` parameter migration remains represented by the managed compatibility type. Use this release with Schedule I 0.4.7; compatibility with the previous game release is not claimed. From 08237df33ab13db3c1f684d1d36e2e116d8991bc Mon Sep 17 00:00:00 2001 From: ifBars Date: Fri, 9 Oct 2026 04:57:36 -0700 Subject: [PATCH 57/57] fix(ci): use shipped API baseline for stable release PRs --- .github/workflows/docs.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a214e51d..e77b087d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -322,6 +322,7 @@ jobs: - name: Build target branch API baseline if: github.event_name == 'pull_request' env: + HEAD_REF: ${{ github.head_ref }} BASE_SHA: ${{ github.event.pull_request.base.sha }} BASE_REF: ${{ github.event.pull_request.base.ref }} GH_TOKEN: ${{ github.token }} @@ -346,7 +347,10 @@ jobs: use_release_baseline=true else baseline_tag="$(git describe --tags --abbrev=0 --match 'v[0-9]*' "$BASE_SHA" 2>/dev/null || true)" - if [[ -n "$baseline_tag" ]] && git diff --quiet "$baseline_tag" "$BASE_SHA" -- \ + if [[ "$BASE_REF" == "stable" && "$HEAD_REF" == releases/* && -n "$baseline_tag" ]]; then + # Game-update releases compare to the shipped API without rebuilding old native bindings. + use_release_baseline=true + elif [[ -n "$baseline_tag" ]] && git diff --quiet "$baseline_tag" "$BASE_SHA" -- \ ':(glob)S1API/**/*.cs' \ S1API/S1API.csproj \ S1API/Directory.Build.props \