Skip to content

Compatibility, IL2CPP Patterns, and Troubleshooting

SCP: Secret Laboratory is an IL2CPP build of Unity. MelonLoader’s IL2CPP path papers over most of the cross-language friction, but a handful of patterns are non-obvious and bite every first-time Anomaly mod author. This chapter is a catalogue of the ones you’ll hit.

StartCoroutine(...) is a member of MonoBehaviour in regular Unity. In IL2CPP it still exists, but calling it on an IL2CPP-registered MonoBehaviour from managed code is unreliable. Prefer one of:

// MelonLoader's static entry point
var handle = MelonLoader.MelonCoroutines.Start(MyCoroutine());
MelonLoader.MelonCoroutines.Stop(handle);
// Or Anomaly's thin wrapper
Anomaly.Client.Api.Features.Coroutines.Start(MyCoroutine());
Anomaly.Client.Api.Features.Coroutines.CallDelayed(0.5f, () => DoThing());

If you only need time-based logic — “after 3 seconds, run X” or “every second, tick Y” — skip coroutines entirely and use Scheduler.In / Scheduler.Every. Coroutines are for patterns that yield on Unity frame events (WaitForEndOfFrame, WaitForFixedUpdate, etc.).

Unity destroys GameObject instances when a scene unloads unless you mark them:

UnityEngine.Object.DontDestroyOnLoad(myGameObject);

Apply this on any object whose lifetime spans rounds (a HUD root, a persistent state manager). Forgetting it leads to a fun class of bug where everything works until the first scene transition, then your mod’s state silently disappears.

When you write your own MonoBehaviour subclass for use in the IL2CPP game, you must register the type so IL2CPP knows it exists:

using Il2CppInterop.Runtime.InteropTypes;
using Il2CppInterop.Runtime.Injection;
using MelonLoader;
using UnityEngine;
[RegisterTypeInIl2Cpp]
public class MyHudController : MonoBehaviour
{
// IL2CPP requires both constructors. They do nothing; that's fine.
public MyHudController(IntPtr ptr) : base(ptr) { }
public MyHudController() : base(ClassInjector.DerivedConstructorPointer<MyHudController>())
{
ClassInjector.DerivedConstructorBody(this);
}
private void Awake() { /* your init */ }
private void Update() { /* per-frame work */ }
}

The two-constructor pattern is mandatory. Without it, AddComponent<MyHudController> either fails outright or creates a half-initialized object.

Managed collections on registered MonoBehaviours

Section titled “Managed collections on registered MonoBehaviours”

IL2CPP rewrites methods of registered types so they can be called from the IL2CPP runtime. If one of those methods touches a managed Dictionary<K,V>, List<T>, or Queue<T>, the rewriter can’t represent the generic instantiation correctly.

The fix is to mark the method as “skip IL2CPP rewrite” — managed code still calls it normally, but IL2CPP doesn’t try to expose it:

using Il2CppInterop.Runtime.Attributes;
using MelonLoader;
[RegisterTypeInIl2Cpp]
public class MyStateTracker : MonoBehaviour
{
public MyStateTracker(IntPtr ptr) : base(ptr) { }
private readonly Dictionary<int, string> _cache = new();
[HideFromIl2Cpp]
public void AddEntry(int id, string value)
{
_cache[id] = value;
}
}

Rule of thumb: any MonoBehaviour method (on a registered class) that uses a managed generic collection gets [HideFromIl2Cpp]. Unity won’t call it (IL2CPP’s Unity-facing surface doesn’t know about it), but your own C# code calls it fine.

SCP:SL and MelonLoader expose events via IL2CPP types. Subscribing a managed delegate directly to such an event fails at runtime — you need to convert:

using Il2CppInterop.Runtime.InteropTypes.Arrays;
using Il2CppInterop.Runtime;
using Il2CppInventorySystem.Items.Firearms.ShotEvents;
ShotEventManager.OnShot += DelegateSupport.ConvertDelegate<ShotEventManager.Shot>(
new Action<ShotEvent>(OnShot));
void OnShot(ShotEvent evt) { /* ... */ }

If you hit a “cannot convert System.Action to Il2Cpp…” compile error, you’re missing a DelegateSupport.ConvertDelegate<T> step. Prefer public MelonLoader and Il2CppInterop examples for event-binding patterns.

Forgetting [assembly: MelonAdditionalDependencies("Anomaly.Client")]

Section titled “Forgetting [assembly: MelonAdditionalDependencies("Anomaly.Client")]”

MelonLoader calls OnInitializeMelon in dependency-declared order. If your mod calls any Anomaly API during its own OnInitializeMelon and hasn’t declared the dependency, load order is undefined — on some runs it works, on others it hits a NullReferenceException deep inside an Anomaly registry. Always declare the dependency.

These events do not replay to late subscribers. Connected reports a transport connection transition; Disconnected can also report failed handshake/retry attempts that never reached Ready. CanvasReady dispatches for each new main-menu canvas start, and Ready can dispatch again after a single-scene transition. Subscribe during OnInitializeMelon, unregister during disposal, and make cleanup safe to repeat. Do not count disconnect notifications as one per completed gameplay session.

Treating Ready as a local-player guarantee

Section titled “Treating Ready as a local-player guarantee”

ClientEvents.Ready comes from the network manager’s OnClientSceneChanged callback, before all gameplay prerequisites necessarily exist. Client.LocalPlayer can still be null, world collections may be incomplete, and Client.EnrollmentId can remain null until the server publishes it. Null-check the local player and any related state at the point of use, including inside Ready handlers, and defer work until its prerequisites are available. Client.IsClientReady records the lifecycle signal; it is not a combined gameplay/authentication readiness check.

Player.Health and Player.Stats.Health are read-only observations. Writing through raw Unity or IL2CPP objects does not grant server authority and replicated updates can overwrite local changes. If you need authoritative state, see Server Companion Quickstart.

Event subscriptions persist across disconnect, but session state must be reset explicitly. A failed handshake or retry can disconnect before any gameplay session or Ready event. Retain every public Scheduler handle, and cancel and clear it idempotently in disconnect and unload paths:

ClientEvents.Disconnected += _ =>
{
_sessionTask?.Cancel();
_sessionTask = null;
_myCache.Clear();
};

Global scheduler reset, pause, resume, and owner-wide cancellation are internal. Managed modules get owner-scoped cancellation through IModContext; peer Melon mods own their handles.

When your mod crashes, stack traces often span IL2CPP and managed frames. Rules:

  • Managed frames start with your namespace. They’re readable normally.
  • IL2CPP frames show as Il2CppSystem.* or Il2CppInterop.Runtime.*. Skim past them — they’re the glue, not the bug.
  • Unity engine frames (UnityEngine.Object.Instantiate, etc.) are usually the interop boundary; the frame immediately above is often where your bug lives.

For an IL2CPP crash with no managed frames, check type registration, delegate conversion, destroyed-object access, and whether the generated references match the running game. The absence of managed frames does not identify a single cause.

Prefer public MelonLoader, Il2CppInterop, and Anomaly API examples. If a needed pattern is missing from these docs, open a documentation issue with the use case.

End-to-End Tutorial — build a small Client + Server + Shared feature from scratch.