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>
This commit is contained in:
parent
2ad805469d
commit
834c2547a9
10 changed files with 1558 additions and 29 deletions
|
|
@ -0,0 +1,234 @@
|
|||
using AcDream.Content.CharGen;
|
||||
using AcDream.Core.CharGen;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.Content.Tests.CharGen;
|
||||
|
||||
/// <summary>
|
||||
/// Installed-DAT gate for <see cref="ChargenAppearanceCatalog"/>'s
|
||||
/// <see cref="IChargenPaletteColorSource"/> implementation, PLUS
|
||||
/// <see cref="ChargenSwatchColorResolver"/> exercised end-to-end against
|
||||
/// real dat data — Campaign CC gate round 1 Batch G (R2-5, register
|
||||
/// AP-216/AP-217). Values below are MEASURED against the installed EoR dat
|
||||
/// (Aluvian male, heritage 1 / gender 1 — the same probe subject
|
||||
/// <c>ChargenAppearanceCatalogInstalledDatTests</c> uses), not assumed: a
|
||||
/// live probe (<c>ScratchPaletteProbe</c>, this batch's throwaway
|
||||
/// investigation harness) printed every value pinned here before it was
|
||||
/// written into an assertion.
|
||||
///
|
||||
/// <para>Env-gated skip (house pattern, matched from
|
||||
/// <c>ChargenAppearanceCatalogInstalledDatTests</c>): returns green with a
|
||||
/// console SKIP note when no installed dat directory is configured.</para>
|
||||
/// </summary>
|
||||
public sealed class ChargenAppearanceCatalogColorTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public ChargenAppearanceCatalogColorTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private const uint AluvianId = 1u;
|
||||
private const int MaleGenderKey = 1;
|
||||
|
||||
private static string? ResolveDatDir()
|
||||
{
|
||||
string? fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
|
||||
return fromEnv;
|
||||
string def = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
return Directory.Exists(def) ? def : null;
|
||||
}
|
||||
|
||||
private static (ChargenGenderOptions gender, ChargenAppearanceCatalog catalog)? LoadAluvianMale(
|
||||
DatCollectionAdapter adapter)
|
||||
{
|
||||
ChargenOptions options = ChargenTableReader.Load(adapter);
|
||||
if (!options.TryGetHeritage(AluvianId, out ChargenHeritageOptions? heritage))
|
||||
return null;
|
||||
if (!heritage.GendersByKey.TryGetValue(MaleGenderKey, out ChargenGenderOptions? gender))
|
||||
return null;
|
||||
return (gender, new ChargenAppearanceCatalog(adapter));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direct <see cref="ChargenAppearanceCatalog.TryGetColor"/> pin — the
|
||||
/// Eye family's shape (no PalSet indirection): reading the SAME palette
|
||||
/// id/index pair twice returns the SAME color (cache correctness) and
|
||||
/// matches the measured pixel.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryGetColor_EyePaletteAtFixedIndex_MatchesMeasuredPixel()
|
||||
{
|
||||
string? datDir = ResolveDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
var loaded = LoadAluvianMale(adapter);
|
||||
Assert.NotNull(loaded);
|
||||
(ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value;
|
||||
Assert.NotEmpty(gender.EyeColors);
|
||||
|
||||
bool ok = catalog.TryGetColor(
|
||||
gender.EyeColors[0], ChargenSwatchColorResolver.EyeSampleIndex, out ChargenSwatchRgb color);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(new ChargenSwatchRgb(15, 63, 93), color);
|
||||
|
||||
// Re-reading the SAME palette (cache hit path) is byte-identical.
|
||||
catalog.TryGetColor(gender.EyeColors[0], ChargenSwatchColorResolver.EyeSampleIndex, out ChargenSwatchRgb again);
|
||||
Assert.Equal(color, again);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetColor_OutOfRangeIndex_ReturnsFalse()
|
||||
{
|
||||
string? datDir = ResolveDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
var loaded = LoadAluvianMale(adapter);
|
||||
Assert.NotNull(loaded);
|
||||
(ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value;
|
||||
|
||||
bool ok = catalog.TryGetColor(gender.EyeColors[0], index: int.MaxValue, out _);
|
||||
|
||||
Assert.False(ok);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end through <see cref="ChargenSwatchColorResolver.TryGetPalSetAverageColor"/>
|
||||
/// for Hair — averaged across every one of Aluvian male's 13 hair-color
|
||||
/// PalSet's own sub-palettes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HairSwatchZero_AveragedAcrossPalSet_MatchesMeasuredColor()
|
||||
{
|
||||
string? datDir = ResolveDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
var loaded = LoadAluvianMale(adapter);
|
||||
Assert.NotNull(loaded);
|
||||
(ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value;
|
||||
Assert.NotEmpty(gender.HairColors);
|
||||
|
||||
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
|
||||
catalog, catalog, gender.HairColors[0], ChargenSwatchColorResolver.HairSampleIndex,
|
||||
out ChargenSwatchRgb color);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(new ChargenSwatchRgb(101, 94, 4), color);
|
||||
}
|
||||
|
||||
/// <summary>Distinct hair-color swatches must resolve to DISTINCT
|
||||
/// colors — the whole point of the mechanism (R2-5's complaint was that
|
||||
/// every swatch showed the SAME static art regardless of which color it
|
||||
/// represents).</summary>
|
||||
[Fact]
|
||||
public void EveryHairColorSwatch_ResolvesToADistinctColor()
|
||||
{
|
||||
string? datDir = ResolveDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
var loaded = LoadAluvianMale(adapter);
|
||||
Assert.NotNull(loaded);
|
||||
(ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value;
|
||||
Assert.True(gender.HairColors.Count > 1, "fixture assumption: Aluvian male has >1 hair color choice");
|
||||
|
||||
var seen = new HashSet<ChargenSwatchRgb>();
|
||||
foreach (uint palSetId in gender.HairColors)
|
||||
{
|
||||
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
|
||||
catalog, catalog, palSetId, ChargenSwatchColorResolver.HairSampleIndex, out ChargenSwatchRgb color);
|
||||
Assert.True(ok);
|
||||
_out.WriteLine($"hair palSet=0x{palSetId:X8} rgb=({color.R},{color.G},{color.B})");
|
||||
Assert.True(seen.Add(color), $"duplicate representative color {color} for palSet 0x{palSetId:X8}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end through the clothing family's two-step lookup
|
||||
/// (<see cref="ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId"/>
|
||||
/// then <see cref="ChargenSwatchColorResolver.TryGetPalSetAverageColor"/>)
|
||||
/// for Aluvian male's first headgear garment.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HeadgearSwatchZero_ResolvesThroughTheEquippedGarmentsClothingTable()
|
||||
{
|
||||
string? datDir = ResolveDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
var loaded = LoadAluvianMale(adapter);
|
||||
Assert.NotNull(loaded);
|
||||
(ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value;
|
||||
Assert.NotEmpty(gender.Headgears);
|
||||
Assert.NotEmpty(gender.ClothingColors);
|
||||
|
||||
ChargenGearOption firstHeadgear = gender.Headgears[0];
|
||||
bool palSetOk = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId(
|
||||
catalog, firstHeadgear.ClothingTableId, gender.ClothingColors[0], out uint palSetId);
|
||||
Assert.True(palSetOk);
|
||||
Assert.Equal(0x0F000009u, palSetId);
|
||||
|
||||
bool colorOk = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
|
||||
catalog, catalog, palSetId, ChargenSwatchColorResolver.ClothingSampleIndex, out ChargenSwatchRgb color);
|
||||
Assert.True(colorOk);
|
||||
Assert.Equal(new ChargenSwatchRgb(59, 59, 59), color);
|
||||
}
|
||||
|
||||
/// <summary>The Nose/Mouth/Skin family's shared single representative
|
||||
/// swatch — sourced from <see cref="ChargenGenderOptions.SkinPalSetId"/>,
|
||||
/// not any per-part list.</summary>
|
||||
[Fact]
|
||||
public void SkinFamilySwatch_ResolvesFromTheSharedSkinPalSet()
|
||||
{
|
||||
string? datDir = ResolveDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var adapter = new DatCollectionAdapter(dats);
|
||||
var loaded = LoadAluvianMale(adapter);
|
||||
Assert.NotNull(loaded);
|
||||
(ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value;
|
||||
|
||||
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
|
||||
catalog, catalog, gender.SkinPalSetId, ChargenSwatchColorResolver.SkinFamilySampleIndex,
|
||||
out ChargenSwatchRgb color);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(new ChargenSwatchRgb(182, 148, 118), color); // a plausible flesh tone.
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue