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

@ -126,6 +126,31 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
0x10000314u, 0x10000315u, 0x10000316u, 0x10000317u,
];
/// <summary>
/// GF-9 (Campaign CC gate round 1 Batch B): the nine Type-3 companion
/// "selected" overlay elements, one per <see cref="SwatchIds"/> entry at
/// the SAME index — retail <c>gmCGAppearancePage::InitializePage</c>'s
/// own id-pair table (<c>@0x004800ff-00480164</c>, the switch that fills
/// <c>m_tColorWheel[i][0x10]</c>/<c>[0x14]</c>-ish offsets with the
/// swatch/overlay id pair per index) resolves the SAME nine ids this
/// array carries, in the SAME index order. <c>SetColor</c> (case
/// <c>0x0047DD50</c>, <c>iCurColor</c> assignment) is retail's actual
/// click-feedback mechanism — <c>SetVisible</c> on the overlay at
/// <c>iCurColor</c>'s index, NOT a state swap on the swatch itself (the
/// swatch buttons author only an unnamed DirectState sprite; measured
/// against the installed dat, they have NO Normal/Highlight media at
/// all, so <see cref="UiButton.Selected"/> was always a complete no-op
/// here — see <see cref="RefreshColorAndShadeControls"/>). Live-DAT-
/// probe-confirmed siblings of the swatches under the same color-wheel
/// container (<c>0x100003B9</c>), each roughly centered on its paired
/// swatch's own rect.
/// </summary>
internal static readonly uint[] SwatchOverlayIds =
[
0x10000318u, 0x10000319u, 0x1000031Au, 0x1000031Bu, 0x1000031Cu,
0x1000031Du, 0x1000031Eu, 0x1000031Fu, 0x10000320u,
];
/// <summary>Live-DAT-measured arrow geometry, uniform across all nine
/// spins (every one is 200px wide): decrement child at local
/// x=[80,127), increment child at x=[127,174). Anything outside both
@ -144,6 +169,7 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
private readonly UiElement? _clothesChoices;
private readonly Dictionary<Part, UiButton> _spins = [];
private readonly UiButton?[] _swatches = new UiButton?[SwatchIds.Length];
private readonly UiElement?[] _swatchOverlays = new UiElement?[SwatchOverlayIds.Length];
private readonly UiScrollbar? _shadeScroll;
private readonly UiButton? _rotateClockwise;
private readonly UiButton? _rotateCounterClockwise;
@ -209,6 +235,9 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
_swatches[i] = swatch;
}
for (int i = 0; i < SwatchOverlayIds.Length; i++)
_swatchOverlays[i] = Find<UiElement>(pageRoot, SwatchOverlayIds[i]);
_shadeScroll = Find<UiScrollbar>(pageRoot, ShadeScrollId);
if (_shadeScroll is not null)
_shadeScroll.ScalarChanged = SetShadeFromScalar;
@ -223,10 +252,33 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
_rotateCounterClockwise.OnClick = () => PreviewControl?.RotateCounterClockwise();
_zoomIn = Find<UiButton>(pageRoot, ZoomInId);
if (_zoomIn is not null)
_zoomIn.OnClick = () => PreviewControl?.ZoomIn();
_zoomIn.OnClick = () =>
{
PreviewControl?.ZoomIn();
// GF-10: gmCGAppearancePage::ZoomIn @0x0047CF00
// (@0x0047d005/0x0047d00f) ends ZoomInButton->SetState(6)
// (Highlight), ZoomOutButton->SetState(1) (Normal) — a
// mutual-exclusive pair. Re-derived from InitializePage
// @0x0047fdd0-0048032e (m_bZoomedIn = 0 at construction,
// @0x004802c3): NO explicit initial SetState call exists
// for either button, so both start at their DAT-authored
// "Normal" default (live-DAT-probe-confirmed) until the
// first real zoom click — this port does not force an
// initial Highlight.
_zoomIn.TrySetRetailState(UiButtonStateMachine.Highlight);
_zoomOut?.TrySetRetailState(UiButtonStateMachine.Normal);
};
_zoomOut = Find<UiButton>(pageRoot, ZoomOutId);
if (_zoomOut is not null)
_zoomOut.OnClick = () => PreviewControl?.ZoomOut();
_zoomOut.OnClick = () =>
{
PreviewControl?.ZoomOut();
// GF-10: gmCGAppearancePage::ZoomOut @0x0047D050
// (@0x0047d140/0x0047d14a) mirrors ZoomIn — ZoomOutButton
// -> Highlight(6), ZoomInButton -> Normal(1).
_zoomOut.TrySetRetailState(UiButtonStateMachine.Highlight);
_zoomIn?.TrySetRetailState(UiButtonStateMachine.Normal);
};
ApplyChoiceVisibility();
}
@ -608,12 +660,21 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
: UiButtonStateMachine.Normal);
}
// GF-9 (Campaign CC gate round 1 Batch B): retail's ACTUAL swatch
// click feedback is the companion overlay's visibility (SetColor
// @0x0047DD50 -> m_tColorWheel[...][0x10][iCurColor*7]->SetVisible),
// not a state swap on the swatch button — measured against the
// installed dat, the nine swatches author only a DirectState sprite
// with no Normal/Highlight media at all, so a prior
// swatch.Selected assignment here was a permanent no-op (see
// SwatchOverlayIds' own doc comment). Exactly one overlay is
// visible: the one at the current part's own selected color index.
ChargenAppearanceSlot? colorSlot = ColorSlotFor(_currentPart);
uint currentColor = colorSlot is null ? Unset : ColorCurrent(_currentPart, snapshot.Appearance);
for (int i = 0; i < _swatches.Length; i++)
for (int i = 0; i < _swatchOverlays.Length; i++)
{
if (_swatches[i] is { } swatch)
swatch.Selected = colorSlot is not null && currentColor == (uint)i;
if (_swatchOverlays[i] is { } overlay)
overlay.Visible = colorSlot is not null && currentColor == (uint)i;
}
ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart);

