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:
@@ -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
|
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).
|
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 `<GetDamage>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
|
## Re-deriving / tracking on updates
|
||||||
|
|
||||||
Tooling (regenerates the map from a new `dump.cs` + bundles):
|
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` /
|
`find_rip_refs` / `find_rip_refs_batch` (RIP-relative data xrefs), `disasm_method` /
|
||||||
`analyze` (resolves calls, reads float consts), `scan_movss_consts`.
|
`analyze` (resolves calls, reads float consts), `scan_movss_consts`.
|
||||||
- `bundle/component_census.py`, `bundle/dump_blueprint.py`.
|
- `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
|
To diff the *formula* across patches: re-locate `HealthAndDamageExtensions.GetDamage` and
|
||||||
`<GetDamage>d__12.MoveNext` by signature and compare. To get the actual *numbers*: they are
|
`<GetDamage>d__12.MoveNext` by signature and compare. To get the actual *numbers*: they are
|
||||||
|
|||||||
67
reverse/find_damage_writes.py
Normal file
67
reverse/find_damage_writes.py
Normal 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()
|
||||||
57
reverse/ghidra_decomp_targets.py
Normal file
57
reverse/ghidra_decomp_targets.py
Normal 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))
|
||||||
Reference in New Issue
Block a user