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 1/3] 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 6e727b00f1229ff4d47c9db7934622186bc11b9f Mon Sep 17 00:00:00 2001 From: ifBars Date: Wed, 7 Oct 2026 20:28:18 -0700 Subject: [PATCH 2/3] 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 daa4836e9a12d4393ee84a3ae6e0a1963f367abf Mon Sep 17 00:00:00 2001 From: ifBars Date: Wed, 7 Oct 2026 20:39:26 -0700 Subject: [PATCH 3/3] 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; }