Ports CEnchantmentRegistry::EnchantAttribute (PDB 0x00594570, see
docs/research/named-retail/acclient_2013_pseudo_c.txt line 416110).
The retail formula:
real_max = (vital.(ranks+start) + attribute_contribution) * mult_buff + add_buff
clamp >= 5 if base >= 5 else >= 1
is now applied in LocalPlayerState.GetMaxApprox.
EnchantmentMath.GetMod(activeEnchantments, table, statKey)
- Family-stacking dedup via SpellTable.Family (only one buff per
family-bucket wins, by highest spell-id as a generation proxy).
- Family=0 means "no bucket" — each layer is its own bucket.
- Returns (Multiplier, Additive) ready to apply.
- StatKey constants: MaxHealth=1, MaxStamina=3, MaxMana=5
(verified against named-retail/acclient.h line 37287-37301).
Spellbook.GetVitalMod(statKey) delegates to EnchantmentMath using
its constructor-injected SpellTable.
LocalPlayerState.GetMaxApprox now applies the full formula with
the min-vital floor (matches CreatureVital::GetMaxValue at PDB
0x0058F2DD). When Spellbook is null (back-compat), falls back to
Identity (no buff modification) — existing tests stay green.
GameWindow constructor wires SpellBook -> LocalPlayer so the chain
is complete in the live session.
Architecture in place; data still flat.
Until ISSUES.md #12 lands the wire-format extension that captures
StatMod (type/key/val) on ActiveEnchantmentRecord, the per-enchantment
modifier value isn't aggregated yet — GetMod returns Identity. Once
#12 wires the data, the existing aggregator + formula light up
automatically. Live +Acdream Stam/Mana will keep reading ~95% until
#12 lands.
6 new EnchantmentMathTests cover: empty list returns Identity,
no-table-entries returns Identity, stat-key constants match ACE,
Identity is (1, 0), family-stacking dedup, family=0 (no-bucket).
Total tests: 828 -> 834.
Closes#6 architecturally. Files #12 to track the wire-data follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>