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

@ -37,6 +37,9 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
private readonly FaceSegment[] _faceSegments;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
private readonly HashSet<uint> _availableStates = new();
private readonly bool _hasCustomSelectionPair;
private IReadOnlyDictionary<uint, Vector4>? _stateLabelColors;
private IReadOnlyDictionary<uint, bool>? _stateLabelOutlines;
private bool _pressed;
private bool _pointerOver;
private bool _selected;
@ -157,6 +160,25 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots.</summary>
public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center;
/// <summary>
/// GF-11c (Campaign CC gate round 1 Batch B): optional authored label
/// rectangle, LOCAL to this button. When a caption is LIFTED from a
/// DISTINCT Type-12 child that carries its own independent rect (e.g.
/// the Town page's per-marker name label, positioned below/beside its
/// marker rather than immediately right of it), <see cref="OnDraw"/>
/// draws the label within THIS box using its own authored geometry
/// instead of the FaceLeft-derived offset / full-button-width centering
/// the ordinary case uses (label authored directly on the button, right
/// beside a single-purpose face segment — the heritage/template/Face-
/// Clothes row family, where the current face-relative math is already
/// correct). Null (default, every pre-existing button) preserves the
/// EXACT prior draw math — <see cref="LabelAlignment.Left"/> still adds
/// <see cref="LabelOffsetX"/> to the button's own local origin, and
/// <see cref="LabelAlignment.Center"/> still centers within the whole
/// button width/height.
/// </summary>
public (float X, float Y, float Width, float Height)? LabelBox { get; set; }
/// <summary>Label horizontal alignment options.</summary>
public enum LabelAlignment { Center, Left }
@ -330,6 +352,21 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
foreach (FaceSegment segment in _faceSegments)
AddAvailableStates(segment.Info);
// Campaign CC gate round 1 Batch B (GF-1/GF-8): retail's custom
// "Unselected"/"Selected" radio-selection state pair
// (RetailUiStateIds.Unselected/Selected, 0x10000016/0x10000017) is
// authored as STATE DESCRIPTORS whose names UiButtonStateMachine's
// Normal/Highlight machine doesn't recognize — the standard
// AddAvailableStates loop above never admits them, so the ordinary
// RequestedState()-driven UpdateVisualState can never select them
// (measured: Selected=true committed nothing against the installed
// dat before this fix). HasStateMedia already checks the same media
// presence (face-segment child OR the button's own StateMedia) used
// everywhere else in this class, so this reuses that exact
// detection rather than adding a new one.
_hasCustomSelectionPair = HasStateMedia(RetailUiStateIds.StateName(RetailUiStateIds.Unselected))
&& HasStateMedia(RetailUiStateIds.StateName(RetailUiStateIds.Selected));
ToggleBehavior = info.TryGetEffectiveBool(0x0Bu, out bool toggle) && toggle;
RolloverEnabled = info.TryGetEffectiveBool(0x13u, out bool rollover) && rollover;
HotClickEnabled = info.TryGetEffectiveBool(0x0Fu, out bool hotClick) && hotClick;
@ -402,10 +439,17 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
if (Label is { Length: > 0 } label && LabelFont is { } lf)
{
// GF-11c: LabelBox null (every pre-existing button) reduces boxX/
// boxY to 0 and boxWidth/boxHeight to the button's own Width/
// Height — byte-identical to the prior unconditional math.
float boxX = LabelBox?.X ?? 0f;
float boxY = LabelBox?.Y ?? 0f;
float boxWidth = LabelBox?.Width ?? Width;
float boxHeight = LabelBox?.Height ?? Height;
float tx = LabelAlign == LabelAlignment.Left
? LabelOffsetX
: (Width - lf.MeasureWidth(label)) * 0.5f; // centered (default)
float ty = (Height - lf.LineHeight) * 0.5f;
? boxX + LabelOffsetX
: boxX + (boxWidth - lf.MeasureWidth(label)) * 0.5f; // centered (default)
float ty = boxY + (boxHeight - lf.LineHeight) * 0.5f;
ctx.DrawStringDat(lf, label, tx, ty, LabelColor, Outline, OutlineColor);
}
@ -638,13 +682,77 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
private void UpdateVisualState()
{
uint requested = UiButtonStateMachine.RequestedState(new UiButtonVisualInput(
Disabled: !Enabled,
Selected: _selected,
RolloverEnabled: RolloverEnabled,
Pressed: _pressed,
PointerOver: _pointerOver));
if (_availableStates.Contains(requested))
uint requested = ComputeRequestedStateId();
if (_hasCustomSelectionPair)
{
// gmCGHeritagePage::Update @0x00483219-0x0048372D (and the
// mirrored template/sub-tab/gender call sites): retail sets
// this pair directly by SELECTION, not through the ordinary
// Normal/Highlight/rollover/pressed machine — these buttons
// never author rollover or pressed media for the pair, so
// there is nothing faithful to compute beyond selected-or-not.
ActiveState = RetailUiStateIds.StateName(requested);
}
else if (_availableStates.Contains(requested))
{
ActiveState = UiButtonStateMachine.StateName(requested);
}
// AP-222: apply the per-state label style off the REQUESTED id, not
// the (possibly art-gated) committed ActiveState — retail's own
// SetState(6) commits the state's PROPERTIES (including text color)
// unconditionally; only the SPRITE draw silently no-ops when a
// state has no media (this class's own #382 comment on
// TrySetRetailState documents the same distinction). The
// Appearance spins' current-part highlight is exactly this case:
// _availableStates never contains Highlight (their arrow face
// segments carry no Highlight art), so ActiveState stays "Normal"
// forever, but the spin's OWN label color must still swap.
ApplyPerStateLabelStyle(requested);
}
private uint ComputeRequestedStateId()
=> _hasCustomSelectionPair
? (_selected ? RetailUiStateIds.Selected : RetailUiStateIds.Unselected)
: UiButtonStateMachine.RequestedState(new UiButtonVisualInput(
Disabled: !Enabled,
Selected: _selected,
RolloverEnabled: RolloverEnabled,
Pressed: _pressed,
PointerOver: _pointerOver));
/// <summary>
/// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): optional per-
/// RETAIL-STATE label color/outline override, additive over the single
/// default <see cref="LabelColor"/>/<see cref="Outline"/> lifted once at
/// construction. Set by <see cref="Layout.DatWidgetFactory"/> ONLY when
/// the authored dat genuinely carries more than one distinct value
/// across this button's (or its lifted caption child's) own states —
/// e.g. the Appearance spins' Highlight-state gold brightening
/// (dat properties <c>0x1B</c>/<c>0x21</c>, live-DAT-measured
/// 218,167,85 -&gt; 255,221,131 plus outline off -&gt; on) or the Town
/// buttons' Normal-to-white caption swap (218,167,85 -&gt; 255,255,255).
/// A button with a single authored color (the overwhelming majority)
/// never calls this, so <see cref="LabelColor"/>/<see cref="Outline"/>
/// keep behaving exactly as before — including every existing external
/// post-construction assignment (e.g. <c>ChatWindowController</c>'s Send
/// caption, <c>PaperdollController</c>'s Slots label), none of which
/// author a second distinct per-state color.
/// </summary>
internal void SetPerStateLabelStyle(
IReadOnlyDictionary<uint, Vector4>? colors,
IReadOnlyDictionary<uint, bool>? outlines)
{
_stateLabelColors = colors;
_stateLabelOutlines = outlines;
ApplyPerStateLabelStyle(ComputeRequestedStateId());
}
private void ApplyPerStateLabelStyle(uint requestedStateId)
{
if (_stateLabelColors is { } colors && colors.TryGetValue(requestedStateId, out Vector4 color))
LabelColor = color;
if (_stateLabelOutlines is { } outlines && outlines.TryGetValue(requestedStateId, out bool outline))
Outline = outline;
}
}