feat(net): #7 PlayerDescriptionParser - enchantment block walker + StatMod flow
Extends PlayerDescriptionParser past the spell block to parse the
Enchantment trailer per holtburger events.rs:462-501 +
magic/types.rs:40. New EnchantmentEntry record carries the full
60-64 byte wire payload:
u16 spell_id, layer, spell_category, has_spell_set_id
u32 power_level
f64 start_time, duration
u32 caster_guid
f32 degrade_modifier, degrade_limit
f64 last_time_degraded
u32 stat_mod_type, stat_mod_key
f32 stat_mod_value
[u32 spell_set_id]?
+ EnchantmentBucket (Multiplicative / Additive / Cooldown / Vitae)
EnchantmentMask outer u32 selects which buckets follow; each bucket
(except Vitae) is u32 count + N records. Vitae is a singleton.
Parsed.Enchantments now exposed as IReadOnlyList<EnchantmentEntry>.
GameEventWiring routes each entry through Spellbook.OnEnchantmentAdded
with the full StatMod data + bucket. EnchantmentMath.GetMod consumes
StatMod records to produce real (Multiplier, Additive) per stat key:
Bucket 1 (Multiplicative): multiplier *= val
Bucket 2 (Additive): additive += val
Bucket 8 (Vitae): multiplier *= val (applied last)
Bucket 4 (Cooldown): skipped (not a vital mod)
ActiveEnchantmentRecord extended with optional StatModType /
StatModKey / StatModValue / Bucket fields. Existing 4-arg callers
stay compatible (defaults to null / 0). New OnEnchantmentAdded
overload accepts the full record from PlayerDescription path.
Tests: 7 new (834 -> 841):
- PlayerDescriptionParserTests (2): enchantment block schema with
multiplicative + additive buckets, Vitae singleton.
- EnchantmentMathTests (5): multiplicative buffs aggregate, additive
buffs sum, stat-key mismatch filters out, Vitae applied
multiplicatively, family-stacking picks higher spell-id.
Closes #7 (parser past spells, enchantment block parsed).
Closes #12 (StatMod flow architecture — data lights up #6's
aggregator). Files #13 (remaining trailer sections: options /
shortcuts / hotbars / desired_comps / spellbook_filters / options2 /
gameplay_options / inventory / equipped — needs the heuristic
gameplay_options walker per holtburger).
Note: ParseMagicUpdateEnchantment (live-update 0x02C2) NOT yet
extended — still uses 4-field summary. PlayerDescription is the
load-bearing path for #6; live updates can be folded in separately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b153bbe5ad
commit
bb5003a849
7 changed files with 447 additions and 58 deletions
|
|
@ -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<EnchantmentEntry> 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`
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,6 +129,49 @@ public static class PlayerDescriptionParser
|
|||
float X, float Y, float Z,
|
||||
float Qw, float Qx, float Qy, float Qz);
|
||||
|
||||
/// <summary>One enchantment entry from the trailer enchantment
|
||||
/// block. Wire layout per holtburger
|
||||
/// <c>messages/magic/types.rs:40</c> (60 or 64 bytes per record).
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>Bucket the enchantment came from in the
|
||||
/// <c>EnchantmentMask</c> outer bitfield. Determines whether the
|
||||
/// stat-mod aggregator multiplies or adds.</summary>
|
||||
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<uint, WorldPosition> Positions,
|
||||
IReadOnlyList<AttributeEntry> Attributes,
|
||||
IReadOnlyList<SkillEntry> Skills,
|
||||
IReadOnlyDictionary<uint, float> Spells);
|
||||
IReadOnlyDictionary<uint, float> Spells,
|
||||
IReadOnlyList<EnchantmentEntry> Enchantments);
|
||||
|
||||
/// <summary>
|
||||
/// Parse a PlayerDescription payload. The 0xF7B0 envelope has been
|
||||
|
|
@ -161,6 +205,7 @@ public static class PlayerDescriptionParser
|
|||
var attributes = new List<AttributeEntry>();
|
||||
var skills = new List<SkillEntry>();
|
||||
var spells = new Dictionary<uint, float>();
|
||||
var enchantments = new List<EnchantmentEntry>();
|
||||
|
||||
// ── 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<uint, float> spells)
|
||||
{
|
||||
return new Parsed(weenieType, pFlags, vFlags, hasHealth,
|
||||
bundle, positions, attributes, skills, spells);
|
||||
bundle, positions, attributes, skills, spells,
|
||||
System.Array.Empty<EnchantmentEntry>());
|
||||
}
|
||||
|
||||
// ── 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<byte> src, ref int pos, List<EnchantmentEntry> 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<byte> src, ref int pos, List<EnchantmentEntry> 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<byte> 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<byte> 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<byte> src, ref int pos)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -104,6 +104,19 @@ public sealed class Spellbook
|
|||
EnchantmentAdded?.Invoke(record);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issue #7 / #12 — accept a fully-populated record from
|
||||
/// <c>PlayerDescription</c>'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 <c>MagicUpdateEnchantment</c>.
|
||||
/// </summary>
|
||||
public void OnEnchantmentAdded(ActiveEnchantmentRecord record)
|
||||
{
|
||||
_activeByLayer[record.LayerId] = record;
|
||||
EnchantmentAdded?.Invoke(record);
|
||||
}
|
||||
|
||||
/// <summary>0x02C3 / 0x02C7 MagicRemove/DispelEnchantment.</summary>
|
||||
public void OnEnchantmentRemoved(uint layerId, uint spellId)
|
||||
{
|
||||
|
|
@ -129,13 +142,27 @@ public sealed class Spellbook
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of one active enchantment layer on the player. Richer detail
|
||||
/// (stat mods, category, power) requires the full <see cref="ActiveBuff"/>
|
||||
/// struct — this record is the wire-slim version surfaced by the
|
||||
/// <see cref="Spellbook.EnchantmentAdded"/> event.
|
||||
/// Summary of one active enchantment layer on the player. The
|
||||
/// optional StatMod fields (issue #12) carry the wire-level
|
||||
/// `_smod` triad <c>(type, key, val)</c> when available — only
|
||||
/// `PlayerDescription`'s enchantment block currently populates these
|
||||
/// (<see cref="AcDream.Core.Net.Messages.PlayerDescriptionParser"/>).
|
||||
/// `MagicUpdateEnchantment` events still produce records with these
|
||||
/// fields null until the wire parser is extended.
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="Bucket"/> tells <c>EnchantmentMath</c> whether this
|
||||
/// enchantment's StatMod is multiplicative (<c>0x01</c>), additive
|
||||
/// (<c>0x02</c>), cooldown (<c>0x04</c>), or vitae (<c>0x08</c>) per
|
||||
/// the retail <c>EnchantmentMask</c> classification.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue