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