"""Core IL2CPP reverse-engineering helpers for SAND's GameAssembly.dll. dump.cs (Il2CppDumper output) gives every method's RVA, file Offset, VA and signature. GameAssembly.dll holds the actual x86-64 bodies. This module: - maps VA <-> file offset via the PE section table, - builds a sorted method index from dump.cs (cached to a pickle), - attributes any VA to its containing method, - finds call-rel32 / jmp-rel32 sites that target a given VA (xrefs), - disassembles a method body and pulls out float constants it loads. No method bodies live in dump.cs — only signatures. The values we want (weapon damage floats) are operands inside the bodies, recovered here. """ import os, re, pickle, bisect, struct GAME = "/mnt/d/SteamLibrary/steamapps/common/Sand Playtest" DLL = os.path.join(GAME, "GameAssembly.dll") DUMP = "/home/downloadpizza/sand_tools/il2cpp/dump.cs" CACHE = "/home/downloadpizza/sand_tools/reverse/_method_index.pkl" IMAGE_BASE = 0x180000000 # from PE optional header (VA = IMAGE_BASE + RVA) class PE: """Minimal PE map: VA<->file-offset, plus raw byte access.""" def __init__(self, path=DLL): import pefile self.pe = pefile.PE(path, fast_load=True) self.base = self.pe.OPTIONAL_HEADER.ImageBase self.data = self.pe.__data__ # bytes-like of whole file self.sections = [] for s in self.pe.sections: self.sections.append(( s.VirtualAddress, s.Misc_VirtualSize, s.PointerToRawData, s.SizeOfRawData, s.Name.decode(errors="replace").strip("\x00"), )) def rva_to_off(self, rva): for va, vsz, praw, rsz, name in self.sections: if va <= rva < va + max(vsz, rsz): if rva - va < rsz: return praw + (rva - va) return None # in bss / uninitialized return None def off_to_rva(self, off): for va, vsz, praw, rsz, name in self.sections: if praw <= off < praw + rsz: return va + (off - praw) return None def va_to_off(self, va): return self.rva_to_off(va - self.base) def read_off(self, off, n): return bytes(self.data[off:off + n]) def read_va(self, va, n): off = self.va_to_off(va) if off is None: return None return self.read_off(off, n) def text_range(self): for va, vsz, praw, rsz, name in self.sections: if name == ".text": return va, vsz, praw, rsz return None def code_ranges(self): """Executable code sections. IL2CPP method bodies live in the 'il2cpp' section; runtime/engine glue in '.text'.""" out = [] for va, vsz, praw, rsz, name in self.sections: if name in (".text", "il2cpp"): out.append((va, vsz, praw, rsz, name)) return out _RVA_RE = re.compile(r"// RVA: 0x([0-9A-Fa-f]+) Offset: 0x([0-9A-Fa-f]+) VA: 0x([0-9A-Fa-f]+)") def build_index(force=False): """Parse dump.cs -> list of methods sorted by VA. Each entry: dict(va, rva, off, sig). sig is the C#-ish declaration line. Cached to CACHE. """ if not force and os.path.exists(CACHE): with open(CACHE, "rb") as f: return pickle.load(f) methods = [] with open(DUMP, "r", encoding="utf-8", errors="replace") as f: prev = None for line in f: m = _RVA_RE.search(line) if m: prev = (int(m.group(1), 16), int(m.group(2), 16), int(m.group(3), 16)) continue if prev is not None: sig = line.strip() rva, off, va = prev if rva != 0: methods.append({"va": va, "rva": rva, "off": off, "sig": sig}) prev = None methods.sort(key=lambda d: d["va"]) with open(CACHE, "wb") as f: pickle.dump(methods, f) return methods class Index: def __init__(self): self.methods = build_index() self.vas = [m["va"] for m in self.methods] def method_at(self, va): """Return the method whose body contains `va` (largest va <= target).""" i = bisect.bisect_right(self.vas, va) - 1 if i < 0: return None return self.methods[i] def next_va(self, va): i = bisect.bisect_right(self.vas, va) return self.vas[i] if i < len(self.vas) else None def find_sig(self, needle): return [m for m in self.methods if needle in m["sig"]] def find_xrefs(pe, target_vas, call_only=True): """Scan all code sections for E8 (call) / E9 (jmp) rel32 sites whose target is in `target_vas` (int or set/iterable of ints). Returns list of (site_va, opcode_byte, target_va). Linear byte scan with displacement check; a few false positives are possible but validated by the disassembler downstream. """ if isinstance(target_vas, int): target_vas = {target_vas} else: target_vas = set(target_vas) out = [] base = pe.base data = pe.data for sva, svsz, spraw, srsz, name in pe.code_ranges(): raw = bytes(data[spraw:spraw + srsz]) n = len(raw) j = 0 while True: j = raw.find(b"\xe8", j) if call_only else _find_either(raw, j) if j < 0 or j + 5 > n: break disp = struct.unpack_from(" disp + i == (target_va - base - sva - 4) where i is the in-section offset of the disp field. We solve for i. """ import numpy as np out = [] base = pe.base data = pe.data for sva, svsz, spraw, srsz, name in pe.code_ranges(): raw = np.frombuffer(bytes(data[spraw:spraw + srsz]), dtype=np.uint8) n = len(raw) # little-endian int32 at every byte offset 0..n-4 disp = (raw[0:n-3].astype(np.int64) | (raw[1:n-2].astype(np.int64) << 8) | (raw[2:n-1].astype(np.int64) << 16) | (raw[3:n].astype(np.int64) << 24)) # sign-extend 32-bit disp = disp - ((disp & 0x80000000) << 1) idx = np.arange(n - 3, dtype=np.int64) const = target_va - base - sva - 4 hits = np.nonzero(disp + idx == const)[0] for i in hits.tolist(): out.append(base + sva + int(i)) # va of the disp field return out def find_rip_refs_batch(pe, targets): """One pass over code: for a set of target VAs, return {target_va: [disp_field_va,...]}. Assumes disp32 is the final 4 bytes of the instruction (movss/mov/lea loads).""" import numpy as np targets = np.array(sorted(set(int(t) for t in targets)), dtype=np.int64) out = {int(t): [] for t in targets} base = pe.base data = pe.data for sva, svsz, spraw, srsz, name in pe.code_ranges(): raw = np.frombuffer(bytes(data[spraw:spraw + srsz]), dtype=np.uint8) n = len(raw) disp = (raw[0:n-3].astype(np.int64) | (raw[1:n-2].astype(np.int64) << 8) | (raw[2:n-1].astype(np.int64) << 16) | (raw[3:n].astype(np.int64) << 24)) disp = disp - ((disp & 0x80000000) << 1) idx = np.arange(n - 3, dtype=np.int64) tgt = base + sva + idx + 4 + disp # absolute target of a disp-final instruction mask = np.isin(tgt, targets) for i in np.nonzero(mask)[0]: t = int(tgt[i]) if t in out: out[t].append(base + sva + int(i)) return out def disasm_around(pe, va, back=24, total=64): """Disassemble a window [va-back, va-back+total) to recover the instruction that contains/precedes `va` (e.g. a RIP-ref disp field).""" from capstone import Cs, CS_ARCH_X86, CS_MODE_64 start = va - back off = pe.va_to_off(start) code = pe.read_off(off, total) md = Cs(CS_ARCH_X86, CS_MODE_64) md.detail = True return list(md.disasm(code, start)) def _find_either(raw, start): a = raw.find(b"\xe8", start) b = raw.find(b"\xe9", start) if a < 0: return b if b < 0: return a return min(a, b) def disasm_method(pe, idx, va, max_bytes=0x4000): """Disassemble one method body (until next method VA or max_bytes).""" from capstone import Cs, CS_ARCH_X86, CS_MODE_64 nxt = idx.next_va(va) size = max_bytes if nxt: size = min(size, nxt - va) off = pe.va_to_off(va) code = pe.read_off(off, size) md = Cs(CS_ARCH_X86, CS_MODE_64) md.detail = True return list(md.disasm(code, va)) def rip_target(insn): """If an instruction has a RIP-relative memory operand, return its absolute VA.""" from capstone import x86 for op in insn.operands: if op.type == x86.X86_OP_MEM and op.mem.base == x86.X86_REG_RIP: return insn.address + insn.size + op.mem.disp return None def read_f32(pe, va): b = pe.read_va(va, 4) return None if b is None else struct.unpack("= 0: if j + 8 <= n: modrm = raw[j + 3] if modrm in rm_ok: disp = _s.unpack_from("= lo) and (hi is None or av <= hi): out.append((insn_va, v, kind)) j = raw.find(pref, j + 1) return out def analyze(pe, idx, va, max_bytes=0x6000, show=True): """Disassemble a method; resolve call targets to method sigs and read any RIP-relative float/double constants loaded (movss/movsd/comiss/addss/...). Returns (insns, calls, floats).""" from capstone import x86 ins = disasm_method(pe, idx, va, max_bytes) calls, floats, lines = [], [], [] for i in ins: tgt = rip_target(i) note = "" mn = i.mnemonic if mn in ("call", "jmp") and i.operands and i.operands[0].type == x86.X86_OP_IMM: d = i.operands[0].imm m = idx.method_at(d) nm = m["sig"] if (m and abs(m["va"] - d) < 0x6000) else "?" if mn == "call": calls.append((i.address, d, nm)) note = " -> %x %s" % (d, nm[:78]) elif tgt is not None and ("ss" in mn or "sd" in mn or mn == "movss" or mn == "movsd"): # float/double constant load from .rdata if "sd" in mn: v = read_f64(pe, tgt) floats.append((i.address, tgt, v, "f64")) note = " ; =%s (f64 @%x)" % (v, tgt) else: v = read_f32(pe, tgt) floats.append((i.address, tgt, v, "f32")) note = " ; =%s (f32 @%x)" % (v, tgt) elif tgt is not None: note = " ; [data %x]" % tgt lines.append(" %x %-9s %s%s" % (i.address, mn, i.op_str, note)) if show: print("\n".join(lines)) return ins, calls, floats if __name__ == "__main__": pe = PE() idx = Index() print("methods indexed:", len(idx.methods)) print("text section:", [hex(x) for x in pe.text_range()]) # sanity: the known DamagePhysical float setter m = idx.method_at(0x1849EC5B0) print("method at 0x1849EC5B0:", m["sig"] if m else None, hex(m["va"]) if m else "")