From e4899b43e7a774ce1448c165395986e0a7fdd127 Mon Sep 17 00:00:00 2001 From: DownloadPizza Date: Thu, 11 Jun 2026 19:08:38 +0200 Subject: [PATCH] =?UTF-8?q?weapon=20damage:=20Ghidra=20decompile=20phase?= =?UTF-8?q?=20=E2=80=94=20confirms=20server-authoritative,=20no=20static?= =?UTF-8?q?=20seed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulk-decompiled ~17.2k combat/system functions (Ghidra 11.1.2 headless) and confirmed via a second toolchain what the capstone analysis found: - GetDamage is a pure component read (returns *(comp+0x10), 0 if absent), no constants. - PlainDamgeDealerComponent has CopyTo -> it's network-replicated (snapshot from server); the client receives damage, never computes it. - No client producer writes a damage value (GetDamage has no real combat caller; the rich calc factory has 0 callers). Conclusion documented: per-weapon damage numbers are server-authoritative runtime Entitas component values, assigned via fully-generic dispatch with no static class/index/string anchor -> not statically extractable from GameAssembly.dll. Trackable static artifacts are the model + formula RVAs. Adds reusable pipeline: reverse/ghidra_decomp_targets.py, reverse/find_damage_writes.py. --- docs/WEAPON_DAMAGE.md | 29 ++++++++++++++ reverse/find_damage_writes.py | 67 ++++++++++++++++++++++++++++++++ reverse/ghidra_decomp_targets.py | 57 +++++++++++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 reverse/find_damage_writes.py create mode 100644 reverse/ghidra_decomp_targets.py diff --git a/docs/WEAPON_DAMAGE.md b/docs/WEAPON_DAMAGE.md index 5521863..14ce29e 100644 --- a/docs/WEAPON_DAMAGE.md +++ b/docs/WEAPON_DAMAGE.md @@ -90,6 +90,32 @@ can follow. That, plus server-authoritative resolution, is why the constant can' by static anchoring (in my tooling **or** Ghidra — Ghidra reads bodies but can't defeat the dynamic dispatch without already knowing which system to read). +## Ghidra deep-dive (decompilation) — what it added + +Imported `GameAssembly.dll` into Ghidra 11.1.2 (headless, JDK17) and bulk-decompiled +~17,200 combat/system functions to C (`ghidra/` — gitignored; pipeline = `methods.tsv` +from `script.json` → `scripts/decomp_targets.py` → `decomp.c` → `find_damage_writes.py`). +Findings, all consistent with the capstone analysis above: + +- **`GetDamage` is a pure component read.** Decompiled body is the 8-way switch returning + `*(undefined4 *)(component + 0x10)` (the per-type `value`), or `0`. No constants. +- **`PlainDamgeDealerComponent` is network-replicated.** Its `CopyTo` (RVA 0x4BB1A50) + copies `+0x10`(damageAmount)/`+0x14`(damageType)/`+0x15`(isMelee) — i.e. the component + travels in **snapshots from the server**; the client receives the damage, doesn't compute it. +- **No client producer.** Across all 17k decompiled functions, nothing writes a damage value + into a `PlainDamgeDealer`/`DamageXxx` component from a literal or from `GetDamage`: + `GetDamage` (0x4BAC520) has no real combat caller (only `DeathController$$OnHit` UI + + the calc itself), and the rich calc `d__12` factory (0x4BAC460) has **zero** + callers. So the damage *computation* is not exercised in client-reachable code. + +**Conclusion:** the per-weapon damage numbers are **server-authoritative runtime data**. +They live as Entitas component `value` floats created/assigned by server-side code through +fully-generic dispatch (no static class/index/string anchor) and reach the client only via +network snapshots / `DamageEventMessage`. There is no static constant in `GameAssembly.dll` +that an xref, pattern scan, or decompile-grep can tie to "revolver melee = N". Getting the +literal number requires a **runtime** read (in-game weapon-inspect UI, which calls +`GetDamage`), not static extraction. + ## Re-deriving / tracking on updates Tooling (regenerates the map from a new `dump.cs` + bundles): @@ -97,6 +123,9 @@ Tooling (regenerates the map from a new `dump.cs` + bundles): `find_rip_refs` / `find_rip_refs_batch` (RIP-relative data xrefs), `disasm_method` / `analyze` (resolves calls, reads float consts), `scan_movss_consts`. - `bundle/component_census.py`, `bundle/dump_blueprint.py`. +- `reverse/ghidra_decomp_targets.py` (Ghidra headless post-script: decompile a target + list to C) + `reverse/find_damage_writes.py` (scan decompiled C for the populate + fingerprint). Ghidra install/project/output live under the gitignored `ghidra/`. To diff the *formula* across patches: re-locate `HealthAndDamageExtensions.GetDamage` and `d__12.MoveNext` by signature and compare. To get the actual *numbers*: they are diff --git a/reverse/find_damage_writes.py b/reverse/find_damage_writes.py new file mode 100644 index 0000000..d0a8c09 --- /dev/null +++ b/reverse/find_damage_writes.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Scan ghidra/decomp.c (Ghidra decompiled C) for the PlainDamgeDealerComponent +populate fingerprint: the same pointer var written at +0x10 (float, damageAmount), ++0x14 (damageType) and +0x15 (isMelee). Also reports HitEventInfo (+0x30 damageAmount) +and any function assigning a float constant to a +0x10 offset near those. +""" +import re, sys + +BLOCK = re.compile(r"^// ==== (.+?) @ \+0x([0-9a-f]+) ====$") + + +def blocks(path): + name = None + addr = None + buf = [] + for line in open(path, errors="replace"): + m = BLOCK.match(line.rstrip("\n")) + if m: + if name is not None: + yield name, addr, "".join(buf) + name, addr, buf = m.group(1), m.group(2), [] + else: + buf.append(line) + if name is not None: + yield name, addr, "".join(buf) + + +# var written at a given byte offset: e.g. *(float *)(lVar5 + 0x10) = ... +def vars_writing_offset(code, off): + pat = re.compile(r"\*\([^)]*\)\(([A-Za-z_][A-Za-z0-9_]*) \+ 0x%x\) =" % off) + return set(pat.findall(code)) + + +def main(): + path = sys.argv[1] if len(sys.argv) > 1 else "/home/downloadpizza/sand_tools/ghidra/decomp.c" + hits = [] + for name, addr, code in blocks(path): + v10 = vars_writing_offset(code, 0x10) + v14 = vars_writing_offset(code, 0x14) + v15 = vars_writing_offset(code, 0x15) + common = v10 & v14 & v15 + if common: + # confirm the +0x10 write is a float + for v in common: + if re.search(r"\*\(float \*\)\(%s \+ 0x10\) =" % re.escape(v), code): + hits.append((name, addr, v)) + break + print("=== STRICT: +0x10 float, +0x14, +0x15 same ptr ===") + for name, addr, v in hits: + print(" +0x%s var %s %s" % (addr, v, name)) + print("strict total:", len(hits)) + + # LOOSE: float@+0x10 and byte@+0x15 on same ptr (isMelee + damageAmount) + loose = [] + for name, addr, code in blocks(path): + common = vars_writing_offset(code, 0x10) & vars_writing_offset(code, 0x15) + for v in common: + if re.search(r"\*\(float \*\)\(%s \+ 0x10\) =" % re.escape(v), code): + loose.append((name, addr, v)); break + print("\n=== LOOSE: +0x10 float & +0x15 same ptr ===") + for name, addr, v in loose: + print(" +0x%s var %s %s" % (addr, v, name)) + print("loose total:", len(loose)) + + +if __name__ == "__main__": + main() diff --git a/reverse/ghidra_decomp_targets.py b/reverse/ghidra_decomp_targets.py new file mode 100644 index 0000000..f2bf713 --- /dev/null +++ b/reverse/ghidra_decomp_targets.py @@ -0,0 +1,57 @@ +# Ghidra headless script: decompile a target list of IL2CPP functions to C. +# @category il2cpp +# Reads ghidra/targets.txt (rvaname), creates+names+decompiles each, +# dumps all C to ghidra/decomp.c for offline grepping. +import time +from ghidra.app.decompiler import DecompInterface +from ghidra.util.task import ConsoleTaskMonitor +from ghidra.program.model.symbol import SourceType + +ROOT = "/home/downloadpizza/sand_tools/ghidra" +base = currentProgram.getImageBase() + +decomp = DecompInterface() +decomp.openProgram(currentProgram) +monitor = ConsoleTaskMonitor() + +out = open(ROOT + "/decomp.c", "w") +n = 0 +ok = 0 +t0 = time.time() +fp = open(ROOT + "/targets.txt") +for line in fp: + line = line.rstrip("\n") + if not line: + continue + rva, name = line.split("\t", 1) + # ascii-sanitize: obfuscated names use Cyrillic look-alikes -> replace + name = "".join((ch if ord(ch) < 128 else "_") for ch in name) + addr = base.add(int(rva)) + fn = getFunctionAt(addr) + if fn is None: + disassemble(addr) + createFunction(addr, name) + fn = getFunctionAt(addr) + n += 1 + if fn is None: + continue + try: + fn.setName(name, SourceType.USER_DEFINED) + except: + pass + try: + res = decomp.decompileFunction(fn, 25, monitor) + if res is not None and res.decompileCompleted(): + c = res.getDecompiledFunction().getC() + c = "".join((ch if ord(ch) < 128 else "_") for ch in c) + out.write("\n// ==== %s @ +0x%x ====\n" % (name, int(rva))) + out.write(c) + ok += 1 + except Exception, e: + pass + if n % 1000 == 0: + out.flush() + print("processed %d ok %d (%.0fs)" % (n, ok, time.time() - t0)) +fp.close() +out.close() +print("DONE processed %d ok %d in %.0fs" % (n, ok, time.time() - t0))