Async Harmony trap¶
Patching an async method with a Harmony Postfix does not intercept the method's work. The C# compiler rewrites async methods into a state machine; the outer method body Harmony sees is the kick-off stub that returns the awaitable immediately, before any of the asynchronous work has run. A Postfix on that stub fires when the awaitable is returned, not when the work completes, and has no access to any results produced inside the state machine. If the async method also mutates pooled state or writes UI widgets in-flight rather than returning a collection, there is no __result for a Postfix to filter either.
Problem¶
An async Task or async UniTask method compiles to:
- A stub method with the original signature that constructs a state machine, calls
MoveNext()once, and returns the awaitable. - A generated state-machine type whose
MoveNext()contains the real body split across continuations at eachawait.
Harmony's attribute path ([HarmonyPatch(typeof(T), "Name")]) resolves and patches the stub. The Postfix therefore:
- Fires when the awaitable is handed back, not when the async work completes.
- Cannot read values produced inside the state machine, because those are local to
MoveNextframes and no__resultexposes them. - Cannot block or alter the work that runs after the first
await.
This is not a Harmony bug: Harmony is patching exactly the method the attribute names. The trap is that the method the developer thinks they are patching (the full async body) is not the method the compiler actually exposes.
The trap compounds when the async method mutates pooled or UI state in-flight rather than returning a value. There is no ref T __result to edit; the side effects have already landed by the time any observer could see them.
Solution / recipe¶
Patch a synchronous method that the async body calls, not the async method itself. The compiler's state-machine rewrite only affects the outer method; any synchronous method invoked from inside MoveNext is a normal method. Harmony patches on those synchronous methods run as expected for every call, including calls made from inside async continuations.
Recipe:
- Read the async method body. Identify the synchronous helper methods it calls per unit of work (per row, per page, per item).
- Pick the helper whose signature exposes the value to filter or the side effect to intercept. A helper that returns
booland is called once per candidate is ideal: a Postfix withref bool __resultcan short-circuit downstream work by returningfalse. - Patch that helper. The Prefix or Postfix runs synchronously inside the async method's continuation, with full access to
__instanceand arguments.
Verify the helper has few enough callers that patching it does not affect unrelated code paths. A single-caller helper is ideal; when the helper is shared, the Prefix must branch on call site (type of __instance, values of arguments) before acting.
Concrete example¶
Stationpedia.DoSearch(string hash, string pattern, CancellationTokenSource) is private async UniTask and mutates a pooled _SPDASearchInserts list of UI widgets in-flight. It has no return list and no ref parameter a Postfix could filter. A Postfix on DoSearch therefore cannot remove results.
StationpediaPage.IsRegexMatch(string hash, string pattern) is synchronous, called once per page from inside DoSearch, returns bool, and has exactly one caller in the game. A Postfix with ref bool __result runs inside the async body's MoveNext continuation for every candidate page; returning false short-circuits the MakePage call downstream of the branch.
See ../GameSystems/StationpediaSearch.md for the full trigger chain and why IsRegexMatch is the correct filter target for Stationpedia search.
UniTask's builder runs the first step synchronously¶
The "calls MoveNext() once" in step 1 above holds for async UniTask<T> as shipped with the game. AsyncUniTaskMethodBuilder<T> in rocketstation_Data/Managed/UniTask.dll, read with ilspycmd -t 'Cysharp.Threading.Tasks.CompilerServices.AsyncUniTaskMethodBuilder1'` at game 0.2.6428.27798:
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[DebuggerHidden]
public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
{
stateMachine.MoveNext();
}
[DebuggerHidden]
public void SetStateMachine(IAsyncStateMachine stateMachine)
{
}
and each await that does not complete synchronously parks the machine in UniTask's own runner rather than through the state machine's SetStateMachine:
public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine
{
if (runnerPromise == null)
{
AsyncUniTask<TStateMachine, T>.SetStateMachine(ref stateMachine, ref runnerPromise);
}
Action moveNext = runnerPromise.MoveNext;
awaiter.UnsafeOnCompleted(moveNext);
}
Two consequences for a mod that wraps a vanilla UniTask in an async method of its own (the Spray Paint Plus glow side-car does, from a postfix on SaveHelper.Save):
- The state machine's first
MoveNext, and with it the compile of the wholeMoveNext, happens synchronously inside the call to the async method, in the caller's frame. Atry/catchin the caller therefore sees the async body failing to compile against a changed game (StaleModReferenceJitCrash); later steps run fromrunnerPromise.MoveNexton code that has already compiled. - The generated state machine's own
IAsyncStateMachine.SetStateMachineis never reached through this builder: the builder'sSetStateMachineis empty and the await path usesAsyncUniTask<TStateMachine, T>.SetStateMachineinstead.
Cited verifications¶
- F0219g (Plans/StationpediaPlus/PLAN.md:910-924): primary statement of the trap in the context of
Stationpedia.DoSearchas anasync UniTaskat game.cs:230584, and the recipe of patchingStationpediaPage.IsRegexMatch(single-caller,bool-returning, synchronous) instead.
Verification history¶
- 2026-04-20: page created by lifting the generalized trap from GameSystems/StationpediaSearch.md (F0219g) per Phase 6 Pass B recommendation.
- 2026-09-21: added "UniTask's builder runs the first step synchronously" at game 0.2.6428.27798, excerpts verbatim from
ilspycmd -tofAsyncUniTaskMethodBuilder1in the game'sUniTask.dll`, read during the Spray Paint Plus v1.12.0 entry-point hardening. Additive: it confirms step 1 of "Problem" for UniTask specifically and contradicts nothing; the older sections keep their 0.2.6228.27061 stamps.
Open questions¶
None at creation.