Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 
 
 
 
 
 
 

Repository files navigation

il2cpp-wasm-teardown

How much of a Unity game's code survives IL2CPP compilation? Measured: 0.00%.

The same measurement on two Mono-backend Unity games returns 93-95%. The logic is not deleted -- 99.90% of it is recoverable as lifted intermediate representation, just not as anything a compiler will accept.

This repo is two things:

  1. A measurement toolkit that works on any Unity game (tools/, ~32 KB, no third-party dependencies). Point it at a decompiled project and get a number. Nothing is hardcoded to a specific title.
  2. A worked case study -- porting MiSide (Aihasto) to WebAssembly, to find out what a 0.00% survival rate actually costs you in practice.

Part 1 reproduces in minutes against any game you own. Part 2 does not reproduce without a 4 GB install and tens of GB of intermediates -- read it as field notes, not as a build script.

Note

Detailed Guides & Tutorials: Step-by-step ISIL disassembly tutorials, x86_64/ARM64 register decoding, UnityEvent rewiring guides, repair recipes, and census methodology notes are available on the Project Wiki.

No game assets, decompiled source, or builds are distributed here -- see Legal.


Start here

If you are deciding whether to ship with IL2CPP, or want to know what you could recover from your own build:

# decompile your own build first:
#   IL2CPP -> AssetRipper or Cpp2IL
#   Mono   -> ilspycmd -p -o <outdir> Assembly-CSharp.dll
python tools/measure-bodies.py <decompiled-scripts-dir>
python tools/measure-owned.py  <decompiled-scripts-dir>

measure-bodies.py classifies every recovered method as empty, a trivial stub, or a real body. measure-owned.py splits your own code from third-party middleware, which matters more than it sounds -- see the control group below.

Both tools exit with an error on a bad or empty path instead of printing a result. 0.00% live is also the correct answer for a genuine IL2CPP export, so a typo must never be able to imitate the finding.

If you came here for the Unity WebGL and decompiled-project repair tooling, skip to Repairing a decompiled project.


The finding

Unity's IL2CPP backend converts C# to C++ and compiles it natively. Decompiling the result recovers class shapes, field names, and every [SerializeField] value — but not method bodies.

I wanted a number for "not method bodies," so I wrote tools/measure-bodies.py to classify every recovered method as empty, a trivial stub (return null / return default), or a real body.

Measured against the untouched decompiler output, before any hand-written code was added back:

files=1225  methods=4899  empty=3408  trivialStub=1491  realBody=0  live=0.00%

game-owned : 781 files  3309 methods  live=0   0.00%
vendor     : 444 files  1590 methods  live=0   0.00%

Not one of 4,899 recovered methods kept a real body. Not the game code, and not the Steamworks bindings either — IL2CPP does not care that a library is open source, it compiles everything to native code the same way.

The single largest method body in the entire export is 7 statements long, and all 7 are out-parameter stubs:

public static bool GetFavoriteGame(int iGame, out AppId_t pnAppID, out uint pnIP, ...)
{
    pnAppID = default(AppId_t);
    pnIP = default(uint);
    pnConnPort = default(ushort);
    pnQueryPort = default(ushort);
    punFlags = default(uint);
    return false;
}

That is the ceiling. There is no logic anywhere in the managed output.

Measure the pristine export, not your working copy. Once you start implementing replacements the percentage climbs, and it is measuring you rather than the game.

Four corrections worth reading

