fix(chargen): Campaign CC gate round 1 closeout — Group 1: real color wheel wiring

Lands Batch G's two STOPPED items, making the real palette-color swatch
wheel visually live instead of inert:

- UiButton and UiDatElement gain a per-instance Tint property threaded
  into every existing DrawSprite call (defaults to Vector4.One, so every
  pre-existing button/element is byte-identical unless a caller sets a
  non-identity tint).
- CharacterCreationAppearancePage now sets Tint directly on each color
  swatch button and the GradCircle element, replacing the Batch G
  flat-fill ChargenSwatchColorTile overlay outright — an opaque
  rectangle drawn on top can never reproduce retail's actual
  SurfaceWindow::BlitAndColor(..., Blit_Multiply, color) multiply blend,
  only a genuine per-instance sprite tint can, so the overlay approach is
  deleted rather than layered under the new mechanism.
- CharacterCreationUiController and RetailUiRuntime grow pass-through
  properties (AppearancePalSetSource/AppearanceClothingTableSource/
  AppearancePaletteColorSource) mirroring the existing PreviewControl
  seam, so LivePresentationComposition can wire a DAT-backed
  ChargenAppearanceCatalog into the Appearance page (wiring itself lands
  with the Group 3 commit, since it shares a file with an unrelated F16
  fix).

Register: AP-216/AP-217 RETIRED (161 -> now further reduced in later
commits) — both rows' remaining gaps are closed, not merely narrowed.
CharacterCreationAppearancePageSwatchColorTests updated for the new
Tint-based assertions (two pre-existing assertions were carried over
incorrectly from the old overlay-visibility model and are corrected).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 15:33:19 +02:00
parent 1f6365d3e5
commit e1d7d09599
7 changed files with 193 additions and 192 deletions

View file

