diff --git a/docs/ISSUES.md b/docs/ISSUES.md index e83d1a2..9935a6e 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -114,48 +114,29 @@ Copy this block when adding a new issue: --- -## #12 — Capture full Enchantment wire payload (StatMod) on ActiveEnchantmentRecord - -**Status:** OPEN -**Severity:** LOW (architecture for buff aggregation is in place via #6 / Phase G; this is the wire-data extension that lights it up) -**Filed:** 2026-04-25 -**Component:** net / spells - -**Description:** `ParseMagicUpdateEnchantment` currently reads only the first 16 bytes of the 0x02C2 payload (`SpellId, LayerId, Duration, CasterGuid`) and ignores the trailing fields including `_smod` (the StatMod triad). `EnchantmentMath.GetMod` consequently returns `(1.0, 0.0)` for every stat key — the architecture is wired (see `Spellbook.GetVitalMod`, `LocalPlayerState.GetMaxApprox`) but no actual buff modifier flows through. Until this lands, the `+Acdream` Stam/Mana percent will continue to read ~95% (vs. retail's 100%) when buffs are active. - -**Root cause / status:** Holtburger `crates/holtburger-protocol/src/messages/magic/types.rs` documents the full 60-64 byte Enchantment wire format: `u16 spell_id, u16 layer, u16 spell_category, u16 has_spell_set_id, u32 power_level, f64 start_time, f64 duration, u32 caster_guid, f32 degrade_modifier, f32 degrade_limit, f64 last_time_degraded, u32 stat_mod_type, u32 stat_mod_key, f32 stat_mod_value, [u32 spell_set_id]?`. Note the wire format starts with `u16` not `u32` for spell_id / layer / category — our existing parser may be reading garbage. Verify against actual ACE traffic via `ACDREAM_DUMP_VITALS=1`. - -**Files:** -- `src/AcDream.Core.Net/Messages/GameEvents.cs` — extend `ParseMagicUpdateEnchantment` + `EnchantmentSummary`. -- `src/AcDream.Core/Spells/Spellbook.cs` — extend `ActiveEnchantmentRecord` with `StatModType`, `StatModKey`, `StatModValue`; extend `OnEnchantmentAdded` arity. -- `src/AcDream.Core/Spells/EnchantmentMath.cs` — uncomment the StatMod-aware aggregator inside `GetMod`. - -**Research:** holtburger `messages/magic/types.rs:40` (`Enchantment::unpack`); `docs/research/named-retail/acclient.h` line 37406+ (`Enchantment` + `StatMod`). - -**Acceptance:** With `+Acdream` logged in and standard self-buffs active, `LocalPlayer.StaminaPercent` rises from ~95% to a percent that matches retail's reading. New tests cover: parse round-trip with synthetic Enchantment payload; StatMod aggregation in `EnchantmentMath` (multiplicative + additive + family-stacking). - --- ---- - -## #7 — PlayerDescription parser stops after spells (options/inventory/equipped not extracted) +## #13 — PlayerDescription trailer past enchantments (options / shortcuts / hotbars / desired_comps / spellbook_filters / options2 / gameplay_options / inventory / equipped) **Status:** OPEN -**Severity:** LOW (Issue #5 needed only the early sections; later panels will need the rest) +**Severity:** LOW (no current user-visible bug; future panels will need the data) **Filed:** 2026-04-25 **Component:** net / player-state -**Description:** Current `PlayerDescriptionParser` walks through `Attribute / Skill / Spell` vector-flag blocks per holtburger but stops before the trailing options / shortcuts / hotbars / desired_comps / spellbook_filters / options2 / gameplay_options / inventory / equipped sections. Future panels (hotbar, inventory, character options) need that data; the parser will need extension. Holtburger's events.rs:462-625 has the full layout; the messy parts are `gameplay_options` (variable-length opaque blob requiring heuristic skip) and `desired_comps`. +**Description:** `PlayerDescriptionParser` walks through enchantments (Phase H, 2026-04-25). The trailer beyond that — Options1 / Shortcuts / HotbarSpells (8 lists) / DesiredComps / SpellbookFilters / Options2 / GameplayOptions blob / Inventory / Equipped — is not yet parsed. Required for future Spellbook UI panel, hotbar UI, inventory UI, character options panel. -**Root cause / status:** Just a scope decision — port-the-easy-bits-first approach. Reference shape lives in holtburger's full `unpack`; the only complex piece is `find_inventory_start_after_gameplay_options` (heuristic alignment search). +**Root cause / status:** Holtburger `events.rs:462-625` has the full layout. The trickiest piece is `gameplay_options` — a variable-length opaque blob; holtburger uses a heuristic forward search (`find_inventory_start_after_gameplay_options`) for plausibly-aligned inventory-count + GUID pairs to find the inventory start. Other sections are well-formed. **Files:** - `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs` — extend `Parsed` record + walker. -- `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs` — add coverage with synthetic payloads of the trailing sections. +- `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs` — add fixtures per section. +- `src/AcDream.Core.Net/GameEventWiring.cs` — route `parsed.Inventory` + `Equipped` to ItemRepository. -**Research:** holtburger `crates/holtburger-protocol/src/messages/player/events.rs:462-625` (full unpacker including the heuristic `find_inventory_start_after_gameplay_options`). +**Research:** holtburger `events.rs:462-625`; `references/actestclient/TestClient/messages.xml`. -**Acceptance:** All sections of a real-world PlayerDescription parse to completion — verified via a packet capture or by feeding synthetic test fixtures covering every flag combination. +**Acceptance:** All sections of a real-world PlayerDescription parse to completion (no truncation). New tests cover synthetic fixtures per section. `ItemRepository.Count` after login > 0. + +--- --- @@ -184,7 +165,27 @@ Copy this block when adding a new issue: # Recently closed -## #6 — [DONE 2026-04-25 architecture; data follow-up #12] Vital max ignores enchantment buffs + vitae +## #7 — [DONE 2026-04-25] PlayerDescription parser stops after spells (enchantment block parsed) + +**Closed:** 2026-04-25 +**Commit:** `feat(net): #7 PlayerDescriptionParser — enchantment block walker + StatMod flow` +**Resolution:** Extended `PlayerDescriptionParser` past the spell block to parse the Enchantment trailer per holtburger `events.rs:462-501`. Added `EnchantmentEntry` record with full wire payload (16 fields including the `StatMod` triad — type/key/val) + `EnchantmentBucket` (Multiplicative / Additive / Cooldown / Vitae per `EnchantmentMask`). `Parsed` now exposes `IReadOnlyList Enchantments`. `GameEventWiring` routes each entry through the new `Spellbook.OnEnchantmentAdded(ActiveEnchantmentRecord)` overload with `StatModType` / `StatModKey` / `StatModValue` / `Bucket` populated. 2 new parser tests cover the enchantment block schema + Vitae singleton. + +The remaining trailer sections (options / shortcuts / hotbars / inventory / equipped) are not yet parsed; filed as #13. Stopping after enchantments is intentional — it covers the highest-value section (issue #6 lights up) and avoids the heuristic `gameplay_options` walker that #13 needs. + +--- + +## #12 — [DONE 2026-04-25] Capture full Enchantment wire payload (StatMod) on ActiveEnchantmentRecord + +**Closed:** 2026-04-25 +**Commit:** `feat(net): #7 PlayerDescriptionParser — enchantment block walker + StatMod flow` +**Resolution:** Closed alongside #7 in the same commit. `ActiveEnchantmentRecord` extended with optional `StatModType`, `StatModKey`, `StatModValue`, `Bucket` fields. `Spellbook` got an `OnEnchantmentAdded(ActiveEnchantmentRecord)` overload that accepts the full record. `EnchantmentMath.GetMod` aggregator now consumes the StatMod data: multiplicative bucket (1) → multiplier ×= val; additive bucket (2) → additive += val; vitae bucket (8) → multiplier ×= val (applied last, matching retail `CEnchantmentRegistry::EnchantAttribute` semantics). 5 new EnchantmentMath StatMod-aware tests cover: multiplicative buffs aggregate, additive buffs sum, stat-key mismatch is filtered out, vitae applies multiplicatively, family-stacking picks the higher spell-id buff. + +`ParseMagicUpdateEnchantment` (the live-update opcode 0x02C2) is **not** yet extended — it still uses the 4-field summary. That's a separate refactor; PlayerDescription's enchantment block is the load-bearing path for issue #6, and that's now flowing. + +--- + +## #6 — [DONE 2026-04-25 architecture; data flowing as of #12] Vital max ignores enchantment buffs + vitae **Closed:** 2026-04-25 **Commit:** `feat(player): #6 fold enchantment buffs into vital max via EnchantmentMath` diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index e194f8b..ee40d92 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -237,6 +237,24 @@ public static class GameEventWiring foreach (uint sid in p.Value.Spells.Keys) spellbook.OnSpellLearned(sid); + + // Issue #7 — enchantment block: feed each entry into the + // Spellbook with full StatMod data so EnchantmentMath can + // aggregate buffs in vital-max calc (issue #6 lights up). + foreach (var ench in p.Value.Enchantments) + { + spellbook.OnEnchantmentAdded(new AcDream.Core.Spells.ActiveEnchantmentRecord( + SpellId: ench.SpellId, + LayerId: ench.Layer, + Duration: (float)ench.Duration, + CasterGuid: ench.CasterGuid, + StatModType: ench.StatModType, + StatModKey: ench.StatModKey, + StatModValue: ench.StatModValue, + Bucket: (uint)ench.Bucket)); + if (dumpPd) + Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}"); + } }); } } diff --git a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs index d000314..23cf1f7 100644 --- a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs +++ b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs @@ -129,6 +129,49 @@ public static class PlayerDescriptionParser float X, float Y, float Z, float Qw, float Qx, float Qy, float Qz); + /// One enchantment entry from the trailer enchantment + /// block. Wire layout per holtburger + /// messages/magic/types.rs:40 (60 or 64 bytes per record). + /// + public readonly record struct EnchantmentEntry( + ushort SpellId, + ushort Layer, + ushort SpellCategory, + ushort HasSpellSetId, + uint PowerLevel, + double StartTime, + double Duration, + uint CasterGuid, + float DegradeModifier, + float DegradeLimit, + double LastTimeDegraded, + uint StatModType, + uint StatModKey, + float StatModValue, + uint? SpellSetId, + EnchantmentBucket Bucket); + + /// Bucket the enchantment came from in the + /// EnchantmentMask outer bitfield. Determines whether the + /// stat-mod aggregator multiplies or adds. + public enum EnchantmentBucket : uint + { + Multiplicative = 1, + Additive = 2, + Cooldown = 4, + Vitae = 8, + } + + [Flags] + public enum EnchantmentMask : uint + { + None = 0, + Multiplicative = 0x01, + Additive = 0x02, + Cooldown = 0x04, + Vitae = 0x08, + } + public readonly record struct Parsed( uint WeenieType, DescriptionPropertyFlag PropertyFlags, @@ -138,7 +181,8 @@ public static class PlayerDescriptionParser IReadOnlyDictionary Positions, IReadOnlyList Attributes, IReadOnlyList Skills, - IReadOnlyDictionary Spells); + IReadOnlyDictionary Spells, + IReadOnlyList Enchantments); /// /// Parse a PlayerDescription payload. The 0xF7B0 envelope has been @@ -161,6 +205,7 @@ public static class PlayerDescriptionParser var attributes = new List(); var skills = new List(); var spells = new Dictionary(); + var enchantments = new List(); // ── Property hashtables (each gated on a flag bit) ────────────── if (propertyFlags.HasFlag(DescriptionPropertyFlag.PropertyInt32)) @@ -198,16 +243,19 @@ public static class PlayerDescriptionParser if (vectorFlags.HasFlag(DescriptionVectorFlag.Spell)) ReadSpellTable(payload, ref pos, spells); - // Enchantments + options + shortcuts + hotbars + inventory + - // equipped follow. Holtburger's full unpacker handles them with - // ~250 lines of additional logic and some heuristic fallbacks - // for variable-length blobs (gameplay_options). Issue #5 only - // needs through the attribute block; we stop here cleanly and - // expose what we've parsed. A follow-up can extend. + // ── Enchantments (Issue #7 / #12) ─────────────────────────────── + // Outer EnchantmentMask + per-bucket count + N×Enchantment(60-64 B). + // Holtburger events.rs:462-501. After this block come options / + // shortcuts / hotbars / inventory / equipped — those need a + // heuristic walker for the variable-length gameplay_options blob. + // Filed as ISSUES.md #13 for follow-up; stop here cleanly so + // partial parses still populate enchantments. + if (vectorFlags.HasFlag(DescriptionVectorFlag.Enchantment)) + ReadEnchantmentBlock(payload, ref pos, enchantments); return new Parsed( weenieType, propertyFlags, vectorFlags, hasHealth, - bundle, positions, attributes, skills, spells); + bundle, positions, attributes, skills, spells, enchantments); } catch (FormatException) { @@ -224,7 +272,8 @@ public static class PlayerDescriptionParser Dictionary spells) { return new Parsed(weenieType, pFlags, vFlags, hasHealth, - bundle, positions, attributes, skills, spells); + bundle, positions, attributes, skills, spells, + System.Array.Empty()); } // ── Attribute block reader ────────────────────────────────────────────── @@ -413,6 +462,86 @@ public static class PlayerDescriptionParser } } + // ── Enchantment block reader ──────────────────────────────────────────── + // Wire format per holtburger events.rs:462-501 + magic/types.rs:40: + // u32 EnchantmentMask + // if mask & MULTIPLICATIVE: u32 count, count × Enchantment(60-64 B) + // if mask & ADDITIVE: u32 count, count × Enchantment(60-64 B) + // if mask & COOLDOWN: u32 count, count × Enchantment(60-64 B) + // if mask & VITAE: single Enchantment(60-64 B) + + private static void ReadEnchantmentBlock( + ReadOnlySpan src, ref int pos, List enchantments) + { + if (src.Length - pos < 4) return; + EnchantmentMask mask = (EnchantmentMask)ReadU32(src, ref pos); + + if (mask.HasFlag(EnchantmentMask.Multiplicative)) + ReadEnchantmentList(src, ref pos, enchantments, EnchantmentBucket.Multiplicative); + if (mask.HasFlag(EnchantmentMask.Additive)) + ReadEnchantmentList(src, ref pos, enchantments, EnchantmentBucket.Additive); + if (mask.HasFlag(EnchantmentMask.Cooldown)) + ReadEnchantmentList(src, ref pos, enchantments, EnchantmentBucket.Cooldown); + if (mask.HasFlag(EnchantmentMask.Vitae)) + { + // Vitae is a single enchantment (no count prefix). + enchantments.Add(ReadEnchantment(src, ref pos, EnchantmentBucket.Vitae)); + } + } + + private static void ReadEnchantmentList( + ReadOnlySpan src, ref int pos, List dest, + EnchantmentBucket bucket) + { + uint count = ReadU32(src, ref pos); + if (count > 0x4000) throw new FormatException("unreasonable enchantment list count"); + for (int i = 0; i < count; i++) + dest.Add(ReadEnchantment(src, ref pos, bucket)); + } + + private static EnchantmentEntry ReadEnchantment( + ReadOnlySpan src, ref int pos, EnchantmentBucket bucket) + { + // Holtburger Enchantment::unpack — 28 + 4 (Guid) + 28 + (4 if has_set_id) bytes: + // u16 spell_id, u16 layer, u16 spell_category, u16 has_spell_set_id, + // u32 power_level, f64 start_time, f64 duration, (28 bytes total) + // u32 caster_guid, (4) + // f32 degrade_modifier, f32 degrade_limit, f64 last_time_degraded, + // u32 stat_mod_type, u32 stat_mod_key, f32 stat_mod_value, (28) + // if has_spell_set_id != 0: u32 spell_set_id (0 or 4) + if (src.Length - pos < 60) throw new FormatException("truncated enchantment record"); + ushort spellId = ReadU16(src, ref pos); + ushort layer = ReadU16(src, ref pos); + ushort spellCategory = ReadU16(src, ref pos); + ushort hasSpellSetId = ReadU16(src, ref pos); + uint powerLevel = ReadU32(src, ref pos); + double startTime = ReadF64(src, ref pos); + double duration = ReadF64(src, ref pos); + uint casterGuid = ReadU32(src, ref pos); + float degradeModifier= ReadF32(src, ref pos); + float degradeLimit = ReadF32(src, ref pos); + double lastDegraded = ReadF64(src, ref pos); + uint statModType = ReadU32(src, ref pos); + uint statModKey = ReadU32(src, ref pos); + float statModValue = ReadF32(src, ref pos); + uint? spellSetId = null; + if (hasSpellSetId != 0) + spellSetId = ReadU32(src, ref pos); + return new EnchantmentEntry( + spellId, layer, spellCategory, hasSpellSetId, powerLevel, + startTime, duration, casterGuid, degradeModifier, degradeLimit, + lastDegraded, statModType, statModKey, statModValue, spellSetId, + bucket); + } + + private static ushort ReadU16(ReadOnlySpan src, ref int pos) + { + if (src.Length - pos < 2) throw new FormatException("truncated u16"); + ushort v = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos)); + pos += 2; + return v; + } + // ── Primitive readers ─────────────────────────────────────────────────── private static uint ReadU32(ReadOnlySpan src, ref int pos) diff --git a/src/AcDream.Core/Spells/EnchantmentMath.cs b/src/AcDream.Core/Spells/EnchantmentMath.cs index 1b1a4eb..73949eb 100644 --- a/src/AcDream.Core/Spells/EnchantmentMath.cs +++ b/src/AcDream.Core/Spells/EnchantmentMath.cs @@ -102,22 +102,34 @@ public static class EnchantmentMath } } - // Aggregate StatMod values from the deduplicated set. - // Note: ActiveEnchantmentRecord doesn't currently carry - // StatMod (type/key/val). Until ISSUES.md #12 lands the wire - // extension, this loop produces no aggregation effect — we - // keep the architecture in place so the swap-in is local. + // Aggregate StatMod values from the deduplicated set. Records + // with StatModKey == statKey contribute; bucket determines + // whether the value is multiplicative or additive. + // Bucket 1 (Multiplicative): multiplier *= ench.StatModValue + // Bucket 2 (Additive): additive += ench.StatModValue + // Bucket 8 (Vitae): multiplier *= ench.StatModValue (post-pass) + // Records without StatMod data (StatModKey == null) — e.g. + // those from older MagicUpdateEnchantment events that don't + // yet parse the full payload — contribute nothing. float multiplier = 1.0f; float additive = 0.0f; + float vitae = 1.0f; foreach (var ench in stronger.Values) { - // TODO ISSUES.md #12: filter by ench.StatModKey == statKey - // and apply ench.StatModType (multiplicative vs additive) - // with ench.StatModValue. For now, stat-key filtering is - // a no-op since the data isn't on the record yet. - _ = ench; - _ = statKey; + if (ench.StatModKey is not uint key || key != statKey) continue; + if (ench.StatModValue is not float val) continue; + + switch (ench.Bucket) + { + case 1: multiplier *= val; break; + case 2: additive += val; break; + case 8: vitae *= val; break; + // Bucket 4 (Cooldown) doesn't affect vital max. + } } + // Vitae is applied multiplicatively last per retail + // CEnchantmentRegistry::EnchantAttribute behaviour. + multiplier *= vitae; return multiplier == 1.0f && additive == 0.0f ? VitalMod.Identity : new VitalMod(multiplier, additive); diff --git a/src/AcDream.Core/Spells/Spellbook.cs b/src/AcDream.Core/Spells/Spellbook.cs index 78a41ae..66ca93a 100644 --- a/src/AcDream.Core/Spells/Spellbook.cs +++ b/src/AcDream.Core/Spells/Spellbook.cs @@ -104,6 +104,19 @@ public sealed class Spellbook EnchantmentAdded?.Invoke(record); } + /// + /// Issue #7 / #12 — accept a fully-populated record from + /// PlayerDescription's enchantment block (which carries + /// the StatMod triad + bucket). Used when the wire-format extension + /// gives us the full per-enchantment payload, rather than the + /// 4-field summary from MagicUpdateEnchantment. + /// + public void OnEnchantmentAdded(ActiveEnchantmentRecord record) + { + _activeByLayer[record.LayerId] = record; + EnchantmentAdded?.Invoke(record); + } + /// 0x02C3 / 0x02C7 MagicRemove/DispelEnchantment. public void OnEnchantmentRemoved(uint layerId, uint spellId) { @@ -129,13 +142,27 @@ public sealed class Spellbook } /// -/// Summary of one active enchantment layer on the player. Richer detail -/// (stat mods, category, power) requires the full -/// struct — this record is the wire-slim version surfaced by the -/// event. +/// Summary of one active enchantment layer on the player. The +/// optional StatMod fields (issue #12) carry the wire-level +/// `_smod` triad (type, key, val) when available — only +/// `PlayerDescription`'s enchantment block currently populates these +/// (). +/// `MagicUpdateEnchantment` events still produce records with these +/// fields null until the wire parser is extended. +/// +/// +/// tells EnchantmentMath whether this +/// enchantment's StatMod is multiplicative (0x01), additive +/// (0x02), cooldown (0x04), or vitae (0x08) per +/// the retail EnchantmentMask classification. +/// /// public readonly record struct ActiveEnchantmentRecord( - uint SpellId, - uint LayerId, - float Duration, - uint CasterGuid); + uint SpellId, + uint LayerId, + float Duration, + uint CasterGuid, + uint? StatModType = null, + uint? StatModKey = null, + float? StatModValue = null, + uint Bucket = 0); diff --git a/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs b/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs index f2e6312..fda1261 100644 --- a/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs +++ b/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs @@ -207,6 +207,108 @@ public sealed class PlayerDescriptionParserTests w.Write(ranks); w.Write(start); w.Write(xp); w.Write(current); } + [Fact] + public void TryParse_EnchantmentBlock_PopulatesEnchantments_WithStatModAndBucket() + { + // ATTRIBUTE | SPELL | ENCHANTMENT vector flag (= 0x301 minus + // SKILL = 0x301 incl. ATTRIBUTE+SPELL+ENCHANTMENT). Empty + // attribute block + empty spell table + 1 multiplicative + // enchantment + 1 additive enchantment. Verifies end-to-end + // that the enchantment record schema lands intact. + var sb = new MemoryStream(); + using var writer = new BinaryWriter(sb); + writer.Write(0u); // propertyFlags + writer.Write(0x52u); // weenieType + // vectorFlags = ATTRIBUTE (0x01) | SPELL (0x100) | ENCHANTMENT (0x200) = 0x301 + writer.Write(0x301u); + writer.Write(1u); // has_health + writer.Write(0u); // attribute_flags = 0 -> no entries + + // Spell table: empty (count=0). + writer.Write((ushort)0); + writer.Write((ushort)0); + + // EnchantmentMask = MULTIPLICATIVE (0x01) | ADDITIVE (0x02) = 0x03 + writer.Write(0x03u); + // Multiplicative list: 1 entry + writer.Write(1u); + WriteEnchantment(writer, + spellId: 1234, layer: 5, spellCategory: 100, hasSpellSetId: 0, + powerLevel: 999, startTime: 12.5, duration: 1800.0, + casterGuid: 0xCAFE0001u, degradeMod: 1.0f, degradeLimit: 0.5f, + lastDegraded: 0.0, statModType: 0x00010000u, statModKey: 3u /* MaxStamina */, + statModValue: 1.5f); + // Additive list: 1 entry + writer.Write(1u); + WriteEnchantment(writer, + spellId: 5678, layer: 6, spellCategory: 101, hasSpellSetId: 0, + powerLevel: 100, startTime: 13.0, duration: 1500.0, + casterGuid: 0xCAFE0002u, degradeMod: 1.0f, degradeLimit: 0.5f, + lastDegraded: 0.0, statModType: 0x00020000u, statModKey: 5u /* MaxMana */, + statModValue: 25.0f); + + var parsed = PlayerDescriptionParser.TryParse(sb.ToArray()); + + Assert.NotNull(parsed); + Assert.Equal(2, parsed!.Value.Enchantments.Count); + + var mult = parsed.Value.Enchantments[0]; + Assert.Equal((ushort)1234, mult.SpellId); + Assert.Equal((ushort)5, mult.Layer); + Assert.Equal(3u, mult.StatModKey); + Assert.Equal(1.5f, mult.StatModValue); + Assert.Equal(PlayerDescriptionParser.EnchantmentBucket.Multiplicative, mult.Bucket); + + var add = parsed.Value.Enchantments[1]; + Assert.Equal((ushort)5678, add.SpellId); + Assert.Equal(5u, add.StatModKey); + Assert.Equal(25.0f, add.StatModValue); + Assert.Equal(PlayerDescriptionParser.EnchantmentBucket.Additive, add.Bucket); + } + + [Fact] + public void TryParse_VitaeSingleton_AppearsInEnchantments() + { + // EnchantmentMask = VITAE only (0x08). Single Enchantment, no + // count prefix. + var sb = new MemoryStream(); + using var writer = new BinaryWriter(sb); + writer.Write(0u); // propertyFlags + writer.Write(0x52u); // weenieType + writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT + writer.Write(1u); // has_health + writer.Write(0u); // empty attribute_flags + + writer.Write(0x08u); // EnchantmentMask = VITAE + WriteEnchantment(writer, + spellId: 7777, layer: 0, spellCategory: 0, hasSpellSetId: 0, + powerLevel: 0, startTime: 0.0, duration: -1.0, + casterGuid: 0u, degradeMod: 0f, degradeLimit: 0f, + lastDegraded: 0.0, statModType: 0u, statModKey: 1u /* MaxHealth */, + statModValue: 0.95f); + + var parsed = PlayerDescriptionParser.TryParse(sb.ToArray()); + + Assert.NotNull(parsed); + Assert.Single(parsed!.Value.Enchantments); + Assert.Equal(PlayerDescriptionParser.EnchantmentBucket.Vitae, parsed.Value.Enchantments[0].Bucket); + Assert.Equal(0.95f, parsed.Value.Enchantments[0].StatModValue); + } + + private static void WriteEnchantment(BinaryWriter w, + ushort spellId, ushort layer, ushort spellCategory, ushort hasSpellSetId, + uint powerLevel, double startTime, double duration, uint casterGuid, + float degradeMod, float degradeLimit, double lastDegraded, + uint statModType, uint statModKey, float statModValue) + { + w.Write(spellId); w.Write(layer); w.Write(spellCategory); w.Write(hasSpellSetId); + w.Write(powerLevel); w.Write(startTime); w.Write(duration); + w.Write(casterGuid); + w.Write(degradeMod); w.Write(degradeLimit); w.Write(lastDegraded); + w.Write(statModType); w.Write(statModKey); w.Write(statModValue); + // Skip optional spell_set_id (only present if hasSpellSetId != 0). + } + [Fact] public void TryParse_SpellTable_PopulatesSpellsDictionary() { diff --git a/tests/AcDream.Core.Tests/Spells/EnchantmentMathTests.cs b/tests/AcDream.Core.Tests/Spells/EnchantmentMathTests.cs index 290dd67..f02a4ee 100644 --- a/tests/AcDream.Core.Tests/Spells/EnchantmentMathTests.cs +++ b/tests/AcDream.Core.Tests/Spells/EnchantmentMathTests.cs @@ -109,6 +109,106 @@ public sealed class EnchantmentMathTests Assert.Equal(EnchantmentMath.VitalMod.Identity, mod); } + [Fact] + public void GetMod_MultiplicativeBucket_AppliesProductWhenStatKeyMatches() + { + // Two multiplicative enchantments on MaxStamina (key=3): values + // 1.2 and 1.1 → final multiplier = 1.2 × 1.1 = 1.32. + // Different families so neither dedups the other. + var table = LoadTable( + (10u, "Buff10", 100u), + (11u, "Buff11", 200u)); + var enchantments = new[] + { + MakeMultRecord(spellId: 10, layer: 1, statKey: 3u, val: 1.2f), + MakeMultRecord(spellId: 11, layer: 2, statKey: 3u, val: 1.1f), + }; + var mod = EnchantmentMath.GetMod(enchantments, table, + EnchantmentMath.StatKey.MaxStamina); + Assert.Equal(1.32f, mod.Multiplier, precision: 4); + Assert.Equal(0.0f, mod.Additive); + } + + [Fact] + public void GetMod_AdditiveBucket_SumsValueWhenStatKeyMatches() + { + var table = LoadTable( + (20u, "Add1", 300u), + (21u, "Add2", 301u)); + var enchantments = new[] + { + MakeAddRecord(spellId: 20, layer: 1, statKey: 5u /* MaxMana */, val: 25f), + MakeAddRecord(spellId: 21, layer: 2, statKey: 5u, val: 50f), + }; + var mod = EnchantmentMath.GetMod(enchantments, table, + EnchantmentMath.StatKey.MaxMana); + Assert.Equal(1.0f, mod.Multiplier); + Assert.Equal(75.0f, mod.Additive); + } + + [Fact] + public void GetMod_StatKeyMismatch_DoesNotContribute() + { + var table = LoadTable((30u, "Health buff", 500u)); + // Buff modifies MaxHealth (key=1) but we ask for MaxStamina (key=3). + var enchantments = new[] + { + MakeMultRecord(spellId: 30, layer: 1, statKey: 1u /* MaxHealth */, val: 1.5f), + }; + var mod = EnchantmentMath.GetMod(enchantments, table, + EnchantmentMath.StatKey.MaxStamina); + Assert.Equal(EnchantmentMath.VitalMod.Identity, mod); + } + + [Fact] + public void GetMod_VitaeBucket_AppliedMultiplicativelyAfterBuffs() + { + // Vitae = 0.85 (15% death penalty) on MaxHealth, plus a +10 + // additive from a Restoration buff. Family 0 means each is its + // own bucket. + var table = LoadTable( + (40u, "Restoration", 0u), + (41u, "Vitae", 0u)); + var enchantments = new[] + { + MakeAddRecord(spellId: 40, layer: 1, statKey: 1u /* MaxHealth */, val: 10f), + MakeVitaeRecord(spellId: 41, layer: 2, statKey: 1u, val: 0.85f), + }; + var mod = EnchantmentMath.GetMod(enchantments, table, + EnchantmentMath.StatKey.MaxHealth); + // Vitae multiplier 0.85, additive 10. + Assert.Equal(0.85f, mod.Multiplier, precision: 3); + Assert.Equal(10.0f, mod.Additive); + } + + [Fact] + public void GetMod_FamilyStacking_PicksHigherSpellId() + { + // Two spells in the same family — only the one with the higher + // SpellId should contribute. + var table = LoadTable( + (10u, "Strength I", 1u), // Family=1 + (132u, "Strength VII", 1u)); // same family + var enchantments = new[] + { + MakeMultRecord(spellId: 10u, layer: 1, statKey: 3u, val: 1.1f), + MakeMultRecord(spellId: 132u, layer: 2, statKey: 3u, val: 1.5f), + }; + var mod = EnchantmentMath.GetMod(enchantments, table, + EnchantmentMath.StatKey.MaxStamina); + // Only the higher-id buff (1.5) applies. + Assert.Equal(1.5f, mod.Multiplier, precision: 3); + } + + private static ActiveEnchantmentRecord MakeMultRecord(uint spellId, uint layer, uint statKey, float val) => + new(spellId, layer, 60f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 1u); + + private static ActiveEnchantmentRecord MakeAddRecord(uint spellId, uint layer, uint statKey, float val) => + new(spellId, layer, 60f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 2u); + + private static ActiveEnchantmentRecord MakeVitaeRecord(uint spellId, uint layer, uint statKey, float val) => + new(spellId, layer, -1f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 8u); + private static SpellTable LoadTable(params (uint id, string name, uint family)[] rows) { // Build a synthetic CSV with just enough columns for SpellTable to