diff --git a/S1API.Tests/NPCs/NPCBehaviourOwnershipTests.cs b/S1API.Tests/NPCs/NPCBehaviourOwnershipTests.cs
new file mode 100644
index 00000000..c50a5bca
--- /dev/null
+++ b/S1API.Tests/NPCs/NPCBehaviourOwnershipTests.cs
@@ -0,0 +1,13 @@
+using S1API.Internal.Patches;
+
+namespace S1API.Tests.NPCs;
+
+public sealed class NPCBehaviourOwnershipTests
+{
+ [Fact]
+ public void GameMembersTheBehaviourOwnershipRepairReliesOnExist()
+ {
+ // If the game renames one of these, the repair stops working and a custom NPC can stop a save from loading.
+ Assert.Empty(NPCPatches.FindMissingBehaviourOwnershipMembers());
+ }
+}
diff --git a/S1API/Internal/Patches/NPCPatches.cs b/S1API/Internal/Patches/NPCPatches.cs
index d480e800..b62692ec 100644
--- a/S1API/Internal/Patches/NPCPatches.cs
+++ b/S1API/Internal/Patches/NPCPatches.cs
@@ -315,6 +315,121 @@ private static void Prefix(S1NPCs.NPCMovement __instance, ref Vector3 pos)
return ReflectionUtils.TryGetFieldOrProperty(action, "npc") as S1NPCs.NPC;
}
+ ///
+ /// Names the game members the behaviour-ownership repair relies on that are missing, so a game update
+ /// that renames one is a clear message and not a silent load failure.
+ ///
+ internal static System.Collections.Generic.IReadOnlyList FindMissingBehaviourOwnershipMembers()
+ {
+ const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
+ var missing = new System.Collections.Generic.List();
+ if (typeof(S1NPCsBehaviour.ConsumeProductBehaviour).GetMethod("OnStartServer", flags) == null)
+ missing.Add("ConsumeProductBehaviour.OnStartServer");
+ if (typeof(S1NPCsBehaviour.Behaviour).GetProperty("beh", flags) == null)
+ missing.Add("Behaviour.beh");
+ if (typeof(S1NPCsBehaviour.NPCBehaviour).GetProperty("Npc", flags) == null)
+ missing.Add("NPCBehaviour.Npc");
+#if MONOMELON
+ // Mono writes the auto-property backing fields, since the setters are not public.
+ if (ConsumeProductBehaviourOwnershipPatch.BehaviourOwnerField == null)
+ missing.Add("Behaviour.k__BackingField");
+ if (ConsumeProductBehaviourOwnershipPatch.NpcOwnerField == null)
+ missing.Add("NPCBehaviour.k__BackingField");
+#endif
+ return missing;
+ }
+
+ ///
+ /// A custom NPC's product behaviour must not abort loading because it cannot find its NPC.
+ ///
+ ///
+ /// 0.4.7's OnStartServer is base.OnStartServer(); Npc.OnNPCDeinitialized += ..., where Npc is
+ /// beh.Npc. Both references are set only in Awake, so an instance whose Awake ran before it was
+ /// parented keeps nulls, and the exception aborts FishNet's scene setup: the save never finishes loading.
+ /// The prefix repairs them as Awake would have. If there is still no NPC, the finalizer contains that one
+ /// exception (only the deinitialize subscription is lost). S1API's own NPCs only.
+ ///
+ [HarmonyPatch]
+ private static class ConsumeProductBehaviourOwnershipPatch
+ {
+ private const int MaxContainedLogs = 20;
+ private static int _contained;
+#if MONOMELON
+ internal static readonly FieldInfo? BehaviourOwnerField =
+ AccessTools.Field(typeof(S1NPCsBehaviour.Behaviour), "k__BackingField");
+ internal static readonly FieldInfo? NpcOwnerField =
+ AccessTools.Field(typeof(S1NPCsBehaviour.NPCBehaviour), "k__BackingField");
+#endif
+
+ private static MethodBase? TargetMethod() =>
+ AccessTools.Method(typeof(S1NPCsBehaviour.ConsumeProductBehaviour), "OnStartServer");
+
+ [HarmonyPrefix]
+ private static void Prefix(S1NPCsBehaviour.ConsumeProductBehaviour __instance)
+ {
+ try
+ {
+ if (__instance == null || !IsS1ApiCustomNpcComponent(__instance))
+ return;
+
+ S1NPCsBehaviour.NPCBehaviour? owner = __instance.beh;
+ if (owner == null)
+ {
+ owner = __instance.GetComponentInParent(true);
+ if (owner == null)
+ return;
+#if IL2CPPMELON
+ __instance.beh = owner;
+#else
+ BehaviourOwnerField?.SetValue(__instance, owner);
+#endif
+ }
+
+ if (owner.Npc != null)
+ return;
+
+ S1NPCs.NPC? npc = owner.GetComponentInParent(true);
+ if (npc == null)
+ return;
+#if IL2CPPMELON
+ owner.Npc = npc;
+#else
+ NpcOwnerField?.SetValue(owner, npc);
+#endif
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning($"[NPC] Could not repair behaviour ownership: {ex.Message}");
+ }
+ }
+
+ [HarmonyFinalizer]
+ private static Exception? Finalizer(Exception? __exception, S1NPCsBehaviour.ConsumeProductBehaviour __instance)
+ {
+ if (__exception == null)
+ return null;
+
+ try
+ {
+ // Only the failure the prefix could not repair: an S1API NPC's behaviour still without its NPC.
+ if (__instance == null || !IsS1ApiCustomNpcComponent(__instance) || __instance.beh?.Npc != null)
+ return __exception;
+ }
+ catch
+ {
+ return __exception;
+ }
+
+ if (_contained++ < MaxContainedLogs)
+ {
+ Logger.Warning(
+ $"[NPC] Contained an OnStartServer failure on '{__instance.gameObject.name}' (no NPC found); loading continues: {__exception.Message}");
+ }
+
+ return null;
+ }
+ }
+
[HarmonyPatch]
private static class NpcBehaviourSummonLogicPatch
{