feat(ui): port retail gameplay indicator bar

Promote all seven LayoutDesc 0x21000071 controls to retained buttons, drive link quality, effects, Vitae, and burden from live state, and route Character Information plus end-session confirmation through the shared UI owners. Keep network timing in WorldSession and pin retail thresholds, flash cadence, authored states, and action routing with focused conformance tests.

Release build and all 5,807 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:02:44 +02:00
parent 06016014bc
commit 52c529be86
15 changed files with 857 additions and 204 deletions

View file

@ -1,102 +0,0 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.UI.Layout;
public sealed class EffectsIndicatorControllerTests
{
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
[Fact]
public void AuthoredFixture_BuildsRetailIndicatorButtons()
{
ElementInfo info = FixtureLoader.LoadIndicatorsInfos();
Assert.Equal(0x10000610u, info.Id);
Assert.Equal(150f, info.Width);
Assert.Equal(30f, info.Height);
ImportedLayout layout = LayoutImporter.Build(info, NoTex, datFont: null);
Assert.IsType<UiButton>(layout.FindElement(EffectsIndicatorController.HelpfulButtonId));
Assert.IsType<UiButton>(layout.FindElement(EffectsIndicatorController.HarmfulButtonId));
}
[Fact]
public void Enchantments_GhostAndEnableMatchingIndicator_ThenClickCorrectPanel()
{
var table = SpellTable.LoadFromReader(new StringReader(
"Spell ID,Name,Flags [Hex]\n42,Boon,0x4\n43,Bane,0x0\n"));
var spellbook = new Spellbook(table);
ImportedLayout layout = BuildSyntheticLayout();
var toggled = new List<uint>();
using EffectsIndicatorController controller = EffectsIndicatorController.Bind(
layout, spellbook, toggled.Add)!;
UiButton helpful = Assert.IsType<UiButton>(
layout.FindElement(EffectsIndicatorController.HelpfulButtonId));
UiButton harmful = Assert.IsType<UiButton>(
layout.FindElement(EffectsIndicatorController.HarmfulButtonId));
Assert.False(helpful.Enabled);
Assert.False(harmful.Enabled);
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 10u, 60f, 1u, Bucket: 4u, SpellCategory: 42u));
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
43u, 11u, 60f, 2u, Bucket: 8u, SpellCategory: 43u));
Assert.False(helpful.Enabled);
Assert.False(harmful.Enabled);
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 1u, 60f, 1u, Bucket: 1u, SpellCategory: 99u, PowerLevel: 1u));
Assert.True(helpful.Enabled);
Assert.False(harmful.Enabled);
helpful.OnEvent(new UiEvent(0, helpful, UiEventType.Click));
Assert.Equal([RetailPanelCatalog.PositiveEffects], toggled);
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
43u, 1u, 60f, 2u, Bucket: 2u, SpellCategory: 99u, PowerLevel: 7u));
// The harmful spell wins the shared category in the effects list, but
// retail's raw registry counters keep both indicator types enabled.
Assert.True(helpful.Enabled);
Assert.True(harmful.Enabled);
harmful.OnEvent(new UiEvent(0, harmful, UiEventType.Click));
Assert.Equal(
[RetailPanelCatalog.PositiveEffects, RetailPanelCatalog.NegativeEffects],
toggled);
spellbook.OnEnchantmentRemoved(1u, 42u);
Assert.False(helpful.Enabled);
Assert.True(harmful.Enabled);
}
private static ImportedLayout BuildSyntheticLayout()
{
var root = new ElementInfo { Id = 1u, Type = 3u, Width = 150f, Height = 30f };
var helpful = ButtonInfo(EffectsIndicatorController.HelpfulButtonId);
var harmful = ButtonInfo(EffectsIndicatorController.HarmfulButtonId);
return LayoutImporter.BuildFromInfos(root, [helpful, harmful], NoTex, null);
}
private static ElementInfo ButtonInfo(uint id)
{
var info = new ElementInfo
{
Id = id,
Type = EffectsIndicatorController.RetailClassId,
Width = 20f,
Height = 20f,
DefaultStateId = UiButtonStateMachine.Normal,
};
info.States[UiButtonStateMachine.Normal] = new UiStateInfo
{
Id = UiButtonStateMachine.Normal,
Name = "Normal",
};
info.States[UiButtonStateMachine.Ghosted] = new UiStateInfo
{
Id = UiButtonStateMachine.Ghosted,
Name = "Ghosted",
};
return info;
}
}

View file

