fix(ui): restore retail vitals and window interactions
All checks were successful
CI / linux-portable (push) Successful in 3m16s
CI / windows-gate (push) Successful in 5m41s
CI / release (push) Successful in 2m5s

This commit is contained in:
Erik 2026-08-20 13:26:35 +02:00
parent 4d84456c21
commit 1bd2b30291
36 changed files with 1462 additions and 86 deletions

View file

@ -1,6 +1,7 @@
using System.Collections.Generic;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Core.Properties;
using AcDream.Core.Spells;
namespace AcDream.Core.Player;
@ -305,7 +306,7 @@ public sealed class LocalPlayerState
/// </summary>
public uint? GetMaxApprox(VitalKind kind)
{
uint? baseValue = GetBaseMaxApprox(kind);
uint? baseValue = GetMaxBeforeSecondaryEnchantments(kind);
if (baseValue is not uint unbuffed) return null;
// Preserve the "no data" sentinel — when the unbuffed max is 0
// we lack the inputs to compute anything reasonable. The retail
@ -315,12 +316,13 @@ public sealed class LocalPlayerState
var mod = _spellbook?.GetVitalMod(StatKeyForKind(kind))
?? EnchantmentMath.VitalMod.Identity;
// Apply: (unbuffed * mult) + additive, then clamp to retail's
// min-vital floor (5 if base >= 5 else 1) — matches
// CreatureVital::GetMaxValue at PDB 0x0058F2DD.
// min-vital floor (5 if base >= 5 else 1). The final cast is
// retail CEnchantmentRegistry::EnchantAttribute2nd's _ftol2
// conversion at 0x00594787: truncate toward zero, do not round.
float buffed = (unbuffed * mod.Multiplier) + mod.Additive;
uint minFloor = unbuffed >= 5 ? 5u : 1u;
if (buffed < minFloor) buffed = minFloor;
return (uint)System.Math.Round(buffed);
return (uint)buffed;
}
/// <summary>
@ -333,7 +335,27 @@ public sealed class LocalPlayerState
if (vital is null) return null;
return vital.Value.Ranks
+ vital.Value.Start
+ AttributeContribution(kind);
+ AttributeContribution(kind, effective: false)
+ GearHealthBonus(kind);
}
/// <summary>
/// Retail <c>CACQualities::InqAttribute2nd @ 0x00592020</c> computes a
/// maximum vital's formula contribution through <c>InqAttribute</c> with
/// enchantments enabled, adds <c>GearMaxHealth</c> (property 379) for
/// health, and only then calls <c>EnchantAttribute2nd</c>. Keeping this
/// stage separate prevents primary-attribute records from leaking into
/// the secondary-attribute modifier while still allowing buffed
/// Endurance/Self to feed the vital formula.
/// </summary>
private uint? GetMaxBeforeSecondaryEnchantments(VitalKind kind)
{
VitalSnapshot? vital = Get(kind);
if (vital is null) return null;
return vital.Value.Ranks
+ vital.Value.Start
+ AttributeContribution(kind, effective: true)
+ GearHealthBonus(kind);
}
/// <summary>
@ -622,21 +644,37 @@ public sealed class LocalPlayerState
// coefficients, but the values themselves don't change between dat
// versions in retail.
private uint AttributeContribution(VitalKind kind)
private uint AttributeContribution(VitalKind kind, bool effective)
{
uint endurance = GetAttrCurrent(AttributeKind.Endurance, effective);
uint self = GetAttrCurrent(AttributeKind.Self, effective);
switch (kind)
{
case VitalKind.Health:
return GetAttrCurrent(AttributeKind.Endurance) / 2u;
// SecondaryAttributeTable's SkillFormula is
// floor((Endurance / 2) + 0.5), not integer truncation.
return (endurance / 2u) + (endurance & 1u);
case VitalKind.Stamina:
return GetAttrCurrent(AttributeKind.Endurance);
return endurance;
case VitalKind.Mana:
return GetAttrCurrent(AttributeKind.Self);
return self;
default:
return 0u;
}
}
private uint GetAttrCurrent(AttributeKind kind) =>
_attrs.TryGetValue(kind, out var a) ? a.Current : 0u;
private uint GetAttrCurrent(AttributeKind kind, bool effective)
{
if (!_attrs.TryGetValue(kind, out var attribute)) return 0u;
if (!effective || _spellbook is null) return attribute.Current;
int value = GetEffectiveAttribute(kind) ?? 0;
return value > 0 ? (uint)value : 0u;
}
private uint GearHealthBonus(VitalKind kind)
{
if (kind != VitalKind.Health) return 0u;
int value = _properties.GetInt((uint)PropertyInt.GearMaxHealth);
return value > 0 ? (uint)value : 0u;
}
}