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,46 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// A resolved representative swatch color — the RGB byte triple retail's
/// <c>Palette::get_color32 @0x0053e050</c> (a direct <c>ARGB[index]</c>
/// read, no bounds check on the real client) yields for one fixed sample
/// index into a Palette dat object's (0x04......) color table. Alpha is
/// deliberately omitted: retail's swatch/gradient recolor path
/// (<c>SurfaceWindow::ReplaceColor</c> /
/// <c>SurfaceWindow::BlitAndColor(..., Blit_Multiply, ...)</c>) only ever
/// reads R/G/B out of the sampled color — <c>gmCGAppearancePage</c>'s
/// <c>m_tColorWheel</c> entries carry <c>iRed</c>/<c>iGreen</c>/<c>iBlue</c>
/// fields and no <c>iAlpha</c> at all (<c>DoColorSpots @0x0047d850</c>,
/// <c>DoGradDisk @0x0047da90</c>).
/// </summary>
public readonly record struct ChargenSwatchRgb(byte R, byte G, byte B);
/// <summary>
/// Resolves one Palette dat object (0x04......) to a representative color
/// at a fixed sample index — retail's
/// <c>ClientCharGenState::GetColorFromPal @0x00563990</c>
/// (<c>DBObj::Get(QualifiedDataID(id, PALETTE_TYPE=0xa))</c> then
/// <c>Palette::get_color32(index)</c>, i.e. a direct, unchecked
/// <c>ARGB[index]</c> read). The production implementation
/// (<c>AcDream.Content.CharGen.ChargenAppearanceCatalog</c>) reads and
/// caches the real dat object, matching <see cref="IChargenPalSetSource"/>'s
/// established Core/Content split (interface in Core, Chorizite-backed
/// implementation in Content); unit tests supply a hand-built fake so this
/// interface's only consumer, <see cref="ChargenSwatchColorResolver"/>,
/// stays free of any Chorizite dependency.
/// </summary>
public interface IChargenPaletteColorSource
{
/// <summary>
/// Returns false when the Palette dat object itself doesn't resolve, OR
/// when <paramref name="index"/> falls outside its color table —
/// retail's own <c>Palette::get_color32</c> has NO bounds check (a
/// genuinely unchecked <c>ARGB[index]</c> read), so this is a
/// deliberate defensive divergence: acdream cannot reproduce retail's
/// undefined-behavior read as safe managed code, and treats an
/// out-of-range sample the same as a missing palette (no representative
/// color, caller skips the contribution) rather than throwing or
/// fabricating a value.
/// </summary>
bool TryGetColor(uint paletteId, int index, out ChargenSwatchRgb color);
}

View file

