Harmony PatchAll does not isolate a failing patch class¶
Symptom¶
Some of the mod's features work and the rest do nothing. Nothing crashed. The mod's own "patches applied" line is missing from BepInEx\LogOutput.log, or the mod never got as far as printing one. There may be a single HarmonyX line about one method, and nothing anywhere saying the other patches were skipped.
The cause is almost always this: one patch class threw while it was being applied, and Harmony.PatchAll(assembly) abandoned the whole walk at that point. Every patch class already processed stays live. Every patch class after it is never applied, and neither is any line of code below the PatchAll call in the same try.
Five triggers recorded elsewhere in this knowledge base, one blast radius:
| Trigger | Exception | Page |
|---|---|---|
[HarmonyPatch(typeof(SaveHelper), nameof(SaveHelper.Save))] against two overloads |
AmbiguousMatchException wrapped in HarmonyException |
SaveZipExtension |
TargetMethod() returning an open generic method definition (Thing.Create<T>) |
NotSupportedException: Specified method is not supported |
PlacementOrientation |
Patching a Device base overload without an explicit argument-type array |
AmbiguousMatchException |
PowerSegmentingDevices |
| Injected parameter named differently from the original's parameter | System.Exception: Parameter "x" not found in method ... |
HarmonyParameterNameBinding |
[HarmonyPatch(typeof(Subclass), "InheritedMethod")] on a method the subclass does not declare |
ArgumentException: Undefined target method for patch method ... |
HarmonyInheritedMethods, HarmonyLogicableInheritedMethodTrap |
The trigger varies. The consequence does not. This page is the shared cause and the fix.
Why: PatchAll has no per-class guard¶
Four methods in HarmonyX 2.9.0.0 produce the behaviour. Line numbers are into .work/decomp/0.2.6428.27798/0Harmony.decompiled.cs.
1. Harmony.PatchAll(Assembly) (line 2657) is a bare loop with no try.
public void PatchAll(Assembly assembly)
{
AccessTools.GetTypesFromAssembly(assembly).Do(delegate(Type type)
{
CreateClassProcessor(type).Patch();
});
}
2. CollectionExtensions.Do<T> (line 8415) has no try either. It is a plain walk of the enumerator, so an exception out of action ends the enumeration:
public static void Do<T>(this IEnumerable<T> sequence, Action<T> action)
{
if (sequence != null)
{
IEnumerator<T> enumerator = sequence.GetEnumerator();
while (enumerator.MoveNext())
{
action(enumerator.Current);
}
}
}
3. PatchClassProcessor.Patch() (line 3926) catches its own exception into a local, then hands it to ReportException. It does not swallow it:
public List<MethodInfo> Patch()
{
if (containerAttributes == null)
{
return null;
}
Exception exception = null;
if (!RunMethod<HarmonyPrepare, bool>(defaultIfNotExisting: true, defaultIfFailing: false, null, new object[0]))
{
RunMethod<HarmonyCleanup>(ref exception, new object[0]);
ReportException(exception, null);
return new List<MethodInfo>();
}
List<MethodInfo> result = new List<MethodInfo>();
MethodBase lastOriginal = null;
try
{
List<MethodBase> bulkMethods = GetBulkMethods();
if (bulkMethods.Count == 1)
{
lastOriginal = bulkMethods[0];
}
ReversePatch(ref lastOriginal);
result = ((bulkMethods.Count > 0) ? BulkPatch(bulkMethods, ref lastOriginal) : PatchWithAttributes(ref lastOriginal));
}
catch (Exception ex)
{
exception = ex;
}
RunMethod<HarmonyCleanup>(ref exception, new object[1] { exception });
ReportException(exception, lastOriginal);
return result;
}
Note the first statement: Patch() returns null for a type carrying no Harmony attributes. The constructor (line 3883) leaves containerAttributes null when allowUnannotatedType is false and HarmonyMethodExtensions.GetFromType(type) came back empty. A null return means "not a patch class", not "failed". Most types in a mod assembly return null here, which matters when writing the replacement loop below.
4. PatchClassProcessor.ReportException (line 4115) ends with an unconditional throw. The whole method:
private void ReportException(Exception exception, MethodBase original)
{
if (exception == null)
{
return;
}
Logger.Log(Logger.LogChannel.Debug, delegate
{
Harmony.VersionInfo(out var currentVersion);
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine($"### Exception from user \"{instance.Id}\", Harmony v{currentVersion}");
stringBuilder.AppendLine("### Original: " + (original?.FullDescription() ?? "NULL"));
stringBuilder.AppendLine("### Patch class: " + containerType.FullDescription());
Exception ex = exception;
if (ex is HarmonyException ex2)
{
ex = ex2.InnerException;
}
string text = ex.ToString();
while (text.Contains("\n\n"))
{
text = text.Replace("\n\n", "\n");
}
text = text.Split('\n').Join((string line) => "### " + line, "\n");
stringBuilder.AppendLine(text.Trim());
return stringBuilder.ToString();
});
if (exception is HarmonyException)
{
throw exception;
}
throw new HarmonyException("Patching exception in method " + original.FullDescription(), exception);
}
The early return on a null exception is what makes a clean patch class harmless. For any non-null exception the method logs (see "Why the log looks empty") and then throws, every time, with no flag and no caller-supplied policy. There is no code path in which ReportException receives an exception and returns.
ReportException has five call sites, all inside PatchClassProcessor: Patch() at lines 3936 and 3956, ProcessPatchJob at line 4062, and both RunMethod overloads at lines 4182 and 4207. Any of them can therefore throw out of Patch(), out of the Do(), and out of PatchAll.
original.FullDescription() on the final line is safe when original is null: GeneralExtensions.FullDescription(this MethodBase member) (line 7779) opens with if (member == null) return "null";. That is why the message sometimes reads Patching exception in method null, as recorded on HarmonyLogicableInheritedMethodTrap.
Which patch classes survive the abort¶
Whatever the walk had already reached. The order is AccessTools.GetTypesFromAssembly(assembly) (line 5007):
public static Type[] GetTypesFromAssembly(Assembly assembly)
{
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
ReflectionTypeLoadException ex2 = ex;
Logger.Log(Logger.LogChannel.Warn, () => $"AccessTools.GetTypesFromAssembly: assembly {assembly} => {ex2}");
return ex2.Types.Where((Type type) => type != null).ToArray();
}
}
Nothing between Assembly.GetTypes() and Patch() sorts or reorders, so the enumeration order is exactly Assembly.GetTypes() order: the assembly's metadata TypeDef table order.
It is not alphabetical by type name, and nothing in the .NET contract guarantees any order at all. Measured 2026-09-21 against the built SprayPaintPlus.dll (53 loadable types): the returned sequence is element-for-element identical to the TypeDef table dumped with System.Reflection.Metadata.MetadataReader, and it diverges from the alphabetically sorted sequence at index 2. The clearest counter-example sits near the top of that assembly's own list: ThingDestroyCleanupPatch is returned at position 5 and ClientDisconnectCleanupPatch at position 6, because both are declared in CleanupPatches.cs in that order. An alphabetical rule predicts the reverse. A second assembly (ClientDriver.dll, 54 types) behaves the same way.
What the order actually tracks, for a normal Roslyn build: source-file compile order, then declaration order within each file, with compiler-generated closure and state-machine types trailing at the end. The default **/*.cs glob makes that look alphabetical because the file names happen to sort that way, which is exactly the trap: it is alphabetical by FILE name, never by TYPE name, and explicit <Compile Include> items reorder it freely.
Determinism: fixed for a given binary, not stable across rebuilds. Five fresh processes reading the same DLL produced one identical ordering (single distinct hash, 5 of 5), because the order is a property baked into the file rather than computed at load. Adding, renaming or reordering a source file moves types in the table. So the same half of the mod does fail every launch, which is what makes this read like a feature-specific bug rather than an abort, and then the survivor set changes the next time someone touches an unrelated file. Treat it as an accident, never as a design.
Two corollaries:
- Everything below the
PatchAllcall in the sametryis also skipped. Manual registration that runs afterPatchAll(LogicType registration, IC10 constant patchers, recipe edits) never executes. Recorded independently on HarmonyLogicableInheritedMethodTrap. - A
ReflectionTypeLoadExceptionsilently shrinks the work set before the walk even starts.GetTypesFromAssemblycatches it, logs atWarn(visible under the default config) and returns only the types that loaded. Patch classes among the missing ones are never seen, with no exception and no abort. A real instance of that warning for theIC10Inspectorassembly is in this machine'sLogOutput.log.
Why the log looks empty¶
ReportException writes its detailed report to Logger.LogChannel.Debug. Under the shipped BepInEx 5.4.23.5 that text cannot reach any log file, at any configuration. Two independent gates sit in front of it and the second one has no switch.
Gate 1, the HarmonyX channel filter, closed by default. HarmonyLib.Tools.Logger.Log (line 9429) drops the message unless the channel is in ChannelFilter:
ChannelFilter (line 9417) has no initializer in 0Harmony.dll, so it starts at LogChannel.None. BepInEx assigns it from config in the HarmonyLogSource constructor (BepInEx.decompiled.cs line 2610), and the shipped default in $(StationeersPath)\BepInEx\config\BepInEx.cfg is:
[Harmony.Logger]
## Specifies which Harmony log channels to listen to.
# Default value: Warn, Error
LogChannels = Warn, Error
Debug is not in that set, so MessageReceived is never raised for it.
Gate 2, the BepInEx bridge, has no Debug entry at all. Even with LogChannels = All, HarmonyLogSource (BepInEx.decompiled.cs lines 2594 to 2623) drops the message:
private static readonly Dictionary<HarmonyLib.Tools.Logger.LogChannel, LogLevel> LevelMap = new Dictionary<HarmonyLib.Tools.Logger.LogChannel, LogLevel>
{
[HarmonyLib.Tools.Logger.LogChannel.Info] = LogLevel.Info,
[HarmonyLib.Tools.Logger.LogChannel.Warn] = LogLevel.Warning,
[HarmonyLib.Tools.Logger.LogChannel.Error] = LogLevel.Error,
[HarmonyLib.Tools.Logger.LogChannel.IL] = LogLevel.Debug
};
private void HandleHarmonyMessage(object sender, HarmonyLib.Tools.Logger.LogEventArgs e)
{
if (LevelMap.TryGetValue(e.LogChannel, out var value))
{
LogEvent?.Invoke(this, new LogEventArgs(e.Message, value, this));
}
}
There is no LogChannel.Debug key, so TryGetValue fails and the event is never forwarded to any BepInEx sink. The one message that names the failing patch class is structurally unreachable through BepInEx's log pipeline. That is the crux of why this failure is so confusing in the field: the mod half-loads, and the explanation is written to a channel with nowhere to land.
A third gate exists but is not the binding one: [Logging.Disk] LogLevels = Fatal, Error, Warning, Message, Info in the same config excludes LogLevel.Debug from LogOutput.log, so the message would be dropped even if LevelMap had the key.
What a reader will and will not see¶
Will see, with stock config:
- HarmonyX
WarnandErrorlines from other code paths. The most useful is inMethodPatcher(line 10024), which logs atErrorimmediately before rethrowing:
Logger.Log(Logger.LogChannel.Error, () => $"Failed to patch {original.FullDescription()}: {e}", debug);
That is the [Error : HarmonyX] Failed to patch void ... line quoted verbatim on HarmonyParameterNameBinding. It fires only for IL-compile failures, not for target-resolution failures such as Undefined target method or AmbiguousMatchException, which throw before any IL is emitted.
- Whatever the mod's own catch around PatchAll prints. SprayPaintPlus logs Log.LogFatal($"Failed to apply patches: {e}"), which puts the wrapped HarmonyException text into LogOutput.log at Fatal. A mod with no such catch prints nothing of its own.
- The absence of the mod's own patches-applied line, which is the most reliable signal of all. Grep with an open bracket (\[Fatal / \[Error), never \[Fatal\]: the real format is [Fatal :ModName].
Will not see, with stock config:
- The
### Patch class: <TypeName>report fromReportException. It is the only output that names the failing class directly. - Any statement that the remaining patch classes were skipped. HarmonyX never emits one, at any level.
Turning the report back on¶
Two steps, both required, because the gates are independent:
- Open the channel: set
LogChannels = Warn, Error, Debugunder[Harmony.Logger]in$(StationeersPath)\BepInEx\config\BepInEx.cfg. - Route around the BepInEx bridge, which still drops it. Either set
HarmonyLib.Tools.HarmonyFileLog.Enabled = truefrom mod code, or subscribe toHarmonyLib.Tools.Logger.MessageReceiveddirectly and forward to the mod's ownManualLogSource.
HarmonyFileLog (line 9291) writes every message that passes the channel filter to FileWriterPath, default "HarmonyLog.txt" resolved through Path.GetFullPath, so it lands in the game's working directory:
private static void ToggleDebug()
{
if (Enabled)
{
if (Writer == null)
{
Writer = new StreamWriter(File.Create(Path.GetFullPath(FileWriterPath)));
}
Logger.MessageReceived += OnMessage;
}
else
{
Logger.MessageReceived -= OnMessage;
}
}
ToggleDebug only subscribes a handler. It does not touch ChannelFilter, so step 1 is not optional. The same applies to the HARMONY_DEBUG environment variable: the Harmony constructor (line 2559) reads it and sets HarmonyFileLog.Enabled = true at line 2579, but leaves the channel filter to whatever BepInEx assigned.
Prepare() is the built-in guard, and only when it RETURNS false¶
Patch() runs the class-level [HarmonyPrepare] method first, before GetBulkMethods() and outside the try:
if (!RunMethod<HarmonyPrepare, bool>(defaultIfNotExisting: true, defaultIfFailing: false, null, new object[0]))
{
RunMethod<HarmonyCleanup>(ref exception, new object[0]);
ReportException(exception, null);
return new List<MethodInfo>();
}
Returning false is a clean skip. exception is still null at that point unless a [HarmonyCleanup] method with an Exception return type supplied one, so ReportException takes its early return and Patch() returns an EMPTY LIST rather than throwing. The walk continues to the next type. This is the whole reason Prepare() is the correct guard for a target that may not exist in the running build: it is the only supported way to say "skip this class" without taking the assembly down. See BestEffortIntegration for the pattern.
Throwing from Prepare() is not a skip. It aborts PatchAll exactly like a failing patch. The defaultIfFailing: false argument suggests otherwise and is misleading: it is written into a local that is never returned on the exception path. RunMethod<S, T> (line 4149):
private T RunMethod<S, T>(T defaultIfNotExisting, T defaultIfFailing, Func<T, string> failOnResult = null, params object[] parameters)
{
if (auxilaryMethods.TryGetValue(typeof(S), out var value))
{
object[] inputs = (parameters ?? new object[0]).Union(new object[1] { instance }).ToArray();
object[] parameters2 = AccessTools.ActualParameters(value, inputs);
if (value.ReturnType != typeof(void) && !typeof(T).IsAssignableFrom(value.ReturnType))
{
throw new Exception("Method " + value.FullDescription() + " has wrong return type (should be assignable to " + typeof(T).FullName + ")");
}
T val = defaultIfFailing;
try
{
if (value.ReturnType == typeof(void))
{
value.Invoke(null, parameters2);
val = defaultIfNotExisting;
}
else
{
val = (T)value.Invoke(null, parameters2);
}
if (failOnResult != null)
{
string text = failOnResult(val);
if (text != null)
{
throw new Exception("Method " + value.FullDescription() + " returned an unexpected result: " + text);
}
}
}
catch (Exception exception)
{
ReportException(exception, value);
}
return val;
}
return defaultIfNotExisting;
}
val = defaultIfFailing is assigned before the try, but the catch calls ReportException, which always throws for a non-null exception, so return val is unreachable whenever Prepare() threw. defaultIfFailing is effectively dead code in HarmonyX 2.9.0.0. The thrown object is HarmonyException("Patching exception in method " + <Prepare's FullDescription>, inner), and because the call went through MethodInfo.Invoke, the inner exception is a TargetInvocationException wrapping the real one.
Practical consequence: a Prepare() written as a guard can itself be the thing that kills PatchAll. Write it so it cannot throw:
[HarmonyPrepare]
static bool Prepare()
{
return AccessTools.Method(typeof(SomeGameType), "SomeMethod") != null;
}
AccessTools.Method returns null and logs a Warn rather than throwing, so this form degrades to a clean skip. A Prepare() that dereferences a possibly-null lookup, or calls into game state that is not ready yet, converts a survivable miss into a full abort.
The null argument trap in Prepare(MethodBase original)¶
Prepare is invoked TWICE per patch class, with different arguments, and the second form is easy to get wrong.
- Class level, from
Patch()line 3933:parametersisnew object[0]. - Per target method, from
ProcessPatchJobline 4038:parametersisnew object[1] { job.original }.
AccessTools.ActualParameters (line 5732) fills each declared parameter by assignability and falls back to AccessTools.GetDefaultValue(pType) (line 6524), which returns null for any reference type. On the class-level call the only available input is the Harmony instance, which is not assignable to MethodBase, so:
static bool Prepare(MethodBase original) receives original == null on its first call, then the real method on each subsequent call. An implementation that dereferences original without a null check throws a NullReferenceException on that first call, ReportException wraps and rethrows it, and the whole PatchAll dies. Always guard:
[HarmonyPrepare]
static bool Prepare(MethodBase original)
{
if (original == null) return true; // class-level call, decide per method below
return original.DeclaringType != null;
}
The fix: patch per class¶
Replace harmony.PatchAll() with an explicit loop over the same type list, calling the same processor, with the try that PatchAll does not have. This is the shipped implementation in Mods/SprayPaintPlus/SprayPaintPlus/Plugin.cs (ApplyPatchesPerClass, lines 177 to 212, called at line 144):
private static void ApplyPatchesPerClass(Harmony harmony)
{
int applied = 0;
var failed = new List<string>();
foreach (Type type in AccessTools.GetTypesFromAssembly(typeof(SprayPaintPlusPlugin).Assembly))
{
try
{
// Returns null for a type carrying no Harmony attributes, which
// is most of them.
var patched = harmony.CreateClassProcessor(type)?.Patch();
if (patched != null && patched.Count > 0)
applied++;
}
catch (Exception e)
{
failed.Add(type.Name);
Log.LogError(
$"Patch class {type.Name} could not be applied and was skipped: " +
$"{e.GetType().Name}: {e.Message}");
}
}
if (failed.Count == 0)
{
Log.LogInfo($"Patches applied successfully ({applied} patch classes).");
return;
}
Log.LogError(
$"{applied} patch class(es) applied, {failed.Count} skipped: {string.Join(", ", failed.ToArray())}. " +
"The features those classes provide are off for this session; everything else is " +
"running. This means the game changed shape and the mod needs a rebuild: please " +
"report it at github.com/SixFive7/StationeersPlus/issues with the errors above.");
}
Four properties worth copying:
AccessTools.GetTypesFromAssembly(...)is the same callPatchAllmakes, so the work set is identical and no patch class is missed by switching.- A null return is treated as "not a patch class", not as a failure.
Patch()returns null for every type without Harmony attributes, which is most of a mod assembly. Counting those as failures would report dozens of phantom skips. The?.onCreateClassProcessor(type)?.Patch()is belt and braces:CreateClassProcessoritself does not return null in this version, it returns a processor whosePatch()returns null. - Successes are counted, failures are named. A healthy log gets one line with a number. A broken one gets the class name, exception type and message per failure, on the
Errorlevel, which the default disk sink does record. That is the informationReportExceptionwould have given if anything could read its channel. - The final
Errorline states the consequence in the player's terms and asks for a report, because from the player's side the only visible evidence is a feature that does nothing.
Harmony.PatchAll(Type) (line 2667) is the single-type overload and does NOT behave differently on failure:
It throws to its caller the same way. Two differences are worth knowing: the blast radius is one type, so wrapping it in a try per call achieves the same isolation as the loop above, and allowUnannotatedType: true makes HarmonyMethod.Merge (line 3104) return a non-null HarmonyMethod even from an empty attribute list, so Patch() never takes the null early-return and will process a type with no Harmony attributes at all.
What HarmonyX does not offer¶
A reader will ask whether there is a switch for this. There is not, in HarmonyX 2.9.0.0 as shipped with the game.
- No isolation option on
Harmony. The full public surface of the class is 29 members (lines 2541 to 2884) and none of them takes an error policy:PatchAll(),PatchAll(Assembly),PatchAll(Type),CreateProcessor,CreateClassProcessorwith and withoutallowUnannotatedType,CreateReversePatcher,Patch,ReversePatch, theUnpatchfamily, the query family,VersionInfo, andCreateAndPatchAll(Type)/CreateAndPatchAll(Assembly), which are thin wrappers overPatchAllwith the identical behaviour. - No isolation attribute. The 20 attribute classes (lines 1663 to 2193) are
HarmonyAttribute,HarmonyPatch,HarmonyReversePatch,HarmonyPatchAll,HarmonyPriority,HarmonyBefore,HarmonyAfter,HarmonyDebug,HarmonyEmitIL,HarmonyWrapSafe,HarmonyPrepare,HarmonyCleanup,HarmonyTargetMethod,HarmonyTargetMethods,HarmonyPrefix,HarmonyPostfix,HarmonyTranspiler,HarmonyILManipulator,HarmonyFinalizer,HarmonyArgument. None of them affects what happens when patch application throws. [HarmonyWrapSafe](line 2138) is not it, despite the name. Its own summary reads "A Harmony attribute to automatically wrap the patch into try/catch. Exceptions are logged to Harmony log and eaten." It setsinfo.wrapTryCatch = true, which wraps the patch method's body at RUNTIME so a throwing prefix or postfix does not break the patched game method. It does nothing at patch-application time.[HarmonyPatchCategory]andPatchAllUncategorizeddo not exist in this build. Those are later Harmony features; there is no occurrence of either name in the shipped assembly. Even where they exist they select which classes to patch, not what happens when one throws.
So the per-class loop is not a workaround for a missing option. It is the only mechanism available.
Verification history¶
- 2026-09-21: page created.
Harmony.PatchAll(Assembly),CollectionExtensions.Do<T>,PatchClassProcessor.Patch,PatchClassProcessor.ReportException,RunMethod<S, T>,AccessTools.GetTypesFromAssembly,AccessTools.ActualParameters,AccessTools.GetDefaultValue,HarmonyFileLog,HarmonyLib.Tools.Loggerand the full public surface ofHarmonyread in the 0Harmony 2.9.0.0 decompile;HarmonyLogSourceand the Chainloader plugin-load catch read in the BepInEx 5.4.23.5 decompile; the two shipped config defaults read fromBepInEx.cfg. - 2026-09-21: conflict on "which patch classes survive a PatchAll abort". Previous claim, on
Patterns/HarmonyLogicableInheritedMethodTrap.mdline 68: "Alphabetically earlier patches typically survive; later ones don't." New finding: the order isAssembly.GetTypes()order, which is the metadata TypeDef table order and is not alphabetical. Fresh validator verdict: the new finding is correct. It measured the builtSprayPaintPlus.dll(53 loadable types), found the returned order element-for-element identical to the TypeDef table read withSystem.Reflection.Metadata.MetadataReader, found it diverging from alphabetical at index 2, and produced the in-assembly counter-exampleThingDestroyCleanupPatch(position 5) beforeClientDisconnectCleanupPatch(position 6), both declared inCleanupPatches.cs. It confirmed the ordering is identical across five fresh processes for one binary and thatAssembly.GetTypesdocuments no ordering guarantee. A second assembly (ClientDriver.dll) reproduced the divergence. Result: "Which patch classes survive the abort" on this page states metadata TypeDef order and explicitly rejects alphabetical; the alphabetical wording onHarmonyLogicableInheritedMethodTrap.mdline 68 is superseded and should be corrected on that page.
Open questions¶
- The type-ordering measurement was made on CoreCLR (PowerShell 7) reading the same DLLs, not on the game's Mono runtime. Stationeers ships only the embedded
MonoBleedingEdge\EmbedRuntime\mono-2.0-bdwgc.dllwith no standalone interpreter, so reproducing the probe under Mono requires driving the game. The decisive fact is runtime-independent (the TypeDef table order is fixed in the file and both runtimes enumerate it), but Mono was not measured directly. - Whether an exception thrown from a
BaseUnityPlugin.Awakepropagates out ofGameObject.AddComponenton this Unity build, or is caught and logged by Unity's script-invocation layer, was not settled. It decides which of two loaders reports the failure: the BepInEx Chainloader catch (BepInEx.decompiled.csline 6216,Logger.LogError($"Error loading [{value2}] : {ex.Message}")followed by aLogDebug(ex)that the default disk sink drops), or StationeersLaunchPad'sLoadStrategy.LoadFailed(StationeersLaunchPad.decompiled.csline 12800,mod.Logger.LogException(ex)), reached becauseLoadedMod.LoadEntrypoints(line 12683) callsentrypoint.Instantiate(gameObject)with notryof its own. Both catches exist and both log; which one fires, and therefore the exact text inLogOutput.log, is unverified. It does not affect the cause or the fix, only the log signature for a mod that has nocatchof its own.