Swap ChatWindowController's imported main-chat LayoutDesc from the wrong 0x21000006 (an unrelated layout whose root and 800px resize bar appear nowhere in the EoR gameplay UI) to retail's ACTUAL main chat window, 0x2100006F (window root 0x10000600, authored 410x100 — confirmed by a direct DAT dump, found in dats.Local not dats.Portal). Every downstream compensation that existed only to paper over the wrong import is deleted: the hand-cropped 490px content width, the dropped 800px resize bar, the 9px transcript patch, the orphan-sibling pruning, the max/min-vs-scrollbar overlap shift, and the scrollbar top-reclaim. The window now mounts with RetailWindowChrome.Imported (0x2100006F's own 8 border/corner elements are its complete chrome) instead of the universal nine-slice wrapper. LayoutImporter/DatWidgetFactory gain a Type-9 (UIElement_Resizebar) case: UiResizeGrip decodes retail's exact four-bool BorderLocation algorithm (0x2A=bottom/0x2B=left/0x2C=right/0x2D=top, UIElement_Resizebar::StartMouseResizing @0x0046B7E0) into a ResizeEdges bitmask. A direct DAT dump established the true shape: only 7 of the 8 grip-position ids are Type 9 — the straight top-EDGE strip (0x1000069C) is a Type-2 Dragbar (move handle), not a Resizebar, because the main window has no title bar. UiRoot now gives a directly-hit grip's own edges priority over its generic proximity heuristic, and a directly-hit move handle the same priority over ambient proximity — so the plain top strip moves the window while its two corner grips resize it including the Y axis, and all 4 edges + 4 corners work everywhere else. This also fixes the reported "no diagonal cursor at corners" (CursorFeedbackController's existing RetailCursorCatalog cursor ids already matched the DAT exactly; they just never received a genuine diagonal edge combination) and "cannot grow in Y from the bottom-right corner" (the old NineSlice+crop mount's indirection is gone; the Imported mount uses the DAT's real minH=100/maxH=2000/minW=300/maxW=2000 directly). The 8 cosmetic "_Locked" border-art twins default hidden (register row AP-185 — retail's UiLocked-driven art swap between the two skins is not ported; UiRoot.UiLocked continues to gate the underlying interaction correctly either way). The 4 chat-window-1..4 indicator buttons import generically (visible, inert) for CH6b to wire. The two hand-drawn translucent-black tints on the transcript/input are removed now that their parent panels draw their own authored background sprites. Filed #366 (chat window's new-unseen-text indicator 0x1000048C is swallowed by UiText.ConsumesDatChildren, pre-existing and out of scope). Corrected the research doc's "all eight grips" claim against the direct DAT dump. Full Release suite: 12,317 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
814 lines
35 KiB
C#
814 lines
35 KiB
C#
using System.Numerics;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
public class DatWidgetFactoryTests
|
|
{
|
|
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
|
|
|
|
// ── Test 1: Type 7 → UiMeter ─────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Type7_Meter_MakesUiMeter()
|
|
{
|
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 7, Width = 150, Height = 16 }, NoTex, null);
|
|
Assert.IsType<UiMeter>(e);
|
|
}
|
|
|
|
[Fact]
|
|
public void RetailRadarClass_MakesUiRadar()
|
|
{
|
|
var e = DatWidgetFactory.Create(
|
|
new ElementInfo { Type = UiRadar.RetailClassId, Width = 120, Height = 140 },
|
|
NoTex,
|
|
null);
|
|
|
|
Assert.IsType<UiRadar>(e);
|
|
}
|
|
|
|
// ── Test 2: Unknown type → UiDatElement fallback ─────────────────────────
|
|
|
|
[Fact]
|
|
public void UnknownType_FallsBackToGeneric()
|
|
{
|
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null);
|
|
Assert.IsType<UiDatElement>(e);
|
|
}
|
|
|
|
[Fact]
|
|
public void Type2_Dragbar_IsWindowMoveHandle_AndClaimsThePointer()
|
|
{
|
|
// Retail UIElement_Dragbar (element class 2, Register @ 0x0046C840). It must
|
|
// opt back out of UiDatElement's decoration ClickThrough so the press reaches
|
|
// it and starts the window move.
|
|
var e = DatWidgetFactory.Create(
|
|
new ElementInfo { Type = 2, Width = 600, Height = 5 }, NoTex, null);
|
|
|
|
Assert.IsType<UiDatElement>(e);
|
|
Assert.True(e.WindowMoveHandle);
|
|
Assert.False(e.ClickThrough);
|
|
}
|
|
|
|
// ── Type 9 → UiResizeGrip (retail UIElement_Resizebar) ───────────────────
|
|
// Campaign CH slice CH6a: LayoutDesc 0x2100006F's main chat window authors
|
|
// 7 live Type-9 grips (corners + 3 straight edges — the top edge is a
|
|
// separate Type-2 Dragbar move handle, not a grip) via property bools
|
|
// 0x2A=bottom/0x2B=left/0x2C=right/0x2D=top. Real element ids + their
|
|
// decoded bools, confirmed against the installed DAT:
|
|
// 0x1000069B (TL) bottom=F left=T right=F top=T -> UpperLeft
|
|
// 0x1000069D (TR) bottom=F left=F right=T top=T -> UpperRight
|
|
// 0x1000069E (L) bottom=F left=T right=F top=F -> Left
|
|
// 0x1000069F (BL) bottom=T left=T right=F top=F -> LowerLeft
|
|
// 0x100006A0 (B) bottom=T left=F right=F top=F -> Bottom
|
|
// 0x100006A1 (BR) bottom=T left=F right=T top=F -> LowerRight
|
|
// 0x100006A2 (R) bottom=F left=F right=T top=F -> Right
|
|
|
|
[Fact]
|
|
public void Type9_Resizebar_NoBoolsSet_DecodesToNoneEdges()
|
|
{
|
|
var e = DatWidgetFactory.Create(GripInfo(), NoTex, null);
|
|
var grip = Assert.IsType<UiResizeGrip>(e);
|
|
Assert.Equal(UiResizeGrip.Border.None, grip.BorderLocation);
|
|
Assert.Equal(ResizeEdges.None, grip.Edges);
|
|
}
|
|
|
|
[Theory]
|
|
// (bottom, left, right, top) -> (Border, Edges) — the 8 authored grip shapes
|
|
// (edges set exactly one bool; corners set exactly two adjacent bools).
|
|
[InlineData(false, false, false, true, UiResizeGrip.Border.Top, ResizeEdges.Top)]
|
|
[InlineData(true, false, false, false, UiResizeGrip.Border.Bottom, ResizeEdges.Bottom)]
|
|
[InlineData(false, true, false, false, UiResizeGrip.Border.Left, ResizeEdges.Left)]
|
|
[InlineData(false, false, true, false, UiResizeGrip.Border.Right, ResizeEdges.Right)]
|
|
[InlineData(false, true, false, true, UiResizeGrip.Border.UpperLeft, ResizeEdges.Left | ResizeEdges.Top)]
|
|
[InlineData(false, false, true, true, UiResizeGrip.Border.UpperRight, ResizeEdges.Right | ResizeEdges.Top)]
|
|
[InlineData(true, true, false, false, UiResizeGrip.Border.LowerLeft, ResizeEdges.Left | ResizeEdges.Bottom)]
|
|
[InlineData(true, false, true, false, UiResizeGrip.Border.LowerRight, ResizeEdges.Right | ResizeEdges.Bottom)]
|
|
public void Type9_Resizebar_DecodesEachOfTheEightAuthoredGrips(
|
|
bool bottom, bool left, bool right, bool top,
|
|
UiResizeGrip.Border expectedBorder, ResizeEdges expectedEdges)
|
|
{
|
|
var e = DatWidgetFactory.Create(GripInfo(bottom, left, right, top), NoTex, null);
|
|
var grip = Assert.IsType<UiResizeGrip>(e);
|
|
Assert.Equal(expectedBorder, grip.BorderLocation);
|
|
Assert.Equal(expectedEdges, grip.Edges);
|
|
}
|
|
|
|
[Fact]
|
|
public void Type9_Resizebar_IsNotClickThrough_SoItCanCaptureTheResizeDrag()
|
|
{
|
|
// A grip is a precise hit region — it must NOT default to UiDatElement's
|
|
// decoration ClickThrough=true, or UiRoot's hit-test would skip straight
|
|
// past it to whatever's behind.
|
|
var e = DatWidgetFactory.Create(GripInfo(bottom: true), NoTex, null);
|
|
var grip = Assert.IsType<UiResizeGrip>(e);
|
|
Assert.False(grip.ClickThrough);
|
|
}
|
|
|
|
[Fact]
|
|
public void DecodeBorderLocation_MatchesRetailBranchOrder_RightBeatsLeftBeatsTopBeatsBottom()
|
|
{
|
|
// UIElement_Resizebar::StartMouseResizing @0x0046B7E0's if/else-if chain —
|
|
// retail's authored data never sets more than 2 bools, but the branch
|
|
// ORDER is still part of the port: right wins over left, and within a
|
|
// branch top wins over bottom.
|
|
Assert.Equal(
|
|
UiResizeGrip.Border.UpperRight,
|
|
UiResizeGrip.DecodeBorderLocation(bottom: true, left: true, right: true, top: true));
|
|
Assert.Equal(
|
|
UiResizeGrip.Border.UpperLeft,
|
|
UiResizeGrip.DecodeBorderLocation(bottom: true, left: true, right: false, top: true));
|
|
Assert.Equal(
|
|
UiResizeGrip.Border.Top,
|
|
UiResizeGrip.DecodeBorderLocation(bottom: true, left: false, right: false, top: true));
|
|
}
|
|
|
|
private static ElementInfo GripInfo(bool bottom = false, bool left = false, bool right = false, bool top = false)
|
|
{
|
|
var info = new ElementInfo { Id = 0x1000069Bu, Type = 9, Width = 5, Height = 5 };
|
|
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
if (bottom) state.Properties.Values[0x2Au] = Bool(true);
|
|
if (left) state.Properties.Values[0x2Bu] = Bool(true);
|
|
if (right) state.Properties.Values[0x2Cu] = Bool(true);
|
|
if (top) state.Properties.Values[0x2Du] = Bool(true);
|
|
info.States[UiStateInfo.DirectStateId] = state;
|
|
return info;
|
|
}
|
|
|
|
// ── Test 3: Type 12 → UiText (behavioral text widget) ────────────────────
|
|
|
|
[Fact]
|
|
public void Type12_Text_MakesUiText()
|
|
{
|
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null);
|
|
var text = Assert.IsType<UiText>(e);
|
|
Assert.False(text.Selectable);
|
|
Assert.True(text.ClickThrough);
|
|
Assert.False(text.AcceptsFocus);
|
|
Assert.False(text.CapturesPointerDrag);
|
|
}
|
|
|
|
[Fact]
|
|
public void Type12_EditableProperty_MakesUiFieldInPlace()
|
|
{
|
|
var info = TextInfo(
|
|
(0x16u, Bool(true)),
|
|
(0x20u, Bool(true)),
|
|
(0x27u, Bool(true)),
|
|
(0x1Eu, Integer(80)));
|
|
|
|
var field = Assert.IsType<UiField>(DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
Assert.Equal(info.Id, field.ElementId);
|
|
Assert.True(field.OneLine);
|
|
Assert.True(field.Selectable);
|
|
Assert.Equal(80, field.MaxCharacters);
|
|
}
|
|
|
|
[Fact]
|
|
public void Type12_SelectableProperty_MakesSelectableUiText()
|
|
{
|
|
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
|
|
TextInfo((0x27u, Bool(true))),
|
|
NoTex,
|
|
null));
|
|
|
|
Assert.True(text.Selectable);
|
|
Assert.False(text.ClickThrough);
|
|
Assert.True(text.AcceptsFocus);
|
|
Assert.True(text.CapturesPointerDrag);
|
|
}
|
|
|
|
// ── Test 4: Rect + anchors set from ElementInfo ───────────────────────────
|
|
|
|
/// <summary>
|
|
/// A Type-3 element with X=5,Y=21,W=150,H=16, Left=1,Top=1,Right=1 should have
|
|
/// its rect + anchors copied onto the returned widget.
|
|
/// Per UIElement::UpdateForParentSizeChange @0x00462640:
|
|
/// Left=1 → AnchorEdges.Left (near-pin); Top=1 → AnchorEdges.Top;
|
|
/// Right=1 → AnchorEdges.Right (stretch / track parent right); Bottom=0 → neither.
|
|
/// Combined: Left | Top | Right.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RectAndAnchors_SetFromElementInfo()
|
|
{
|
|
var info = new ElementInfo
|
|
{
|
|
Type = 3,
|
|
X = 5, Y = 21,
|
|
Width = 150, Height = 16,
|
|
Left = 1, Top = 1,
|
|
Right = 1, Bottom = 0,
|
|
};
|
|
var e = DatWidgetFactory.Create(info, NoTex, null)!;
|
|
Assert.Equal(5f, e.Left);
|
|
Assert.Equal(21f, e.Top);
|
|
Assert.Equal(150f, e.Width);
|
|
Assert.Equal(16f, e.Height);
|
|
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors);
|
|
}
|
|
|
|
[Fact]
|
|
public void ImportedDescendant_PreservesExactRawEdgePolicy()
|
|
{
|
|
var info = new ElementInfo
|
|
{
|
|
Type = 3,
|
|
X = 10,
|
|
Y = 20,
|
|
Width = 30,
|
|
Height = 40,
|
|
Left = 4,
|
|
Top = 3,
|
|
Right = 1,
|
|
Bottom = 0,
|
|
OriginalParentWidth = 100,
|
|
OriginalParentHeight = 200,
|
|
HasOriginalParentSize = true,
|
|
};
|
|
|
|
var element = DatWidgetFactory.Create(info, NoTex, null)!;
|
|
var policy = Assert.IsType<UiLayoutPolicy>(element.LayoutPolicy);
|
|
|
|
Assert.Equal(4u, policy.LeftMode);
|
|
Assert.Equal(3u, policy.TopMode);
|
|
Assert.Equal(1u, policy.RightMode);
|
|
Assert.Equal(0u, policy.BottomMode);
|
|
Assert.Equal(new UiPixelRect(0, 0, 99, 199), policy.OriginalParent);
|
|
}
|
|
|
|
[Fact]
|
|
public void ImportedRoot_HasNoRawEdgePolicy()
|
|
{
|
|
var element = DatWidgetFactory.Create(
|
|
new ElementInfo { Type = 3, Width = 100, Height = 200 },
|
|
NoTex,
|
|
null)!;
|
|
|
|
Assert.Null(element.LayoutPolicy);
|
|
}
|
|
|
|
[Fact]
|
|
public void Create_PropagatesStateCursors()
|
|
{
|
|
var info = new ElementInfo { Type = 3 };
|
|
info.StateCursors["Drag_rollover_accept"] = new UiCursorMedia(0x06008888u, 7, 8);
|
|
|
|
var e = DatWidgetFactory.Create(info, NoTex, null)!;
|
|
|
|
Assert.Equal(new UiCursorMedia(0x06008888u, 7, 8),
|
|
e.CursorForState("Drag_rollover_accept", allowFallback: false));
|
|
}
|
|
|
|
// ── Test 5: ReadOrder propagated to ZOrder ───────────────────────────────
|
|
|
|
[Fact]
|
|
public void Create_PropagatesReadOrderToZOrder()
|
|
{
|
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, ReadOrder = 7 }, NoTex, null);
|
|
Assert.Equal(7, e!.ZOrder);
|
|
}
|
|
|
|
// ── Test G1a: Type 12 always produces UiText (with or without own sprites) ──
|
|
|
|
[Fact]
|
|
public void DatWidgetFactory_Type12_AlwaysMakesUiText()
|
|
{
|
|
var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16,
|
|
StateMedia = { ["Normal"] = (0x00001234u, 1) } };
|
|
Assert.IsType<UiText>(DatWidgetFactory.Create(withMedia, NoTex, null));
|
|
Assert.IsType<UiText>(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null));
|
|
}
|
|
|
|
// ── Test 5c: Type 1 → UiButton ──────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Type1_Button_MakesUiButton()
|
|
{
|
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex, null);
|
|
Assert.IsType<UiButton>(e);
|
|
}
|
|
|
|
// ── Test 5b: Type 11 → UiScrollbar ──────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Type11_Scrollbar_MakesUiScrollbar()
|
|
{
|
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null);
|
|
Assert.IsType<UiScrollbar>(e);
|
|
}
|
|
|
|
[Fact]
|
|
public void Type11_HorizontalScrollbar_usesDirectRetailTrackAndThumbChildren()
|
|
{
|
|
const uint Track = 0x06004CF6u;
|
|
const uint Thumb = 0x06005DC3u;
|
|
var info = new ElementInfo { Type = 11, Id = 0x100001A4u, Width = 90, Height = 14 };
|
|
var track = new ElementInfo { Id = 4u, Type = 3u, Width = 90, Height = 14 };
|
|
track.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
|
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(Track, 1) };
|
|
var thumb = new ElementInfo { Id = 1u, Type = 3u, Width = 16, Height = 14 };
|
|
thumb.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
|
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(Thumb, 1) };
|
|
info.Children.Add(track);
|
|
info.Children.Add(thumb);
|
|
|
|
var bar = Assert.IsType<UiScrollbar>(DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
Assert.True(bar.Horizontal);
|
|
Assert.Equal(Track, bar.TrackSprite);
|
|
Assert.Equal(Thumb, bar.ThumbSprite);
|
|
Assert.Equal(0u, bar.UpSprite);
|
|
Assert.Equal(0u, bar.DownSprite);
|
|
}
|
|
|
|
[Fact]
|
|
public void Type11_HorizontalScrollbar_ImportsAuthoredArrowButtonsAndHideDisabled()
|
|
{
|
|
const uint DecrementId = 0x10000071u;
|
|
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, 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, 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,
|
|
Id = SpellcastingUiController.FavoriteScrollbarId,
|
|
Width = 685f,
|
|
Height = 36f,
|
|
Children = [decrement, increment],
|
|
};
|
|
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
state.Properties.Values[0x77u] = new UiPropertyValue
|
|
{ Kind = UiPropertyKind.Enum, UnsignedValue = IncrementId };
|
|
state.Properties.Values[0x78u] = new UiPropertyValue
|
|
{ Kind = UiPropertyKind.Enum, UnsignedValue = DecrementId };
|
|
state.Properties.Values[0x79u] = Bool(true);
|
|
info.States[UiStateInfo.DirectStateId] = state;
|
|
|
|
var bar = Assert.IsType<UiScrollbar>(
|
|
DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
Assert.True(bar.Horizontal);
|
|
Assert.True(bar.HideWhenDisabled);
|
|
Assert.Equal(23f, bar.DecrementButtonExtent);
|
|
Assert.Equal(23f, bar.IncrementButtonExtent);
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public void RetailToolbarFixture_buildsEditableStackEntry_andAuthoredHorizontalSlider()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadToolbar();
|
|
|
|
var entry = Assert.IsType<UiField>(layout.FindElement(0x100001A3u));
|
|
Assert.True(entry.RightAligned);
|
|
var bar = Assert.IsType<UiScrollbar>(layout.FindElement(0x100001A4u));
|
|
Assert.True(bar.Horizontal);
|
|
Assert.Equal(0x06004CF6u, bar.TrackSprite);
|
|
Assert.Equal(0x06005DC3u, bar.ThumbSprite);
|
|
}
|
|
|
|
// ── Test 5e: Type 3 is NOT registered — chrome/containers stay generic ────
|
|
//
|
|
// Retail Type 3 = UIElement_Field, but acdream's Type-3 dat elements (vitals/chat
|
|
// bevel chrome + the transcript/input container panels) are inert sprite-bearing
|
|
// chrome, not editable fields. They stay on the UiDatElement fallback so their
|
|
// sprites render and they gain no spurious focus/edit affordance. The one true
|
|
// editable field (the chat input, 0x10000016) resolves to Type 12 and is
|
|
// controller-placed as a UiField. Register Type 3 → UiField only when a window
|
|
// carries a factory-built editable Type-3 field.
|
|
|
|
[Fact]
|
|
public void Type3_NotRegistered_FallsBackToGeneric()
|
|
{
|
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null);
|
|
Assert.IsType<UiDatElement>(e);
|
|
}
|
|
|
|
// ── Test 5d: Type 6 → UiMenu ─────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Type6_Menu_MakesUiMenu()
|
|
{
|
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null);
|
|
Assert.IsType<UiMenu>(e);
|
|
}
|
|
|
|
// ── Test 7: Type 0x10000031 → UiItemList ────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Create_buildsUiItemList_forItemListClassId()
|
|
{
|
|
var info = new AcDream.App.UI.Layout.ElementInfo { Id = 0x100001A7u, Type = 0x10000031u, Width = 32, Height = 32 };
|
|
var w = AcDream.App.UI.Layout.DatWidgetFactory.Create(info, _ => (0u, 0, 0), null);
|
|
Assert.IsType<AcDream.App.UI.UiItemList>(w);
|
|
}
|
|
|
|
// ── Test M1: Single-image meter (toolbar selected-object meters) ────────
|
|
//
|
|
// The toolbar health/mana meters (0x100001A1 / 0x100001A2) use a DIFFERENT
|
|
// shape from the vitals 3-slice meters: the back-track sprite lives on the
|
|
// meter ELEMENT's own DirectState ("" key), and there is exactly ONE Type-3
|
|
// child whose own DirectState ("" key) carries the fill sprite. That child
|
|
// has no image grandchildren, so SliceIds would return all-zero — the new
|
|
// Count==1 branch reads the StateMedia entries directly instead.
|
|
// The sprites go in the TILE slot (Back/FrontTile), NOT the cap slot: DrawMode=Normal
|
|
// tiles at native width across the full bar geometry (UIElement_Meter::DrawChildren),
|
|
// so the back spans all 140px and the fill clips to 140*fraction for any native width.
|
|
// Back/FrontLeft + Back/FrontRight must be 0 (no caps on a single-image bar).
|
|
|
|
[Fact]
|
|
public void BuildMeter_SingleImageShape_ReadsDirectStateFromElementAndFillChild()
|
|
{
|
|
const uint BackFile = 0x0600193Eu; // health back-track (from toolbar dump)
|
|
const uint FillFile = 0x0600193Fu; // health fill (from toolbar dump)
|
|
|
|
// Meter element: Type 7, own DirectState = back-track sprite.
|
|
var meter = new ElementInfo { Type = 7, Id = 0x100001A1u, Width = 140, Height = 31 };
|
|
meter.StateMedia[""] = (BackFile, 1);
|
|
|
|
// Single Type-3 fill container: own DirectState = fill sprite, no grandchildren.
|
|
var fillContainer = new ElementInfo { Type = 3, ReadOrder = 1 };
|
|
fillContainer.StateMedia[""] = (FillFile, 1);
|
|
meter.Children.Add(fillContainer);
|
|
|
|
var e = DatWidgetFactory.Create(meter, NoTex, null);
|
|
|
|
var m = Assert.IsType<UiMeter>(e);
|
|
// Back-track on the meter element's own DirectState, fill on the single child —
|
|
// both in the TILE slot so they tile across the full 140px bar (DrawMode=Normal).
|
|
Assert.Equal(BackFile, m.BackTile);
|
|
Assert.Equal(0u, m.BackLeft);
|
|
Assert.Equal(0u, m.BackRight);
|
|
Assert.Equal(FillFile, m.FrontTile);
|
|
Assert.Equal(0u, m.FrontLeft);
|
|
Assert.Equal(0u, m.FrontRight);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildMeter_StatefulSingleImageShape_MapsNumericFillStatesAndHidesDirectRange()
|
|
{
|
|
const uint TrackFile = 0x06004D0Bu;
|
|
const uint JumpState = 0x10000042u;
|
|
const uint JumpFile = 0x06001354u;
|
|
const uint RangeFile = 0x0600715Eu;
|
|
|
|
var meter = new ElementInfo { Type = 7, Id = 0x10000034u, Width = 600, Height = 15 };
|
|
meter.StateMedia[""] = (TrackFile, 1);
|
|
|
|
var fills = new ElementInfo { Type = 3, Id = 2u, ReadOrder = 2 };
|
|
fills.States[JumpState] = new UiStateInfo { Id = JumpState, Name = "JumpMode" };
|
|
fills.StateMedia["JumpMode"] = (JumpFile, 1);
|
|
meter.Children.Add(fills);
|
|
|
|
var range = new ElementInfo { Type = 3, Id = 0x100005EEu, ReadOrder = 1 };
|
|
range.StateMedia[""] = (RangeFile, 1);
|
|
meter.Children.Add(range);
|
|
|
|
var result = Assert.IsType<UiMeter>(DatWidgetFactory.Create(meter, NoTex, null));
|
|
|
|
Assert.Equal(TrackFile, result.BackTile);
|
|
Assert.Equal(0u, result.FrontTile);
|
|
Assert.True(result.TrySetRetailState(JumpState));
|
|
Assert.Equal(JumpState, result.ActiveRetailStateId);
|
|
Assert.Equal(JumpFile, result.FrontTile);
|
|
Assert.NotEqual(RangeFile, result.BackTile);
|
|
Assert.NotEqual(RangeFile, result.FrontTile);
|
|
}
|
|
|
|
// ── Test 6: Meter slice extraction (the important one) ───────────────────
|
|
|
|
/// <summary>
|
|
/// A meter (Type 7) whose two Type-3 containers each carry 3 image children
|
|
/// (ordered by X, bearing a DirectState "" sprite), plus the front container
|
|
/// has a fourth expand-overlay child with ONLY a named "ShowDetail" state —
|
|
/// that overlay must be excluded from the slice count.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MeterSliceExtraction_ReadsGrandchildImageIds_IgnoresOverlay()
|
|
{
|
|
// Slice ids sourced from format doc §11 — real health-bar ids.
|
|
const uint BackL = 0x0600747Eu, BackT = 0x0600747Fu, BackR = 0x06007480u;
|
|
const uint FrontL = 0x06007481u, FrontT = 0x06007482u, FrontR = 0x06007483u;
|
|
const uint OverlayFile = 0x06007490u;
|
|
|
|
// Back container (ReadOrder 0 — drawn first / behind)
|
|
var backChild = new ElementInfo { Type = 3, ReadOrder = 0 };
|
|
backChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (BackL, 1) } });
|
|
backChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (BackT, 1) } });
|
|
backChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (BackR, 1) } });
|
|
|
|
// Front container (ReadOrder 1 — drawn on top)
|
|
var frontChild = new ElementInfo { Type = 3, ReadOrder = 1 };
|
|
frontChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (FrontL, 1) } });
|
|
frontChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (FrontT, 1) } });
|
|
frontChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (FrontR, 1) } });
|
|
// Expand-detail overlay: named state only — NO DirectState "" — must be ignored.
|
|
frontChild.Children.Add(new ElementInfo
|
|
{
|
|
X = 0,
|
|
StateMedia = { ["ShowDetail"] = (OverlayFile, 3) }
|
|
});
|
|
|
|
var meter = new ElementInfo { Type = 7, Width = 150, Height = 16 };
|
|
meter.Children.Add(backChild);
|
|
meter.Children.Add(frontChild);
|
|
|
|
var e = DatWidgetFactory.Create(meter, NoTex, null);
|
|
|
|
var m = Assert.IsType<UiMeter>(e);
|
|
Assert.Equal(BackL, m.BackLeft);
|
|
Assert.Equal(BackT, m.BackTile);
|
|
Assert.Equal(BackR, m.BackRight);
|
|
Assert.Equal(FrontL, m.FrontLeft);
|
|
Assert.Equal(FrontT, m.FrontTile);
|
|
Assert.Equal(FrontR, m.FrontRight);
|
|
// Overlay (ShowDetail-only, no DirectState "") must not leak into any slice slot.
|
|
Assert.NotEqual(OverlayFile, m.FrontRight);
|
|
Assert.NotEqual(OverlayFile, m.FrontTile);
|
|
}
|
|
|
|
// ── Justification build-time application (new for importer Fix A) ────────
|
|
|
|
/// <summary>
|
|
/// A Type-12 text element with HJustify=Center (the default) must produce a
|
|
/// UiText with Centered=true and RightAligned=false at build time.
|
|
/// This proves BuildText applies the dat's HJustify at construction without a
|
|
/// controller binding step.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildText_HJustifyCenter_SetsCentered()
|
|
{
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Center };
|
|
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
|
Assert.True(t.Centered);
|
|
Assert.False(t.RightAligned);
|
|
Assert.Equal(VJustify.Center, t.VerticalJustify);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A Type-12 text element with HJustify=Right must produce a UiText with
|
|
/// Centered=false and RightAligned=true at build time.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildText_HJustifyRight_SetsRightAligned()
|
|
{
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Right };
|
|
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
|
Assert.False(t.Centered);
|
|
Assert.True(t.RightAligned);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A Type-12 text element with HJustify=Left must produce a UiText with
|
|
/// Centered=false and RightAligned=false at build time.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildText_HJustifyLeft_SetsNeitherCenteredNorRight()
|
|
{
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Left };
|
|
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
|
Assert.False(t.Centered);
|
|
Assert.False(t.RightAligned);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A Type-12 text element with VJustify=Top must produce a UiText with
|
|
/// VerticalJustify=Top at build time.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildText_VJustifyTop_SetsVerticalJustifyTop()
|
|
{
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 55, VJustify = VJustify.Top };
|
|
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
|
Assert.Equal(VJustify.Top, t.VerticalJustify);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A controller override: build-time sets Centered=true (HJustify=Center), then
|
|
/// a controller sets Centered=false afterward. The controller's override wins —
|
|
/// this is the backward-compatibility guarantee.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildText_ControllerOverrideWinsAfterBuild()
|
|
{
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Center };
|
|
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
|
Assert.True(t.Centered); // build-time value
|
|
t.Centered = false; // controller override
|
|
Assert.False(t.Centered); // override wins
|
|
}
|
|
|
|
// ── DefaultColor from dat FontColor (importer Fix B) ─────────────────────
|
|
|
|
/// <summary>
|
|
/// When ElementInfo.FontColor is set (dat carried 0x1B ColorBaseProperty),
|
|
/// DatWidgetFactory.BuildText must copy it onto UiText.DefaultColor.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildText_FontColorPresent_SetsDefaultColor()
|
|
{
|
|
var gold = new Vector4(1f, 0.82f, 0.36f, 1f);
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = gold };
|
|
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
|
Assert.Equal(gold, t.DefaultColor);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildText_RetailConstructorDefaultsToZeroMargins()
|
|
{
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 20 };
|
|
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
Assert.Equal(0f, text.Padding);
|
|
Assert.False(text.OneLine);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildText_AuthoredLineTracksStateFontColor()
|
|
{
|
|
var info = new ElementInfo
|
|
{
|
|
Type = 12,
|
|
Width = 100,
|
|
Height = 20,
|
|
DefaultStateId = RetailUiStateIds.Closed,
|
|
DefaultStateName = "Closed",
|
|
};
|
|
var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
direct.Properties.Values[0x17u] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.StringInfo,
|
|
StringInfoValue = new UiStringInfoValue(0, 1, 2, 0, 1, 0),
|
|
};
|
|
var closed = new UiStateInfo { Id = RetailUiStateIds.Closed, Name = "Closed" };
|
|
closed.Properties.Values[0x1Bu] = Color(127, 127, 127);
|
|
var open = new UiStateInfo { Id = RetailUiStateIds.Open, Name = "Open" };
|
|
open.Properties.Values[0x1Bu] = Color(204, 204, 204);
|
|
info.States[UiStateInfo.DirectStateId] = direct;
|
|
info.States[RetailUiStateIds.Closed] = closed;
|
|
info.States[RetailUiStateIds.Open] = open;
|
|
|
|
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
|
|
info, NoTex, null, stringResolve: _ => "Spells"));
|
|
Assert.Equal(127f / 255f, text.LinesProvider()[0].Color.X, 5);
|
|
|
|
Assert.True(text.TrySetRetailState(RetailUiStateIds.Open));
|
|
|
|
Assert.Equal(204f / 255f, text.LinesProvider()[0].Color.X, 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void HorizontalScrollbar_PreservesNestedCombatMeterFillSprite()
|
|
{
|
|
var track = new ElementInfo { Id = 4, Type = 3, Width = 507, Height = 14 };
|
|
track.StateMedia[""] = (0x06001919u, 1);
|
|
track.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
|
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x06001919u, 1) };
|
|
var thumb = new ElementInfo { Id = 1, Type = 1, Width = 12, Height = 14 };
|
|
thumb.StateMedia[""] = (0x06001923u, 1);
|
|
thumb.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
|
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x06001923u, 1) };
|
|
var fill = new ElementInfo { Id = 2, Type = 3, Width = 507, Height = 14 };
|
|
fill.StateMedia[""] = (0x06001200u, 1);
|
|
fill.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
|
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x06001200u, 1) };
|
|
var range = new ElementInfo
|
|
{
|
|
Id = 0x100005EFu,
|
|
Type = 3,
|
|
X = 63,
|
|
Width = 407,
|
|
Height = 14,
|
|
OriginalParentWidth = 507,
|
|
OriginalParentHeight = 14,
|
|
HasOriginalParentSize = true,
|
|
};
|
|
range.StateMedia[""] = (0x0600715Eu, 1);
|
|
range.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
|
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x0600715Eu, 1) };
|
|
var meter = new ElementInfo
|
|
{
|
|
Id = CombatUiController.PowerControlId + 1,
|
|
Type = 7,
|
|
Width = 507,
|
|
Height = 14,
|
|
Children = [fill, range],
|
|
};
|
|
var meterState = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
meterState.Properties.Values[0x6Fu] = new UiPropertyValue
|
|
{ Kind = UiPropertyKind.Enum, UnsignedValue = 1u };
|
|
meter.States[UiStateInfo.DirectStateId] = meterState;
|
|
var info = new ElementInfo
|
|
{
|
|
Id = CombatUiController.PowerControlId,
|
|
Type = 11,
|
|
Width = 507,
|
|
Height = 14,
|
|
Children = [track, meter, thumb],
|
|
};
|
|
|
|
var bar = Assert.IsType<UiScrollbar>(
|
|
DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
Assert.True(bar.Horizontal);
|
|
Assert.Equal(0x06001919u, bar.TrackSprite);
|
|
Assert.Equal(0x06001923u, bar.ThumbSprite);
|
|
Assert.Equal(0x06001200u, bar.ScalarFillSprite);
|
|
Assert.Equal(0x0600715Eu, bar.ScalarRangeSprite);
|
|
Assert.NotNull(bar.ScalarRangeLayoutPolicy);
|
|
Assert.False(bar.ScalarFillFromRight);
|
|
}
|
|
|
|
private static ElementInfo TextInfo(params (uint Id, UiPropertyValue Value)[] properties)
|
|
{
|
|
var info = new ElementInfo { Id = 0x10000016u, Type = 12, Width = 100, Height = 20 };
|
|
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
foreach (var property in properties)
|
|
state.Properties.Values[property.Id] = property.Value;
|
|
info.States[UiStateInfo.DirectStateId] = state;
|
|
return info;
|
|
}
|
|
|
|
private static UiPropertyValue Bool(bool value)
|
|
=> new() { Kind = UiPropertyKind.Bool, BoolValue = value };
|
|
|
|
private static UiPropertyValue Integer(int value)
|
|
=> new() { Kind = UiPropertyKind.Integer, IntegerValue = value };
|
|
|
|
private static UiPropertyValue Color(byte red, byte green, byte blue)
|
|
=> new()
|
|
{
|
|
Kind = UiPropertyKind.Color,
|
|
ColorValue = new UiColorValue(blue, green, red, 255),
|
|
};
|
|
|
|
/// <summary>
|
|
/// When ElementInfo.FontColor is null (dat carried no 0x1B), DefaultColor must
|
|
/// stay at white (Vector4.One) — the backward-compatible default.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildText_FontColorAbsent_DefaultColorIsWhite()
|
|
{
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = null };
|
|
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
|
Assert.Equal(Vector4.One, t.DefaultColor);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Backward-compat guarantee: a controller that sets LinesProvider with an
|
|
/// explicit per-line color AFTER build is unaffected by DefaultColor.
|
|
/// The controller's per-line color is stored in the Line record, not DefaultColor,
|
|
/// so DefaultColor changing from dat does not touch existing controller bindings.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildText_ControllerExplicitLineColor_UnaffectedByDefaultColor()
|
|
{
|
|
// Dat sets a parchment color.
|
|
var parchment = new Vector4(0.92f, 0.90f, 0.82f, 1f);
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = parchment };
|
|
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
// Controller overrides with an explicit gold color in the Line record.
|
|
var gold = new Vector4(1f, 0.82f, 0.36f, 1f);
|
|
t.LinesProvider = () => new[] { new UiText.Line("text", gold) };
|
|
|
|
// The line's color is gold (controller wins), NOT the dat parchment.
|
|
Assert.Equal(gold, t.LinesProvider()[0].Color);
|
|
// DefaultColor still holds the dat parchment (unmodified by the controller binding).
|
|
Assert.Equal(parchment, t.DefaultColor);
|
|
}
|
|
}
|