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; /// /// DAT-backed / /// implementation: reads PalSet (0x0F......) and ClothingTable (0x19......) /// dat objects on demand and projects them into 's /// pure Core types, matching ChargenTableReader'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. /// /// /// NOT thread-safe on its own (fix round F7, CC6b-MOUNT review): /// / do a lazy raw /// _dats.Get<T>() read on first use per id — and the shared /// DatCollection every sibling in this codebase guards with the /// process-wide DAT lock is itself NOT thread-safe /// (feedback_phase_a1_hotfix_saga.md). Every call site MUST hold that /// same lock (ChargenPreviewController's _datLock, the /// composition root's d.DatLock) around calls into this class, exactly /// like every other DAT-touching call in this codebase already does. This /// class's own caches only /// protect the CACHE from concurrent mutation — they do nothing for the /// underlying DatCollection read the cache miss triggers. /// /// /// /// (Campaign CC gate round 1 /// Batch G, R2-5): the real color-wheel/swatch mechanism /// (ChargenSwatchColorResolver) needs one more DAT read this class /// didn't previously do — a raw Palette dat object's (0x04......) own color /// table, retail's Palette::get_color32 equivalent. Same lazy-cache /// shape as /, /// same DAT-lock obligation on every call site. /// /// public sealed class ChargenAppearanceCatalog : IChargenPalSetSource, IChargenClothingTableSource, IChargenPaletteColorSource { private readonly IDatReaderWriter _dats; private readonly ConcurrentDictionary _palSets = new(); private readonly ConcurrentDictionary _clothingTables = new(); private readonly ConcurrentDictionary _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); /// /// Retail's ClientCharGenState::GetColorFromPal @0x00563990: load /// the Palette dat object and read its color table at a fixed index — /// direct ARGB[index], no averaging, no shade indirection. Unlike /// retail's own unchecked array read, this bounds-checks /// against the loaded palette's actual color /// count and returns false rather than reading out of range (see /// 's own doc for /// why that divergence is deliberate). /// public bool TryGetColor(uint paletteId, int index, out ChargenSwatchRgb color) { color = default; DatPalette? palette = _palettes.GetOrAdd(paletteId, id => _dats.Get(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(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(id); if (table is null) return null; var baseEffects = new Dictionary( table.ClothingBaseEffects.Count); foreach (var pair in table.ClothingBaseEffects) baseEffects[pair.Key.DataId] = ProjectBaseEffect(pair.Value.CloObjectEffects); var templates = new Dictionary( 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 objectEffects) { var partChanges = new List(objectEffects.Count); var textureChanges = new List(); 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 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)); } }