@ -73,20 +73,29 @@ namespace AcDream.App.UI.Layout;
/// ///
/// <para> /// <para>
/// <b>The real color wheel (Campaign CC gate round 1 Batch G, R2-5, /// <b>The real color wheel (Campaign CC gate round 1 Batch G, R2-5,
/// register AP-216/AP-217):</b> retail's <c>DoColorSpots @0x0047d850</c> / /// register AP-216/AP-217 — CLOSEOUT (Group 1) makes it visually live):</b>
/// <c>DoGradDisk @0x0047da90</c> paint each swatch and the gradient disc /// retail's <c>DoColorSpots @0x0047d850</c> / <c>DoGradDisk @0x0047da90</c>
/// with an ACTUAL representative color sampled from the real DAT palette /// paint each swatch and the gradient disc with an ACTUAL representative
/// data (<c>AcDream.Core.CharGen.ChargenSwatchColorResolver</c> ports the /// color sampled from the real DAT palette data
/// (<c>AcDream.Core.CharGen.ChargenSwatchColorResolver</c> ports the
/// computation — see its own doc for the two color-source shapes and the /// computation — see its own doc for the two color-source shapes and the
/// clothing PalSet lookup). <see cref="PalSetSource"/>/ /// clothing PalSet lookup) via a genuine multiplicative sprite tint —
/// <see cref="ClothingTableSource"/>/<see cref="PaletteColorSource"/> are /// retail's own <c>SurfaceWindow::BlitAndColor(..., Blit_Multiply,
/// late-bound composition seams (same pattern as <see cref="PreviewControl"/>) /// color)</c>. <see cref="PalSetSource"/>/<see cref="ClothingTableSource"/>/
/// a DAT-backed catalog wires in after construction; the <see cref="ChargenSwatchColorTile"/> /// <see cref="PaletteColorSource"/> are late-bound composition seams (same
/// children painted over each swatch/the gradient disc are this batch's /// pattern as <see cref="PreviewControl"/>) a DAT-backed catalog wires in
/// rendering primitive — see that class's own doc for why it is a flat /// after construction (<c>CharacterCreationUiController.AppearancePalSetSource</c>
/// color fill (a documented approximation of retail's actual recolored- /// etc., assigned once by <c>LivePresentationComposition</c> alongside the
/// sprite blit) and the STOPPED shared-file edit that would upgrade it to /// existing <c>AppearancePreviewControl</c> wiring). Each swatch
/// a genuine texture tint. /// (<see cref="UiButton"/>) and the gradient disc (<see cref="UiDatElement"/>,
/// resolved via <see cref="_gradCircle"/>) now set their OWN
/// <see cref="UiButton.Tint"/>/<see cref="UiDatElement.Tint"/> directly —
/// the earlier flat-fill <c>ChargenSwatchColorTile</c> overlay (Batch G's
/// documented approximation, since neither widget exposed a tint hook yet)
/// is retired: a flat opaque rectangle drawn ON TOP of a sprite can never
/// reproduce a multiply blend, only a genuine per-instance sprite tint can,
/// so this closeout replaces the overlay outright rather than layering a
/// tint UNDER it.
/// </para> /// </para>
/// </summary> /// </summary>
internal sealed class CharacterCreationAppearancePage : IDisposable internal sealed class CharacterCreationAppearancePage : IDisposable
@ -193,17 +202,13 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
private readonly UiButton? _rotateCounterClockwise; private readonly UiButton? _rotateCounterClockwise;
private readonly UiButton? _zoomIn; private readonly UiButton? _zoomIn;
private readonly UiButton? _zoomOut; private readonly UiButton? _zoomOut;
private readonly UiElement? _gradCircle;
/// <summary>R2-5: one flat-color tile per swatch, added as an EXTRA /// <summary>The gradient disc (<c>0x1000030e</c>) — Type 3 in the
/// child of the swatch it decorates (see <see cref="ChargenSwatchColorTile"/>'s /// authored dat, so <see cref="UiDatElement"/> (not the base
/// own doc) — null wherever the matching <see cref="_swatches"/> entry /// <see cref="UiElement"/>) is what <see cref="Find{T}"/> resolves;
/// itself is null (nothing to attach to).</summary> /// typed concretely (post-closeout Group 1) so <see cref="RefreshColorAndShadeControls"/>
private readonly ChargenSwatchColorTile?[] _swatchColorTiles = new ChargenSwatchColorTile?[SwatchIds.Length]; /// can set <see cref="UiDatElement.Tint"/> directly.</summary>
private readonly UiDatElement? _gradCircle;
/// <summary>R2-5: the gradient disc's own tint tile, an extra child of
/// <see cref="_gradCircle"/>.</summary>
private readonly ChargenSwatchColorTile? _gradCircleTile;
private Choice _currentChoice = Choice.Face; private Choice _currentChoice = Choice.Face;
private Part _currentPart = Part.Hair; private Part _currentPart = Part.Hair;
@ -219,15 +224,13 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
/// R2-5 late-bound seams (same pattern as <see cref="PreviewControl"/> /// R2-5 late-bound seams (same pattern as <see cref="PreviewControl"/>
/// above) for the real color-wheel mechanism — null (the default) /// above) for the real color-wheel mechanism — null (the default)
/// leaves every swatch/the gradient disc showing ONLY its authored /// leaves every swatch/the gradient disc showing ONLY its authored
/// static art, i.e. this page's pre-Batch-G behavior, until a /// static art, i.e. this page's pre-Batch-G behavior. Closeout Group 1
/// composition root supplies a DAT-backed /// wires a DAT-backed <c>AcDream.Content.CharGen.ChargenAppearanceCatalog</c>
/// <c>AcDream.Content.CharGen.ChargenAppearanceCatalog</c> (which /// (which already implements all three interfaces) into these three
/// already implements all three interfaces) for these three /// properties via <c>CharacterCreationUiController.AppearancePalSetSource</c>/
/// properties, mirroring how <see cref="PreviewControl"/> itself gets /// <c>AppearanceClothingTableSource</c>/<c>AppearancePaletteColorSource</c>,
/// wired in from outside this class. STOPPED (Batch G): that /// mirroring how <see cref="PreviewControl"/> itself gets wired in from
/// assignment is a 3-line addition to /// outside this class.
/// <c>CharacterCreationUiController.cs</c>, outside this batch's file
/// contract — see the batch's handoff notes.
/// </summary> /// </summary>
internal IChargenPalSetSource? PalSetSource { get; set; } internal IChargenPalSetSource? PalSetSource { get; set; }
internal IChargenClothingTableSource? ClothingTableSource { get; set; } internal IChargenClothingTableSource? ClothingTableSource { get; set; }
@ -280,18 +283,6 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
int index = i; int index = i;
swatch.OnClick = () => SelectColor(index); swatch.OnClick = () => SelectColor(index);
_swatches[i] = swatch; _swatches[i] = swatch;
// R2-5: an extra CHILD tile, sized to exactly cover the
// swatch's own face — see ChargenSwatchColorTile's own doc for
// why this is a flat fill rather than a recolored sprite, and
// for why ClickThrough there keeps this from ever swallowing
// the swatch's own click.
var tile = new ChargenSwatchColorTile
{
Left = 0f, Top = 0f, Width = swatch.Width, Height = swatch.Height,
};
swatch.AddChild(tile);
_swatchColorTiles[i] = tile;
} }
for (int i = 0; i < SwatchOverlayIds.Length; i++) for (int i = 0; i < SwatchOverlayIds.Length; i++)
@ -301,15 +292,7 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
if (_shadeScroll is not null) if (_shadeScroll is not null)
_shadeScroll.ScalarChanged = SetShadeFromScalar; _shadeScroll.ScalarChanged = SetShadeFromScalar;
_gradCircle = Find<UiElement>(pageRoot, GradCircleId); _gradCircle = Find<UiDatElement>(pageRoot, GradCircleId);
if (_gradCircle is not null)
{
_gradCircleTile = new ChargenSwatchColorTile
{
Left = 0f, Top = 0f, Width = _gradCircle.Width, Height = _gradCircle.Height,
};
_gradCircle.AddChild(_gradCircleTile);
}
Viewport = Find<UiViewport>(pageRoot, ViewportId); Viewport = Find<UiViewport>(pageRoot, ViewportId);
@ -777,12 +760,13 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
continue; continue;
bool visible = i < displayCount; bool visible = i < displayCount;
swatch.Visible = visible; swatch.Visible = visible;
if (_swatchColorTiles[i] is { } tile) // Closeout Group 1: a genuine multiplicative sprite tint on the
{ // swatch's own authored spot art (UiButton.Tint), replacing the
ChargenSwatchRgb? rgb = visible ? swatchColors[i] : null; // Batch G flat-fill overlay. Vector4.One (identity) reproduces
tile.Color = rgb is { } c ? ToTintColor(c) : null; // the swatch's bare authored art untouched — both "no color data
tile.Visible = rgb is not null; // yet" (sources unwired) and "beyond this part's color count".
} ChargenSwatchRgb? rgb = visible ? swatchColors[i] : null;
swatch.Tint = rgb is { } c ? ToTintColor(c) : Vector4.One;
} }
// AP-217 (Batch C PARTIAL -> Batch G, R2-5, FULL): // AP-217 (Batch C PARTIAL -> Batch G, R2-5, FULL):
@ -798,18 +782,17 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
{ {
bool isEyes = _currentPart == Part.Eyes; bool isEyes = _currentPart == Part.Eyes;
_gradCircle.Visible = !isEyes; _gradCircle.Visible = !isEyes;
if (_gradCircleTile is { } gradTile) // Closeout Group 1: same Tint mechanism as the swatches above —
{ // the gradient disc's own authored art is multiplied by the
int gradIndex = isEyes // currently-selected swatch's color instead of an overlay child.
? -1 int gradIndex = isEyes
: colorSlot is null ? -1
? 0 : colorSlot is null
: (int)ColorCurrent(_currentPart, snapshot.Appearance); ? 0
ChargenSwatchRgb? gradColor = : (int)ColorCurrent(_currentPart, snapshot.Appearance);
gradIndex >= 0 && gradIndex < swatchColors.Length ? swatchColors[gradIndex] : null; ChargenSwatchRgb? gradColor =
gradTile.Color = gradColor is { } gc ? ToTintColor(gc) : null; gradIndex >= 0 && gradIndex < swatchColors.Length ? swatchColors[gradIndex] : null;
gradTile.Visible = !isEyes && gradColor is not null; _gradCircle.Tint = gradColor is { } gc ? ToTintColor(gc) : Vector4.One;
}
} }
ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart); ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart);

