fix(chargen): Campaign CC gate round 1 Batch G — real color wheel (DoColorSpots/DoGradDisk color rendering)

R2-5: retail's gmCGAppearancePage::DoColorSpots/SetSelection/DoGradDisk
paint the nine color swatches and the gradient disc with a real,
computed representative color (PalSet-averaged for Hair/Nose+Mouth+
Skin/Headgear/Shirt/Trousers/Footwear at fixed sample indices
0xd0/0xb0/0x520; direct-Palette for Eyes at 0x103), not the static
authored art acdream showed before this batch.

Ports the full palette-to-RGB pipeline: a new pure Core resolver
(ChargenSwatchColorResolver + IChargenPaletteColorSource) backed by a
new ChargenAppearanceCatalog.TryGetColor reading real Palette dat
objects, pinned against the installed EoR dat. CharacterCreationAppearancePage
recomputes all nine swatches + the gradient disc's tint on every
refresh (part/color/heritage change) and paints them through a new
ChargenSwatchColorTile overlay child — a flat-color-fill approximation
of retail's actual recolored-sprite blit, since neither UiButton
(sealed) nor UiDatElement exposes a per-instance sprite tint today.

Two STOPPED items remain outside this batch's file contract before the
mechanism is visually live: (1) wiring PalSetSource/ClothingTableSource/
PaletteColorSource from CharacterCreationUiController.cs (mirrors the
existing PreviewControl seam); (2) a small additive Tint property on
UiButton/UiDatElement for a byte-true recolor instead of the flat fill.
Also ports Nose/Mouth/Skin's single non-interactive representative
swatch, beyond AP-216/AP-217's original six-part scope.

Register AP-216/AP-217 rewritten (not retired — the two STOPPED items
keep them open). Tests: 11 new Core, 6 new Content live-DAT, 8 new
App-layer fixture. App suite 5321/3 -> 5329/3, Runtime 1735/0
unchanged, zero regressions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 14:16:14 +02:00
parent 2ad805469d
commit 834c2547a9
10 changed files with 1558 additions and 29 deletions

View file