This number was wrong three times, and every time it was too generous. A fourth bug was in the owned/vendor split rather than the survival number. All four were found by opening the decompiled files and reading them, not by trusting the script:

  • 8.22% — measured a working copy that already contained hand-written replacements, so it was partly measuring me.
  • 7.70% — right scope, wrong classifier. The stub test only matched single-statement bodies. A stripped method with out parameters must still assign each one, so MirrorRef.GetAutoResolution decompiles to width = default(int); height = default(int); — two statements, counted as real code.
  • 3.04% — same class of bug, subtler values. The stub test recognised 0 and null but not (IntPtr)0, 0uL, 0.0, or '\0', so 149 Steamworks P/Invoke stubs read as live code. That is where the "Steamworks survives intact" claim came from, and it was wrong.
  • The vendor split matched raw string prefixes. measure-owned.py tested segment.startswith(entry), so the entry gog (GOG Galaxy) also claimed GogoGaga.OptimizedRopesAndCables, and properties (for Unity.Properties) claimed every assembly's own Properties/AssemblyInfo.cs. Matching is now on dot-separated namespace components, so unity matches Unity.Entities but never a game namespace that merely starts with those letters. This moved one MiSide file from vendor to game-owned and left every published percentage unchanged — but on a game with namespaces like Bestiary or Shapeshifter it would have quietly shrunk the game-owned bucket, which is the one bucket the argument depends on.

If the tool reports anything above 0.00% on a pure IL2CPP export, check the stub patterns before believing it.

The inverse is the interesting half: everything serialized survived. Transforms, materials, lighting, prefab hierarchies, physics settings, and crucially the UnityEvent graphs authored in the Inspector. Every interactive object still knew exactly which methods it was supposed to call — it just had no methods to call.

So the port strategy was: keep 100% of the data, rewrite the behaviour by hand.


Can that 0% be raised?

Short answer: yes, but not into C#.

measure-bodies.py counts compilable C# bodies. That is the right metric for "can I rebuild this project", and by that metric IL2CPP output is 0.00%. It is the wrong metric for "is the logic gone", so I tested every extraction path Cpp2IL offers:

Path Output Live C#
AssetRipper signatures + all [SerializeField] values 0.00%
Cpp2IL dll_il_recovery 4,905 methods, every body throw null; 0.00%
Cpp2IL + call/native analyzers 4,899 methods, every body throw null; 0.00%
Cpp2IL isil 994 files, 27.7 MB, 596,947 instructions n/a

ISIL is the one that works. It emits Cpp2IL's lifted intermediate representation instead of C#, so the C# metric stays at 0.00% — but:

[ISIL] files=994 methods=7256 withLiftedLogic=7249 empty=7
[ISIL] instructions=596,947  ->  99.90% of methods

Call targets resolve to real names, so the output is readable:

020 Compare [rbx+153], 0   ; firstStart
024 Call ButtonMouseClick.Start
025 Compare [rbx+32], 0    ; interactable
029 Compare [rbx+160], 0   ; lockButton
031 Move rcx, [rbx+64]     ; eventEnter
035 Call UnityEvent.Invoke
036 Move [rbx+154], 1      ; changeNow

Those numeric offsets are the reason both halves are needed: AssetRipper recovers field names and declaration order, ISIL recovers the logic that uses them, and reading them together turns [rbx+64] into eventEnter.

I checked this against ButtonMouseClick.PointerEnter() — a method I had already reimplemented by hand, guessing from field names and UnityEvent wiring. The guess was right about the gate (interactable && !lockButton, instructions 025/029) and wrong about a lazy-init guard (if (!firstStart) Start();, instructions 020-024) that I had no way to infer.

So the honest framing is that IL2CPP is a translation tax, not a wall. Nothing is deleted; it moves from a form you can compile to a form you have to read. A port is still hand-work — but transcription, not guesswork.

For a full step-by-step walkthrough decoding ISIL instructions on both x86_64 and ARM64 registers, see Tutorial: Reading and Reconstructing Raw ISIL on the Wiki.


Control group: is this IL2CPP, or just decompilers being bad?

One game proves nothing. If measure-bodies.py reports 0% on everything, the tool is broken. So I ran the identical script over four games across both Unity scripting backends.

Mono games were decompiled with ilspycmd; IL2CPP games with AssetRipper (MiSide) and Cpp2IL (Data Center).

Game Unity Backend Files Methods Live Live %
House Flipper 2019.4 Mono 4,528 231,243 219,675 95.00%
Car Mechanic Simulator 2018 2017.4 Mono 114 1,040 967 92.98%
MiSide 2021.3 IL2CPP 1,225 4,899 0 0.00%
Data Center 6000.4 IL2CPP 282 2,549 0 0.00%

Game-owned code only, third-party middleware excluded:

Game Backend Owned methods Live Live %
House Flipper Mono 218,860 208,216 95.14%
Car Mechanic Simulator 2018 Mono 130 108 83.08%
MiSide IL2CPP 3,309 0 0.00%
Data Center IL2CPP 2,366 0 0.00%

Note the second table is not just a smaller version of the first. On Car Mechanic Simulator 2018 the studio's own code measures 83.08% live while its bundled middleware measures 94.40% -- the game's own code is the less recoverable half, even on Mono. Third-party libraries are written to be redistributed and tend to survive tooling better, so a whole-project average quietly flatters the part you actually care about. That is the reason measure-owned.py exists, and it is the number to look at for your own title.

The vendor list is a plain tuple at the top of measure-owned.py. Anything it does not recognise is counted as game-owned, so an unlisted library shows up as your code rather than silently disappearing -- the error direction is conservative. Add your own middleware namespaces before trusting the split.

The split is total. Not "IL2CPP is harder to decompile" — the managed method bodies are not present in the shipped files at all. Mono ships IL that is essentially source code with the names left on; IL2CPP ships native code and a metadata table describing the shapes that used to hold logic.

Data Center matters because it is Unity 6. The behaviour is not a 2021-era quirk that newer versions fixed.

Cpp2IL was run twice on Data Center, once in --use-dummy-dlls mode and once attempting body recovery. Both produced 0.00% live. The recovery attempt is what the second row of raw counts reflects: 2,549 bodies emitted, every one of them throw null;, plus fields annotated Not supported: data(...).

Reproduce any row:

python tools/measure-bodies.py <decompiled-scripts-dir>
python tools/measure-owned.py  <decompiled-scripts-dir>

What the scenes actually contain

Measured in-editor after import, not estimated:

Scene GameObjects Renderers Lights Materials Missing scripts
Scene 1 - RealRoom 1,882 370 17 143 10
Scene 2 - InGame 1,493 562 10 229 9

The menu scene carries 108 ButtonMouseClick components, 84 of them with wired event handlers and 20 with scene-load calls — every string argument intact.


Pipeline

  1. Decompile the shipped IL2CPP build into a Unity 2021.3.35f1 project (AssetRipper). Original install is treated as read-only throughout.
  2. Repair script references. The decompiler emits one GUID per assembly; Unity expects one per script file. Result: ~1,880 broken m_Script pointers across the scenes used here (966 in the menu group, 917 in the two game scenes). tools/remap-ugui.py rewrites them against the real uGUI GUIDs, idempotently, and reports anything it can't map.
  3. Repair native plugins. Several DLLs came back with IMAGE_FILE_DLL cleared and the PE subsystem set to console (3) instead of GUI (2), so Unity refused to load them. tools/fix-plugin-pe.ps1 patches the headers back.
  4. Import a measured closure. Rather than importing 4 GB, walk the actual dependency graph of the target scenes and copy only what they reference.
  5. Reimplement the minimum behaviour needed for the slice to function.
  6. Build to WebGL (IL2CPP → Emscripten → WebAssembly) and serve with the headers Brotli-compressed Unity builds require.

The bugs worth reading about

Most of the work was finding out what was broken, not fixing it. Four examples, each one a wrong assumption I had to correct with a measurement.

The menu was invisible because two methods were empty. Out of 4,903 stripped methods, exactly two mattered: ButtonMouseClick's pointer handlers (so no click ever reached eventClick) and Menu.Start() (which was supposed to activate the panel holding 107 of the 108 buttons — it ships with activeSelf = false). Implementing those two made the entire menu work, including every original event chain.

