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

@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using System.Collections.Frozen;
using AcDream.Core.CharGen;
using DatClothingTable = DatReaderWriter.DBObjs.ClothingTable;
using DatPalette = DatReaderWriter.DBObjs.Palette;
using DatPalSet = DatReaderWriter.DBObjs.PalSet;
using DatCloObjectEffect = DatReaderWriter.Types.CloObjectEffect;
using DatCloSubPalette = DatReaderWriter.Types.CloSubPalette;
@ -32,12 +33,24 @@ namespace AcDream.Content.CharGen;
/// protect the CACHE from concurrent mutation — they do nothing for the
/// underlying <c>DatCollection</c> read the cache miss triggers.
/// </para>
///
/// <para>
/// <b><see cref="IChargenPaletteColorSource"/> (Campaign CC gate round 1
/// Batch G, R2-5):</b> the real color-wheel/swatch mechanism
/// (<c>ChargenSwatchColorResolver</c>) needs one more DAT read this class
/// didn't previously do — a raw Palette dat object's (0x04......) own color
/// table, retail's <c>Palette::get_color32</c> equivalent. Same lazy-cache
/// shape as <see cref="TryGetPalSet"/>/<see cref="TryGetClothingTable"/>,
/// same DAT-lock obligation on every call site.
/// </para>
/// </summary>
public sealed class ChargenAppearanceCatalog : IChargenPalSetSource, IChargenClothingTableSource
public sealed class ChargenAppearanceCatalog :
IChargenPalSetSource, IChargenClothingTableSource, IChargenPaletteColorSource
{
private readonly IDatReaderWriter _dats;
private readonly ConcurrentDictionary<uint, ChargenPalSet?> _palSets = new();
private readonly ConcurrentDictionary<uint, ChargenClothingTable?> _clothingTables = new();
private readonly ConcurrentDictionary<uint, DatPalette?> _palettes = new();
public ChargenAppearanceCatalog(IDatReaderWriter dats)
{
@ -50,6 +63,28 @@ public sealed class ChargenAppearanceCatalog : IChargenPalSetSource, IChargenClo
public ChargenClothingTable? TryGetClothingTable(uint clothingTableId) =>
_clothingTables.GetOrAdd(clothingTableId, LoadClothingTable);
/// <summary>
/// Retail's <c>ClientCharGenState::GetColorFromPal @0x00563990</c>: load
/// the Palette dat object and read its color table at a fixed index —
/// direct <c>ARGB[index]</c>, no averaging, no shade indirection. Unlike
/// retail's own unchecked array read, this bounds-checks
/// <paramref name="index"/> against the loaded palette's actual color
/// count and returns false rather than reading out of range (see
/// <see cref="IChargenPaletteColorSource.TryGetColor"/>'s own doc for
/// why that divergence is deliberate).
/// </summary>
public bool TryGetColor(uint paletteId, int index, out ChargenSwatchRgb color)
{
color = default;
DatPalette? palette = _palettes.GetOrAdd(paletteId, id => _dats.Get<DatPalette>(id));
if (palette is null || index < 0 || index >= palette.Colors.Count)
return false;
DatReaderWriter.Types.ColorARGB c = palette.Colors[index];
color = new ChargenSwatchRgb(c.Red, c.Green, c.Blue);
return true;
}
private ChargenPalSet? LoadPalSet(uint id)
{
DatPalSet? palSet = _dats.Get<DatPalSet>(id);