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)
{