View file

@ -878,8 +878,31 @@ public static class DatWidgetFactory
button.FaceTop = face.Y;
button.FaceWidth = face.Width;
button.FaceHeight = face.Height;
button.LabelAlign = UiButton.LabelAlignment.Left;
button.LabelOffsetX = face.X + face.Width + 4f;
if (!ReferenceEquals(labelInfo, info))
{
// GF-11c (Campaign CC gate round 1 Batch B): a DISTINCT
// Type-12 caption was lifted (e.g. the Town page's per-
// marker name label, 0x10000409 under each town button —
// live-DAT-probe-confirmed authored rect + Center justify,
// independent of the marker face's own geometry) — honor
// ITS OWN authored rect/justify instead of the face-
// relative offset below, which is only correct when the
// label text is authored DIRECTLY on the button itself,
// immediately beside a single-purpose face segment (the
// heritage/template/Face-Clothes sub-tab row family —
// still handled by the else-branch two lines down, since
// ReferenceEquals(labelInfo, info) is true there).
button.LabelBox = (labelInfo.X, labelInfo.Y, labelInfo.Width, labelInfo.Height);
button.LabelAlign = labelInfo.HJustify == HJustify.Left
? UiButton.LabelAlignment.Left
: UiButton.LabelAlignment.Center;
}
else
{
button.LabelAlign = UiButton.LabelAlignment.Left;
button.LabelOffsetX = face.X + face.Width + 4f;
}
}
else if (labelInfo.HJustify == HJustify.Left)
{
@ -901,6 +924,15 @@ public static class DatWidgetFactory
button.LabelOffsetX = labelInfo.X;
}
// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): per-state label
// color/outline (dat properties 0x1B/0x21 authored PER STATE on the
// label-bearing element — the Appearance spins' own states, or the
// Town caption child's states) — additive, only non-null when the
// authored dat genuinely carries more than one distinct value.
button.SetPerStateLabelStyle(
ElementReader.BuildPerStateColorMap(labelInfo, 0x1Bu),
ElementReader.BuildPerStateBoolMap(labelInfo, 0x21u));
return button;
}

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.App.UI;
@ -673,4 +674,65 @@ public static class ElementReader
})
.ToArray();
}
/// <summary>
/// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): resolves a color
/// property (0x1B FontColor's Array-tolerant shape, same unwrap as
/// <see cref="ReadEffectiveColorPalette"/>) for EVERY state <paramref
/// name="info"/> itself authors, keyed by retail numeric state id.
/// Returns null unless at least two states resolve to GENUINELY
/// DIFFERENT colors — the overwhelming majority of elements author one
/// color for every state (or none at all), and for those this returns
/// null so the caller keeps its existing single-default-color behavior
/// untouched. Only elements that really do recolor per state (the
/// Appearance spins' Highlight brightening, the Town buttons' Normal-
/// to-white caption swap) get a non-null map.
/// </summary>
internal static IReadOnlyDictionary<uint, Vector4>? BuildPerStateColorMap(
ElementInfo info, uint propertyId)
{
Dictionary<uint, Vector4>? map = null;
foreach (uint stateId in info.States.Keys)
{
if (!info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value, stateId))
continue;
UiPropertyValue? colorValue = value.Kind == UiPropertyKind.Color
? value
: value.Kind == UiPropertyKind.Array
&& value.ArrayValue.Count > 0
&& value.ArrayValue[0].Kind == UiPropertyKind.Color
? value.ArrayValue[0]
: null;
if (colorValue is null)
continue;
UiColorValue c = colorValue.ColorValue;
float alpha = c.Alpha == 0 ? 1f : c.Alpha / 255f;
(map ??= new Dictionary<uint, Vector4>())[stateId] =
new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha);
}
return map is { Count: > 1 } && map.Values.Distinct().Count() > 1 ? map : null;
}
/// <summary>
/// AP-222 counterpart of <see cref="BuildPerStateColorMap"/> for a bool
/// property (0x21 Outline) — same "null unless genuinely per-state"
/// gating.
/// </summary>
internal static IReadOnlyDictionary<uint, bool>? BuildPerStateBoolMap(
ElementInfo info, uint propertyId)
{
Dictionary<uint, bool>? map = null;
foreach (uint stateId in info.States.Keys)
{
if (!info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value, stateId)
|| value.Kind != UiPropertyKind.Bool)
continue;
(map ??= new Dictionary<uint, bool>())[stateId] = value.BoolValue;
}
return map is { Count: > 1 } && map.Values.Distinct().Count() > 1 ? map : null;
}
}