Skip to content

Comprehensive Decompiler, Memory & IL Restoration Improvements for Unity Android IL2CPP - #623

Open
tranthanhbinh2603 wants to merge 10 commits into
SamboyCoding:developmentfrom
tranthanhbinh2603:fix/decompiler-and-il-restore
Open

tranthanhbinh2603 wants to merge 10 commits into
SamboyCoding:developmentfrom
tranthanhbinh2603:fix/decompiler-and-il-restore

Conversation

@tranthanhbinh2603

@tranthanhbinh2603 tranthanhbinh2603 commented Oct 25, 2026 •

Copy link
Copy Markdown

1. Context & Motivation

When decompiling Unity Android applications (ARM64 ELF binaries, IL2CPP v24 through v31+), several systemic issues and architecture-specific lifting limitations cause decompilation degradation:

  • String Literals Emitting null: String constants referenced through Global Offset Table (GOT) slots or pointer indirection fail to resolve, causing decompiled code to pass null or unmanaged pointer math where meaningful string literals should be.
  • Stripped Method Bodies: Large methods (>30 KB) such as network packet handlers, Protobuf/FlatBuffers decoders, and UI routers are completely skipped due to MaxMethodSizeBytes.
  • Obfuscated Pointer Math on Arrays & Strings: Compilers emit register-shifted pointer arithmetic (ptr = base + (index << shift)) for array and string indexing, which upstream fails to lift, producing raw unmanaged memory loads ([loc0 + 0x14]).
  • Unresolved Calls & Crashes: Interface virtual dispatches on ARM64, ambiguous generic method calls, and generic field offset calculations fail to resolve or throw exceptions (e.g., Index was outside the bounds of the array or Cast from String to Int64 not supported).
  • Excessive Boilerplate: Compiler-injected null checks (if (x == null) throw), array bounds checks, and runtime class initialization guards clutter decompiled methods.
  • Decompiler Hangs on Complex Graphs: Textbook set-intersection dominator calculations cause exponential slowdowns and high memory allocation on methods with hundreds or thousands of basic blocks.

This PR addresses these limitations with comprehensive algorithmic improvements across the entire pipeline while strictly preserving all upstream diagnostic warnings.


2. In-Depth Technical Breakdown of Changes

A. String Literal & Global Memory Resolution (MetadataResolver.cs)

  • Accurate String Lookup instead of null:
    • The Problem: In Android ELF .so libraries, string literals are frequently accessed indirectly via the Global Offset Table (GOT) or data segment pointer slots. In upstream, ResolveGlobalLoads only checked for Type, Method, and Field globals, completely missing string literals. Furthermore, LiteralSlotAddress only recognized direct Immediate operands. As a result, string loads could not be resolved, leaving dereferences as unresolved pointers that degraded into null in the final decompiled C# code.
    • The Solution:
      1. GOT & Pointer Dereferencing: Added pointer chasing in ResolveGlobalLoads and ResolveLiteralString. If an address does not directly match a global or literal, the virtual address is mapped and dereferenced (libContext.Binary.ReadPointerAtVirtualAddress(address)) to follow GOT/relocation slots.
      2. String Metadata Retrieval: Explicitly queries libContext.GetLiteralByAddress(slotAddress) for destinations that can hold a string or object.
      3. Register Copy Propagation: Tracks resolved string/metadata slots across local variable register copies (dest = src), substituting memory dereferences ([slotLocal]) with StringLiteral and strongly typing the target local as System.String.
      4. ARM64 Memory Addressing: Extended LiteralSlotAddress to handle MemoryOperand { Base: null, Index: null, Scale: 0 }, correctly matching ARM64 ADRP + LDR sequences.

B. Pointer Derivation & High-Level Array/String Restoration (ArrayRecovery.cs, IlGenerator.cs)

  • Tracking Derived Array Pointers:
    • The Problem: ARM64 compilers optimize array accesses by deriving pointers into temporary registers (ptr = array + 0x20, or ptr = array + (index << 3)). Upstream only recognized MemoryOperand where memory.Base was directly the array local. Any derived pointer was left as unmanaged memory math.
    • The Solution:
      • Implemented arrayPointers to derive (Array, ArrayType, Offset) and indexedArrayPointers to track base arrays with variable indices and shift scales (scale = 1 << shiftImm).
      • Reconstructs effective memory offsets and translates memory dereferences into high-level ArrayAccess(array, index) and ArrayLength(array).
      • Cleans up dead pointer-calculation and shift instructions by replacing them with Nop.
      • Added cycle protection (visitedBlocks HashSet) in RecoverAllocation to prevent infinite loops on cyclic control-flow graphs.
  • String Properties in IL:
    • Emits callvirt System.String.get_Length when loading offset 0x10 from a string local.
    • Emits callvirt System.String.get_Chars(Int32) when loading offset 0x14 with an index from a string local.

