Files
SandTools/docs/WEAPON_DAMAGE.md
DownloadPizza 5946e0910b docs: item sales value + PlayFab/master-server backend map
- SALES_VALUE.md: price model (PriceDto/ItemDto.SellPrice/PriceDataComponent), server-authoritative
- BACKEND_PLAYFAB.md: backend = PlayFab Economy + ws:// master server (per-region);
  observed no-playtest boot behavior, capture findings, OnRegionsLoadFailed dialog path
- WEAPON_DAMAGE.md: cross-link to backend doc
2026-06-12 01:17:07 +02:00

140 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
## 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.