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

@ -234,12 +234,9 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
Assert.IsType<UiButton>(UiElement.FindDescendant(
pageRoot, CharacterCreationAppearancePage.SwatchIds[index]));
private static ChargenSwatchColorTile SwatchTile(UiElement pageRoot, int index) =>
Assert.IsType<ChargenSwatchColorTile>(Assert.Single(Swatch(pageRoot, index).Children));
private static ChargenSwatchColorTile GradTile(UiElement pageRoot) =>
Assert.IsType<ChargenSwatchColorTile>(Assert.Single(
UiElement.FindDescendant(pageRoot, CharacterCreationAppearancePage.GradCircleId)!.Children));
private static UiDatElement GradCircle(UiElement pageRoot) =>
Assert.IsType<UiDatElement>(UiElement.FindDescendant(
pageRoot, CharacterCreationAppearancePage.GradCircleId));
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);
Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color);
Assert.True(SwatchTile(root, 0).Visible);
Assert.Equal(ToVector4(HairColorB), SwatchTile(root, 1).Color);
Assert.True(SwatchTile(root, 1).Visible);
// Only two hair colors exist — swatch 2 must be blank.
Assert.Null(SwatchTile(root, 2).Color);
Assert.False(SwatchTile(root, 2).Visible);
Assert.Equal(ToVector4(HairColorA), Swatch(root, 0).Tint);
Assert.True(Swatch(root, 0).Visible);
Assert.Equal(ToVector4(HairColorB), Swatch(root, 1).Tint);
Assert.True(Swatch(root, 1).Visible);
// Only two hair colors exist — swatch 2 must be hidden and untinted.
Assert.Equal(Vector4.One, Swatch(root, 2).Tint);
Assert.False(Swatch(root, 2).Visible);
}
/// <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();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
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>(
UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId));
eyesSpin.OnClickAt!(180, 10); // select zone — switches _currentPart, no index change.
Assert.Equal(ToVector4(EyeColorA), SwatchTile(root, 0).Color);
Assert.Equal(ToVector4(EyeColorB), SwatchTile(root, 1).Color);
Assert.Equal(ToVector4(EyeColorA), Swatch(root, 0).Tint);
Assert.Equal(ToVector4(EyeColorB), Swatch(root, 1).Tint);
}
/// <summary>Color change (clicking a different swatch) must retint the
@ -287,9 +284,12 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
page.Refresh(view, view.Snapshot);
// No color selected yet (Unset) — no tint.
Assert.Null(GradTile(root).Color);
Assert.False(GradTile(root).Visible);
// No color selected yet (Unset) — no tint, but the disc's own base
// art is still shown (Visible tracks !isEyes only, independent of
// 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!();
view.Snapshot = view.Snapshot with
@ -297,8 +297,8 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
Appearance = view.Snapshot.Appearance with { HairColor = 0u },
};
page.Refresh(view, view.Snapshot);
Assert.Equal(ToVector4(HairColorA), GradTile(root).Color);
Assert.True(GradTile(root).Visible);
Assert.Equal(ToVector4(HairColorA), GradCircle(root).Tint);
Assert.True(GradCircle(root).Visible);
Swatch(root, 1).OnClick!();
view.Snapshot = view.Snapshot with
@ -306,13 +306,13 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
Appearance = view.Snapshot.Appearance with { HairColor = 1u },
};
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
/// tint is ever shown for Eyes, regardless of the selected eye color.</summary>
/// <summary>AP-217: Eyes always hides the gradient disc — no tint is
/// ever shown for Eyes, regardless of the selected eye color.</summary>
[Fact]
public void EyesPart_GradientDiscTileStaysBlank()
public void EyesPart_GradientDiscStaysHiddenAndUntinted()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
@ -326,11 +326,11 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId));
eyesSpin.OnClickAt!(180, 10);
Assert.False(GradTile(root).Visible);
Assert.Null(GradTile(root).Color);
Assert.False(GradCircle(root).Visible);
Assert.Equal(Vector4.One, GradCircle(root).Tint);
// The swatches themselves still show real eye colors — only the
// disc blanks.
Assert.Equal(ToVector4(EyeColorA), SwatchTile(root, 0).Color);
// disc hides.
Assert.Equal(ToVector4(EyeColorA), Swatch(root, 0).Tint);
}
/// <summary>Nose/Mouth/Skin (colorSlot == null): retail still shows
@ -348,19 +348,20 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
UiElement.FindDescendant(root, CharacterCreationAppearancePage.SkinSpinId));
skinSpin.OnClickAt!(10, 10); // skin has no arrow zones — every click selects it.
Assert.Equal(ToVector4(SkinColor), SwatchTile(root, 0).Color);
Assert.True(SwatchTile(root, 0).Visible);
Assert.Null(SwatchTile(root, 1).Color);
Assert.False(SwatchTile(root, 1).Visible);
Assert.Equal(ToVector4(SkinColor), GradTile(root).Color);
Assert.True(GradTile(root).Visible);
Assert.Equal(ToVector4(SkinColor), Swatch(root, 0).Tint);
Assert.True(Swatch(root, 0).Visible);
Assert.Equal(Vector4.One, Swatch(root, 1).Tint);
Assert.False(Swatch(root, 1).Visible);
Assert.Equal(ToVector4(SkinColor), GradCircle(root).Tint);
Assert.True(GradCircle(root).Visible);
}
/// <summary>Headgear's swatch resolves through the CURRENTLY EQUIPPED
/// 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]
public void HeadgearPart_ResolvesThroughTheEquippedGarment_UnsetShowsNoSwatches()
public void HeadgearPart_ResolvesThroughTheEquippedGarment_UnsetShowsNoColor()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
@ -374,16 +375,21 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
UiElement.FindDescendant(root, CharacterCreationAppearancePage.HeadgearSpinId));
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.
// 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
{
Appearance = view.Snapshot.Appearance with { HeadgearStyle = RuntimeCharacterCreationAppearance.Unset },
};
page.Refresh(view, view.Snapshot);
Assert.Null(SwatchTile(root, 0).Color);
Assert.False(SwatchTile(root, 0).Visible);
Assert.Equal(Vector4.One, Swatch(root, 0).Tint);
Assert.True(Swatch(root, 0).Visible);
}
/// <summary>Heritage/gender change must re-source the color computation
@ -395,7 +401,7 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
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.
const uint otherHairPalSetId = 0x0F00_00AAu;
@ -409,16 +415,17 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
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"/>/
/// <see cref="CharacterCreationAppearancePage.ClothingTableSource"/>/
/// <see cref="CharacterCreationAppearancePage.PaletteColorSource"/> are
/// wired (composition-root STOPPED item), every tile MUST stay
/// invisible — the mechanism is fully inert, not a half-broken draw.</summary>
/// wired, every swatch/the gradient disc MUST show its bare authored
/// art (identity tint) — the mechanism is fully inert, not a
/// half-broken draw.</summary>
[Fact]
public void UnwiredSources_LeaveEveryTileInvisible()
public void UnwiredSources_LeaveEverySwatchUntinted()
{
var view = new FakeView(MakeOptions(HeritageId, MakeGender()), HeritageId);
var bindings = new CharacterCreationRuntimeBindings(
@ -440,8 +447,8 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
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++)
Assert.False(SwatchTile(root, i).Visible);
Assert.Equal(Vector4.One, Swatch(root, i).Tint);
}
}