fix(chargen): Campaign CC gate round 1 Batch B — authored selection states, label state, zoom/swatch feedback

GF-1/GF-8: UiButton now recognizes retail's custom Unselected/Selected
radio-pair (0x10000016/0x10000017), bypassing the standard Normal/
Highlight machine that never admitted those state names — .Selected now
lights the heritage/template/gender/Face-Clothes rows it was always a
no-op for.

AP-222/GF-11b: per-state label color/outline (dat 0x1B/0x21) now applies
off the REQUESTED retail state id, not the art-gated committed
ActiveState — resolves the Appearance spins' current-part highlight
(text recolors even though no Highlight art exists on either client) and
the Town caption's Normal-to-white swap.

GF-11c: UiButton.LabelBox lets a lifted caption with its own authored
rect draw there instead of the face-relative offset that's only correct
when the label is authored directly on the button (heritage/template
family, unchanged).

GF-9: wires the real nine companion overlay elements (SetColor's
SetVisible mechanism) that swatch clicks were always meant to drive,
retiring AP-215 item 1 (the swatch.Selected substitution was a permanent
no-op — swatches author no Highlight media at all).

GF-10: zoom buttons now set the retail-mirrored mutual-exclusive
Highlight/Normal pair on click; InitializePage carries no initial
SetState for either button, so both stay at "Normal" until first click.

Register: AP-222 retired (mechanism identified and ported), AP-215
narrowed (item 1 retired, item 2 unrelated and unchanged), row count
recount corrected 164 (was already one high before this batch).

App suite 5282/3 (was 5266/3), Runtime 1735/0 unchanged. Fixture + live-
DAT tests only — no graphical client launch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 11:37:09 +02:00
parent 1d9de5e095
commit 7d09821fdc
11 changed files with 1043 additions and 42 deletions

View file

@ -1,5 +1,6 @@
using System.IO;
using System.Linq;
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Content;
@ -278,6 +279,239 @@ public sealed class CharacterCreationLiveDatTests
UiElement.FindDescendant(townRoot, 0x10000409u));
}
/// <summary>
/// GF-11b/GF-11c (Campaign CC gate round 1 Batch B). Live-DAT-measured:
/// each town button's marker (<c>0x1000040D</c>'s own child
/// <c>0x1000040C</c>) and its per-button name caption both carry the
/// SAME numeric id <c>0x10000409</c> as the page-level description
/// panel found by the sibling test above — a genuine id collision in
/// the installed dat between two DIFFERENT elements in DIFFERENT
/// subtrees (harmless for <c>DatWidgetFactory.BuildButton</c>'s lift,
/// which walks the button's OWN <c>ElementInfo.Children</c> list rather
/// than resolving by a global id lookup — but it means this test
/// verifies the BUILT BUTTON's own <see cref="UiButton.Label"/>/
/// <see cref="UiButton.LabelBox"/>/<see cref="UiButton.LabelColor"/>,
/// not a second <c>FindDescendant</c> call, which would ambiguously
/// return the unrelated page-level panel). Pins: the caption's own
/// authored rect (0,4,100,37) survives instead of being overwritten by
/// the marker-face-relative offset (GF-11c), and its color swaps
/// Normal (218,167,85) -&gt; Highlight/white (255,255,255) on selection
/// (GF-11b).
/// </summary>
[InstalledDatFact]
public void TownPage_HoltburgButton_CaptionHonorsOwnRectAndRecolorsWhiteOnSelection()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement townRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.TownPageElementId));
UiButton holtburg = AssertButton(townRoot, 0x1000040Du);
Assert.Equal("Holtburg", holtburg.Label);
Assert.Equal(UiButton.LabelAlignment.Center, holtburg.LabelAlign);
Assert.Equal((0f, 4f, 100f, 37f), holtburg.LabelBox);
holtburg.Selected = false;
Assert.Equal(new Vector4(218f / 255f, 167f / 255f, 85f / 255f, 1f), holtburg.LabelColor);
holtburg.Selected = true;
Assert.Equal(new Vector4(1f, 1f, 1f, 1f), holtburg.LabelColor);
}
/// <summary>
/// GF-1 (Campaign CC gate round 1 Batch B). Live-DAT-measured: the
/// Heritage row (<c>0x100003BFu</c>, Aluvian) authors retail's custom
/// "Unselected"/"Selected" radio-pair state DESCRIPTORS directly on the
/// row (property-only, no media), with the actual per-state art on its
/// single stateful dot child (<c>0x100003C0</c>). Before this fix,
/// <see cref="UiButton.Selected"/> committed nothing here.
/// </summary>
[InstalledDatFact]
public void HeritagePage_Row_SelectedTogglesTheAuthoredRadioDotState()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement heritageRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.HeritagePageElementId));
UiButton aluvian = AssertButton(heritageRoot, 0x100003BFu);
Assert.Equal("Unselected", aluvian.ActiveState);
aluvian.Selected = true;
Assert.Equal("Selected", aluvian.ActiveState);
Assert.Equal(RetailUiStateIds.Selected, aluvian.ActiveRetailStateId);
aluvian.Selected = false;
Assert.Equal("Unselected", aluvian.ActiveState);
}
/// <summary>
/// GF-1 counterpart: the Profession template row (<c>0x100003D9u</c>,
/// Custom/Adventurer) authors the identical custom radio-pair shape on
/// its own icon child (<c>0x100002E9</c>).
/// </summary>
[InstalledDatFact]
public void ProfessionPage_TemplateButton_SelectedTogglesTheAuthoredIconState()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement professionRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.ProfessionPageElementId));
UiButton template = AssertButton(professionRoot, 0x100003D9u);
Assert.Equal("Unselected", template.ActiveState);
template.Selected = true;
Assert.Equal("Selected", template.ActiveState);
template.Selected = false;
Assert.Equal("Unselected", template.ActiveState);
}
/// <summary>
/// GF-8: the Appearance page's Face/Clothes sub-tab buttons
/// (<c>0x100003A9u</c>/<c>0x100003AAu</c>) author the SAME custom
/// radio-pair shape on their own icon child (<c>0x100002E9</c>) — same
/// mechanism as the heritage/template rows, different page.
/// </summary>
[InstalledDatFact]
public void AppearancePage_FaceClothesSubTabButtons_SelectedTogglesTheAuthoredState()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement appearanceRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.AppearancePageElementId));
foreach (uint buttonId in new[]
{
CharacterCreationAppearancePage.FaceButtonId,
CharacterCreationAppearancePage.ClothesButtonId,
})
{
UiButton button = AssertButton(appearanceRoot, buttonId);
Assert.Equal("Unselected", button.ActiveState);
button.Selected = true;
Assert.Equal("Selected", button.ActiveState);
button.Selected = false;
Assert.Equal("Unselected", button.ActiveState);
}
}
/// <summary>
/// GF-1 family, gender-button shape: <c>0x100003A7u</c>/
/// <c>0x100003A8u</c> author the custom Unselected/Selected media
/// DIRECTLY on the button's own StateMedia (no separate face-segment
/// child) — live-DAT-measured, distinct from the heritage/template/
/// sub-tab family above. Exercises <see cref="UiButton"/>'s OTHER
/// custom-selection-pair code path (media on the button itself, so
/// <c>info.StateMedia.Count != 0</c> and no face child is ever
/// computed).
/// </summary>
[InstalledDatFact]
public void AppearancePage_GenderButtons_SelectedTogglesTheAuthoredState()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement appearanceRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.AppearancePageElementId));
foreach (uint buttonId in new[]
{
CharacterCreationAppearancePage.FemaleButtonId,
CharacterCreationAppearancePage.MaleButtonId,
})
{
UiButton button = AssertButton(appearanceRoot, buttonId);
Assert.Equal("Unselected", button.ActiveState);
button.Selected = true;
Assert.Equal("Selected", button.ActiveState);
button.Selected = false;
Assert.Equal("Unselected", button.ActiveState);
}
}
/// <summary>
/// GF-9 (Campaign CC gate round 1 Batch B). Live-DAT-measured: all nine
/// companion overlay elements (<c>SwatchOverlayIds</c>) resolve as
/// siblings of the swatches under the color-wheel container — retail's
/// ACTUAL click-feedback mechanism (<c>SetColor</c>'s
/// <c>m_tColorWheel[...][0x10][iCurColor*7]-&gt;SetVisible</c>), not a
/// state swap on the swatch buttons themselves (which author only an
/// unnamed DirectState sprite — no Normal/Highlight media at all).
/// </summary>
[InstalledDatFact]
public void AppearancePage_SwatchOverlays_AllNinePresent()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement appearanceRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.AppearancePageElementId));
foreach (uint overlayId in CharacterCreationAppearancePage.SwatchOverlayIds)
{
UiElement overlay = Assert.IsAssignableFrom<UiElement>(
UiElement.FindDescendant(appearanceRoot, overlayId));
// Retail's overlay ring starts hidden — SetColor only shows the
// one at the current color index; nothing is selected before
// any color choice runs.
Assert.True(overlay.Visible);
}
}
/// <summary>
/// GF-10 (Campaign CC gate round 1 Batch B). Live-DAT-measured: both
/// zoom buttons author a STANDARD Normal/Highlight(/rollover) pair —
/// unlike the custom radio-pair family above, this is pure wiring
/// (<see cref="CharacterCreationAppearancePage"/>'s click handlers), not
/// a new UiButton mechanism.
/// </summary>
[InstalledDatFact]
public void AppearancePage_ZoomButtons_AuthorStandardNormalHighlightPair()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement appearanceRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.AppearancePageElementId));
foreach (uint buttonId in new[]
{
CharacterCreationAppearancePage.ZoomInId,
CharacterCreationAppearancePage.ZoomOutId,
})
{
UiButton button = AssertButton(appearanceRoot, buttonId);
Assert.Equal("Normal", button.ActiveState);
Assert.True(button.TrySetRetailState(UiButtonStateMachine.Highlight));
Assert.Equal("Highlight", button.ActiveState);
Assert.True(button.TrySetRetailState(UiButtonStateMachine.Normal));
Assert.Equal("Normal", button.ActiveState);
}
}
/// <summary>The exit-warning + all per-heritage/per-town DAT string
/// keys this slice cites actually resolve in the installed table.
/// </summary>
@ -401,6 +635,25 @@ public sealed class CharacterCreationLiveDatTests
spin.TrySetRetailState(UiButtonStateMachine.Highlight),
$"spin 0x{spinId:X8} must accept a Highlight state request.");
Assert.Equal("Normal", spin.ActiveState);
// AP-222 CORRECTED + RESOLVED (Campaign CC gate round 1 Batch B):
// the art half of the "no-op" stays a genuine no-op (ActiveState
// pinned to "Normal" above, unchanged) — retail's own spin art
// authors no Highlight media either, matching acdream. But the
// current-part highlight is NOT presentation-dead: retail's
// SetState(6) also recolors the spin's caption text (dat
// property 0x1B), live-DAT-measured 218,167,85 (Normal) ->
// 255,221,131 (Highlight), plus outline off -> on (property
// 0x21). DatWidgetFactory.BuildButton wires this per-state style
// unconditionally, so it is already active on `spin` from
// TrySetRetailState(Highlight) above, independent of whether the
// page has assigned a Label string yet.
Assert.Equal(new Vector4(255f / 255f, 221f / 255f, 131f / 255f, 1f), spin.LabelColor);
Assert.True(spin.Outline, $"spin 0x{spinId:X8} must outline its label in the Highlight state.");
Assert.True(spin.TrySetRetailState(UiButtonStateMachine.Normal));
Assert.Equal(new Vector4(218f / 255f, 167f / 255f, 85f / 255f, 1f), spin.LabelColor);
Assert.False(spin.Outline, $"spin 0x{spinId:X8} must not outline its label in the Normal state.");
}
// Every color-wheel-family id resolves through EXISTING

View file

@ -785,6 +785,52 @@ public sealed class CharacterCreationUiControllerTests
Assert.Equal(0, environment.Runtime.AppearanceIndexCallCount);
}
/// <summary>
/// GF-9 (Campaign CC gate round 1 Batch B): retail's ACTUAL swatch click
/// feedback — exactly one companion overlay visible, tracking the
/// current part's own selected color index (<c>SetColor</c>'s
/// <c>m_tColorWheel[...][0x10][iCurColor*7]-&gt;SetVisible</c>). Drives
/// the snapshot directly (the fake binding only records what a click
/// SENDS, it doesn't feed it back) to exercise
/// <c>RefreshColorAndShadeControls</c>'s own overlay loop end to end.
/// </summary>
[Fact]
public void AppearanceSwatchOverlays_ExactlyOneVisible_TrackingTheCurrentPartsColorIndex()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
// Part defaults to Hair on construction — no extra click needed.
UiElement[] overlays = [.. CharacterCreationAppearancePage.SwatchOverlayIds
.Select(environment.Page)];
// No color selected yet (Unset) -> every overlay hidden.
Assert.All(overlays, overlay => Assert.False(overlay.Visible));
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
environment.Runtime.View.Snapshot = snapshot with
{
Revision = snapshot.Revision + 1,
Appearance = snapshot.Appearance with { HairColor = 1u },
};
environment.Controller.Tick();
for (int i = 0; i < overlays.Length; i++)
Assert.Equal(i == 1, overlays[i].Visible);
snapshot = environment.Runtime.View.Snapshot;
environment.Runtime.View.Snapshot = snapshot with
{
Revision = snapshot.Revision + 1,
Appearance = snapshot.Appearance with { HairColor = 2u },
};
environment.Controller.Tick();
for (int i = 0; i < overlays.Length; i++)
Assert.Equal(i == 2, overlays[i].Visible);
}
[Fact]
public void AppearanceShadeScroll_ScalarChanged_SetsShadeForTheCurrentPart()
{
@ -848,6 +894,48 @@ public sealed class CharacterCreationUiControllerTests
environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!();
}
/// <summary>
/// GF-10 (Campaign CC gate round 1 Batch B): ports
/// <c>gmCGAppearancePage::ZoomIn @0x0047CF00</c>
/// (<c>@0x0047d005/0x0047d00f</c>: ZoomInButton -&gt; Highlight(6),
/// ZoomOutButton -&gt; Normal(1)) and its <c>ZoomOut</c> mirror
/// (<c>@0x0047D050</c>, <c>@0x0047d140/0x0047d14a</c>). Both buttons
/// start at their DAT-authored "Normal" default — re-derived from
/// <c>InitializePage @0x0047fdd0-0048032e</c>: <c>m_bZoomedIn = 0</c> is
/// set at construction (<c>@0x004802c3</c>) but NO explicit initial
/// <c>SetState</c> call exists for either zoom button anywhere in
/// <c>InitializePage</c>, so this port does not force one either.
/// </summary>
[Fact]
public void AppearanceZoomButtons_ClickPath_TogglesMutualExclusiveHighlightPair()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
var preview = new FakeChargenPreviewControl();
environment.Controller.AppearancePreviewControl = preview;
UiButton zoomIn = environment.Button(CharacterCreationAppearancePage.ZoomInId);
UiButton zoomOut = environment.Button(CharacterCreationAppearancePage.ZoomOutId);
Assert.Equal("Normal", zoomIn.ActiveState);
Assert.Equal("Normal", zoomOut.ActiveState);
zoomIn.OnClick!();
Assert.Equal("Highlight", zoomIn.ActiveState);
Assert.Equal("Normal", zoomOut.ActiveState);
zoomOut.OnClick!();
Assert.Equal("Normal", zoomIn.ActiveState);
Assert.Equal("Highlight", zoomOut.ActiveState);
// Re-asserting the SAME direction is idempotent (retail's own early-
// return branch when already zoomed in/out — this port doesn't
// track m_bZoomedIn, but the RESULT is identical either way).
zoomOut.OnClick!();
Assert.Equal("Normal", zoomIn.ActiveState);
Assert.Equal("Highlight", zoomOut.ActiveState);
}
private static void SelectAluvianMale(EnvironmentHarness environment)
{
environment.Runtime.SelectHeritageDirect(AluvianId);
@ -2151,6 +2239,11 @@ public sealed class CharacterCreationUiControllerTests
foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds)
page.Children.Add(ButtonInfo(swatchId));
// GF-9: the nine companion overlay elements, live-DAT-measured as
// plain Type-3 siblings of the swatches under the color-wheel
// container.
foreach (uint overlayId in CharacterCreationAppearancePage.SwatchOverlayIds)
page.Children.Add(ContainerInfo(overlayId));
page.Children.Add(ScrollbarInfo(CharacterCreationAppearancePage.ShadeScrollId));
page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.GradCircleId));
@ -2166,12 +2259,27 @@ public sealed class CharacterCreationUiControllerTests
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateClockwiseId));
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateCounterClockwiseId));
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ZoomInId));
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ZoomOutId));
// GF-10: unlike the plain ButtonInfo() used above, the zoom buttons
// need REAL Normal/Highlight media so AppearanceZoomButtons_
// ClickPath_TogglesMutualExclusiveHighlightPair can observe the
// actual mutual-exclusive state swap through TrySetRetailState —
// live-DAT-measured shape (both start "Normal", both author
// Highlight/rollover media).
page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomInId));
page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomOutId));
return page;
}
private static ElementInfo ZoomButtonInfo(uint id)
{
var info = new ElementInfo { Id = id, Type = 1u, Width = 81f, Height = 38f };
info.StateMedia["Normal"] = (0x06004D55u, 1);
info.StateMedia["Highlight"] = (0x06004D56u, 1);
info.DefaultStateName = "Normal";
return info;
}
private static ElementInfo SpinInfo(uint id)
{
var spin = new ElementInfo

View file

@ -366,6 +366,96 @@ public class DatWidgetFactoryTests
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
}
// ── Test 5b: Type 11 → UiScrollbar ──────────────────────────────────────
[Fact]

View file

@ -249,6 +249,171 @@ public class UiButtonTests
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 (Campaign CC gate round 1 Batch B): per-state label
/// color/outline reacts to the REQUESTED retail state id even when the
/// standard art-availability gate never lets ActiveState reach it — the
/// Appearance spins' exact shape (their arrow face segments carry no
/// Highlight media at all, so ActiveState is permanently stuck at
/// "Normal", but the label text must still recolor). Live-DAT-measured
/// values: Normal (218,167,85), Highlight (255,221,131), outline
/// off -&gt; on.
/// </summary>
[Fact]
public void PerStateLabelStyle_AppliesEvenWhenActiveStateCannotReachIt()
{
var info = ButtonInfo("Normal"); // no Highlight media at all
AddBoolProperty(info, 0x0Bu, true); // ToggleBehavior
var b = CreateButton(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);
b.Selected = true;
// The art stays "Normal" (no Highlight media exists to commit to) —
// this is the exact AP-222 no-op the standard gate always produced —
// but the label color/outline must still reach the Highlight values.
Assert.Equal("Normal", b.ActiveState);
Assert.Equal(colors[UiButtonStateMachine.Highlight], b.LabelColor);
Assert.True(b.Outline);
}
/// <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);
}
private static UiButton ButtonWithStates(params string[] states)
{
var info = ButtonInfo(states);