From 23cf7a4099a66e8b8a5ce2db1a01014297bdb5be Mon Sep 17 00:00:00 2001 From: "Diffuin[bot]" Date: Wed, 12 Aug 2026 08:10:54 +0000 Subject: [PATCH 1/2] chore(diffuin): address #260 --- S1API.Tests/Entities/NPCPersistentIdsTests.cs | 34 +++++++ S1API/Entities/NPC.cs | 67 +++++++++++++- S1API/Internal/Entities/NPCPersistentIds.cs | 35 ++++++++ S1API/Internal/Patches/NPCPatches.cs | 88 +++++++++++-------- S1API/Internal/Patches/QuestPatches.cs | 2 + 5 files changed, 188 insertions(+), 38 deletions(-) create mode 100644 S1API.Tests/Entities/NPCPersistentIdsTests.cs create mode 100644 S1API/Internal/Entities/NPCPersistentIds.cs diff --git a/S1API.Tests/Entities/NPCPersistentIdsTests.cs b/S1API.Tests/Entities/NPCPersistentIdsTests.cs new file mode 100644 index 00000000..944478a8 --- /dev/null +++ b/S1API.Tests/Entities/NPCPersistentIdsTests.cs @@ -0,0 +1,34 @@ +using S1API.Internal.Entities; + +namespace S1API.Tests.Entities; + +public sealed class NPCPersistentIdsTests +{ + [Fact] + public void IdentityIdProducesTheSameGuidAcrossConstructionPaths() + { + Assert.True(NPCPersistentIds.TryGetGuid("mod.author:custom_npc", out Guid first)); + Assert.True(NPCPersistentIds.TryGetGuid(" MOD.AUTHOR:CUSTOM_NPC ", out Guid second)); + + Assert.Equal(first, second); + Assert.NotEqual(Guid.Empty, first); + } + + [Fact] + public void DifferentIdentityIdsProduceDifferentGuids() + { + NPCPersistentIds.TryGetGuid("mod.author:customer_a", out Guid first); + NPCPersistentIds.TryGetGuid("mod.author:customer_b", out Guid second); + + Assert.NotEqual(first, second); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void MissingIdentityIdDoesNotProducePersistentGuid(string id) + { + Assert.False(NPCPersistentIds.TryGetGuid(id, out Guid guid)); + Assert.Equal(Guid.Empty, guid); + } +} diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index 6a9883bc..a3118886 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -2111,7 +2111,7 @@ protected NPC() if (Icon == null) NPCDataAccess.ApplyIcon(S1NPC, S1DevUtilities.PlayerSingleton.Instance.AppIcon); - S1NPC.BakedGUID = Guid.NewGuid().ToString(); + AssignPersistentGuid(id); if (IsPhysical) ResetConversationCategoriesToDefaults(); @@ -2170,6 +2170,7 @@ protected NPC(string id, string? firstName, string? lastName, Sprite? icon = nul bool hasLastName = !string.IsNullOrEmpty(lastName); NPCDataAccess.ApplyIdentity(S1NPC, id, firstName, lastName); + AssignPersistentGuid(id); if (icon != null) { NPCDataAccess.ApplyIcon(S1NPC, icon); @@ -3239,10 +3240,22 @@ public bool ConversationCanBeHidden internal bool RelationshipLoadedFromSave { get; private set; } /// - /// INTERNAL: Marks native relationship state as hydrated from save data. + /// INTERNAL: Applies relationship data from a native save payload and retains it through activation. /// - internal void MarkRelationshipLoadedFromSave() => + internal void LoadRelationshipFromSave( + float relationDelta, + bool unlocked, + S1Relation.NPCRelationData.EUnlockType unlockType) + { + if (!NPCRelationshipPersistencePolicy.IsValidSavedDelta(relationDelta)) + return; + + _loadedRelationshipDelta = relationDelta; + _loadedRelationshipUnlocked = unlocked; + _loadedRelationshipUnlockType = unlockType; RelationshipLoadedFromSave = true; + RestoreLoadedRelationship(); + } /// /// INTERNAL: Constructor used for base game NPCs. @@ -4044,6 +4057,48 @@ private void InitializeNetworkBehaviours() } } + private void AssignPersistentGuid(string? npcId) + { + Guid guid = NPCPersistentIds.TryGetGuid(npcId, out Guid persistentGuid) + ? persistentGuid + : Guid.NewGuid(); + S1NPC.BakedGUID = guid.ToString(); + } + + internal void RegisterPersistentGuidForContractLoad() + { + if (!Guid.TryParse(S1NPC.BakedGUID, out Guid guid)) + return; + + try + { +#if IL2CPPMELON + S1NPC.SetGUID(new Il2CppSystem.Guid(guid.ToString())); +#else + S1NPC.SetGUID(guid); +#endif + } + catch (Exception ex) + { + Logger.Warning( + $"[NPC] Failed to register persistent GUID for '{GetSafeNpcId()}': {ex.Message}"); + } + } + + private void RestoreLoadedRelationship() + { + if (!_loadedRelationshipDelta.HasValue || S1NPC.RelationData == null) + return; + + S1NPC.RelationData.SetRelationship(_loadedRelationshipDelta.Value, false); + if (_loadedRelationshipUnlocked) + { + S1NPC.RelationData.Unlock( + _loadedRelationshipUnlockType, + notify: false); + } + } + private void RestoreRuntimeAvatarAppearance() { if (_runtimeAvatar == null) @@ -4081,6 +4136,9 @@ private void RestoreRuntimeAvatarAppearance() private NPCDrinking? _drinking; private NPCItemHolding? _itemHolding; private bool _relationshipDataAppliedFromPrefab; + private float? _loadedRelationshipDelta; + private bool _loadedRelationshipUnlocked; + private S1Relation.NPCRelationData.EUnlockType _loadedRelationshipUnlockType; private readonly System.Collections.Generic.List _recommendationSubscriptions = new System.Collections.Generic.List(); @@ -4117,6 +4175,7 @@ internal bool PrepareForNetworkSpawn() } NPCDataAccess.PrepareForRuntime(S1NPC); + RestoreLoadedRelationship(); var customer = gameObject.GetComponent(); if (customer != null) @@ -4199,6 +4258,8 @@ internal void FinalizeNetworkSpawn() { try { + RestoreLoadedRelationship(); + // Ensure NPCAwareness.Responses reference is valid after spawn // Network spawning can sometimes break component references if (S1NPC.Awareness != null && S1NPC.Responses is S1Responses.NPCResponses_Civilian validResponses) diff --git a/S1API/Internal/Entities/NPCPersistentIds.cs b/S1API/Internal/Entities/NPCPersistentIds.cs new file mode 100644 index 00000000..6f995383 --- /dev/null +++ b/S1API/Internal/Entities/NPCPersistentIds.cs @@ -0,0 +1,35 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace S1API.Internal.Entities +{ + /// + /// Creates stable native identifiers for custom NPCs that participate in persisted game systems. + /// + internal static class NPCPersistentIds + { + internal static bool TryGetGuid(string? npcId, out Guid guid) + { + if (string.IsNullOrWhiteSpace(npcId)) + { + guid = Guid.Empty; + return false; + } + + byte[] hash; + using (SHA256 algorithm = SHA256.Create()) + { + string value = $"S1API.NPC:v1:{npcId.Trim().ToLowerInvariant()}"; + hash = algorithm.ComputeHash(Encoding.UTF8.GetBytes(value)); + } + + var guidBytes = new byte[16]; + Array.Copy(hash, guidBytes, guidBytes.Length); + guidBytes[6] = (byte)((guidBytes[6] & 0x0f) | 0x50); + guidBytes[8] = (byte)((guidBytes[8] & 0x3f) | 0x80); + guid = new Guid(guidBytes); + return true; + } + } +} diff --git a/S1API/Internal/Patches/NPCPatches.cs b/S1API/Internal/Patches/NPCPatches.cs index f272e61d..e944ce29 100644 --- a/S1API/Internal/Patches/NPCPatches.cs +++ b/S1API/Internal/Patches/NPCPatches.cs @@ -574,7 +574,40 @@ private static void RebuildPendingCustomNpcTypes(bool useConsolidatedFlow) if (type.Assembly == Assembly.GetExecutingAssembly()) continue; // skip S1API internal wrapper types - _pendingCustomNpcTypes.Add(type); + if (!NPC.All.Any(npc => npc.GetType() == type)) + _pendingCustomNpcTypes.Add(type); + } + } + + /// + /// Creates inactive custom NPCs and registers their persistent GUIDs before native contracts load. + /// NPCsLoader later hydrates and queues the same instances for network spawn. + /// + internal static void PrepareCustomNpcsForContractLoad() + { + if (!IsInMainScene() || !InstanceFinder.IsServer) + return; + + foreach (Type type in ReflectionUtils.GetDerivedClasses()) + { + if (type == null || type.IsAbstract || type.Assembly == Assembly.GetExecutingAssembly()) + continue; + + NPC? customNpc = NPC.All.FirstOrDefault(npc => npc.GetType() == type); + if (customNpc == null) + { + try + { + customNpc = (NPC)Activator.CreateInstance(type, true)!; + } + catch (Exception ex) + { + LogCustomNpcInstantiationException(type, "before contract loading", ex); + continue; + } + } + + customNpc.RegisterPersistentGuidForContractLoad(); } } @@ -781,6 +814,15 @@ private static void InstantiateRemainingCustomNpcs(string mainPath) } } + private static void RegisterPreparedCustomNpcsForNetworking() + { + foreach (NPC customNpc in NPC.All) + { + if (customNpc.IsCustomNPC) + RegisterCustomNpcForNetworking(customNpc); + } + } + /// /// Patching performed for when game NPCs are loaded. /// Creates custom NPC instances before the loader runs. @@ -955,6 +997,7 @@ private static bool NPCsLoader_Load_Prefix(S1Loaders.NPCsLoader __instance, stri // Instantiate any new custom NPCs that don't have save entries yet (e.g., newly added mods) InstantiateRemainingCustomNpcs(mainPath); + RegisterPreparedCustomNpcsForNetworking(); return false; // Skip original loader } } @@ -1468,36 +1511,10 @@ private static bool NPCLoader_Load_Prefix(S1Datas.DynamicSaveData saveData) // Native relationship data is authoritative whenever a finite saved delta exists. if (saveData.TryGetData("Relationship", out S1Datas.RelationshipData rel) && rel != null && s1BaseNpc.RelationData != null) { - if (NPCRelationshipPersistencePolicy.IsValidSavedDelta(rel.RelationDelta)) - { - s1BaseNpc.RelationData.SetRelationship( - rel.RelationDelta, - false); - apiNpc.MarkRelationshipLoadedFromSave(); - } - - if (rel.Unlocked) - { - s1BaseNpc.RelationData.Unlock(rel.UnlockType, notify: false); - - // Store unlock type for potential restoration - try - { - apiNpc = FindWrapperForS1Npc(s1BaseNpc); - if (apiNpc != null) - { - var unlockTypeField = typeof(NPC).GetField("_loadedUnlockType", BindingFlags.NonPublic | BindingFlags.Instance); - if (unlockTypeField != null) - { - var s1UnlockType = rel.UnlockType == S1Relation.NPCRelationData.EUnlockType.Recommendation - ? S1Relation.NPCRelationData.EUnlockType.Recommendation - : S1Relation.NPCRelationData.EUnlockType.DirectApproach; - unlockTypeField.SetValue(apiNpc, s1UnlockType); - } - } - } - catch { } - } + apiNpc.LoadRelationshipFromSave( + rel.RelationDelta, + rel.Unlocked, + rel.UnlockType); } if (saveData.TryGetData("MessageConversation", out S1Datas.MSGConversationData convo)) @@ -2139,11 +2156,12 @@ private static void NPCLoader_Load_Postfix(S1Datas.DynamicSaveData saveData) if (saveData.TryGetData( "Relationship", out S1Datas.RelationshipData relationshipData) - && relationshipData != null - && NPCRelationshipPersistencePolicy.IsValidSavedDelta( - relationshipData.RelationDelta)) + && relationshipData != null) { - apiNpc.MarkRelationshipLoadedFromSave(); + apiNpc.LoadRelationshipFromSave( + relationshipData.RelationDelta, + relationshipData.Unlocked, + relationshipData.UnlockType); } } diff --git a/S1API/Internal/Patches/QuestPatches.cs b/S1API/Internal/Patches/QuestPatches.cs index d7426ac9..7420411e 100644 --- a/S1API/Internal/Patches/QuestPatches.cs +++ b/S1API/Internal/Patches/QuestPatches.cs @@ -127,6 +127,8 @@ private static void SaveManager_Save_Postfix(string saveFolderPath) [HarmonyPrefix] private static void QuestsLoaderLoad_Prefix(string mainPath) { + NPCPatches.PrepareCustomNpcsForContractLoad(); + // Load and parse the Quests.json file to extract modded quest data if (!File.Exists(mainPath)) return; From 693c5d6c8fc2776894640db8fd2efed3e28e81f4 Mon Sep 17 00:00:00 2001 From: ifBars Date: Wed, 12 Aug 2026 01:41:23 -0700 Subject: [PATCH 2/2] fix(npc): complete persistence restoration --- .../CustomNpcPreparationPolicyTests.cs | 40 +++++++++++++++++++ S1API/Entities/NPCCustomer.cs | 13 +++++- .../Entities/CustomNpcPreparationPolicy.cs | 19 +++++++++ S1API/Internal/Patches/NPCPatches.cs | 26 +++++++----- 4 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 S1API.Tests/Entities/CustomNpcPreparationPolicyTests.cs create mode 100644 S1API/Internal/Entities/CustomNpcPreparationPolicy.cs diff --git a/S1API.Tests/Entities/CustomNpcPreparationPolicyTests.cs b/S1API.Tests/Entities/CustomNpcPreparationPolicyTests.cs new file mode 100644 index 00000000..d1fd10ee --- /dev/null +++ b/S1API.Tests/Entities/CustomNpcPreparationPolicyTests.cs @@ -0,0 +1,40 @@ +using S1API.Internal.Entities; + +namespace S1API.Tests.Entities; + +public sealed class CustomNpcPreparationPolicyTests +{ + [Fact] + public void PreparedInstanceIsReusedOnlyForItsExactCustomType() + { + var first = new FirstCustomNpc(); + var second = new SecondCustomNpc(); + object[] instances = { first, second }; + + object? result = CustomNpcPreparationPolicy.FindExactType( + instances, + typeof(SecondCustomNpc)); + + Assert.Same(second, result); + } + + [Fact] + public void MissingPreparedTypeRequiresNewConstruction() + { + object[] instances = { new FirstCustomNpc() }; + + object? result = CustomNpcPreparationPolicy.FindExactType( + instances, + typeof(SecondCustomNpc)); + + Assert.Null(result); + } + + private sealed class FirstCustomNpc + { + } + + private sealed class SecondCustomNpc + { + } +} diff --git a/S1API/Entities/NPCCustomer.cs b/S1API/Entities/NPCCustomer.cs index 3c8f622c..e95c9e7e 100644 --- a/S1API/Entities/NPCCustomer.cs +++ b/S1API/Entities/NPCCustomer.cs @@ -581,7 +581,18 @@ private void InitializeRuntimeState(S1Economy.Customer customer) // Ensure the deal-attendance implementation used by this game version is present. try { - EnsureDealAttendanceSupport(NPC?.gameObject, NPC?.GetType()); + if (EnsureDealAttendanceSupport(NPC?.gameObject, NPC?.GetType())) + { + var attendance = NPC?.gameObject + .GetComponentInChildren(true); + if (attendance != null) + { + ReflectionUtils.TrySetFieldOrProperty( + customer, + "_attendDealBehaviour", + attendance); + } + } } catch { /* ignore */ } } diff --git a/S1API/Internal/Entities/CustomNpcPreparationPolicy.cs b/S1API/Internal/Entities/CustomNpcPreparationPolicy.cs new file mode 100644 index 00000000..3cffe733 --- /dev/null +++ b/S1API/Internal/Entities/CustomNpcPreparationPolicy.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace S1API.Internal.Entities +{ + /// + /// Selects prepared custom NPC instances for reuse across native loader phases. + /// + internal static class CustomNpcPreparationPolicy + { + internal static T? FindExactType( + IEnumerable instances, + Type requestedType) + where T : class => + instances.FirstOrDefault( + instance => instance != null && instance.GetType() == requestedType); + } +} diff --git a/S1API/Internal/Patches/NPCPatches.cs b/S1API/Internal/Patches/NPCPatches.cs index e944ce29..b88f57c1 100644 --- a/S1API/Internal/Patches/NPCPatches.cs +++ b/S1API/Internal/Patches/NPCPatches.cs @@ -574,7 +574,7 @@ private static void RebuildPendingCustomNpcTypes(bool useConsolidatedFlow) if (type.Assembly == Assembly.GetExecutingAssembly()) continue; // skip S1API internal wrapper types - if (!NPC.All.Any(npc => npc.GetType() == type)) + if (CustomNpcPreparationPolicy.FindExactType(NPC.All, type) == null) _pendingCustomNpcTypes.Add(type); } } @@ -593,7 +593,7 @@ internal static void PrepareCustomNpcsForContractLoad() if (type == null || type.IsAbstract || type.Assembly == Assembly.GetExecutingAssembly()) continue; - NPC? customNpc = NPC.All.FirstOrDefault(npc => npc.GetType() == type); + NPC? customNpc = CustomNpcPreparationPolicy.FindExactType(NPC.All, type); if (customNpc == null) { try @@ -607,6 +607,9 @@ internal static void PrepareCustomNpcsForContractLoad() } } + if (customNpc.gameObject.GetComponent() != null) + customNpc.Customer.EnsureCustomer(); + customNpc.RegisterPersistentGuidForContractLoad(); } } @@ -864,16 +867,19 @@ private static void NPCsLoadersLoad(S1Loaders.NPCsLoader __instance, string main int createdCount = 0; foreach (Type type in ReflectionUtils.GetDerivedClasses()) { - if (type.IsAbstract) + if (type.IsAbstract || type.Assembly == Assembly.GetExecutingAssembly()) continue; - - NPC? customNPC = (NPC)Activator.CreateInstance(type, true)!; - if (customNPC == null) - throw new Exception($"Unable to create instance of {type.FullName}!"); - // We skip any S1API NPCs, as they are base NPC wrappers. - if (type.Assembly == Assembly.GetExecutingAssembly()) - continue; + // QuestsLoader may have prepared this instance already so accepted contracts can + // resolve its persistent GUID. Reuse it rather than creating a duplicate wrapper + // whose default state would later win during save serialization. + NPC? customNPC = CustomNpcPreparationPolicy.FindExactType(NPC.All, type); + if (customNPC == null) + { + customNPC = (NPC?)Activator.CreateInstance(type, true); + if (customNPC == null) + throw new Exception($"Unable to create instance of {type.FullName}!"); + } var baseNpc = customNPC.S1NPC ?? throw new InvalidOperationException(