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,234 @@
using AcDream.Content.CharGen;
using AcDream.Core.CharGen;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit.Abstractions;
namespace AcDream.Content.Tests.CharGen;
/// <summary>
/// Installed-DAT gate for <see cref="ChargenAppearanceCatalog"/>'s
/// <see cref="IChargenPaletteColorSource"/> implementation, PLUS
/// <see cref="ChargenSwatchColorResolver"/> exercised end-to-end against
/// real dat data — Campaign CC gate round 1 Batch G (R2-5, register
/// AP-216/AP-217). Values below are MEASURED against the installed EoR dat
/// (Aluvian male, heritage 1 / gender 1 — the same probe subject
/// <c>ChargenAppearanceCatalogInstalledDatTests</c> uses), not assumed: a
/// live probe (<c>ScratchPaletteProbe</c>, this batch's throwaway
/// investigation harness) printed every value pinned here before it was
/// written into an assertion.
///
/// <para>Env-gated skip (house pattern, matched from
/// <c>ChargenAppearanceCatalogInstalledDatTests</c>): returns green with a
/// console SKIP note when no installed dat directory is configured.</para>
/// </summary>
public sealed class ChargenAppearanceCatalogColorTests
{
private readonly ITestOutputHelper _out;
public ChargenAppearanceCatalogColorTests(ITestOutputHelper output) => _out = output;
private const uint AluvianId = 1u;
private const int MaleGenderKey = 1;
private static string? ResolveDatDir()
{
string? fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
return fromEnv;
string def = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(def) ? def : null;
}
private static (ChargenGenderOptions gender, ChargenAppearanceCatalog catalog)? LoadAluvianMale(
DatCollectionAdapter adapter)
{
ChargenOptions options = ChargenTableReader.Load(adapter);
if (!options.TryGetHeritage(AluvianId, out ChargenHeritageOptions? heritage))
return null;
if (!heritage.GendersByKey.TryGetValue(MaleGenderKey, out ChargenGenderOptions? gender))
return null;
return (gender, new ChargenAppearanceCatalog(adapter));
}
/// <summary>
/// Direct <see cref="ChargenAppearanceCatalog.TryGetColor"/> pin — the
/// Eye family's shape (no PalSet indirection): reading the SAME palette
/// id/index pair twice returns the SAME color (cache correctness) and
/// matches the measured pixel.
/// </summary>
[Fact]
public void TryGetColor_EyePaletteAtFixedIndex_MatchesMeasuredPixel()
{
string? datDir = ResolveDatDir();
if (datDir is null)
{
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
var loaded = LoadAluvianMale(adapter);
Assert.NotNull(loaded);
(ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value;
Assert.NotEmpty(gender.EyeColors);
bool ok = catalog.TryGetColor(
gender.EyeColors[0], ChargenSwatchColorResolver.EyeSampleIndex, out ChargenSwatchRgb color);
Assert.True(ok);
Assert.Equal(new ChargenSwatchRgb(15, 63, 93), color);
// Re-reading the SAME palette (cache hit path) is byte-identical.
catalog.TryGetColor(gender.EyeColors[0], ChargenSwatchColorResolver.EyeSampleIndex, out ChargenSwatchRgb again);
Assert.Equal(color, again);
}
[Fact]
public void TryGetColor_OutOfRangeIndex_ReturnsFalse()
{
string? datDir = ResolveDatDir();
if (datDir is null)
{
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
var loaded = LoadAluvianMale(adapter);
Assert.NotNull(loaded);
(ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value;
bool ok = catalog.TryGetColor(gender.EyeColors[0], index: int.MaxValue, out _);
Assert.False(ok);
}
/// <summary>
/// End-to-end through <see cref="ChargenSwatchColorResolver.TryGetPalSetAverageColor"/>
/// for Hair — averaged across every one of Aluvian male's 13 hair-color
/// PalSet's own sub-palettes.
/// </summary>
[Fact]
public void HairSwatchZero_AveragedAcrossPalSet_MatchesMeasuredColor()
{
string? datDir = ResolveDatDir();
if (datDir is null)
{
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
var loaded = LoadAluvianMale(adapter);
Assert.NotNull(loaded);
(ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value;
Assert.NotEmpty(gender.HairColors);
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
catalog, catalog, gender.HairColors[0], ChargenSwatchColorResolver.HairSampleIndex,
out ChargenSwatchRgb color);
Assert.True(ok);
Assert.Equal(new ChargenSwatchRgb(101, 94, 4), color);
}
/// <summary>Distinct hair-color swatches must resolve to DISTINCT
/// colors — the whole point of the mechanism (R2-5's complaint was that
/// every swatch showed the SAME static art regardless of which color it
/// represents).</summary>
[Fact]
public void EveryHairColorSwatch_ResolvesToADistinctColor()
{
string? datDir = ResolveDatDir();
if (datDir is null)
{
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
var loaded = LoadAluvianMale(adapter);
Assert.NotNull(loaded);
(ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value;
Assert.True(gender.HairColors.Count > 1, "fixture assumption: Aluvian male has >1 hair color choice");
var seen = new HashSet<ChargenSwatchRgb>();
foreach (uint palSetId in gender.HairColors)
{
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
catalog, catalog, palSetId, ChargenSwatchColorResolver.HairSampleIndex, out ChargenSwatchRgb color);
Assert.True(ok);
_out.WriteLine($"hair palSet=0x{palSetId:X8} rgb=({color.R},{color.G},{color.B})");
Assert.True(seen.Add(color), $"duplicate representative color {color} for palSet 0x{palSetId:X8}");
}
}
/// <summary>
/// End-to-end through the clothing family's two-step lookup
/// (<see cref="ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId"/>
/// then <see cref="ChargenSwatchColorResolver.TryGetPalSetAverageColor"/>)
/// for Aluvian male's first headgear garment.
/// </summary>
[Fact]
public void HeadgearSwatchZero_ResolvesThroughTheEquippedGarmentsClothingTable()
{
string? datDir = ResolveDatDir();
if (datDir is null)
{
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
var loaded = LoadAluvianMale(adapter);
Assert.NotNull(loaded);
(ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value;
Assert.NotEmpty(gender.Headgears);
Assert.NotEmpty(gender.ClothingColors);
ChargenGearOption firstHeadgear = gender.Headgears[0];
bool palSetOk = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId(
catalog, firstHeadgear.ClothingTableId, gender.ClothingColors[0], out uint palSetId);
Assert.True(palSetOk);
Assert.Equal(0x0F000009u, palSetId);
bool colorOk = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
catalog, catalog, palSetId, ChargenSwatchColorResolver.ClothingSampleIndex, out ChargenSwatchRgb color);
Assert.True(colorOk);
Assert.Equal(new ChargenSwatchRgb(59, 59, 59), color);
}
/// <summary>The Nose/Mouth/Skin family's shared single representative
/// swatch — sourced from <see cref="ChargenGenderOptions.SkinPalSetId"/>,
/// not any per-part list.</summary>
[Fact]
public void SkinFamilySwatch_ResolvesFromTheSharedSkinPalSet()
{
string? datDir = ResolveDatDir();
if (datDir is null)
{
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
var loaded = LoadAluvianMale(adapter);
Assert.NotNull(loaded);
(ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value;
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
catalog, catalog, gender.SkinPalSetId, ChargenSwatchColorResolver.SkinFamilySampleIndex,
out ChargenSwatchRgb color);
Assert.True(ok);
Assert.Equal(new ChargenSwatchRgb(182, 148, 118), color); // a plausible flesh tone.
}
}