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>
106 lines
4.5 KiB
C#
106 lines
4.5 KiB
C#
using AcDream.Core.Combat;
|
|
using AcDream.Core.Player;
|
|
|
|
namespace AcDream.UI.Abstractions.Panels.Vitals;
|
|
|
|
/// <summary>
|
|
/// ViewModel for the vitals HUD panel. Reads live health percentage for the
|
|
/// local player from <see cref="CombatState"/> (which is fed by the server's
|
|
/// <c>UpdateHealth (0x01C0)</c> GameEvent).
|
|
///
|
|
/// <para>
|
|
/// <b>Sources:</b>
|
|
/// </para>
|
|
///
|
|
/// <list type="bullet">
|
|
/// <item>HP — <see cref="CombatState.GetHealthPercent"/> (percent-only,
|
|
/// updated by <c>UpdateHealth (0x01C0)</c>).</item>
|
|
/// <item>Stamina / Mana — <see cref="LocalPlayerState"/>'s
|
|
/// <c>StaminaPercent</c> / <c>ManaPercent</c>, populated from the
|
|
/// <c>CreatureProfile</c> embedded in <c>PlayerDescription
|
|
/// (0x0013)</c>. When no <see cref="LocalPlayerState"/> is wired
|
|
/// (older constructor / tests), both stay <c>null</c> and
|
|
/// <c>VitalsPanel</c> simply skips those bars.</item>
|
|
/// </list>
|
|
///
|
|
/// <para>
|
|
/// <b>GUID timing:</b> the local player's server GUID isn't known at
|
|
/// <c>OnLoad</c> (pre-login). Construct with <see cref="SetLocalPlayerGuid"/>
|
|
/// left as 0; <c>GameWindow</c> calls the setter when the live session
|
|
/// receives its guid at <c>EnterWorld</c>. Before the GUID is set,
|
|
/// <see cref="HealthPercent"/> returns 1.0 (via <c>CombatState</c>'s safe
|
|
/// default for unknown guids) — the bar reads "full", which is harmless.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class VitalsVM
|
|
{
|
|
private readonly CombatState _combat;
|
|
private readonly LocalPlayerState? _local;
|
|
private uint _localPlayerGuid;
|
|
|
|
/// <summary>
|
|
/// Build a VitalsVM bound to a <see cref="CombatState"/> and (optionally)
|
|
/// a <see cref="LocalPlayerState"/>. The GUID starts at 0; call
|
|
/// <see cref="SetLocalPlayerGuid"/> once the live session assigns it.
|
|
/// When <paramref name="localPlayer"/> is <c>null</c> (back-compat),
|
|
/// stamina + mana stay <c>null</c> and <c>VitalsPanel</c> skips those bars.
|
|
/// </summary>
|
|
public VitalsVM(CombatState combat, LocalPlayerState? localPlayer = null)
|
|
{
|
|
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
|
_local = localPlayer;
|
|
_localPlayerGuid = 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Push the authoritative local-player GUID from <c>WorldSession</c>.
|
|
/// One-way setter — only <c>GameWindow</c> should call it, exactly once
|
|
/// per live session.
|
|
/// </summary>
|
|
public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid;
|
|
|
|
/// <summary>
|
|
/// Current health percent (0..1) for the local player. Returns 1.0
|
|
/// before login or if the server has never sent an UpdateHealth for
|
|
/// this GUID.
|
|
/// </summary>
|
|
public float HealthPercent => _combat.GetHealthPercent(_localPlayerGuid);
|
|
|
|
/// <summary>
|
|
/// Stamina percent (0..1), or <c>null</c> when no
|
|
/// <see cref="LocalPlayerState"/> is wired or it hasn't received a
|
|
/// <c>PlayerDescription</c> with both current and max yet. Reads
|
|
/// through to the cache every access — no VM-side caching.
|
|
/// </summary>
|
|
public float? StaminaPercent => _local?.StaminaPercent;
|
|
|
|
/// <summary>
|
|
/// Mana percent (0..1), or <c>null</c> under the same conditions as
|
|
/// <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);
|
|
}
|