Skip to content

IL2CPP and Managed Code Stripping

OxHeart works on IL2CPP platforms (iOS, Android, consoles, Windows/macOS/Linux IL2CPP — WebGL is not supported, see the Platform Support table in README.md), but because the engine relies on reflection for service creation and discovery, managed code stripping needs to be understood by every project that builds with IL2CPP.


What the Engine Does via Reflection

Mechanism Reflection used
Engine service creation ConfigurableServiceInitializer resolves constructors via reflection and invokes them
IServiceRegistrar auto-discovery All loaded assemblies are scanned; registrars are created with Activator.CreateInstance
ServiceRegistrationInfo custom services Interface and implementation types are resolved from string type names via Type.GetType

At Managed Stripping Level Medium or High, the Unity linker removes types and members it cannot prove are used. Reflection call paths are invisible to it, so without protection the linker can strip service constructors or whole classes, and the engine fails at runtime (services come back null, registrars never run).


What the Engine Already Protects (you do not need to do this)

OxHeart imports as a .unitypackage that lands at Assets/OxHeart/, and it ships a link.xml at Assets/OxHeart/link.xml, which preserves all four runtime assemblies:

<linker>
  <assembly fullname="OxHeart-Core" preserve="all" />
  <assembly fullname="OxHeart-Runtime" preserve="all" />
  <assembly fullname="OxHeart-Helpers" preserve="all" />
  <assembly fullname="OxHeart-Utility" preserve="all" />
</linker>

Unity honors any link.xml located anywhere under the project's Assets/ folder, so because OxHeart installs under Assets/, engine-side types are safe at any stripping level with no action on your part.

If you relocate OxHeart into Packages/. Unity does not scan a link.xml at a UPM package root — package-root link files are intentionally ignored, so this bundled file only works while OxHeart lives under Assets/ (its default). If you move the folder into your project's Packages/ directory, copy the four <assembly> entries above into a link.xml somewhere under your own Assets/, or the linker will strip the engine's reflection-only service constructors.


What Your Project Must Do

The shipped link.xml only covers OxHeart's assemblies. Game-side types that are reached only through reflection need protection in your project:

1. IServiceRegistrar implementations

Registrars are discovered by assembly scanning and instantiated via reflection — nothing references them statically. Add [Preserve]:

using UnityEngine.Scripting;

[Preserve]
public class GameServiceRegistrar : IServiceRegistrar
{
    public int Priority => 0;
    public void RegisterServices(IServiceLocator serviceLocator, ServiceConfiguration serviceConfig)
    {
        // ...
    }
}

[Preserve] on the class keeps the class, its constructors, and its methods.

2. Custom services registered via ServiceRegistrationInfo (inspector-driven)

These are resolved from assembly-qualified string type names, which the linker cannot see at all. Either add [Preserve] to both the interface and the implementation, or add them to a link.xml in your project's Assets/ folder:

<linker>
  <assembly fullname="MyGame">
    <type fullname="MyGame.Services.IWalletService" preserve="all" />
    <type fullname="MyGame.Services.WalletService" preserve="all" />
  </assembly>
</linker>

3. Types you serialize with the save system

JsonDataSerializer can construct objects via Activator.CreateInstance<T>(). Your PersistableData subclasses are normally referenced statically by your own code, but if a data class is only ever created through deserialization, give it [Preserve] too.

Auto-properties can lose data silently — read this before shipping

Stripping applies to members, not just whole types, and the save system reads your data by reflection. A public auto-property whose getter is never called anywhere in your own code — common for fields that are written during play and only ever read back by the loader — can be removed by the linker even though its class survives. Newtonsoft then never sees the member, so the key is simply absent from the save file: no exception, no warning, no quarantine. The data is gone and nothing tells you.

public class PlayerSave : PersistableData
{
    public override string DataId => "player";

    public int Coins { get; set; }          // ← at risk if your code never reads Coins
}

Verified on Unity 6000.0, IL2CPP, Managed Stripping High, with the data class in a game assembly (not preserved by OxHeart's link.xml):

What you write Survives High stripping
public int Coins; (public field)
[SerializeField] private int _coins; + accessors recommended
public int Coins { get; set; }, getter called somewhere in your code
public int Coins { get; set; }, getter only ever called by the serializer key dropped from the save

[Preserve] on the class is not enough for this case. It keeps the type and its constructors, and the object still serializes — but the auto-property is still stripped and its key still disappears. Two things do work:

  1. Use a [SerializeField] private backing field instead of an auto-property. The engine's contract resolver picks these up deliberately (including private fields declared on base classes), and they survive at any stripping level. This is the recommended shape for save data, and it matches how you already write serialized Unity fields.
  2. Root the type in your own link.xml — any link.xml under Assets/ is honored:
<linker>
  <assembly fullname="Assembly-CSharp">
    <type fullname="MyGame.Saves.PlayerSave" preserve="all" />
  </assembly>
</linker>

If you already ship auto-property save types and cannot change their shape, use option 2 — changing the member shape would alter the serialized key names (Coins_coins) and needs a SchemaVersion bump plus a migration step, exactly like any other field rename.


Verifying Your Build

  1. Set Project Settings → Player → Managed Stripping Level to the level you ship with (test High if unsure — it is the strictest).
  2. Make an IL2CPP build for your target platform.
  3. Run it and confirm:
  4. The engine boots (no ServiceNotFoundException for enabled services).
  5. Your IServiceRegistrar log lines appear (External registrar executed: ...).
  6. A save/load round-trip works if you use the save system.

If a service disappears only in IL2CPP builds (works in the Editor and Mono builds), stripping is almost always the cause — check the linker log in the build output for the type name, then apply one of the protections above.

When a service class survives stripping but its constructors do not, the engine logs an explicit error at initialization: Service type '…' has no public constructors — likely stripped by the managed linker. That message in the Player log is a direct signal to add or fix a link.xml entry for the named assembly.


See Also