View file

@ -363,6 +363,29 @@ internal sealed class CharacterCreationUiController : IDisposable
set => _appearancePage.PreviewControl = value; set => _appearancePage.PreviewControl = value;
} }
/// <summary>Campaign CC gate round 1 closeout (Group 1, R2-5): the same
/// late-bound pattern as <see cref="AppearancePreviewControl"/> above,
/// for the real color-wheel/swatch-color mechanism's three DAT-backed
/// seams — see <see cref="CharacterCreationAppearancePage.PalSetSource"/>'s
/// own doc comment.</summary>
internal IChargenPalSetSource? AppearancePalSetSource
{
get => _appearancePage.PalSetSource;
set => _appearancePage.PalSetSource = value;
}
internal IChargenClothingTableSource? AppearanceClothingTableSource
{
get => _appearancePage.ClothingTableSource;
set => _appearancePage.ClothingTableSource = value;
}
internal IChargenPaletteColorSource? AppearancePaletteColorSource
{
get => _appearancePage.PaletteColorSource;
set => _appearancePage.PaletteColorSource = value;
}
/// <summary>Gates the Appearance preview's per-frame work on whether /// <summary>Gates the Appearance preview's per-frame work on whether
/// that specific page — AND the whole chargen screen — is the one /// that specific page — AND the whole chargen screen — is the one
/// currently showing. <c>Close()</c> only ever hides <see cref="Root"/>, /// currently showing. <c>Close()</c> only ever hides <see cref="Root"/>,

