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>
155 lines
7.1 KiB
C#
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<T>()</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));
|
|
}
|
|
}
|