fix(ui): match retail spell bar controls

Place favorite-bar arrows by their authored sides, import rollover and pressed media through the shared scrollbar, and preserve manual offsets across passive refreshes. Carry the mixed-parent DAT anchor chain to a fixed 18-cell favorite viewport so overflow controls and the Cast button remain inside the retail-sized combat frame.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 07:01:01 +02:00
parent 02c29e67c8
commit 0134122c28
17 changed files with 424 additions and 59 deletions

View file

@ -230,24 +230,32 @@ public class DatWidgetFactoryTests
const uint IncrementId = 0x10000072u;
const uint DecrementSprite = 0x06004CDCu;
const uint IncrementSprite = 0x06004CDEu;
const uint DecrementRollover = 0x06004CDDu;
const uint IncrementRollover = 0x06004CDFu;
var decrement = new ElementInfo
{
Id = DecrementId, Type = 1u, Width = 23f, Height = 36f,
Id = DecrementId, Type = 1u, X = 63f, Width = 23f, Height = 36f,
};
decrement.States[UiStateInfo.DirectStateId] = new UiStateInfo
{
Id = UiStateInfo.DirectStateId,
Image = new UiImageMedia(DecrementSprite, 1),
};
decrement.StateMedia["Normal"] = (DecrementSprite, 1);
decrement.StateMedia["Normal_rollover"] = (DecrementRollover, 1);
decrement.StateMedia["Normal_pressed"] = (DecrementSprite, 1);
var increment = new ElementInfo
{
Id = IncrementId, Type = 1u, Width = 23f, Height = 36f,
Id = IncrementId, Type = 1u, X = 0f, Width = 23f, Height = 36f,
};
increment.States[UiStateInfo.DirectStateId] = new UiStateInfo
{
Id = UiStateInfo.DirectStateId,
Image = new UiImageMedia(IncrementSprite, 1),
};
increment.StateMedia["Normal"] = (IncrementSprite, 1);
increment.StateMedia["Normal_rollover"] = (IncrementRollover, 1);
increment.StateMedia["Normal_pressed"] = (IncrementSprite, 1);
var info = new ElementInfo
{
Type = 11u,
@ -271,8 +279,12 @@ public class DatWidgetFactoryTests
Assert.True(bar.HideWhenDisabled);
Assert.Equal(23f, bar.DecrementButtonExtent);
Assert.Equal(23f, bar.IncrementButtonExtent);
Assert.Equal(DecrementSprite, bar.UpSprite);
Assert.Equal(IncrementSprite, bar.DownSprite);
Assert.Equal(IncrementSprite, bar.UpSprite);
Assert.Equal(IncrementRollover, bar.UpRolloverSprite);
Assert.Equal(IncrementSprite, bar.UpPressedSprite);
Assert.Equal(DecrementSprite, bar.DownSprite);
Assert.Equal(DecrementRollover, bar.DownRolloverSprite);
Assert.Equal(DecrementSprite, bar.DownPressedSprite);
Assert.Equal(0u, bar.TrackSprite);
Assert.Equal(0u, bar.ThumbSprite);
}

View file

