diff --git a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs index 23cf1f7..4911f66 100644 --- a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs +++ b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs @@ -153,13 +153,18 @@ public static class PlayerDescriptionParser /// Bucket the enchantment came from in the /// EnchantmentMask outer bitfield. Determines whether the - /// stat-mod aggregator multiplies or adds. + /// stat-mod aggregator multiplies or adds. **Bit values match + /// ACE's `EnchantmentMask` enum** at + /// `references/ACE/Source/ACE.Entity/Enum/EnchantmentCategory.cs` — + /// Vitae = 0x4 (bit 2), Cooldown = 0x8 (bit 3). Critically NOT the + /// reverse: getting these wrong causes the parser to read a Vitae + /// singleton as a Cooldown list-with-count and fail. public enum EnchantmentBucket : uint { Multiplicative = 1, Additive = 2, - Cooldown = 4, - Vitae = 8, + Vitae = 4, + Cooldown = 8, } [Flags] @@ -168,8 +173,8 @@ public static class PlayerDescriptionParser None = 0, Multiplicative = 0x01, Additive = 0x02, - Cooldown = 0x04, - Vitae = 0x08, + Vitae = 0x04, + Cooldown = 0x08, } public readonly record struct Parsed( @@ -257,9 +262,12 @@ public static class PlayerDescriptionParser weenieType, propertyFlags, vectorFlags, hasHealth, bundle, positions, attributes, skills, spells, enchantments); } - catch (FormatException) + catch (FormatException ex) { // Truncation mid-walk — return null so caller knows parse failed. + // Diagnostic when ACDREAM_DUMP_VITALS=1 surfaces the failure point. + if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1") + System.Console.WriteLine($"PlayerDescriptionParser: FormatException at pos={pos}/{payload.Length}: {ex.Message}"); return null; } } diff --git a/src/AcDream.Core/Spells/EnchantmentMath.cs b/src/AcDream.Core/Spells/EnchantmentMath.cs index 73949eb..6fd27ee 100644 --- a/src/AcDream.Core/Spells/EnchantmentMath.cs +++ b/src/AcDream.Core/Spells/EnchantmentMath.cs @@ -102,12 +102,12 @@ public static class EnchantmentMath } } - // Aggregate StatMod values from the deduplicated set. Records - // with StatModKey == statKey contribute; bucket determines - // whether the value is multiplicative or additive. + // Aggregate StatMod values from the deduplicated set. Bucket + // values match ACE's EnchantmentMask flag bits: // Bucket 1 (Multiplicative): multiplier *= ench.StatModValue // Bucket 2 (Additive): additive += ench.StatModValue - // Bucket 8 (Vitae): multiplier *= ench.StatModValue (post-pass) + // Bucket 4 (Vitae): multiplier *= ench.StatModValue (post-pass) + // Bucket 8 (Cooldown): skipped (doesn't affect vital max) // Records without StatMod data (StatModKey == null) — e.g. // those from older MagicUpdateEnchantment events that don't // yet parse the full payload — contribute nothing. @@ -116,15 +116,27 @@ public static class EnchantmentMath float vitae = 1.0f; foreach (var ench in stronger.Values) { - if (ench.StatModKey is not uint key || key != statKey) continue; if (ench.StatModValue is not float val) continue; + // Vitae (bucket 4) is a special-case singleton on + // CEnchantmentRegistry._vitae and applies its multiplier + // to ALL vitals regardless of StatModKey (retail uses + // key=0 as "any vital"). Apply unconditionally and skip + // the per-key check. + if (ench.Bucket == 4) + { + vitae *= val; + continue; + } + + // Multiplicative + Additive buffs filter by stat key — + // only those targeting the requested vital contribute. + if (ench.StatModKey is not uint key || key != statKey) 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. + // Bucket 8 (Cooldown) doesn't affect vital max. } } // Vitae is applied multiplicatively last per retail diff --git a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs index 0b0b58a..de209b3 100644 --- a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs @@ -42,18 +42,21 @@ public sealed class VitalsPanel : IPanel return; } - // HP — always available from CombatState. + // HP — always available from CombatState. If LocalPlayer has the + // absolute values too, prefer "current/max (percent)" overlay. float hp = _vm.HealthPercent; renderer.Text("HP"); renderer.SameLine(); - renderer.ProgressBar(hp, BarWidth, overlay: $"{hp * 100f:F0}%"); + renderer.ProgressBar(hp, BarWidth, overlay: FormatOverlay( + hp, _vm.HealthCurrent, _vm.HealthMax)); // Stamina — show only when the VM has a real value. if (_vm.StaminaPercent is float stam) { renderer.Text("Stam"); renderer.SameLine(); - renderer.ProgressBar(stam, BarWidth, overlay: $"{stam * 100f:F0}%"); + renderer.ProgressBar(stam, BarWidth, overlay: FormatOverlay( + stam, _vm.StaminaCurrent, _vm.StaminaMax)); } // Mana — show only when the VM has a real value. @@ -61,9 +64,23 @@ public sealed class VitalsPanel : IPanel { renderer.Text("Mana"); renderer.SameLine(); - renderer.ProgressBar(mana, BarWidth, overlay: $"{mana * 100f:F0}%"); + renderer.ProgressBar(mana, BarWidth, overlay: FormatOverlay( + mana, _vm.ManaCurrent, _vm.ManaMax)); } renderer.End(); } + + /// + /// Format a vital-bar overlay. Prefers current / max (percent%) + /// when absolute values are available; falls back to percent-only + /// when not (e.g. HP pre-PlayerDescription, where only the + /// CombatState percent has been wired). + /// + private static string FormatOverlay(float percent, uint? current, uint? max) + { + if (current is uint c && max is uint m && m > 0) + return $"{c} / {m} ({percent * 100f:F0}%)"; + return $"{percent * 100f:F0}%"; + } } diff --git a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs index 151d849..5460def 100644 --- a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs @@ -79,4 +79,28 @@ public sealed class VitalsVM /// . /// public float? ManaPercent => _local?.ManaPercent; + + // ── Absolute values for HUD overlays ────────────────────────────────── + + /// Current health value (server-authoritative absolute) or + /// null if hasn't received the + /// vital snapshot yet. + public uint? HealthCurrent => _local?.Get(LocalPlayerState.VitalKind.Health)?.Current; + + /// Max health value, accounting for attribute contribution + /// + active enchantment buffs + vitae. null if no vital + /// snapshot yet. + public uint? HealthMax => _local?.GetMaxApprox(LocalPlayerState.VitalKind.Health); + + /// Current stamina value. + public uint? StaminaCurrent => _local?.Get(LocalPlayerState.VitalKind.Stamina)?.Current; + + /// Max stamina including buffs + vitae. + public uint? StaminaMax => _local?.GetMaxApprox(LocalPlayerState.VitalKind.Stamina); + + /// Current mana value. + public uint? ManaCurrent => _local?.Get(LocalPlayerState.VitalKind.Mana)?.Current; + + /// Max mana including buffs + vitae. + public uint? ManaMax => _local?.GetMaxApprox(LocalPlayerState.VitalKind.Mana); } diff --git a/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs b/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs index fda1261..4908bb8 100644 --- a/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs +++ b/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs @@ -279,7 +279,7 @@ public sealed class PlayerDescriptionParserTests writer.Write(1u); // has_health writer.Write(0u); // empty attribute_flags - writer.Write(0x08u); // EnchantmentMask = VITAE + writer.Write(0x04u); // EnchantmentMask = VITAE (ACE bit 2) WriteEnchantment(writer, spellId: 7777, layer: 0, spellCategory: 0, hasSpellSetId: 0, powerLevel: 0, startTime: 0.0, duration: -1.0, diff --git a/tests/AcDream.Core.Tests/Spells/EnchantmentMathTests.cs b/tests/AcDream.Core.Tests/Spells/EnchantmentMathTests.cs index f02a4ee..08f6c57 100644 --- a/tests/AcDream.Core.Tests/Spells/EnchantmentMathTests.cs +++ b/tests/AcDream.Core.Tests/Spells/EnchantmentMathTests.cs @@ -181,6 +181,25 @@ public sealed class EnchantmentMathTests Assert.Equal(10.0f, mod.Additive); } + [Fact] + public void GetMod_Vitae_AppliesEvenWhenStatModKeyIsZero() + { + // Retail behaviour observed in live trace (2026-04-25): ACE's + // Vitae enchantment serializes with StatModKey = 0 (meaning + // "any vital"). The Vitae multiplier must apply regardless of + // the requested stat key — otherwise +Acdream's 5% death + // penalty wouldn't show up in the Vitals HUD percent. + var table = LoadTable((666u, "Vitae", 0u)); + var enchantments = new[] + { + MakeVitaeRecord(spellId: 666, layer: 0, statKey: 0u /* "any" */, val: 0.95f), + }; + // Query for MaxStamina; Vitae key=0 should still apply. + var mod = EnchantmentMath.GetMod(enchantments, table, + EnchantmentMath.StatKey.MaxStamina); + Assert.Equal(0.95f, mod.Multiplier, precision: 3); + } + [Fact] public void GetMod_FamilyStacking_PicksHigherSpellId() { @@ -207,7 +226,7 @@ public sealed class EnchantmentMathTests 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); + new(spellId, layer, -1f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 4u); private static SpellTable LoadTable(params (uint id, string name, uint family)[] rows) {