C. Large Method Decompilation & Graph Guard (MethodAnalysisContext.cs)

  • Removed the arbitrary MaxMethodSizeBytes = 30000 limit (MaxMethodSizeBytes = -1) so large methods are decompiled rather than stripped into empty stubs.
  • Added a circuit breaker for pathological or corrupt control-flow graphs (blockCount > 25000) to safely stub them and prevent process termination.
  • Added generational garbage collection (GC.Collect(2, ...)) after analyzing methods with >1,000 blocks to bound working memory.

D. Fast $O(N)$ Iterative Dominator Algorithm (DominatorInfo.cs)

  • The Problem: Upstream used the textbook iterative dominator algorithm with repeated HashSet<Block> intersections ( $Dom(b) = {b} \cup \bigcap Dom(p)$ ), causing $O(N^2)$ to $O(N^3)$ computational complexity and massive heap churn. Large methods took minutes or caused out-of-memory errors.
  • The Solution: Replaced with the Cooper-Harvey-Kennedy (CHK) algorithm ("A Simple, Fast Dominance Algorithm"):
    • Uses post-order DFS numbering of reachable blocks.
    • Computes immediate dominators via lowest common ancestor traversals using post-order indices.
    • Runs in practical $O(N)$ time with near-zero allocations during iteration.
    • Added lazy caching for full dominator sets and optimized Dominates(a, b) to walk immediate dominators directly.

E. ARM64 Interface Dispatch & Key Function Lifting (InterfaceDispatchRecovery.cs, KeyFunctionRecovery.cs, NewArmV8InstructionSet.cs)

  • ARM64 Interface Dispatch: Upstream only recognized x86_64 interface dispatch calling conventions (where the target loads VirtualInvokeData::methodPtr via memory dereference). On ARM64 (AAPCS64), dispatch targets are passed in registers (X0 or copied from a Phi). Added ChaseCopies to follow register moves, extract interface arguments and slot indices, and resolve virtual interface invocations back to callvirt.
  • VTable Offset: Added support for Unity 2021+ Il2CppClass::vtable offset (0x130).
  • Key Function Lifting:
    • Lifts il2cpp_vm_object_box into IL box <Type>.
    • Lifts il2cpp_object_unbox / il2cpp_vm_object_unbox into direct Move.
    • Lifts il2cpp_vm_object_is_inst (is/as operator) into direct Move.
    • Automatically strips runtime class initialization boilerplate (il2cpp_runtime_class_init...).
  • ARM64 Branch Analysis: Handled unconditional B branch instructions with condition code NONE or AL as valid call/branch targets.
  • Write Barrier Discovery: Added write-barrier anchor analysis for ARM64 scanning known corlib reference stores (Task.GetAwaiter, ExecutionContext, CancellationTokenSource).

F. Generic Type Field Layouts (GenericInstanceFieldLayout.cs)

  • Upstream returned null for generic classes with non-trivial base types, causing field accesses on generic classes to emit raw memory offsets.
  • Implemented ComputeBaseTypeOffset taking into account object headers (2 * pointer size for reference types, 0 for value types), alignment constraints, and base type hierarchies (System.Object / System.ValueType).
  • Implemented ResolveFieldType to substitute generic parameters with concrete type arguments.

G. Injected Check & Dead Call Elimination (InjectedCheckRemover.cs, CallArgumentTrimmer.cs)

  • Injected Compiler Checks: IL2CPP injects explicit null checks and bounds checks before every dereference. Extended InjectedCheckRemover to match zero constants on either side of CheckEqual/CheckNotEqual, handle branch jumps to shared throw blocks (up to depth 2), and handle Newobj followed by Throw local.
  • Dead Assembly Helpers: In CallArgumentTrimmer, unresolved calls to immediate addresses (such as dangling write barriers) with unused return values are converted to Nop, removing clutter.