View file

@ -1,69 +0,0 @@
using System.Numerics;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Campaign CC gate round 1 Batch G (R2-5, register AP-216/AP-217): paints
/// one flat-fill patch of a computed <see cref="AcDream.Core.CharGen.ChargenSwatchRgb"/>
/// on top of whatever element it is attached to as a child — the
/// Appearance page's real-color rendering primitive for the nine color
/// swatches and the gradient disc.
///
/// <para>
/// <b>Why a flat fill, not a recolored sprite (documented approximation):</b>
/// retail's own mechanism (<c>gmCGAppearancePage::DoColorSpots @0x0047d850</c>
/// / <c>DoGradDisk @0x0047da90</c>) blits an authored "spot"/gradient
/// graphic and RECOLORS it in place
/// (<c>SurfaceWindow::ReplaceColor</c> / <c>BlitAndColor(...,
/// Blit_Multiply, color)</c>) — a genuine multiplicative texture tint. The
/// retained-UI sprite pipeline this codebase already has
/// (<see cref="UiRenderContext.DrawSprite"/>) DOES carry a per-draw
/// <c>Vector4 tint</c> parameter that could reproduce that exact multiply
/// blend, but neither <see cref="UiButton"/> (the nine swatches' own type,
/// sealed) nor <see cref="UiDatElement"/> (the gradient disc's own type)
/// exposes a per-instance tint hook on their EXISTING sprite draw calls —
/// adding one is a small, precisely-scoped, additive change to those two
/// shared widget files, outside this batch's file contract (reported as a
/// STOPPED item; see the batch's own commit message / handoff notes for the
/// exact diff). Rather than leave the swatches/wheel colorless pending that
/// follow-up, this class achieves the same OBSERVABLE result — "this
/// swatch/wheel visibly reflects the real computed color" — the cheapest
/// way the CURRENT public primitives allow: <see cref="UiRenderContext.DrawFill"/>
/// is a plain solid-color quad, so the tile reads as a flat color patch
/// rather than a recolored dot/gradient graphic. It is added as an extra
/// CHILD of the swatch/disc it decorates (never replacing or subclassing
/// either sealed/shared type), so it draws strictly ON TOP
/// (<see cref="UiElement.DrawSelfAndChildren"/>: children paint after their
/// parent's own <c>OnDraw</c>) without disturbing the underlying element's
/// own state machine, media, or click handling at all.
/// </para>
///
/// <para>
/// <see cref="UiElement.ClickThrough"/> defaults to <c>false</c> on the
/// base class, so this MUST be set true by the constructor here (not left
/// to a caller to remember) — <see cref="UiElement.HitTest"/> walks
/// children BEFORE testing the parent, and an opaque, click-absorbing tile
/// sitting on top of a swatch button would silently eat every click meant
/// for it.
/// </para>
/// </summary>
internal sealed class ChargenSwatchColorTile : UiElement
{
public ChargenSwatchColorTile()
{
ClickThrough = true;
Visible = false;
}
/// <summary>The color to paint, or null to draw nothing this frame
/// (<see cref="Visible"/> is the authoritative on/off switch — callers
/// should set both together, matching every other swatch-visibility
/// site in <c>CharacterCreationAppearancePage</c>).</summary>
public Vector4? Color { get; set; }
protected override void OnDraw(UiRenderContext ctx)
{
if (Color is { } c && Width > 0f && Height > 0f)
ctx.DrawFill(0f, 0f, Width, Height, c);
}
}

View file

