diff --git a/README.md b/README.md index 657f14b..682a8a9 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - **IL2CPP support** via [Cpp2IL](https://github.com/SamboyCoding/Cpp2IL): `GameAssembly.dll` / `libil2cpp.so` + `global-metadata.dat` are detected automatically, dummy assemblies are generated and cached, and they feed the .NET explorer and MonoBehaviour field parsing. - **.NET class explorer** — browse the game's managed assemblies as C#-like stubs (with optional IL). GUI tab **".NET Classes"**, CLI `-m dotnet`, MCP `dotnet_list` / `dotnet_type`. -- **Ghidra / Il2CppDumper package** (`-m il2cpp`) — generates `script.json`, `il2cpp.h`, `il2cpp_ghidra.h` and bundled `ghidra.py` / `ghidra_with_struct.py` scripts (patched for Ghidra Jython 2.7 **and** 11.3+ PyGhidra) so functions get named the same way [Il2CppDumper](https://github.com/Perfare/Il2CppDumper) does. Plus `--il2cpp-lookup`, `--il2cpp-strings`, and `--il2cpp-dummy-dll` (export the dummy .NET assemblies to `/DummyDll` for dnSpy / ILSpy / dotPeek). +- **Ghidra / Il2CppDumper package** (`-m il2cpp`) — generates `script.json`, `il2cpp.h`, `il2cpp_ghidra.h` and bundled `ghidra.py` / `ghidra_with_struct.py` scripts (patched for Ghidra Jython 2.7 **and** 11.3+ PyGhidra) so functions get named the same way [Il2CppDumper](https://github.com/Perfare/Il2CppDumper) does. Plus `--il2cpp-lookup` (name ↔ address, `--il2cpp-fuzzy` for typo tolerance), `--il2cpp-strings`, `--il2cpp-decode` / `--il2cpp-data` (recover the float/double/int constants Ghidra hides as raw hex or `DAT_` loads), `--il2cpp-clean` (strip IL2CPP boilerplate from decompiled functions + annotate constants), `--il2cpp-suggest` (map a feature to the `Type$$` symbols worth decompiling), and `--il2cpp-dummy-dll` (export the dummy .NET assemblies to `/DummyDll` for dnSpy / ILSpy / dotPeek). - **Type-tree database (TPK)** — decode type-tree-stripped builds via a bundled `classdata.tpk` (`--typetree-db`, auto-loaded when present). - **glTF 2.0 export** (`.glb` / `.gltf`) as an FBX-free alternative (meshes, skinning, materials + embedded textures, node animations). - **Godot 4 export** (`-m godot`) — converts a game's **materials** and **particle FX** into Godot 4 scaffolds: @@ -123,11 +123,18 @@ UnityRiftCLI -m animator ``` UnityRiftCLI -m il2cpp -o ``` -Look up a method/address while decompiling: +Look up a method/address while decompiling (add `--il2cpp-fuzzy` for typo-tolerant name matching): ``` UnityRiftCLI -m il2cpp --il2cpp-lookup PlayerController$$Update UnityRiftCLI -m il2cpp --il2cpp-lookup 0x1A2B3C ``` +Recover the constants Ghidra hides as raw hex, clean up a decompiled function, and find symbols to look at: +``` +UnityRiftCLI -m il2cpp --il2cpp-decode 0x3f19999a3e99999a # -> (0.3f, 0.6f) +UnityRiftCLI -m il2cpp --il2cpp-data 0x4fb2ada # read the DAT_ literal from the binary +UnityRiftCLI -m il2cpp --il2cpp-clean FUN_1800abcd.c # strip boilerplate + annotate constants +UnityRiftCLI -m il2cpp --il2cpp-suggest parry,adrenaline # ranked Type$$ symbols to decompile +``` ### Advanced Samples - Export image assets converted to webp format to a specified output folder diff --git a/UnityRiftCLI/Options/CLIOptions.cs b/UnityRiftCLI/Options/CLIOptions.cs index 11a623b..f88436c 100644 --- a/UnityRiftCLI/Options/CLIOptions.cs +++ b/UnityRiftCLI/Options/CLIOptions.cs @@ -162,6 +162,12 @@ internal static class CLIOptions public static Option f_il2cpp; public static Option> o_il2cppLookup; public static Option> o_il2cppStrings; + public static Option> o_il2cppDecode; + public static Option> o_il2cppData; + public static Option> o_il2cppClean; + public static Option> o_il2cppSuggest; + public static Option f_il2cppFuzzy; + public static Option f_il2cppCleanRaw; public static Option f_il2cppDummyDll; public static Option f_godotAttachPlugin; @@ -681,6 +687,67 @@ private static void InitOptions() optionExample: "Example: \"-m il2cpp --il2cpp-strings error\"\n", optionHelpGroup: HelpGroups.Il2Cpp ); + o_il2cppDecode = new GroupedOption> + ( + optionDefaultValue: new List(), + optionName: "--il2cpp-decode ", + optionDescription: "Decode a packed float/double/int immediate seen in Ghidra pseudocode\n" + + "(e.g. a '= 0x3f19999a3e99999a;' store -> '(0.3f, 0.6f)'). No binary read needed.\n" + + "Only for \"-m il2cpp\". *Multiple values separated by ',' or ';' without spaces\n", + optionExample: "Example: \"-m il2cpp --il2cpp-decode 0x3f19999a3e99999a\"\n", + optionHelpGroup: HelpGroups.Il2Cpp + ); + o_il2cppData = new GroupedOption> + ( + optionDefaultValue: new List(), + optionName: "--il2cpp-data ", + optionDescription: "Read the literal-pool constant behind a DAT_ load from the IL2CPP binary\n" + + "and decode it as a float/double (the hex in DAT_xxxxxxxx is the virtual address).\n" + + "Only for \"-m il2cpp\". *Multiple values separated by ',' or ';' without spaces\n", + optionExample: "Example: \"-m il2cpp --il2cpp-data 0x4fb2ada\"\n", + optionHelpGroup: HelpGroups.Il2Cpp + ); + o_il2cppClean = new GroupedOption> + ( + optionDefaultValue: new List(), + optionName: "--il2cpp-clean ", + optionDescription: "Clean Ghidra IL2CPP pseudocode: strip class-init/metadata/ctor boilerplate (by shape)\n" + + "and annotate hidden float/DAT_ constants inline. is a .c file or a folder of them.\n" + + "Only for \"-m il2cpp\". *Multiple paths separated by ',' or ';' without spaces\n", + optionExample: "Example: \"-m il2cpp --il2cpp-clean out/FUN_1800abcd.c\"\n", + optionHelpGroup: HelpGroups.Il2Cpp + ); + o_il2cppSuggest = new GroupedOption> + ( + optionDefaultValue: new List(), + optionName: "--il2cpp-suggest ", + optionDescription: "Suggest IL2CPP types/methods worth decompiling for the given keyword(s) or a text file\n" + + "(CamelCase / long identifiers are extracted from a file). Prints ranked Type$$ prefixes.\n" + + "Combine with --il2cpp-fuzzy for typo-tolerant matching. Only for \"-m il2cpp\".\n", + optionExample: "Example: \"-m il2cpp --il2cpp-suggest adrenaline,parry,damage\"\n", + optionHelpGroup: HelpGroups.Il2Cpp + ); + f_il2cppFuzzy = new GroupedOption + ( + optionDefaultValue: false, + optionName: "--il2cpp-fuzzy", + optionDescription: "(Flag) Typo-tolerant matching for --il2cpp-lookup and --il2cpp-suggest\n" + + "(ranks near-miss names by similarity instead of exact substring).\n" + + "Only for \"-m il2cpp\".\n", + optionExample: "Example: \"-m il2cpp --il2cpp-lookup PlyerController --il2cpp-fuzzy\"\n", + optionHelpGroup: HelpGroups.Il2Cpp, + isFlag: true + ); + f_il2cppCleanRaw = new GroupedOption + ( + optionDefaultValue: false, + optionName: "--il2cpp-clean-raw", + optionDescription: "(Flag) For --il2cpp-clean: keep the structural noise, only annotate constants.\n" + + "Only for \"-m il2cpp\".\n", + optionExample: "", + optionHelpGroup: HelpGroups.Il2Cpp, + isFlag: true + ); f_il2cppDummyDll = new GroupedOption ( optionDefaultValue: false, @@ -1030,6 +1097,18 @@ public static void ParseArgs(string[] args) f_il2cppDummyDll.Value = true; flagIndexes.Add(i); break; + case "--il2cpp-fuzzy": + case "--il2cpp-clean-raw": + if (o_workMode.Value != WorkMode.Il2Cpp) + { + Console.WriteLine($"{"Error".Color(brightRed)} during parsing [{flag.Color(brightYellow)}] flag. This flag is only for \"-m il2cpp\".\n"); + ShowOptionDescription(o_workMode); + return; + } + if (flag == "--il2cpp-fuzzy") f_il2cppFuzzy.Value = true; + else f_il2cppCleanRaw.Value = true; + flagIndexes.Add(i); + break; case "--godot-attach-plugin": if (o_workMode.Value != WorkMode.GodotScene) { @@ -1583,6 +1662,24 @@ public static void ParseArgs(string[] args) } o_il2cppStrings.Value.AddRange(ValueSplitter(value, isRegex: f_filterWithRegex.Value)); break; + case "--il2cpp-decode": + case "--il2cpp-data": + case "--il2cpp-clean": + case "--il2cpp-suggest": + if (o_workMode.Value != WorkMode.Il2Cpp) + { + Console.WriteLine($"{"Error".Color(brightRed)} during parsing [{option.Color(brightYellow)}] option. This option is only for \"-m il2cpp\".\n"); + ShowOptionDescription(o_workMode); + return; + } + switch (option) + { + case "--il2cpp-decode": o_il2cppDecode.Value.AddRange(ValueSplitter(value)); break; + case "--il2cpp-data": o_il2cppData.Value.AddRange(ValueSplitter(value)); break; + case "--il2cpp-clean": o_il2cppClean.Value.AddRange(ValueSplitter(value)); break; + default: o_il2cppSuggest.Value.AddRange(ValueSplitter(value)); break; + } + break; case "--typetree-db": if (File.Exists(value)) { diff --git a/UnityRiftCLI/Studio.Il2Cpp.cs b/UnityRiftCLI/Studio.Il2Cpp.cs index 85c8904..14531e3 100644 --- a/UnityRiftCLI/Studio.Il2Cpp.cs +++ b/UnityRiftCLI/Studio.Il2Cpp.cs @@ -3,6 +3,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; +using System.Collections.Generic; using System.IO; using Ansi = UnityRift.ColorConsole; @@ -86,10 +87,31 @@ public static void ExportIl2CppGhidraPackage() var lookups = CLIOptions.o_il2cppLookup.Value; var strings = CLIOptions.o_il2cppStrings.Value; + var decodes = CLIOptions.o_il2cppDecode.Value; + var datas = CLIOptions.o_il2cppData.Value; + var cleans = CLIOptions.o_il2cppClean.Value; + var suggests = CLIOptions.o_il2cppSuggest.Value; var regex = CLIOptions.f_filterWithRegex.Value; - if (lookups.Count == 0 && strings.Count == 0) + var fuzzy = CLIOptions.f_il2cppFuzzy.Value; + if (lookups.Count == 0 && strings.Count == 0 && decodes.Count == 0 && + datas.Count == 0 && cleans.Count == 0 && suggests.Count == 0) return; + // The game binary backs DAT_ literal-pool reads (data/clean); opened lazily. + BinaryImage image = null; + var imageTried = false; + BinaryImage GetImage() + { + if (!imageTried) + { + imageTried = true; + image = BinaryImage.TryOpen(game.BinaryPath); + if (image == null) + Logger.Warning($"Could not open IL2CPP binary for literal-pool reads: {game.BinaryPath}"); + } + return image; + } + var root = new JObject(); if (lookups.Count > 0) { @@ -104,7 +126,7 @@ public static void ExportIl2CppGhidraPackage() } else { - item["result"] = idx.FindByName(q, regex); + item["result"] = idx.FindByName(q, regex, fuzzy: fuzzy); } arr.Add(item); } @@ -117,7 +139,64 @@ public static void ExportIl2CppGhidraPackage() arr.Add(new JObject { ["query"] = q, ["result"] = idx.FindStrings(q, regex) }); root["strings"] = arr; } - Logger.Default.Log(LoggerEvent.Info, root.ToString(Formatting.Indented), ignoreLevel: true); + if (decodes.Count > 0) + { + var arr = new JArray(); + foreach (var q in decodes) + { + var item = new JObject { ["value"] = q }; + if (Il2CppConstantResolver.TryParseHex(q, out var v)) + item["decoded"] = Il2CppConstantResolver.DecodeImmediate(v) ?? "(not a plausible float/double/int)"; + else + item["error"] = "not a hex value"; + arr.Add(item); + } + root["decode"] = arr; + } + if (datas.Count > 0) + { + var arr = new JArray(); + var img = GetImage(); + foreach (var q in datas) + { + var item = new JObject { ["va"] = q }; + if (!Il2CppConstantResolver.TryParseHex(q, out var va)) item["error"] = "not a hex address"; + else if (img == null) item["error"] = "binary not available"; + else item["decoded"] = img.ResolveData(va) ?? "(no plausible constant at this address)"; + arr.Add(item); + } + root["data"] = arr; + } + if (cleans.Count > 0) + { + var strip = !CLIOptions.f_il2cppCleanRaw.Value; + var img = GetImage(); + foreach (var pathArg in cleans) + { + var files = new List(); + if (Directory.Exists(pathArg)) files.AddRange(Directory.GetFiles(pathArg, "*.c")); + else if (File.Exists(pathArg)) files.Add(pathArg); + else { Logger.Warning($"--il2cpp-clean: path not found: {pathArg}"); continue; } + foreach (var f in files) + { + var cleaned = Il2CppDecompCleaner.Clean(File.ReadAllText(f), strip, floats: true, img: img); + Logger.Default.Log(LoggerEvent.Info, $"========= {Path.GetFileName(f)}\n{cleaned}", ignoreLevel: true); + } + } + } + if (suggests.Count > 0) + { + // A single arg that is a file path -> extract keywords from its text; otherwise treat as keywords. + var keywords = new List(); + foreach (var s in suggests) + { + if (File.Exists(s)) keywords.AddRange(Il2CppSymbolIndex.KeywordsFromText(File.ReadAllText(s))); + else keywords.Add(s); + } + root["suggest"] = idx.Suggest(keywords, methods: true, fuzzy: fuzzy); + } + if (root.HasValues) + Logger.Default.Log(LoggerEvent.Info, root.ToString(Formatting.Indented), ignoreLevel: true); #endif } } diff --git a/UnityRiftUtility/Il2Cpp/Il2CppConstantResolver.cs b/UnityRiftUtility/Il2Cpp/Il2CppConstantResolver.cs new file mode 100644 index 0000000..d131f3e --- /dev/null +++ b/UnityRiftUtility/Il2Cpp/Il2CppConstantResolver.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; + +namespace UnityRift +{ + /// + /// Recovers the float/double/int constants that a Ghidra IL2CPP decompilation hides as raw + /// hex, so the actual game-logic numbers become readable. Two independent jobs: + /// + /// 1. Packed immediate decoding (no binary needed). ARM64/x64 materialise float/double field + /// initialisers as 32/64-bit immediate stores that Ghidra renders as a hex literal + /// (*(undefined8 *)(x + 0x24) = 0x3f19999a3e99999a;). A 16-hex-digit value is two + /// little-endian 32-bit floats packed into one 64-bit store (low word = lower address). + /// + /// 2. DAT_<va> literal-pool resolution (reads the binary). Constants that don't + /// fit an immediate are loaded from a read-only literal pool; the hex in the symbol IS the + /// virtual address. maps VA → file offset for PE and ELF. + /// + /// Framework-agnostic (no Cpp2IL/LibCpp2IL dependency): usable from any .NET target and alongside + /// a live Ghidra session. Mirrors the standalone il2cpp_floats.py helper. + /// + public static class Il2CppConstantResolver + { + private static readonly CultureInfo Inv = CultureInfo.InvariantCulture; + + // A 32-bit pattern is only shown as a float if the IEEE-754 value is "sane" (not a denormal, + // not absurdly large) so ordinary ints/enums are not mislabelled as floats. + private static bool PlausibleFloat(float f) + { + if (f == 0f) return true; + if (float.IsNaN(f) || float.IsInfinity(f)) return false; + var a = Math.Abs(f); + return a >= 1e-4 && a <= 1e9; + } + + private static bool PlausibleDouble(double d) + { + if (d == 0d) return true; + if (double.IsNaN(d) || double.IsInfinity(d)) return false; + var a = Math.Abs(d); + return a >= 1e-6 && a <= 1e12; + } + + private static float F32(uint u) => BitConverter.ToSingle(BitConverter.GetBytes(u), 0); + private static double F64(ulong u) => BitConverter.ToDouble(BitConverter.GetBytes(u), 0); + + /// Tidy number formatting: 0.6000000238 → "0.6", 1.0 → "1.0". + private static string Fmt(double f) + { + var r = Math.Round(f, 6); + if (r == Math.Truncate(r) && Math.Abs(r) < 1e15) + return ((long)r).ToString(Inv) + ".0"; + return r.ToString("0.######", Inv); + } + + /// + /// Decodes the int value of a = 0x...; store into a short annotation such as + /// "(0.3f, 0.6f)", "1.0f" or "(20, 100)", or null if nothing plausible. + /// + public static string DecodeImmediate(ulong hexval) + { + if (hexval > 0xFFFFFFFF) + { + var lo = (uint)(hexval & 0xFFFFFFFF); + var hi = (uint)((hexval >> 32) & 0xFFFFFFFF); + var flo = F32(lo); + var fhi = F32(hi); + // Two packed 32-bit floats (low word = lower address, shown first). + if (PlausibleFloat(flo) && PlausibleFloat(fhi) && !(lo == 0 && hi == 0)) + return $"({Fmt(flo)}f, {Fmt(fhi)}f)"; + // Or a single 64-bit double. + var d = F64(hexval); + if (PlausibleDouble(d) && hexval != 0) + return Fmt(d); + // Or two small packed 32-bit ints (e.g. 0x6400000014 -> (20, 100)). + if (lo > 0 && lo < 0x100000 && hi > 0 && hi < 0x100000) + return $"({lo}, {hi})"; + return null; + } + else + { + var f = F32((uint)hexval); + if (PlausibleFloat(f) && hexval != 0) + return $"{Fmt(f)}f"; + return null; + } + } + + /// Parses "0x1234" / "1234" (hex) into a ulong; returns false on garbage. + public static bool TryParseHex(string s, out ulong value) + { + value = 0; + if (string.IsNullOrWhiteSpace(s)) return false; + s = s.Trim(); + if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) s = s.Substring(2); + return ulong.TryParse(s, NumberStyles.HexNumber, Inv, out value); + } + } + + /// + /// Minimal PE/ELF (32/64-bit) virtual-address → file-offset mapper that reads raw bytes, used to + /// resolve DAT_<va> literal-pool loads to their constant value. No external dependency. + /// + public sealed class BinaryImage + { + private readonly byte[] _data; + private readonly List<(ulong va, ulong size, long off)> _segs = new List<(ulong, ulong, long)>(); + + public bool Ok => _segs.Count > 0; + + private BinaryImage(byte[] data) { _data = data; } + + /// Opens a GameAssembly.dll (PE) or libil2cpp.so (ELF); returns null if it can't be parsed. + public static BinaryImage TryOpen(string path) + { + try + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null; + var img = new BinaryImage(File.ReadAllBytes(path)); + if (img._data.Length < 0x40) return null; + var magic = BitConverter.ToUInt16(img._data, 0); + if (magic == 0x5A4D) img.ParsePe(); + else if (img._data[0] == 0x7F && img._data[1] == (byte)'E' && img._data[2] == (byte)'L' && img._data[3] == (byte)'F') img.ParseElf(); + else return null; + return img.Ok ? img : null; + } + catch + { + return null; + } + } + + private void ParsePe() + { + var lfanew = BitConverter.ToInt32(_data, 0x3C); + if (BitConverter.ToUInt32(_data, lfanew) != 0x00004550) return; // "PE\0\0" + var coff = lfanew + 4; + var numSections = BitConverter.ToUInt16(_data, coff + 2); + var optSize = BitConverter.ToUInt16(_data, coff + 16); + var optStart = coff + 20; + var optMagic = BitConverter.ToUInt16(_data, optStart); + ulong imageBase; + if (optMagic == 0x20B) imageBase = BitConverter.ToUInt64(_data, optStart + 24); // PE32+ + else if (optMagic == 0x10B) imageBase = BitConverter.ToUInt32(_data, optStart + 28); // PE32 + else return; + var sec = optStart + optSize; + for (var i = 0; i < numSections; i++) + { + var s = sec + i * 40; + var virtualAddress = BitConverter.ToUInt32(_data, s + 12); + var sizeOfRaw = BitConverter.ToUInt32(_data, s + 16); + var ptrToRaw = BitConverter.ToUInt32(_data, s + 20); + if (sizeOfRaw == 0) continue; + _segs.Add((imageBase + virtualAddress, sizeOfRaw, ptrToRaw)); + } + } + + private void ParseElf() + { + var is64 = _data[4] == 2; + var little = _data[5] != 2; + if (!little) return; // Unity binaries are little-endian + ulong ePhoff; + ushort ePhentsize, ePhnum; + if (is64) + { + ePhoff = BitConverter.ToUInt64(_data, 0x20); + ePhentsize = BitConverter.ToUInt16(_data, 0x36); + ePhnum = BitConverter.ToUInt16(_data, 0x38); + } + else + { + ePhoff = BitConverter.ToUInt32(_data, 0x1C); + ePhentsize = BitConverter.ToUInt16(_data, 0x2A); + ePhnum = BitConverter.ToUInt16(_data, 0x2C); + } + const uint PT_LOAD = 1; + for (var i = 0; i < ePhnum; i++) + { + var off = (long)ePhoff + i * ePhentsize; + var pType = BitConverter.ToUInt32(_data, (int)off); + if (pType != PT_LOAD) continue; + ulong pOffset, pVaddr, pFilesz; + if (is64) + { + pOffset = BitConverter.ToUInt64(_data, (int)off + 0x08); + pVaddr = BitConverter.ToUInt64(_data, (int)off + 0x10); + pFilesz = BitConverter.ToUInt64(_data, (int)off + 0x20); + } + else + { + pOffset = BitConverter.ToUInt32(_data, (int)off + 0x04); + pVaddr = BitConverter.ToUInt32(_data, (int)off + 0x08); + pFilesz = BitConverter.ToUInt32(_data, (int)off + 0x10); + } + if (pFilesz == 0) continue; + _segs.Add((pVaddr, pFilesz, (long)pOffset)); + } + } + + private long VaToOffset(ulong va) + { + foreach (var (segVa, size, off) in _segs) + if (va >= segVa && va < segVa + size) return off + (long)(va - segVa); + return -1; + } + + /// Reads bytes at virtual address , or null if out of range. + public byte[] Read(ulong va, int n) + { + var o = VaToOffset(va); + if (o < 0 || o + n > _data.Length) return null; + var buf = new byte[n]; + Array.Copy(_data, o, buf, 0, n); + return buf; + } + + /// Best-guess annotation for a DAT_<va> literal load: a plausible float, then double. + public string ResolveData(ulong va) + { + var b = Read(va, 8); + if (b == null) + { + b = Read(va, 4); + if (b == null) return null; + } + var u32 = BitConverter.ToUInt32(b, 0); + var ann = Il2CppConstantResolver.DecodeImmediate(u32); + if (ann != null && u32 != 0) return ann; + if (b.Length >= 8) + { + var u64 = BitConverter.ToUInt64(b, 0); + var d = Il2CppConstantResolver.DecodeImmediate(u64); + if (d != null && u64 != 0) return d; + } + return null; + } + } +} diff --git a/UnityRiftUtility/Il2Cpp/Il2CppDecompCleaner.cs b/UnityRiftUtility/Il2Cpp/Il2CppDecompCleaner.cs new file mode 100644 index 0000000..93ddf86 --- /dev/null +++ b/UnityRiftUtility/Il2Cpp/Il2CppDecompCleaner.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace UnityRift +{ + /// + /// Makes Ghidra IL2CPP pseudocode readable: strips the boilerplate that IL2CPP emits into every + /// function (class-init guards, metadata-init thunks, ctor scaffolding, empty declarations) by + /// SHAPE rather than by literal DAT_/FUN_ addresses, so it survives a rebased/rebuilt binary, and + /// annotates hidden float/double/int constants in place via . + /// + /// Framework-agnostic text transform (no Cpp2IL dependency). Mirrors the standalone clean_dec.py + /// helper, minus its project-specific namespace shortening. + /// + public static class Il2CppDecompCleaner + { + // Structural noise, matched by SHAPE (not by specific addresses). + private static readonly string[] StructSkip = + { + // IL2CPP class-init guard triplet: if ((DAT_x & 1) == 0) { thunk(0x..); DAT_x = 1; } + @"\(DAT_[0-9a-fx]+ & 1\) == 0", + @"^\s*DAT_[0-9a-fx]+ = 1;\s*$", + @"^\s*_?thunk_FUN_[0-9a-f]+\(0x[0-9a-f]+\);\s*$", // il2cpp_codegen_initialize_* thunk + // Managed base-ctor / init boilerplate + @"System_Object___ctor\([^)]*\)", + @"^\s*Il2CppObject__.*;\s*$", + // Decompiler bookkeeping + @"WARNING: (Globals|Type prop|Could not|Removing|Restarted)", + @"^\s*halt_baddata\(\);\s*$", + // Bare local declarations with no initialiser (undefined4 uVarN; etc.) + @"^\s*(undefined\d*|u?long|u?int|float|double|u?short|byte|bool|code|char)\s*\*?\s*[a-zA-Z_]\w*\s*;\s*$", + // Empty lines and lone braces + @"^\s*$", + @"^\s*[{}]\s*$", + // Trivial goto/label scaffolding + @"^\s*goto LAB_\w+;\s*$", + @"^\s*LAB_\w+:\s*$", + }; + + private static readonly Regex StructRe = new Regex(string.Join("|", StructSkip), RegexOptions.Compiled); + private static readonly Regex HexStore = new Regex(@"=\s*(0x[0-9a-fA-F]{8,16})\s*;", RegexOptions.Compiled); + private static readonly Regex DatRef = new Regex(@"\bDAT_([0-9a-fA-F]{5,8})\b", RegexOptions.Compiled); + + private static string AnnotateFloats(string line, BinaryImage img) + { + var notes = new List(); + foreach (Match m in HexStore.Matches(line)) + { + if (Il2CppConstantResolver.TryParseHex(m.Groups[1].Value, out var v)) + { + var ann = Il2CppConstantResolver.DecodeImmediate(v); + if (ann != null) notes.Add(ann); + } + } + if (img != null) + { + var seen = new HashSet(); + foreach (Match m in DatRef.Matches(line)) + { + if (!Il2CppConstantResolver.TryParseHex("0x" + m.Groups[1].Value, out var va)) continue; + if (!seen.Add(va)) continue; + var ann = img.ResolveData(va); + if (ann != null) notes.Add($"DAT_{m.Groups[1].Value}={ann}"); + } + } + var trimmed = line.TrimEnd(); + return notes.Count > 0 ? trimmed + " /* " + string.Join(", ", notes) + " */" : trimmed; + } + + /// + /// Cleans a block of Ghidra pseudocode. removes structural noise; + /// annotates constants ( also resolves DAT_ loads). + /// + public static string Clean(string text, bool strip = true, bool floats = true, BinaryImage img = null) + { + var lines = text.Replace("\r", "").Split('\n'); + var outLines = new List(lines.Length); + foreach (var raw in lines) + { + if (strip && StructRe.IsMatch(raw)) continue; + outLines.Add(floats ? AnnotateFloats(raw, img) : raw.TrimEnd()); + } + // Collapse runs of blank lines left behind by stripping. + var sb = new StringBuilder(); + var prevBlank = false; + foreach (var l in outLines) + { + var blank = l.Length == 0; + if (blank && prevBlank) continue; + sb.Append(l).Append('\n'); + prevBlank = blank; + } + return sb.ToString().TrimEnd('\n'); + } + } +} diff --git a/UnityRiftUtility/Il2Cpp/Il2CppSymbolIndex.cs b/UnityRiftUtility/Il2Cpp/Il2CppSymbolIndex.cs index bb54fef..dc12382 100644 --- a/UnityRiftUtility/Il2Cpp/Il2CppSymbolIndex.cs +++ b/UnityRiftUtility/Il2Cpp/Il2CppSymbolIndex.cs @@ -139,9 +139,13 @@ private static int FloorIndex(List sorted, ulong value) return ans; } - /// Name search over methods and metadata symbols (substring, or regex). Case-insensitive. - public JArray FindByName(string query, bool regex, int max = 50) + /// Name search over methods and metadata symbols (substring, or regex). Case-insensitive. + /// With , also matches near-misses (typo-tolerant) and ranks by similarity. + public JArray FindByName(string query, bool regex, int max = 50, bool fuzzy = false) { + if (fuzzy && !regex) + return FindFuzzy(query, max); + Func match; if (regex) { @@ -170,6 +174,128 @@ public JArray FindByName(string query, bool regex, int max = 50) return arr; } + private JArray FindFuzzy(string query, int max) + { + var q = query.ToLowerInvariant(); + var scored = new List<(double score, Method m)>(); + foreach (var m in Methods) + { + if (m.Name == null) continue; + var name = m.Name.ToLowerInvariant(); + var contains = name.IndexOf(q, StringComparison.Ordinal) >= 0; + var s = Ratio(q, Leaf(name)); + if (contains) s += 1.0; + if (s >= 0.6) scored.Add((s, m)); + } + return new JArray(scored.OrderByDescending(x => x.score).Take(max).Select(x => + new JObject { ["kind"] = "method", ["name"] = x.m.Name, ["rva"] = "0x" + x.m.Rva.ToString("X"), ["va"] = Va(x.m.Rva), ["signature"] = x.m.Signature, ["score"] = Math.Round(x.score, 3) })); + } + + #region fuzzy suggestion + + private static readonly HashSet StopWords = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "the","and","for","with","this","that","from","into","your","have","will","value","float","int","bool", + "void","null","true","false","return","string","object","class","struct","public","private","static", + "get","set","update","start","awake","ctor","field","backing","enum","using","namespace", + }; + + /// The type/method leaf name (drops namespace, keeps the identifier we compare against). + private static string Leaf(string name) + { + var t = name.Split(new[] { "$$" }, StringSplitOptions.None)[0]; + var dot = t.LastIndexOf('.'); + return dot >= 0 ? t.Substring(dot + 1) : t; + } + + private static string TypeOf(string name) + { + var i = name.IndexOf("$$", StringComparison.Ordinal); + return i < 0 ? name : name.Substring(0, i); + } + + /// difflib-style similarity ratio in [0,1] via normalised Levenshtein distance. + public static double Ratio(string a, string b) + { + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return 0; + var n = a.Length; var m = b.Length; + var d = new int[m + 1]; + for (var j = 0; j <= m; j++) d[j] = j; + for (var i = 1; i <= n; i++) + { + var prev = d[0]; + d[0] = i; + for (var j = 1; j <= m; j++) + { + var tmp = d[j]; + d[j] = Math.Min(Math.Min(d[j] + 1, d[j - 1] + 1), prev + (a[i - 1] == b[j - 1] ? 0 : 1)); + prev = tmp; + } + } + var dist = d[m]; + var max = Math.Max(n, m); + return max == 0 ? 1.0 : 1.0 - (double)dist / max; + } + + /// Pulls domain tokens from arbitrary text (a script, notes, keywords): CamelCase parts and long identifiers. + public static List KeywordsFromText(string text) + { + var toks = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (Match m in Regex.Matches(text, @"[A-Z][a-z]+(?:[A-Z][a-z]+)+")) + foreach (Match p in Regex.Matches(m.Value, @"[A-Z][a-z]+")) + if (p.Value.Length >= 4) toks.Add(p.Value.ToLowerInvariant()); + foreach (Match m in Regex.Matches(text, @"[A-Za-z_]{4,}")) + { + var w = m.Value.Trim('_').ToLowerInvariant(); + if (w.Length >= 4 && !StopWords.Contains(w)) toks.Add(w); + } + return toks.ToList(); + } + + /// + /// Given keywords (or tokens extracted from text), suggests IL2CPP types/methods worth + /// decompiling — ranked candidate Type$$ prefixes (and optionally Type$$Method hits), + /// ready to paste into a names file or feed to Ghidra. + /// + public JObject Suggest(IEnumerable keywords, int top = 8, bool methods = false, bool fuzzy = false) + { + var kws = keywords.Select(k => k.ToLowerInvariant()).Where(k => k.Length >= 4 && !StopWords.Contains(k)).Distinct().ToList(); + var typeScore = new Dictionary(StringComparer.Ordinal); + var methodScore = new Dictionary(StringComparer.Ordinal); + foreach (var kw in kws) + { + var hits = new List<(double score, string name)>(); + foreach (var m in Methods) + { + if (m.Name == null) continue; + var lname = m.Name.ToLowerInvariant(); + var contains = lname.Contains(kw); + var r = Ratio(kw, Leaf(lname)); + var score = contains ? 1.0 + r : r; + if (contains || (fuzzy && r >= 0.72)) hits.Add((score, m.Name)); + } + hits.Sort((x, y) => y.score.CompareTo(x.score)); + foreach (var (score, name) in hits.Take(top * 4)) + { + var t = TypeOf(name); + typeScore[t] = Math.Max(typeScore.TryGetValue(t, out var s) ? s : 0, score); + if (methods) methodScore[name] = score; + } + } + var types = typeScore.OrderByDescending(x => x.Value).Select(x => x.Key + "$$").ToList(); + var result = new JObject + { + ["keywords"] = new JArray(kws), + ["typeCount"] = types.Count, + ["types"] = new JArray(types), + }; + if (methods) + result["methods"] = new JArray(methodScore.OrderByDescending(x => x.Value).Take(top * 3).Select(x => x.Key)); + return result; + } + + #endregion + public JArray FindStrings(string query, bool regex, int max = 50) { Func match; diff --git a/tools/mcp/README.md b/tools/mcp/README.md index 8a234dd..006dd53 100644 --- a/tools/mcp/README.md +++ b/tools/mcp/README.md @@ -26,6 +26,10 @@ MCP **stdio** transport (newline-delimited JSON-RPC 2.0). No `npm install` neede | `il2cpp_export` | Generate an Il2CppDumper-compatible Ghidra package (`script.json`, `il2cpp.h`, `ghidra.py`) from GameAssembly/libil2cpp (`-m il2cpp`). | | `il2cpp_lookup` | Translate managed names ↔ RVAs/VAs while decompiling (`-m il2cpp --il2cpp-lookup`). | | `il2cpp_strings` | Search IL2CPP string literals by text (`-m il2cpp --il2cpp-strings`). | +| `il2cpp_decode` | Decode a raw hex immediate into the float/double/int constant(s) it really is (`--il2cpp-decode`). | +| `il2cpp_data` | Resolve a `DAT_` literal-pool load to its constant by reading the binary (`--il2cpp-data`). | +| `il2cpp_clean` | Strip IL2CPP boilerplate from Ghidra pseudocode and annotate constants inline (`--il2cpp-clean`). | +| `il2cpp_suggest` | Suggest `Type$$`/`Type$$Method` symbols to decompile from keywords or a script file (`--il2cpp-suggest`). | | `asset_run` | Run the CLI with a verbatim argument list (escape hatch). | | `list_output` | Recursively list files in an output folder with sizes. | @@ -48,7 +52,13 @@ cached under `%LOCALAPPDATA%\UnityRift\il2cpp`; the first run takes `il2cpp_export` writes the Ghidra helpers next to those stubs (`/il2cpp/script.json`, `il2cpp_ghidra.h`, and a `ghidra/` folder with `ghidra.py` / `ghidra_with_struct.py`). Import the native binary into Ghidra, parse `il2cpp_ghidra.h`, then run the script and pick `script.json`. -`il2cpp_lookup` / `il2cpp_strings` translate names and addresses while you decompile. +`il2cpp_lookup` / `il2cpp_strings` translate names and addresses while you decompile +(`il2cpp_lookup` takes `use_fuzzy` for typo-tolerant name matching). To read the actual +game-logic numbers, `il2cpp_decode` turns a raw hex immediate into its float/double value +and `il2cpp_data` reads the constant behind a `DAT_` load from the binary; +`il2cpp_clean` makes a decompiled function readable (drops IL2CPP boilerplate, annotates +constants); and `il2cpp_suggest` maps a feature you're chasing ("parry", "adrenaline") to +the `Type$$` symbols worth decompiling. Every CLI-invoking tool returns the exact command line, the exit code, elapsed time, and the combined stdout+stderr (ANSI stripped) — i.e. the CLI's own log. diff --git a/tools/mcp/unityrift-mcp.mjs b/tools/mcp/unityrift-mcp.mjs index 727b5b9..bf041d2 100644 --- a/tools/mcp/unityrift-mcp.mjs +++ b/tools/mcp/unityrift-mcp.mjs @@ -437,6 +437,7 @@ const tools = [ input_path: { type: "string", description: "Game folder or IL2CPP binary (same as il2cpp_export)." }, query: { type: "string", description: "Name (PlayerController$$Update) or address (0x1A2B3C or va:0x1800...). " }, use_regex: { type: "boolean", description: "Treat query as a regular expression (names only)." }, + use_fuzzy: { type: "boolean", description: "Typo-tolerant name matching: rank near-miss names by similarity (names only)." }, unity_version: { type: "string" }, output_path: { type: "string", description: `Also copy the Ghidra package to /il2cpp. Default: ${defaultOutDir()}` }, timeout_sec: { type: "number", description: `Timeout in seconds (default ${DEFAULT_TIMEOUT}).` }, @@ -447,6 +448,7 @@ const tools = [ const out = a.output_path || defaultOutDir(); const args = [a.input_path, "-m", "il2cpp", "-o", out, "--il2cpp-lookup", a.query]; if (a.use_regex) args.push("--filter-with-regex"); + if (a.use_fuzzy) args.push("--il2cpp-fuzzy"); if (a.unity_version) args.push("--unity-version", a.unity_version); return resultText(await runCli(args, a.timeout_sec || Math.max(DEFAULT_TIMEOUT, 600))); }, @@ -476,6 +478,111 @@ const tools = [ return resultText(await runCli(args, a.timeout_sec || Math.max(DEFAULT_TIMEOUT, 600))); }, }, + { + name: "il2cpp_decode", + description: + "Decode a raw hex immediate that Ghidra shows in IL2CPP pseudocode into the float/double/int " + + "constant(s) it really is (CLI '-m il2cpp --il2cpp-decode'). A 16-digit value is two packed " + + "32-bit floats (e.g. 0x3f19999a3e99999a -> (0.3f, 0.6f)); an 8-digit one is a single float " + + "(0x3f800000 -> 1.0f). No binary read needed. Use it to recover the game-logic numbers behind " + + "'*(undefined8 *)(x + 0x24) = 0x...;' stores.", + inputSchema: { + type: "object", + properties: { + input_path: { type: "string", description: "Game folder or IL2CPP binary (same as il2cpp_export)." }, + value: { type: "string", description: "Hex immediate(s), e.g. '0x3f19999a3e99999a' (comma/semicolon separated for multiple)." }, + unity_version: { type: "string" }, + output_path: { type: "string", description: `Ghidra package folder. Default: ${defaultOutDir()}` }, + timeout_sec: { type: "number", description: `Timeout in seconds (default ${DEFAULT_TIMEOUT}).` }, + }, + required: ["input_path", "value"], + }, + handler: async (a) => { + const out = a.output_path || defaultOutDir(); + const args = [a.input_path, "-m", "il2cpp", "-o", out, "--il2cpp-decode", a.value]; + if (a.unity_version) args.push("--unity-version", a.unity_version); + return resultText(await runCli(args, a.timeout_sec || Math.max(DEFAULT_TIMEOUT, 600))); + }, + }, + { + name: "il2cpp_data", + description: + "Resolve a DAT_ literal-pool load to its constant value by reading the IL2CPP binary " + + "(CLI '-m il2cpp --il2cpp-data'). The hex in a Ghidra symbol like DAT_04fb2ada IS the virtual " + + "address; this maps VA -> file offset (PE and ELF) and decodes the bytes as a float/double. " + + "Use for float constants too big for an inline immediate.", + inputSchema: { + type: "object", + properties: { + input_path: { type: "string", description: "Game folder or IL2CPP binary." }, + va: { type: "string", description: "Virtual address(es) of the DAT_ load, e.g. '0x4fb2ada' (comma/semicolon separated)." }, + unity_version: { type: "string" }, + output_path: { type: "string", description: `Ghidra package folder. Default: ${defaultOutDir()}` }, + timeout_sec: { type: "number", description: `Timeout in seconds (default ${DEFAULT_TIMEOUT}).` }, + }, + required: ["input_path", "va"], + }, + handler: async (a) => { + const out = a.output_path || defaultOutDir(); + const args = [a.input_path, "-m", "il2cpp", "-o", out, "--il2cpp-data", a.va]; + if (a.unity_version) args.push("--unity-version", a.unity_version); + return resultText(await runCli(args, a.timeout_sec || Math.max(DEFAULT_TIMEOUT, 600))); + }, + }, + { + name: "il2cpp_clean", + description: + "Clean Ghidra IL2CPP pseudocode for reading (CLI '-m il2cpp --il2cpp-clean'). Strips the " + + "boilerplate IL2CPP puts in every function (class-init guards, metadata-init thunks, ctor " + + "scaffolding, empty declarations) by SHAPE (survives a rebased binary) and annotates hidden " + + "float/DAT_ constants inline. Point clean_path at a .c file exported from Ghidra, or a folder of them.", + inputSchema: { + type: "object", + properties: { + input_path: { type: "string", description: "Game folder or IL2CPP binary (backs DAT_ reads)." }, + clean_path: { type: "string", description: "A .c pseudocode file, or a folder of *.c files (comma/semicolon separated for multiple)." }, + raw: { type: "boolean", description: "Keep structural noise; only annotate constants." }, + unity_version: { type: "string" }, + output_path: { type: "string", description: `Ghidra package folder. Default: ${defaultOutDir()}` }, + timeout_sec: { type: "number", description: `Timeout in seconds (default ${DEFAULT_TIMEOUT}).` }, + }, + required: ["input_path", "clean_path"], + }, + handler: async (a) => { + const out = a.output_path || defaultOutDir(); + const args = [a.input_path, "-m", "il2cpp", "-o", out, "--il2cpp-clean", a.clean_path]; + if (a.raw) args.push("--il2cpp-clean-raw"); + if (a.unity_version) args.push("--unity-version", a.unity_version); + return resultText(await runCli(args, a.timeout_sec || Math.max(DEFAULT_TIMEOUT, 600))); + }, + }, + { + name: "il2cpp_suggest", + description: + "Suggest which IL2CPP types/methods to decompile for a feature you're reversing " + + "(CLI '-m il2cpp --il2cpp-suggest'). Give keywords (or a text/script file to extract them from) " + + "and it fuzzy-matches type/method names from the package and returns ranked Type$$ prefixes " + + "(plus specific Type$$Method hits). Bridges 'I want the parry logic' -> the actual symbol names.", + inputSchema: { + type: "object", + properties: { + input_path: { type: "string", description: "Game folder or IL2CPP binary." }, + keywords: { type: "string", description: "Keyword(s) e.g. 'adrenaline,parry,damage', OR a path to a text/script file to pull tokens from." }, + use_fuzzy: { type: "boolean", description: "Typo-tolerant matching (ranks near-miss names by similarity)." }, + unity_version: { type: "string" }, + output_path: { type: "string", description: `Ghidra package folder. Default: ${defaultOutDir()}` }, + timeout_sec: { type: "number", description: `Timeout in seconds (default ${DEFAULT_TIMEOUT}).` }, + }, + required: ["input_path", "keywords"], + }, + handler: async (a) => { + const out = a.output_path || defaultOutDir(); + const args = [a.input_path, "-m", "il2cpp", "-o", out, "--il2cpp-suggest", a.keywords]; + if (a.use_fuzzy) args.push("--il2cpp-fuzzy"); + if (a.unity_version) args.push("--unity-version", a.unity_version); + return resultText(await runCli(args, a.timeout_sec || Math.max(DEFAULT_TIMEOUT, 600))); + }, + }, { name: "asset_run", description: @@ -715,7 +822,7 @@ async function handle(msg) { reply(id, { protocolVersion: params?.protocolVersion || "2025-06-18", capabilities: { tools: {} }, - serverInfo: { name: "unityrift-cli", version: "0.4.0" }, + serverInfo: { name: "unityrift-cli", version: "0.5.0" }, }); return; case "notifications/initialized":