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:
commit
1f6365d3e5
10 changed files with 1558 additions and 30 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
69
src/AcDream.App/UI/Layout/ChargenSwatchColorTile.cs
Normal file
69
src/AcDream.App/UI/Layout/ChargenSwatchColorTile.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
46
src/AcDream.Core/CharGen/ChargenSwatchColor.cs
Normal file
46
src/AcDream.Core/CharGen/ChargenSwatchColor.cs
Normal 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);
|
||||
}
|
||||
197
src/AcDream.Core/CharGen/ChargenSwatchColorResolver.cs
Normal file
197
src/AcDream.Core/CharGen/ChargenSwatchColorResolver.cs
Normal 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 > 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue