Merge campaign-cc-batch-g: Batch G — real color wheel mechanism (inert until closeout wiring)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	docs/research/2026-08-16-campaign-cc-gate-round1-findings.md
This commit is contained in:
Erik 2026-08-16 14:19:30 +02:00
commit 1f6365d3e5
10 changed files with 1558 additions and 30 deletions

View file

@ -1,3 +1,4 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.CharGen;
using AcDream.Runtime;
@ -69,6 +70,24 @@ namespace AcDream.App.UI.Layout;
/// trousers/footwear) DO carry a real <see cref="ChargenGearOption.Name"/>
/// and show it directly.
/// </para>
///
/// <para>
/// <b>The real color wheel (Campaign CC gate round 1 Batch G, R2-5,
/// register AP-216/AP-217):</b> retail's <c>DoColorSpots @0x0047d850</c> /
/// <c>DoGradDisk @0x0047da90</c> paint each swatch and the gradient disc
/// with an ACTUAL representative color sampled from the real DAT palette
/// data (<c>AcDream.Core.CharGen.ChargenSwatchColorResolver</c> ports the
/// computation — see its own doc for the two color-source shapes and the
/// clothing PalSet lookup). <see cref="PalSetSource"/>/
/// <see cref="ClothingTableSource"/>/<see cref="PaletteColorSource"/> are
/// late-bound composition seams (same pattern as <see cref="PreviewControl"/>)
/// a DAT-backed catalog wires in after construction; the <see cref="ChargenSwatchColorTile"/>
/// children painted over each swatch/the gradient disc are this batch's
/// rendering primitive — see that class's own doc for why it is a flat
/// color fill (a documented approximation of retail's actual recolored-
/// sprite blit) and the STOPPED shared-file edit that would upgrade it to
/// a genuine texture tint.
/// </para>
/// </summary>
internal sealed class CharacterCreationAppearancePage : IDisposable
{
@ -176,6 +195,16 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
private readonly UiButton? _zoomOut;
private readonly UiElement? _gradCircle;
/// <summary>R2-5: one flat-color tile per swatch, added as an EXTRA
/// child of the swatch it decorates (see <see cref="ChargenSwatchColorTile"/>'s
/// own doc) — null wherever the matching <see cref="_swatches"/> entry
/// itself is null (nothing to attach to).</summary>
private readonly ChargenSwatchColorTile?[] _swatchColorTiles = new ChargenSwatchColorTile?[SwatchIds.Length];
/// <summary>R2-5: the gradient disc's own tint tile, an extra child of
/// <see cref="_gradCircle"/>.</summary>
private readonly ChargenSwatchColorTile? _gradCircleTile;
private Choice _currentChoice = Choice.Face;
private Part _currentPart = Part.Hair;
private bool _eyesArrowsDisabled;
@ -186,6 +215,24 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
/// page cannot receive the real renderer at construction time.</summary>
internal IChargenPreviewControl? PreviewControl { get; set; }
/// <summary>
/// R2-5 late-bound seams (same pattern as <see cref="PreviewControl"/>
/// above) for the real color-wheel mechanism — null (the default)
/// leaves every swatch/the gradient disc showing ONLY its authored
/// static art, i.e. this page's pre-Batch-G behavior, until a
/// composition root supplies a DAT-backed
/// <c>AcDream.Content.CharGen.ChargenAppearanceCatalog</c> (which
/// already implements all three interfaces) for these three
/// properties, mirroring how <see cref="PreviewControl"/> itself gets
/// wired in from outside this class. STOPPED (Batch G): that
/// assignment is a 3-line addition to
/// <c>CharacterCreationUiController.cs</c>, outside this batch's file
/// contract — see the batch's handoff notes.
/// </summary>
internal IChargenPalSetSource? PalSetSource { get; set; }
internal IChargenClothingTableSource? ClothingTableSource { get; set; }
internal IChargenPaletteColorSource? PaletteColorSource { get; set; }
/// <summary>The authored viewport (<c>0x100003bb</c>) — the composition
/// root assigns its <c>Renderer</c> once the graphics backend exists,
/// mirroring the paperdoll's own late <c>viewport.Renderer = ...</c>
@ -233,6 +280,18 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
int index = i;
swatch.OnClick = () => SelectColor(index);
_swatches[i] = swatch;
// R2-5: an extra CHILD tile, sized to exactly cover the
// swatch's own face — see ChargenSwatchColorTile's own doc for
// why this is a flat fill rather than a recolored sprite, and
// for why ClickThrough there keeps this from ever swallowing
// the swatch's own click.
var tile = new ChargenSwatchColorTile
{
Left = 0f, Top = 0f, Width = swatch.Width, Height = swatch.Height,
};
swatch.AddChild(tile);
_swatchColorTiles[i] = tile;
}
for (int i = 0; i < SwatchOverlayIds.Length; i++)
@ -243,6 +302,14 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
_shadeScroll.ScalarChanged = SetShadeFromScalar;
_gradCircle = Find<UiElement>(pageRoot, GradCircleId);
if (_gradCircle is not null)
{
_gradCircleTile = new ChargenSwatchColorTile
{
Left = 0f, Top = 0f, Width = _gradCircle.Width, Height = _gradCircle.Height,
};
_gradCircle.AddChild(_gradCircleTile);
}
Viewport = Find<UiViewport>(pageRoot, ViewportId);
@ -679,36 +746,71 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
overlay.Visible = colorSlot is not null && currentColor == (uint)i;
}
// AP-216 (Campaign CC gate round 1 Batch C, PARTIAL): retail's
// DoColorSpots @0x0047d850 blits ACTUAL-color art for each valid
// swatch and BLANK art for any swatch beyond the current part's
// real color count. Painting each swatch with its own represented
// color needs a PalSet/Palette-id -> RGB resolution pipeline this
// batch does not add (no chargen page currently reads DAT palette
// pixels at runtime) — register AP-216 stays open for that half.
// This ships the cheap, fully-evidenced half: hiding a swatch a
// part's color list doesn't actually have (closest faithful
// rendering the existing pipeline supports — Visible=false is the
// acdream equivalent of "blit nothing").
int colorCount = colorSlot is not null
&& TryGetGender(view, snapshot, out ChargenGenderOptions? swatchGender)
? ColorCount(_currentPart, swatchGender)
: 0;
// AP-216 (Campaign CC gate round 1 Batch C PARTIAL -> Batch G,
// R2-5, FULL): retail's DoColorSpots @0x0047d850 blits ACTUAL-color
// art for each valid swatch and BLANK art for any swatch beyond the
// current part's real color count. The "beyond count" half shipped
// at Batch C (hiding a swatch the part's color list doesn't have);
// this batch adds the "actual color" half via
// ChargenSwatchColorResolver (see this page's own class doc).
//
// displayCount diverges from the interactive colorSlot/colorCount
// pairing for exactly one family: Nose/Mouth/Skin (colorSlot ==
// null, per ColorSlotFor's own doc) still get ONE representative
// swatch in retail — SetSelection's Nose/Mouth/Skin cases each hard-
// code var_1e0 = 1 (@0x0047e456/0x0047e4b7/0x0047e510) even though
// no ListenToElementMessage case ever makes that swatch clickable
// (SetColor's switch has no case for those three parts either).
bool swatchGenderResolved = TryGetGender(view, snapshot, out ChargenGenderOptions? swatchGender);
int colorCount = colorSlot is not null && swatchGenderResolved
? ColorCount(_currentPart, swatchGender!)
: 0;
int displayCount = colorSlot is not null ? colorCount : 1;
ChargenSwatchRgb?[] swatchColors = swatchGenderResolved
? ComputeSwatchColors(swatchGender!, snapshot.Appearance)
: new ChargenSwatchRgb?[SwatchIds.Length];
for (int i = 0; i < _swatches.Length; i++)
{
if (_swatches[i] is { } swatch)
swatch.Visible = colorSlot is not null && i < colorCount;
if (_swatches[i] is not { } swatch)
continue;
bool visible = i < displayCount;
swatch.Visible = visible;
if (_swatchColorTiles[i] is { } tile)
{
ChargenSwatchRgb? rgb = visible ? swatchColors[i] : null;
tile.Color = rgb is { } c ? ToTintColor(c) : null;
tile.Visible = rgb is not null;
}
}
// AP-217 (PARTIAL): gmCGAppearancePage::DoGradDisk @0x0047da90
// blits the blank "grad plug" for Eyes (DoGradDisk(this, 1),
// called from SetSelection @0x0047e85d) and a gradient graphic
// TINTED with the current part's color otherwise — the tinted
// repaint needs the same palette-to-RGB pipeline AP-216's open
// half needs, so it stays open too. This ships the evidenced
// Eyes-blank half only.
// AP-217 (Batch C PARTIAL -> Batch G, R2-5, FULL):
// gmCGAppearancePage::DoGradDisk @0x0047da90 blits the blank "grad
// plug" for Eyes (DoGradDisk(this, 1), called from SetSelection
// @0x0047e85d) and a gradient graphic TINTED with the CURRENTLY
// SELECTED swatch's own color otherwise (SetColor @0x0047dd50's
// tail, DoGradDisk(this, 0) after m_iCurColor is already updated —
// @0x0047de18). Nose/Mouth/Skin always tint from swatch index 0
// (SetSelection hard-codes eyeColor = 0 for those three cases,
// matching displayCount's own reasoning above).
if (_gradCircle is not null)
_gradCircle.Visible = _currentPart != Part.Eyes;
{
bool isEyes = _currentPart == Part.Eyes;
_gradCircle.Visible = !isEyes;
if (_gradCircleTile is { } gradTile)
{
int gradIndex = isEyes
? -1
: colorSlot is null
? 0
: (int)ColorCurrent(_currentPart, snapshot.Appearance);
ChargenSwatchRgb? gradColor =
gradIndex >= 0 && gradIndex < swatchColors.Length ? swatchColors[gradIndex] : null;
gradTile.Color = gradColor is { } gc ? ToTintColor(gc) : null;
gradTile.Visible = !isEyes && gradColor is not null;
}
}
ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart);
if (_shadeScroll is null)
@ -727,6 +829,152 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
}
}
// ── Real swatch/gradient colors (R2-5) ──────────────────────────────
private static readonly ChargenSwatchRgb?[] EmptySwatchColors = new ChargenSwatchRgb?[SwatchIds.Length];
/// <summary>
/// Computes one representative <see cref="ChargenSwatchRgb"/> per
/// swatch slot (0..8, matching <see cref="SwatchIds"/>'s own order) for
/// <see cref="_currentPart"/>, or an all-null array wherever the
/// palette-resolution seams (<see cref="PalSetSource"/>/
/// <see cref="ClothingTableSource"/>/<see cref="PaletteColorSource"/>)
/// aren't wired yet — see this page's own class doc +
/// <see cref="AcDream.Core.CharGen.ChargenSwatchColorResolver"/>'s doc
/// for the retail mechanism each branch below ports.
/// </summary>
private ChargenSwatchRgb?[] ComputeSwatchColors(
ChargenGenderOptions gender, RuntimeCharacterCreationAppearance appearance)
{
if (PalSetSource is not { } palSets || PaletteColorSource is not { } colors)
return EmptySwatchColors;
var result = new ChargenSwatchRgb?[SwatchIds.Length];
switch (_currentPart)
{
case Part.Hair:
FillPalSetFamily(result, gender.HairColors, palSets, colors, ChargenSwatchColorResolver.HairSampleIndex);
break;
case Part.Eyes:
FillDirectFamily(result, gender.EyeColors, colors, ChargenSwatchColorResolver.EyeSampleIndex);
break;
case Part.Nose:
case Part.Mouth:
case Part.Skin:
// Retail: ONE representative swatch sourced from the
// single skin PalSet (SetSelection's Nose/Mouth/Skin cases,
// @0x0047e488/0x0047e4e9/0x0047e542 — all three set
// __return = 0xb0 against the same skinPalSetID DBObj get).
if (ChargenSwatchColorResolver.TryGetPalSetAverageColor(
palSets, colors, gender.SkinPalSetId,
ChargenSwatchColorResolver.SkinFamilySampleIndex, out ChargenSwatchRgb skin))
{
result[0] = skin;
}
break;
case Part.Headgear:
FillClothingFamily(result, gender, gender.Headgears, appearance.HeadgearStyle, palSets, colors);
break;
case Part.Shirt:
FillClothingFamily(result, gender, gender.Shirts, appearance.ShirtStyle, palSets, colors);
break;
case Part.Trousers:
FillClothingFamily(result, gender, gender.Pants, appearance.TrousersStyle, palSets, colors);
break;
case Part.Footwear:
FillClothingFamily(result, gender, gender.Footwear, appearance.FootwearStyle, palSets, colors);
break;
}
return result;
}
/// <summary>Hair's shape: one PalSet id per swatch index, straight off
/// <see cref="ChargenGenderOptions.HairColors"/> (already the exact
/// list <see cref="AcDream.Core.CharGen.ChargenAppearanceFactory"/>
/// indexes for the SAME selection when composing the 3D preview).</summary>
private static void FillPalSetFamily(
ChargenSwatchRgb?[] result,
IReadOnlyList<uint> palSetIds,
IChargenPalSetSource palSets,
IChargenPaletteColorSource colors,
int sampleIndex)
{
int count = Math.Min(result.Length, palSetIds.Count);
for (int i = 0; i < count; i++)
{
if (ChargenSwatchColorResolver.TryGetPalSetAverageColor(
palSets, colors, palSetIds[i], sampleIndex, out ChargenSwatchRgb c))
{
result[i] = c;
}
}
}
/// <summary>Eyes' shape: one Palette id per swatch index DIRECTLY off
/// <see cref="ChargenGenderOptions.EyeColors"/> — no PalSet
/// indirection, no averaging (see <see cref="ChargenPalSet"/>'s own
/// doc for why Eyes is the one exception).</summary>
private static void FillDirectFamily(
ChargenSwatchRgb?[] result,
IReadOnlyList<uint> paletteIds,
IChargenPaletteColorSource colors,
int sampleIndex)
{
int count = Math.Min(result.Length, paletteIds.Count);
for (int i = 0; i < count; i++)
{
if (ChargenSwatchColorResolver.TryGetDirectColor(colors, paletteIds[i], sampleIndex, out ChargenSwatchRgb c))
result[i] = c;
}
}
/// <summary>
/// Headgear/Shirt/Trousers/Footwear's shape: every swatch index shares
/// the SAME <see cref="ChargenGenderOptions.ClothingColors"/> template-id
/// list (register AP-208), resolved against the CURRENTLY EQUIPPED
/// garment's own ClothingTable — see
/// <see cref="AcDream.Core.CharGen.ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId"/>'s
/// own doc for why a direct by-id lookup reproduces retail's
/// <c>StoreColorInformation</c> result without needing its own array-
/// building order.
/// </summary>
private void FillClothingFamily(
ChargenSwatchRgb?[] result,
ChargenGenderOptions gender,
IReadOnlyList<ChargenGearOption> gearOptions,
uint styleIndex,
IChargenPalSetSource palSets,
IChargenPaletteColorSource colors)
{
if (ClothingTableSource is not { } clothingTables)
return;
// Retail: an Unset ("no garment") style leaves numHeadgearColors
// (etc) at its CharGenState::SetHeadgearStyle @0x005c5350 reset
// value of 0 — no garment equipped means no dye choices to show.
if (styleIndex == Unset || styleIndex >= (uint)gearOptions.Count)
return;
uint clothingTableId = gearOptions[(int)styleIndex].ClothingTableId;
IReadOnlyList<uint> clothingColors = gender.ClothingColors;
int count = Math.Min(result.Length, clothingColors.Count);
for (int i = 0; i < count; i++)
{
if (!ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId(
clothingTables, clothingTableId, clothingColors[i], out uint palSetId))
{
continue;
}
if (ChargenSwatchColorResolver.TryGetPalSetAverageColor(
palSets, colors, palSetId, ChargenSwatchColorResolver.ClothingSampleIndex, out ChargenSwatchRgb c))
{
result[i] = c;
}
}
}
private static Vector4 ToTintColor(ChargenSwatchRgb rgb) =>
new(rgb.R / 255f, rgb.G / 255f, rgb.B / 255f, 1f);
// ── Spin captions ────────────────────────────────────────────────
/// <summary>
@ -961,5 +1209,11 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
// PreviewControl is owned by the composition root (disposed with
// the leased ChargenPreviewRenderer) — just drop the reference.
PreviewControl = null;
// R2-5: same ownership shape as PreviewControl above — these are
// borrowed references into a DAT-backed catalog the composition
// root owns, not this page's own resources.
PalSetSource = null;
ClothingTableSource = null;
PaletteColorSource = null;
}
}

View file

@ -0,0 +1,69 @@
using System.Numerics;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Campaign CC gate round 1 Batch G (R2-5, register AP-216/AP-217): paints
/// one flat-fill patch of a computed <see cref="AcDream.Core.CharGen.ChargenSwatchRgb"/>
/// on top of whatever element it is attached to as a child — the
/// Appearance page's real-color rendering primitive for the nine color
/// swatches and the gradient disc.
///
/// <para>
/// <b>Why a flat fill, not a recolored sprite (documented approximation):</b>
/// retail's own mechanism (<c>gmCGAppearancePage::DoColorSpots @0x0047d850</c>
/// / <c>DoGradDisk @0x0047da90</c>) blits an authored "spot"/gradient
/// graphic and RECOLORS it in place
/// (<c>SurfaceWindow::ReplaceColor</c> / <c>BlitAndColor(...,
/// Blit_Multiply, color)</c>) — a genuine multiplicative texture tint. The
/// retained-UI sprite pipeline this codebase already has
/// (<see cref="UiRenderContext.DrawSprite"/>) DOES carry a per-draw
/// <c>Vector4 tint</c> parameter that could reproduce that exact multiply
/// blend, but neither <see cref="UiButton"/> (the nine swatches' own type,
/// sealed) nor <see cref="UiDatElement"/> (the gradient disc's own type)
/// exposes a per-instance tint hook on their EXISTING sprite draw calls —
/// adding one is a small, precisely-scoped, additive change to those two
/// shared widget files, outside this batch's file contract (reported as a
/// STOPPED item; see the batch's own commit message / handoff notes for the
/// exact diff). Rather than leave the swatches/wheel colorless pending that
/// follow-up, this class achieves the same OBSERVABLE result — "this
/// swatch/wheel visibly reflects the real computed color" — the cheapest
/// way the CURRENT public primitives allow: <see cref="UiRenderContext.DrawFill"/>
/// is a plain solid-color quad, so the tile reads as a flat color patch
/// rather than a recolored dot/gradient graphic. It is added as an extra
/// CHILD of the swatch/disc it decorates (never replacing or subclassing
/// either sealed/shared type), so it draws strictly ON TOP
/// (<see cref="UiElement.DrawSelfAndChildren"/>: children paint after their
/// parent's own <c>OnDraw</c>) without disturbing the underlying element's
/// own state machine, media, or click handling at all.
/// </para>
///
/// <para>
/// <see cref="UiElement.ClickThrough"/> defaults to <c>false</c> on the
/// base class, so this MUST be set true by the constructor here (not left
/// to a caller to remember) — <see cref="UiElement.HitTest"/> walks
/// children BEFORE testing the parent, and an opaque, click-absorbing tile
/// sitting on top of a swatch button would silently eat every click meant
/// for it.
/// </para>
/// </summary>
internal sealed class ChargenSwatchColorTile : UiElement
{
public ChargenSwatchColorTile()
{
ClickThrough = true;
Visible = false;
}
/// <summary>The color to paint, or null to draw nothing this frame
/// (<see cref="Visible"/> is the authoritative on/off switch — callers
/// should set both together, matching every other swatch-visibility
/// site in <c>CharacterCreationAppearancePage</c>).</summary>
public Vector4? Color { get; set; }
protected override void OnDraw(UiRenderContext ctx)
{
if (Color is { } c && Width > 0f && Height > 0f)
ctx.DrawFill(0f, 0f, Width, Height, c);
}
}

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

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