@ -0,0 +1,212 @@
using AcDream.Core.CharGen;
namespace AcDream.Core.Tests.CharGen;
/// <summary>
/// Hand-built-fixture tests for <see cref="ChargenSwatchColorResolver"/> —
/// Campaign CC gate round 1 Batch G (R2-5, register AP-216/AP-217). Real
/// installed-DAT coverage (pinned RGB values off the actual client dats)
/// lives in <c>AcDream.Content.Tests.CharGen.ChargenAppearanceCatalogColorTests</c>.
/// </summary>
public sealed class ChargenSwatchColorResolverTests
{
private sealed class FakePalSetSource : IChargenPalSetSource
{
private readonly Dictionary<uint, ChargenPalSet> _sets = new();
public void Add(uint id, params uint[] paletteIds) => _sets[id] = new ChargenPalSet(paletteIds);
public ChargenPalSet? TryGetPalSet(uint palSetId) => _sets.TryGetValue(palSetId, out var s) ? s : null;
}
private sealed class FakeClothingTableSource : IChargenClothingTableSource
{
private readonly Dictionary<uint, ChargenClothingTable> _tables = new();
public void Add(uint id, ChargenClothingTable table) => _tables[id] = table;
public ChargenClothingTable? TryGetClothingTable(uint clothingTableId) =>
_tables.TryGetValue(clothingTableId, out var t) ? t : null;
}
/// <summary>Fixed-color fake: every (paletteId, index) pair the test
/// registers resolves to an EXACT color, so averaging math can be
/// checked by hand rather than against opaque installed-DAT pixels.</summary>
private sealed class FakeColorSource : IChargenPaletteColorSource
{
private readonly Dictionary<(uint paletteId, int index), ChargenSwatchRgb> _colors = new();
public void Add(uint paletteId, int index, byte r, byte g, byte b) =>
_colors[(paletteId, index)] = new ChargenSwatchRgb(r, g, b);
public bool TryGetColor(uint paletteId, int index, out ChargenSwatchRgb color) =>
_colors.TryGetValue((paletteId, index), out color);
}
// ── TryGetPalSetAverageColor ────────────────────────────────────────
[Fact]
public void TryGetPalSetAverageColor_AveragesEveryPaletteInTheSet()
{
var palSets = new FakePalSetSource();
palSets.Add(0x0F00_0001u, 0x0400_0001u, 0x0400_0002u);
var colors = new FakeColorSource();
colors.Add(0x0400_0001u, 0xd0, r: 100, g: 0, b: 0);
colors.Add(0x0400_0002u, 0xd0, r: 200, g: 0, b: 0);
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
palSets, colors, 0x0F00_0001u, ChargenSwatchColorResolver.HairSampleIndex, out ChargenSwatchRgb color);
Assert.True(ok);
Assert.Equal(new ChargenSwatchRgb(150, 0, 0), color);
}
/// <summary>Retail's own loop (@0x0047e759-0x0047e80f) accumulates
/// unconditionally and ALWAYS divides by the full num_pals — a per-
/// entry miss contributes (0,0,0) rather than shrinking the divisor
/// (GetColorFromPal's own miss path @0x005639b5 returns 0, it does not
/// skip the accumulation).</summary>
[Fact]
public void TryGetPalSetAverageColor_MissingIndividualPaletteContributesBlackNotSkip()
{
var palSets = new FakePalSetSource();
palSets.Add(0x0F00_0002u, 0x0400_0010u, 0x0400_0011u); // second id never registered in colors.
var colors = new FakeColorSource();
colors.Add(0x0400_0010u, 0xd0, r: 200, g: 100, b: 50);
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
palSets, colors, 0x0F00_0002u, ChargenSwatchColorResolver.HairSampleIndex, out ChargenSwatchRgb color);
Assert.True(ok);
// (200+0)/2=100, (100+0)/2=50, (50+0)/2=25 — divided by the FULL
// count of 2, not 1.
Assert.Equal(new ChargenSwatchRgb(100, 50, 25), color);
}
/// <summary>Retail's explicit zero-init before the num_pals>0 guard
/// (@0x0047e790) — an empty-but-resolved PalSet is BLACK, not a miss.</summary>
[Fact]
public void TryGetPalSetAverageColor_EmptyPalSet_ReturnsTrueBlack()
{
var palSets = new FakePalSetSource();
palSets.Add(0x0F00_0003u); // zero palette ids.
var colors = new FakeColorSource();
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
palSets, colors, 0x0F00_0003u, ChargenSwatchColorResolver.HairSampleIndex, out ChargenSwatchRgb color);
Assert.True(ok);
Assert.Equal(new ChargenSwatchRgb(0, 0, 0), color);
}
[Fact]
public void TryGetPalSetAverageColor_UnresolvedPalSetId_ReturnsFalse()
{
var palSets = new FakePalSetSource();
var colors = new FakeColorSource();
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
palSets, colors, 0x0F00_DEADu, ChargenSwatchColorResolver.HairSampleIndex, out _);
Assert.False(ok);
}
// ── TryGetDirectColor (Eyes) ─────────────────────────────────────────
[Fact]
public void TryGetDirectColor_ReadsThePaletteDirectly_NoPalSetIndirection()
{
var colors = new FakeColorSource();
colors.Add(0x0400_0099u, ChargenSwatchColorResolver.EyeSampleIndex, r: 15, g: 63, b: 93);
bool ok = ChargenSwatchColorResolver.TryGetDirectColor(
colors, 0x0400_0099u, ChargenSwatchColorResolver.EyeSampleIndex, out ChargenSwatchRgb color);
Assert.True(ok);
Assert.Equal(new ChargenSwatchRgb(15, 63, 93), color);
}
[Fact]
public void TryGetDirectColor_UnresolvedPaletteId_ReturnsFalse()
{
var colors = new FakeColorSource();
bool ok = ChargenSwatchColorResolver.TryGetDirectColor(
colors, 0x0400_DEADu, ChargenSwatchColorResolver.EyeSampleIndex, out _);
Assert.False(ok);
}
// ── TryGetClothingSwatchPalSetId ─────────────────────────────────────
private static ChargenClothingTable MakeClothingTable(uint templateId, uint firstChoicePalSetId, uint secondChoicePalSetId)
{
var template = new ChargenClothingPaletteTemplate(
[
new ChargenClothingSubPaletteChoice(firstChoicePalSetId, [new ChargenClothingSubPaletteRange(0, 8)]),
new ChargenClothingSubPaletteChoice(secondChoicePalSetId, [new ChargenClothingSubPaletteRange(8, 8)]),
]);
return new ChargenClothingTable(
new Dictionary<uint, ChargenClothingBaseEffect>(),
new Dictionary<uint, ChargenClothingPaletteTemplate> { [templateId] = template });
}
[Fact]
public void TryGetClothingSwatchPalSetId_ReturnsTheFIRSTChoicesPalSetId()
{
var clothingTables = new FakeClothingTableSource();
clothingTables.Add(0x1900_0001u, MakeClothingTable(9u, 0x0F00_0010u, 0x0F00_0011u));
bool ok = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId(
clothingTables, 0x1900_0001u, paletteTemplateId: 9u, out uint palSetId);
Assert.True(ok);
Assert.Equal(0x0F00_0010u, palSetId); // choices[0], not choices[1].
}
[Fact]
public void TryGetClothingSwatchPalSetId_UnresolvedClothingTable_ReturnsFalse()
{
var clothingTables = new FakeClothingTableSource();
bool ok = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId(
clothingTables, 0x1900_DEADu, paletteTemplateId: 9u, out _);
Assert.False(ok);
}
[Fact]
public void TryGetClothingSwatchPalSetId_TemplateIdNotInThisGarmentsTable_ReturnsFalse()
{
var clothingTables = new FakeClothingTableSource();
clothingTables.Add(0x1900_0002u, MakeClothingTable(9u, 0x0F00_0010u, 0x0F00_0011u));
bool ok = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId(
clothingTables, 0x1900_0002u, paletteTemplateId: 999u, out _);
Assert.False(ok);
}
[Fact]
public void TryGetClothingSwatchPalSetId_TemplateWithNoChoices_ReturnsFalse()
{
var clothingTables = new FakeClothingTableSource();
var emptyTemplate = new ChargenClothingPaletteTemplate([]);
clothingTables.Add(
0x1900_0003u,
new ChargenClothingTable(
new Dictionary<uint, ChargenClothingBaseEffect>(),
new Dictionary<uint, ChargenClothingPaletteTemplate> { [5u] = emptyTemplate }));
bool ok = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId(
clothingTables, 0x1900_0003u, paletteTemplateId: 5u, out _);
Assert.False(ok);
}
// ── Fixed sample-index constants (decomp anchors, gmCGAppearancePage::SetSelection) ──
[Fact]
public void SampleIndexConstants_MatchTheDecompiledLiterals()
{
Assert.Equal(0xd0, ChargenSwatchColorResolver.HairSampleIndex);
Assert.Equal(0xb0, ChargenSwatchColorResolver.SkinFamilySampleIndex);
Assert.Equal(0x103, ChargenSwatchColorResolver.EyeSampleIndex);
Assert.Equal(0x520, ChargenSwatchColorResolver.ClothingSampleIndex);
}
}