Four of the five owner-reported chat deltas (2026-08-24), each traced to
its retail mechanism:
1. Missing gold separator left of the input: the chat input authors two
1px Type-3 rail CHILDREN (0x10000017 at X=0, 0x10000018
right-anchored) whose only media is Normal_focussed (0x06004D67,
live-DAT probed). UiField consumes its DAT children, so the rails
were swallowed and never drawn. The factory now folds them into the
field, which draws both while focused.
2. Button says "General", retail says "Gen": the talk button's short
caption comes from per-target ID_Chat_ChatTargetMenu* strings
(HandleSelection @0x004cd540, StringTable 0x23000001 via
compute_str_hash — recovered from the raw binary after BN elided the
ids into name-hash globals). Authored set: Chat/Tell/Fell/Pat/Mon/
Vas/Alg/Gen/Trade/LFG/RP/Soc/Olt. Menu rows + squelch/tell specials
resolve from the same table (ID_Chat_TellTo*); production resolves
through DatStringResolver, fallbacks ARE the authored EoR English.
ChatStringsLiveDatTests pins the whole set against the installed DAT.
3. Channel button stayed green while the popup was open: retail's
pressed face is the momentary physical press ("flicks"); the OPEN
state drives only the arrow-cap child's StateDesc swap
(UIElement_Menu::UpdateState @0x0046cad0 writes attribute 0xe).
UiMenu now keys the face on the press, not on IsOpen.
4. Window-title/button text "vibrates" while dragging windows:
DrawStringDatPass snapped glyphs with MathF.Round — banker's
rounding. A centered label with a constant .5 fraction alternates
round-up/round-down across successive integers, double-stepping then
sticking while the background glides. Half-up Floor(v+0.5) snaps
every tie one way: uniform 1px steps in lock-step with sprites.
The fifth report (input row sticking out on window resize) did not
reproduce: a controller-bound fixture resize at 220/300/600px keeps the
whole input row inside the window (test added) — awaiting the owner's
exact gesture.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1488 lines
66 KiB
C#
1488 lines
66 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);
|
|
}
|
|
|
|
// ── Dialog roots: retail class types 0x13/0x19 → UiDialogRoot ────────────
|
|
|
|
[Theory]
|
|
[InlineData(0x13u)] // ConfirmationDialog (catalog root 0x15)
|
|
[InlineData(0x15u)] // ConfirmationTextInputDialog (catalog root 0x2C)
|
|
[InlineData(0x17u)] // MessageDialog (catalog root 0x24)
|
|
[InlineData(0x19u)] // WaitDialog (catalog root 0x31 — OP8 #396's live
|
|
// crash: unmapped type built a plain UiDatElement and
|
|
// RetailWaitDialogView's ctor threw out of OnClick)
|
|
public void DialogRootTypes_MakeUiDialogRoot(uint type)
|
|
{
|
|
var e = DatWidgetFactory.Create(
|
|
new ElementInfo { Type = type, Width = 800, Height = 600 }, NoTex, null);
|
|
Assert.IsType<UiDialogRoot>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 2026-08-24 owner report (missing gold separator left of the chat
|
|
/// input): the chat input authors two 1px Type-3 rail CHILDREN
|
|
/// (0x10000017 at X=0, 0x10000018 right-anchored) whose only media is
|
|
/// Normal_focussed (0x06004D67, live-DAT probed). UiField consumes its
|
|
/// DAT children, so the factory must fold the rails into the field for
|
|
/// its own focused draw.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Type12_EditableField_FoldsAuthoredFocusRailsIntoTheWidget()
|
|
{
|
|
var info = TextInfo((0x16u, Bool(true)));
|
|
info.Width = 306f;
|
|
info.Height = 17f;
|
|
var leftRail = new ElementInfo
|
|
{
|
|
Id = 0x10000017u, Type = 3u, X = 0f, Width = 1f, Height = 17f,
|
|
};
|
|
leftRail.StateMedia["Normal_focussed"] = (0x06004D67u, 1);
|
|
var rightRail = new ElementInfo
|
|
{
|
|
Id = 0x10000018u, Type = 3u, X = 305f, Width = 1f, Height = 17f,
|
|
};
|
|
rightRail.StateMedia["Normal_focussed"] = (0x06004D67u, 1);
|
|
info.Children.Add(leftRail);
|
|
info.Children.Add(rightRail);
|
|
|
|
var field = Assert.IsType<UiField>(DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
Assert.Equal(0x06004D67u, field.FocusRailLeftSprite);
|
|
Assert.Equal(1f, field.FocusRailLeftWidth);
|
|
Assert.Equal(0x06004D67u, field.FocusRailRightSprite);
|
|
Assert.Equal(1f, field.FocusRailRightWidth);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign LA gate round 2 finding 2: the retail character-select row
|
|
/// template (LayoutDesc 0x21000004, element 0x100003A5, live-DAT
|
|
/// confirmed) authors HJustify=Left DIRECTLY on the row's own
|
|
/// UIElement_Button — no separate Type-12 caption child (its label comes
|
|
/// from the runtime-bound character name, not an authored string), just
|
|
/// three stateful Type-3 highlight-art children. The old guard
|
|
/// (<c>!ReferenceEquals(labelInfo, info)</c>) only honored HJustify when
|
|
/// the label was LIFTED from a distinct Type-12 child, so a button
|
|
/// authoring its own justify with no such child fell through to
|
|
/// UiButton's Center default. This reproduces that exact shape.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildButton_OwnHJustifyLeft_NoTextChild_MultipleStatefulFaces_LabelAlignsLeft()
|
|
{
|
|
var info = new ElementInfo
|
|
{
|
|
Type = 1,
|
|
Width = 160,
|
|
Height = 16,
|
|
HJustify = HJustify.Left,
|
|
};
|
|
info.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal" };
|
|
info.States[2u] = new UiStateInfo { Id = 2u, Name = "Normal_rollover" };
|
|
info.States[3u] = new UiStateInfo { Id = 3u, Name = "Highlight" };
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
var face = new ElementInfo { Type = 3, ReadOrder = (uint)i };
|
|
face.StateMedia["Normal_rollover"] = (0x06000000u + (uint)i, 1);
|
|
info.Children.Add(face);
|
|
}
|
|
|
|
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
Assert.Equal(UiButton.LabelAlignment.Left, button.LabelAlign);
|
|
// Direct (non-lifted) case: LabelOffsetX stays at UiButton's own
|
|
// default small left padding, not a bogus inner offset.
|
|
Assert.Equal(3f, button.LabelOffsetX);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A button whose own authored HJustify really is Center (the normal
|
|
/// case — CREATE/ENTER/DELETE/RESTORE captions) must stay centered; the
|
|
/// fix only widens the Left branch, it must not force every button left.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildButton_OwnHJustifyCenter_NoTextChild_StaysCentered()
|
|
{
|
|
var info = new ElementInfo
|
|
{
|
|
Type = 1,
|
|
Width = 160,
|
|
Height = 16,
|
|
HJustify = HJustify.Center,
|
|
};
|
|
|
|
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
Assert.Equal(UiButton.LabelAlignment.Center, button.LabelAlign);
|
|
}
|
|
|
|
/// <summary>
|
|
/// GF-11c (Campaign CC gate round 1 Batch B): the Town page's per-marker
|
|
/// caption shape (live-DAT-measured on 0x1000040D/0x10000409) — a
|
|
/// single stateful face child (the marker/pin, Normal/Highlight media)
|
|
/// PLUS a DISTINCT Type-12 caption child with its own authored rect and
|
|
/// Center justify, positioned independently of the marker (e.g. below
|
|
/// or above it, not necessarily beside it). Before this fix, the
|
|
/// caption's own rect/justify was discarded in favor of a Left-aligned
|
|
/// offset computed from the FACE rect — correct only for the heritage/
|
|
/// template row family below, where the label is authored directly on
|
|
/// the button itself (see the companion regression test).
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildButton_SingleFaceChild_LiftedCaptionWithOwnRect_HonorsLabelBoxNotFaceOffset()
|
|
{
|
|
uint stringId = 555u;
|
|
var info = new ElementInfo { Type = 1, Width = 106, Height = 80 };
|
|
info.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal" };
|
|
info.States[6u] = new UiStateInfo { Id = 6u, Name = "Highlight" };
|
|
|
|
var caption = new ElementInfo
|
|
{
|
|
Type = 12,
|
|
X = 0,
|
|
Y = 4,
|
|
Width = 100,
|
|
Height = 37,
|
|
HJustify = HJustify.Center,
|
|
};
|
|
caption.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
caption.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.StringInfo,
|
|
StringInfoValue = new UiStringInfoValue(0, stringId, 0, 0, 0, 0),
|
|
};
|
|
info.Children.Add(caption);
|
|
|
|
var marker = new ElementInfo { Type = 3, X = 36, Y = 36, Width = 38, Height = 38 };
|
|
marker.StateMedia["Normal"] = (0x06004D60u, 1);
|
|
marker.StateMedia["Highlight"] = (0x06004D61u, 1);
|
|
info.Children.Add(marker);
|
|
|
|
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
|
|
info, NoTex, null,
|
|
stringResolve: value => value.StringId == stringId ? "Holtburg" : null));
|
|
|
|
Assert.Equal("Holtburg", button.Label);
|
|
Assert.Equal(UiButton.LabelAlignment.Center, button.LabelAlign);
|
|
Assert.Equal((0f, 4f, 100f, 37f), button.LabelBox);
|
|
// The face geometry is still captured for the marker's own draw —
|
|
// just no longer used to derive the label's position.
|
|
Assert.Equal((36f, 36f, 38f, 38f), (button.FaceLeft, button.FaceTop, button.FaceWidth, button.FaceHeight));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression companion: the heritage/template/Face-Clothes row shape —
|
|
/// the label string is authored DIRECTLY on the button (no distinct
|
|
/// Type-12 child), beside a single stateful face child (the radio dot).
|
|
/// This is the case the FACE-relative Left-aligned offset math IS
|
|
/// correct for, and it must keep working exactly as before GF-11c.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildButton_SingleFaceChild_DirectLabel_KeepsFaceRelativeOffset()
|
|
{
|
|
uint stringId = 777u;
|
|
var info = new ElementInfo { Type = 1, Width = 305, Height = 32, HJustify = HJustify.Left };
|
|
info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.StringInfo,
|
|
StringInfoValue = new UiStringInfoValue(0, stringId, 0, 0, 0, 0),
|
|
};
|
|
info.States[RetailUiStateIds.Unselected] = new UiStateInfo { Id = RetailUiStateIds.Unselected, Name = "Unselected" };
|
|
info.States[RetailUiStateIds.Selected] = new UiStateInfo { Id = RetailUiStateIds.Selected, Name = "Selected" };
|
|
|
|
var dot = new ElementInfo { Type = 3, Width = 32, Height = 32 };
|
|
dot.StateMedia["Unselected"] = (0x06006E35u, 1);
|
|
dot.StateMedia["Selected"] = (0x06006E21u, 1);
|
|
info.Children.Add(dot);
|
|
|
|
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
|
|
info, NoTex, null,
|
|
stringResolve: value => value.StringId == stringId ? "Aluvian" : null));
|
|
|
|
Assert.Equal("Aluvian", button.Label);
|
|
Assert.Null(button.LabelBox);
|
|
Assert.Equal(UiButton.LabelAlignment.Left, button.LabelAlign);
|
|
Assert.Equal(36f, button.LabelOffsetX); // face.X(0) + face.Width(32) + 4
|
|
}
|
|
|
|
/// <summary>
|
|
/// GF-4a (Campaign CC gate round 1 Batch C): retail's chargen display
|
|
/// buttons (live-DAT-measured shape) author their CAPTION directly as
|
|
/// their own P0x17 AND carry one SEPARATE, media-less Type-12 child for
|
|
/// the live value. <c>BuildButton</c> surfaces that child through
|
|
/// <see cref="UiButton.ValueBox"/>/<see cref="UiButton.ValueLabel"/>
|
|
/// instead of dropping it — coexisting with, not clobbering,
|
|
/// <see cref="UiButton.Label"/>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildButton_OwnCaptionPlusMediaLessTextChild_SurfacesValueSlotWithoutClobberingLabel()
|
|
{
|
|
uint captionStringId = 111u;
|
|
var info = new ElementInfo { Type = 1, Width = 80, Height = 20 };
|
|
info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.StringInfo,
|
|
StringInfoValue = new UiStringInfoValue(0, captionStringId, 0, 0, 0, 0),
|
|
};
|
|
// The button carries its own background media (authoredFaces stays
|
|
// empty — matches the real display buttons, which have their OWN
|
|
// frame art, not a lifted face-segment child).
|
|
info.StateMedia[""] = (0x06000001u, 1);
|
|
|
|
var valueChild = new ElementInfo { Type = 12, X = 5, Y = 2, Width = 60, Height = 16 };
|
|
info.Children.Add(valueChild);
|
|
|
|
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
|
|
info, NoTex, null,
|
|
stringResolve: value => value.StringId == captionStringId ? "Attribute Credits" : null));
|
|
|
|
Assert.Equal("Attribute Credits", button.Label);
|
|
Assert.Null(button.ValueLabel); // nothing authored on the child itself
|
|
Assert.Equal((5f, 2f, 60f, 16f), button.ValueBox);
|
|
|
|
// Writing the live value (as CharacterCreationProfessionPage.SetDisplay
|
|
// does) must not touch the caption — the whole point of this fix.
|
|
button.ValueLabel = "42";
|
|
Assert.Equal("Attribute Credits", button.Label);
|
|
Assert.Equal("42", button.ValueLabel);
|
|
}
|
|
|
|
/// <summary>
|
|
/// R4-1 (Campaign CC gate round 1 re-test 3): the value child
|
|
/// (<c>0x100002f3</c>) is BASE-INHERITED across four sibling buttons of
|
|
/// DIFFERING widths — Health/Stamina/Mana at 150px share the same
|
|
/// child id/rect (local X=116) as the wider, 231px Skills credits
|
|
/// button — so the child's own authored <c>OriginalParentWidth</c>
|
|
/// (baked in at whichever button FIRST resolved it, 150) diverges from
|
|
/// the ACTUAL containing button's current width (231) for exactly the
|
|
/// wider button. Before this fix, <c>ValueBox</c> was the child's raw
|
|
/// (un-reflowed) rect regardless — this fixture reproduces that exact
|
|
/// shape (a 150px "design" width baked into the child, hosted under a
|
|
/// 231px-wide button, with the child's own Right-tracking edge modes
|
|
/// 2/1) and proves <c>UiLayoutPolicy</c>'s raw-edge reflow now shifts
|
|
/// the value box by the SAME 81px the button grew (116 -> 197),
|
|
/// landing clear of "Available Skill Credits"'s own measured span
|
|
/// instead of colliding mid-caption ("Available Skill0Credits").
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildButton_ValueChildBaseInheritedNarrowerParent_ReflowsToWiderButton()
|
|
{
|
|
uint captionStringId = 333u;
|
|
var info = new ElementInfo { Type = 1, Width = 231, Height = 28 };
|
|
info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.StringInfo,
|
|
StringInfoValue = new UiStringInfoValue(0, captionStringId, 0, 0, 0, 0),
|
|
};
|
|
|
|
var valueChild = new ElementInfo
|
|
{
|
|
Type = 12,
|
|
X = 116,
|
|
Y = 0,
|
|
Width = 34,
|
|
Height = 28,
|
|
Left = 2,
|
|
Top = 1,
|
|
Right = 1,
|
|
Bottom = 1,
|
|
OriginalParentWidth = 150,
|
|
OriginalParentHeight = 28,
|
|
HasOriginalParentSize = true,
|
|
HJustify = HJustify.Right,
|
|
};
|
|
info.Children.Add(valueChild);
|
|
|
|
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
|
|
info, NoTex, null,
|
|
stringResolve: value => value.StringId == captionStringId ? "Available Skill Credits" : null));
|
|
|
|
Assert.Equal("Available Skill Credits", button.Label);
|
|
// 116 + (231-150) = 197 -> width/height preserved (34/28).
|
|
Assert.Equal((197f, 0f, 34f, 28f), button.ValueBox);
|
|
Assert.Equal(UiButton.LabelAlignment.Right, button.ValueAlign);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Negative companion to the reflow test above: a value child whose
|
|
/// OWN authored parent width already MATCHES the actual button (the
|
|
/// Health/Stamina/Mana shape, un-widened) reflows to its byte-identical
|
|
/// raw rect — delta is zero, so this is confirmed additive, not a
|
|
/// blanket shift.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildButton_ValueChildOriginalParentMatchesActual_RectUnchanged()
|
|
{
|
|
uint captionStringId = 334u;
|
|
var info = new ElementInfo { Type = 1, Width = 150, Height = 28 };
|
|
info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.StringInfo,
|
|
StringInfoValue = new UiStringInfoValue(0, captionStringId, 0, 0, 0, 0),
|
|
};
|
|
|
|
var valueChild = new ElementInfo
|
|
{
|
|
Type = 12,
|
|
X = 116,
|
|
Y = 0,
|
|
Width = 34,
|
|
Height = 28,
|
|
Left = 2,
|
|
Top = 1,
|
|
Right = 1,
|
|
Bottom = 1,
|
|
OriginalParentWidth = 150,
|
|
OriginalParentHeight = 28,
|
|
HasOriginalParentSize = true,
|
|
};
|
|
info.Children.Add(valueChild);
|
|
|
|
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
|
|
info, NoTex, null,
|
|
stringResolve: value => value.StringId == captionStringId ? "Health" : null));
|
|
|
|
Assert.Equal((116f, 0f, 34f, 28f), button.ValueBox);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Negative companion: a button whose caption was LIFTED from a
|
|
/// distinct Type-12 child (the town-marker shape,
|
|
/// <c>!ReferenceEquals(labelInfo, info)</c>) must NOT pick up a
|
|
/// ValueBox even if the button happens to have another Type-12 child —
|
|
/// the gate is <c>ReferenceEquals(labelInfo, info)</c>, own-caption
|
|
/// only.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildButton_LiftedCaption_NeverSurfacesValueSlot()
|
|
{
|
|
uint stringId = 222u;
|
|
var info = new ElementInfo { Type = 1, Width = 106, Height = 80 };
|
|
info.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal" };
|
|
info.States[6u] = new UiStateInfo { Id = 6u, Name = "Highlight" };
|
|
|
|
var caption = new ElementInfo { Type = 12, X = 0, Y = 4, Width = 100, Height = 37 };
|
|
caption.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
caption.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.StringInfo,
|
|
StringInfoValue = new UiStringInfoValue(0, stringId, 0, 0, 0, 0),
|
|
};
|
|
info.Children.Add(caption);
|
|
|
|
var marker = new ElementInfo { Type = 3, X = 36, Y = 36, Width = 38, Height = 38 };
|
|
marker.StateMedia["Normal"] = (0x06004D60u, 1);
|
|
marker.StateMedia["Highlight"] = (0x06004D61u, 1);
|
|
info.Children.Add(marker);
|
|
|
|
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
|
|
info, NoTex, null,
|
|
stringResolve: value => value.StringId == stringId ? "Holtburg" : null));
|
|
|
|
Assert.Equal("Holtburg", button.Label);
|
|
Assert.Null(button.ValueBox);
|
|
Assert.Null(button.ValueLabel);
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// R3-4/R3-7 (Campaign CC gate round 1 re-test 2): a VERTICAL scrollbar
|
|
/// whose thumb child carries its OWN direct media (Normal/
|
|
/// Normal_rollover/Normal_pressed) and has NO slice children — the
|
|
/// exact live-DAT shape of the chargen Skills listbox scrollbar
|
|
/// (0x100003f8), Summary's OVERVIEW listbox scrollbar (0x10000401),
|
|
/// and the how-to box's own scrollbar (0x100002e7). Before this fix
|
|
/// ThumbSprite stayed 0 (the 3-slice-children search this factory was
|
|
/// originally built against — chat's scrollbar shape, see
|
|
/// <see cref="ChatLayoutConformanceTests.ChatFixture_ScrollbarImportsInheritedMediaRoles"/>
|
|
/// — found nothing to select), so the track and arrows rendered but
|
|
/// the thumb never did, regardless of overflow.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Type11_VerticalScrollbar_SingleSpriteThumbWithNoSliceChildren_SetsThumbSprite()
|
|
{
|
|
const uint Thumb = 0x06005A11u;
|
|
const uint DecrementId = 0x10000071u;
|
|
const uint IncrementId = 0x10000072u;
|
|
var decrement = new ElementInfo { Id = DecrementId, Type = 1u, Y = 83f, Width = 37f, Height = 17f };
|
|
decrement.StateMedia["Normal"] = (0x06004C69u, 1);
|
|
var increment = new ElementInfo { Id = IncrementId, Type = 1u, Y = 0f, Width = 37f, Height = 17f };
|
|
increment.StateMedia["Normal"] = (0x06004C6Cu, 1);
|
|
var thumb = new ElementInfo { Id = 1u, Type = 1u, Width = 37f, Height = 39f };
|
|
thumb.StateMedia["Normal"] = (Thumb, 1);
|
|
thumb.StateMedia["Normal_rollover"] = (0x06005A12u, 1);
|
|
thumb.StateMedia["Normal_pressed"] = (0x06005A13u, 1);
|
|
thumb.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Thumb, 1) };
|
|
|
|
var info = new ElementInfo
|
|
{
|
|
Type = 11u,
|
|
Width = 37f,
|
|
Height = 307f,
|
|
Children = [decrement, increment, thumb],
|
|
};
|
|
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 };
|
|
info.States[UiStateInfo.DirectStateId] = state;
|
|
|
|
var bar = Assert.IsType<UiScrollbar>(DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
Assert.False(bar.Horizontal);
|
|
Assert.Equal(Thumb, bar.ThumbSprite);
|
|
Assert.Equal(0u, bar.ThumbTopSprite);
|
|
Assert.Equal(0u, bar.ThumbBotSprite);
|
|
// 2026-08-24: the thumb's own rollover/pressed media survive onto
|
|
// the widget (hover highlight / held-drag art).
|
|
Assert.Equal(0x06005A12u, bar.ThumbRolloverSprite);
|
|
Assert.Equal(0x06005A13u, bar.ThumbPressedSprite);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 2026-08-24 owner report ("the arrows point in the wrong direction"):
|
|
/// retail seats scrollbar buttons by DESIGNATION —
|
|
/// <c>UIElement_Scrollbar::UpdateScrollingArea @0x00470AA0</c> moves the
|
|
/// INCREMENT designee (attribute 0x77) to the top corner and the
|
|
/// DECREMENT designee (0x78) to the bottom corner regardless of authored
|
|
/// position. The real vertical base skin (0x10000455 in layout
|
|
/// 0x2100003E) authors the DOWN-arrow decrement at Y=0 and the UP-arrow
|
|
/// increment at Y=32, so the previous authored-Y ordering put the
|
|
/// down-arrow art on the TOP button.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Type11_VerticalScrollbar_SeatsButtonsByDesignation_NotAuthoredPosition()
|
|
{
|
|
const uint DecrementId = 0x10000071u; // DOWN arrow, authored at Y=0
|
|
const uint IncrementId = 0x10000072u; // UP arrow, authored at Y=32
|
|
var decrement = new ElementInfo { Id = DecrementId, Type = 1u, Y = 0f, Width = 16f, Height = 16f };
|
|
decrement.StateMedia["Normal"] = (0x06004C69u, 1);
|
|
decrement.StateMedia["Normal_rollover"] = (0x06004C6Au, 1);
|
|
decrement.StateMedia["Normal_pressed"] = (0x06004C6Bu, 1);
|
|
var increment = new ElementInfo { Id = IncrementId, Type = 1u, Y = 32f, Width = 16f, Height = 16f };
|
|
increment.StateMedia["Normal"] = (0x06004C6Cu, 1);
|
|
increment.StateMedia["Normal_rollover"] = (0x06004C6Du, 1);
|
|
increment.StateMedia["Normal_pressed"] = (0x06004C6Eu, 1);
|
|
|
|
var info = new ElementInfo
|
|
{
|
|
Type = 11u,
|
|
Width = 16f,
|
|
Height = 48f,
|
|
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 };
|
|
info.States[UiStateInfo.DirectStateId] = state;
|
|
|
|
var bar = Assert.IsType<UiScrollbar>(DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
// Top slot = the increment designee's UP-arrow media.
|
|
Assert.Equal(0x06004C6Cu, bar.UpSprite);
|
|
Assert.Equal(0x06004C6Du, bar.UpRolloverSprite);
|
|
Assert.Equal(0x06004C6Eu, bar.UpPressedSprite);
|
|
// Bottom slot = the decrement designee's DOWN-arrow media.
|
|
Assert.Equal(0x06004C69u, bar.DownSprite);
|
|
Assert.Equal(0x06004C6Au, bar.DownRolloverSprite);
|
|
Assert.Equal(0x06004C6Bu, bar.DownPressedSprite);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Negative companion: a VERTICAL thumb WITH real 3-slice children
|
|
/// (chat's own shape) is unaffected by the R3-4/R3-7 fallback — the
|
|
/// `slices.Length == 0` gate never triggers, so ThumbSprite/Top/Bot
|
|
/// come from the slice children exactly as before.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Type11_VerticalScrollbar_ThumbWithSliceChildren_StillUsesSliceMedia()
|
|
{
|
|
const uint Top = 0x06004C60u;
|
|
const uint Mid = 0x06004C63u;
|
|
const uint Bot = 0x06004C66u;
|
|
var decrement = new ElementInfo { Id = 0x10000071u, Type = 1u, Y = 0f, Width = 16f, Height = 16f };
|
|
decrement.StateMedia["Normal"] = (0x06004C69u, 1);
|
|
var increment = new ElementInfo { Id = 0x10000072u, Type = 1u, Y = 32f, Width = 16f, Height = 16f };
|
|
increment.StateMedia["Normal"] = (0x06004C6Cu, 1);
|
|
var thumb = new ElementInfo { Id = 1u, Type = 1u, Width = 16f, Height = 16f };
|
|
var topCap = new ElementInfo { Id = 0x10000364u, Type = 3u, Y = 0f, Width = 16f, Height = 3f };
|
|
topCap.StateMedia["Normal"] = (Top, 1);
|
|
topCap.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Top, 1) };
|
|
var mid = new ElementInfo { Id = 0x10000365u, Type = 3u, Y = 3f, Width = 16f, Height = 10f };
|
|
mid.StateMedia["Normal"] = (Mid, 1);
|
|
mid.StateMedia["Normal_rollover"] = (0x06004C64u, 1);
|
|
mid.StateMedia["Normal_pressed"] = (0x06004C65u, 1);
|
|
mid.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Mid, 1) };
|
|
var botCap = new ElementInfo { Id = 0x10000366u, Type = 3u, Y = 13f, Width = 16f, Height = 3f };
|
|
botCap.StateMedia["Normal"] = (Bot, 1);
|
|
botCap.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Bot, 1) };
|
|
thumb.Children.Add(topCap);
|
|
thumb.Children.Add(mid);
|
|
thumb.Children.Add(botCap);
|
|
|
|
var info = new ElementInfo
|
|
{
|
|
Type = 11u,
|
|
Width = 16f,
|
|
Height = 73f,
|
|
Children = [decrement, increment, thumb],
|
|
};
|
|
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
state.Properties.Values[0x77u] = new UiPropertyValue
|
|
{ Kind = UiPropertyKind.Enum, UnsignedValue = 0x10000072u };
|
|
state.Properties.Values[0x78u] = new UiPropertyValue
|
|
{ Kind = UiPropertyKind.Enum, UnsignedValue = 0x10000071u };
|
|
info.States[UiStateInfo.DirectStateId] = state;
|
|
|
|
var bar = Assert.IsType<UiScrollbar>(DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
Assert.False(bar.Horizontal);
|
|
Assert.Equal(Top, bar.ThumbTopSprite);
|
|
Assert.Equal(Mid, bar.ThumbSprite);
|
|
Assert.Equal(Bot, bar.ThumbBotSprite);
|
|
// 2026-08-24: slice rollover/pressed media survive onto the widget.
|
|
Assert.Equal(0x06004C64u, bar.ThumbRolloverSprite);
|
|
Assert.Equal(0x06004C65u, bar.ThumbPressedSprite);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 2026-08-13 social gate round 3: a MULTILINE authored string (a real
|
|
/// '\n' — the DAT's literal backslash-n escape decodes at the string
|
|
/// source since the 2026-08-17 systemic round, so the resolver seam
|
|
/// hands this factory decoded text) word-wraps each authored line to the
|
|
/// widget's live width — retail's GlyphList draw, the same wrap the
|
|
/// confirmation dialog view uses. The fellowship empty-state was
|
|
/// rendering its three authored lines as three clipped runs.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildText_MultilineAuthored_WordWrapsToLiveWidth()
|
|
{
|
|
var info = new ElementInfo { Type = 12, Width = 100, Height = 80 };
|
|
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),
|
|
};
|
|
info.States[UiStateInfo.DirectStateId] = direct;
|
|
// No DatFont in this fixture — the fallback measure is 8 px/char, so
|
|
// Width=100 fits 12 characters per wrapped line.
|
|
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
|
|
info, NoTex, null,
|
|
stringResolve: _ => "one two three four five\nsix"));
|
|
|
|
var lines = text.LinesProvider!();
|
|
Assert.True(lines.Count >= 3);
|
|
Assert.All(lines, line => Assert.True(line.Text.Length <= 12));
|
|
Assert.Equal(
|
|
"one two three four five six",
|
|
string.Join(" ", lines.Select(static line => line.Text)));
|
|
|
|
// The provider tracks the LIVE width — widening re-wraps.
|
|
text.Width = 400f;
|
|
lines = text.LinesProvider!();
|
|
Assert.Equal(2, lines.Count);
|
|
Assert.Equal("one two three four five", lines[0].Text);
|
|
Assert.Equal("six", lines[1].Text);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 2026-08-14 friends gate: a Type-12 element whose OWN states author
|
|
/// 0x17 strings (the friends row's status cell: 'Online'/'Offline' with
|
|
/// per-state colors) swaps its displayed line on TrySetRetailState —
|
|
/// retail's state-cascade text swap.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildText_PerStateAuthoredStrings_SwapWithRetailState()
|
|
{
|
|
var info = new ElementInfo { Type = 12, Width = 270, Height = 24 };
|
|
var online = new UiStateInfo { Id = 0x10000054u, Name = "Online" };
|
|
online.Properties.Values[0x17u] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.StringInfo,
|
|
// (Token, StringId, TableId, ...) — StringId 2 = 'Online'.
|
|
StringInfoValue = new UiStringInfoValue(0, 2, 1, 0, 1, 0),
|
|
};
|
|
online.Properties.Values[0x1Bu] = Color(0, 255, 0);
|
|
var offline = new UiStateInfo { Id = 0x10000055u, Name = "Offline" };
|
|
offline.Properties.Values[0x17u] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.StringInfo,
|
|
StringInfoValue = new UiStringInfoValue(0, 3, 1, 0, 1, 0),
|
|
};
|
|
info.States[0x10000054u] = online;
|
|
info.States[0x10000055u] = offline;
|
|
|
|
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
|
|
info, NoTex, null,
|
|
stringResolve: si => si.StringId == 2u ? "Online" : "Offline"));
|
|
|
|
Assert.True(text.TrySetRetailState(0x10000054u));
|
|
var line = Assert.Single(text.LinesProvider!());
|
|
Assert.Equal("Online", line.Text);
|
|
Assert.Equal(1f, line.Color.Y, 3); // authored green rides the state
|
|
|
|
Assert.True(text.TrySetRetailState(0x10000055u));
|
|
line = Assert.Single(text.LinesProvider!());
|
|
Assert.Equal("Offline", line.Text);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 2026-08-14 gate: the stateful-fill meter (gmPowerbarUI's shape in
|
|
/// LayoutDesc 0x21000072) also absorbs its Type-12 caption child
|
|
/// (0x10000035, per-mode strings 'Height'/'Power'/'Accuracy') into
|
|
/// per-state labels latched by TrySetRetailState — the absorbed
|
|
/// equivalent of retail's PassToChildren state cascade.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildMeter_StatefulFill_AbsorbsCaptionChildPerStateStrings()
|
|
{
|
|
var meter = new ElementInfo { Id = 0x34, Type = 7, Width = 600, Height = 15 };
|
|
meter.StateMedia[""] = (0x06004D0Bu, 1);
|
|
|
|
var fill = new ElementInfo { Id = 2, Type = 3, Width = 600, Height = 15 };
|
|
fill.States[0x10000042u] = new UiStateInfo { Id = 0x10000042u, Name = "JumpMode" };
|
|
fill.StateMedia["JumpMode"] = (0x06001354u, 1);
|
|
meter.Children.Add(fill);
|
|
|
|
var caption = new ElementInfo { Id = 0x35, Type = 12, Width = 600, Height = 15 };
|
|
var jumpText = new UiStateInfo { Id = 0x10000042u, Name = "JumpMode" };
|
|
jumpText.Properties.Values[0x17u] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.StringInfo,
|
|
StringInfoValue = new UiStringInfoValue(0, 1, 2, 0, 1, 0),
|
|
};
|
|
// The mode state's own justification (0x14=0x3 Right — the powerbar
|
|
// modes all author it; the element default stays centered).
|
|
jumpText.Properties.Values[0x14u] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.Enum,
|
|
UnsignedValue = 0x3u,
|
|
};
|
|
caption.States[0x10000042u] = jumpText;
|
|
meter.Children.Add(caption);
|
|
|
|
var m = Assert.IsType<UiMeter>(DatWidgetFactory.Create(
|
|
meter, NoTex, null, stringResolve: _ => "Height"));
|
|
|
|
Assert.Null(m.ActiveStateLabel);
|
|
Assert.True(m.TrySetRetailState(0x10000042u));
|
|
Assert.Equal("Height", m.ActiveStateLabel);
|
|
Assert.Equal(UiMeterLabelAlign.Right, m.ActiveStateLabelAlign);
|
|
Assert.Equal(0x06001354u, m.FrontTile);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|