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

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

View file

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

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"),
(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,

View file

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

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]
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]

View file

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