@ -7,6 +7,43 @@ public sealed class RetailWindowFrameTests
{
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
[Theory]
[InlineData(9, 459f, 288f)]
[InlineData(13, 587f, 416f)]
[InlineData(18, 747f, 576f)]
public void CombatRoot_FitsRequestedFavoriteSlotCapacity(
int visibleSlots,
float expectedRootWidth,
float expectedListWidth)
{
ImportedLayout combat = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
float width = RetailCombatLayout.FitFavoriteSlots(combat, visibleSlots);
Assert.Equal(expectedRootWidth, width);
Assert.Equal(expectedListWidth, combat.FindElement(
SpellcastingUiController.FavoriteListId)!.Width);
}
[Fact]
public void CombatRoot_EighteenSlotsKeepArrowsAndCastInsideFrame()
{
ImportedLayout combat = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
RetailCombatLayout.FitFavoriteSlots(combat);
UiElement group = combat.FindElement(0x100000AAu)!;
UiElement scrollbar = combat.FindElement(SpellcastingUiController.FavoriteScrollbarId)!;
UiElement cast = combat.FindElement(SpellcastingUiController.CastButtonId)!;
Assert.Equal(747f, combat.Root.Width);
Assert.Equal((40f, 622f), (group.Left, group.Width));
Assert.Equal(622f, scrollbar.Width);
Assert.Equal((662f, 75f), (cast.Left, cast.Width));
Assert.Equal(737f, cast.Left + cast.Width);
}
[Fact]
public void ImportedChrome_MountsRootWithoutASecondFrame()
{
@ -41,6 +78,30 @@ public sealed class RetailWindowFrameTests
Assert.Equal(40f, content.MinWidth);
}
[Fact]
public void ImportedChrome_CanPreserveFixedHudDesktopAnchors()
{
var root = new UiRoot { Width = 1920, Height = 1080 };
var content = new UiPanel { Width = 1730, Height = 90 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
content,
NoTex,
new RetailWindowFrame.Options
{
WindowName = "combat",
Chrome = RetailWindowChrome.Imported,
Left = 0f,
Top = 980f,
OuterAnchors = AnchorEdges.Left | AnchorEdges.Bottom,
});
Assert.Equal(
AnchorEdges.Left | AnchorEdges.Bottom,
handle.OuterFrame.Anchors);
}
[Fact]
public void NineSlice_UsesCroppedContentAndDatHeightConstraints()
{

View file

@ -16,7 +16,7 @@ public sealed class SpellcastingUiControllerTests
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
ApplyAnchors(layout.Root);
RetailCombatLayout.FitFavoriteSlots(layout);
var spellbook = new Spellbook();
spellbook.OnSpellLearned(42u, 1f);
spellbook.SetFavorite(0, 0, 42u);
@ -44,11 +44,13 @@ public sealed class SpellcastingUiControllerTests
Assert.True(scrollbar.HideWhenDisabled);
Assert.Equal(23f, scrollbar.DecrementButtonExtent);
Assert.Equal(23f, scrollbar.IncrementButtonExtent);
Assert.Equal(0x06004CDCu, scrollbar.UpSprite);
Assert.Equal(0x06004CDEu, scrollbar.DownSprite);
Assert.Equal(0x06004CDEu, scrollbar.UpSprite);
Assert.Equal(0x06004CDFu, scrollbar.UpRolloverSprite);
Assert.Equal(0x06004CDCu, scrollbar.DownSprite);
Assert.Equal(0x06004CDDu, scrollbar.DownRolloverSprite);
Assert.False(scrollbar.IsPresentationVisible);
Assert.Equal(439f, list.Width);
Assert.Equal(13, list.GetNumUIItems());
Assert.Equal(576f, list.Width);
Assert.Equal(18, list.GetNumUIItems());
UiCatalogSlot favorite = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
Assert.Equal(42u, favorite.EntryId);
@ -63,7 +65,7 @@ public sealed class SpellcastingUiControllerTests
Assert.Equal(i, slot.ShortcutNum);
Assert.Same(empty, slot.ActiveDigitArray());
}
for (int i = 9; i < 13; i++)
for (int i = 9; i < 18; i++)
{
Assert.Equal(0x06001A97u, list.GetItem(i)!.EmptySprite);
Assert.Equal(-1, list.GetItem(i)!.ShortcutNum);
@ -71,7 +73,7 @@ public sealed class SpellcastingUiControllerTests
var cast = Assert.IsType<UiButton>(
layout.FindElement(SpellcastingUiController.CastButtonId));
Assert.Equal((525f, 75f), (cast.Left, cast.Width));
Assert.Equal((662f, 75f), (cast.Left, cast.Width));
Assert.Equal(3, cast.FaceSegmentCount);
Assert.Equal(
[
@ -87,7 +89,7 @@ public sealed class SpellcastingUiControllerTests
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
ApplyAnchors(layout.Root);
RetailCombatLayout.FitFavoriteSlots(layout);
var spellbook = new Spellbook();
for (uint spellId = 1u; spellId <= 20u; spellId++)
{
@ -105,7 +107,7 @@ public sealed class SpellcastingUiControllerTests
UiScrollbar scrollbar = Descendants(group).OfType<UiScrollbar>().Single(
candidate => candidate.DatElementId == SpellcastingUiController.FavoriteScrollbarId);
Assert.True(scrollbar.IsPresentationVisible);
Assert.Equal(201, list.Scroll.MaxScroll);
Assert.Equal(64, list.Scroll.MaxScroll);
Assert.True(scrollbar.OnEvent(new UiEvent(
0u, scrollbar, UiEventType.MouseDown, Data1: (int)scrollbar.Width - 1)));
@ -117,6 +119,14 @@ public sealed class SpellcastingUiControllerTests
controller.AddFavorite(20u);
Assert.Equal(list.Scroll.MaxScroll, list.Scroll.ScrollY);
Assert.True(scrollbar.OnEvent(new UiEvent(
0u, scrollbar, UiEventType.MouseDown, Data1: 0)));
int manualOffset = list.Scroll.ScrollY;
objects.AddOrUpdate(new ClientObject { ObjectId = 99u, Name = "Unrelated" });
controller.Tick();
Assert.Equal(manualOffset, list.Scroll.ScrollY);
}
[Fact]

View file

@ -157,6 +157,47 @@ public class UiScrollbarTests
Assert.Equal(64, model.ScrollY);
}
[Fact]
public void HorizontalModel_UsesIndependentRolloverAndPressedArrowMedia()
{
var root = new UiRoot { Width = 300f, Height = 100f };
var model = new UiScrollable
{
ContentHeight = 640,
ViewHeight = 320,
LineHeight = 32,
};
var bar = new UiScrollbar
{
Width = 160f,
Height = 36f,
Horizontal = true,
Model = model,
UpSprite = 1u,
UpRolloverSprite = 2u,
UpPressedSprite = 3u,
DownSprite = 4u,
DownRolloverSprite = 5u,
DownPressedSprite = 6u,
};
root.AddChild(bar);
root.OnMouseMove(5, 10);
Assert.Equal(2u, bar.ActiveStartSpriteForTest);
Assert.Equal(4u, bar.ActiveEndSpriteForTest);
// Moving between two regions of the same procedural scrollbar must
// refresh its sub-control hover, not wait for a whole-widget leave.
root.OnMouseMove(155, 10);
Assert.Equal(1u, bar.ActiveStartSpriteForTest);
Assert.Equal(5u, bar.ActiveEndSpriteForTest);
root.OnMouseDown(UiMouseButton.Left, 155, 10);
Assert.Equal(6u, bar.ActiveEndSpriteForTest);
root.OnMouseUp(UiMouseButton.Left, 155, 10);
Assert.Equal(5u, bar.ActiveEndSpriteForTest);
}
[Fact]
public void ModelWithoutOverflow_IsDisabledAndHideDisabledSuppressesPresentation()
{