The loading screen was never animated. I assumed I'd broken it by excluding .anim files from the import. Then I counted: zero Animators, zero legacy Animation components in that scene. It was always script-driven. The exclusion was innocent. I recovered the bar's real geometry from the surviving RectTransform values (a 500×10 track with a pivot at the left edge) and drove it from AsyncOperation.progress.

Scene data is LZ4-compressed inside the .data file. I searched the shipped build for a scene name, got nothing, and nearly concluded the scene hadn't been included. Dumping the surrounding bytes showed LZ4 back-references. Literal grep against a Unity .data file produces false negatives — a flawed test, not a defect.

Managed names live in the .data file, not the .wasm. Verifying the final build, I searched the decompressed .wasm for my own method names and got MISSING for all of them. That test was also wrong: IL2CPP puts type and method names in global-metadata.dat, which ships inside .data. The .wasm holds only compiled machine code. Checking the right file returned all 12 symbols.


What was rebuilt by hand

Written from scratch against the surviving serialized values — not recovered:

Component What it does
ButtonMouseClick Pointer down/up/enter/exit → invokes the original eventClick
Menu.Start() Activates the menu panel and releases the cursor
SceneLoading Progress bar, spinner, animated ellipsis, scene handoff
PlayerMove First-person movement, mouse look, raycast interaction
ObjectInteractive Aim highlight + Click() → fires the original event chain
ObjectInteractive_CaseInfo Billboarded interaction prompt with fade

The player controller uses the game's own values rather than invented ones — speedPlayer = 0.65, Rigidbody mass 5 with rotation frozen, capsule height 1.85, and the real HeadPlayer bone as the camera mount.

Because the UnityEvent graphs survived, clicking an object fires its original authored chainSetActive, Animator.SetTrigger, ObjectAnimationPlayer.AnimationPlay, and so on. One door in Scene 2 carries a 23-call chain, fully intact. That behaviour is recovered, not reimplemented.

One trap worth noting: the outline package used for highlighting is itself stripped, and its OutlineParameters property returns null. Reading it unguarded throws a NullReferenceException every frame.


What does not work

Stated plainly, because the title says duct tape and it means it:

  • No dialogue. Text is addressed by integer index into a string table that didn't survive as an editable asset — no StreamingAssets, zero TextAssets in the project. The function that formats every line is return null.
  • No minigames. ~200 Location* classes kept their Inspector data and lost their rules. Rebuilding them is rewriting the game, not porting it.
  • Little animation. Animator controllers imported; the .anim clips were excluded from this pass, so triggers fire into nothing.
  • Little audio. The audio helper methods are stripped like everything else.

This runs, and you can walk around and interact. It is not the game.


Reproducing the measurement

python tools/measure-bodies.py <decompiled-scripts-dir>
python tools/measure-owned.py  <decompiled-scripts-dir>

measure-bodies.py prints the empty / stub / live breakdown and lists which files retained code. measure-owned.py adds the game-owned vs vendor split.

Run this against your own decompiled project before planning any port -- the percentage tells you whether you are doing a port or a rewrite, and those are very different projects.

Three things worth knowing before you trust a number it gives you:

Point it at the pristine export, not a project you have started editing. I got this wrong once and published a figure that was partly counting my own replacement code.

A high number can mean your extractor is lying. Cpp2IL's IL-recovery mode emits a body for every method, but each one is throw null;. Before these tools classified that as a stub, a fully stripped assembly scored 100.00% live. Any tool that reports near-total survival on an IL2CPP build deserves a look at the actual decompiled text.

It is a heuristic, not a compiler. Method detection is regex-based, so it is a sound estimator rather than ground truth. It is deliberately biased toward counting things as live: anything it cannot confidently classify as a stub is reported as real code, so the survival rate it prints is an upper bound.

Beyond Unity, the same census answers a general question -- did my extraction actually recover logic, or does it only look like it did? -- for any tool that emits C#.