@ -179,6 +179,15 @@ public class UiDatElement : UiElement, IUiDatStateful
/// <summary>Label color (default white).</summary> /// <summary>Label color (default white).</summary>
public Vector4 LabelColor { get; set; } = Vector4.One; public Vector4 LabelColor { get; set; } = Vector4.One;
/// <summary>
/// Campaign CC gate round 1 closeout (Group 1, R2-5): per-instance
/// multiplicative sprite tint, threaded into both <see cref="UiRenderContext.DrawSprite"/>
/// calls this class makes (the runtime-image path and the ordinary
/// authored-media path) — same shape and same default-identity
/// no-op-for-existing-callers guarantee as <see cref="UiButton.Tint"/>.
/// </summary>
public Vector4 Tint { get; set; } = Vector4.One;
/// <summary>Retail LayoutDesc property <c>0x21</c> (two-pass glyph outline, /// <summary>Retail LayoutDesc property <c>0x21</c> (two-pass glyph outline,
/// <c>UIElement_Text::SetOutline @0x0046a81c</c>). Seeded in the ctor from the /// <c>UIElement_Text::SetOutline @0x0046a81c</c>). Seeded in the ctor from the
/// element's effective-default state, same as <see cref="UiText.Outline"/> /// element's effective-default state, same as <see cref="UiText.Outline"/>
@ -271,7 +280,7 @@ public class UiDatElement : UiElement, IUiDatStateful
0f, 0f,
1f, 1f,
1f, 1f,
Vector4.One); Tint);
} }
DrawLabel(ctx); DrawLabel(ctx);
return; return;
@ -290,7 +299,7 @@ public class UiDatElement : UiElement, IUiDatStateful
// doc). Overlay/Alphablend use the same blit (the sprite shader // doc). Overlay/Alphablend use the same blit (the sprite shader
// already alpha-blends). No Stretch mode exists in DrawModeType; // already alpha-blends). No Stretch mode exists in DrawModeType;
// whole-canvas stretching happens at UiRoot.FixedCanvasSize. // whole-canvas stretching happens at UiRoot.FixedCanvasSize.
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Tint);
} }
} }

View file

@ -680,6 +680,41 @@ public sealed class RetailUiRuntime : IDisposable
} }
} }
/// <summary>Campaign CC gate round 1 closeout (Group 1, R2-5): the same
/// late-bound pattern as <see cref="ChargenPreviewControl"/> above, for
/// the real color-wheel/swatch-color mechanism's three DAT-backed seams
/// — see <see cref="AcDream.App.UI.Layout.CharacterCreationAppearancePage.PalSetSource"/>'s
/// own doc comment.</summary>
internal AcDream.Core.CharGen.IChargenPalSetSource? ChargenPalSetSource
{
get => CharacterCreationController?.AppearancePalSetSource;
set
{
if (CharacterCreationController is { } controller)
controller.AppearancePalSetSource = value;
}
}
internal AcDream.Core.CharGen.IChargenClothingTableSource? ChargenClothingTableSource
{
get => CharacterCreationController?.AppearanceClothingTableSource;
set
{
if (CharacterCreationController is { } controller)
controller.AppearanceClothingTableSource = value;
}
}
internal AcDream.Core.CharGen.IChargenPaletteColorSource? ChargenPaletteColorSource
{
get => CharacterCreationController?.AppearancePaletteColorSource;
set
{
if (CharacterCreationController is { } controller)
controller.AppearancePaletteColorSource = value;
}
}
/// <summary>CC6b-MOUNT: whether the Appearance page (specifically) is /// <summary>CC6b-MOUNT: whether the Appearance page (specifically) is
/// the one currently showing — false, safely, before the screen mounts. /// the one currently showing — false, safely, before the screen mounts.
/// </summary> /// </summary>

View file

@ -153,6 +153,19 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// </summary> /// </summary>
public uint? FaceFileOverride { get; set; } public uint? FaceFileOverride { get; set; }
/// <summary>
/// Campaign CC gate round 1 closeout (Group 1, R2-5): per-instance
/// multiplicative sprite tint, threaded into every <see cref="UiRenderContext.DrawSprite"/>
/// call this class makes (main face, face-segment, drag-acceptance
/// overlay) — retail's own <c>SurfaceWindow::BlitAndColor(...,
/// Blit_Multiply, color)</c>. Default <see cref="Vector4.One"/> (white,
/// full alpha) leaves every DrawSprite call byte-identical to before
/// this property existed; only a caller that explicitly sets a
/// non-identity tint (e.g. <see cref="Layout.CharacterCreationAppearancePage"/>'s
/// color-wheel swatches) changes what draws.
/// </summary>
public Vector4 Tint { get; set; } = Vector4.One;
/// <summary>Additional left inset for left-aligned labels.</summary> /// <summary>Additional left inset for left-aligned labels.</summary>
public float LabelOffsetX { get; set; } = 3f; public float LabelOffsetX { get; set; } = 3f;
@ -469,7 +482,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
float faceWidth = FaceWidth > 0f ? FaceWidth : Width; float faceWidth = FaceWidth > 0f ? FaceWidth : Width;
float faceHeight = FaceHeight > 0f ? FaceHeight : Height; float faceHeight = FaceHeight > 0f ? FaceHeight : Height;
ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight, ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight,
0, 0, faceWidth / tw, faceHeight / th, Vector4.One); 0, 0, faceWidth / tw, faceHeight / th, Tint);
} }
} }
} }
@ -525,7 +538,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
{ {
var (tex, _, _) = _resolve(dragSprite); var (tex, _, _) = _resolve(dragSprite);
if (tex != 0) if (tex != 0)
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Tint);
} }
} }
@ -647,7 +660,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
// first reflowed by its own four-edge retail layout policy. // first reflowed by its own four-edge retail layout policy.
ctx.DrawSprite(texture, rect.X0, rect.Y0, rect.Width, rect.Height, ctx.DrawSprite(texture, rect.X0, rect.Y0, rect.Width, rect.Height,
0f, 0f, (float)rect.Width / textureWidth, (float)rect.Height / textureHeight, 0f, 0f, (float)rect.Width / textureWidth, (float)rect.Height / textureHeight,
Vector4.One); Tint);
} }
private void AddAvailableStates(ElementInfo mediaInfo) private void AddAvailableStates(ElementInfo mediaInfo)

