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

@ -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;

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.");
}