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

@ -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