View file

@ -234,12 +234,9 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
Assert.IsType<UiButton>(UiElement.FindDescendant( Assert.IsType<UiButton>(UiElement.FindDescendant(
pageRoot, CharacterCreationAppearancePage.SwatchIds[index])); pageRoot, CharacterCreationAppearancePage.SwatchIds[index]));
private static ChargenSwatchColorTile SwatchTile(UiElement pageRoot, int index) => private static UiDatElement GradCircle(UiElement pageRoot) =>
Assert.IsType<ChargenSwatchColorTile>(Assert.Single(Swatch(pageRoot, index).Children)); Assert.IsType<UiDatElement>(UiElement.FindDescendant(
pageRoot, CharacterCreationAppearancePage.GradCircleId));
private static ChargenSwatchColorTile GradTile(UiElement pageRoot) =>
Assert.IsType<ChargenSwatchColorTile>(Assert.Single(
UiElement.FindDescendant(pageRoot, CharacterCreationAppearancePage.GradCircleId)!.Children));
private static Vector4 ToVector4(ChargenSwatchRgb rgb) => new(rgb.R / 255f, rgb.G / 255f, rgb.B / 255f, 1f); private static Vector4 ToVector4(ChargenSwatchRgb rgb) => new(rgb.R / 255f, rgb.G / 255f, rgb.B / 255f, 1f);
@ -251,13 +248,13 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
page.Refresh(view, view.Snapshot); page.Refresh(view, view.Snapshot);
Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color); Assert.Equal(ToVector4(HairColorA), Swatch(root, 0).Tint);
Assert.True(SwatchTile(root, 0).Visible); Assert.True(Swatch(root, 0).Visible);
Assert.Equal(ToVector4(HairColorB), SwatchTile(root, 1).Color); Assert.Equal(ToVector4(HairColorB), Swatch(root, 1).Tint);
Assert.True(SwatchTile(root, 1).Visible); Assert.True(Swatch(root, 1).Visible);
// Only two hair colors exist — swatch 2 must be blank. // Only two hair colors exist — swatch 2 must be hidden and untinted.
Assert.Null(SwatchTile(root, 2).Color); Assert.Equal(Vector4.One, Swatch(root, 2).Tint);
Assert.False(SwatchTile(root, 2).Visible); Assert.False(Swatch(root, 2).Visible);
} }
/// <summary>Part change (Hair -&gt; Eyes via the spin's own select-zone /// <summary>Part change (Hair -&gt; Eyes via the spin's own select-zone
@ -269,14 +266,14 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
var (pal, clothing, colors) = MakeSources(); var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
page.Refresh(view, view.Snapshot); page.Refresh(view, view.Snapshot);
Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color); Assert.Equal(ToVector4(HairColorA), Swatch(root, 0).Tint);
UiButton eyesSpin = Assert.IsType<UiButton>( UiButton eyesSpin = Assert.IsType<UiButton>(
UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId)); UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId));
eyesSpin.OnClickAt!(180, 10); // select zone — switches _currentPart, no index change. eyesSpin.OnClickAt!(180, 10); // select zone — switches _currentPart, no index change.
Assert.Equal(ToVector4(EyeColorA), SwatchTile(root, 0).Color); Assert.Equal(ToVector4(EyeColorA), Swatch(root, 0).Tint);
Assert.Equal(ToVector4(EyeColorB), SwatchTile(root, 1).Color); Assert.Equal(ToVector4(EyeColorB), Swatch(root, 1).Tint);
} }
/// <summary>Color change (clicking a different swatch) must retint the /// <summary>Color change (clicking a different swatch) must retint the
@ -287,9 +284,12 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
var (pal, clothing, colors) = MakeSources(); var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
page.Refresh(view, view.Snapshot); page.Refresh(view, view.Snapshot);
// No color selected yet (Unset) — no tint. // No color selected yet (Unset) — no tint, but the disc's own base
Assert.Null(GradTile(root).Color); // art is still shown (Visible tracks !isEyes only, independent of
Assert.False(GradTile(root).Visible); // whether a real color has been resolved — the disc is never
// hidden pending a tint, only Eyes hides it at all).
Assert.Equal(Vector4.One, GradCircle(root).Tint);
Assert.True(GradCircle(root).Visible);
Swatch(root, 0).OnClick!(); Swatch(root, 0).OnClick!();
view.Snapshot = view.Snapshot with view.Snapshot = view.Snapshot with
@ -297,8 +297,8 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
Appearance = view.Snapshot.Appearance with { HairColor = 0u }, Appearance = view.Snapshot.Appearance with { HairColor = 0u },
}; };
page.Refresh(view, view.Snapshot); page.Refresh(view, view.Snapshot);
Assert.Equal(ToVector4(HairColorA), GradTile(root).Color); Assert.Equal(ToVector4(HairColorA), GradCircle(root).Tint);
Assert.True(GradTile(root).Visible); Assert.True(GradCircle(root).Visible);
Swatch(root, 1).OnClick!(); Swatch(root, 1).OnClick!();
view.Snapshot = view.Snapshot with view.Snapshot = view.Snapshot with
@ -306,13 +306,13 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
Appearance = view.Snapshot.Appearance with { HairColor = 1u }, Appearance = view.Snapshot.Appearance with { HairColor = 1u },
}; };
page.Refresh(view, view.Snapshot); page.Refresh(view, view.Snapshot);
Assert.Equal(ToVector4(HairColorB), GradTile(root).Color); Assert.Equal(ToVector4(HairColorB), GradCircle(root).Tint);
} }
/// <summary>AP-217: Eyes always blanks the gradient disc's tile — no /// <summary>AP-217: Eyes always hides the gradient disc — no tint is
/// tint is ever shown for Eyes, regardless of the selected eye color.</summary> /// ever shown for Eyes, regardless of the selected eye color.</summary>
[Fact] [Fact]
public void EyesPart_GradientDiscTileStaysBlank() public void EyesPart_GradientDiscStaysHiddenAndUntinted()
{ {
var (pal, clothing, colors) = MakeSources(); var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
@ -326,11 +326,11 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId)); UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId));
eyesSpin.OnClickAt!(180, 10); eyesSpin.OnClickAt!(180, 10);
Assert.False(GradTile(root).Visible); Assert.False(GradCircle(root).Visible);
Assert.Null(GradTile(root).Color); Assert.Equal(Vector4.One, GradCircle(root).Tint);
// The swatches themselves still show real eye colors — only the // The swatches themselves still show real eye colors — only the
// disc blanks. // disc hides.
Assert.Equal(ToVector4(EyeColorA), SwatchTile(root, 0).Color); Assert.Equal(ToVector4(EyeColorA), Swatch(root, 0).Tint);
} }
/// <summary>Nose/Mouth/Skin (colorSlot == null): retail still shows /// <summary>Nose/Mouth/Skin (colorSlot == null): retail still shows
@ -348,19 +348,20 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
UiElement.FindDescendant(root, CharacterCreationAppearancePage.SkinSpinId)); UiElement.FindDescendant(root, CharacterCreationAppearancePage.SkinSpinId));
skinSpin.OnClickAt!(10, 10); // skin has no arrow zones — every click selects it. skinSpin.OnClickAt!(10, 10); // skin has no arrow zones — every click selects it.
Assert.Equal(ToVector4(SkinColor), SwatchTile(root, 0).Color); Assert.Equal(ToVector4(SkinColor), Swatch(root, 0).Tint);
Assert.True(SwatchTile(root, 0).Visible); Assert.True(Swatch(root, 0).Visible);
Assert.Null(SwatchTile(root, 1).Color); Assert.Equal(Vector4.One, Swatch(root, 1).Tint);
Assert.False(SwatchTile(root, 1).Visible); Assert.False(Swatch(root, 1).Visible);
Assert.Equal(ToVector4(SkinColor), GradTile(root).Color); Assert.Equal(ToVector4(SkinColor), GradCircle(root).Tint);
Assert.True(GradTile(root).Visible); Assert.True(GradCircle(root).Visible);
} }
/// <summary>Headgear's swatch resolves through the CURRENTLY EQUIPPED /// <summary>Headgear's swatch resolves through the CURRENTLY EQUIPPED
/// garment's own ClothingTable — an Unset headgear style (no garment) /// garment's own ClothingTable — an Unset headgear style (no garment)
/// shows no swatches at all.</summary> /// shows no color (the swatch stays visible with its bare authored art,
/// untinted).</summary>
[Fact] [Fact]
public void HeadgearPart_ResolvesThroughTheEquippedGarment_UnsetShowsNoSwatches() public void HeadgearPart_ResolvesThroughTheEquippedGarment_UnsetShowsNoColor()
{ {
var (pal, clothing, colors) = MakeSources(); var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
@ -374,16 +375,21 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
UiElement.FindDescendant(root, CharacterCreationAppearancePage.HeadgearSpinId)); UiElement.FindDescendant(root, CharacterCreationAppearancePage.HeadgearSpinId));
headgearSpin.OnClickAt!(180, 10); headgearSpin.OnClickAt!(180, 10);
Assert.Equal(ToVector4(HeadgearColorA), SwatchTile(root, 0).Color); Assert.Equal(ToVector4(HeadgearColorA), Swatch(root, 0).Tint);
// Now un-equip (Unset) and refresh again — no garment, no colors. // Now un-equip (Unset) and refresh again — no garment, no colors.
// The swatch's own Visible stays true (the "beyond count" gate is
// the gender's fixed ClothingColors list length, independent of
// which garment is equipped — Batch C's already-shipped half); only
// the TINT reverts to identity, showing the swatch's bare authored
// art with no color.
view.Snapshot = view.Snapshot with view.Snapshot = view.Snapshot with
{ {
Appearance = view.Snapshot.Appearance with { HeadgearStyle = RuntimeCharacterCreationAppearance.Unset }, Appearance = view.Snapshot.Appearance with { HeadgearStyle = RuntimeCharacterCreationAppearance.Unset },
}; };
page.Refresh(view, view.Snapshot); page.Refresh(view, view.Snapshot);
Assert.Null(SwatchTile(root, 0).Color); Assert.Equal(Vector4.One, Swatch(root, 0).Tint);
Assert.False(SwatchTile(root, 0).Visible); Assert.True(Swatch(root, 0).Visible);
} }
/// <summary>Heritage/gender change must re-source the color computation /// <summary>Heritage/gender change must re-source the color computation
@ -395,7 +401,7 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
var (pal, clothing, colors) = MakeSources(); var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
page.Refresh(view, view.Snapshot); page.Refresh(view, view.Snapshot);
Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color); Assert.Equal(ToVector4(HairColorA), Swatch(root, 0).Tint);
// A second heritage with a DIFFERENT hair-color list. // A second heritage with a DIFFERENT hair-color list.
const uint otherHairPalSetId = 0x0F00_00AAu; const uint otherHairPalSetId = 0x0F00_00AAu;
@ -409,16 +415,17 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
page.Refresh(otherView, otherView.Snapshot); page.Refresh(otherView, otherView.Snapshot);
Assert.Equal(ToVector4(otherHairColor), SwatchTile(root, 0).Color); Assert.Equal(ToVector4(otherHairColor), Swatch(root, 0).Tint);
} }
/// <summary>Before <see cref="CharacterCreationAppearancePage.PalSetSource"/>/ /// <summary>Before <see cref="CharacterCreationAppearancePage.PalSetSource"/>/
/// <see cref="CharacterCreationAppearancePage.ClothingTableSource"/>/ /// <see cref="CharacterCreationAppearancePage.ClothingTableSource"/>/
/// <see cref="CharacterCreationAppearancePage.PaletteColorSource"/> are /// <see cref="CharacterCreationAppearancePage.PaletteColorSource"/> are
/// wired (composition-root STOPPED item), every tile MUST stay /// wired, every swatch/the gradient disc MUST show its bare authored
/// invisible — the mechanism is fully inert, not a half-broken draw.</summary> /// art (identity tint) — the mechanism is fully inert, not a
/// half-broken draw.</summary>
[Fact] [Fact]
public void UnwiredSources_LeaveEveryTileInvisible() public void UnwiredSources_LeaveEverySwatchUntinted()
{ {
var view = new FakeView(MakeOptions(HeritageId, MakeGender()), HeritageId); var view = new FakeView(MakeOptions(HeritageId, MakeGender()), HeritageId);
var bindings = new CharacterCreationRuntimeBindings( var bindings = new CharacterCreationRuntimeBindings(
@ -440,8 +447,8 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
page.Refresh(view, view.Snapshot); page.Refresh(view, view.Snapshot);
Assert.False(GradTile(root).Visible); Assert.Equal(Vector4.One, GradCircle(root).Tint);
for (int i = 0; i < CharacterCreationAppearancePage.SwatchIds.Length; i++) for (int i = 0; i < CharacterCreationAppearancePage.SwatchIds.Length; i++)
Assert.False(SwatchTile(root, i).Visible); Assert.Equal(Vector4.One, Swatch(root, i).Tint);
} }
} }