@ -0,0 +1,197 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// Campaign CC gate round 1 Batch G (R2-5, register AP-216/AP-217's
/// remaining halves): ports the color-computation half of
/// <c>gmCGAppearancePage::SetSelection @0x0047e260</c> — the per-swatch
/// representative RGB retail stores into <c>m_tColorWheel[i].iRed/iGreen/
/// iBlue</c> before <c>DoColorSpots @0x0047d850</c> paints it and
/// <c>DoGradDisk @0x0047da90</c> tints the gradient disc with the CURRENTLY
/// selected swatch's own entry.
///
/// <para>
/// <b>Two distinct color-source shapes, both decomp-traced:</b>
/// </para>
/// <list type="number">
/// <item>
/// <b>PalSet-averaged (Hair / Nose+Mouth+Skin / Headgear / Shirt /
/// Trousers / Footwear).</b> Retail resolves ONE PalSet id per swatch
/// index, then — for EVERY Palette id inside that PalSet (its <c>num_pals</c>
/// sub-palettes/shades) — samples a FIXED index via
/// <c>GetColorFromPal</c> and averages the R/G/B channels
/// (<c>@0x0047e759-0x0047e80f</c>, the shared loop every non-Eyes case
/// jumps into at <c>label_47e74b</c>). The averaging is intentional: the
/// swatch shows one representative hue for a color CHOICE that actually
/// spans several shade variants, not any single shade.
/// </item>
/// <item>
/// <b>Direct (Eyes only).</b> Retail uses the raw entry from
/// <c>ChargenGenderOptions.EyeColors</c> directly as a Palette id — no
/// PalSet indirection, no averaging, one <c>GetColorFromPal</c> call per
/// swatch (<c>@0x0047e3bf-0x0047e40f</c>), matching
/// <see cref="ChargenPalSet"/>'s own doc for why Eyes is the one
/// exception to the PalSet convention everywhere else in this campaign.
/// </item>
/// </list>
///
/// <para>
/// <b>Clothing's PalSet id source (Headgear/Shirt/Trousers/Footwear)</b> is
/// itself retail's own second-order lookup: <c>CharGenState::SetHeadgearStyle
/// @0x005c5350</c> (and its Shirt/Trousers/Footwear siblings,
/// <c>@0x005c5470/0x005c5590/0x005c56b0</c>) call
/// <c>StoreColorInformation @0x005c44d0</c> against the NEWLY SELECTED
/// garment's own ClothingTable (<c>DBObj::Get(clothingTableId, 0x19)</c>)
/// every time the style changes, walking that table's own
/// <c>CloPaletteTemplate</c> hash table and recording — for every template
/// id that ALSO appears in the gender's shared
/// <see cref="ChargenGenderOptions.ClothingColors"/> list — that template's
/// FIRST sub-palette choice's PalSet id
/// (<c>headgearPalSetIDs[]</c>/<c>shirtPalSetIDs[]</c>/etc, offset <c>+0x10</c>
/// off the copied <c>CloPaletteTemplate</c>, a decompiler-elided field read
/// cross-checked against this codebase's own
/// <c>ChargenClothingSubPaletteChoice.PalSetId</c> — the first
/// <c>Choices</c> entry of the SAME projected shape).
/// <see cref="TryGetClothingSwatchPalSetId"/> reproduces the OBSERVABLE
/// result (which PalSet a given <see cref="ChargenGenderOptions.ClothingColors"/>
/// index represents for the currently equipped garment) via a direct
/// dictionary lookup by template id rather than replicating retail's own
/// array-building traversal — a hash-table walk's OWN internal bucket order
/// is an implementation detail of retail's cache, not part of the
/// observable behavior, and a by-id lookup is provably order-independent.
/// This keeps the swatch index space IDENTICAL to what
/// <c>ChargenAppearanceFactory.ComposeClothingSlot</c> already treats as
/// canonical (<c>gender.ClothingColors[(int)colorIndex]</c>, the SAME
/// index space <c>RuntimeCharacterCreationAppearance</c> persists and the
/// 3D preview already renders correctly from, proven across this
/// campaign's own installed-DAT and live two-client gates) — deliberately
/// NOT re-deriving a second, potentially-divergent index space from
/// <c>StoreColorInformation</c>'s own cache-building order.
/// </para>
/// </summary>
public static class ChargenSwatchColorResolver
{
/// <summary>Hair's fixed sample index (<c>gmCGAppearancePage::SetSelection</c>
/// case ECG_PARTS_HAIR, <c>__return = 0xd0</c> @0x0047e388).</summary>
public const int HairSampleIndex = 0xd0;
/// <summary>Nose/Mouth/Skin's shared fixed sample index — all three route
/// to the SAME single-entry PalSet id (<c>ChargenGenderOptions.SkinPalSetId</c>)
/// with <c>__return = 0xb0</c> (Nose @0x0047e488, Mouth @0x0047e4e9, Skin
/// @0x0047e542).</summary>
public const int SkinFamilySampleIndex = 0xb0;
/// <summary>Eyes' fixed sample index, used DIRECTLY against
/// <c>ChargenGenderOptions.EyeColors[i]</c> with no PalSet indirection
/// (case ECG_PARTS_EYES, <c>GetColorFromPal(..., 0x103)</c>
/// @0x0047e3e2).</summary>
public const int EyeSampleIndex = 0x103;
/// <summary>The shared clothing sample index — Headgear/Shirt/Trousers/
/// Footwear all set <c>__return = 0x520</c> before falling into the
/// shared averaging loop (@0x0047e5be/0x0047e62b/0x0047e6ab/0x0047e733).</summary>
public const int ClothingSampleIndex = 0x520;
/// <summary>
/// PalSet-averaged representative color (Hair / Nose+Mouth+Skin /
/// Headgear / Shirt / Trousers / Footwear) — retail's shared
/// <c>label_47e74b</c> loop: resolve the PalSet, sample every one of its
/// Palette ids at <paramref name="sampleIndex"/>, average the R/G/B
/// channels. A PalSet that resolves but carries zero Palette ids
/// reproduces retail's own explicit zero-init with no averaging
/// division (<c>@0x0047e790</c> zeroes <c>iRed/iGreen/iBlue</c>
/// unconditionally before the <c>num_pals &gt; 0</c> guard) — returns
/// true with a BLACK color, not false, matching retail's actual output
/// for that shape. Returns false only when the PalSet id itself doesn't
/// resolve at all (retail's outer <c>if (__return_8 != 0)</c> miss,
/// which leaves that swatch's <c>m_tColorWheel</c> entry untouched from
/// whatever it held before — the closest acdream equivalent is "no
/// color to paint this swatch with").
/// </summary>
public static bool TryGetPalSetAverageColor(
IChargenPalSetSource palSets,
IChargenPaletteColorSource colors,
uint palSetId,
int sampleIndex,
out ChargenSwatchRgb color)
{
ArgumentNullException.ThrowIfNull(palSets);
ArgumentNullException.ThrowIfNull(colors);
color = default;
ChargenPalSet? palSet = palSets.TryGetPalSet(palSetId);
if (palSet is null)
return false;
if (palSet.PaletteIds.Count == 0)
return true; // retail: explicit zero-init, no division — black.
int sumR = 0, sumG = 0, sumB = 0;
foreach (uint paletteId in palSet.PaletteIds)
{
// A per-entry miss contributes (0,0,0) to the running sum —
// retail's own loop (@0x0047e7a5-0x0047e7dc) accumulates
// unconditionally and always divides by the FULL num_pals
// afterward; GetColorFromPal's own miss path
// (@0x005639b5) returns 0 rather than skipping the entry.
if (colors.TryGetColor(paletteId, sampleIndex, out ChargenSwatchRgb c))
{
sumR += c.R;
sumG += c.G;
sumB += c.B;
}
}
int count = palSet.PaletteIds.Count;
color = new ChargenSwatchRgb((byte)(sumR / count), (byte)(sumG / count), (byte)(sumB / count));
return true;
}
/// <summary>
/// Direct representative color (Eyes only) — one
/// <see cref="IChargenPaletteColorSource.TryGetColor"/> call against
/// <paramref name="paletteId"/> with no PalSet indirection and no
/// averaging, matching retail's ECG_PARTS_EYES case exactly.
/// </summary>
public static bool TryGetDirectColor(
IChargenPaletteColorSource colors,
uint paletteId,
int sampleIndex,
out ChargenSwatchRgb color)
{
ArgumentNullException.ThrowIfNull(colors);
return colors.TryGetColor(paletteId, sampleIndex, out color);
}
/// <summary>
/// Resolves the PalSet id a clothing swatch index represents for the
/// CURRENTLY EQUIPPED garment — see this class's own doc for why a
/// direct by-id lookup reproduces retail's observable
/// <c>StoreColorInformation</c> result without replicating its own
/// cache-building traversal order. Returns false when the garment's
/// ClothingTable doesn't resolve, has no palette template for
/// <paramref name="paletteTemplateId"/>, or that template carries no
/// sub-palette choices at all (an authored garment with a dye slot but
/// literally zero dye options) — every case retail's own miss paths
/// treat as "this swatch has no color."
/// </summary>
public static bool TryGetClothingSwatchPalSetId(
IChargenClothingTableSource clothingTables,
uint clothingTableId,
uint paletteTemplateId,
out uint palSetId)
{
ArgumentNullException.ThrowIfNull(clothingTables);
palSetId = 0;
ChargenClothingTable? table = clothingTables.TryGetClothingTable(clothingTableId);
if (table is null)
return false;
if (!table.PaletteTemplatesById.TryGetValue(paletteTemplateId, out ChargenClothingPaletteTemplate? template))
return false;
if (template.Choices.Count == 0)
return false;
palSetId = template.Choices[0].PalSetId;
return true;
}
}