Skip to content

Commit e8a03d5

Browse files
committed
spell: add GetSpellBonusDamage / GetSpellBonusHealing
GetSpellBonusDamage(school) reads the player's per-school spell damage directly from the CGPlayer sub-struct at [player+0xE68]+0xFD4 (POS) / +0xFF0 (NEG), net = POS-NEG. That field isn't in the broadcast descriptor (+0x110 reads zero — the vanilla server never sends it there), but the client keeps a fully-computed copy at +0xE68 with gear/enchants/buffs/talents/sets baked in. Same source nampower's GetSpellPower reads; verified 613 generic / 647 Fire on a geared mage. 1-based school, matching 3.3.5's Script_GetSpellBonusDamage. GetSpellBonusHealing() has no such field (vanilla lacks a healing-done field entirely), so it's derived to mirror the server's SpellBaseHealingBonusDone: - flat healing: SPELL_AURA_MOD_HEALING_DONE (135) off gear equip-effects, item enchants, random suffixes, and active buffs - Spirit conversion: SPELL_AURA_MOD_SPELL_HEALING_OF_STAT_PERCENT (175) x total Spirit / 100 (Priest Spiritual Guidance) - Armor conversion: SPELL_AURA_MOD_SPELL_HEALING_OF_ARMOR_PERCENT (199) x armor / 100 (Turtle Ironclad) Percents are read straight from Spell.dbc, so it's generic across any aura-175/199 talent/buff including Turtle customs, and gated on SPELL_ATTR_ PASSIVE for the known-spell-bitmap walk. Spirit/Armor come from UNIT_FIELD_ STAT4 (+0x250) / UNIT_FIELD_RESISTANCES[0] (+0x254). Item flat healing routes through the shared Item::StatAccum engine rather than re-implementing the item spell/suffix/enchant walk: extract a reusable ApplyEnchant() (from ApplyRandomSuffix) plus a Value() reader, and read ITEM_MOD_SPELL_HEALING_DONE_SHORT. The healing sum is expensive (StatAccum over 19 items + buff table + the 27k known-spell bitmap), so it's cached and recomputed only when a player stat input changes. New Player::StatSignal (a pull-based epoch) is bumped from the aura hooks (player-guarded), the equipment observer, and the spell learn/ unlearn writers (0x4B25B0 / 0x4B2C50) -- so buffs, gear, and talent respecs all invalidate instantly. A coarse world-tick recompute remains only as a backstop against a missed signal.
1 parent adba73a commit e8a03d5

11 files changed

Lines changed: 564 additions & 29 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
5656
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
5757
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
5858
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
59-
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
59+
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
6060
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
6161
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
6262
| [Table](docs/API.md#table) | `table.wipe` |

‎docs/API.md‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,8 @@ build instructions.
370370
- [`C_Spell.IsSpellPassive(spellID)`](#c_spellisspellpassivespellid)
371371
- [`IsPlayerSpell(spellID)`](#isplayerspellspellid)
372372
- [`IsSpellKnown(spellID, [isPet])`](#isspellknownspellid-ispet)
373+
- [`GetSpellBonusDamage(school)`](#getspellbonusdamageschool)
374+
- [`GetSpellBonusHealing()`](#getspellbonushealing)
373375
- [`IsUsableSpell(spell)` / `IsUsableSpell(slot, bookType)`](#isusablespellspell--isusablespellslot-booktype)
374376
- [`C_Spell.IsSpellUsable(spellID)`](#c_spellisspellusablespellid)
375377
- [`C_Spell.GetSpellCooldown(spellIdentifier)`](#c_spellgetspellcooldownspellidentifier)
@@ -8997,6 +8999,54 @@ function does the same spellbook walk in its inner helper at
89978999
`0x0053B4E0` (player array `[0x00BE6D88]`, pet array `[0x00BE7D98]`,
89989000
same shape just different addresses).
89999001

9002+
### `GetSpellBonusDamage(school)`
9003+
9004+
Returns the local player's flat spell-damage bonus (spell power) for a
9005+
given magic school, as a number. `school` is **1-based**: `1` Physical,
9006+
`2` Holy, `3` Fire, `4` Nature, `5` Frost, `6` Shadow, `7` Arcane. Raises
9007+
a usage error for a missing / out-of-range school; returns `0` before
9008+
you're in the world.
9009+
9010+
```lua
9011+
GetSpellBonusDamage(3) -- your +Fire spell damage
9012+
GetSpellBonusDamage(6) -- your +Shadow spell damage
9013+
```
9014+
9015+
### `GetSpellBonusHealing()`
9016+
9017+
Returns the local player's flat healing bonus, as a number.
9018+
9019+
```lua
9020+
GetSpellBonusHealing() -- your +Healing
9021+
```
9022+
9023+
> **Damage is an exact field read; healing is derived.**
9024+
>
9025+
> `GetSpellBonusDamage` reads the client's fully-computed value directly
9026+
> from the CGPlayer sub-struct (`PLAYER_FIELD_MOD_DAMAGE_DONE_POS − _NEG`
9027+
> per school). That field isn't in the *broadcast* descriptor — the
9028+
> vanilla server never sends it there, which is why it looks absent — but
9029+
> the client keeps a computed copy for its own use, with gear, enchants,
9030+
> buffs, talents, and set bonuses all baked in. So the value is exact and
9031+
> complete. (Same source nampower's `GetSpellPower` reads.)
9032+
>
9033+
> `GetSpellBonusHealing` has no such field — vanilla 1.12 never had a
9034+
> healing-done field at all (confirmed in-game: toggling a pure +healing
9035+
> item moved no field in the player struct) — so it's **derived** in two
9036+
> parts, both from `Spell.dbc`: (1) flat healing = the sum of every
9037+
> `SPELL_AURA_MOD_HEALING_DONE` off gear equip-effects, item enchants
9038+
> (permanent + weapon oils), random suffixes, and active buffs; plus (2)
9039+
> talent/buff stat-conversions, mirroring the server's own
9040+
> `SpellBaseHealingBonusDone` — the sum of `SPELL_AURA_MOD_SPELL_HEALING_OF`
9041+
> `_STAT_PERCENT` (aura 175) × total Spirit / 100 over your passive talents
9042+
> (Priest *Spiritual Guidance*) and active buffs. Part 2 reads the talent
9043+
> percent directly, so it's **generic** (any aura-175 talent/buff, including
9044+
> Turtle customs) and exact — e.g. Spiritual Guidance 5/5 with 182 Spirit
9045+
> adds `int(182 × 25 / 100) = 45`.
9046+
>
9047+
> Residual: assumes talent ranks supersede (only the learned rank is known)
9048+
> and misses set-bonus healing granted via a set-completion spell.
9049+
90009050
### `IsUsableSpell(spell)` / `IsUsableSpell(slot, bookType)`
90019051

90029052
Returns `(usable, noMana)` for a spell, matching the modern

‎src/Offsets.h‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,19 @@ enum Offsets {
798798
// mov edx, [eax + 0x110]
799799
// movzx eax, byte ptr [edx + 0x7B]
800800
OFF_UNIT_DESCRIPTOR_POWER_TYPE_BYTE = 0x7B,
801+
// UNIT_FIELD_STAT0..4 — the fully-computed (base+item+buff) primary
802+
// stats, broadcast in the descriptor. Order Str/Agi/Sta/Int/Spirit;
803+
// STAT4 (Spirit) = fieldIndex 0x94 → +0x250 (from the UpdateField name
804+
// table; cross-checked in-game: descriptor +0x250 read 173→182 when a
805+
// +9-Spirit item was equipped). What GetStat(STAT_SPIRIT) returns
806+
// server-side; used by GetSpellBonusHealing for the Spiritual-Guidance
807+
// (aura 175) spirit→healing conversion.
808+
OFF_UNIT_FIELD_STAT_SPIRIT = 0x250,
809+
// UNIT_FIELD_RESISTANCES[0] = armor (the fully-computed total), fieldIndex
810+
// 0x95 → +0x254 (7 resistances: armor, then the 6 magic schools). What
811+
// GetArmor() / GetResistance(SPELL_SCHOOL_NORMAL) returns; used by
812+
// GetSpellBonusHealing for Turtle's Ironclad (aura 199) armor→healing.
813+
OFF_UNIT_FIELD_RESISTANCE_ARMOR = 0x254,
801814
OFF_UNIT_FIELD_FLAGS = 0xA0,
802815
UNIT_FLAG_PLAYER_CONTROLLED = 0x08,
803816
// Bit 19 of UNIT_FIELD_FLAGS — `Script_UnitAffectingCombat` at
@@ -868,6 +881,27 @@ enum Offsets {
868881
UNIT_AURA_TOTAL = 48,
869882
UNIT_AURA_VISIBLE_MASK = 0x0E, // nibble mask used by the engine's visibility gate
870883

884+
// PLAYER_FIELD_MOD_DAMAGE_DONE_POS/NEG — the player's per-school spell
885+
// damage bonus. Offsets from the engine's own UpdateField name/index
886+
// table in `.rdata` (20-byte entries `{char* name, u32 fieldIndex, u32
887+
// count, ...}` at VA 0x83B6C8): POS = fieldIndex 0x3F5 count 7 → byte
888+
// offset fieldIndex*4 = 0xFD4; NEG = 0x3FC → 0xFF0. Seven int32 each,
889+
// one per school (0 physical .. 6 arcane); net bonus = POS − NEG.
890+
//
891+
// **Read these off `[player + OFF_CGPLAYER_INFO]` (the +0xE68 CGPlayer
892+
// sub-struct), NOT the broadcast descriptor at +0x110.** The descriptor
893+
// copy is never sent to the client and reads zero (verified in-game: the
894+
// whole post-skills PLAYER descriptor region is zero on a geared L60 —
895+
// vanilla never displayed spell power, so the server doesn't broadcast
896+
// it). The +0xE68 struct holds the client's fully-computed value
897+
// (gear + enchants + buffs + talents + sets baked in). Same base +
898+
// offsets nampower's GetSpellPower uses; verified 613 generic / 647 Fire
899+
// on a geared mage. Read by GetSpellBonusDamage in [src/spell/BonusDamage.cpp].
900+
// (No MOD_HEALING_DONE equivalent — vanilla lacks the field entirely;
901+
// GetSpellBonusHealing is derived instead.)
902+
OFF_PLAYER_FIELD_MOD_DAMAGE_DONE_POS = 0xFD4,
903+
OFF_PLAYER_FIELD_MOD_DAMAGE_DONE_NEG = 0xFF0,
904+
871905
// Reusable "is this spell record a user-visible aura" predicate.
872906
// `__fastcall(spellRecord*) -> bool`. Checks Spell.dbc Attributes
873907
// (high bit of byte at +0x18 must be clear), AttributesEx bit
@@ -1804,6 +1838,16 @@ enum Offsets {
18041838
// last use"). Negative = single-use, destroyed when count hits 0.
18051839
// `GetItemCount(includeUses=true)` uses abs() for the multiplier.
18061840
OFF_DESCRIPTOR_SPELL_CHARGES_0 = 0x28,
1841+
// ITEM_FIELD_ENCHANTMENT block (fields 16..36, per the +0x48
1842+
// enchant-charges note above) — 7 enchant slots, 3 dwords each
1843+
// `{enchantID, duration, charges}`. Slot 0 = permanent enchant,
1844+
// slot 1 = temporary (weapon oils / sharpening stones). The
1845+
// enchant ID indexes SpellItemEnchantment.dbc. Block base = field
1846+
// 16 = +0x40 (field 15 FLAGS at +0x3C precedes it); slot stride
1847+
// 0x0C, enchant ID at slot+0x00. Used by spell/BonusDamage.cpp to
1848+
// fold enchant-granted spell power into GetSpellBonusDamage.
1849+
OFF_DESCRIPTOR_ENCHANTMENT_ID = 0x40,
1850+
DESCRIPTOR_ENCHANTMENT_SLOT_STRIDE = 0x0C,
18071851
// ITEM_FIELD_DURABILITY (current) and ITEM_FIELD_MAXDURABILITY (max) live
18081852
// adjacent to each other in the descriptor as plain dwords. Verified in
18091853
// `Script_GetInventoryItemBroken` (`0x004C8590`): after resolving the
@@ -3130,6 +3174,18 @@ enum Offsets {
31303174
// size matches Spell.dbc's row count. Pre-login the slot is NULL.
31313175
VAR_PLAYER_SPELL_BITMAP = 0x00B710FC,
31323176

3177+
// Player spell-knowledge writers — the two functions that mutate the
3178+
// bitmap above. Learn (`FUN_004b25b0`) sets the bit, rebuilds the
3179+
// spellbook arrays, and fires SPELLS_CHANGED; unlearn (`FUN_004b2c50`)
3180+
// clears the bit and removes the spell. Both `__fastcall`: learn is
3181+
// (uint spellID, int notify, uint replacedSpellID), unlearn is
3182+
// (uint spellID, int). Co-hooked (spell/Info.cpp) to bump
3183+
// Player::StatSignal so a talent respec invalidates GetSpellBonusHealing's
3184+
// talent-conversion cache. They fire at login (SMSG_INITIAL_SPELLS) and on
3185+
// each learn/unlearn — never per-frame, so cool hook targets.
3186+
FUN_LEARN_SPELL = 0x004B25B0,
3187+
FUN_UNLEARN_SPELL = 0x004B2C50,
3188+
31333189
// Spell-rank-chain knowledge check — `char __thiscall(player /*ecx*/,
31343190
// spellID)`. Walks `spellID`'s forward rank chain (the class/race-
31353191
// resolved chain via `0x0060C7C0`) and returns nonzero if the player
@@ -5039,6 +5095,8 @@ enum Offsets {
50395095
// sum). AttributesEx2 (+0x24) bit 0x20000000 disables spell mods.
50405096
OFF_SPELL_RECORD_FAMILY_NAME = 0x280, // u32
50415097
OFF_SPELL_RECORD_FAMILY_FLAGS = 0x284, // u64
5098+
OFF_SPELL_RECORD_ATTRIBUTES = 0x18, // u32 (base Attributes)
5099+
SPELL_ATTR_PASSIVE = 0x40, // bit 6 — always-on aura
50425100
OFF_SPELL_RECORD_ATTRIBUTES_EX2 = 0x24, // u32
50435101
SPELL_ATTR_EX2_NO_SPELL_MODS = 0x20000000,
50445102

‎src/aura/Source.cpp‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
#include "Game.h"
2121
#include "Offsets.h"
2222
#include "net/PacketReader.h"
23+
#include "player/StatSignal.h"
2324
#include "spell/Lookup.h"
2425
#include "tick/WorldTick.h"
2526
#include "unit/Identity.h"
@@ -288,6 +289,15 @@ int8_t KindForSlot(int slot) {
288289
return slot >= Offsets::UNIT_AURA_BUFF_COUNT ? KIND_HARMFUL : KIND_HELPFUL;
289290
}
290291

292+
// Bump the player-stat-inputs signal when an aura change hits the LOCAL
293+
// player — buffs/debuffs move GetSpellBonusHealing's flat and Spirit/Armor
294+
// terms, so its lazy cache must invalidate. Guarded on the player object so
295+
// other units' aura churn (combat) doesn't needlessly invalidate it.
296+
void NotifyIfPlayer(void *unit) {
297+
if (static_cast<const uint8_t *>(unit) == Unit::Identity::PlayerObject())
298+
Player::StatSignal::Notify();
299+
}
300+
291301
// OnAuraAdded — a new aura occupies a slot (gives the spellId directly).
292302
using OnAuraAdded_t = void(__fastcall *)(void *unit, void *edx, uint32_t slot,
293303
uint32_t spellId);
@@ -297,6 +307,7 @@ void __fastcall OnAuraAdded_h(void *unit, void *edx, uint32_t slot,
297307
uint32_t spellId) {
298308
g_origOnAuraAdded(unit, edx, slot, spellId);
299309
StampApplication(unit, spellId, KindForSlot(static_cast<int>(slot)));
310+
NotifyIfPlayer(unit);
300311
}
301312

302313
const Game::HookAutoRegister _hookAuraAdded{
@@ -317,6 +328,7 @@ void __fastcall OnAuraStacksChanged_h(void *unit, void *edx, int slot,
317328
unit,
318329
Aura::Data::ReadSpellID(static_cast<const uint8_t *>(unit), slot),
319330
KindForSlot(slot));
331+
NotifyIfPlayer(unit);
320332
}
321333

322334
const Game::HookAutoRegister _hookAuraStacks{
@@ -346,6 +358,7 @@ void __fastcall OnAuraRemoved_h(void *unit, void *edx, uint32_t slot,
346358
g_origOnAuraRemoved(unit, edx, slot, spellId);
347359
(void)slot;
348360
Evict(Unit::Identity::GuidForObject(unit), spellId);
361+
NotifyIfPlayer(unit);
349362
}
350363

351364
const Game::HookAutoRegister _hookAuraRemoved{

‎src/item/StatAccum.cpp‎

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,15 @@ void AddByKey(Accum *acc, const char *key, long delta) {
279279
}
280280
}
281281

282+
long Value(const Accum *acc, const char *key) {
283+
if (acc == nullptr || key == nullptr)
284+
return 0;
285+
for (int i = 0; i < kCount; ++i)
286+
if (std::strcmp(acc[i].key, key) == 0)
287+
return acc[i].value;
288+
return 0;
289+
}
290+
282291
const uint8_t *FetchRecord(uint32_t itemID) {
283292
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
284293
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
@@ -306,6 +315,34 @@ double ComputeDPS(const uint8_t *record) {
306315
return avgTotal / (static_cast<double>(delayMs) / 1000.0);
307316
}
308317

318+
void ApplyEnchant(Accum *acc, uint32_t enchantID, long sign) {
319+
if (enchantID == 0)
320+
return;
321+
const uint8_t *en = DBC::Record(Offsets::VAR_SPELLITEMENCHANT_RECORDS,
322+
Offsets::VAR_SPELLITEMENCHANT_COUNT, enchantID);
323+
if (en == nullptr)
324+
return;
325+
auto type = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_TYPE);
326+
auto amount = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_AMOUNT);
327+
auto arg = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_ARG);
328+
for (int i = 0; i < 3; ++i) { // SpellItemEnchantment has 3 effect slots
329+
switch (type[i]) {
330+
case 3: // equip spell — stat/resist value lives in the spell
331+
AddSpellStatAuras(acc, arg[i], sign);
332+
break;
333+
case 4: // resistance — arg = school index (0 = armor)
334+
AddByKey(acc, ResistKey(arg[i]), sign * static_cast<long>(amount[i]));
335+
break;
336+
case 5: // direct stat — arg = ItemModType, amount = value
337+
AddByKey(acc, StatKeyForItemModType(static_cast<uint32_t>(arg[i])),
338+
sign * static_cast<long>(amount[i]));
339+
break;
340+
default: // 1 proc / 2 weapon-damage / 6 totem / 7 use spell
341+
break;
342+
}
343+
}
344+
}
345+
309346
void ApplyRandomSuffix(Accum *acc, int suffixID, long sign) {
310347
if (suffixID <= 0)
311348
return;
@@ -316,34 +353,8 @@ void ApplyRandomSuffix(Accum *acc, int suffixID, long sign) {
316353
return;
317354
auto enchants = reinterpret_cast<const uint32_t *>(
318355
rp + Offsets::OFF_ITEMRANDOMPROP_ENCHANT);
319-
for (int s = 0; s < Offsets::ITEMRANDOMPROP_ENCHANT_SLOT_COUNT; ++s) {
320-
const uint32_t enchantID = enchants[s];
321-
if (enchantID == 0)
322-
continue;
323-
const uint8_t *en = DBC::Record(Offsets::VAR_SPELLITEMENCHANT_RECORDS,
324-
Offsets::VAR_SPELLITEMENCHANT_COUNT, enchantID);
325-
if (en == nullptr)
326-
continue;
327-
auto type = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_TYPE);
328-
auto amount = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_AMOUNT);
329-
auto arg = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_ARG);
330-
for (int i = 0; i < 3; ++i) { // SpellItemEnchantment has 3 effect slots
331-
switch (type[i]) {
332-
case 3: // equip spell — stat/resist value lives in the spell
333-
AddSpellStatAuras(acc, arg[i], sign);
334-
break;
335-
case 4: // resistance — arg = school index (0 = armor)
336-
AddByKey(acc, ResistKey(arg[i]), sign * static_cast<long>(amount[i]));
337-
break;
338-
case 5: // direct stat — arg = ItemModType, amount = value
339-
AddByKey(acc, StatKeyForItemModType(static_cast<uint32_t>(arg[i])),
340-
sign * static_cast<long>(amount[i]));
341-
break;
342-
default: // 1 proc / 2 weapon-damage / 6 totem / 7 use spell
343-
break;
344-
}
345-
}
346-
}
356+
for (int s = 0; s < Offsets::ITEMRANDOMPROP_ENCHANT_SLOT_COUNT; ++s)
357+
ApplyEnchant(acc, enchants[s], sign);
347358
}
348359

349360
void AccumulateRecord(Accum *acc, const uint8_t *record, long sign) {

‎src/item/StatAccum.h‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ void Init(Accum *acc);
5151
// a zero delta). Linear scan — the table is small and fixed.
5252
void AddByKey(Accum *acc, const char *key, long delta);
5353

54+
// Read the accumulated value for `key` (0 for an unknown key). Linear scan.
55+
long Value(const Accum *acc, const char *key);
56+
5457
// Passive cache peek for an item's `ItemStats_C` record. Does NOT warm
5558
// the cache (returns nullptr for an uncached item) — see the item-cache
5659
// race note in CLAUDE.md.
@@ -66,6 +69,13 @@ void AccumulateRecord(Accum *acc, const uint8_t *record, long sign);
6669
// suffixID <= 0.
6770
void ApplyRandomSuffix(Accum *acc, int suffixID, long sign);
6871

72+
// Fold a single SpellItemEnchantment record (by enchant ID) into `acc`,
73+
// scaled by `sign`. Handles equip-spell (type 3), resistance (4), and
74+
// direct-stat (5) enchant effects. Shared by `ApplyRandomSuffix` (a suffix's
75+
// enchant slots) and callers reading an item's *applied* permanent/temporary
76+
// enchants off its CGItem descriptor. No-op for enchantID 0.
77+
void ApplyEnchant(Accum *acc, uint32_t enchantID, long sign);
78+
6979
// Weapon DPS = average damage / swing time, or 0 for non-weapons.
7080
double ComputeDPS(const uint8_t *record);
7181

0 commit comments

Comments
 (0)