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,447 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign CC gate round 1 Batch G (R2-5, register AP-216/AP-217): fixture
/// tests for <see cref="CharacterCreationAppearancePage"/>'s real swatch/
/// gradient-disc color computation — a hand-built layout + fake DAT sources
/// (no installed dat), so recompute triggers can be asserted deterministically.
/// Real installed-DAT color values are pinned separately in
/// <c>AcDream.Content.Tests.CharGen.ChargenAppearanceCatalogColorTests</c>.
/// </summary>
public sealed class CharacterCreationAppearancePageSwatchColorTests
{
private const uint HeritageId = 1u;
private const int GenderKey = 1;
private const uint HairPalSetIdA = 0x0F00_0001u;
private const uint HairPalSetIdB = 0x0F00_0002u;
private const uint EyePaletteIdA = 0x0400_0010u;
private const uint EyePaletteIdB = 0x0400_0011u;
private const uint HeadgearClothingTableId = 0x1900_0001u;
private const uint HeadgearDyePalSetId = 0x0F00_0010u;
private const uint SkinPalSetId = 0x0F00_0099u;
private static readonly ChargenSwatchRgb HairColorA = new(10, 20, 30);
private static readonly ChargenSwatchRgb HairColorB = new(40, 50, 60);
private static readonly ChargenSwatchRgb EyeColorA = new(70, 80, 90);
private static readonly ChargenSwatchRgb EyeColorB = new(100, 110, 120);
private static readonly ChargenSwatchRgb HeadgearColorA = new(130, 140, 150);
private static readonly ChargenSwatchRgb SkinColor = new(160, 170, 180);
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;
}
private sealed class FakeColorSource : IChargenPaletteColorSource
{
private readonly Dictionary<uint, ChargenSwatchRgb> _colors = new();
public void Add(uint paletteId, ChargenSwatchRgb color) => _colors[paletteId] = color;
public bool TryGetColor(uint paletteId, int index, out ChargenSwatchRgb color) =>
_colors.TryGetValue(paletteId, out color);
}
private static ChargenGenderOptions MakeGender() => new(
GenderKey: GenderKey,
Name: "Male",
Scale: 100u,
SetupId: 0x0200_0001u,
SoundTableId: 0u,
IconId: 0u,
BasePaletteId: 0u,
SkinPalSetId: SkinPalSetId,
PhysicsTableId: 0u,
MotionTableId: 0u,
CombatTableId: 0u,
BaseObjDesc: ChargenObjDesc.Empty,
HairColors: [HairPalSetIdA, HairPalSetIdB],
HairStyles: [new ChargenHairStyle(0u, false, 0u, ChargenObjDesc.Empty)],
EyeColors: [EyePaletteIdA, EyePaletteIdB],
EyeStrips: [new ChargenEyeStrip(0u, 0u, ChargenObjDesc.Empty, ChargenObjDesc.Empty)],
NoseStrips: [new ChargenFaceStrip(0u, ChargenObjDesc.Empty)],
MouthStrips: [new ChargenFaceStrip(0u, ChargenObjDesc.Empty)],
Headgears: [new ChargenGearOption("Cap", HeadgearClothingTableId, 0u)],
Shirts: [],
Pants: [],
Footwear: [],
ClothingColors: [9u]);
private static ChargenOptions MakeOptions(uint heritageId, ChargenGenderOptions gender)
{
var heritage = new ChargenHeritageOptions(
heritageId, "Test", 0u, 0x0200_0001u, 0u,
180u, 100u, [0], [],
new Dictionary<uint, ChargenSkillCost>(), [],
new Dictionary<int, ChargenGenderOptions> { [GenderKey] = gender });
return new ChargenOptions(
[],
new Dictionary<uint, ChargenHeritageOptions> { [heritageId] = heritage },
new Dictionary<uint, ChargenSkillCost>());
}
private static (FakePalSetSource pal, FakeClothingTableSource clothing, FakeColorSource colors) MakeSources()
{
var pal = new FakePalSetSource();
pal.Add(HairPalSetIdA, 0x0400_0001u);
pal.Add(HairPalSetIdB, 0x0400_0002u);
pal.Add(HeadgearDyePalSetId, 0x0400_0003u);
pal.Add(SkinPalSetId, 0x0400_0004u);
var colors = new FakeColorSource();
colors.Add(0x0400_0001u, HairColorA);
colors.Add(0x0400_0002u, HairColorB);
colors.Add(EyePaletteIdA, EyeColorA);
colors.Add(EyePaletteIdB, EyeColorB);
colors.Add(0x0400_0003u, HeadgearColorA);
colors.Add(0x0400_0004u, SkinColor);
var choice = new ChargenClothingSubPaletteChoice(HeadgearDyePalSetId, [new ChargenClothingSubPaletteRange(0, 8)]);
var templates = new Dictionary<uint, ChargenClothingPaletteTemplate>
{
[9u] = new ChargenClothingPaletteTemplate([choice]),
};
var clothing = new FakeClothingTableSource();
clothing.Add(HeadgearClothingTableId, new ChargenClothingTable(
new Dictionary<uint, ChargenClothingBaseEffect>(), templates));
return (pal, clothing, colors);
}
private sealed class FakeView(ChargenOptions options, uint heritageId) : IRuntimeCharacterCreationView
{
public RuntimeCharacterCreationSnapshot Snapshot { get; set; } = new(
new RuntimeGenerationToken(1u),
IsActive: true,
Revision: 1,
HeritageId: heritageId,
GenderKey: (uint)GenderKey,
Appearance: RuntimeCharacterCreationAppearance.Default,
Template: RuntimeCharacterCreationSnapshot.TemplateUnset,
Attributes: default,
AttributeLockMask: 0u,
TotalAttributeCredits: 0u,
RemainingAttributeCredits: 0,
TotalSkillCredits: 0u,
RemainingSkillCredits: 0,
Name: string.Empty,
StartArea: -1,
Slot: 0u,
VerificationPending: false,
LastLocalRefusal: default,
LastRejection: null,
LastCreated: null);
public ChargenOptions Options { get; } = options;
public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) => ChargenSkillAdvancementClass.Inactive;
public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) => NullSubscription.Instance;
private sealed class NullSubscription : IDisposable
{
public static readonly NullSubscription Instance = new();
public void Dispose() { }
}
}
/// <summary>Minimal appearance-page fixture — just what
/// <see cref="CharacterCreationAppearancePage"/>'s constructor resolves
/// (spins for Hair/Eyes/Headgear used to drive <c>_currentPart</c> via
/// their own select-zone click, matching every other test in this
/// campaign's <c>CharacterCreationUiControllerTests</c> harness; the
/// nine swatches + the gradient disc, this test's own subject).</summary>
private static UiElement BuildPageRoot()
{
var page = new ElementInfo
{
Id = 0x100003D4u,
Type = 3u,
Width = 800f,
Height = 500f,
};
page.Children.Add(SpinInfo(CharacterCreationAppearancePage.HairSpinId));
page.Children.Add(SpinInfo(CharacterCreationAppearancePage.EyesSpinId));
page.Children.Add(SpinInfo(CharacterCreationAppearancePage.SkinSpinId));
page.Children.Add(SpinInfo(CharacterCreationAppearancePage.HeadgearSpinId));
foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds)
page.Children.Add(ButtonInfo(swatchId));
foreach (uint overlayId in CharacterCreationAppearancePage.SwatchOverlayIds)
page.Children.Add(ContainerInfo(overlayId));
page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.GradCircleId));
return LayoutImporter.Build(page, _ => (0u, 0, 0), null).Root;
}
private static ElementInfo SpinInfo(uint id)
{
var spin = new ElementInfo { Id = id, Type = 1u, Width = 200f, Height = 24f };
spin.Children.Add(new ElementInfo { Id = 0x1000030Au, Type = 1u, X = 80f, Width = 47f, Height = 24f });
spin.Children.Add(new ElementInfo { Id = 0x1000030Bu, Type = 1u, X = 127f, Width = 47f, Height = 24f });
return spin;
}
private static ElementInfo ButtonInfo(uint id) => new() { Id = id, Type = 1u, Width = 20f, Height = 20f };
private static ElementInfo ContainerInfo(uint id) => new() { Id = id, Type = 3u, Width = 64f, Height = 64f };
private static (CharacterCreationAppearancePage Page, FakeView View, UiElement Root) BuildPage(
FakePalSetSource pal, FakeClothingTableSource clothing, FakeColorSource colors)
{
var view = new FakeView(MakeOptions(HeritageId, MakeGender()), HeritageId);
var bindings = new CharacterCreationRuntimeBindings(
() => view,
_ => default,
_ => default,
_ => default,
(_, _) => default,
(_, _) => default,
_ => default,
_ => default,
_ => default,
_ => default,
_ => default,
() => { },
SetAppearanceIndex: (_, _) => default);
UiElement pageRoot = BuildPageRoot();
var page = new CharacterCreationAppearancePage(pageRoot, bindings)
{
PalSetSource = pal,
ClothingTableSource = clothing,
PaletteColorSource = colors,
};
return (page, view, pageRoot);
}
private static UiButton Swatch(UiElement pageRoot, int index) =>
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 Vector4 ToVector4(ChargenSwatchRgb rgb) => new(rgb.R / 255f, rgb.G / 255f, rgb.B / 255f, 1f);
[Fact]
public void HairPart_PaintsBothSwatchesWithTheirOwnDistinctColors()
{
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.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);
}
/// <summary>Part change (Hair -&gt; Eyes via the spin's own select-zone
/// click) must recompute the whole swatch set from the NEW part's own
/// color source.</summary>
[Fact]
public void PartChange_RecomputesTheSwatchSet()
{
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);
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);
}
/// <summary>Color change (clicking a different swatch) must retint the
/// gradient disc to the NEWLY selected swatch's own color.</summary>
[Fact]
public void ColorChange_RetintsTheGradientDiscToTheNewlySelectedSwatch()
{
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);
Swatch(root, 0).OnClick!();
view.Snapshot = view.Snapshot with
{
Appearance = view.Snapshot.Appearance with { HairColor = 0u },
};
page.Refresh(view, view.Snapshot);
Assert.Equal(ToVector4(HairColorA), GradTile(root).Color);
Assert.True(GradTile(root).Visible);
Swatch(root, 1).OnClick!();
view.Snapshot = view.Snapshot with
{
Appearance = view.Snapshot.Appearance with { HairColor = 1u },
};
page.Refresh(view, view.Snapshot);
Assert.Equal(ToVector4(HairColorB), GradTile(root).Color);
}
/// <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>
[Fact]
public void EyesPart_GradientDiscTileStaysBlank()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
view.Snapshot = view.Snapshot with
{
Appearance = view.Snapshot.Appearance with { EyeColor = 0u },
};
page.Refresh(view, view.Snapshot);
UiButton eyesSpin = Assert.IsType<UiButton>(
UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId));
eyesSpin.OnClickAt!(180, 10);
Assert.False(GradTile(root).Visible);
Assert.Null(GradTile(root).Color);
// The swatches themselves still show real eye colors — only the
// disc blanks.
Assert.Equal(ToVector4(EyeColorA), SwatchTile(root, 0).Color);
}
/// <summary>Nose/Mouth/Skin (colorSlot == null): retail still shows
/// exactly ONE representative swatch, sourced from the shared skin
/// PalSet, and the gradient disc tints from that SAME swatch — see
/// <c>ComputeSwatchColors</c>'s own doc.</summary>
[Fact]
public void SkinPart_ShowsExactlyOneRepresentativeSwatchAndTintsTheDiscFromIt()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
page.Refresh(view, view.Snapshot);
UiButton skinSpin = Assert.IsType<UiButton>(
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);
}
/// <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>
[Fact]
public void HeadgearPart_ResolvesThroughTheEquippedGarment_UnsetShowsNoSwatches()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
view.Snapshot = view.Snapshot with
{
Appearance = view.Snapshot.Appearance with { HeadgearStyle = 0u },
};
page.Refresh(view, view.Snapshot);
UiButton headgearSpin = Assert.IsType<UiButton>(
UiElement.FindDescendant(root, CharacterCreationAppearancePage.HeadgearSpinId));
headgearSpin.OnClickAt!(180, 10);
Assert.Equal(ToVector4(HeadgearColorA), SwatchTile(root, 0).Color);
// Now un-equip (Unset) and refresh again — no garment, no colors.
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);
}
/// <summary>Heritage/gender change must re-source the color computation
/// from the NEW gender's own option lists — a second, differently-
/// colored fixture proves the recompute isn't cached from the first.</summary>
[Fact]
public void HeritageChange_RecomputesFromTheNewGendersOwnColorLists()
{
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);
// A second heritage with a DIFFERENT hair-color list.
const uint otherHairPalSetId = 0x0F00_00AAu;
var otherHairColor = new ChargenSwatchRgb(200, 201, 202);
pal.Add(otherHairPalSetId, 0x0400_00AAu);
colors.Add(0x0400_00AAu, otherHairColor);
const uint otherHeritageId = 2u;
ChargenGenderOptions otherGender = MakeGender() with { HairColors = [otherHairPalSetId] };
ChargenOptions otherOptions = MakeOptions(otherHeritageId, otherGender);
var otherView = new FakeView(otherOptions, otherHeritageId);
page.Refresh(otherView, otherView.Snapshot);
Assert.Equal(ToVector4(otherHairColor), SwatchTile(root, 0).Color);
}
/// <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>
[Fact]
public void UnwiredSources_LeaveEveryTileInvisible()
{
var view = new FakeView(MakeOptions(HeritageId, MakeGender()), HeritageId);
var bindings = new CharacterCreationRuntimeBindings(
() => view,
_ => default,
_ => default,
_ => default,
(_, _) => default,
(_, _) => default,
_ => default,
_ => default,
_ => default,
_ => default,
_ => default,
() => { },
SetAppearanceIndex: (_, _) => default);
UiElement root = BuildPageRoot();
var page = new CharacterCreationAppearancePage(root, bindings); // sources left null.
page.Refresh(view, view.Snapshot);
Assert.False(GradTile(root).Visible);
for (int i = 0; i < CharacterCreationAppearancePage.SwatchIds.Length; i++)
Assert.False(SwatchTile(root, i).Visible);
}
}

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.
}
}

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);
}
}