Skip to content

Debug actions: Skip methods Log all patch cannot instrument - #981

Open
M-r-A wants to merge 1 commit into
rwmt:devfrom
RimWorld-mods-patches:pr/log-all-patch-skip-uninstrumentable
Open

Debug actions: Skip methods Log all patch cannot instrument#981
M-r-A wants to merge 1 commit into
rwmt:devfrom
RimWorld-mods-patches:pr/log-all-patch-skip-uninstrumentable

Conversation

@M-r-A

@M-r-A M-r-A commented Aug 13, 2026

Copy link
Copy Markdown

Problem

Multiplayer -> Log all patch, and its main-menu variant Log all patch entry,
attach a call logger to every method the multiplayer assembly declares. The
action throws on the first extern method it reaches, the loop aborts, and
every method not yet reached is left uninstrumented — while the action still
emits a large, plausible-looking trace with no sign that coverage is partial.

Exception filling window for LudeonTK.Dialog_Debug: System.InvalidProgramException:
Invalid IL code in (wrapper dynamic-method)
MonoMod.Utils.DynamicMethodDefinition:Multiplayer.Client.ArbiterWindowFix.SetParent_Patch1 (int,int): IL_0015: ret

  at HarmonyLib.PatchFunctions.UpdateWrapper (System.Reflection.MethodBase original, HarmonyLib.PatchInfo patchInfo)
  at HarmonyLib.PatchProcessor.Patch ()
  at HarmonyLib.Harmony.Patch (System.Reflection.MethodBase original, ...)
  at Multiplayer.Client.MpDebugActions.LogAllPatch ()
  at Multiplayer.Client.MpDebugActions.LogAllPatchEntry ()

A reader reasonably concludes that any method absent from the trace was never
called, when it may simply never have been instrumented. Which methods survive
depends on the order DefinedTypes happens to return, so the coverage boundary
is arbitrary and can move between builds.

Measured on the same main menu, before and after:

Before After
Lines 36,766 607,583
Distinct MP types instrumented 21 55
InvalidProgramException 1 0
Types instrumented before, not after 0

Types that were previously never reached include Desyncs.DeferredStackTracing,
ModCompatibilityManager, Patches.MarkLongEvents and Patches.NewLongEvent
the desync and long-event machinery this action is most often used to trace.

Root cause

Harmony patches by emitting a wrapper around the original's body. An extern
method has no body, so the wrapper is malformed and the runtime rejects it.

The filter in LogAllPatch excludes generics, property getters, abstracts and
delegate members, but not externs, and the Patch call is not guarded:

foreach (var method in Assembly.GetExecutingAssembly().DefinedTypes.SelectMany(t => t.DeclaredMethods))
    if (method.Name != "MultiplayerMethodCallLogger" &&
        !method.Name.StartsWith("get_") &&
        !method.IsGenericMethod &&
        method.DeclaringType?.IsGenericType is false &&
        method.DeclaringType?.BaseType != typeof(MulticastDelegate) &&
        !method.IsAbstract)
        Multiplayer.harmony.Patch(method, prefix: ...);   // no try/catch

The assembly declares two extern methods — ArbiterWindowFix's
DllImport("User32") declarations, used to hide the headless arbiter's window.
This does not depend on the host operating system: the loop reflects over them
rather than calling them, and a P/Invoke declaration is unpatchable everywhere.

Fix

Exclude extern methods (MethodAttributes.PinvokeImpl) and methods still
carrying open generic parameters, alongside the exclusions already present.

Guard the Patch call as well. The filter cannot anticipate every reason Harmony
may refuse a method, and this action is wanted precisely when something is
already wrong, so it should lose one entry rather than the whole trace. The count
is reported once at the end, and the first refusal in full, so a systematic
problem stays visible without burying the trace under one line per method.

The decision moves to Multiplayer.Common.InstrumentationTargets so it can be
covered by a test — the test project cannot reference the client, which needs the
game's assemblies.

Testing

  • Builds clean in Debug; 163/163 tests pass.
  • New InstrumentationTargetsTest uses a stand-in extern declaration that is
    never called, so it needs no library and behaves the same on every platform.
  • Manual, at the main menu with dev mode on: Log all patch entry completes with
    no exception, and produces the coverage in the table above.

Notes

Log all patch attaches a call logger to every method the assembly declares.
Harmony patches by emitting a wrapper around the original's body, and an extern
method has none, so the wrapper is malformed and the runtime rejects it with
InvalidProgramException. The assembly declares two, both P/Invokes used to hide
the headless arbiter's window.

Reaching the first threw out of the loop, so every method not yet reached was
left uninstrumented while the action still emitted a large, plausible-looking
trace with no sign that coverage was partial. Which methods survived depended on
the order DefinedTypes happened to return, so the boundary moved between builds.

Exclude externs and methods still carrying open generic parameters, and guard
the Patch call. The filter cannot anticipate every reason Harmony may refuse a
method, and this action is wanted precisely when something is already wrong, so
it should lose one entry rather than the whole trace. The count is reported once
at the end, and the first refusal in full.

The decision moves to Common so it can be covered by a test; the test project
cannot reference the client, which needs the game's assemblies.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant