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 1/2] 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 9ec0598fc17813a732cde58c6539a8162c8ab825 Mon Sep 17 00:00:00 2001 From: ifBars Date: Thu, 1 Oct 2026 21:20:31 -0700 Subject: [PATCH 2/2] 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" }));