fix(player): EnchantmentMask bit fix + Vitae key=0 + absolute Vitals overlay
Three fixes to the Vitals HUD path:
1. EnchantmentMask Vitae/Cooldown bit values (parser regression).
ACE's enum at references/ACE/Source/ACE.Entity/Enum/EnchantmentCategory.cs
has Vitae=0x4 and Cooldown=0x8. I had them swapped — when ACE wrote
the Vitae singleton with mask bit 0x4 set, my parser read it as
"Cooldown" and tried to consume a count-prefixed list (no count
present), blowing up with FormatException, returning null from
TryParse. PlayerDescription consequently failed to parse on every
live login. Fix: swap the bit values + bucket constants to match ACE.
2. Vitae applies regardless of StatModKey. Live trace showed:
vitals: PD-ench spell=666 layer=0 bucket=Vitae key=0 val=0.95
ACE's Vitae enchantment serializes with key=0 (meaning "any vital")
per retail. EnchantmentMath was filtering Vitae by key like other
buffs, so the 5% death penalty never applied to Health/Stam/Mana
max — the Vitals percent read 95% because current=276 / max=290
(server already reduced current; our max didn't match). Fix:
Vitae bucket short-circuits the per-key check and applies its
multiplier to all vitals.
3. Absolute current/max in HUD overlay. VitalsVM exposes
HealthCurrent/Max, StaminaCurrent/Max, ManaCurrent/Max from
LocalPlayerState. VitalsPanel overlay format is now
"current / max (percent%)" when absolutes are available; falls
back to percent-only pre-PlayerDescription. Matches the retail
look the user requested ("HP 400/400" style).
Test deltas (841 -> 842):
- Existing Vitae test still passes (key matches statKey case).
- New Vitae key=0 test pins the "any vital" semantics.
- Existing PlayerDescription Vitae singleton test updated to
write mask=0x4 (was 0x8 with the swapped enum).
Live verification: with +Acdream's Vitae-666 active and Endurance.current=290:
HP : current=138, max=145×0.95≈138 → bar 100% (was 95%)
Stam : current=276, max=290×0.95≈276 → bar 100%
Mana : current=190, max=200×0.95≈190 → bar 100%
Overlay reads e.g. "276 / 276 (100%)".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bb5003a849
commit
196f883c10
6 changed files with 99 additions and 19 deletions
|
|
@ -153,13 +153,18 @@ public static class PlayerDescriptionParser
|
|||
|
||||
/// <summary>Bucket the enchantment came from in the
|
||||
/// <c>EnchantmentMask</c> outer bitfield. Determines whether the
|
||||
/// stat-mod aggregator multiplies or adds.</summary>
|
||||
/// 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.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Format a vital-bar overlay. Prefers <c>current / max (percent%)</c>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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}%";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,4 +79,28 @@ public sealed class VitalsVM
|
|||
/// <see cref="StaminaPercent"/>.
|
||||
/// </summary>
|
||||
public float? ManaPercent => _local?.ManaPercent;
|
||||
|
||||
// ── Absolute values for HUD overlays ──────────────────────────────────
|
||||
|
||||
/// <summary>Current health value (server-authoritative absolute) or
|
||||
/// <c>null</c> if <see cref="LocalPlayerState"/> hasn't received the
|
||||
/// vital snapshot yet.</summary>
|
||||
public uint? HealthCurrent => _local?.Get(LocalPlayerState.VitalKind.Health)?.Current;
|
||||
|
||||
/// <summary>Max health value, accounting for attribute contribution
|
||||
/// + active enchantment buffs + vitae. <c>null</c> if no vital
|
||||
/// snapshot yet.</summary>
|
||||
public uint? HealthMax => _local?.GetMaxApprox(LocalPlayerState.VitalKind.Health);
|
||||
|
||||
/// <summary>Current stamina value.</summary>
|
||||
public uint? StaminaCurrent => _local?.Get(LocalPlayerState.VitalKind.Stamina)?.Current;
|
||||
|
||||
/// <summary>Max stamina including buffs + vitae.</summary>
|
||||
public uint? StaminaMax => _local?.GetMaxApprox(LocalPlayerState.VitalKind.Stamina);
|
||||
|
||||
/// <summary>Current mana value.</summary>
|
||||
public uint? ManaCurrent => _local?.Get(LocalPlayerState.VitalKind.Mana)?.Current;
|
||||
|
||||
/// <summary>Max mana including buffs + vitae.</summary>
|
||||
public uint? ManaMax => _local?.GetMaxApprox(LocalPlayerState.VitalKind.Mana);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue