Every launcher-started play session on 2026-08-19 died a few seconds after
login. The user's own session evidence shows it three times in a row:
started -> connected -> characterList -> exited code 1 "crashed", with
client.err.log carrying
System.ArgumentNullException: Value cannot be null. (Parameter 'key')
at System.Collections.Generic.Dictionary`2.FindValue(TKey key)
at AcDream.App.UI.UiButton.OnDraw(UiRenderContext ctx)
UiButton allocated its per-face-segment media-state array as `new string[n]`,
leaving every element null, while the single-face sibling _faceMediaState was
correctly seeded to "" (DirectState). NextMediaState returns `current`
unchanged on three of its four arms — including retail's own "committed state
authored with an empty media array keeps the previous media playing" rule — so
on a multi-segment button whose committed state carries no media the null
survived the first SyncMediaStates and reached
ElementInfo.StateMedia.TryGetValue(null), throwing mid-paint and taking the
process down.
Seed the array with "" at construction. That is what the constructor's
existing comment already claimed the media machine did ("the media machine
begins on the element's BASE media"); only the segment array was left out.
Verified by reverting the one-line fix: the new regression test throws
ArgumentNullException from UiButton.ActiveFile, the same frame as the live
crash. AcDream.App.Tests UiButton filter: 41 passed, 3 skipped.
Found while investigating Campaign LU item 4 ("launching the selected
character doesn't work") — this is why nothing worked. Also lands the Campaign
LU plan doc, whose recon section records the mechanisms the remaining slices
build on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
807 lines
32 KiB
C#
807 lines
32 KiB
C#
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
namespace AcDream.App.Tests.UI;
|
|
|
|
public class UiButtonTests
|
|
{
|
|
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
|
|
private bool _clicked;
|
|
|
|
[Fact]
|
|
public void Click_InvokesOnClick()
|
|
{
|
|
var b = new UiButton(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex)
|
|
{ OnClick = () => _clicked = true };
|
|
b.OnEvent(new UiEvent(0, null, UiEventType.Click));
|
|
Assert.True(_clicked);
|
|
}
|
|
|
|
[Fact]
|
|
public void Click_ProvidesLocalCoordinatesToPositionAwareHandler()
|
|
{
|
|
(int X, int Y) clicked = default;
|
|
var b = new UiButton(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex)
|
|
{
|
|
OnClickAt = (x, y) => clicked = (x, y),
|
|
};
|
|
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.Click, Data1: 17, Data2: 9));
|
|
|
|
Assert.Equal((17, 9), clicked);
|
|
}
|
|
|
|
[Fact]
|
|
public void DoubleClick_IsOptInAndDisabledButtonsSwallowWithoutInvoking()
|
|
{
|
|
int activations = 0;
|
|
var button = new UiButton(
|
|
new ElementInfo { Type = 1, Width = 46, Height = 18 },
|
|
NoTex);
|
|
var doubleClick = new UiEvent(
|
|
0,
|
|
button,
|
|
UiEventType.DoubleClick);
|
|
|
|
Assert.False(button.OnEvent(doubleClick));
|
|
|
|
button.OnDoubleClick = () => activations++;
|
|
Assert.True(button.OnEvent(doubleClick));
|
|
Assert.Equal(1, activations);
|
|
|
|
button.Enabled = false;
|
|
Assert.True(button.OnEvent(doubleClick));
|
|
Assert.Equal(1, activations);
|
|
}
|
|
|
|
[Fact]
|
|
public void PointerDownAndUp_InvokeDistinctTransitionHandlers()
|
|
{
|
|
var transitions = new List<string>();
|
|
var b = new UiButton(new ElementInfo { Type = 1, Width = 20, Height = 20 }, NoTex)
|
|
{
|
|
Width = 20,
|
|
Height = 20,
|
|
OnPressed = () => transitions.Add("pressed"),
|
|
OnReleased = () => transitions.Add("released"),
|
|
};
|
|
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 30, Data2: 30));
|
|
|
|
Assert.Equal(["pressed", "released"], transitions);
|
|
}
|
|
|
|
[Fact]
|
|
public void NotClickThrough_SoItReceivesClicks()
|
|
{
|
|
var b = new UiButton(new ElementInfo { Type = 1 }, NoTex);
|
|
Assert.False(b.ClickThrough);
|
|
}
|
|
|
|
[Fact]
|
|
public void PointerTransitions_UseRetailNormalStates()
|
|
{
|
|
var b = ButtonWithStates("Normal", "Normal_rollover", "Normal_pressed");
|
|
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.HoverEnter));
|
|
Assert.Equal("Normal_rollover", b.ActiveState);
|
|
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
|
|
Assert.Equal("Normal_pressed", b.ActiveState);
|
|
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.MouseMove, Data1: 50, Data2: 50));
|
|
Assert.Equal("Normal_rollover", b.ActiveState);
|
|
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.MouseMove, Data1: 5, Data2: 5));
|
|
Assert.Equal("Normal_pressed", b.ActiveState);
|
|
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
|
|
Assert.Equal("Normal_rollover", b.ActiveState);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToggleRelease_SelectsHighlightState()
|
|
{
|
|
var info = ButtonInfo("Normal", "Highlight");
|
|
AddBoolProperty(info, 0x0Bu, true);
|
|
var b = CreateButton(info);
|
|
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
|
|
|
|
Assert.True(b.Selected);
|
|
Assert.Equal("Highlight", b.ActiveState);
|
|
}
|
|
|
|
[Fact]
|
|
public void SuppressSelfToggle_PressReleaseDoesNotFlipSelected()
|
|
{
|
|
// CH6a/b REJECT-review SHOULD-FIX 3: the chat-window 1-4 indicators
|
|
// (0x10000522-0x10000525) carry DAT property 0x0B (ToggleBehavior) =
|
|
// true — same shape as ToggleRelease_SelectsHighlightState above —
|
|
// but retail's own click dispatch has no case for their element ids
|
|
// (gmMainChatUI::ListenToElementMessage @0x004CDA80), so a click must
|
|
// NOT flip their Selected mirror. Only SetIndicatorOpen (an external
|
|
// writer) may change it.
|
|
var info = ButtonInfo("Normal", "Highlight");
|
|
AddBoolProperty(info, 0x0Bu, true);
|
|
var b = CreateButton(info);
|
|
b.SuppressSelfToggle = true;
|
|
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
|
|
|
|
Assert.False(b.Selected);
|
|
Assert.Equal("Normal", b.ActiveState);
|
|
|
|
// The external mirror path still works — SuppressSelfToggle only
|
|
// blocks the self-click, not a producer's own write.
|
|
b.Selected = true;
|
|
Assert.True(b.Selected);
|
|
Assert.Equal("Highlight", b.ActiveState);
|
|
}
|
|
|
|
[Fact]
|
|
public void DisabledProperty_SelectsGhostedAndSuppressesClick()
|
|
{
|
|
var info = ButtonInfo("Normal", "Ghosted");
|
|
AddBoolProperty(info, 0x0Du, true);
|
|
var b = CreateButton(info);
|
|
b.OnClick = () => _clicked = true;
|
|
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.Click));
|
|
|
|
Assert.False(b.Enabled);
|
|
Assert.Equal("Ghosted", b.ActiveState);
|
|
Assert.False(_clicked);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingStandardState_PreservesCustomSemanticState()
|
|
{
|
|
var info = ButtonInfo("LockedUI");
|
|
info.DefaultStateName = "LockedUI";
|
|
var b = CreateButton(info);
|
|
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.HoverEnter));
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
|
|
|
|
Assert.Equal("LockedUI", b.ActiveState);
|
|
}
|
|
|
|
[Fact]
|
|
public void PropertyOnlyPressedState_PreservesDrawableNormalFace()
|
|
{
|
|
// #416 media-rule port: retail COMMITS the authored empty-media
|
|
// Normal_pressed (UIElement::SetState @0x00464E70 commits any
|
|
// authored state) while the FACE keeps the Normal art (the
|
|
// @0x004651c0 media rule: an empty media array never replaces the
|
|
// playing media) — the press must not blank the button.
|
|
var info = ButtonInfo("Normal", "Highlight");
|
|
info.States[UiButtonStateMachine.NormalPressed] = new UiStateInfo
|
|
{
|
|
Id = UiButtonStateMachine.NormalPressed,
|
|
Name = "Normal_pressed",
|
|
};
|
|
var b = CreateDrawableButton(info);
|
|
Assert.Equal(1u, DrawnFaceFile(b)); // ButtonInfo assigns "Normal" file 1
|
|
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
|
|
|
|
Assert.Equal("Normal_pressed", b.ActiveState);
|
|
Assert.Equal(1u, DrawnFaceFile(b));
|
|
}
|
|
|
|
[Fact]
|
|
public void DirectStateCascade_WithoutRealMedia_DoesNotBlankAnAlreadyResolvedState()
|
|
{
|
|
// #382: LayoutImporter.BuildWidget's post-attach state reapply cascades a
|
|
// PARENT's PassToChildren DirectState to EVERY IUiDatStateful child (the
|
|
// chat window's indicator-button backing panel, 0x10000600, authors exactly
|
|
// this). Every button structurally carries a DirectStateId entry in its own
|
|
// States dict purely as the property bag for ToggleBehavior/RolloverEnabled/
|
|
// etc (see AddBoolProperty below) — that structural presence must NOT be
|
|
// enough to accept a DirectState transition when the button has no real ""
|
|
// media, or an ancestor's unrelated cascade blanks an already-correct
|
|
// "Normal" resolution before first paint.
|
|
var info = ButtonInfo("Normal", "Highlight");
|
|
AddBoolProperty(info, 0x13u, true); // RolloverEnabled — populates States[DirectStateId]
|
|
var b = CreateButton(info);
|
|
Assert.Equal("Normal", b.ActiveState);
|
|
|
|
bool ok = b.TrySetRetailState(UiStateInfo.DirectStateId);
|
|
|
|
Assert.False(ok);
|
|
Assert.Equal("Normal", b.ActiveState);
|
|
}
|
|
|
|
[Fact]
|
|
public void DirectStateTransition_WithRealMedia_StillSucceeds()
|
|
{
|
|
// The companion positive case: a button that legitimately authors ""
|
|
// (DirectState) media must still be able to transition to it explicitly —
|
|
// the fix narrows the check to "has real media", it does not disable the
|
|
// DirectState branch outright.
|
|
var info = ButtonInfo("Normal");
|
|
info.StateMedia[""] = (7u, 1);
|
|
var b = CreateButton(info);
|
|
|
|
bool ok = b.TrySetRetailState(UiStateInfo.DirectStateId);
|
|
|
|
Assert.True(ok);
|
|
Assert.Equal("", b.ActiveState);
|
|
}
|
|
|
|
[Fact]
|
|
public void HotClick_FiresImmediatelyRepeatsAndSuppressesReleaseClick()
|
|
{
|
|
var info = ButtonInfo("Normal");
|
|
AddBoolProperty(info, 0x0Fu, true);
|
|
AddFloatProperty(info, 0x10u, 0.10f);
|
|
AddFloatProperty(info, 0x11u, 0.05f);
|
|
int clicks = 0;
|
|
var b = CreateButton(info);
|
|
b.OnClick = () => clicks++;
|
|
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
|
|
Assert.Equal(1, clicks);
|
|
b.OnGlobalUiTime(1.00);
|
|
b.OnGlobalUiTime(1.09);
|
|
Assert.Equal(1, clicks);
|
|
b.OnGlobalUiTime(1.11);
|
|
Assert.Equal(2, clicks);
|
|
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.Click, Data1: 5, Data2: 5));
|
|
Assert.Equal(2, clicks);
|
|
}
|
|
|
|
/// <summary>
|
|
/// GF-1/GF-8 (Campaign CC gate round 1 Batch B): the "gender button"
|
|
/// shape — retail's custom Unselected/Selected radio-pair media authored
|
|
/// DIRECTLY on the button's own StateMedia (no separate face-segment
|
|
/// child), live-DAT-measured on 0x100003A7/0x100003A8 (Female/Male).
|
|
/// Before this fix, .Selected committed nothing: the standard
|
|
/// AddAvailableStates loop never recognized the "Unselected"/"Selected"
|
|
/// names, so _availableStates was empty and UpdateVisualState's
|
|
/// RequestedState (which only ever returns Normal/Highlight/Ghosted ids)
|
|
/// could never match anyway.
|
|
/// </summary>
|
|
[Fact]
|
|
public void CustomSelectionPair_MediaDirectlyOnButton_SelectedTogglesActiveState()
|
|
{
|
|
var info = ButtonInfo("Unselected", "Selected");
|
|
var b = CreateButton(info);
|
|
|
|
Assert.Equal("Unselected", b.ActiveState);
|
|
|
|
b.Selected = true;
|
|
Assert.Equal("Selected", b.ActiveState);
|
|
|
|
b.Selected = false;
|
|
Assert.Equal("Unselected", b.ActiveState);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The "heritage/template/sub-tab row" shape — the parent authors the
|
|
/// Unselected/Selected state DESCRIPTORS (property bag only, no media),
|
|
/// and a single stateful child (the radio dot / icon) carries the
|
|
/// actual per-state art, matching <c>FindStatefulFaceChildren</c>'s
|
|
/// name-overlap detection. Live-DAT-measured on the Heritage row
|
|
/// (0x100003BF, dot child 0x100003C0) and the Profession template row
|
|
/// (0x100003D9, icon child 0x100002E9).
|
|
/// </summary>
|
|
[Fact]
|
|
public void CustomSelectionPair_MediaOnFaceChild_SelectedTogglesActiveState()
|
|
{
|
|
var info = new ElementInfo { Type = 1, Width = 305, Height = 32 };
|
|
info.States[UiButtonStateMachine.NormalPressed] = new UiStateInfo
|
|
{
|
|
Id = UiButtonStateMachine.NormalPressed,
|
|
Name = "Normal_pressed",
|
|
};
|
|
info.States[RetailUiStateIds.Unselected] = new UiStateInfo
|
|
{
|
|
Id = RetailUiStateIds.Unselected,
|
|
Name = "Unselected",
|
|
};
|
|
info.States[RetailUiStateIds.Selected] = new UiStateInfo
|
|
{
|
|
Id = RetailUiStateIds.Selected,
|
|
Name = "Selected",
|
|
};
|
|
info.DefaultStateName = "Unselected";
|
|
|
|
var dot = new ElementInfo { Type = 3, Width = 32, Height = 32 };
|
|
dot.StateMedia["Unselected"] = (0x06006E35u, 1);
|
|
dot.StateMedia["Selected"] = (0x06006E21u, 1);
|
|
info.Children.Add(dot);
|
|
|
|
// Face-child discovery (FindStatefulFaceChildren) is DatWidgetFactory's
|
|
// job, not UiButton's own constructor — go through the real factory
|
|
// path so this fixture matches production exactly (raw CreateButton
|
|
// below bypasses that discovery entirely).
|
|
var b = Assert.IsType<UiButton>(DatWidgetFactory.Create(info, NoTex, null));
|
|
|
|
Assert.Equal("Unselected", b.ActiveState);
|
|
|
|
b.Selected = true;
|
|
Assert.Equal("Selected", b.ActiveState);
|
|
Assert.Equal(RetailUiStateIds.Selected, b.ActiveRetailStateId);
|
|
|
|
b.Selected = false;
|
|
Assert.Equal("Unselected", b.ActiveState);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression pin: a STANDARD ToggleBehavior button (no Unselected/
|
|
/// Selected states authored at all — the overwhelming majority of
|
|
/// buttons, including every pre-existing ToggleBehavior consumer) keeps
|
|
/// behaving exactly as before the custom-pair bypass was added.
|
|
/// </summary>
|
|
[Fact]
|
|
public void CustomSelectionPair_Absent_StandardToggleBehaviorUnchanged()
|
|
{
|
|
var info = ButtonInfo("Normal", "Highlight");
|
|
AddBoolProperty(info, 0x0Bu, true);
|
|
var b = CreateButton(info);
|
|
|
|
b.Selected = true;
|
|
Assert.Equal("Highlight", b.ActiveState);
|
|
|
|
b.Selected = false;
|
|
Assert.Equal("Normal", b.ActiveState);
|
|
}
|
|
|
|
/// <summary>
|
|
/// AP-222 / GF-11b, re-derived at the #416 media-rule port: the
|
|
/// Appearance spins author a PROPERTY-ONLY Highlight StateDesc (the
|
|
/// 0x1B/0x21 label style, no media). Retail's machine gate
|
|
/// (UIElement_Button::UpdateState_ @0x00471CF0) admits any AUTHORED
|
|
/// state, and UIElement::SetState @0x00464E70 then commits it — the
|
|
/// label recolors — while the ARROW ART stays on Normal through the
|
|
/// SetState media rule (@0x004651c0: an empty media array never
|
|
/// replaces the playing media). Live-DAT-measured values: Normal
|
|
/// (218,167,85), Highlight (255,221,131), outline off -> on.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PerStateLabelStyle_PropertyOnlyHighlight_CommitsAndKeepsFace()
|
|
{
|
|
var info = ButtonInfo("Normal"); // Highlight authors NO media...
|
|
info.States[UiButtonStateMachine.Highlight] = new UiStateInfo
|
|
{
|
|
Id = UiButtonStateMachine.Highlight,
|
|
Name = "Highlight", // ...but IS authored (property-only)
|
|
};
|
|
AddBoolProperty(info, 0x0Bu, true); // ToggleBehavior
|
|
var b = CreateDrawableButton(info);
|
|
b.Label = "Hair Style";
|
|
b.LabelColor = new System.Numerics.Vector4(1f, 1f, 1f, 1f);
|
|
|
|
var colors = new Dictionary<uint, System.Numerics.Vector4>
|
|
{
|
|
[UiButtonStateMachine.Normal] = new(218f / 255f, 167f / 255f, 85f / 255f, 1f),
|
|
[UiButtonStateMachine.Highlight] = new(255f / 255f, 221f / 255f, 131f / 255f, 1f),
|
|
};
|
|
var outlines = new Dictionary<uint, bool>
|
|
{
|
|
[UiButtonStateMachine.Normal] = false,
|
|
[UiButtonStateMachine.Highlight] = true,
|
|
};
|
|
b.SetPerStateLabelStyle(colors, outlines);
|
|
|
|
Assert.Equal(colors[UiButtonStateMachine.Normal], b.LabelColor);
|
|
Assert.False(b.Outline);
|
|
Assert.Equal(1u, DrawnFaceFile(b)); // "Normal" art (file 1)
|
|
|
|
b.Selected = true;
|
|
|
|
// The authored property-only Highlight COMMITS (retail SetState) and
|
|
// the label recolors; the face keeps the Normal art (media rule).
|
|
Assert.Equal("Highlight", b.ActiveState);
|
|
Assert.Equal(colors[UiButtonStateMachine.Highlight], b.LabelColor);
|
|
Assert.True(b.Outline);
|
|
Assert.Equal(1u, DrawnFaceFile(b));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression pin: a button with NO per-state color map (null, the
|
|
/// overwhelming majority — every existing external post-construction
|
|
/// LabelColor assignment such as ChatWindowController's Send caption or
|
|
/// PaperdollController's Slots label) never has its LabelColor touched
|
|
/// by a state change.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PerStateLabelStyle_Absent_ExternalLabelColorAssignmentSurvivesStateChanges()
|
|
{
|
|
var info = ButtonInfo("Normal", "Highlight");
|
|
AddBoolProperty(info, 0x0Bu, true);
|
|
var b = CreateButton(info);
|
|
var externalColor = new System.Numerics.Vector4(1f, 0.92f, 0.72f, 1f);
|
|
b.LabelColor = externalColor;
|
|
|
|
b.Selected = true;
|
|
Assert.Equal("Highlight", b.ActiveState);
|
|
Assert.Equal(externalColor, b.LabelColor);
|
|
|
|
b.Selected = false;
|
|
Assert.Equal(externalColor, b.LabelColor);
|
|
}
|
|
|
|
// ── R2-2/R2-3 (Campaign CC gate round 1 Batch E): WrapBlockLines ────
|
|
|
|
private static float BitmapMeasure(string text) => text.Length * 8f;
|
|
|
|
/// <summary>
|
|
/// A single line that already fits its box draws with the SAME
|
|
/// centered-block geometry the pre-fix unconditional one-line math
|
|
/// produced — the fix is a strict superset for every already-working
|
|
/// button caption.
|
|
/// </summary>
|
|
[Fact]
|
|
public void WrapBlockLines_SingleLineThatFits_MatchesPriorOneLineGeometry()
|
|
{
|
|
var lines = UiButton.WrapBlockLines(
|
|
"Health", BitmapMeasure, lineHeight: 24f,
|
|
boxX: 0f, boxY: 0f, boxWidth: 150f, boxHeight: 50f,
|
|
UiButton.LabelAlignment.Left, leftOffset: 3f);
|
|
|
|
Assert.Single(lines);
|
|
Assert.Equal("Health", lines[0].Text);
|
|
Assert.Equal(3f, lines[0].X); // boxX + leftOffset
|
|
Assert.Equal((50f - 24f) * 0.5f, lines[0].Y); // vertically centered, one line
|
|
}
|
|
|
|
/// <summary>
|
|
/// R2-2: an authored newline (already normalized to a real '\n' by
|
|
/// DatWidgetFactory's ResolveAuthoredString) splits into stacked lines
|
|
/// even when EACH half individually fits the box — "Attribute\nCredits"
|
|
/// must become two lines, not one literal run.
|
|
/// </summary>
|
|
[Fact]
|
|
public void WrapBlockLines_EmbeddedNewline_ProducesTwoStackedLines()
|
|
{
|
|
var lines = UiButton.WrapBlockLines(
|
|
"Attribute\nCredits", BitmapMeasure, lineHeight: 24f,
|
|
boxX: 0f, boxY: 0f, boxWidth: 90f, boxHeight: 50f,
|
|
UiButton.LabelAlignment.Left, leftOffset: 3f);
|
|
|
|
Assert.Equal(2, lines.Count);
|
|
Assert.Equal("Attribute", lines[0].Text);
|
|
Assert.Equal("Credits", lines[1].Text);
|
|
// Block-centered: total height 48 in a 50-tall box -> start Y = 1.
|
|
Assert.Equal(1f, lines[0].Y);
|
|
Assert.Equal(25f, lines[1].Y); // startY + 1*lineHeight
|
|
}
|
|
|
|
/// <summary>
|
|
/// R3-2 (re-test 2 correction, supersedes the retired Batch E
|
|
/// "WordWrapsToFitAvailableWidth" expectation): a single-paragraph
|
|
/// caption with NO authored newline stays ONE line even when it
|
|
/// overflows the available width — the exact live-DAT shape of the
|
|
/// Skills credits button's own "Available Skill Credits" caption
|
|
/// (measured 193px, live-DAT-probed against the button's own FULL
|
|
/// 231px width, which it fits comfortably — the 113px figure in the
|
|
/// old test was the WRONG width in the first place, since retail never
|
|
/// confines a caption's wrap width to a sibling value element's rect;
|
|
/// see <see cref="UiButton.DrawBlockLabel"/>'s own doc for the
|
|
/// GlyphList::Recalculate citation). Even forced into an artificially
|
|
/// narrow box (as here), the caption must NOT wrap — retail's
|
|
/// UIElement_Button captions only ever split on an authored <c>\n</c>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void WrapBlockLines_LongSingleParagraph_NeverWordWraps()
|
|
{
|
|
var lines = UiButton.WrapBlockLines(
|
|
"Available Skill Credits", BitmapMeasure, lineHeight: 24f,
|
|
boxX: 0f, boxY: 0f, boxWidth: 113f, boxHeight: 28f,
|
|
UiButton.LabelAlignment.Left, leftOffset: 3f);
|
|
|
|
Assert.Single(lines);
|
|
Assert.Equal("Available Skill Credits", lines[0].Text);
|
|
}
|
|
|
|
/// <summary>
|
|
/// R3-1 (re-test 2): the Profession/chargen attribute slider name label
|
|
/// (element <c>0x100002ed</c>, e.g. "Coordination") authors <c>OneLine=
|
|
/// true</c> and a 115px-wide box — live-DAT-measured against the real
|
|
/// dat font, the caption itself is 113px wide, just 1px narrower than
|
|
/// the raw box but 1px WIDER than the box minus the class's own default
|
|
/// 3px <c>LabelOffsetX</c> (112px) — exactly the boundary the retired
|
|
/// Batch E width-check would have tripped on, wrapping a single WORD
|
|
/// (no space to break at) into a garbled two-line split. Pins that this
|
|
/// no longer happens for any box/text combination, narrow or not.
|
|
/// </summary>
|
|
[Fact]
|
|
public void WrapBlockLines_SingleWordNarrowerThanBoxButWiderThanOffsetAdjustedWidth_StaysOneLine()
|
|
{
|
|
var lines = UiButton.WrapBlockLines(
|
|
"Coordination", BitmapMeasure, lineHeight: 24f,
|
|
boxX: 0f, boxY: 0f, boxWidth: 115f, boxHeight: 24f,
|
|
UiButton.LabelAlignment.Left, leftOffset: 3f);
|
|
|
|
Assert.Single(lines);
|
|
Assert.Equal("Coordination", lines[0].Text);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The ValueBox-vs-Label confinement math itself, exercised through
|
|
/// OnDraw's own computation: a button with BOTH Label and a coexisting
|
|
/// ValueBox still shrinks the caption's OWN boxWidth to stop before the
|
|
/// value box starts. As of R3-2 (re-test 2) this confined width no
|
|
/// longer changes whether or how the caption draws — a single
|
|
/// (unwrapped) line is never clipped to it (see
|
|
/// <see cref="UiButton.DrawBlockLabel"/>'s own doc) — so this test only
|
|
/// pins that the computation itself is unchanged, not that it gates
|
|
/// any rendering decision.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildButton_OwnCaptionWithCoexistingValueBox_ConfinesLabelWidthBeforeValueBox()
|
|
{
|
|
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),
|
|
};
|
|
info.StateMedia[""] = (0x06000001u, 1);
|
|
|
|
var valueChild = new ElementInfo { Type = 12, X = 116, Y = 0, Width = 34, Height = 28 };
|
|
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);
|
|
Assert.Equal((116f, 0f, 34f, 28f), button.ValueBox);
|
|
|
|
// The caption's own available width for WrapBlockLines is bounded by
|
|
// ValueBox.X (116), NOT the button's full Width (231) — reproducing
|
|
// OnDraw's own confinement math here (private OnDraw isn't directly
|
|
// callable, so this pins the INPUT the fix computes for it).
|
|
float confinedWidth = System.MathF.Min(button.Width, button.ValueBox!.Value.X - 0f);
|
|
Assert.Equal(116f, confinedWidth);
|
|
Assert.True(confinedWidth < button.Width, "the confined width must be narrower than the full button");
|
|
}
|
|
|
|
// ── R2-2 authored caption (source-decoded) ───────────────────────────
|
|
|
|
/// <summary>
|
|
/// R2-2's successor contract (2026-08-17 systemic round): the DAT's
|
|
/// LITERAL two-character escape "\n" (0x5C 0x6E — the Profession
|
|
/// credits button's own authored caption is exactly this shape) decodes
|
|
/// at the string SOURCE (DatStringResolver → RetailStringEscapes,
|
|
/// retail's own placement), so the resolver seam hands BuildButton a
|
|
/// caption with a REAL line break — and the factory passes it through
|
|
/// verbatim, with no second decode that would corrupt an authored
|
|
/// backslash pair.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildButton_OwnCaption_PassesSourceDecodedTextThrough()
|
|
{
|
|
uint stringId = 444u;
|
|
var info = new ElementInfo { Type = 1, Width = 150, Height = 50 };
|
|
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),
|
|
};
|
|
|
|
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
|
|
info, NoTex, null,
|
|
// The resolver seam models DatStringResolver's post-decode
|
|
// output: a REAL '\n', plus a literal backslash pair that a
|
|
// stray second decode would corrupt into a line break.
|
|
stringResolve: value => value.StringId == stringId
|
|
? "Attribute\n Credits \\not-an-escape"
|
|
: null));
|
|
|
|
Assert.Equal("Attribute\n Credits \\not-an-escape", button.Label);
|
|
}
|
|
|
|
private static UiButton ButtonWithStates(params string[] states)
|
|
{
|
|
var info = ButtonInfo(states);
|
|
AddBoolProperty(info, 0x13u, true);
|
|
return CreateButton(info);
|
|
}
|
|
|
|
// ── 2026-08-17 morning gate finding 3: the PassToChildren cascade +
|
|
// per-state P0x3B (Invisible) honor. Fixture mirrors the live map
|
|
// town-hotspot template (0x100001F0 in 0x21000026, raw-DAT-dumped):
|
|
// a media-less RolloverEnabled button whose Normal/Normal_rollover
|
|
// descriptors are PassToChildren and whose highlight child authors
|
|
// Normal={0x3B:true} / Normal_rollover={0x3B:false}. ──
|
|
|
|
private static ElementInfo MapNoteShapedInfo(bool passToChildren = true)
|
|
{
|
|
var info = new ElementInfo { Type = 1, Width = 10, Height = 10 };
|
|
AddBoolProperty(info, 0x13u, true); // P0x13 RolloverEnabled
|
|
info.States[UiButtonStateMachine.Normal] = new UiStateInfo
|
|
{
|
|
Id = UiButtonStateMachine.Normal,
|
|
Name = "Normal",
|
|
PassToChildren = passToChildren,
|
|
};
|
|
info.States[UiButtonStateMachine.NormalRollover] = new UiStateInfo
|
|
{
|
|
Id = UiButtonStateMachine.NormalRollover,
|
|
Name = "Normal_rollover",
|
|
PassToChildren = passToChildren,
|
|
};
|
|
return info;
|
|
}
|
|
|
|
private static UiDatElement HighlightChild()
|
|
{
|
|
var info = new ElementInfo { Type = 3, Width = 10, Height = 10 };
|
|
var normal = new UiStateInfo { Id = 1, Name = "Normal" };
|
|
normal.Properties.Values[0x3Bu] = new UiPropertyValue
|
|
{ Kind = UiPropertyKind.Bool, BoolValue = true };
|
|
var rollover = new UiStateInfo { Id = 2, Name = "Normal_rollover" };
|
|
rollover.Properties.Values[0x3Bu] = new UiPropertyValue
|
|
{ Kind = UiPropertyKind.Bool, BoolValue = false };
|
|
info.States[1] = normal;
|
|
info.States[2] = rollover;
|
|
return new UiDatElement(info, NoTex);
|
|
}
|
|
|
|
[Fact]
|
|
public void PassToChildrenStates_CascadeToStatefulChildren_DrivingPerStateInvisible()
|
|
{
|
|
// Retail UIElement::SetState @0x00464E70's PassToChildren cascade +
|
|
// OnSetAttribute @0x00462d80 case 8 (P0x3B -> SetVisible(value==0)):
|
|
// the map marker's green rollover frame is hidden at rest and shown
|
|
// only while the pointer is over the button.
|
|
var b = CreateButton(MapNoteShapedInfo());
|
|
UiDatElement kid = HighlightChild();
|
|
b.AddChild(kid);
|
|
Assert.True(kid.Visible); // pre-cascade construction default
|
|
|
|
// The initial Normal application (retail's own initial UpdateState_)
|
|
// hides the highlight.
|
|
Assert.True(b.TrySetRetailState(UiButtonStateMachine.Normal));
|
|
Assert.False(kid.Visible);
|
|
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.HoverEnter));
|
|
Assert.True(kid.Visible); // Normal_rollover: P0x3B=false -> shown
|
|
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.HoverLeave));
|
|
Assert.False(kid.Visible); // back to Normal: P0x3B=true -> hidden
|
|
}
|
|
|
|
[Fact]
|
|
public void StatesWithoutPassToChildren_DoNotCascade()
|
|
{
|
|
var b = CreateButton(MapNoteShapedInfo(passToChildren: false));
|
|
UiDatElement kid = HighlightChild();
|
|
b.AddChild(kid);
|
|
|
|
b.TrySetRetailState(UiButtonStateMachine.Normal);
|
|
b.OnEvent(new UiEvent(0, b, UiEventType.HoverEnter));
|
|
|
|
Assert.True(kid.Visible); // never cascaded, never hidden/shown
|
|
}
|
|
|
|
[Fact]
|
|
public void UiDatElement_TrySetRetailState_HonorsPerStateInvisible()
|
|
{
|
|
// The child half in isolation — the state-transition application of
|
|
// dat property 0x3B, distinct from ElementReader.Invisible's
|
|
// construction-time effective read (GF-13/AP-230).
|
|
UiDatElement kid = HighlightChild();
|
|
Assert.True(kid.Visible);
|
|
|
|
Assert.True(kid.TrySetRetailState(1u));
|
|
Assert.False(kid.Visible);
|
|
|
|
Assert.True(kid.TrySetRetailState(2u));
|
|
Assert.True(kid.Visible);
|
|
}
|
|
|
|
private static ElementInfo ButtonInfo(params string[] states)
|
|
{
|
|
var info = new ElementInfo { Type = 1, Width = 20, Height = 20 };
|
|
uint file = 1;
|
|
foreach (string state in states)
|
|
info.StateMedia[state] = (file++, 1);
|
|
if (states.Length > 0)
|
|
info.DefaultStateName = states[0];
|
|
return info;
|
|
}
|
|
|
|
private static void AddBoolProperty(ElementInfo info, uint id, bool value)
|
|
{
|
|
if (!info.States.TryGetValue(UiStateInfo.DirectStateId, out var state))
|
|
{
|
|
state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
info.States[UiStateInfo.DirectStateId] = state;
|
|
}
|
|
state.Properties.Values[id] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.Bool,
|
|
BoolValue = value,
|
|
};
|
|
}
|
|
|
|
private static void AddFloatProperty(ElementInfo info, uint id, float value)
|
|
{
|
|
if (!info.States.TryGetValue(UiStateInfo.DirectStateId, out var state))
|
|
{
|
|
state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
info.States[UiStateInfo.DirectStateId] = state;
|
|
}
|
|
state.Properties.Values[id] = new UiPropertyValue
|
|
{
|
|
Kind = UiPropertyKind.Float,
|
|
FloatValue = value,
|
|
};
|
|
}
|
|
|
|
private static UiButton CreateButton(ElementInfo info)
|
|
=> new(info, NoTex) { Width = info.Width, Height = info.Height };
|
|
|
|
/// <summary>
|
|
/// #420 regression. A multi-segment face whose committed state authors no
|
|
/// media used to leave that segment's media-state name NULL (the array
|
|
/// started as <c>new string[n]</c> and NextMediaState returns the previous
|
|
/// value unchanged on that arm), and the null then reached
|
|
/// <c>ElementInfo.StateMedia.TryGetValue</c>, throwing
|
|
/// ArgumentNullException from inside OnDraw. Live symptom: the client
|
|
/// crashed on the character-select screen on every launch.
|
|
///
|
|
/// <para>The assertion is secondary — the point is that drawing COMPLETES.
|
|
/// Before the fix this test throws instead of failing.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void MultiSegmentFace_CommittedStateWithoutMedia_DrawsInsteadOfThrowing()
|
|
{
|
|
var info = new ElementInfo { Type = 1, Width = 32, Height = 16 };
|
|
var button = new UiButton(
|
|
info,
|
|
static file => (file, 8, 8),
|
|
mediaInfo: null,
|
|
faceSegments: [new ElementInfo { Type = 1, Width = 16, Height = 16 }])
|
|
{
|
|
Width = info.Width,
|
|
Height = info.Height,
|
|
};
|
|
|
|
Assert.Equal(0u, DrawnFaceFile(button));
|
|
}
|
|
|
|
// ── #416 media-rule draw harness ─────────────────────────────────────
|
|
|
|
private sealed class NullGpuFrameSource
|
|
: AcDream.App.Rendering.ICurrentGpuFrameSource
|
|
{
|
|
public AcDream.App.Rendering.Gpu.IGpuFrame? CurrentFrame => null;
|
|
}
|
|
|
|
/// <summary>Identity resolve (texture handle == file id) so the drawn
|
|
/// face file is directly observable through the recording renderer.</summary>
|
|
private static UiButton CreateDrawableButton(ElementInfo info)
|
|
=> new(info, static file => (file, 8, 8))
|
|
{
|
|
Width = info.Width,
|
|
Height = info.Height,
|
|
};
|
|
|
|
private static uint DrawnFaceFile(UiButton button)
|
|
{
|
|
var device = new AcDream.App.Tests.Rendering.Gpu.RecordingGpuDevice();
|
|
var renderer = new AcDream.App.Rendering.TextRenderer(
|
|
device, new NullGpuFrameSource(), "unused");
|
|
renderer.Begin(new System.Numerics.Vector2(200f, 200f));
|
|
var ctx = new UiRenderContext(
|
|
renderer, new System.Numerics.Vector2(200f, 200f));
|
|
button.DrawSelfAndChildren(ctx);
|
|
return renderer.DebugSpriteSegmentVerts.Count == 0
|
|
? 0u
|
|
: renderer.DebugSpriteSegmentVerts[0].Item1;
|
|
}
|
|
}
|