@ -0,0 +1,216 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.UI.Layout;
public sealed class IndicatorBarControllerTests
{
private const uint Player = 0x50000001u;
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
[Fact]
public void AuthoredFixture_BuildsAllSevenRetailIndicatorButtons()
{
ElementInfo info = FixtureLoader.LoadIndicatorsInfos();
Assert.Equal(0x10000610u, info.Id);
Assert.Equal(150f, info.Width);
Assert.Equal(30f, info.Height);
ImportedLayout layout = LayoutImporter.Build(info, NoTex, datFont: null);
uint[] ids =
[
IndicatorBarController.LinkButtonId,
IndicatorBarController.HelpfulButtonId,
IndicatorBarController.HarmfulButtonId,
IndicatorBarController.VitaeButtonId,
IndicatorBarController.BurdenButtonId,
IndicatorBarController.MiniGameButtonId,
IndicatorBarController.EndCharacterSessionButtonId,
];
foreach (uint id in ids)
Assert.IsType<UiButton>(layout.FindElement(id));
}
[Fact]
public void Enchantments_UpdateHelpfulHarmfulAndVitae_AndDispatchPanels()
{
var h = CreateHarness();
using IndicatorBarController controller = h.Controller;
UiButton helpful = h.Button(IndicatorBarController.HelpfulButtonId);
UiButton harmful = h.Button(IndicatorBarController.HarmfulButtonId);
UiButton vitae = h.Button(IndicatorBarController.VitaeButtonId);
Assert.False(helpful.Enabled);
Assert.False(harmful.Enabled);
Assert.False(vitae.Enabled);
h.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 1u, 60f, 1u, Bucket: 1u, SpellCategory: 99u));
h.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
43u, 2u, 60f, 2u, Bucket: 2u, SpellCategory: 99u));
Assert.True(helpful.Enabled);
Assert.True(harmful.Enabled);
helpful.OnEvent(new UiEvent(0, helpful, UiEventType.Click));
harmful.OnEvent(new UiEvent(0, harmful, UiEventType.Click));
Assert.Equal(
[RetailPanelCatalog.PositiveEffects, RetailPanelCatalog.NegativeEffects],
h.ToggledPanels);
h.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
50u, 3u, 60f, 1u, StatModValue: 1f, Bucket: 4u));
Assert.False(vitae.Enabled);
h.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
50u, 3u, 60f, 1u, StatModValue: 0.99f, Bucket: 4u));
Assert.True(vitae.Enabled);
Assert.Equal(UiButtonStateMachine.Normal, vitae.ActiveRetailStateId);
h.Spellbook.OnEnchantmentRemoved(3u, 50u);
Assert.False(vitae.Enabled);
}
[Theory]
[InlineData(1499, IndicatorBarController.UnencumberedState)]
[InlineData(1500, IndicatorBarController.EncumberedState)]
[InlineData(2999, IndicatorBarController.EncumberedState)]
[InlineData(3000, IndicatorBarController.HeavilyEncumberedState)]
public void Burden_UsesExactRetailLoadBoundaries(int burden, uint expectedState)
{
var h = CreateHarness(strength: 10);
using IndicatorBarController controller = h.Controller;
h.Objects.UpdateIntProperty(Player, 5u, burden);
UiButton button = h.Button(IndicatorBarController.BurdenButtonId);
Assert.Equal(expectedState, button.ActiveRetailStateId);
}
[Fact]
public void Burden_ClickOpensCharacterInformationPanel()
{
var h = CreateHarness();
using IndicatorBarController controller = h.Controller;
UiButton burden = h.Button(IndicatorBarController.BurdenButtonId);
burden.OnEvent(new UiEvent(0, burden, UiEventType.Click));
Assert.Equal([RetailPanelCatalog.Character], h.ToggledPanels);
}
[Theory]
[InlineData(true, 4.999, IndicatorBarController.ConnectionGoodState)]
[InlineData(true, 5.0, IndicatorBarController.ConnectionUncertainState)]
[InlineData(true, 19.999, IndicatorBarController.ConnectionUncertainState)]
[InlineData(true, 20.0, IndicatorBarController.ConnectionBadState)]
[InlineData(true, 39.999, IndicatorBarController.ConnectionBadState)]
[InlineData(true, 40.0, IndicatorBarController.ConnectionDisconnectedState)]
[InlineData(false, 0.0, IndicatorBarController.ConnectionDisconnectedState)]
public void LinkStatus_UsesRetailThresholds(
bool connected,
double age,
uint expectedState)
{
var h = CreateHarness();
using IndicatorBarController controller = h.Controller;
h.LinkStatus = new LinkStatusSnapshot(connected, age);
h.Time = 4.0;
controller.Tick();
Assert.Equal(
expectedState,
h.Button(IndicatorBarController.LinkButtonId).ActiveRetailStateId);
}
[Fact]
public void LinkStatus_UpdatesAtFourSeconds_AndBadFlashesEveryPointSevenFive()
{
var h = CreateHarness();
using IndicatorBarController controller = h.Controller;
UiButton link = h.Button(IndicatorBarController.LinkButtonId);
h.LinkStatus = new LinkStatusSnapshot(true, 20d);
h.Time = 3.999;
controller.Tick();
Assert.Equal(IndicatorBarController.ConnectionGoodState, link.ActiveRetailStateId);
h.Time = 4.0;
controller.Tick();
Assert.Equal(IndicatorBarController.ConnectionBadState, link.ActiveRetailStateId);
h.Time = 4.749;
controller.Tick();
Assert.Equal(IndicatorBarController.ConnectionBadState, link.ActiveRetailStateId);
h.Time = 4.75;
controller.Tick();
Assert.Equal(IndicatorBarController.ConnectionUncertainState, link.ActiveRetailStateId);
h.Time = 5.5;
controller.Tick();
Assert.Equal(IndicatorBarController.ConnectionBadState, link.ActiveRetailStateId);
}
[Fact]
public void MiniGameNoticesAndEndSessionActionUseAuthoredButtons()
{
var h = CreateHarness();
using IndicatorBarController controller = h.Controller;
UiButton miniGame = h.Button(IndicatorBarController.MiniGameButtonId);
UiButton endSession = h.Button(IndicatorBarController.EndCharacterSessionButtonId);
Assert.False(miniGame.Enabled);
controller.SetMiniGameActive(true);
Assert.True(miniGame.Enabled);
controller.SetMiniGameActive(false);
Assert.False(miniGame.Enabled);
endSession.OnEvent(new UiEvent(0, endSession, UiEventType.Click));
Assert.Equal(1, h.EndSessionRequests);
}
private static Harness CreateHarness(int strength = 10)
{
var table = SpellTable.LoadFromReader(new StringReader(
"Spell ID,Name,Flags [Hex]\n42,Boon,0x4\n43,Bane,0x0\n50,Vitae,0x0\n"));
var spellbook = new Spellbook(table);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = Player });
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadIndicatorsInfos(), NoTex, datFont: null);
var harness = new Harness(layout, spellbook, objects, strength);
harness.Controller = IndicatorBarController.Bind(
layout,
new IndicatorBarBindings(
spellbook,
objects,
() => Player,
() => strength,
() => harness.LinkStatus,
() => harness.Time,
harness.ToggledPanels.Add,
() => harness.EndSessionRequests++))!;
return harness;
}
private sealed class Harness(
ImportedLayout layout,
Spellbook spellbook,
ClientObjectTable objects,
int strength)
{
public ImportedLayout Layout { get; } = layout;
public Spellbook Spellbook { get; } = spellbook;
public ClientObjectTable Objects { get; } = objects;
public int Strength { get; } = strength;
public List<uint> ToggledPanels { get; } = [];
public double Time { get; set; }
public LinkStatusSnapshot LinkStatus { get; set; } = new(true, 0d);
public int EndSessionRequests { get; set; }
public IndicatorBarController Controller { get; set; } = null!;
public UiButton Button(uint id) => Assert.IsType<UiButton>(Layout.FindElement(id));
}
}

View file

@ -23,7 +23,7 @@ public sealed class RetailLayoutFixtureGenerator
(0x2100002Eu, "character_2100002E.json"),
(SpellbookWindowController.LayoutId, "spellbook_21000034.json"),
(0x2100006Cu, "vitals_2100006C.json"),
(EffectsIndicatorController.LayoutId, "indicators_21000071.json"),
(IndicatorBarController.LayoutId, "indicators_21000071.json"),
(0x21000072u, "powerbar_21000072.json"),
(0x21000073u, "combat_21000073.json"),
(0x21000074u, "radar_21000074.json"),

View file

@ -0,0 +1,46 @@
using AcDream.Core.Net;
using Xunit;
namespace AcDream.Core.Net.Tests;
public sealed class WorldSessionLinkStatusTests
{
[Theory]
[InlineData(WorldSession.State.Disconnected)]
[InlineData(WorldSession.State.Failed)]
public void DisconnectedStates_DoNotPublishPacketAge(WorldSession.State state)
{
LinkStatusSnapshot snapshot = WorldSession.BuildLinkStatus(
state, lastInboundPacketTicks: 100, nowTicks: 900, frequency: 100);
Assert.False(snapshot.Connected);
Assert.Equal(0d, snapshot.SecondsSinceLastPacket);
}
[Theory]
[InlineData(WorldSession.State.Handshaking)]
[InlineData(WorldSession.State.InCharacterSelect)]
[InlineData(WorldSession.State.EnteringWorld)]
[InlineData(WorldSession.State.InWorld)]
public void ConnectedStates_PublishMonotonicPacketAge(WorldSession.State state)
{
LinkStatusSnapshot snapshot = WorldSession.BuildLinkStatus(
state, lastInboundPacketTicks: 100, nowTicks: 850, frequency: 100);
Assert.True(snapshot.Connected);
Assert.Equal(7.5d, snapshot.SecondsSinceLastPacket);
}
[Fact]
public void ClockRegression_ClampsPacketAgeToZero()
{
LinkStatusSnapshot snapshot = WorldSession.BuildLinkStatus(
WorldSession.State.InWorld,
lastInboundPacketTicks: 200,
nowTicks: 100,
frequency: 100);
Assert.True(snapshot.Connected);
Assert.Equal(0d, snapshot.SecondsSinceLastPacket);
}
}