H. CIL Code Generation & Typing Fixes (IlGenerator.cs)

  • Untyped Locals: Systematically collects and infers types for all local variables from assigning instructions (CheckEqual -> bool, arithmetic -> int32, Call -> return type, Newobj/NewArr -> allocated type).
  • Missing Locals: Added GetOrCreateLocal to guarantee every referenced local has a defined CilLocalVariable, preventing AsmResolver out-of-bounds errors.
  • Numeric Literal Typing: Emits ldc.r4, ldc.r8, or ldc.i8 based on expectedType, resolving runtime exceptions when assigning constants to 64-bit integers or floats.
  • Method Terminators: Guarantees that every method body terminates with a valid CIL terminator (ret or throw), preventing invalid PE verification failures.

3. Before vs. After Decompilation Comparison

Example 1: String Literal Resolution (Fixed from null)

  • Before:
    // String literal slot at GOT offset could not be dereferenced; emitted as null
    string apiUrl = null;
    this.ConnectToServer(apiUrl, 8080);
  • After:
    // GOT pointer dereferenced and matched to metadata literal pool
    string apiUrl = "https://game-gateway.prod.unity.internal/api/v2";
    this.ConnectToServer(apiUrl, 8080);

Example 2: String Indexing & Length

  • Before:
    // Raw memory offsets on System.String instance
    int len = *(int*)((byte*)str + 16);
    char ch = *(char*)((byte*)str + 20 + (IntPtr)index * 2);
  • After:
    int len = str.Length;
    char ch = str[index];

Example 3: Array Access with Pointer Arithmetic

  • Before:
    // ARM64 scaled pointer addition left as raw memory load
    IntPtr ptr = (IntPtr)((byte*)items + (index << 3) + 32);
    Item item = *(Item*)ptr;
  • After:
    Item item = items[index];

Example 4: Large Methods (> 30 KB)

  • Before:
    // Skipped by MaxMethodSizeBytes (was 30,000 bytes)
    public void HandleNetworkMessage(int msgId, byte[] payload)
    {
    }
  • After:
    // Completely decompiled with preserved control-flow and switch statements
    public void HandleNetworkMessage(int msgId, byte[] payload)
    {
        switch (msgId)
        {
            case 1001:
                this.OnLoginResponse(payload);
                break;
            // ... 150+ cases restored
        }
    }

Example 5: Compiler-Injected Checks & Boilerplate

  • Before:
    if (!Klass->initialized)
        il2cpp_codegen_initialize_runtime_metadata(0x1A2B3C);
    if (player == null)
        throw new NullReferenceException();
    player.UpdateHealth(100);
  • After:
    // Injected runtime metadata guards and null-checks cleanly removed
    player.UpdateHealth(100);

Example 6: Key Function & Object Boxing

  • Before:
    IntPtr boxedPtr = il2cpp_vm_object_box(typeof(int), ref value);
  • After:
    object boxedVal = (object)value; // Emitted as clean CIL `box [System.Private.CoreLib]System.Int32`

4. Diagnostics & Console Cleanliness

  • Full Diagnostic Preservation: All 13 upstream Diagnostic(...) calls (Unmanaged memory load, Unknown operand, Store into unknown operand, Unknown instruction, Indirect call/jump, Phi opcodes, Stack shift, etc.) are 100% preserved. Any unresolved decompilation states remain visible to consumers via comments/warnings rather than being silently hidden.
  • Zero Console Spam: All temporary/profiling Logger.InfoNewline calls and heartbeat timers were eliminated. Console output remains clean and parallel decompiler performance is unimpeded by lock contention.

5. Verification & Test Binaries

  • Test Suite: All 82 unit tests in Cpp2IL.Core.Tests and LibCpp2ILTests pass successfully (total: 82, failed: 0, succeeded: 82).
  • Sample APK / ELF Binaries: If you would like sample Unity Android binaries, test scripts, or repro cases used to validate these changes, please feel free to reach out in this issues or my email in github profile.

6. Maintainer Collaboration Note

I welcome your review and feedback! If there are specific design decisions you would like adjusted, or if you would prefer this PR partitioned into smaller, modular pull requests (e.g., String & Array Lifting, Dominator Optimization, or Build Configuration), please let me know. Feel free to request changes, suggest modifications, or branch this PR into a dedicated review branch on upstream if you prefer to tweak it before merging into development.

…ndidate in ambiguous call resolution

- In ResolveAmbiguousCalls, when multiple candidates exist on a type, filter by caller method name to better handle recursive calls and base method overrides.
- Use FirstOrDefault instead of breaking early with SingleOrDefault to avoid leaving calls unresolved when shared method bodies cannot be fully disambiguated.
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