feat(ui): share indicator detail panels

Port the authored Link Status, Vitae, and Mini Game detail roots and register every indicator page with retail's one-active gmPanelUI owner. Helpful/Harmful and the new pages now replace Inventory, Character, or Magic at one canonical window position while preserving the DAT restore-previous flag.

Correct the retail ping wire to its payload-free request/response, publish measured RTT, and port Vitae recovery XP from the live modifier and player properties. Keep transport packet-loss averaging and mini-game gameplay explicitly tracked under AP-110.

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

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 10:27:41 +02:00
parent 52c529be86
commit a96767ba6d
28 changed files with 6539 additions and 32 deletions

View file

@ -61,6 +61,12 @@ back to inert generic DAT elements.
`gmUIElement_EffectsIndicator`. The retained controller consequently had no `gmUIElement_EffectsIndicator`. The retained controller consequently had no
live bindings for session packet age, Vitae, or the player load ratio and did live bindings for session packet age, Vitae, or the player load ratio and did
not dispatch Character Information or the shared logout confirmation path. not dispatch Character Information or the shared logout confirmation path.
The strip and its actions are now ported. Link Status (panel 8), both effect
pages (4/5), Vitae (15), and the authored Mini Game shell (9) are registered as
children of the same retained main-panel owner as Inventory/Character/Magic,
so switching pages preserves one canonical position. Link ping RTT and Vitae
recovery XP are live; transport packet-loss averaging and Mini Game gameplay
remain the explicitly narrowed AP-110 residual.
**Files:** `src/AcDream.App/UI/Layout/IndicatorBarController.cs`; **Files:** `src/AcDream.App/UI/Layout/IndicatorBarController.cs`;
`src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`;
@ -74,6 +80,8 @@ 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 helpful, harmful, Vitae, and burden react to live state; the burden icon opens
Character Information; effects open their matching panels; crossed swords use Character Information; effects open their matching panels; crossed swords use
the shared confirmation dialog and close gracefully only after acceptance. the shared confirmation dialog and close gracefully only after acceptance.
Every non-ghosted detail icon opens its authored page in the shared main-panel
position; closing a restore-previous page returns to the deferred ordinary page.
--- ---

View file

@ -207,7 +207,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` |
| AP-108 | Paperdoll/AutoWield still omit full `AutoWieldIsLegal`/dual-wield rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **Primary replacement retired from this row 2026-07-14:** inventory activation and paperdoll drops share the confirmed blocker transaction, preserve explicit slot intent, relocate an already-worn item to an explicitly selected compatible slot, and emit retail's successful move-to-backpack status. **Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, and live doll work; primary weapon, incompatible shield, and mismatched ammo blockers sequence through server-confirmed dequip→wield in both peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::BlocksUseOfShield @ 0x0055D3E0`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | | AP-108 | Paperdoll/AutoWield still omit full `AutoWieldIsLegal`/dual-wield rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **Primary replacement retired from this row 2026-07-14:** inventory activation and paperdoll drops share the confirmed blocker transaction, preserve explicit slot intent, relocate an already-worn item to an explicitly selected compatible slot, and emit retail's successful move-to-backpack status. **Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, and live doll work; primary weapon, incompatible shield, and mismatched ammo blockers sequence through server-confirmed dequip→wield in both peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::BlocksUseOfShield @ 0x0055D3E0`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` |
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | | AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/examine/floating chat, quests/map/vitae/options/smartbox, vendor/trade/salvage/tinkering, and D.6 nameplates/floaters | D.5/D.6 roadmap; retained layout registration set | Basic combat plus M3 spellbook/component/effects/favorite-spell surfaces now cover the active melee/missile/magic loops; later waves land each remaining surface against authoritative state | Large portions of retail gameplay still have no production UI outside the current combat/magic/inventory/character loops | Named `gm*UI::PostInit` methods and LayoutDesc catalog | | AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/examine/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat plus M3 spellbook/component/effects/favorite-spell/Link/Vitae surfaces now cover the active melee/missile/magic loops; the Link page has exact ping RTT but reports the current transport's zero default until its missing reliability statistics land | Large portions of retail gameplay still have no production UI; real packet loss is displayed as 0.00% instead of retail's moving average | Named `gm*UI::PostInit` methods; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog |
| ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` | | ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` |
| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` | | AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
| AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` | | AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` |

View file

@ -1,6 +1,6 @@
# acdream — strategic roadmap # acdream — strategic roadmap
**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. **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 its Link/Vitae detail pages, 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. **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.
--- ---
@ -22,7 +22,9 @@ active enchantment, then recall through the DAT-driven portal presentation.
equipped caster endowment, scoped retail keys, spellbook/component book, equipped caster endowment, scoped retail keys, spellbook/component book,
positive/negative active-enchantment panels, the seven-control link/effect/ positive/negative active-enchantment panels, the seven-control link/effect/
Vitae/burden/mini-game/session indicator strip, and the retail Vitae/burden/mini-game/session indicator strip, and the retail
one-active-main-panel lifecycle. Connected-gate one-active-main-panel lifecycle. Link Status, Helpful/Harmful, Vitae, and
the inactive Mini Game shell now occupy that same canonical main-panel
position; Link RTT and Vitae recovery XP are live. Connected-gate
correction now also pins type-12 spellbook tabs, retail zero text margins, correction now also pins type-12 spellbook tabs, retail zero text margins,
and `PassToChildren` Closed/Open chrome through real-DAT fixtures. and `PassToChildren` Closed/Open chrome through real-DAT fixtures.
3. **Pending user visual gate:** connected projectile/self-buff casts plus the 3. **Pending user visual gate:** connected projectile/self-buff casts plus the

View file

@ -714,6 +714,11 @@ 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 visibility now flows through retail's one-active `gmPanelUI` owner. M3 remains
active only for the connected single-client magic active only for the connected single-client magic
UI/casting visual gate and the final two-client portal observer gate. UI/casting visual gate and the final two-client portal observer gate.
The 2026-07-17 indicator follow-up also mounts the authored Link Status, Vitae,
and Mini Game detail roots as children of that same main-panel owner. Helpful,
Harmful, Link, and Vitae therefore replace Inventory/Character/Magic in one
canonical window position. Link uses retail's payload-free ping/RTT cadence;
Vitae uses PropertyInt 129/139 and the retail XP-pool threshold formula.
The first connected gate exposed and corrected two retained-import defects: The first connected gate exposed and corrected two retained-import defects:
spellbook tabs are authored stateful text controls (not buttons), and retail spellbook tabs are authored stateful text controls (not buttons), and retail
text starts with zero margins. Real-DAT fixtures now pin spellbook binding, text starts with zero margins. Real-DAT fixtures now pin spellbook binding,

View file

@ -25,6 +25,17 @@ Named retail functions:
- `gmUIElement_BurdenIndicator::Update @ 0x004E6CE0` - `gmUIElement_BurdenIndicator::Update @ 0x004E6CE0`
- `LinkStatusHolder::GetConnectionStatus @ 0x00411380` - `LinkStatusHolder::GetConnectionStatus @ 0x00411380`
- `LinkStatusHolder::OnHeartbeat @ 0x004113D0` - `LinkStatusHolder::OnHeartbeat @ 0x004113D0`
- `gmPanelUI::SetupChildren @ 0x004BC9E0`
- `gmPanelUI::RecvNotice_SetPanelVisibility @ 0x004BC6F0`
- `gmLinkStatusUI::PostInit @ 0x004AADB0`
- `gmLinkStatusUI::Update @ 0x004AAEA0`
- `gmLinkStatusUI::ListenToElementMessage @ 0x004AB0B0`
- `gmLinkStatusUI::ListenToGlobalMessage @ 0x004AB110`
- `gmLinkStatusUI::RecvNotice_Ping @ 0x004AB150`
- `gmVitaeUI::PostInit @ 0x004A7240`
- `gmVitaeUI::Update @ 0x004A7400`
- `VitaeSystem::VitaeCPPoolThreshold @ 0x005C8FD0`
- `CM_Character::Event_RequestPing @ 0x006A19A0`
- `gmEffectsUI::SpellEffectMatchesUIType @ 0x004B76C0` - `gmEffectsUI::SpellEffectMatchesUIType @ 0x004B76C0`
- `gmGamePlayUI::RecvNotice_EndCharacterSession @ 0x004EBEA0` - `gmGamePlayUI::RecvNotice_EndCharacterSession @ 0x004EBEA0`
@ -40,6 +51,11 @@ The installed retail LayoutDesc establishes the fixed child order and art:
| 105 | `0x100000F3` | `0x10000004` | mini-game | | 105 | `0x100000F3` | `0x10000004` | mini-game |
| 125 | `0x100000FA` | ordinary button | end character session | | 125 | `0x100000FA` | ordinary button | end character session |
`gmPanelUI::SetupChildren` reads enum property `0x10000029` from each
registered child. The installed main-panel LayoutDesc resolves the indicator
detail pages to Link Status `8`, Mini Game `9`, and Vitae `15` (Helpful and
Harmful remain `4` and `5`). They are not independent floating-window ids.
## Pseudocode ## Pseudocode
### Link indicator ### Link indicator
@ -82,6 +98,46 @@ acdream records the last successfully decoded server datagram in
keeps socket/session facts out of the UI while preserving retail's thresholds keeps socket/session facts out of the UI while preserving retail's thresholds
and cadence. and cadence.
### Link Status detail page
```text
On shown:
register for time notices
pleaseRequestPing = true
Update()
On hidden:
unregister time notices
pleaseRequestPing = false
On time notice while visible:
if now >= nextUpdate:
nextUpdate = now + 5 seconds
Update()
Update:
replace text with localized connection explanation + 5/20-second legend
append 10-second packet-loss percentage with two decimal places
append ping in milliseconds, or "????" before a response
if pleaseRequestPing or now - lastPingRequest >= 120 seconds:
lastPingRequest = now
Event_RequestPing()
pleaseRequestPing = false
Event_RequestPing:
send F7B1, game-action sequence, opcode 0x01E9
// no payload
On empty 0x01EA PingResponse:
pingRoundTrip = monotonicNow - lastPingRequest
```
The network session owns the send timestamp and response sample. The retained
page owns only retail's display and refresh cadence. acdream's transport does
not yet expose the retail NAK/retransmission moving averages, so packet-loss
averaging remains the explicit AP-110 residual rather than being guessed from
ordinary packet sequence gaps.
### Helpful and harmful effects ### Helpful and harmful effects
```text ```text
@ -109,6 +165,25 @@ On PlayerDesc or VitaeChanged:
SetState(Ghosted = 13) SetState(Ghosted = 13)
``` ```
The detail page uses the same modifier:
```text
lostPercent = 100 - int(vitae * 100)
if lostPercent <= 0:
show localized full-strength sentence
else:
vitaePool = player PropertyInt 129
deathLevel = player PropertyInt 139, falling back to Level (25)
threshold = int(((deathLevel ^ 2.5 * 2.5) + 20)
* vitae ^ 5 + 0.5)
remaining = threshold - vitaePool
show localized lost-percent sentence and remaining-XP sentence
```
The x87 powers omitted from the named pseudo-C output for
`VitaeCPPoolThreshold` were cross-checked against the official ACE
`Player_Xp.cs` port. The property ids and rounding expression agree.
### Burden / character information ### Burden / character information
```text ```text
@ -136,6 +211,28 @@ 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 still-unported mini-game subsystem (existing divergence AP-110), so this task
does not manufacture a game-active state from unrelated packets. does not manufacture a game-active state from unrelated packets.
The authored `gmMiniGameUI` root `0x1000016A` is mounted and registered as
main panel `9`, including its close behavior. It remains unreachable while the
indicator is Ghosted; board state and game actions remain with the unported
mini-game subsystem.
### Shared main-panel ownership
```text
For every toolbar or indicator detail page:
register page with gmPanelUI panel id
when shown:
hide the current child
move the requested child to the shared main-panel placement
show only the requested child
when a page with bool property 0x10000049 closes:
restore the deferred ordinary child
```
Helpful/Harmful, Link Status, Vitae, and the Mini Game shell therefore share
the same retained host position as Inventory, Character, and Magic. They do
not remember independent positions.
### End character session ### End character session
```text ```text
@ -159,8 +256,8 @@ CharacterLogOff and transport Disconnect packets.
`EncumbranceSystem::Load @ 0x004FCC40`. `EncumbranceSystem::Load @ 0x004FCC40`.
- The existing enchantment parser preserves the separate Mult, Add, Vitae, and - The existing enchantment parser preserves the separate Mult, Add, Vitae, and
Cooldown registry buckets used by the retail indicator queries. Cooldown registry buckets used by the retail indicator queries.
- ACE/reference source is not present in this worktree. No server behavior is - Official ACE was cross-checked for the empty PingResponse and the vitae-pool
inferred for the strip: state is derived only from packets already parsed by threshold expression; the named retail client remains the behavior oracle.
the client, and the named retail client remains the behavior oracle. - The Link Status and Vitae pages now use their authored DAT roots and localized
- Full Link Status, Vitae, and mini-game panels remain tracked by AP-110. This strings. AP-110 retains only packet-loss averaging and mini-game gameplay for
port does not replace them with an ad-hoc panel. this slice; neither is replaced with an ad-hoc approximation.

View file

@ -2320,6 +2320,7 @@ public sealed class GameWindow : IDisposable
() => _liveSession?.LinkStatus () => _liveSession?.LinkStatus
?? AcDream.Core.Net.LinkStatusSnapshot.Disconnected, ?? AcDream.Core.Net.LinkStatusSnapshot.Disconnected,
ClientTimerNow, ClientTimerNow,
() => _liveSession?.RequestLinkStatusPing(),
() => _window?.Close()), () => _window?.Close()),
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings( Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
Objects, Objects,

View file

@ -85,7 +85,10 @@ public sealed class IndicatorBarController : IRetainedPanelController
_helpful.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.PositiveEffects); _helpful.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.PositiveEffects);
_harmful.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.NegativeEffects); _harmful.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.NegativeEffects);
_link.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.LinkStatus);
_vitae.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.Vitae);
_burden.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.Character); _burden.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.Character);
_miniGame.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.MiniGame);
_endCharacterSession.OnClick = bindings.RequestEndCharacterSession; _endCharacterSession.OnClick = bindings.RequestEndCharacterSession;
bindings.Spellbook.EnchantmentsChanged += UpdateEnchantments; bindings.Spellbook.EnchantmentsChanged += UpdateEnchantments;

View file

@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Shared text shaping for retail indicator detail pages. Retail
/// <c>UIElement_Text::SetText</c> reflows newline-delimited strings into the
/// authored field width; acdream's retained text seam carries already-shaped
/// lines, so controllers perform the equivalent shaping before publication.
/// </summary>
internal static class IndicatorDetailText
{
public static IReadOnlyList<UiText.Line> Shape(UiText target, string text)
{
ArgumentNullException.ThrowIfNull(target);
text ??= string.Empty;
float maxWidth = Math.Max(1f, target.Width - (2f * target.Padding));
float Measure(string value)
=> target.DatFont?.MeasureWidth(value)
?? target.Font?.MeasureWidth(value)
?? value.Length * 8f;
var lines = new List<UiText.Line>();
string normalized = text.Replace("\\n", "\n", StringComparison.Ordinal);
foreach (string paragraph in normalized.Split('\n'))
{
if (paragraph.Length == 0)
{
lines.Add(new UiText.Line(string.Empty, target.DefaultColor));
continue;
}
foreach (string line in ChatWindowController.WrapText(paragraph, maxWidth, Measure))
lines.Add(new UiText.Line(line, target.DefaultColor));
}
return lines;
}
}

View file

@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using AcDream.Core.Net;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retained port of retail <c>gmLinkStatusUI</c>. LayoutDesc owns the page and
/// chrome; this controller owns its five-second text refresh and 120-second
/// ping cadence.
/// </summary>
public sealed class LinkStatusUiController : IRetainedPanelController
{
public const uint LayoutId = 0x2100001Du;
public const uint RootId = 0x10000167u;
public const uint MainTextId = 0x10000169u;
public const uint CloseId = 0x100000FCu;
private const double UpdateIntervalSeconds = 5d;
private const double PingIntervalSeconds = 120d;
private readonly UiText _mainText;
private readonly UiButton? _close;
private readonly Func<LinkStatusSnapshot> _snapshot;
private readonly Func<double> _currentTime;
private readonly Action _requestPing;
private readonly LinkStatusStrings _strings;
private IReadOnlyList<UiText.Line> _lines = Array.Empty<UiText.Line>();
private double _nextUpdateTime = -1d;
private double _lastPingRequestTime = -1d;
private bool _pleaseRequestPing;
private bool _visible;
private LinkStatusUiController(
ImportedLayout layout,
Func<LinkStatusSnapshot> snapshot,
Func<double> currentTime,
Action requestPing,
LinkStatusStrings strings,
Action? close)
{
_mainText = (UiText)layout.FindElement(MainTextId)!;
_close = layout.FindElement(CloseId) as UiButton;
_snapshot = snapshot;
_currentTime = currentTime;
_requestPing = requestPing;
_strings = strings;
_mainText.LinesProvider = () => _lines;
if (_close is not null) _close.OnClick = close;
}
public static LinkStatusUiController? Bind(
ImportedLayout layout,
Func<LinkStatusSnapshot> snapshot,
Func<double> currentTime,
Action requestPing,
LinkStatusStrings strings,
Action? close = null)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentNullException.ThrowIfNull(currentTime);
ArgumentNullException.ThrowIfNull(requestPing);
ArgumentNullException.ThrowIfNull(strings);
return layout.FindElement(MainTextId) is UiText
? new LinkStatusUiController(
layout, snapshot, currentTime, requestPing, strings, close)
: null;
}
/// <summary>
/// Port of <c>gmLinkStatusUI::ListenToGlobalMessage @ 0x004AB110</c>.
/// </summary>
public void Tick()
{
if (!_visible) return;
double now = _currentTime();
if (!double.IsFinite(now) || now < _nextUpdateTime) return;
_nextUpdateTime = now + UpdateIntervalSeconds;
Update(now);
}
public void OnShown()
{
_visible = true;
_pleaseRequestPing = true;
Update(_currentTime());
}
public void OnHidden()
{
_visible = false;
_pleaseRequestPing = false;
}
private void Update(double now)
{
LinkStatusSnapshot value = _snapshot();
string ping = value.RoundTripSeconds is double seconds
&& double.IsFinite(seconds)
&& seconds >= 0d
? (seconds * 1000d).ToString("F0", CultureInfo.CurrentCulture)
: "????";
string body = _strings.Description
+ _strings.Legend
+ _strings.DisconnectWarning
+ _strings.PacketLossPrefix
+ value.PacketLossPercentage.ToString("F2", CultureInfo.CurrentCulture)
+ _strings.PingPrefix
+ ping;
_lines = IndicatorDetailText.Shape(_mainText, body);
if (!double.IsFinite(now)) return;
bool periodicPing = _lastPingRequestTime >= 0d
&& now - _lastPingRequestTime >= PingIntervalSeconds;
if (!_pleaseRequestPing && !periodicPing) return;
_lastPingRequestTime = now;
_pleaseRequestPing = false;
_requestPing();
}
public void Dispose()
{
if (_close is not null) _close.OnClick = null;
}
}
public sealed record LinkStatusStrings(
string Description,
string Legend,
string DisconnectWarning,
string PacketLossPrefix,
string PingPrefix)
{
public static LinkStatusStrings English { get; } = new(
"The Link Indicator shows the current status of your connection to the game servers.",
"\n\nGREEN = your link is good.\nYELLOW = no packets for at least 5 sec.\nRED = no packets for at least 20 sec.",
"\n\nIf approximately forty seconds pass without receiving a packet, you will be disconnected from the server.",
"\n\n\nPacket loss for the last 10 sec: ",
"\n\nRoundtrip Ping time to Server: ");
}

View file

@ -0,0 +1,35 @@
using System;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Authored shell for retail <c>gmMiniGameUI</c>. The indicator remains
/// ghosted until a future game-state owner calls <c>SetMiniGameActive</c>;
/// mounting the page now keeps its window lifecycle on the shared retail host.
/// </summary>
public sealed class MiniGameUiController : IRetainedPanelController
{
public const uint LayoutId = 0x2100001Eu;
public const uint RootId = 0x1000016Au;
public const uint CloseId = 0x1000016Bu;
private readonly UiButton _close;
private MiniGameUiController(UiButton close, Action? closeWindow)
{
_close = close;
_close.OnClick = closeWindow;
}
public static MiniGameUiController? Bind(
ImportedLayout layout,
Action? close = null)
{
ArgumentNullException.ThrowIfNull(layout);
return layout.FindElement(CloseId) is UiButton button
? new MiniGameUiController(button, close)
: null;
}
public void Dispose() => _close.OnClick = null;
}

View file

@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retained port of retail <c>gmVitaeUI</c>. It presents the active vitae
/// modifier and the authoritative experience pool needed to regain one percent.
/// </summary>
public sealed class VitaeUiController : IRetainedPanelController
{
public const uint LayoutId = 0x21000020u;
public const uint RootId = 0x100001C1u;
public const uint MainTextId = 0x100001C3u;
public const uint CloseId = 0x100000FCu;
internal const uint VitaeCpPoolProperty = 0x81u;
internal const uint DeathLevelProperty = 0x8Bu;
internal const uint LevelProperty = 0x19u;
private readonly Spellbook _spellbook;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly VitaeStrings _strings;
private readonly UiText _mainText;
private readonly UiButton? _close;
private IReadOnlyList<UiText.Line> _lines = Array.Empty<UiText.Line>();
private bool _disposed;
private VitaeUiController(
ImportedLayout layout,
Spellbook spellbook,
ClientObjectTable objects,
Func<uint> playerGuid,
VitaeStrings strings,
Action? close)
{
_spellbook = spellbook;
_objects = objects;
_playerGuid = playerGuid;
_strings = strings;
_mainText = (UiText)layout.FindElement(MainTextId)!;
_close = layout.FindElement(CloseId) as UiButton;
_mainText.LinesProvider = () => _lines;
if (_close is not null) _close.OnClick = close;
_spellbook.EnchantmentsChanged += Update;
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
_objects.Cleared += Update;
Update();
}
public static VitaeUiController? Bind(
ImportedLayout layout,
Spellbook spellbook,
ClientObjectTable objects,
Func<uint> playerGuid,
VitaeStrings strings,
Action? close = null)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(objects);
ArgumentNullException.ThrowIfNull(playerGuid);
ArgumentNullException.ThrowIfNull(strings);
return layout.FindElement(MainTextId) is UiText
? new VitaeUiController(
layout, spellbook, objects, playerGuid, strings, close)
: null;
}
public void OnShown() => Update();
private void OnObjectChanged(ClientObject item)
{
if (item.ObjectId == _playerGuid()) Update();
}
private void Update()
{
float vitae = CurrentVitae();
int lostPercent = 100 - (int)(vitae * 100f);
string body;
if (lostPercent <= 0)
{
body = _strings.FullStrength;
}
else
{
ClientObject? player = _objects.Get(_playerGuid());
int pool = player?.Properties.GetInt(VitaeCpPoolProperty) ?? 0;
int level = 0;
if (player is not null)
{
level = player.Properties.Ints.TryGetValue(
DeathLevelProperty, out int deathLevel)
? deathLevel
: player.Properties.GetInt(LevelProperty);
}
int remaining = VitaeCpPoolThreshold(vitae, level) - pool;
body = _strings.LostPrefix
+ lostPercent
+ _strings.LostSuffix
+ _strings.RecoveryPrefix
+ remaining
+ _strings.RecoverySuffix;
}
_lines = IndicatorDetailText.Shape(_mainText, body);
}
private float CurrentVitae()
=> _spellbook.ActiveEnchantmentSnapshot
.Where(record => record.Bucket == 4u)
.Select(record => record.StatModValue)
.OfType<float>()
.Where(float.IsFinite)
.DefaultIfEmpty(1f)
.Last();
/// <summary>
/// Retail <c>VitaeSystem::VitaeCPPoolThreshold @ 0x005C8FD0</c>, with
/// the x87 expression cross-checked against ACE's Player_Xp port.
/// </summary>
internal static int VitaeCpPoolThreshold(float vitae, int level)
=> (int)(((Math.Pow(level, 2.5d) * 2.5d) + 20d)
* Math.Pow(vitae, 5d)
+ 0.5d);
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_spellbook.EnchantmentsChanged -= Update;
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
_objects.Cleared -= Update;
if (_close is not null) _close.OnClick = null;
}
}
public sealed record VitaeStrings(
string FullStrength,
string LostPrefix,
string LostSuffix,
string RecoveryPrefix,
string RecoverySuffix)
{
public static VitaeStrings English { get; } = new(
"Your Vitae, or life force, is at full strength.",
"Due to your recent death, you have temporarily lost ",
" % of your Vitae, or life force.",
"\n\nYou will regain 1% of your Vitae once you earn ",
" more experience.");
}

View file

@ -10,16 +10,22 @@ public static class RetailPanelCatalog
public const uint PositiveEffects = 4u; public const uint PositiveEffects = 4u;
public const uint NegativeEffects = 5u; public const uint NegativeEffects = 5u;
public const uint Inventory = 7u; public const uint Inventory = 7u;
public const uint LinkStatus = 8u;
public const uint MiniGame = 9u;
public const uint Character = 11u; public const uint Character = 11u;
public const uint Magic = 13u; public const uint Magic = 13u;
public const uint Vitae = 15u;
private static readonly (uint PanelId, string WindowName)[] Mounted = private static readonly (uint PanelId, string WindowName)[] Mounted =
{ {
(PositiveEffects, WindowNames.PositiveEffects), (PositiveEffects, WindowNames.PositiveEffects),
(NegativeEffects, WindowNames.NegativeEffects), (NegativeEffects, WindowNames.NegativeEffects),
(Inventory, WindowNames.Inventory), (Inventory, WindowNames.Inventory),
(LinkStatus, WindowNames.LinkStatus),
(MiniGame, WindowNames.MiniGame),
(Character, WindowNames.Character), (Character, WindowNames.Character),
(Magic, WindowNames.Spellbook), (Magic, WindowNames.Spellbook),
(Vitae, WindowNames.Vitae),
}; };
private static readonly (uint PanelId, string WindowName)[] Toolbar = private static readonly (uint PanelId, string WindowName)[] Toolbar =

View file

@ -81,6 +81,7 @@ public sealed record IndicatorRuntimeBindings(
Func<int?> Strength, Func<int?> Strength,
Func<LinkStatusSnapshot> LinkStatus, Func<LinkStatusSnapshot> LinkStatus,
Func<double> CurrentTime, Func<double> CurrentTime,
Action RequestLinkStatusPing,
Action EndCharacterSession); Action EndCharacterSession);
public sealed record ToolbarRuntimeBindings( public sealed record ToolbarRuntimeBindings(
@ -196,6 +197,7 @@ public sealed class RetailUiRuntime : IDisposable
MountCombat(); MountCombat();
MountSpellbook(); MountSpellbook();
MountEffects(); MountEffects();
MountIndicatorDetailPanels();
MountIndicators(); MountIndicators();
MountJumpPowerbar(); MountJumpPowerbar();
MountDialogFactory(); MountDialogFactory();
@ -245,6 +247,9 @@ public sealed class RetailUiRuntime : IDisposable
public SpellbookWindowController? SpellbookWindowController { get; private set; } public SpellbookWindowController? SpellbookWindowController { get; private set; }
public EffectsUiController? PositiveEffectsController { get; private set; } public EffectsUiController? PositiveEffectsController { get; private set; }
public EffectsUiController? NegativeEffectsController { get; private set; } public EffectsUiController? NegativeEffectsController { get; private set; }
public LinkStatusUiController? LinkStatusUiController { get; private set; }
public VitaeUiController? VitaeUiController { get; private set; }
public MiniGameUiController? MiniGameUiController { get; private set; }
public IndicatorBarController? IndicatorBarController { get; private set; } public IndicatorBarController? IndicatorBarController { get; private set; }
public JumpPowerbarController? JumpPowerbarController { get; private set; } public JumpPowerbarController? JumpPowerbarController { get; private set; }
public RetailFpsController? FpsController { get; private set; } public RetailFpsController? FpsController { get; private set; }
@ -274,6 +279,7 @@ public sealed class RetailUiRuntime : IDisposable
SpellcastingUiController?.Tick(); SpellcastingUiController?.Tick();
PositiveEffectsController?.Tick(); PositiveEffectsController?.Tick();
NegativeEffectsController?.Tick(); NegativeEffectsController?.Tick();
LinkStatusUiController?.Tick();
IndicatorBarController?.Tick(); IndicatorBarController?.Tick();
JumpPowerbarController?.Tick(); JumpPowerbarController?.Tick();
SelectedObjectController?.Tick(deltaSeconds); SelectedObjectController?.Tick(deltaSeconds);
@ -880,7 +886,7 @@ public sealed class RetailUiRuntime : IDisposable
if (positive) PositiveEffectsController = controller; if (positive) PositiveEffectsController = controller;
else NegativeEffectsController = controller; else NegativeEffectsController = controller;
UiElement root = layout.Root; UiElement root = layout.Root;
RetailWindowFrame.Mount( RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root, Host.Root,
root, root,
_bindings.Assets.ResolveSprite, _bindings.Assets.ResolveSprite,
@ -898,10 +904,184 @@ public sealed class RetailUiRuntime : IDisposable
ContentClickThrough = false, ContentClickThrough = false,
Controller = controller, Controller = controller,
}); });
_panelUi.Register( _panelUi.RegisterMainPanel(
positive ? RetailPanelCatalog.PositiveEffects : RetailPanelCatalog.NegativeEffects, positive ? RetailPanelCatalog.PositiveEffects : RetailPanelCatalog.NegativeEffects,
positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects, positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects,
rootInfo); handle,
rootInfo.TryGetEffectiveBool(
RetailPanelUiController.RestorePreviousPropertyId,
out bool restorePrevious)
&& restorePrevious);
}
private void MountIndicatorDetailPanels()
{
MountLinkStatusPanel();
MountVitaePanel();
MountMiniGamePanel();
}
private void MountLinkStatusPanel()
{
ElementInfo? rootInfo;
ImportedLayout? layout;
LinkStatusStrings strings;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
LinkStatusUiController.LayoutId,
LinkStatusUiController.RootId);
var resolver = new DatStringResolver(_bindings.Assets.Dats);
layout = rootInfo is null
? null
: LayoutImporter.Build(
rootInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
resolver.Resolve);
LinkStatusStrings fallback = LinkStatusStrings.English;
strings = new LinkStatusStrings(
resolver.Resolve(0x23000001u, 0x0D632F7Fu) ?? fallback.Description,
resolver.Resolve(0x23000001u, 0x038FABF3u) ?? fallback.Legend,
resolver.Resolve(0x23000001u, 0x05481084u) ?? fallback.DisconnectWarning,
resolver.Resolve(0x23000001u, 0x0CADBA93u) ?? fallback.PacketLossPrefix,
resolver.Resolve(0x23000001u, 0x0D6485F7u) ?? fallback.PingPrefix);
}
if (rootInfo is null || layout is null) return;
LinkStatusUiController? controller = Layout.LinkStatusUiController.Bind(
layout,
_bindings.Indicators.LinkStatus,
_bindings.Indicators.CurrentTime,
_bindings.Indicators.RequestLinkStatusPing,
strings,
() => CloseWindow(WindowNames.LinkStatus));
if (controller is null) return;
LinkStatusUiController = controller;
RegisterIndicatorDetailPanel(
RetailPanelCatalog.LinkStatus,
WindowNames.LinkStatus,
rootInfo,
layout.Root,
controller);
}
private void MountVitaePanel()
{
ElementInfo? rootInfo;
ImportedLayout? layout;
VitaeStrings strings;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
VitaeUiController.LayoutId,
VitaeUiController.RootId);
var resolver = new DatStringResolver(_bindings.Assets.Dats);
layout = rootInfo is null
? null
: LayoutImporter.Build(
rootInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
resolver.Resolve);
VitaeStrings fallback = VitaeStrings.English;
strings = new VitaeStrings(
resolver.Resolve(0x23000001u, 0x0142DDECu) ?? fallback.FullStrength,
resolver.Resolve(0x23000001u, 0x045D7665u, 0) ?? fallback.LostPrefix,
resolver.Resolve(0x23000001u, 0x045D7665u, 1) ?? fallback.LostSuffix,
resolver.Resolve(0x23000001u, 0x0DCD8C35u, 0) ?? fallback.RecoveryPrefix,
resolver.Resolve(0x23000001u, 0x0DCD8C35u, 1) ?? fallback.RecoverySuffix);
}
if (rootInfo is null || layout is null) return;
VitaeUiController? controller = Layout.VitaeUiController.Bind(
layout,
_bindings.Indicators.Spellbook,
_bindings.Indicators.Objects,
_bindings.Indicators.PlayerGuid,
strings,
() => CloseWindow(WindowNames.Vitae));
if (controller is null) return;
VitaeUiController = controller;
RegisterIndicatorDetailPanel(
RetailPanelCatalog.Vitae,
WindowNames.Vitae,
rootInfo,
layout.Root,
controller);
}
private void MountMiniGamePanel()
{
ElementInfo? rootInfo;
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
MiniGameUiController.LayoutId,
MiniGameUiController.RootId);
layout = rootInfo is null
? null
: LayoutImporter.Build(
rootInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
new DatStringResolver(_bindings.Assets.Dats).Resolve);
}
if (rootInfo is null || layout is null) return;
MiniGameUiController? controller = Layout.MiniGameUiController.Bind(
layout,
() => CloseWindow(WindowNames.MiniGame));
if (controller is null) return;
MiniGameUiController = controller;
RegisterIndicatorDetailPanel(
RetailPanelCatalog.MiniGame,
WindowNames.MiniGame,
rootInfo,
layout.Root,
controller);
}
private void RegisterIndicatorDetailPanel(
uint panelId,
string windowName,
ElementInfo rootInfo,
UiElement root,
IRetainedPanelController controller)
{
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = windowName,
Chrome = RetailWindowChrome.Imported,
Left = 18f,
Top = 18f,
Visible = false,
Resizable = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = controller,
});
_panelUi.RegisterMainPanel(
panelId,
windowName,
handle,
rootInfo.TryGetEffectiveBool(
RetailPanelUiController.RestorePreviousPropertyId,
out bool restorePrevious)
&& restorePrevious);
} }
private void MountIndicators() private void MountIndicators()

View file

@ -16,4 +16,7 @@ public static class WindowNames
public const string Indicators = "indicators"; public const string Indicators = "indicators";
public const string PositiveEffects = "effects-positive"; public const string PositiveEffects = "effects-positive";
public const string NegativeEffects = "effects-negative"; public const string NegativeEffects = "effects-negative";
public const string LinkStatus = "link-status";
public const string MiniGame = "mini-game";
public const string Vitae = "vitae";
} }

View file

@ -7,7 +7,9 @@ namespace AcDream.Core.Net;
/// </summary> /// </summary>
public readonly record struct LinkStatusSnapshot( public readonly record struct LinkStatusSnapshot(
bool Connected, bool Connected,
double SecondsSinceLastPacket) double SecondsSinceLastPacket,
double PacketLossPercentage = 0d,
double? RoundTripSeconds = null)
{ {
public static LinkStatusSnapshot Disconnected => new(false, 0d); public static LinkStatusSnapshot Disconnected => new(false, 0d);
} }

View file

@ -149,12 +149,9 @@ public static class GameEvents
// ── Pings / misc ──────────────────────────────────────────────────────── // ── Pings / misc ────────────────────────────────────────────────────────
/// <summary>0x01EA PingResponse: echoes the client ping id.</summary> /// <summary>0x01EA PingResponse has no payload; receipt is the acknowledgement.</summary>
public static uint? ParsePingResponse(ReadOnlySpan<byte> payload) public static bool ParsePingResponse(ReadOnlySpan<byte> payload)
{ => payload.IsEmpty;
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
// ── Spells / magic ────────────────────────────────────────────────────── // ── Spells / magic ──────────────────────────────────────────────────────

View file

@ -30,7 +30,7 @@ public static class SocialActions
// Queries // Queries
public const uint QueryHealthOpcode = 0x01BFu; // u32 targetGuid public const uint QueryHealthOpcode = 0x01BFu; // u32 targetGuid
public const uint QueryItemManaOpcode = 0x0263u; // u32 itemGuid; zero cancels public const uint QueryItemManaOpcode = 0x0263u; // u32 itemGuid; zero cancels
public const uint PingRequestOpcode = 0x01E9u; // u32 clientId public const uint PingRequestOpcode = 0x01E9u; // no payload
// Fellowship // Fellowship
public const uint FellowshipCreateOpcode = 0x00A2u; // string16L name, bool openness, bool shareXP public const uint FellowshipCreateOpcode = 0x00A2u; // string16L name, bool openness, bool shareXP
@ -71,14 +71,16 @@ public static class SocialActions
return body; return body;
} }
/// <summary>Keepalive ping — server echoes with PingResponse (0x01EA).</summary> /// <summary>
public static byte[] BuildPingRequest(uint seq, uint clientId) /// Request the empty PingResponse (0x01EA). Retail
/// <c>CM_Character::Event_RequestPing @ 0x006A19A0</c> sends no payload.
/// </summary>
public static byte[] BuildPingRequest(uint seq)
{ {
byte[] body = new byte[16]; byte[] body = new byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), PingRequestOpcode); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), PingRequestOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), clientId);
return body; return body;
} }

View file

@ -529,20 +529,49 @@ public sealed class WorldSession : IDisposable
CurrentState, CurrentState,
Volatile.Read(ref _lastInboundPacketTicks), Volatile.Read(ref _lastInboundPacketTicks),
Stopwatch.GetTimestamp(), Stopwatch.GetTimestamp(),
Stopwatch.Frequency); Stopwatch.Frequency,
PingRoundTripSeconds);
internal double? PingRoundTripSeconds
{
get
{
double value = BitConverter.Int64BitsToDouble(
Volatile.Read(ref _lastPingRoundTripBits));
return double.IsFinite(value) && value >= 0d ? value : null;
}
}
internal static LinkStatusSnapshot BuildLinkStatus( internal static LinkStatusSnapshot BuildLinkStatus(
State state, State state,
long lastInboundPacketTicks, long lastInboundPacketTicks,
long nowTicks, long nowTicks,
long frequency) long frequency,
double? roundTripSeconds = null,
double packetLossPercentage = 0d)
{ {
bool connected = state is not State.Disconnected and not State.Failed; bool connected = state is not State.Disconnected and not State.Failed;
if (!connected || frequency <= 0) if (!connected || frequency <= 0)
return LinkStatusSnapshot.Disconnected; return LinkStatusSnapshot.Disconnected;
long elapsed = Math.Max(0, nowTicks - lastInboundPacketTicks); long elapsed = Math.Max(0, nowTicks - lastInboundPacketTicks);
return new LinkStatusSnapshot(true, elapsed / (double)frequency); return new LinkStatusSnapshot(
true,
elapsed / (double)frequency,
packetLossPercentage,
roundTripSeconds);
}
private void RecordPingResponse(long nowTicks)
{
long requestTicks = Interlocked.Exchange(ref _lastPingRequestTicks, 0L);
if (requestTicks <= 0L || nowTicks < requestTicks || Stopwatch.Frequency <= 0)
return;
double elapsed = (nowTicks - requestTicks) / (double)Stopwatch.Frequency;
Volatile.Write(
ref _lastPingRoundTripBits,
BitConverter.DoubleToInt64Bits(elapsed));
} }
/// <summary>Movement sequence counters for outbound MoveToState/AutonomousPosition.</summary> /// <summary>Movement sequence counters for outbound MoveToState/AutonomousPosition.</summary>
@ -574,6 +603,8 @@ public sealed class WorldSession : IDisposable
private readonly NetClient _net; private readonly NetClient _net;
private long _lastInboundPacketTicks = Stopwatch.GetTimestamp(); private long _lastInboundPacketTicks = Stopwatch.GetTimestamp();
private long _lastPingRequestTicks;
private long _lastPingRoundTripBits = BitConverter.DoubleToInt64Bits(double.NaN);
private readonly IPEndPoint _loginEndpoint; private readonly IPEndPoint _loginEndpoint;
private readonly IPEndPoint _connectEndpoint; private readonly IPEndPoint _connectEndpoint;
private readonly FragmentAssembler _assembler = new(); private readonly FragmentAssembler _assembler = new();
@ -656,6 +687,11 @@ public sealed class WorldSession : IDisposable
var parsed = SetTurbineChatChannels.TryParse(e.Payload.Span); var parsed = SetTurbineChatChannels.TryParse(e.Payload.Span);
if (parsed is not null) TurbineChannelsReceived?.Invoke(parsed.Value); if (parsed is not null) TurbineChannelsReceived?.Invoke(parsed.Value);
}); });
GameEvents.Register(GameEventType.PingResponse, e =>
{
if (Messages.GameEvents.ParsePingResponse(e.Payload.Span))
RecordPingResponse(Stopwatch.GetTimestamp());
});
} }
/// <summary> /// <summary>
@ -1701,6 +1737,18 @@ public sealed class WorldSession : IDisposable
SendGameAction(body); SendGameAction(body);
} }
/// <summary>
/// Request the round-trip sample displayed by retail <c>gmLinkStatusUI</c>.
/// The response is an empty 0x01EA GameEvent, so the session retains the
/// monotonic send timestamp rather than putting an invented id on the wire.
/// </summary>
public void RequestLinkStatusPing()
{
Volatile.Write(ref _lastPingRequestTicks, Stopwatch.GetTimestamp());
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildPingRequest(seq));
}
/// <summary>Send retail TargetedMeleeAttack (0x0008).</summary> /// <summary>Send retail TargetedMeleeAttack (0x0008).</summary>
public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel) public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel)
{ {

View file

@ -143,6 +143,24 @@ public static class FixtureLoader
public static ElementInfo LoadIndicatorsInfos() public static ElementInfo LoadIndicatorsInfos()
=> LoadInfos("indicators_21000071.json"); => LoadInfos("indicators_21000071.json");
public static ImportedLayout LoadLinkStatus()
=> LayoutImporter.Build(LoadLinkStatusInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadLinkStatusInfos()
=> LoadInfos("link_status_2100001D.json");
public static ImportedLayout LoadVitae()
=> LayoutImporter.Build(LoadVitaeInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadVitaeInfos()
=> LoadInfos("vitae_21000020.json");
public static ImportedLayout LoadMiniGame()
=> LayoutImporter.Build(LoadMiniGameInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadMiniGameInfos()
=> LoadInfos("mini_game_2100001E.json");
// ── Shared loader ──────────────────────────────────────────────────────── // ── Shared loader ────────────────────────────────────────────────────────
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName) private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)

View file

@ -67,6 +67,8 @@ public sealed class IndicatorBarControllerTests
50u, 3u, 60f, 1u, StatModValue: 0.99f, Bucket: 4u)); 50u, 3u, 60f, 1u, StatModValue: 0.99f, Bucket: 4u));
Assert.True(vitae.Enabled); Assert.True(vitae.Enabled);
Assert.Equal(UiButtonStateMachine.Normal, vitae.ActiveRetailStateId); Assert.Equal(UiButtonStateMachine.Normal, vitae.ActiveRetailStateId);
vitae.OnEvent(new UiEvent(0, vitae, UiEventType.Click));
Assert.Equal(RetailPanelCatalog.Vitae, h.ToggledPanels[^1]);
h.Spellbook.OnEnchantmentRemoved(3u, 50u); h.Spellbook.OnEnchantmentRemoved(3u, 50u);
Assert.False(vitae.Enabled); Assert.False(vitae.Enabled);
@ -164,6 +166,8 @@ public sealed class IndicatorBarControllerTests
Assert.False(miniGame.Enabled); Assert.False(miniGame.Enabled);
controller.SetMiniGameActive(true); controller.SetMiniGameActive(true);
Assert.True(miniGame.Enabled); Assert.True(miniGame.Enabled);
miniGame.OnEvent(new UiEvent(0, miniGame, UiEventType.Click));
Assert.Equal(RetailPanelCatalog.MiniGame, h.ToggledPanels[^1]);
controller.SetMiniGameActive(false); controller.SetMiniGameActive(false);
Assert.False(miniGame.Enabled); Assert.False(miniGame.Enabled);
@ -171,6 +175,18 @@ public sealed class IndicatorBarControllerTests
Assert.Equal(1, h.EndSessionRequests); Assert.Equal(1, h.EndSessionRequests);
} }
[Fact]
public void LinkButton_DispatchesSharedLinkStatusPanel()
{
var h = CreateHarness();
using IndicatorBarController controller = h.Controller;
UiButton link = h.Button(IndicatorBarController.LinkButtonId);
link.OnEvent(new UiEvent(0, link, UiEventType.Click));
Assert.Equal([RetailPanelCatalog.LinkStatus], h.ToggledPanels);
}
private static Harness CreateHarness(int strength = 10) private static Harness CreateHarness(int strength = 10)
{ {
var table = SpellTable.LoadFromReader(new StringReader( var table = SpellTable.LoadFromReader(new StringReader(

View file

@ -0,0 +1,166 @@
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 IndicatorDetailPanelControllerTests
{
private const uint Player = 0x50000001u;
[Fact]
public void AuthoredFixtures_AreRetailMainPanelPages()
{
AssertPage(
FixtureLoader.LoadLinkStatusInfos(),
LinkStatusUiController.RootId,
0x1000001Du,
LinkStatusUiController.MainTextId,
LinkStatusUiController.CloseId);
AssertPage(
FixtureLoader.LoadVitaeInfos(),
VitaeUiController.RootId,
0x10000020u,
VitaeUiController.MainTextId,
VitaeUiController.CloseId);
AssertPage(
FixtureLoader.LoadMiniGameInfos(),
MiniGameUiController.RootId,
0x1000001Eu,
0x1000016Eu,
MiniGameUiController.CloseId);
Assert.Equal(8u, RetailPanelCatalog.LinkStatus);
Assert.Equal(9u, RetailPanelCatalog.MiniGame);
Assert.Equal(15u, RetailPanelCatalog.Vitae);
}
[Fact]
public void LinkStatus_RequestsPingOnShow_RefreshesText_AndRepeatsAfter120Seconds()
{
ImportedLayout layout = FixtureLoader.LoadLinkStatus();
double now = 0d;
int pings = 0;
int closes = 0;
LinkStatusSnapshot snapshot = new(
Connected: true,
SecondsSinceLastPacket: 0.25d,
PacketLossPercentage: 1.25d,
RoundTripSeconds: 0.123d);
using LinkStatusUiController controller = LinkStatusUiController.Bind(
layout,
() => snapshot,
() => now,
() => pings++,
LinkStatusStrings.English,
() => closes++)!;
controller.OnShown();
Assert.Equal(1, pings);
string text = VisibleText(layout, LinkStatusUiController.MainTextId);
Assert.Contains("Link Indicator", text, StringComparison.Ordinal);
Assert.Contains("1.25", text, StringComparison.Ordinal);
Assert.Contains("123", text, StringComparison.Ordinal);
controller.Tick();
now = 119d;
controller.Tick();
Assert.Equal(1, pings);
now = 124d;
controller.Tick();
Assert.Equal(2, pings);
UiButton close = Assert.IsType<UiButton>(layout.FindElement(LinkStatusUiController.CloseId));
close.OnEvent(new UiEvent(0, close, UiEventType.Click));
Assert.Equal(1, closes);
controller.OnHidden();
now = 300d;
controller.Tick();
Assert.Equal(2, pings);
}
[Fact]
public void Vitae_UsesDeathLevelPoolAndRetailThresholdFormula()
{
ImportedLayout layout = FixtureLoader.LoadVitae();
var spellbook = new Spellbook();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = Player });
objects.UpdateIntProperty(Player, VitaeUiController.DeathLevelProperty, 10);
objects.UpdateIntProperty(Player, VitaeUiController.VitaeCpPoolProperty, 100);
using VitaeUiController controller = VitaeUiController.Bind(
layout,
spellbook,
objects,
() => Player,
VitaeStrings.English)!;
Assert.Contains(
"full strength",
VisibleText(layout, VitaeUiController.MainTextId),
StringComparison.Ordinal);
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 666u,
LayerId: 1u,
Duration: -1f,
CasterGuid: Player,
StatModValue: 0.9f,
Bucket: 4u));
string text = VisibleText(layout, VitaeUiController.MainTextId);
Assert.Contains("lost 10 %", text, StringComparison.Ordinal);
Assert.Contains("earn 379 more experience", text, StringComparison.Ordinal);
Assert.Equal(479, VitaeUiController.VitaeCpPoolThreshold(0.9f, 10));
}
[Fact]
public void MiniGameFixture_BindsItsAuthoredCloseButton()
{
ImportedLayout layout = FixtureLoader.LoadMiniGame();
int closes = 0;
using MiniGameUiController controller = MiniGameUiController.Bind(
layout, () => closes++)!;
UiButton close = Assert.IsType<UiButton>(layout.FindElement(MiniGameUiController.CloseId));
close.OnEvent(new UiEvent(0, close, UiEventType.Click));
Assert.Equal(1, closes);
}
private static void AssertPage(
ElementInfo info,
uint rootId,
uint type,
uint contentId,
uint closeId)
{
Assert.Equal(rootId, info.Id);
Assert.Equal(type, info.Type);
Assert.Equal((300f, 362f), (info.Width, info.Height));
Assert.NotNull(Find(info, contentId));
Assert.NotNull(Find(info, closeId));
Assert.True(info.TryGetEffectiveBool(
RetailPanelUiController.RestorePreviousPropertyId,
out bool restorePrevious));
Assert.True(restorePrevious);
}
private static string VisibleText(ImportedLayout layout, uint id)
{
UiText text = Assert.IsType<UiText>(layout.FindElement(id));
return string.Join(' ', text.LinesProvider().Select(line => line.Text));
}
private static ElementInfo? Find(ElementInfo root, uint id)
{
if (root.Id == id) return root;
foreach (ElementInfo child in root.Children)
if (Find(child, id) is { } found) return found;
return null;
}
}

View file

@ -18,6 +18,8 @@ public sealed class RetailLayoutFixtureGenerator
{ {
(0x21000006u, "chat_21000006.json"), (0x21000006u, "chat_21000006.json"),
(0x21000016u, "toolbar_21000016.json"), (0x21000016u, "toolbar_21000016.json"),
(0x2100001Du, "link_status_2100001D.json"),
(0x21000020u, "vitae_21000020.json"),
(0x21000023u, "inventory_21000023.json"), (0x21000023u, "inventory_21000023.json"),
(0x21000024u, "paperdoll_21000024.json"), (0x21000024u, "paperdoll_21000024.json"),
(0x2100002Eu, "character_2100002E.json"), (0x2100002Eu, "character_2100002E.json"),
@ -118,6 +120,17 @@ public sealed class RetailLayoutFixtureGenerator
File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), effectsJson); File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), effectsJson);
} }
ElementInfo? miniGame = LayoutImporter.ImportInfos(
dats, MiniGameUiController.LayoutId, MiniGameUiController.RootId);
Assert.NotNull(miniGame);
File.WriteAllText(
Path.Combine(FixtureDirectory(), "mini_game_2100001E.json"),
JsonSerializer.Serialize(miniGame, new JsonSerializerOptions
{
IncludeFields = true,
WriteIndented = true,
}));
foreach ((uint rootId, string fileName) in new[] foreach ((uint rootId, string fileName) in new[]
{ {
(0x10000466u, (0x10000466u,

View file

@ -113,7 +113,7 @@ public sealed class RetailPanelUiControllerTests
} }
[Fact] [Fact]
public void RestorePreviousEffect_DoesNotReplaceMainPanelPlacement() public void IndicatorDetailPanel_SharesPlacementAndRestoresPreviousPanel()
{ {
var root = new UiRoot { Width = 1280f, Height = 720f }; var root = new UiRoot { Width = 1280f, Height = 720f };
RetailWindowHandle inventory = Mount(root, WindowNames.Inventory, 40f, 60f); RetailWindowHandle inventory = Mount(root, WindowNames.Inventory, 40f, 60f);
@ -122,13 +122,17 @@ public sealed class RetailPanelUiControllerTests
using var controller = Create(root); using var controller = Create(root);
controller.RegisterMainPanel(7u, WindowNames.Inventory, inventory); controller.RegisterMainPanel(7u, WindowNames.Inventory, inventory);
controller.RegisterMainPanel(11u, WindowNames.Character, character); controller.RegisterMainPanel(11u, WindowNames.Character, character);
controller.Register(4u, WindowNames.PositiveEffects, restorePrevious: true); controller.RegisterMainPanel(
4u,
WindowNames.PositiveEffects,
effect,
restorePrevious: true);
controller.SetPanelVisibility(7u, visible: true); controller.SetPanelVisibility(7u, visible: true);
inventory.MoveTo(280f, 120f); inventory.MoveTo(280f, 120f);
controller.SetPanelVisibility(4u, visible: true); controller.SetPanelVisibility(4u, visible: true);
Assert.Equal((900f, 30f), (effect.Left, effect.Top)); Assert.Equal((280f, 120f), (effect.Left, effect.Top));
Assert.False(inventory.IsVisible); Assert.False(inventory.IsVisible);
Assert.True(effect.IsVisible); Assert.True(effect.IsVisible);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -37,11 +37,14 @@ public sealed class SocialActionsTests
[Fact] [Fact]
public void BuildPingRequest_HasOpcode0x01E9() public void BuildPingRequest_HasOpcode0x01E9()
{ {
byte[] body = SocialActions.BuildPingRequest(seq: 1, clientId: 42); byte[] body = SocialActions.BuildPingRequest(seq: 1);
Assert.Equal(12, body.Length);
Assert.Equal(SocialActions.GameActionEnvelope,
BinaryPrimitives.ReadUInt32LittleEndian(body));
Assert.Equal(1u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
Assert.Equal(SocialActions.PingRequestOpcode, Assert.Equal(SocialActions.PingRequestOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(42u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
} }
[Fact] [Fact]

View file

@ -1,4 +1,6 @@
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using System.Net;
using Xunit; using Xunit;
namespace AcDream.Core.Net.Tests; namespace AcDream.Core.Net.Tests;
@ -43,4 +45,40 @@ public sealed class WorldSessionLinkStatusTests
Assert.True(snapshot.Connected); Assert.True(snapshot.Connected);
Assert.Equal(0d, snapshot.SecondsSinceLastPacket); Assert.Equal(0d, snapshot.SecondsSinceLastPacket);
} }
[Fact]
public void ConnectedSnapshot_CarriesPacketLossAndRoundTripTelemetry()
{
LinkStatusSnapshot snapshot = WorldSession.BuildLinkStatus(
WorldSession.State.InWorld,
lastInboundPacketTicks: 100,
nowTicks: 200,
frequency: 100,
roundTripSeconds: 0.125d,
packetLossPercentage: 2.5d);
Assert.Equal(2.5d, snapshot.PacketLossPercentage);
Assert.Equal(0.125d, snapshot.RoundTripSeconds);
}
[Fact]
public void RequestAndEmptyPingResponse_MeasureMonotonicRoundTrip()
{
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 65000));
byte[]? action = null;
session.GameActionCapture = bytes => action = bytes;
session.RequestLinkStatusPing();
session.GameEvents.Dispatch(new GameEventEnvelope(
PlayerGuid: 0u,
Sequence: 1u,
EventType: GameEventType.PingResponse,
Payload: ReadOnlyMemory<byte>.Empty));
Assert.NotNull(action);
Assert.Equal(12, action.Length);
Assert.NotNull(session.PingRoundTripSeconds);
Assert.True(session.PingRoundTripSeconds >= 0d);
}
} }