weapon damage: Ghidra decompile phase — confirms server-authoritative, no static seed

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.
This commit is contained in:
DownloadPizza
2026-06-11 19:08:38 +02:00
parent 674536936a
commit e4899b43e7
3 changed files with 153 additions and 0 deletions

View File

@@ -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()

View File

@@ -0,0 +1,57 @@
# Ghidra headless script: decompile a target list of IL2CPP functions to C.
# @category il2cpp
# Reads ghidra/targets.txt (rva<TAB>name), 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))