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); + } +}