159 lines
11 KiB
Markdown
159 lines
11 KiB
Markdown
# Weapon damage — static map of the model (for tracking across updates)
|
||
|
||
Goal: locate weapon damage (esp. the `_dusters` revolver's **melee** value) in the static
|
||
files, documented so it can be re-checked on patches.
|
||
|
||
**Status:** the damage *model* is fully mapped statically (below). The per-weapon *base
|
||
numbers* are **not** present in any asset, and are **not reachable by static anchor** in
|
||
`GameAssembly.dll` because every ECS component is accessed through fully-generic Entitas
|
||
dispatch (runtime integer index, no static class/index reference in calling code) and the
|
||
damage *resolution* is server-authoritative. What you can diff across updates is the model
|
||
and the formula function (RVAs below); the literal constants need a different method (see
|
||
end). This corrects an earlier draft that wrongly concluded "no value exists" — the values
|
||
**are** live at runtime; they just aren't statically anchorable constants.
|
||
|
||
**Confirmed 2026-06-16 (offline, `bundle/dump_blueprint.py`): the `Damage*DataComponent`s are
|
||
NOT authored on the item/ammo EntityBlueprints in the bundles.** Decoded `item_grenadeContact`,
|
||
`item_shotgunAmmo`, `item_pistolAmmo`, `item_shotgun`, `item_revolverSmall_dusters` — every one
|
||
has only generic components (InteractActions, Count, ItemName, ItemType, NiceName, View,
|
||
ViewSize, colliders, Physics); **zero** `Damage{Physical,…}DataComponent` / `MeleeDataComponent`
|
||
/ AoE. The only `.value` floats present are `ViewSizeDataComponent` (~0.3–0.97), not damage. So
|
||
`GetDamage`'s `DamageXxxDataComponent.value` reads are populated **at runtime (server-authoritative)**
|
||
— there is no per-weapon damage constant in the bundles *or* as a static anchor in the DLL.
|
||
|
||
**Ways to get actual base numbers, ranked:**
|
||
- ✅ **In-game empirical measurement** (controlled damage tests) — the only clean route.
|
||
- ⚠️ Live-client runtime memory (the components hold real values once spawned) — but that's
|
||
process inspection → **BattlEye / no injection** → off-limits.
|
||
- ❌ Static extraction (bundles) — values absent (proven above).
|
||
- ❌ Static decompile constant — none exists (generic Entitas dispatch).
|
||
- ❌ Master-server query — no damage field / no stats endpoint (see [MASTER_SERVER.md](MASTER_SERVER.md)).
|
||
- ❓ Game-server (not master) entity-snapshot capture *might* carry component values, but unverified
|
||
and the server may only transmit results, not per-weapon stats.
|
||
|
||
## Damage model (all static, all in `il2cpp/dump.cs` + verified by disasm)
|
||
|
||
Per-type damage lives as a `float value` (object offset **+0x10**) on 8 components on the
|
||
**item/ammo** entity: `Damage{Physical,Cold,Heat,Rad,Fire,Poison,Siege,True}DataComponent`
|
||
(`: BaseFloatValueComponent`). `MeleeDataComponent` (TypeDefIndex 5825) is a marker only.
|
||
|
||
Read path — `Hologryph.Sand.Shared.Damage.HealthAndDamageExtensions`:
|
||
|
||
| Method | RVA | Role |
|
||
|---|---|---|
|
||
| `GetDamage(ItemEntity, DamageType)` | `0x4BAC520` | jump-table over the 8 types → returns that type's `DamageXxxDataComponent.value` (`[comp+0x10]`), or `0` if absent |
|
||
| `GetDamageModifier(ItemEntity, DamageType)` | `0x4BAC340` | per-type `DamageModifierXxx` value |
|
||
| `GetAoEDamage(ItemEntity, DamageType)` | (via calc) | explosive AoE (`AoEDamageDataComponent.AoEDamage` struct: radius + 8 floats) |
|
||
| `IEnumerable<WeaponDamage> GetDamage(weapon, ammo, distance, isHeadShot, isAoE)` | `0x4BAC460` | the per-shot calc factory (state machine `<GetDamage>d__12`) |
|
||
|
||
**The damage formula** = `<GetDamage>d__12.MoveNext` (**RVA `0x4BB3DB0`**) — decompiled here:
|
||
per damage type it takes base `GetDamage(weapon,type)` (and ammo), then multiplies by
|
||
|
||
- **range falloff**: `RangeDamageModifierDataComponent` → `GetModifierByDistance(distance)`
|
||
(RVA `0x4A4B870`); **skipped when `isMelee`** (iterator flag at `+0x4c`),
|
||
- **headshot**: `HeadShotMultiplierDataComponent.value` (`[+0x10]`) when `isHeadShot`,
|
||
- plus `GetDamageModifier` / `GetAoEDamage`.
|
||
|
||
So **melee damage = base per-type value × headshot mult** (no distance falloff). The base is
|
||
the item's `DamagePhysical`/`DamageSiege` value. `WeaponDamage` struct = `{float damageAmount,
|
||
DamageType damageType}`.
|
||
|
||
## Damage delivery / application (static)
|
||
|
||
- A shot/attack carries **`PlainDamgeDealerComponent`** (game's typo "Damge"; TypeDefIndex
|
||
7343, `IGameContextComponent`): `float damageAmount @+0x10`, `DamageType damageType @+0x14`,
|
||
`bool isMelee @+0x15`. Created at runtime per shot (not in any asset).
|
||
- On impact → **`HitEventInfo`** (`Effects`, TypeDefIndex 7208): `float damageAmount @+0x30`,
|
||
`ammoId`, `ProjectileType`, `HitType`; carried by `HitEventComponent` and snapshot
|
||
`PlainHitEventComponentData` (`damageAmount @+0x2C`).
|
||
- Applied to **`HealthDataComponent`** (TypeDefIndex 7319): `ushort count @+0x10`,
|
||
`DamageType[] damageMask @+0x18`, `float value @+0x20`. `GetTotal()` RVA `0x4BAC900`.
|
||
- Networked as **`DamageEvent`** (TypeDefIndex 7299): `int damageAmount @+0xC`, `targetId`,
|
||
`blueprintId`, `bool isLethal`; batched in `DamageEventMessage`. Client-side resolution
|
||
callers (`OnDamageAvatar(hpBeforeDamage)`, `ConsumeDamage`, armor `GetArmorAbsorbMax`/
|
||
`IsBlocksDamage`/`GetDamageExtra`) are **display/feedback only** → the authoritative
|
||
computation is server-side.
|
||
|
||
## The base numbers are not in any asset (verified, multiple ways)
|
||
|
||
| Check | Result |
|
||
|---|---|
|
||
| All 1446 EntityBlueprints, Odin-decoded (`bundle/component_census.py`) | no `DamageXxx`/`PlainDamgeDealer`/`AoEDamage` component on any item, ammo, or projectile |
|
||
| `item_revolverSmall_dusters` full decode (`bundle/dump_blueprint.py`) | 8 components, all presentation/physics; actions only `WeaponPickup`/`WeaponSwap` |
|
||
| All 35 bundles, UTF-16 + ascii grep of the component type-names | 0 occurrences anywhere |
|
||
| `CheatItemDefinitionsData` (`ItemDefinition`) | only `{Name, Type, StorageStack}` — no damage field |
|
||
|
||
## Why the literal constants aren't statically anchorable
|
||
|
||
All of these were checked and eliminated as ways to reach the code that *sets* a weapon's
|
||
damage value:
|
||
|
||
- **Typed Entitas setters are dead.** `AddDamagePhysical(entity,float)` etc. — 0 callers.
|
||
This is build-wide, not damage-specific (the `HealthDataComponent` setters are also
|
||
uncalled), so "setter has no callers" proves nothing — this build mutates components
|
||
in-place / via generic add, never the generated typed setters.
|
||
- **Component class & module globals** (`DamagePhysical` class `0x187CE73B8`, module
|
||
`0x187D6B778`; `PlainDamgeDealer` class `0x187CE3638`, module `0x187D82300`) are
|
||
referenced **only by the generated extension methods** — no producer/init references them.
|
||
- **Item-id strings have 0 code references.** Verified the string-xref method is sound
|
||
(60/60 sampled `ScriptString` slots resolve to ≥1 ref), then confirmed
|
||
`"item_revolverSmall_dusters"` (slot `0x1807CC1808`) has **0** — so damage is not keyed
|
||
by item-id string in code.
|
||
- **Constant scans** (rip-relative `movss`, and `mov dword[mem],imm32` float-immediates)
|
||
don't isolate weapon damage from UI/physics/geometry noise.
|
||
|
||
Net: an item's damage component is created from a pooled factory (set up once at context
|
||
init) and added via `entity.AddComponent(component)` where the component's index is derived
|
||
from the object itself — there is **no static reference in the producing code** that an xref
|
||
can follow. That, plus server-authoritative resolution, is why the constant can't be reached
|
||
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 `<GetDamage>d__12` factory (0x4BAC460) has **zero**
|
||
callers. So the damage *computation* is not exercised in client-reachable code.
|
||
|
||
> **Backend identified:** that server is **Azure PlayFab** (Economy v2). The runtime route to
|
||
> these numbers — and whether a base value is catalog-authored in `DisplayProperties` — is in
|
||
> [BACKEND_PLAYFAB.md](BACKEND_PLAYFAB.md), along with how to pull it without tripping BattlEye.
|
||
|
||
**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):
|
||
- `reverse/il2cpp_re.py` — PE map, dump.cs method index, `find_xrefs` (call rel32),
|
||
`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
|
||
`<GetDamage>d__12.MoveNext` by signature and compare. To get the actual *numbers*: they are
|
||
runtime component values shown in the in-game weapon-inspect UI (`ShowWeaponInfo` /
|
||
`ShowPhysicalDamageInfo` call `GetDamage`), so a live read is the reliable source; static
|
||
extraction would require following the dynamic Entitas dispatch by hand through the (server-
|
||
side) shot/attack init — not achievable via xref/anchor.
|