Repairing a decompiled project

Useful to anyone who has run AssetRipper on a Unity game and found the project opens broken. Both of these fix problems that are not specific to this port:

python tools/remap-ugui.py <assets-dir>
powershell -File tools/fix-plugin-pe.ps1 -ProjectPath <unity-project>

remap-ugui.py fixes the most common one. AssetRipper emits one synthetic GUID per assembly, but Unity expects one per script file, so every uGUI component in every scene points at nothing -- Text, Image, Button and the rest all deserialize as missing scripts. It was ~1,880 broken references across the scenes used here. The script rewrites them to the real uGUI GUIDs and reports anything it cannot map instead of guessing.

fix-plugin-pe.ps1 handles native plugins that come back with IMAGE_FILE_DLL cleared and the PE subsystem set to console (3) instead of GUI (2), which makes Unity refuse to load them.

Both rewrite files in place, back up everything they touch, and select only files that still need the fix -- so re-running either one is a no-op.

Serving a WebGL build locally

Unity's Brotli-compressed output needs Content-Encoding: br and the correct MIME types. A plain python -m http.server fails with a decompression error, which is a confusing way to lose an afternoon.

python tools/serve-webgl.py <build-dir> <port>

Binds to 127.0.0.1 with no authentication. Local testing only.


Repo layout

tools/
  measure-bodies.py     method-body census (the headline number)
  measure-owned.py      same census, split game-owned vs third-party libraries
  remap-ugui.py         repair decompiler GUIDs -> real uGUI GUIDs
  fix-plugin-pe.ps1     repair PE headers on native plugins
  serve-webgl.py        static server with correct wasm/Brotli headers
  measure-scenes.ps1    per-scene dependency-closure measurement
  measure-groups.ps1    merge scene closures into per-group import lists
  import-group.ps1      copy a measured closure into the project

Every tool takes its paths as arguments and validates them before touching anything. The two that rewrite files in place — remap-ugui.py and fix-plugin-pe.ps1 — back up each file before modifying it and are safe to re-run, since both select only files that still need the fix.

Editor-side measurement harnesses (scene audits, wiring dumps) live in the Unity project and are not redistributable.


On authorship

Roughly half of this was written with AI assistance, and I'd rather say so than have someone guess. The split was fairly consistent: I decided what to measure and what the numbers meant; the model wrote a lot of the scaffolding around that — argument parsing, file walking, the PE header byte arithmetic, the report formatting.

What did not come from a model is the part that mattered. Every GUID in remap-ugui.py was identified by matching the serialized fields present on a broken component against the real component, then verified against the uGUI package source. The first pass at that table was guessed from memory and was wrong, which is how the verification step ended up in there.

Same story for the survival number, which was published wrong three times: 8.22% measured a working copy that already contained my own replacements; 7.70% had the right scope but a classifier that mistook two-statement out stubs for real code; 3.04% missed stub values like (IntPtr)0 and 0uL and so credited 149 Steamworks P/Invoke stubs as live. Each was caught by opening the decompiled files and reading them rather than trusting the script, and each correction moved the number down. 0.00% is the pristine export measured with the tools exactly as published.

The general lesson, if there is one: AI is good at the mechanical layer and confidently wrong about specifics it cannot see. Everything here that survives scrutiny survives because it was checked against the actual bytes on disk rather than accepted because it sounded right.


Legal

This repository contains analysis tooling and notes only. It contains no game assets, no decompiled source, no builds, and no instructions for obtaining them. Everything here operates on a copy you produce yourself from software you own.

MiSide is © Aihasto. If you find this interesting, buy the game — it's good, and it's the reason any of this was worth measuring.

Tooling in this repo is MIT licensed.

About

Measuring how much Unity game code survives IL2CPP: 0.00% of method bodies decompile to compilable C#, vs 93-95% for Mono. Then rebuilding enough of MiSide by hand to run in a browser

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages