feat(ui): port retail gameplay indicator bar

Promote all seven LayoutDesc 0x21000071 controls to retained buttons, drive link quality, effects, Vitae, and burden from live state, and route Character Information plus end-session confirmation through the shared UI owners. Keep network timing in WorldSession and pin retail thresholds, flash cadence, authored states, and action routing with focused conformance tests.

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

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 10:02:44 +02:00
parent 06016014bc
commit 52c529be86
15 changed files with 857 additions and 204 deletions

View file

@ -77,8 +77,13 @@ public static class DatWidgetFactory
{
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
1 => BuildButton(info, resolve, elementFont, fontResolve, stringResolve), // UIElement_Button
EffectsIndicatorController.RetailClassId => BuildButton(
info, resolve, elementFont, fontResolve, stringResolve), // gmUIElement_EffectsIndicator
IndicatorBarController.BurdenClassId
or IndicatorBarController.EffectsClassId
or IndicatorBarController.LinkClassId
or IndicatorBarController.MiniGameClassId
or IndicatorBarController.VitaeClassId => BuildButton(
info, resolve, elementFont, fontResolve, stringResolve),
// gmUIElement_*Indicator custom button classes
6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf

View file

@ -1,86 +0,0 @@
using System;
using System.Linq;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Port of retail <c>gmUIElement_EffectsIndicator::PostInit @ 0x004E6930</c>
/// and <c>Update @ 0x004E6A50</c>: the authored Helpful and Harmful buttons
/// reflect whether matching enchantments exist and send panel visibility notices
/// for gmPanelUI slots 4 and 5.
/// </summary>
public sealed class EffectsIndicatorController : IRetainedPanelController
{
public const uint LayoutId = 0x21000071u;
public const uint RetailClassId = 0x10000002u;
public const uint HelpfulButtonId = 0x100000F5u;
public const uint HarmfulButtonId = 0x100000F6u;
private readonly Spellbook _spellbook;
private readonly UiButton _helpful;
private readonly UiButton _harmful;
private readonly Action<uint> _togglePanel;
private bool _disposed;
private EffectsIndicatorController(
Spellbook spellbook,
UiButton helpful,
UiButton harmful,
Action<uint> togglePanel)
{
_spellbook = spellbook;
_helpful = helpful;
_harmful = harmful;
_togglePanel = togglePanel;
_helpful.OnClick = () => _togglePanel(RetailPanelCatalog.PositiveEffects);
_harmful.OnClick = () => _togglePanel(RetailPanelCatalog.NegativeEffects);
_spellbook.EnchantmentsChanged += Update;
Update();
}
public static EffectsIndicatorController? Bind(
ImportedLayout layout,
Spellbook spellbook,
Action<uint> togglePanel)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(togglePanel);
if (layout.FindElement(HelpfulButtonId) is not UiButton helpful
|| layout.FindElement(HarmfulButtonId) is not UiButton harmful)
return null;
return new EffectsIndicatorController(spellbook, helpful, harmful, togglePanel);
}
private void Update()
{
bool helpful = HasMatchingEffect(beneficial: true);
bool harmful = HasMatchingEffect(beneficial: false);
_helpful.TrySetRetailState(helpful
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted);
_harmful.TrySetRetailState(harmful
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted);
}
private bool HasMatchingEffect(bool beneficial)
// Retail SpellEffectMatchesUIType @ 0x004B76C0 supplies this helpful/harmful split.
=> _spellbook.ActiveEnchantmentSnapshot.Any(record =>
// Retail CountSpellsInList @ 0x00593CD0 counts every raw mult/add
// node. Unlike gmEffectsUI's list, indicator totals are not dueled
// by spell category first.
record.Bucket is 1u or 2u
&& _spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata)
&& metadata.IsBeneficial == beneficial);
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_spellbook.EnchantmentsChanged -= Update;
_helpful.OnClick = null;
_harmful.OnClick = null;
}
}

View file

@ -0,0 +1,275 @@
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);
_burden.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.Character);
_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);
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);