acdream/src/AcDream.App/UI/Layout/IndicatorBarController.cs
Erik 16c21e299c fix(ui): match retail vitae and character info
Restore Vitae's omitted penalty paragraph, replace the invented character summary with gmCharacterInfoUI's ordered report and property meanings, preserve authored translucent body surfaces, and initialize the end-session button in its visible Normal DAT state.

Release build and all 5,830 tests pass with five intentional skips. Connected visual gate pending.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-17 12:06:14 +02:00

281 lines
11 KiB
C#

using System;
using System.Linq;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retained port of retail <c>gmIndicatorsUI</c> LayoutDesc
/// <c>0x21000071</c> and its custom indicator buttons. The DAT owns geometry,
/// sprites, and state names; this controller owns only live state selection and
/// authored input actions.
/// </summary>
public sealed class IndicatorBarController : IRetainedPanelController
{
public const uint LayoutId = 0x21000071u;
public const uint BurdenClassId = 0x10000001u;
public const uint EffectsClassId = 0x10000002u;
public const uint LinkClassId = 0x10000003u;
public const uint MiniGameClassId = 0x10000004u;
public const uint VitaeClassId = 0x10000006u;
public const uint MiniGameButtonId = 0x100000F3u;
public const uint VitaeButtonId = 0x100000F4u;
public const uint HelpfulButtonId = 0x100000F5u;
public const uint HarmfulButtonId = 0x100000F6u;
public const uint BurdenButtonId = 0x100000F7u;
public const uint LinkButtonId = 0x100000F8u;
public const uint EndCharacterSessionButtonId = 0x100000FAu;
public const uint UnencumberedState = 14u;
public const uint EncumberedState = 15u;
public const uint HeavilyEncumberedState = 16u;
public const uint ConnectionGoodState = 17u;
public const uint ConnectionUncertainState = 18u;
public const uint ConnectionBadState = 19u;
public const uint ConnectionDisconnectedState = 20u;
private const uint EncumbranceValProperty = 5u;
private const uint EncumbranceAugProperty = 0xE6u;
private const double LinkUpdateSeconds = 4.0;
private const double LinkFlashSeconds = 0.75;
private readonly IndicatorBarBindings _bindings;
private readonly UiButton _link;
private readonly UiButton _helpful;
private readonly UiButton _harmful;
private readonly UiButton _vitae;
private readonly UiButton _burden;
private readonly UiButton _miniGame;
private readonly UiButton _endCharacterSession;
private LinkSemanticState _linkState = LinkSemanticState.Good;
private double _lastLinkUpdate;
private double _lastLinkFlash;
private bool _disposed;
private enum LinkSemanticState
{
Good = 1,
Uncertain = 2,
Bad = 3,
Disconnected = 4,
}
private IndicatorBarController(
IndicatorBarBindings bindings,
UiButton link,
UiButton helpful,
UiButton harmful,
UiButton vitae,
UiButton burden,
UiButton miniGame,
UiButton endCharacterSession)
{
_bindings = bindings;
_link = link;
_helpful = helpful;
_harmful = harmful;
_vitae = vitae;
_burden = burden;
_miniGame = miniGame;
_endCharacterSession = endCharacterSession;
_helpful.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.PositiveEffects);
_harmful.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.NegativeEffects);
_link.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.LinkStatus);
_vitae.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.Vitae);
_burden.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.CharacterInformation);
_miniGame.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.MiniGame);
_endCharacterSession.OnClick = bindings.RequestEndCharacterSession;
bindings.Spellbook.EnchantmentsChanged += UpdateEnchantments;
bindings.Objects.ObjectAdded += OnObjectChanged;
bindings.Objects.ObjectUpdated += OnObjectChanged;
bindings.Objects.ObjectRemoved += OnObjectChanged;
bindings.Objects.ObjectMoved += OnObjectMoved;
bindings.Objects.ContainerContentsReplaced += OnContainerContentsReplaced;
bindings.Objects.Cleared += OnObjectsCleared;
// gmUIElement_LinkStatusIndicator::PostInit @ 0x004E66D0 starts
// Good and does not query until the four-second time notice cadence.
double now = bindings.CurrentTime();
_lastLinkUpdate = now;
_lastLinkFlash = now;
_link.TrySetRetailState(ConnectionGoodState);
_miniGame.TrySetRetailState(UiButtonStateMachine.Ghosted);
// Ordinary button 0x100000FA has no hover state: retail begins in its
// authored Normal state (0x06007BB1) before any pointer notice.
_endCharacterSession.TrySetRetailState(UiButtonStateMachine.Normal);
UpdateEnchantments();
UpdateBurden();
}
public static IndicatorBarController? Bind(
ImportedLayout layout,
IndicatorBarBindings bindings)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(bindings);
if (layout.FindElement(LinkButtonId) is not UiButton link
|| layout.FindElement(HelpfulButtonId) is not UiButton helpful
|| layout.FindElement(HarmfulButtonId) is not UiButton harmful
|| layout.FindElement(VitaeButtonId) is not UiButton vitae
|| layout.FindElement(BurdenButtonId) is not UiButton burden
|| layout.FindElement(MiniGameButtonId) is not UiButton miniGame
|| layout.FindElement(EndCharacterSessionButtonId) is not UiButton endSession)
return null;
return new IndicatorBarController(
bindings, link, helpful, harmful, vitae, burden, miniGame, endSession);
}
/// <summary>
/// Port of <c>gmUIElement_LinkStatusIndicator::UseTime @ 0x004E67C0</c>.
/// The absolute monotonic clock is injected so the retail 4.0/0.75-second
/// boundaries are deterministic in tests.
/// </summary>
public void Tick()
{
double now = _bindings.CurrentTime();
if (now - _lastLinkUpdate >= LinkUpdateSeconds)
{
UpdateLinkState(now, _bindings.LinkStatus());
_lastLinkUpdate = now;
}
if (_linkState == LinkSemanticState.Bad
&& now - _lastLinkFlash >= LinkFlashSeconds)
{
_link.TrySetRetailState(
_link.ActiveRetailStateId == ConnectionUncertainState
? ConnectionBadState
: ConnectionUncertainState);
_lastLinkFlash = now;
}
}
/// <summary>Retail BeginGame/EndGame notice surface for the future mini-game owner.</summary>
public void SetMiniGameActive(bool active)
=> _miniGame.TrySetRetailState(
active ? UiButtonStateMachine.Normal : UiButtonStateMachine.Ghosted);
private void UpdateLinkState(double now, LinkStatusSnapshot snapshot)
{
// gmUIElement_LinkStatusIndicator::UpdateLinkState @ 0x004E6730.
LinkSemanticState state = !snapshot.Connected || snapshot.SecondsSinceLastPacket >= 40d
? LinkSemanticState.Disconnected
: snapshot.SecondsSinceLastPacket >= 20d
? LinkSemanticState.Bad
: snapshot.SecondsSinceLastPacket >= 5d
? LinkSemanticState.Uncertain
: LinkSemanticState.Good;
SetLinkState(state, now);
}
private void SetLinkState(LinkSemanticState state, double now)
{
// gmUIElement_LinkStatusIndicator::SetLinkState @ 0x004E65E0 does
// nothing for an unchanged semantic state (including an active flash).
if (_linkState == state) return;
_linkState = state;
uint visual = state switch
{
LinkSemanticState.Good => ConnectionGoodState,
LinkSemanticState.Uncertain => ConnectionUncertainState,
LinkSemanticState.Bad => ConnectionBadState,
LinkSemanticState.Disconnected => ConnectionDisconnectedState,
_ => throw new InvalidOperationException($"Unknown link state {state}."),
};
_link.TrySetRetailState(visual);
if (state == LinkSemanticState.Bad)
_lastLinkFlash = now;
}
private void UpdateEnchantments()
{
_helpful.TrySetRetailState(HasMatchingEffect(beneficial: true)
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted);
_harmful.TrySetRetailState(HasMatchingEffect(beneficial: false)
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted);
// gmUIElement_VitaeIndicator::Update @ 0x004E5FE0 enables the
// indicator only when the singleton Vitae modifier is below 1.0.
bool hasVitae = _bindings.Spellbook.ActiveEnchantmentSnapshot.Any(record =>
record.Bucket == 4u
&& record.StatModValue is float modifier
&& modifier < 1f);
_vitae.TrySetRetailState(hasVitae
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted);
}
private bool HasMatchingEffect(bool beneficial)
// Retail SpellEffectMatchesUIType @ 0x004B76C0 supplies this split.
=> _bindings.Spellbook.ActiveEnchantmentSnapshot.Any(record =>
// CountSpellsInList @ 0x00593CD0 scans raw mult/add nodes; it
// does not use the effects-list spell-category duel projection.
record.Bucket is 1u or 2u
&& _bindings.Spellbook.TryGetMetadata(
record.SpellId, out SpellMetadata metadata)
&& metadata.IsBeneficial == beneficial);
private void OnObjectChanged(ClientObject _) => UpdateBurden();
private void OnObjectMoved(ClientObjectMove _) => UpdateBurden();
private void OnContainerContentsReplaced(uint _) => UpdateBurden();
private void OnObjectsCleared() => UpdateBurden();
private void UpdateBurden()
{
uint player = _bindings.PlayerGuid();
ClientObject? playerObject = _bindings.Objects.Get(player);
int strength = _bindings.Strength() ?? 10;
int augmentation = playerObject?.Properties.GetInt(EncumbranceAugProperty) ?? 0;
int capacity = BurdenMath.EncumbranceCapacity(strength, augmentation);
int burden = playerObject?.Properties.Ints.TryGetValue(
EncumbranceValProperty, out int wireBurden) == true
? wireBurden
: _bindings.Objects.SumCarriedBurden(player);
float load = BurdenMath.LoadRatio(capacity, burden);
// gmUIElement_BurdenIndicator::Update @ 0x004E6CE0.
_burden.TrySetRetailState(load < 1f
? UnencumberedState
: load < 2f ? EncumberedState : HeavilyEncumberedState);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_bindings.Spellbook.EnchantmentsChanged -= UpdateEnchantments;
_bindings.Objects.ObjectAdded -= OnObjectChanged;
_bindings.Objects.ObjectUpdated -= OnObjectChanged;
_bindings.Objects.ObjectRemoved -= OnObjectChanged;
_bindings.Objects.ObjectMoved -= OnObjectMoved;
_bindings.Objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
_bindings.Objects.Cleared -= OnObjectsCleared;
_link.OnClick = null;
_helpful.OnClick = null;
_harmful.OnClick = null;
_vitae.OnClick = null;
_burden.OnClick = null;
_miniGame.OnClick = null;
_endCharacterSession.OnClick = null;
}
}
public sealed record IndicatorBarBindings(
Spellbook Spellbook,
ClientObjectTable Objects,
Func<uint> PlayerGuid,
Func<int?> Strength,
Func<LinkStatusSnapshot> LinkStatus,
Func<double> CurrentTime,
Action<uint> TogglePanel,
Action RequestEndCharacterSession);