[GH-ISSUE #5112] [Feature] Resolve Unity IL2CPP method addresses and emit Ghidra seeds (follow-up to #3784) #3150

Closed
opened 2026-10-31 00:37:12 +00:00 by bsakaguchi · 12 comments
Owner

Originally created by @alexlanghart on GitHub (Oct 18, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/5112

Before submitting

  • I searched the open and closed issues and pull requests for a related proposal.
  • I understand the contribution scope must be reviewed before integration; open questions are welcome here.
  • This request does not include credentials, private samples, or sensitive target data.

Area

Native analysis or decompilation

Current limitation

For a Unity IL2CPP build, Ghidra-backed REA tools see GameAssembly.dll / libil2cpp.so as an unnamed native binary: every method is FUN_*, every metadata pointer and string literal is DAT_*. The names exist in global-metadata.dat, and #3807 (first slice of #3607, scoped in #3784) inventories them, but #3784 explicitly leaves out "linking metadata to GameAssembly or libil2cpp.so: method addresses, RVAs, symbol injection" as needing its own scope decision. This issue asks for that decision.

REA already has the injection half: REA_GHIDRA_SEED_FILE creates named functions and labels before auto-analysis. What is missing is producing those seeds from IL2CPP metadata plus the binary's code/metadata registration tables.

Use cases

Asking "how does feature X work" questions of a shipped Unity game or app. Today the only route is to run Il2CppDumper outside REA and convert its script.json into a seed file by hand.

Observed on a commercial Unity game (Unity 2022.3, metadata v31, Windows x64, 60 MB GameAssembly.dll, 17 MB global-metadata.dat), REA 6.5.0, Ghidra 12.1.4, JDK 21, WSL2:

  • Il2CppDumper 6.7.46 located CodeRegistration/MetadataRegistration and wrote script.json in ~9 s (220,389 method entries, 124,086 unique method addresses after identical-code folding, 130,048 known method starts).
  • A ~70-line converter produced a 15 MB seed file: 130,048 function seeds (124,086 named, e.g. Class$$Method) and 65,080 label seeds (*_TypeInfo, Method$..., StringLiteral_...).
  • rea decompile --provider ghidra with that seed file, REA_GHIDRA_STARTUP_TIMEOUT_MS=5400000, GHIDRA_HEADLESS_MAXMEM=9G: seed outcome "9908 functions created, 120140 already present, 0 failed; 65080 labels applied, 0 failed". 23 min 56 s wall, 7.6 GB peak RSS.
  • Decompiled output went from FUN_*/DAT_* to named callees, *_TypeInfo / Method$... references and readable string-literal labels. this and parameters stay untyped, so field access is still *(param_1 + 0xb8) and has to be mapped by hand from the dumped field offsets.

Desired outcome and success criteria

A static, offline route from a caller-selected IL2CPP binary + global-metadata.dat to Ghidra names, for example:

  1. Locate the code/metadata registration in the native binary (PE first; ELF/Mach-O later) and resolve method pointers to addresses, with the metadata and binary SHA-256 both bound into the Evidence.
  2. Emit a seed file in the existing REA_GHIDRA_SEED_FILE format (or apply equivalent seeds directly to a session), marking names as metadata-derived rather than Ghidra discoveries, as seed limitations already do.
  3. Report folded addresses (one body, many methods) explicitly instead of silently keeping one name, since the chosen name can mislead.
  4. Optional later step: typed this/field layouts from metadata (what Il2CppDumper's il2cpp.h provides), which is the biggest remaining readability gap.

Success: the measured case above reproduces with no external dumper, and an unsupported version, encrypted metadata or an unlocatable registration returns a typed failure, not partial names.

Expected interaction with targets

Read existing files or captured data

Target and scope details (optional)

Builds on #3807's v29/v31/v39 decoder. Out of scope: decryption or de-obfuscation, runtime dumping, patching. The game used for the measurements is not shared.

Verification available so far

I have a prototype or observed results

Verification details (optional)

Prototype pipeline: Il2CppDumper script.json → seed TSV → REA_GHIDRA_SEED_FILE. Validation: zero seed failures, and spot-checked method addresses match the dumper's dump.cs. No ground-truth symbols exist for the target, so naming correctness beyond the dumper's output is not independently verified.

Alternatives considered

  • Il2CppDumper / Cpp2IL plus a hand-written converter (what I did): works, but sits outside REA's Evidence, has to be repeated after every game update, and the converter's name choice for folded addresses is ad hoc.
  • set_addresses_names after opening: 124k renames through MCP after a 24 min analysis; seeds before analysis are cheaper and also create functions Ghidra missed (9,908 here).
  • Il2CppDumper's ghidra_with_struct.py on a persistent project: gives typed structs, but REA cannot open a pre-analysed project (#1048, #3751).

Possible direction

A provider like #3807's that also reads the native binary, plus an output mode that writes a seed file and a summary of resolved, folded and unresolved methods. #2811 (Delphi VMT → Ghidra seeds) is the same pattern for another runtime and could share the seed-emission contract.

References

  • #3607 Unity umbrella, #3784 metadata inventory scope, #3807 metadata inventory PR
  • #2811 Delphi seed emission
  • #1048, #3751 persistent Ghidra project (separate; the ~24 min cold start above recurs on every session even with seeds)
  • docs/installation.md → "Ghidra import language and analysis seeds"
Originally created by @alexlanghart on GitHub (Oct 18, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5112 ### Before submitting - [x] I searched the open and closed issues and pull requests for a related proposal. - [x] I understand the contribution scope must be reviewed before integration; open questions are welcome here. - [x] This request does not include credentials, private samples, or sensitive target data. ### Area Native analysis or decompilation ### Current limitation For a Unity IL2CPP build, Ghidra-backed REA tools see `GameAssembly.dll` / `libil2cpp.so` as an unnamed native binary: every method is `FUN_*`, every metadata pointer and string literal is `DAT_*`. The names exist in `global-metadata.dat`, and [#3807](https://github.com/morluto/rea/pull/3807) (first slice of #3607, scoped in #3784) inventories them, but #3784 explicitly leaves out "linking metadata to `GameAssembly` or `libil2cpp.so`: method addresses, RVAs, symbol injection" as needing its own scope decision. This issue asks for that decision. REA already has the injection half: `REA_GHIDRA_SEED_FILE` creates named functions and labels before auto-analysis. What is missing is producing those seeds from IL2CPP metadata plus the binary's code/metadata registration tables. ### Use cases Asking "how does feature X work" questions of a shipped Unity game or app. Today the only route is to run Il2CppDumper outside REA and convert its `script.json` into a seed file by hand. Observed on a commercial Unity game (Unity 2022.3, metadata v31, Windows x64, 60 MB `GameAssembly.dll`, 17 MB `global-metadata.dat`), REA 6.5.0, Ghidra 12.1.4, JDK 21, WSL2: - Il2CppDumper 6.7.46 located `CodeRegistration`/`MetadataRegistration` and wrote `script.json` in ~9 s (220,389 method entries, 124,086 unique method addresses after identical-code folding, 130,048 known method starts). - A ~70-line converter produced a 15 MB seed file: 130,048 `function` seeds (124,086 named, e.g. `Class$$Method`) and 65,080 `label` seeds (`*_TypeInfo`, `Method$...`, `StringLiteral_...`). - `rea decompile --provider ghidra` with that seed file, `REA_GHIDRA_STARTUP_TIMEOUT_MS=5400000`, `GHIDRA_HEADLESS_MAXMEM=9G`: seed outcome "9908 functions created, 120140 already present, 0 failed; 65080 labels applied, 0 failed". 23 min 56 s wall, 7.6 GB peak RSS. - Decompiled output went from `FUN_*`/`DAT_*` to named callees, `*_TypeInfo` / `Method$...` references and readable string-literal labels. `this` and parameters stay untyped, so field access is still `*(param_1 + 0xb8)` and has to be mapped by hand from the dumped field offsets. ### Desired outcome and success criteria A static, offline route from a caller-selected IL2CPP binary + `global-metadata.dat` to Ghidra names, for example: 1. Locate the code/metadata registration in the native binary (PE first; ELF/Mach-O later) and resolve method pointers to addresses, with the metadata and binary SHA-256 both bound into the Evidence. 2. Emit a seed file in the existing `REA_GHIDRA_SEED_FILE` format (or apply equivalent seeds directly to a session), marking names as metadata-derived rather than Ghidra discoveries, as seed limitations already do. 3. Report folded addresses (one body, many methods) explicitly instead of silently keeping one name, since the chosen name can mislead. 4. Optional later step: typed `this`/field layouts from metadata (what Il2CppDumper's `il2cpp.h` provides), which is the biggest remaining readability gap. Success: the measured case above reproduces with no external dumper, and an unsupported version, encrypted metadata or an unlocatable registration returns a typed failure, not partial names. ### Expected interaction with targets Read existing files or captured data ### Target and scope details (optional) Builds on #3807's v29/v31/v39 decoder. Out of scope: decryption or de-obfuscation, runtime dumping, patching. The game used for the measurements is not shared. ### Verification available so far I have a prototype or observed results ### Verification details (optional) Prototype pipeline: Il2CppDumper `script.json` → seed TSV → `REA_GHIDRA_SEED_FILE`. Validation: zero seed failures, and spot-checked method addresses match the dumper's `dump.cs`. No ground-truth symbols exist for the target, so naming correctness beyond the dumper's output is not independently verified. ### Alternatives considered - **Il2CppDumper / Cpp2IL plus a hand-written converter (what I did):** works, but sits outside REA's Evidence, has to be repeated after every game update, and the converter's name choice for folded addresses is ad hoc. - **`set_addresses_names` after opening:** 124k renames through MCP after a 24 min analysis; seeds before analysis are cheaper and also create functions Ghidra missed (9,908 here). - **Il2CppDumper's `ghidra_with_struct.py` on a persistent project:** gives typed structs, but REA cannot open a pre-analysed project (#1048, #3751). ### Possible direction A provider like #3807's that also reads the native binary, plus an output mode that writes a seed file and a summary of resolved, folded and unresolved methods. #2811 (Delphi VMT → Ghidra seeds) is the same pattern for another runtime and could share the seed-emission contract. ### References - #3607 Unity umbrella, #3784 metadata inventory scope, #3807 metadata inventory PR - #2811 Delphi seed emission - #1048, #3751 persistent Ghidra project (separate; the ~24 min cold start above recurs on every session even with seeds) - `docs/installation.md` → "Ghidra import language and analysis seeds"
Author
Owner

@8rulerstar commented on GitHub (Oct 18, 2026):

Thanks for the measurements; they answer the open question in #3784 better than my self-built samples did.

I have a local prototype of this on top of #3807, not opened as a PR while #3784's scope is undecided:

  • Links method addresses from the code registration (s_methodPointers per code-gen module) for Mach-O and ELF; ELF applies R_AARCH64_RELATIVE/R_X86_64_RELATIVE first. Checked against symbols: macOS arm64 builds of Unity 2021.3.45f2, 2022.3.30f1, 2022.3.62f3 and 6000.0.84f1 (10,698 / 11,010 / 11,022 / 14,511 methods, all addresses equal to the dSYM), and Android arm64 and x86-64 2022.3.62f3 (10,057 / 10,057 equal to libil2cpp.sym.so). 32-bit images and Mach-O chained fixups fail as unsupported.
  • --ghidra-seeds PATH writes Namespace.Type$$Method function seeds in the REA_GHIDRA_SEED_FILE format at Ghidra's default load base. On the Android arm64 build: 10,031 created, 26 already present, 0 failed, and rea function libil2cpp.so 'Sample.Player.Slot$$Add' resolves and decompiles by name.
  • Folded addresses: one function seed per address, plus a # folded <address> <kept> also <names> line per group and a count in the result. My builds show no folding, so this is unit-tested only; your MSVC build is where it matters.

Not done: PE is parsed but not checked on a real Windows build, and typed this/fields are out of scope. If a maintainer agrees with the direction here, I can open it as a follow-up PR after #3807, PE verification included.

<!-- gh-comment-id:6255811827 --> @8rulerstar commented on GitHub (Oct 18, 2026): Thanks for the measurements; they answer the open question in #3784 better than my self-built samples did. I have a local prototype of this on top of #3807, not opened as a PR while #3784's scope is undecided: - Links method addresses from the code registration (`s_methodPointers` per code-gen module) for Mach-O and ELF; ELF applies `R_AARCH64_RELATIVE`/`R_X86_64_RELATIVE` first. Checked against symbols: macOS arm64 builds of Unity 2021.3.45f2, 2022.3.30f1, 2022.3.62f3 and 6000.0.84f1 (10,698 / 11,010 / 11,022 / 14,511 methods, all addresses equal to the dSYM), and Android arm64 and x86-64 2022.3.62f3 (10,057 / 10,057 equal to `libil2cpp.sym.so`). 32-bit images and Mach-O chained fixups fail as unsupported. - `--ghidra-seeds PATH` writes `Namespace.Type$$Method` function seeds in the `REA_GHIDRA_SEED_FILE` format at Ghidra's default load base. On the Android arm64 build: 10,031 created, 26 already present, 0 failed, and `rea function libil2cpp.so 'Sample.Player.Slot$$Add'` resolves and decompiles by name. - Folded addresses: one function seed per address, plus a `# folded <address> <kept> also <names>` line per group and a count in the result. My builds show no folding, so this is unit-tested only; your MSVC build is where it matters. Not done: PE is parsed but not checked on a real Windows build, and typed `this`/fields are out of scope. If a maintainer agrees with the direction here, I can open it as a follow-up PR after #3807, PE verification included.
Author
Owner

@XinwangTao commented on GitHub (Oct 19, 2026):

Strong +1 for making this a first-class REA scope instead of an external Il2CppDumper + hand-rolled converter pipeline. Naming every FUN_/DAT_ from global-metadata.dat is the single highest-leverage step for Unity IL2CPP reversing.

One practical concern from the numbers you posted: with 130k method seeds and a 15 MB seed file, seed injection already dominates startup (9908 created vs 120140 already present suggests re-running against an analysed project still churns through the full file). I'd ask for a dedup/idempotency contract in the seed producer — e.g. skip seeds whose target address already has a non-FUN_ name, and allow feeding the seed file in chunks or by address range so incremental analysis doesn't re-pay the full injection cost each time.

<!-- gh-comment-id:6260110182 --> @XinwangTao commented on GitHub (Oct 19, 2026): Strong +1 for making this a first-class REA scope instead of an external Il2CppDumper + hand-rolled converter pipeline. Naming every FUN_*/DAT_* from global-metadata.dat is the single highest-leverage step for Unity IL2CPP reversing. One practical concern from the numbers you posted: with 130k method seeds and a 15 MB seed file, seed injection already dominates startup (9908 created vs 120140 already present suggests re-running against an analysed project still churns through the full file). I'd ask for a dedup/idempotency contract in the seed producer — e.g. skip seeds whose target address already has a non-FUN_ name, and allow feeding the seed file in chunks or by address range so incremental analysis doesn't re-pay the full injection cost each time.
Author
Owner

@alexlanghart commented on GitHub (Oct 19, 2026):

@8rulerstar Thanks, that's further along than I expected. I can help with the PE gap: the build I measured is Windows x64 (MSVC), metadata v31, and folding is substantial there. Il2CppDumper's script.json lists 220,389 method entries but only 124,086 unique method addresses. If you share the branch, I can run your --ghidra-seeds output against it and report the address agreement with Il2CppDumper (matching, differing, missing) plus the folded-group counts, without sharing the binary itself.

<!-- gh-comment-id:6260188239 --> @alexlanghart commented on GitHub (Oct 19, 2026): @8rulerstar Thanks, that's further along than I expected. I can help with the PE gap: the build I measured is Windows x64 (MSVC), metadata v31, and folding is substantial there. Il2CppDumper's `script.json` lists 220,389 method entries but only 124,086 unique method addresses. If you share the branch, I can run your `--ghidra-seeds` output against it and report the address agreement with Il2CppDumper (matching, differing, missing) plus the folded-group counts, without sharing the binary itself.
Author
Owner

@alexlanghart commented on GitHub (Oct 19, 2026):

@XinwangTao A clarification on the numbers: that was a single fresh import, not a re-run. Seeds are applied once in a pre-script before auto-analysis, and "120,140 already present" means Ghidra's PE loader had already created those functions from the exception directory (.pdata) before the seed script ran. I didn't time seed injection separately, so I can't say it dominates; most of the ~24 min was default auto-analysis.

Paying the full cost on every session is the real issue, and that's #1048/#3751 (reusing analyzed programs) rather than seed idempotency. For what it's worth, I'm running a patch for that on my fork: feat/ghidra-gzf-reuse. It adds REA_GHIDRA_PROGRAM_ARCHIVE_DIR: the first session saves the analyzed program as a .gzf keyed by target SHA-256 + analysis profile digest, and later sessions import it with -noanalysis into the usual ephemeral project (the bridge still checks the executable digest and the stored key). On this target the second open went from 26 min 53 s / 7.5 GB peak to 21.6 s / 1.9 GB, with byte-identical decompilation. It also adds REA_GHIDRA_SEED_DIR for per-target seed files. Not proposed as a PR; just a data point for #1048.

<!-- gh-comment-id:6260188500 --> @alexlanghart commented on GitHub (Oct 19, 2026): @XinwangTao A clarification on the numbers: that was a single fresh import, not a re-run. Seeds are applied once in a pre-script before auto-analysis, and "120,140 already present" means Ghidra's PE loader had already created those functions from the exception directory (`.pdata`) before the seed script ran. I didn't time seed injection separately, so I can't say it dominates; most of the ~24 min was default auto-analysis. Paying the full cost on every session is the real issue, and that's #1048/#3751 (reusing analyzed programs) rather than seed idempotency. For what it's worth, I'm running a patch for that on my fork: [`feat/ghidra-gzf-reuse`](https://github.com/alexlanghart/rea/tree/feat/ghidra-gzf-reuse). It adds `REA_GHIDRA_PROGRAM_ARCHIVE_DIR`: the first session saves the analyzed program as a `.gzf` keyed by target SHA-256 + analysis profile digest, and later sessions import it with `-noanalysis` into the usual ephemeral project (the bridge still checks the executable digest and the stored key). On this target the second open went from 26 min 53 s / 7.5 GB peak to 21.6 s / 1.9 GB, with byte-identical decompilation. It also adds `REA_GHIDRA_SEED_DIR` for per-target seed files. Not proposed as a PR; just a data point for #1048.
Author
Owner

@arielfikru commented on GitHub (Oct 19, 2026):

An Android/ELF data point to go next to the Windows x64 one. The ratio flips here: on a stripped ARM64 libil2cpp.so, the seeds create almost every function instead of topping up what the loader already found.

Target: a commercial Unity 2022.3 Android game (libil2cpp.so, ELF64 AArch64 ET_DYN, stripped, 45 MB). Il2CppDumper script.json (105,678 ScriptMethod). Run on REA main at e4c3d7239, Ghidra 12.1.4, JDK 21, macOS arm64, using rea decompile --provider ghidra with REA_GHIDRA_SEED_FILE, REA_GHIDRA_STARTUP_TIMEOUT_MS=5400000 and GHIDRA_HEADLESS_MAXMEM=9G.

this ELF build Windows x64 build above
method entries / unique addresses 105,678 / 105,678 (no folding) 220,389 / 124,086
function seeds 117,154 (105,678 named, 11,476 unnamed from Addresses) 130,048 (124,086 named)
created by seeds / already present 117,026 / 128 9,908 / 120,140
failed 0 0
label seeds 40,315 applied, 0 failed, 0 outside initialized memory 65,080 applied, 0 failed
seed pre-script ~73 s not timed
whole run (import, seeds, default auto-analysis, one decompile) 18 min 51 s, peak RSS 5.0 GiB 23 min 56 s

Three things this adds for the scope decision:

  1. Without .pdata, the seeds are the function boundaries. Ghidra's ELF loader found 128 functions on its own; the other 99.9% came from the seed file. So for ELF the address-resolution step is not an optional extra on top of analysis. It decides whether the methods exist as functions at all.
  2. The seed address space depends on the loader's image base, not the RVA. Il2CppDumper's addresses are RVAs. Ghidra maps this ET_DYN at 0x100000, so each seed is RVA + 0x100000. A resolver inside REA would need to take the base from the loaded program for ELF, the same way as ImageBase for PE, rather than writing RVAs. With the right base, 0 seeds fell outside initialized memory.
  3. No identical-code folding on this toolchain: every method has its own address. The folded-group handling discussed above therefore matters for MSVC builds but is a no-op here, which an ELF test fixture would not exercise.

Output quality, HumanCustomer$$GetPatienceMax: 65 lines of pseudocode with the method names, *_TypeInfo and Method$... labels resolved (game_Player__GetUseCustomerItem, java_util_Vector<object>__Size(..., Method_java_util_Vector<HumanClerk>_Size__)). The logic matches what I recovered by hand earlier: base 200, or 400 with an item, plus a percentage from each clerk's skill 14. The il2cpp throw stub at the end was flagged no-return by default auto-analysis, and REA reports it as a terminal-call limitation. In an earlier pipeline of mine without auto-analysis, those stubs were not no-return and the decompiler ran into the next function, so default analysis matters here. I checked only this one function under REA, not the whole binary.

Not investigated: default analysis also logged 2,562 GccExceptionAnalyzer "Failed to disassemble" errors in 0x02a2xxxx. I can't say yet whether they come from the seeds or from the file itself.

The converter is about 40 lines of Python (unique ScriptMethod addresses named, first name wins; the remaining Addresses entries unnamed; ScriptMetadata, ScriptMetadataMethod and StringLiteral_<n> as labels; whitespace in names replaced, since the seed format rejects it). If a --ghidra-seeds branch appears, I can run it against this build and report address agreement with Il2CppDumper. I won't share the binary.

<!-- gh-comment-id:6260606656 --> @arielfikru commented on GitHub (Oct 19, 2026): An Android/ELF data point to go next to the Windows x64 one. The ratio flips here: on a stripped ARM64 `libil2cpp.so`, the seeds create almost every function instead of topping up what the loader already found. Target: a commercial Unity 2022.3 Android game (`libil2cpp.so`, ELF64 AArch64 `ET_DYN`, stripped, 45 MB). Il2CppDumper `script.json` (105,678 `ScriptMethod`). Run on REA main at e4c3d7239, Ghidra 12.1.4, JDK 21, macOS arm64, using `rea decompile --provider ghidra` with `REA_GHIDRA_SEED_FILE`, `REA_GHIDRA_STARTUP_TIMEOUT_MS=5400000` and `GHIDRA_HEADLESS_MAXMEM=9G`. | | this ELF build | Windows x64 build above | |---|---|---| | method entries / unique addresses | 105,678 / 105,678 (no folding) | 220,389 / 124,086 | | function seeds | 117,154 (105,678 named, 11,476 unnamed from `Addresses`) | 130,048 (124,086 named) | | created by seeds / already present | **117,026 / 128** | 9,908 / 120,140 | | failed | 0 | 0 | | label seeds | 40,315 applied, 0 failed, 0 outside initialized memory | 65,080 applied, 0 failed | | seed pre-script | ~73 s | not timed | | whole run (import, seeds, default auto-analysis, one decompile) | 18 min 51 s, peak RSS 5.0 GiB | 23 min 56 s | Three things this adds for the scope decision: 1. **Without `.pdata`, the seeds are the function boundaries.** Ghidra's ELF loader found 128 functions on its own; the other 99.9% came from the seed file. So for ELF the address-resolution step is not an optional extra on top of analysis. It decides whether the methods exist as functions at all. 2. **The seed address space depends on the loader's image base, not the RVA.** Il2CppDumper's addresses are RVAs. Ghidra maps this `ET_DYN` at `0x100000`, so each seed is `RVA + 0x100000`. A resolver inside REA would need to take the base from the loaded program for ELF, the same way as `ImageBase` for PE, rather than writing RVAs. With the right base, 0 seeds fell outside initialized memory. 3. **No identical-code folding** on this toolchain: every method has its own address. The folded-group handling discussed above therefore matters for MSVC builds but is a no-op here, which an ELF test fixture would not exercise. Output quality, `HumanCustomer$$GetPatienceMax`: 65 lines of pseudocode with the method names, `*_TypeInfo` and `Method$...` labels resolved (`game_Player__GetUseCustomerItem`, `java_util_Vector<object>__Size(..., Method_java_util_Vector<HumanClerk>_Size__)`). The logic matches what I recovered by hand earlier: base 200, or 400 with an item, plus a percentage from each clerk's skill 14. The il2cpp throw stub at the end was flagged no-return by default auto-analysis, and REA reports it as a terminal-call limitation. In an earlier pipeline of mine without auto-analysis, those stubs were not no-return and the decompiler ran into the next function, so default analysis matters here. I checked only this one function under REA, not the whole binary. Not investigated: default analysis also logged 2,562 `GccExceptionAnalyzer` "Failed to disassemble" errors in `0x02a2xxxx`. I can't say yet whether they come from the seeds or from the file itself. The converter is about 40 lines of Python (unique `ScriptMethod` addresses named, first name wins; the remaining `Addresses` entries unnamed; `ScriptMetadata`, `ScriptMetadataMethod` and `StringLiteral_<n>` as labels; whitespace in names replaced, since the seed format rejects it). If a `--ghidra-seeds` branch appears, I can run it against this build and report address agreement with Il2CppDumper. I won't share the binary.
Author
Owner

@8rulerstar commented on GitHub (Oct 19, 2026):

@alexlanghart @arielfikru thanks, both offers are very welcome. The branch is here, on top of #3807 (not a PR; it waits for the scope decision in #3784):

https://github.com/8rulerstar/rea/tree/exp/il2cpp-ghidra

npm ci && npm run build
node scripts/rea.mjs inspect-il2cpp-metadata /abs/<Game>_Data/il2cpp_data/Metadata/global-metadata.dat \
  --binary /abs/GameAssembly.dll --ghidra-seeds /abs/seeds.tsv --json > report.json
export REA_GHIDRA_SEED_FILE=/abs/seeds.tsv   # then rea decompile --provider ghidra ... as before

For Android, pass libil2cpp.so as --binary.

  • report.json is an Evidence envelope. normalized_result.method_addresses has one {address, rva} per method index at link-time addresses, null when the module records no body (abstract, extern and most generic methods).
  • seeds.tsv has one Namespace.Type$$Method function seed per address at Ghidra's default load base: ImageBase for PE, and 0x100000 for an ELF linked at 0, as @arielfikru noted above. That is why seed addresses can differ from address in the report. An Il2CppDumper ScriptMethod address plus that base should equal a seed address. Use --seed-base 0x... if you import at another base. Shared addresses add # folded <address> <kept> also <names> lines. An existing seed path is refused before decoding.
  • Generic method instances are not linked.

Before asking you, I checked PE on the one public Windows IL2CPP build I could find: Between Two Worlds 1.0.5 (MIT, Unity 2023.2.13f1, metadata version 29). It found a real bug: this build pads strings with 0xFF, so a module name had no NUL in front of it and linking failed; that is fixed on the branch. After the fix, the branch links 43,198 methods at 34,485 unique addresses, and every one of those addresses carries the same method name in Il2CppDumper's script.json. Il2CppDumper lists 57,109 unique addresses; the other 22,624 hold only names with a generic argument list (<...>). Folding puts 10,448 methods on 1,735 shared addresses.

One build I could not link: that game's Linux x86-64 GameAssembly.so has every module record but no static codeGenModules pointer in its data or relocations. Il2CppDumper fails on it too. The branch fails closed with code-registration-not-found.

What would help most from your builds: address agreement with Il2CppDumper among non-generic methods (matching, differing, missing), the folded-group count, and any failure reason with the Unity version.

<!-- gh-comment-id:6261388182 --> @8rulerstar commented on GitHub (Oct 19, 2026): @alexlanghart @arielfikru thanks, both offers are very welcome. The branch is here, on top of #3807 (not a PR; it waits for the scope decision in #3784): https://github.com/8rulerstar/rea/tree/exp/il2cpp-ghidra ```bash npm ci && npm run build node scripts/rea.mjs inspect-il2cpp-metadata /abs/<Game>_Data/il2cpp_data/Metadata/global-metadata.dat \ --binary /abs/GameAssembly.dll --ghidra-seeds /abs/seeds.tsv --json > report.json export REA_GHIDRA_SEED_FILE=/abs/seeds.tsv # then rea decompile --provider ghidra ... as before ``` For Android, pass `libil2cpp.so` as `--binary`. - `report.json` is an Evidence envelope. `normalized_result.method_addresses` has one `{address, rva}` per method index at link-time addresses, `null` when the module records no body (abstract, extern and most generic methods). - `seeds.tsv` has one `Namespace.Type$$Method` function seed per address at Ghidra's default load base: `ImageBase` for PE, and `0x100000` for an ELF linked at 0, as @arielfikru noted above. That is why seed addresses can differ from `address` in the report. An Il2CppDumper `ScriptMethod` address plus that base should equal a seed address. Use `--seed-base 0x...` if you import at another base. Shared addresses add `# folded <address> <kept> also <names>` lines. An existing seed path is refused before decoding. - Generic method instances are not linked. Before asking you, I checked PE on the one public Windows IL2CPP build I could find: [Between Two Worlds 1.0.5](https://github.com/SkS-GameDev/Between-two-worlds/releases/tag/1.0.5) (MIT, Unity 2023.2.13f1, metadata version 29). It found a real bug: this build pads strings with `0xFF`, so a module name had no NUL in front of it and linking failed; that is fixed on the branch. After the fix, the branch links 43,198 methods at 34,485 unique addresses, and every one of those addresses carries the same method name in Il2CppDumper's `script.json`. Il2CppDumper lists 57,109 unique addresses; the other 22,624 hold only names with a generic argument list (`<...>`). Folding puts 10,448 methods on 1,735 shared addresses. One build I could not link: that game's Linux x86-64 `GameAssembly.so` has every module record but no static `codeGenModules` pointer in its data or relocations. Il2CppDumper fails on it too. The branch fails closed with `code-registration-not-found`. What would help most from your builds: address agreement with Il2CppDumper among non-generic methods (matching, differing, missing), the folded-group count, and any failure reason with the Unity version.
Author
Owner

@alexlanghart commented on GitHub (Oct 19, 2026):

@8rulerstar Ran exp/il2cpp-ghidra (554ffa1c9) on the Windows x64 build: Unity 2022.3, metadata v31, MSVC, 60 MB GameAssembly.dll. It linked in 5.8 s (1.0 GB peak) and found the code-gen module array with no failures.

  • 123,291 of 136,348 method definitions linked; the rest are null (no body).
  • Non-generic names, compared by name against Il2CppDumper 6.7.46 script.json (RVA + ImageBase 0x180000000): 111,771 match, 0 differ, 0 missing, 0 extra. That includes 7,584 explicit interface implementations (…$$System.Collections.Generic.IEnumerator<System.Object>.get_Current).
  • 89,584 unique addresses; folding: 3,784 groups covering 37,491 methods, all emitted as # folded lines.

I didn't run Ghidra with these seeds; the comparison was address-level only. I can do a seeded decompile if that would help.

<!-- gh-comment-id:6262808900 --> @alexlanghart commented on GitHub (Oct 19, 2026): @8rulerstar Ran `exp/il2cpp-ghidra` (554ffa1c9) on the Windows x64 build: Unity 2022.3, metadata v31, MSVC, 60 MB `GameAssembly.dll`. It linked in 5.8 s (1.0 GB peak) and found the code-gen module array with no failures. - 123,291 of 136,348 method definitions linked; the rest are null (no body). - Non-generic names, compared by name against Il2CppDumper 6.7.46 `script.json` (RVA + `ImageBase` 0x180000000): **111,771 match, 0 differ, 0 missing, 0 extra**. That includes 7,584 explicit interface implementations (`…$$System.Collections.Generic.IEnumerator<System.Object>.get_Current`). - 89,584 unique addresses; folding: **3,784 groups covering 37,491 methods**, all emitted as `# folded` lines. I didn't run Ghidra with these seeds; the comparison was address-level only. I can do a seeded decompile if that would help.
Author
Owner

@arielfikru commented on GitHub (Oct 19, 2026):

@8rulerstar I ran exp/il2cpp-ghidra (554ffa1c9) on the Android build from my earlier comment (Unity 2022.3, metadata version 31; the arm64-v8a libil2cpp.so is the same file, sha256 9fc9d76e…). As-is it fails to link because of a zero-filled method pointer array. With a small local patch it links, and every seed agrees with Il2CppDumper.

1. As-is: code-gen-modules-not-found

code-gen-modules-not-found: no code generation module names UnityEngine.Purchasing.AppleCore.dll with 6 method pointers

Checked against the binary with my own ELF reader, independent of the branch:

module record methodPointerCount methodPointers entries with a R_AARCH64_RELATIVE
UnityEngine.Purchasing.AppleCore.dll 0x29436e8 (.data.rel.ro) 6 0x2b2b9f0 in .bss 0 / 6
Assembly-CSharp.dll (for comparison) 0x284adf8 (.data.rel.ro) 6,537 0x29d0918 in .data 6,451 / 6,537

The record exists, its name pointer is a normal relative relocation, and the count matches. The array sits in .bss because the iOS-only purchasing assembly has no bodies on Android: six zero pointers, never written to the file. findModules keeps a candidate only if isMapped(image, pointers, count * 8), which accepts file-backed ranges only, so the one real record is dropped. Getting past that, the pointer table then fails with invalid-method-pointer: ... method pointer 0 is not file mapped, because readPointer returns null for zero-fill.

2. Local patch (+17/−2 in src/il2cpp/Il2cppBinaryFile.ts)

An isZeroFilled(image, address, size) helper over image.container.zero_fills, used in two places: findModules also accepts an array that lies entirely in a zero-fill range, and the pointer table reads a zero-filled slot as 0n (no body, same path as other null entries) instead of null. The branch's 7 il2cpp test files (83 tests) and tsc --noEmit pass with it. Diff below; happy for you to take it or do it differently. A fixture for it would need a module whose array lies past p_filesz in a PT_LOAD.

patch
diff --git a/src/il2cpp/Il2cppBinaryFile.ts b/src/il2cpp/Il2cppBinaryFile.ts
index 5e7f2e2a8..5c7caf856 100644
--- a/src/il2cpp/Il2cppBinaryFile.ts
+++ b/src/il2cpp/Il2cppBinaryFile.ts
@@ -224,6 +224,14 @@ const readPointer = (image: NativeImage, address: bigint): bigint | null => {
   }
 };
 
+/** True when [address, address + size) lies in one zero-filled range such as .bss. */
+const isZeroFilled = (image: NativeImage, address: bigint, size: number) =>
+  image.container.zero_fills.some(
+    (fill) =>
+      address >= fill.address &&
+      address + BigInt(size) <= fill.address + fill.size,
+  );
+
 const isMapped = (image: NativeImage, address: bigint, size: number) => {
   try {
     mappedFileOffset(image.container.mappings, address, size);
@@ -343,7 +351,12 @@ const findModules = (
       const count = image.bytes.readUInt32LE(offset + 8);
       if (count !== counts[index]) continue;
       const pointers = image.bytes.readBigUInt64LE(offset + 16);
-      if (count > 0 && !isMapped(image, pointers, count * 8)) continue;
+      if (
+        count > 0 &&
+        !isMapped(image, pointers, count * 8) &&
+        !isZeroFilled(image, pointers, count * 8)
+      )
+        continue;
       const list = candidates.get(index) ?? [];
       list.push({
         image: index,
@@ -655,7 +668,9 @@ export const linkIl2cppBinary = (
     let table = tables.get(owner);
     if (table === undefined) {
       table = Array.from({ length: module.method_pointer_count }, (_, slot) =>
-        readPointer(image, module.method_pointers + BigInt(slot * 8)),
+        isZeroFilled(image, module.method_pointers + BigInt(slot * 8), 8)
+          ? 0n
+          : readPointer(image, module.method_pointers + BigInt(slot * 8)),
       );
       tables.set(owner, table);
     }

3. Agreement with Il2CppDumper (patched, arm64-v8a)

count
method indexes in method_addresses 65,056 (58,901 linked, 6,155 null)
seed addresses 58,901 (= unique linked addresses)
seeds at an Il2CppDumper address with the same name 58,901
seeds whose name differs at that address 0
seeds at an address Il2CppDumper doesn't list 0
Il2CppDumper addresses without a seed 46,777, all of them generic instances only (e.g. System.Action<bool>$$.ctor, System.HashCode$$Add<int>)
non-generic Il2CppDumper addresses without a seed 0
folded groups 0 (# folded lines: 0; consistent with my earlier observation that this toolchain doesn't fold)

Comparison: Il2CppDumper ScriptMethod.Address + 0x100000 against the seed address, and the seed name in the set of Il2CppDumper names at that address. "Generic instance" means a type or method argument list remains after compiler-generated names (<>c, <X>b__, <X>d__, k__BackingField) are stripped. Linking took about 2 s.

4. armeabi-v7a (same APK): fails closed as documented

unsupported-container: only 64-bit little-endian images are supported, not 32-bit little-endian
<!-- gh-comment-id:6263052773 --> @arielfikru commented on GitHub (Oct 19, 2026): @8rulerstar I ran `exp/il2cpp-ghidra` (554ffa1c9) on the Android build from my earlier comment (Unity 2022.3, metadata version 31; the arm64-v8a `libil2cpp.so` is the same file, sha256 `9fc9d76e…`). As-is it fails to link because of a zero-filled method pointer array. With a small local patch it links, and every seed agrees with Il2CppDumper. **1. As-is: `code-gen-modules-not-found`** ``` code-gen-modules-not-found: no code generation module names UnityEngine.Purchasing.AppleCore.dll with 6 method pointers ``` Checked against the binary with my own ELF reader, independent of the branch: | module | record | `methodPointerCount` | `methodPointers` | entries with a `R_AARCH64_RELATIVE` | |---|---|---|---|---| | `UnityEngine.Purchasing.AppleCore.dll` | `0x29436e8` (`.data.rel.ro`) | 6 | `0x2b2b9f0` in **`.bss`** | 0 / 6 | | `Assembly-CSharp.dll` (for comparison) | `0x284adf8` (`.data.rel.ro`) | 6,537 | `0x29d0918` in `.data` | 6,451 / 6,537 | The record exists, its name pointer is a normal relative relocation, and the count matches. The array sits in `.bss` because the iOS-only purchasing assembly has no bodies on Android: six zero pointers, never written to the file. `findModules` keeps a candidate only if `isMapped(image, pointers, count * 8)`, which accepts file-backed ranges only, so the one real record is dropped. Getting past that, the pointer table then fails with `invalid-method-pointer: ... method pointer 0 is not file mapped`, because `readPointer` returns `null` for zero-fill. **2. Local patch (+17/−2 in `src/il2cpp/Il2cppBinaryFile.ts`)** An `isZeroFilled(image, address, size)` helper over `image.container.zero_fills`, used in two places: `findModules` also accepts an array that lies entirely in a zero-fill range, and the pointer table reads a zero-filled slot as `0n` (no body, same path as other null entries) instead of `null`. The branch's 7 il2cpp test files (83 tests) and `tsc --noEmit` pass with it. Diff below; happy for you to take it or do it differently. A fixture for it would need a module whose array lies past `p_filesz` in a PT_LOAD. <details> <summary>patch</summary> ```diff diff --git a/src/il2cpp/Il2cppBinaryFile.ts b/src/il2cpp/Il2cppBinaryFile.ts index 5e7f2e2a8..5c7caf856 100644 --- a/src/il2cpp/Il2cppBinaryFile.ts +++ b/src/il2cpp/Il2cppBinaryFile.ts @@ -224,6 +224,14 @@ const readPointer = (image: NativeImage, address: bigint): bigint | null => { } }; +/** True when [address, address + size) lies in one zero-filled range such as .bss. */ +const isZeroFilled = (image: NativeImage, address: bigint, size: number) => + image.container.zero_fills.some( + (fill) => + address >= fill.address && + address + BigInt(size) <= fill.address + fill.size, + ); + const isMapped = (image: NativeImage, address: bigint, size: number) => { try { mappedFileOffset(image.container.mappings, address, size); @@ -343,7 +351,12 @@ const findModules = ( const count = image.bytes.readUInt32LE(offset + 8); if (count !== counts[index]) continue; const pointers = image.bytes.readBigUInt64LE(offset + 16); - if (count > 0 && !isMapped(image, pointers, count * 8)) continue; + if ( + count > 0 && + !isMapped(image, pointers, count * 8) && + !isZeroFilled(image, pointers, count * 8) + ) + continue; const list = candidates.get(index) ?? []; list.push({ image: index, @@ -655,7 +668,9 @@ export const linkIl2cppBinary = ( let table = tables.get(owner); if (table === undefined) { table = Array.from({ length: module.method_pointer_count }, (_, slot) => - readPointer(image, module.method_pointers + BigInt(slot * 8)), + isZeroFilled(image, module.method_pointers + BigInt(slot * 8), 8) + ? 0n + : readPointer(image, module.method_pointers + BigInt(slot * 8)), ); tables.set(owner, table); } ``` </details> **3. Agreement with Il2CppDumper (patched, arm64-v8a)** | | count | |---|---| | method indexes in `method_addresses` | 65,056 (58,901 linked, 6,155 `null`) | | seed addresses | 58,901 (= unique linked addresses) | | seeds at an Il2CppDumper address with the same name | **58,901** | | seeds whose name differs at that address | 0 | | seeds at an address Il2CppDumper doesn't list | 0 | | Il2CppDumper addresses without a seed | 46,777, all of them generic instances only (e.g. `System.Action<bool>$$.ctor`, `System.HashCode$$Add<int>`) | | non-generic Il2CppDumper addresses without a seed | **0** | | folded groups | 0 (`# folded` lines: 0; consistent with my earlier observation that this toolchain doesn't fold) | Comparison: Il2CppDumper `ScriptMethod.Address + 0x100000` against the seed address, and the seed name in the set of Il2CppDumper names at that address. "Generic instance" means a type or method argument list remains after compiler-generated names (`<>c`, `<X>b__`, `<X>d__`, `k__BackingField`) are stripped. Linking took about 2 s. **4. armeabi-v7a (same APK): fails closed as documented** ``` unsupported-container: only 64-bit little-endian images are supported, not 32-bit little-endian ```
Author
Owner

@8rulerstar commented on GitHub (Oct 19, 2026):

@arielfikru thank you very much for taking the time to run this and for tracking it down so precisely. It was a real bug, and your diagnosis was exactly right. I've fixed it in 233ca4d38, following the same approach as your patch: a method pointer array that lies wholly inside zero-fill is now accepted, and its slots are read as methods without bodies. I also added an ELF fixture whose second module keeps its array past p_filesz, and checked that the new test fails if either half of the fix is reverted. Thank you as well for the armeabi-v7a check.

@alexlanghart thank you again for the Windows numbers. Before asking you for anything more, I'm running a seeded Ghidra decompile on the public Windows build myself, so that I can say concretely what to look for. I'll follow up here once I have the result.

<!-- gh-comment-id:6263232407 --> @8rulerstar commented on GitHub (Oct 19, 2026): @arielfikru thank you very much for taking the time to run this and for tracking it down so precisely. It was a real bug, and your diagnosis was exactly right. I've fixed it in 233ca4d38, following the same approach as your patch: a method pointer array that lies wholly inside zero-fill is now accepted, and its slots are read as methods without bodies. I also added an ELF fixture whose second module keeps its array past `p_filesz`, and checked that the new test fails if either half of the fix is reverted. Thank you as well for the armeabi-v7a check. @alexlanghart thank you again for the Windows numbers. Before asking you for anything more, I'm running a seeded Ghidra decompile on the public Windows build myself, so that I can say concretely what to look for. I'll follow up here once I have the result.
Author
Owner

@8rulerstar commented on GitHub (Oct 19, 2026):

@alexlanghart following up as promised: I ran a seeded Ghidra 12.1.4 decompile on the public Windows build (Between Two Worlds 1.0.5, 34 MB GameAssembly.dll).

With REA_GHIDRA_SEED_FILE set, rea decompile found IngameDebugConsole.DebugLogManager$$ShouldRemoveLogEntryToShow by name, and Ghidra named the function at its seed address 0x180c5ca60 IngameDebugConsole_DebugLogManager__ShouldRemoveLogEntryToShow (each . and $ becomes _). Without seeds, the same address shows up as FUN_180c5ca60 with the same body. This is one function on one build, so it shows the seed path works on PE, not that every seed applies.

Each run took about 9 minutes, over the default 5.5-minute startup limit, so I raised REA_GHIDRA_STARTUP_TIMEOUT_MS (to 3 hours; @arielfikru's 90 minutes above is also plenty). Larger builds need a limit above their own analysis time.

There is nothing more I need from your build for this. Thanks again for the address check.

<!-- gh-comment-id:6263425975 --> @8rulerstar commented on GitHub (Oct 19, 2026): @alexlanghart following up as promised: I ran a seeded Ghidra 12.1.4 decompile on the public Windows build (Between Two Worlds 1.0.5, 34 MB `GameAssembly.dll`). With `REA_GHIDRA_SEED_FILE` set, `rea decompile` found `IngameDebugConsole.DebugLogManager$$ShouldRemoveLogEntryToShow` by name, and Ghidra named the function at its seed address `0x180c5ca60` `IngameDebugConsole_DebugLogManager__ShouldRemoveLogEntryToShow` (each `.` and `$` becomes `_`). Without seeds, the same address shows up as `FUN_180c5ca60` with the same body. This is one function on one build, so it shows the seed path works on PE, not that every seed applies. Each run took about 9 minutes, over the default 5.5-minute startup limit, so I raised `REA_GHIDRA_STARTUP_TIMEOUT_MS` (to 3 hours; @arielfikru's 90 minutes above is also plenty). Larger builds need a limit above their own analysis time. There is nothing more I need from your build for this. Thanks again for the address check.
Author
Owner

@8rulerstar commented on GitHub (Oct 20, 2026):

#3784 was closed by the #3807 merge, so I've opened the follow-ups as drafts:

  • #5326: member names, string literals and metadata versions 24 and 27 (metadata only).
  • #5327: native method addresses and --ghidra-seeds, stacked on #5326.

#5327 includes the zero-fill fix based on @arielfikru's diagnosis of the .bss method pointer array, with a fixture for it. For PE, @alexlanghart's Windows x64 comparison (111,771 match, 0 differ against Il2CppDumper) is cited next to the public Between Two Worlds build. Each method row now says code, no-body or unknown instead of a bare null. Generic method instances will follow in a separate PR.

<!-- gh-comment-id:6281116822 --> @8rulerstar commented on GitHub (Oct 20, 2026): #3784 was closed by the #3807 merge, so I've opened the follow-ups as drafts: - #5326: member names, string literals and metadata versions 24 and 27 (metadata only). - #5327: native method addresses and `--ghidra-seeds`, stacked on #5326. #5327 includes the zero-fill fix based on @arielfikru's diagnosis of the `.bss` method pointer array, with a fixture for it. For PE, @alexlanghart's Windows x64 comparison (111,771 match, 0 differ against Il2CppDumper) is cited next to the public Between Two Worlds build. Each method row now says `code`, `no-body` or `unknown` instead of a bare null. Generic method instances will follow in a separate PR.
Author
Owner

@XinwangTao commented on GitHub (Oct 20, 2026):

Thanks @alexlanghart for the clarification — that reframes the cost concern: if the 120k "already present" were created by the .pdata loader before the seed script, then seed injection is a cheap top-up on PE, and the real repeat-session cost belongs to program reuse (#1048/#3751), not to seed idempotency. Agree that a gzf-keyed reuse cache is the right lever there.

@arielfikru's ELF data point makes the scope decision much stronger: on stripped ELF the seeds are the function boundaries (99.9% created), so for Android IL2CPP the address-resolution step is not an optimization but the core capability. The RVA + load-base (0x100000) note is a good trap for the resolver design.

Great to see exp/il2cpp-ghidra validated on both PE (111,771 name matches, folding groups working) and ELF (58,901 seeds agreeing with Il2CppDumper, zero-fill fix in 233ca4d). I'd encourage opening this as a follow-up PR after #3807 lands — the PE and ELF evidence together covers the decision well.

<!-- gh-comment-id:6281158016 --> @XinwangTao commented on GitHub (Oct 20, 2026): Thanks @alexlanghart for the clarification — that reframes the cost concern: if the 120k "already present" were created by the .pdata loader before the seed script, then seed injection is a cheap top-up on PE, and the real repeat-session cost belongs to program reuse (#1048/#3751), not to seed idempotency. Agree that a gzf-keyed reuse cache is the right lever there. @arielfikru's ELF data point makes the scope decision much stronger: on stripped ELF the seeds are the function boundaries (99.9% created), so for Android IL2CPP the address-resolution step is not an optimization but the core capability. The RVA + load-base (0x100000) note is a good trap for the resolver design. Great to see exp/il2cpp-ghidra validated on both PE (111,771 name matches, folding groups working) and ELF (58,901 seeds agreeing with Il2CppDumper, zero-fill fix in 233ca4d). I'd encourage opening this as a follow-up PR after #3807 lands — the PE and ELF evidence together covers the decision well.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3150
No description provided.