diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index f7f4e60d..dcb72441 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -44,6 +44,39 @@ Copy this block when adding a new issue:
---
+## #224 — Gameplay indicator bar only implemented effect icons
+
+**Status:** IN-PROGRESS
+**Severity:** MEDIUM
+**Filed:** 2026-07-17
+**Component:** retained UI / gameplay indicators / session telemetry
+
+**Description:** Retail LayoutDesc `0x21000071` is one seven-control strip for
+link quality, helpful effects, harmful effects, vitae, burden/character
+information, mini-game state, and end-character-session. acdream only promoted
+the two effects controls to behavioral buttons; the other custom classes fell
+back to inert generic DAT elements.
+
+**Root cause / status:** `DatWidgetFactory` recognized only
+`gmUIElement_EffectsIndicator`. The retained controller consequently had no
+live bindings for session packet age, Vitae, or the player load ratio and did
+not dispatch Character Information or the shared logout confirmation path.
+
+**Files:** `src/AcDream.App/UI/Layout/IndicatorBarController.cs`;
+`src/AcDream.App/UI/Layout/DatWidgetFactory.cs`;
+`src/AcDream.Core.Net/WorldSession.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs`.
+
+**Research:**
+`docs/research/2026-07-17-retail-indicator-bar-pseudocode.md`.
+
+**Acceptance:** All seven authored controls render their retail sprites. Link
+quality follows the 5/20/40-second thresholds and bad-link flash cadence;
+helpful, harmful, Vitae, and burden react to live state; the burden icon opens
+Character Information; effects open their matching panels; crossed swords use
+the shared confirmation dialog and close gracefully only after acceptance.
+
+---
+
## #223 — Primary panel resize stopped before the screen edge
**Status:** DONE — 2026-07-17 (connected visual gate accepted)
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index c1517b2c..1fbb9b07 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -1,6 +1,6 @@
# acdream — strategic roadmap
-**Status:** Living document. Updated 2026-07-16. **M3 active; automated implementation complete, visual gates pending.** M2's connected demo loop is complete. M3 now has the retail cast-intent/component/target lifecycle, exact live enchantment wire state, scoped Magic input, retained spell bar, spellbook, component book, positive/negative effects panels, authored effect-indicator buttons, and shared `gmPanelUI` switching over the Step 9 projectile/PhysicsScript/particle/portal foundation. The remaining M3 work is the connected single-client magic-UI/casting gate and final two-client portal-out/materialization observer gate.
+**Status:** Living document. Updated 2026-07-17. **M3 active; automated implementation complete, visual gates pending.** M2's connected demo loop is complete. M3 now has the retail cast-intent/component/target lifecycle, exact live enchantment wire state, scoped Magic input, retained spell bar, spellbook, component book, positive/negative effects panels, the complete authored seven-control gameplay indicator strip, and shared `gmPanelUI` switching over the Step 9 projectile/PhysicsScript/particle/portal foundation. The remaining M3 work is the connected single-client magic-UI/casting gate and final two-client portal-out/materialization observer gate.
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
---
@@ -20,8 +20,9 @@ active enchantment, then recall through the DAT-driven portal presentation.
3,956-row CSV is no longer a production dependency.
2. **Done (automated):** authored Magic combat page, eight favorite tabs,
equipped caster endowment, scoped retail keys, spellbook/component book,
- positive/negative active-enchantment panels, Helpful/Harmful indicator
- buttons, and the retail one-active-main-panel lifecycle. Connected-gate
+ positive/negative active-enchantment panels, the seven-control link/effect/
+ Vitae/burden/mini-game/session indicator strip, and the retail
+ one-active-main-panel lifecycle. Connected-gate
correction now also pins type-12 spellbook tabs, retail zero text margins,
and `PassToChildren` Closed/Open chrome through real-DAT fixtures.
3. **Pending user visual gate:** connected projectile/self-buff casts plus the
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index 114d1cb7..e949cc0c 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -706,7 +706,11 @@ targeted/untargeted messages. Live enchantment add/remove/dispel/purge state is
fully parsed with layer identity and client-clock normalization. The authored
Magic combat page, eight favorite tabs, caster endowment, spellbook/component
book, scoped keys, positive/negative effects windows, and the authored
-Helpful/Harmful indicators are mounted in the retained runtime. Main-panel
+seven-control gameplay indicator strip are mounted in the retained runtime.
+The strip now uses retail DAT states for link quality, Helpful/Harmful effects,
+Vitae, burden, mini-game availability, and end-character-session; live link
+age, player load, and enchantment registries drive their retail thresholds and
+the burden/effect/logout actions use the shared panel/dialog owners. Main-panel
visibility now flows through retail's one-active `gmPanelUI` owner. M3 remains
active only for the connected single-client magic
UI/casting visual gate and the final two-client portal observer gate.
diff --git a/docs/research/2026-07-17-retail-indicator-bar-pseudocode.md b/docs/research/2026-07-17-retail-indicator-bar-pseudocode.md
new file mode 100644
index 00000000..b4dc6dc1
--- /dev/null
+++ b/docs/research/2026-07-17-retail-indicator-bar-pseudocode.md
@@ -0,0 +1,166 @@
+# Retail gameplay indicator bar pseudocode (2026-07-17)
+
+## Scope and oracle
+
+This note covers retail LayoutDesc `0x21000071` (`gmIndicatorsUI`) and its
+seven 20x20 children. The behavior oracle is the named Sept-2013 retail
+decomp. The retained port continues to use the authored LayoutDesc states and
+sprites; code only chooses a state and dispatches the authored input action.
+
+Named retail functions:
+
+- `gmIndicatorsUI::ListenToElementMessage @ 0x004BFA90`
+- `gmUIElement_VitaeIndicator::PostInit @ 0x004E5EA0`
+- `gmUIElement_VitaeIndicator::Update @ 0x004E5FE0`
+- `gmUIElement_MiniGameIndicator::PostInit @ 0x004E6390`
+- `gmUIElement_MiniGameIndicator::RecvNotice_BeginGame @ 0x004E6450`
+- `gmUIElement_MiniGameIndicator::RecvNotice_EndGame @ 0x004E6470`
+- `gmUIElement_LinkStatusIndicator::SetLinkState @ 0x004E65E0`
+- `gmUIElement_LinkStatusIndicator::PostInit @ 0x004E66D0`
+- `gmUIElement_LinkStatusIndicator::UpdateLinkState @ 0x004E6730`
+- `gmUIElement_LinkStatusIndicator::UseTime @ 0x004E67C0`
+- `gmUIElement_EffectsIndicator::PostInit @ 0x004E6930`
+- `gmUIElement_EffectsIndicator::Update @ 0x004E6A50`
+- `gmUIElement_BurdenIndicator::PostInit @ 0x004E6B80`
+- `gmUIElement_BurdenIndicator::Update @ 0x004E6CE0`
+- `LinkStatusHolder::GetConnectionStatus @ 0x00411380`
+- `LinkStatusHolder::OnHeartbeat @ 0x004113D0`
+- `gmEffectsUI::SpellEffectMatchesUIType @ 0x004B76C0`
+- `gmGamePlayUI::RecvNotice_EndCharacterSession @ 0x004EBEA0`
+
+The installed retail LayoutDesc establishes the fixed child order and art:
+
+| X | Element | Retail class | Meaning / input action |
+|---:|---:|---:|---|
+| 5 | `0x100000F8` | `0x10000003` | link status / `LinkStatusPanel` |
+| 25 | `0x100000F5` | `0x10000002` | beneficial enchantments / `HelpfulSpellsPanel` |
+| 45 | `0x100000F6` | `0x10000002` | harmful enchantments / `HarmfulSpellsPanel` |
+| 65 | `0x100000F4` | `0x10000006` | vitae / `VitaePanel` |
+| 85 | `0x100000F7` | `0x10000001` | burden / `CharacterInformationPanel` |
+| 105 | `0x100000F3` | `0x10000004` | mini-game |
+| 125 | `0x100000FA` | ordinary button | end character session |
+
+## Pseudocode
+
+### Link indicator
+
+```text
+PostInit:
+ base.PostInit()
+ listen to global time notices
+ semanticLinkState = Good
+ SetState(Connection_good = 17)
+
+On each global-time notice:
+ if now - lastUpdate >= 4.0 seconds:
+ (secondsSinceServerPacket, connected) = GetConnectionStatus()
+ if !connected or secondsSinceServerPacket >= 40: Disconnected
+ else if secondsSinceServerPacket >= 20: Bad
+ else if secondsSinceServerPacket >= 5: Uncertain
+ else: Good
+ SetLinkState(result)
+ lastUpdate = now
+
+ if semanticLinkState == Bad and now - lastFlash >= 0.75 seconds:
+ toggle visual state between Connection_uncertain (18)
+ and Connection_bad (19)
+ lastFlash = now
+
+SetLinkState(newState):
+ if newState == semanticLinkState: return
+ semanticLinkState = newState
+ set authored state Good=17, Uncertain=18, Bad=19, Disconnected=20
+ when entering Bad: lastFlash = now
+
+OnHeartbeat:
+ lastHeardFromCurrentServer = now
+ packetLoss = linkAverages.averagePacketLoss
+```
+
+acdream records the last successfully decoded server datagram in
+`WorldSession`; the retained controller reads an immutable link snapshot. This
+keeps socket/session facts out of the UI while preserving retail's thresholds
+and cadence.
+
+### Helpful and harmful effects
+
+```text
+On PlayerDesc or enchantment change:
+ helpfulCount = 0
+ harmfulCount = 0
+ for each raw multiplicative/additive enchantment node:
+ spell = lookup spell metadata
+ if spell flags contain Beneficial: helpfulCount++
+ else: harmfulCount++
+ Helpful button = Normal when helpfulCount > 0, else Ghosted
+ Harmful button = Normal when harmfulCount > 0, else Ghosted
+```
+
+The indicator counts raw bucket-1/bucket-2 nodes. It does not use the effects
+panel's later spell-category duel projection.
+
+### Vitae
+
+```text
+On PlayerDesc or VitaeChanged:
+ if enchantment registry has Vitae and vitae.modifier < 1.0:
+ SetState(Normal = 1)
+ else:
+ SetState(Ghosted = 13)
+```
+
+### Burden / character information
+
+```text
+On PlayerDesc or LoadChanged:
+ if InqLoad fails: load = absent
+ if load is absent or load < 1.0: SetState(Unencumbered = 14)
+ else if load < 2.0: SetState(Encumbered = 15)
+ else: SetState(Heavily_encumbered = 16)
+
+On click:
+ dispatch CharacterInformationPanel
+```
+
+`InqLoad` is the existing retail burden ratio:
+`EncumbranceVal / EncumbranceCapacity(Strength, augmentation)`.
+
+### Mini-game
+
+```text
+On BeginGame notice: SetState(Normal = 1)
+On EndGame notice: SetState(Ghosted = 13)
+```
+
+The authored ghosted state is rendered now. The notices remain owned by the
+still-unported mini-game subsystem (existing divergence AP-110), so this task
+does not manufacture a game-active state from unrelated packets.
+
+### End character session
+
+```text
+When child 0x100000FA sends Click:
+ SendNotice_EndCharacterSession(1)
+
+RecvNotice_EndCharacterSession(1):
+ build the shared logout confirmation dialog using
+ ID_Client_EndCharacterSessionConfirm
+ on acceptance, end the character session
+```
+
+The acdream port reuses `RetailDialogFactory`; acceptance closes the current
+client, whose existing `WorldSession.Dispose` sends retail's graceful
+CharacterLogOff and transport Disconnect packets.
+
+## Cross-checks and retained boundaries
+
+- The already-ported `BurdenMath` matches retail
+ `EncumbranceSystem::EncumbranceCapacity @ 0x004FCC00` and
+ `EncumbranceSystem::Load @ 0x004FCC40`.
+- The existing enchantment parser preserves the separate Mult, Add, Vitae, and
+ Cooldown registry buckets used by the retail indicator queries.
+- ACE/reference source is not present in this worktree. No server behavior is
+ inferred for the strip: state is derived only from packets already parsed by
+ the client, and the named retail client remains the behavior oracle.
+- Full Link Status, Vitae, and mini-game panels remain tracked by AP-110. This
+ port does not replace them with an ad-hoc panel.
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index d7692454..3589acc9 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -2310,6 +2310,17 @@ public sealed class GameWindow : IDisposable
() => 1.0,
() => _settingsVm?.DisplayDraft.ShowFps
?? _persistedDisplay.ShowFps),
+ Indicators: new AcDream.App.UI.IndicatorRuntimeBindings(
+ SpellBook,
+ Objects,
+ () => _playerServerGuid,
+ () => LocalPlayer.GetAttribute(
+ AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
+ is { } strength ? (int?)strength.Current : null,
+ () => _liveSession?.LinkStatus
+ ?? AcDream.Core.Net.LinkStatusSnapshot.Disconnected,
+ ClientTimerNow,
+ () => _window?.Close()),
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
Objects,
() => Shortcuts,
diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index 9e8d7e7f..5bfdc7f2 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -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
diff --git a/src/AcDream.App/UI/Layout/EffectsIndicatorController.cs b/src/AcDream.App/UI/Layout/EffectsIndicatorController.cs
deleted file mode 100644
index 04788967..00000000
--- a/src/AcDream.App/UI/Layout/EffectsIndicatorController.cs
+++ /dev/null
@@ -1,86 +0,0 @@
-using System;
-using System.Linq;
-using AcDream.Core.Spells;
-
-namespace AcDream.App.UI.Layout;
-
-///
-/// Port of retail gmUIElement_EffectsIndicator::PostInit @ 0x004E6930
-/// and Update @ 0x004E6A50: the authored Helpful and Harmful buttons
-/// reflect whether matching enchantments exist and send panel visibility notices
-/// for gmPanelUI slots 4 and 5.
-///
-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 _togglePanel;
- private bool _disposed;
-
- private EffectsIndicatorController(
- Spellbook spellbook,
- UiButton helpful,
- UiButton harmful,
- Action 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 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;
- }
-}
diff --git a/src/AcDream.App/UI/Layout/IndicatorBarController.cs b/src/AcDream.App/UI/Layout/IndicatorBarController.cs
new file mode 100644
index 00000000..ff5ab440
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/IndicatorBarController.cs
@@ -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;
+
+///
+/// Retained port of retail gmIndicatorsUI LayoutDesc
+/// 0x21000071 and its custom indicator buttons. The DAT owns geometry,
+/// sprites, and state names; this controller owns only live state selection and
+/// authored input actions.
+///
+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);
+ }
+
+ ///
+ /// Port of gmUIElement_LinkStatusIndicator::UseTime @ 0x004E67C0.
+ /// The absolute monotonic clock is injected so the retail 4.0/0.75-second
+ /// boundaries are deterministic in tests.
+ ///
+ 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;
+ }
+ }
+
+ /// Retail BeginGame/EndGame notice surface for the future mini-game owner.
+ 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 PlayerGuid,
+ Func Strength,
+ Func LinkStatus,
+ Func CurrentTime,
+ Action TogglePanel,
+ Action RequestEndCharacterSession);
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index cf3e3450..2ab6d167 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -8,6 +8,7 @@ using AcDream.App.UI.Layout;
using AcDream.App.UI.Testing;
using AcDream.Core.Combat;
using AcDream.Core.Items;
+using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
@@ -73,6 +74,15 @@ public sealed record FpsRuntimeBindings(
Func DegradeMultiplier,
Func IsVisible);
+public sealed record IndicatorRuntimeBindings(
+ Spellbook Spellbook,
+ ClientObjectTable Objects,
+ Func PlayerGuid,
+ Func Strength,
+ Func LinkStatus,
+ Func CurrentTime,
+ Action EndCharacterSession);
+
public sealed record ToolbarRuntimeBindings(
ClientObjectTable Objects,
Func> Shortcuts,
@@ -142,6 +152,7 @@ public sealed record RetailUiRuntimeBindings(
MagicRuntimeBindings Magic,
JumpPowerbarRuntimeBindings JumpPowerbar,
FpsRuntimeBindings Fps,
+ IndicatorRuntimeBindings Indicators,
ToolbarRuntimeBindings Toolbar,
CharacterRuntimeBindings Character,
InventoryRuntimeBindings Inventory,
@@ -234,7 +245,7 @@ public sealed class RetailUiRuntime : IDisposable
public SpellbookWindowController? SpellbookWindowController { get; private set; }
public EffectsUiController? PositiveEffectsController { get; private set; }
public EffectsUiController? NegativeEffectsController { get; private set; }
- public EffectsIndicatorController? EffectsIndicatorController { get; private set; }
+ public IndicatorBarController? IndicatorBarController { get; private set; }
public JumpPowerbarController? JumpPowerbarController { get; private set; }
public RetailFpsController? FpsController { get; private set; }
public SelectedObjectController? SelectedObjectController { get; private set; }
@@ -263,6 +274,7 @@ public sealed class RetailUiRuntime : IDisposable
SpellcastingUiController?.Tick();
PositiveEffectsController?.Tick();
NegativeEffectsController?.Tick();
+ IndicatorBarController?.Tick();
JumpPowerbarController?.Tick();
SelectedObjectController?.Tick(deltaSeconds);
DialogFactory?.Tick();
@@ -894,24 +906,32 @@ public sealed class RetailUiRuntime : IDisposable
private void MountIndicators()
{
- ImportedLayout? layout = Import(EffectsIndicatorController.LayoutId);
+ ImportedLayout? layout = Import(IndicatorBarController.LayoutId);
if (layout is null)
{
- Console.WriteLine("[M3] effects indicators: LayoutDesc 0x21000071 not found.");
+ Console.WriteLine("[UI] indicator bar: LayoutDesc 0x21000071 not found.");
return;
}
- EffectsIndicatorController? controller = Layout.EffectsIndicatorController.Bind(
+ IndicatorRuntimeBindings b = _bindings.Indicators;
+ IndicatorBarController? controller = Layout.IndicatorBarController.Bind(
layout,
- _bindings.Magic.Spellbook,
- panelId => _panelUi.TogglePanel(panelId));
+ new IndicatorBarBindings(
+ b.Spellbook,
+ b.Objects,
+ b.PlayerGuid,
+ b.Strength,
+ b.LinkStatus,
+ b.CurrentTime,
+ panelId => _panelUi.TogglePanel(panelId),
+ RequestEndCharacterSession));
if (controller is null)
{
- Console.WriteLine("[M3] effects indicators: authored Helpful/Harmful buttons missing.");
+ Console.WriteLine("[UI] indicator bar: one or more authored controls are missing.");
return;
}
- EffectsIndicatorController = controller;
+ IndicatorBarController = controller;
RetailWindowFrame.Mount(
Host.Root,
layout.Root,
@@ -930,7 +950,26 @@ public sealed class RetailUiRuntime : IDisposable
ContentClickThrough = false,
Controller = controller,
});
- Console.WriteLine("[M3] retail Helpful/Harmful effect indicators from LayoutDesc 0x21000071.");
+ Console.WriteLine("[UI] retail seven-control indicator bar from LayoutDesc 0x21000071.");
+ }
+
+ private void RequestEndCharacterSession()
+ {
+ const string fallback = "Are you sure you want to end this character session?";
+ string message;
+ lock (_bindings.Assets.DatLock)
+ {
+ message = new DatStringResolver(_bindings.Assets.Dats).Resolve(
+ 0x23000001u,
+ DatStringResolver.ComputeHash("ID_Client_EndCharacterSessionConfirm"))
+ ?? fallback;
+ }
+
+ ShowConfirmation(message, accepted =>
+ {
+ if (accepted)
+ _bindings.Indicators.EndCharacterSession();
+ });
}
private void MountJumpPowerbar()
diff --git a/src/AcDream.Core.Net/LinkStatusSnapshot.cs b/src/AcDream.Core.Net/LinkStatusSnapshot.cs
new file mode 100644
index 00000000..9322c5c5
--- /dev/null
+++ b/src/AcDream.Core.Net/LinkStatusSnapshot.cs
@@ -0,0 +1,13 @@
+namespace AcDream.Core.Net;
+
+///
+/// Immutable network-owned input for retail's link-status presentation.
+/// records the last successfully decoded server
+/// datagram; UI code applies retail's display thresholds and cadence.
+///
+public readonly record struct LinkStatusSnapshot(
+ bool Connected,
+ double SecondsSinceLastPacket)
+{
+ public static LinkStatusSnapshot Disconnected => new(false, 0d);
+}
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 14e1bc0d..e8fd9bae 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -519,6 +519,32 @@ public sealed class WorldSession : IDisposable
public State CurrentState { get; private set; } = State.Disconnected;
+ ///
+ /// Network-owned source for retail
+ /// LinkStatusHolder::GetConnectionStatus @ 0x00411380. The age is
+ /// measured from the last successfully decoded server datagram using the
+ /// monotonic Stopwatch clock; presentation thresholds remain in the UI.
+ ///
+ public LinkStatusSnapshot LinkStatus => BuildLinkStatus(
+ CurrentState,
+ Volatile.Read(ref _lastInboundPacketTicks),
+ Stopwatch.GetTimestamp(),
+ Stopwatch.Frequency);
+
+ internal static LinkStatusSnapshot BuildLinkStatus(
+ State state,
+ long lastInboundPacketTicks,
+ long nowTicks,
+ long frequency)
+ {
+ bool connected = state is not State.Disconnected and not State.Failed;
+ if (!connected || frequency <= 0)
+ return LinkStatusSnapshot.Disconnected;
+
+ long elapsed = Math.Max(0, nowTicks - lastInboundPacketTicks);
+ return new LinkStatusSnapshot(true, elapsed / (double)frequency);
+ }
+
/// Movement sequence counters for outbound MoveToState/AutonomousPosition.
public ushort InstanceSequence => _instanceSequence;
public ushort ServerControlSequence => _serverControlSequence;
@@ -547,6 +573,7 @@ public sealed class WorldSession : IDisposable
public CharacterList.Parsed? Characters { get; private set; }
private readonly NetClient _net;
+ private long _lastInboundPacketTicks = Stopwatch.GetTimestamp();
private readonly IPEndPoint _loginEndpoint;
private readonly IPEndPoint _connectEndpoint;
private readonly FragmentAssembler _assembler = new();
@@ -852,6 +879,11 @@ public sealed class WorldSession : IDisposable
var dec = PacketCodec.TryDecode(bytes, _inboundIsaac);
if (!dec.IsOk) return;
+ // Retail LinkStatusHolder::OnHeartbeat @ 0x004113D0 updates its
+ // last-heard clock only for valid server traffic. Record at decode
+ // acceptance, before any heavy render-thread message handling.
+ Volatile.Write(ref _lastInboundPacketTicks, Stopwatch.GetTimestamp());
+
// Phase 4.9: send an ACK_SEQUENCE control packet for every received
// server packet with sequence > 0 and no ACK flag of its own. This
// is the proper holtburger pattern (every received packet gets an
diff --git a/tests/AcDream.App.Tests/UI/Layout/EffectsIndicatorControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/EffectsIndicatorControllerTests.cs
deleted file mode 100644
index 59f0dade..00000000
--- a/tests/AcDream.App.Tests/UI/Layout/EffectsIndicatorControllerTests.cs
+++ /dev/null
@@ -1,102 +0,0 @@
-using AcDream.App.UI;
-using AcDream.App.UI.Layout;
-using AcDream.Core.Spells;
-
-namespace AcDream.App.Tests.UI.Layout;
-
-public sealed class EffectsIndicatorControllerTests
-{
- private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
-
- [Fact]
- public void AuthoredFixture_BuildsRetailIndicatorButtons()
- {
- ElementInfo info = FixtureLoader.LoadIndicatorsInfos();
- Assert.Equal(0x10000610u, info.Id);
- Assert.Equal(150f, info.Width);
- Assert.Equal(30f, info.Height);
-
- ImportedLayout layout = LayoutImporter.Build(info, NoTex, datFont: null);
- Assert.IsType(layout.FindElement(EffectsIndicatorController.HelpfulButtonId));
- Assert.IsType(layout.FindElement(EffectsIndicatorController.HarmfulButtonId));
- }
-
- [Fact]
- public void Enchantments_GhostAndEnableMatchingIndicator_ThenClickCorrectPanel()
- {
- var table = SpellTable.LoadFromReader(new StringReader(
- "Spell ID,Name,Flags [Hex]\n42,Boon,0x4\n43,Bane,0x0\n"));
- var spellbook = new Spellbook(table);
- ImportedLayout layout = BuildSyntheticLayout();
- var toggled = new List();
- using EffectsIndicatorController controller = EffectsIndicatorController.Bind(
- layout, spellbook, toggled.Add)!;
- UiButton helpful = Assert.IsType(
- layout.FindElement(EffectsIndicatorController.HelpfulButtonId));
- UiButton harmful = Assert.IsType(
- layout.FindElement(EffectsIndicatorController.HarmfulButtonId));
-
- Assert.False(helpful.Enabled);
- Assert.False(harmful.Enabled);
-
- spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
- 42u, 10u, 60f, 1u, Bucket: 4u, SpellCategory: 42u));
- spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
- 43u, 11u, 60f, 2u, Bucket: 8u, SpellCategory: 43u));
- Assert.False(helpful.Enabled);
- Assert.False(harmful.Enabled);
-
- spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
- 42u, 1u, 60f, 1u, Bucket: 1u, SpellCategory: 99u, PowerLevel: 1u));
- Assert.True(helpful.Enabled);
- Assert.False(harmful.Enabled);
- helpful.OnEvent(new UiEvent(0, helpful, UiEventType.Click));
- Assert.Equal([RetailPanelCatalog.PositiveEffects], toggled);
-
- spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
- 43u, 1u, 60f, 2u, Bucket: 2u, SpellCategory: 99u, PowerLevel: 7u));
- // The harmful spell wins the shared category in the effects list, but
- // retail's raw registry counters keep both indicator types enabled.
- Assert.True(helpful.Enabled);
- Assert.True(harmful.Enabled);
- harmful.OnEvent(new UiEvent(0, harmful, UiEventType.Click));
- Assert.Equal(
- [RetailPanelCatalog.PositiveEffects, RetailPanelCatalog.NegativeEffects],
- toggled);
-
- spellbook.OnEnchantmentRemoved(1u, 42u);
- Assert.False(helpful.Enabled);
- Assert.True(harmful.Enabled);
- }
-
- private static ImportedLayout BuildSyntheticLayout()
- {
- var root = new ElementInfo { Id = 1u, Type = 3u, Width = 150f, Height = 30f };
- var helpful = ButtonInfo(EffectsIndicatorController.HelpfulButtonId);
- var harmful = ButtonInfo(EffectsIndicatorController.HarmfulButtonId);
- return LayoutImporter.BuildFromInfos(root, [helpful, harmful], NoTex, null);
- }
-
- private static ElementInfo ButtonInfo(uint id)
- {
- var info = new ElementInfo
- {
- Id = id,
- Type = EffectsIndicatorController.RetailClassId,
- Width = 20f,
- Height = 20f,
- DefaultStateId = UiButtonStateMachine.Normal,
- };
- info.States[UiButtonStateMachine.Normal] = new UiStateInfo
- {
- Id = UiButtonStateMachine.Normal,
- Name = "Normal",
- };
- info.States[UiButtonStateMachine.Ghosted] = new UiStateInfo
- {
- Id = UiButtonStateMachine.Ghosted,
- Name = "Ghosted",
- };
- return info;
- }
-}
diff --git a/tests/AcDream.App.Tests/UI/Layout/IndicatorBarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/IndicatorBarControllerTests.cs
new file mode 100644
index 00000000..e83d8919
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/IndicatorBarControllerTests.cs
@@ -0,0 +1,216 @@
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+using AcDream.Core.Items;
+using AcDream.Core.Net;
+using AcDream.Core.Spells;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+public sealed class IndicatorBarControllerTests
+{
+ private const uint Player = 0x50000001u;
+ private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
+
+ [Fact]
+ public void AuthoredFixture_BuildsAllSevenRetailIndicatorButtons()
+ {
+ ElementInfo info = FixtureLoader.LoadIndicatorsInfos();
+ Assert.Equal(0x10000610u, info.Id);
+ Assert.Equal(150f, info.Width);
+ Assert.Equal(30f, info.Height);
+
+ ImportedLayout layout = LayoutImporter.Build(info, NoTex, datFont: null);
+ uint[] ids =
+ [
+ IndicatorBarController.LinkButtonId,
+ IndicatorBarController.HelpfulButtonId,
+ IndicatorBarController.HarmfulButtonId,
+ IndicatorBarController.VitaeButtonId,
+ IndicatorBarController.BurdenButtonId,
+ IndicatorBarController.MiniGameButtonId,
+ IndicatorBarController.EndCharacterSessionButtonId,
+ ];
+ foreach (uint id in ids)
+ Assert.IsType(layout.FindElement(id));
+ }
+
+ [Fact]
+ public void Enchantments_UpdateHelpfulHarmfulAndVitae_AndDispatchPanels()
+ {
+ var h = CreateHarness();
+ using IndicatorBarController controller = h.Controller;
+ UiButton helpful = h.Button(IndicatorBarController.HelpfulButtonId);
+ UiButton harmful = h.Button(IndicatorBarController.HarmfulButtonId);
+ UiButton vitae = h.Button(IndicatorBarController.VitaeButtonId);
+
+ Assert.False(helpful.Enabled);
+ Assert.False(harmful.Enabled);
+ Assert.False(vitae.Enabled);
+
+ h.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
+ 42u, 1u, 60f, 1u, Bucket: 1u, SpellCategory: 99u));
+ h.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
+ 43u, 2u, 60f, 2u, Bucket: 2u, SpellCategory: 99u));
+ Assert.True(helpful.Enabled);
+ Assert.True(harmful.Enabled);
+
+ helpful.OnEvent(new UiEvent(0, helpful, UiEventType.Click));
+ harmful.OnEvent(new UiEvent(0, harmful, UiEventType.Click));
+ Assert.Equal(
+ [RetailPanelCatalog.PositiveEffects, RetailPanelCatalog.NegativeEffects],
+ h.ToggledPanels);
+
+ h.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
+ 50u, 3u, 60f, 1u, StatModValue: 1f, Bucket: 4u));
+ Assert.False(vitae.Enabled);
+ h.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
+ 50u, 3u, 60f, 1u, StatModValue: 0.99f, Bucket: 4u));
+ Assert.True(vitae.Enabled);
+ Assert.Equal(UiButtonStateMachine.Normal, vitae.ActiveRetailStateId);
+
+ h.Spellbook.OnEnchantmentRemoved(3u, 50u);
+ Assert.False(vitae.Enabled);
+ }
+
+ [Theory]
+ [InlineData(1499, IndicatorBarController.UnencumberedState)]
+ [InlineData(1500, IndicatorBarController.EncumberedState)]
+ [InlineData(2999, IndicatorBarController.EncumberedState)]
+ [InlineData(3000, IndicatorBarController.HeavilyEncumberedState)]
+ public void Burden_UsesExactRetailLoadBoundaries(int burden, uint expectedState)
+ {
+ var h = CreateHarness(strength: 10);
+ using IndicatorBarController controller = h.Controller;
+ h.Objects.UpdateIntProperty(Player, 5u, burden);
+
+ UiButton button = h.Button(IndicatorBarController.BurdenButtonId);
+ Assert.Equal(expectedState, button.ActiveRetailStateId);
+ }
+
+ [Fact]
+ public void Burden_ClickOpensCharacterInformationPanel()
+ {
+ var h = CreateHarness();
+ using IndicatorBarController controller = h.Controller;
+ UiButton burden = h.Button(IndicatorBarController.BurdenButtonId);
+
+ burden.OnEvent(new UiEvent(0, burden, UiEventType.Click));
+
+ Assert.Equal([RetailPanelCatalog.Character], h.ToggledPanels);
+ }
+
+ [Theory]
+ [InlineData(true, 4.999, IndicatorBarController.ConnectionGoodState)]
+ [InlineData(true, 5.0, IndicatorBarController.ConnectionUncertainState)]
+ [InlineData(true, 19.999, IndicatorBarController.ConnectionUncertainState)]
+ [InlineData(true, 20.0, IndicatorBarController.ConnectionBadState)]
+ [InlineData(true, 39.999, IndicatorBarController.ConnectionBadState)]
+ [InlineData(true, 40.0, IndicatorBarController.ConnectionDisconnectedState)]
+ [InlineData(false, 0.0, IndicatorBarController.ConnectionDisconnectedState)]
+ public void LinkStatus_UsesRetailThresholds(
+ bool connected,
+ double age,
+ uint expectedState)
+ {
+ var h = CreateHarness();
+ using IndicatorBarController controller = h.Controller;
+ h.LinkStatus = new LinkStatusSnapshot(connected, age);
+ h.Time = 4.0;
+
+ controller.Tick();
+
+ Assert.Equal(
+ expectedState,
+ h.Button(IndicatorBarController.LinkButtonId).ActiveRetailStateId);
+ }
+
+ [Fact]
+ public void LinkStatus_UpdatesAtFourSeconds_AndBadFlashesEveryPointSevenFive()
+ {
+ var h = CreateHarness();
+ using IndicatorBarController controller = h.Controller;
+ UiButton link = h.Button(IndicatorBarController.LinkButtonId);
+ h.LinkStatus = new LinkStatusSnapshot(true, 20d);
+
+ h.Time = 3.999;
+ controller.Tick();
+ Assert.Equal(IndicatorBarController.ConnectionGoodState, link.ActiveRetailStateId);
+
+ h.Time = 4.0;
+ controller.Tick();
+ Assert.Equal(IndicatorBarController.ConnectionBadState, link.ActiveRetailStateId);
+
+ h.Time = 4.749;
+ controller.Tick();
+ Assert.Equal(IndicatorBarController.ConnectionBadState, link.ActiveRetailStateId);
+
+ h.Time = 4.75;
+ controller.Tick();
+ Assert.Equal(IndicatorBarController.ConnectionUncertainState, link.ActiveRetailStateId);
+
+ h.Time = 5.5;
+ controller.Tick();
+ Assert.Equal(IndicatorBarController.ConnectionBadState, link.ActiveRetailStateId);
+ }
+
+ [Fact]
+ public void MiniGameNoticesAndEndSessionActionUseAuthoredButtons()
+ {
+ var h = CreateHarness();
+ using IndicatorBarController controller = h.Controller;
+ UiButton miniGame = h.Button(IndicatorBarController.MiniGameButtonId);
+ UiButton endSession = h.Button(IndicatorBarController.EndCharacterSessionButtonId);
+
+ Assert.False(miniGame.Enabled);
+ controller.SetMiniGameActive(true);
+ Assert.True(miniGame.Enabled);
+ controller.SetMiniGameActive(false);
+ Assert.False(miniGame.Enabled);
+
+ endSession.OnEvent(new UiEvent(0, endSession, UiEventType.Click));
+ Assert.Equal(1, h.EndSessionRequests);
+ }
+
+ private static Harness CreateHarness(int strength = 10)
+ {
+ var table = SpellTable.LoadFromReader(new StringReader(
+ "Spell ID,Name,Flags [Hex]\n42,Boon,0x4\n43,Bane,0x0\n50,Vitae,0x0\n"));
+ var spellbook = new Spellbook(table);
+ var objects = new ClientObjectTable();
+ objects.AddOrUpdate(new ClientObject { ObjectId = Player });
+ ImportedLayout layout = LayoutImporter.Build(
+ FixtureLoader.LoadIndicatorsInfos(), NoTex, datFont: null);
+ var harness = new Harness(layout, spellbook, objects, strength);
+ harness.Controller = IndicatorBarController.Bind(
+ layout,
+ new IndicatorBarBindings(
+ spellbook,
+ objects,
+ () => Player,
+ () => strength,
+ () => harness.LinkStatus,
+ () => harness.Time,
+ harness.ToggledPanels.Add,
+ () => harness.EndSessionRequests++))!;
+ return harness;
+ }
+
+ private sealed class Harness(
+ ImportedLayout layout,
+ Spellbook spellbook,
+ ClientObjectTable objects,
+ int strength)
+ {
+ public ImportedLayout Layout { get; } = layout;
+ public Spellbook Spellbook { get; } = spellbook;
+ public ClientObjectTable Objects { get; } = objects;
+ public int Strength { get; } = strength;
+ public List ToggledPanels { get; } = [];
+ public double Time { get; set; }
+ public LinkStatusSnapshot LinkStatus { get; set; } = new(true, 0d);
+ public int EndSessionRequests { get; set; }
+ public IndicatorBarController Controller { get; set; } = null!;
+
+ public UiButton Button(uint id) => Assert.IsType(Layout.FindElement(id));
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs b/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs
index c8277988..4ed18528 100644
--- a/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs
@@ -23,7 +23,7 @@ public sealed class RetailLayoutFixtureGenerator
(0x2100002Eu, "character_2100002E.json"),
(SpellbookWindowController.LayoutId, "spellbook_21000034.json"),
(0x2100006Cu, "vitals_2100006C.json"),
- (EffectsIndicatorController.LayoutId, "indicators_21000071.json"),
+ (IndicatorBarController.LayoutId, "indicators_21000071.json"),
(0x21000072u, "powerbar_21000072.json"),
(0x21000073u, "combat_21000073.json"),
(0x21000074u, "radar_21000074.json"),
diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionLinkStatusTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionLinkStatusTests.cs
new file mode 100644
index 00000000..65abb859
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/WorldSessionLinkStatusTests.cs
@@ -0,0 +1,46 @@
+using AcDream.Core.Net;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests;
+
+public sealed class WorldSessionLinkStatusTests
+{
+ [Theory]
+ [InlineData(WorldSession.State.Disconnected)]
+ [InlineData(WorldSession.State.Failed)]
+ public void DisconnectedStates_DoNotPublishPacketAge(WorldSession.State state)
+ {
+ LinkStatusSnapshot snapshot = WorldSession.BuildLinkStatus(
+ state, lastInboundPacketTicks: 100, nowTicks: 900, frequency: 100);
+
+ Assert.False(snapshot.Connected);
+ Assert.Equal(0d, snapshot.SecondsSinceLastPacket);
+ }
+
+ [Theory]
+ [InlineData(WorldSession.State.Handshaking)]
+ [InlineData(WorldSession.State.InCharacterSelect)]
+ [InlineData(WorldSession.State.EnteringWorld)]
+ [InlineData(WorldSession.State.InWorld)]
+ public void ConnectedStates_PublishMonotonicPacketAge(WorldSession.State state)
+ {
+ LinkStatusSnapshot snapshot = WorldSession.BuildLinkStatus(
+ state, lastInboundPacketTicks: 100, nowTicks: 850, frequency: 100);
+
+ Assert.True(snapshot.Connected);
+ Assert.Equal(7.5d, snapshot.SecondsSinceLastPacket);
+ }
+
+ [Fact]
+ public void ClockRegression_ClampsPacketAgeToZero()
+ {
+ LinkStatusSnapshot snapshot = WorldSession.BuildLinkStatus(
+ WorldSession.State.InWorld,
+ lastInboundPacketTicks: 200,
+ nowTicks: 100,
+ frequency: 100);
+
+ Assert.True(snapshot.Connected);
+ Assert.Equal(0d, snapshot.SecondsSinceLastPacket);
+ }
+}