acdream/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs
Erik 834c2547a9 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>
2026-08-16 14:16:14 +02:00

155 lines
7.1 KiB
C#

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;
namespace AcDream.Content.CharGen;
/// <summary>
/// DAT-backed <see cref="IChargenPalSetSource"/> / <see cref="IChargenClothingTableSource"/>
/// implementation: reads PalSet (0x0F......) and ClothingTable (0x19......)
/// dat objects on demand and projects them into <see cref="ChargenAppearanceFactory"/>'s
/// pure Core types, matching <c>ChargenTableReader</c>'s "no Chorizite leak"
/// discipline for everything it returns. Both lookups cache by dat id — a
/// live preview re-composes on every appearance change, and the same
/// PalSet/ClothingTable ids repeat constantly across heritages, genders, and
/// re-selections within one session.
///
/// <para>
/// <b>NOT thread-safe on its own (fix round F7, CC6b-MOUNT review):</b>
/// <see cref="TryGetPalSet"/>/<see cref="TryGetClothingTable"/> do a lazy raw
/// <c>_dats.Get&lt;T&gt;()</c> read on first use per id — and the shared
/// <c>DatCollection</c> every sibling in this codebase guards with the
/// process-wide DAT lock is itself NOT thread-safe
/// (<c>feedback_phase_a1_hotfix_saga.md</c>). Every call site MUST hold that
/// same lock (<c>ChargenPreviewController</c>'s <c>_datLock</c>, the
/// composition root's <c>d.DatLock</c>) around calls into this class, exactly
/// like every other DAT-touching call in this codebase already does. This
/// class's own <see cref="ConcurrentDictionary{TKey,TValue}"/> caches only
/// 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, 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)
{
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
}
public ChargenPalSet? TryGetPalSet(uint palSetId) =>
_palSets.GetOrAdd(palSetId, LoadPalSet);
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);
if (palSet is null)
return null;
var ids = new uint[palSet.Palettes.Count];
for (int i = 0; i < palSet.Palettes.Count; i++)
ids[i] = palSet.Palettes[i].DataId;
return new ChargenPalSet(Array.AsReadOnly(ids));
}
private ChargenClothingTable? LoadClothingTable(uint id)
{
DatClothingTable? table = _dats.Get<DatClothingTable>(id);
if (table is null)
return null;
var baseEffects = new Dictionary<uint, ChargenClothingBaseEffect>(
table.ClothingBaseEffects.Count);
foreach (var pair in table.ClothingBaseEffects)
baseEffects[pair.Key.DataId] = ProjectBaseEffect(pair.Value.CloObjectEffects);
var templates = new Dictionary<uint, ChargenClothingPaletteTemplate>(
table.ClothingSubPalEffects.Count);
foreach (var pair in table.ClothingSubPalEffects)
templates[pair.Key] = ProjectPaletteTemplate(pair.Value.CloSubPalettes);
return new ChargenClothingTable(
baseEffects.ToFrozenDictionary(),
templates.ToFrozenDictionary());
}
private static ChargenClothingBaseEffect ProjectBaseEffect(
IReadOnlyList<DatCloObjectEffect> objectEffects)
{
var partChanges = new List<ChargenAnimPartChange>(objectEffects.Count);
var textureChanges = new List<ChargenTextureChange>();
foreach (DatCloObjectEffect effect in objectEffects)
{
var partIndex = (byte)effect.Index;
partChanges.Add(new ChargenAnimPartChange(partIndex, effect.ModelId.DataId));
foreach (var tex in effect.CloTextureEffects)
{
textureChanges.Add(new ChargenTextureChange(
partIndex, tex.OldTexture.DataId, tex.NewTexture.DataId));
}
}
return new ChargenClothingBaseEffect(
Array.AsReadOnly(partChanges.ToArray()),
Array.AsReadOnly(textureChanges.ToArray()));
}
private static ChargenClothingPaletteTemplate ProjectPaletteTemplate(
IReadOnlyList<DatCloSubPalette> subPalettes)
{
var choices = new ChargenClothingSubPaletteChoice[subPalettes.Count];
for (int i = 0; i < subPalettes.Count; i++)
{
DatCloSubPalette sub = subPalettes[i];
var ranges = new ChargenClothingSubPaletteRange[sub.Ranges.Count];
for (int j = 0; j < sub.Ranges.Count; j++)
ranges[j] = new ChargenClothingSubPaletteRange(sub.Ranges[j].Offset, sub.Ranges[j].NumColors);
choices[i] = new ChargenClothingSubPaletteChoice(sub.PaletteSet.DataId, Array.AsReadOnly(ranges));
}
return new ChargenClothingPaletteTemplate(Array.AsReadOnly(choices));
}
}