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:
parent
52c529be86
commit
a96767ba6d
28 changed files with 6539 additions and 32 deletions
|
|
@ -61,6 +61,12 @@ back to inert generic DAT elements.
|
|||
`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.
|
||||
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`;
|
||||
`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
|
||||
Character Information; effects open their matching panels; crossed swords use
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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-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-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-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` |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 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.
|
||||
|
||||
---
|
||||
|
|
@ -22,7 +22,9 @@ active enchantment, then recall through the DAT-driven portal presentation.
|
|||
equipped caster endowment, scoped retail keys, spellbook/component book,
|
||||
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
|
||||
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,
|
||||
and `PassToChildren` Closed/Open chrome through real-DAT fixtures.
|
||||
3. **Pending user visual gate:** connected projectile/self-buff casts plus the
|
||||
|
|
|
|||
|
|
@ -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
|
||||
active only for the connected single-client magic
|
||||
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:
|
||||
spellbook tabs are authored stateful text controls (not buttons), and retail
|
||||
text starts with zero margins. Real-DAT fixtures now pin spellbook binding,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,17 @@ Named retail functions:
|
|||
- `gmUIElement_BurdenIndicator::Update @ 0x004E6CE0`
|
||||
- `LinkStatusHolder::GetConnectionStatus @ 0x00411380`
|
||||
- `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`
|
||||
- `gmGamePlayUI::RecvNotice_EndCharacterSession @ 0x004EBEA0`
|
||||
|
||||
|
|
@ -40,6 +51,11 @@ The installed retail LayoutDesc establishes the fixed child order and art:
|
|||
| 105 | `0x100000F3` | `0x10000004` | mini-game |
|
||||
| 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
|
||||
|
||||
### 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
|
||||
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
|
||||
|
||||
```text
|
||||
|
|
@ -109,6 +165,25 @@ On PlayerDesc or VitaeChanged:
|
|||
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
|
||||
|
||||
```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
|
||||
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
|
||||
|
||||
```text
|
||||
|
|
@ -159,8 +256,8 @@ CharacterLogOff and transport Disconnect packets.
|
|||
`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.
|
||||
- Official ACE was cross-checked for the empty PingResponse and the vitae-pool
|
||||
threshold expression; the named retail client remains the behavior oracle.
|
||||
- The Link Status and Vitae pages now use their authored DAT roots and localized
|
||||
strings. AP-110 retains only packet-loss averaging and mini-game gameplay for
|
||||
this slice; neither is replaced with an ad-hoc approximation.
|
||||
|
|
|
|||
|
|
@ -2320,6 +2320,7 @@ public sealed class GameWindow : IDisposable
|
|||
() => _liveSession?.LinkStatus
|
||||
?? AcDream.Core.Net.LinkStatusSnapshot.Disconnected,
|
||||
ClientTimerNow,
|
||||
() => _liveSession?.RequestLinkStatusPing(),
|
||||
() => _window?.Close()),
|
||||
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
|
||||
Objects,
|
||||
|
|
|
|||
|
|
@ -85,7 +85,10 @@ public sealed class IndicatorBarController : IRetainedPanelController
|
|||
|
||||
_helpful.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.PositiveEffects);
|
||||
_harmful.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.NegativeEffects);
|
||||
_link.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.LinkStatus);
|
||||
_vitae.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.Vitae);
|
||||
_burden.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.Character);
|
||||
_miniGame.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.MiniGame);
|
||||
_endCharacterSession.OnClick = bindings.RequestEndCharacterSession;
|
||||
|
||||
bindings.Spellbook.EnchantmentsChanged += UpdateEnchantments;
|
||||
|
|
|
|||
40
src/AcDream.App/UI/Layout/IndicatorDetailText.cs
Normal file
40
src/AcDream.App/UI/Layout/IndicatorDetailText.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
142
src/AcDream.App/UI/Layout/LinkStatusUiController.cs
Normal file
142
src/AcDream.App/UI/Layout/LinkStatusUiController.cs
Normal 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: ");
|
||||
}
|
||||
35
src/AcDream.App/UI/Layout/MiniGameUiController.cs
Normal file
35
src/AcDream.App/UI/Layout/MiniGameUiController.cs
Normal 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;
|
||||
}
|
||||
158
src/AcDream.App/UI/Layout/VitaeUiController.cs
Normal file
158
src/AcDream.App/UI/Layout/VitaeUiController.cs
Normal 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.");
|
||||
}
|
||||
|
|
@ -10,16 +10,22 @@ public static class RetailPanelCatalog
|
|||
public const uint PositiveEffects = 4u;
|
||||
public const uint NegativeEffects = 5u;
|
||||
public const uint Inventory = 7u;
|
||||
public const uint LinkStatus = 8u;
|
||||
public const uint MiniGame = 9u;
|
||||
public const uint Character = 11u;
|
||||
public const uint Magic = 13u;
|
||||
public const uint Vitae = 15u;
|
||||
|
||||
private static readonly (uint PanelId, string WindowName)[] Mounted =
|
||||
{
|
||||
(PositiveEffects, WindowNames.PositiveEffects),
|
||||
(NegativeEffects, WindowNames.NegativeEffects),
|
||||
(Inventory, WindowNames.Inventory),
|
||||
(LinkStatus, WindowNames.LinkStatus),
|
||||
(MiniGame, WindowNames.MiniGame),
|
||||
(Character, WindowNames.Character),
|
||||
(Magic, WindowNames.Spellbook),
|
||||
(Vitae, WindowNames.Vitae),
|
||||
};
|
||||
|
||||
private static readonly (uint PanelId, string WindowName)[] Toolbar =
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ public sealed record IndicatorRuntimeBindings(
|
|||
Func<int?> Strength,
|
||||
Func<LinkStatusSnapshot> LinkStatus,
|
||||
Func<double> CurrentTime,
|
||||
Action RequestLinkStatusPing,
|
||||
Action EndCharacterSession);
|
||||
|
||||
public sealed record ToolbarRuntimeBindings(
|
||||
|
|
@ -196,6 +197,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountCombat();
|
||||
MountSpellbook();
|
||||
MountEffects();
|
||||
MountIndicatorDetailPanels();
|
||||
MountIndicators();
|
||||
MountJumpPowerbar();
|
||||
MountDialogFactory();
|
||||
|
|
@ -245,6 +247,9 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public SpellbookWindowController? SpellbookWindowController { get; private set; }
|
||||
public EffectsUiController? PositiveEffectsController { 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 JumpPowerbarController? JumpPowerbarController { get; private set; }
|
||||
public RetailFpsController? FpsController { get; private set; }
|
||||
|
|
@ -274,6 +279,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
SpellcastingUiController?.Tick();
|
||||
PositiveEffectsController?.Tick();
|
||||
NegativeEffectsController?.Tick();
|
||||
LinkStatusUiController?.Tick();
|
||||
IndicatorBarController?.Tick();
|
||||
JumpPowerbarController?.Tick();
|
||||
SelectedObjectController?.Tick(deltaSeconds);
|
||||
|
|
@ -880,7 +886,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
if (positive) PositiveEffectsController = controller;
|
||||
else NegativeEffectsController = controller;
|
||||
UiElement root = layout.Root;
|
||||
RetailWindowFrame.Mount(
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
root,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
|
|
@ -898,10 +904,184 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
ContentClickThrough = false,
|
||||
Controller = controller,
|
||||
});
|
||||
_panelUi.Register(
|
||||
_panelUi.RegisterMainPanel(
|
||||
positive ? RetailPanelCatalog.PositiveEffects : RetailPanelCatalog.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()
|
||||
|
|
|
|||
|
|
@ -16,4 +16,7 @@ public static class WindowNames
|
|||
public const string Indicators = "indicators";
|
||||
public const string PositiveEffects = "effects-positive";
|
||||
public const string NegativeEffects = "effects-negative";
|
||||
public const string LinkStatus = "link-status";
|
||||
public const string MiniGame = "mini-game";
|
||||
public const string Vitae = "vitae";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ namespace AcDream.Core.Net;
|
|||
/// </summary>
|
||||
public readonly record struct LinkStatusSnapshot(
|
||||
bool Connected,
|
||||
double SecondsSinceLastPacket)
|
||||
double SecondsSinceLastPacket,
|
||||
double PacketLossPercentage = 0d,
|
||||
double? RoundTripSeconds = null)
|
||||
{
|
||||
public static LinkStatusSnapshot Disconnected => new(false, 0d);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,12 +149,9 @@ public static class GameEvents
|
|||
|
||||
// ── Pings / misc ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>0x01EA PingResponse: echoes the client ping id.</summary>
|
||||
public static uint? ParsePingResponse(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 4) return null;
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||
}
|
||||
/// <summary>0x01EA PingResponse has no payload; receipt is the acknowledgement.</summary>
|
||||
public static bool ParsePingResponse(ReadOnlySpan<byte> payload)
|
||||
=> payload.IsEmpty;
|
||||
|
||||
// ── Spells / magic ──────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ public static class SocialActions
|
|||
// Queries
|
||||
public const uint QueryHealthOpcode = 0x01BFu; // u32 targetGuid
|
||||
public const uint QueryItemManaOpcode = 0x0263u; // u32 itemGuid; zero cancels
|
||||
public const uint PingRequestOpcode = 0x01E9u; // u32 clientId
|
||||
public const uint PingRequestOpcode = 0x01E9u; // no payload
|
||||
|
||||
// Fellowship
|
||||
public const uint FellowshipCreateOpcode = 0x00A2u; // string16L name, bool openness, bool shareXP
|
||||
|
|
@ -71,14 +71,16 @@ public static class SocialActions
|
|||
return body;
|
||||
}
|
||||
|
||||
/// <summary>Keepalive ping — server echoes with PingResponse (0x01EA).</summary>
|
||||
public static byte[] BuildPingRequest(uint seq, uint clientId)
|
||||
/// <summary>
|
||||
/// 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.AsSpan(4), seq);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), PingRequestOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), clientId);
|
||||
return body;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -529,20 +529,49 @@ public sealed class WorldSession : IDisposable
|
|||
CurrentState,
|
||||
Volatile.Read(ref _lastInboundPacketTicks),
|
||||
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(
|
||||
State state,
|
||||
long lastInboundPacketTicks,
|
||||
long nowTicks,
|
||||
long frequency)
|
||||
long frequency,
|
||||
double? roundTripSeconds = null,
|
||||
double packetLossPercentage = 0d)
|
||||
{
|
||||
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);
|
||||
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>
|
||||
|
|
@ -574,6 +603,8 @@ public sealed class WorldSession : IDisposable
|
|||
|
||||
private readonly NetClient _net;
|
||||
private long _lastInboundPacketTicks = Stopwatch.GetTimestamp();
|
||||
private long _lastPingRequestTicks;
|
||||
private long _lastPingRoundTripBits = BitConverter.DoubleToInt64Bits(double.NaN);
|
||||
private readonly IPEndPoint _loginEndpoint;
|
||||
private readonly IPEndPoint _connectEndpoint;
|
||||
private readonly FragmentAssembler _assembler = new();
|
||||
|
|
@ -656,6 +687,11 @@ public sealed class WorldSession : IDisposable
|
|||
var parsed = SetTurbineChatChannels.TryParse(e.Payload.Span);
|
||||
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>
|
||||
|
|
@ -1701,6 +1737,18 @@ public sealed class WorldSession : IDisposable
|
|||
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>
|
||||
public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -143,6 +143,24 @@ public static class FixtureLoader
|
|||
public static ElementInfo LoadIndicatorsInfos()
|
||||
=> 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 ────────────────────────────────────────────────────────
|
||||
|
||||
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ public sealed class IndicatorBarControllerTests
|
|||
50u, 3u, 60f, 1u, StatModValue: 0.99f, Bucket: 4u));
|
||||
Assert.True(vitae.Enabled);
|
||||
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);
|
||||
Assert.False(vitae.Enabled);
|
||||
|
|
@ -164,6 +166,8 @@ public sealed class IndicatorBarControllerTests
|
|||
Assert.False(miniGame.Enabled);
|
||||
controller.SetMiniGameActive(true);
|
||||
Assert.True(miniGame.Enabled);
|
||||
miniGame.OnEvent(new UiEvent(0, miniGame, UiEventType.Click));
|
||||
Assert.Equal(RetailPanelCatalog.MiniGame, h.ToggledPanels[^1]);
|
||||
controller.SetMiniGameActive(false);
|
||||
Assert.False(miniGame.Enabled);
|
||||
|
||||
|
|
@ -171,6 +175,18 @@ public sealed class IndicatorBarControllerTests
|
|||
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)
|
||||
{
|
||||
var table = SpellTable.LoadFromReader(new StringReader(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
{
|
||||
(0x21000006u, "chat_21000006.json"),
|
||||
(0x21000016u, "toolbar_21000016.json"),
|
||||
(0x2100001Du, "link_status_2100001D.json"),
|
||||
(0x21000020u, "vitae_21000020.json"),
|
||||
(0x21000023u, "inventory_21000023.json"),
|
||||
(0x21000024u, "paperdoll_21000024.json"),
|
||||
(0x2100002Eu, "character_2100002E.json"),
|
||||
|
|
@ -118,6 +120,17 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
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[]
|
||||
{
|
||||
(0x10000466u,
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ public sealed class RetailPanelUiControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void RestorePreviousEffect_DoesNotReplaceMainPanelPlacement()
|
||||
public void IndicatorDetailPanel_SharesPlacementAndRestoresPreviousPanel()
|
||||
{
|
||||
var root = new UiRoot { Width = 1280f, Height = 720f };
|
||||
RetailWindowHandle inventory = Mount(root, WindowNames.Inventory, 40f, 60f);
|
||||
|
|
@ -122,13 +122,17 @@ public sealed class RetailPanelUiControllerTests
|
|||
using var controller = Create(root);
|
||||
controller.RegisterMainPanel(7u, WindowNames.Inventory, inventory);
|
||||
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);
|
||||
inventory.MoveTo(280f, 120f);
|
||||
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.True(effect.IsVisible);
|
||||
|
||||
|
|
|
|||
1059
tests/AcDream.App.Tests/UI/Layout/fixtures/link_status_2100001D.json
Normal file
1059
tests/AcDream.App.Tests/UI/Layout/fixtures/link_status_2100001D.json
Normal file
File diff suppressed because it is too large
Load diff
3402
tests/AcDream.App.Tests/UI/Layout/fixtures/mini_game_2100001E.json
Normal file
3402
tests/AcDream.App.Tests/UI/Layout/fixtures/mini_game_2100001E.json
Normal file
File diff suppressed because it is too large
Load diff
1059
tests/AcDream.App.Tests/UI/Layout/fixtures/vitae_21000020.json
Normal file
1059
tests/AcDream.App.Tests/UI/Layout/fixtures/vitae_21000020.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -37,11 +37,14 @@ public sealed class SocialActionsTests
|
|||
[Fact]
|
||||
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,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
||||
Assert.Equal(42u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using System.Net;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests;
|
||||
|
|
@ -43,4 +45,40 @@ public sealed class WorldSessionLinkStatusTests
|
|||
Assert.True(snapshot.Connected);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue