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,212 @@
|
|||
using AcDream.Core.CharGen;
|
||||
|
||||
namespace AcDream.Core.Tests.CharGen;
|
||||
|
||||
/// <summary>
|
||||
/// Hand-built-fixture tests for <see cref="ChargenSwatchColorResolver"/> —
|
||||
/// Campaign CC gate round 1 Batch G (R2-5, register AP-216/AP-217). Real
|
||||
/// installed-DAT coverage (pinned RGB values off the actual client dats)
|
||||
/// lives in <c>AcDream.Content.Tests.CharGen.ChargenAppearanceCatalogColorTests</c>.
|
||||
/// </summary>
|
||||
public sealed class ChargenSwatchColorResolverTests
|
||||
{
|
||||
private sealed class FakePalSetSource : IChargenPalSetSource
|
||||
{
|
||||
private readonly Dictionary<uint, ChargenPalSet> _sets = new();
|
||||
public void Add(uint id, params uint[] paletteIds) => _sets[id] = new ChargenPalSet(paletteIds);
|
||||
public ChargenPalSet? TryGetPalSet(uint palSetId) => _sets.TryGetValue(palSetId, out var s) ? s : null;
|
||||
}
|
||||
|
||||
private sealed class FakeClothingTableSource : IChargenClothingTableSource
|
||||
{
|
||||
private readonly Dictionary<uint, ChargenClothingTable> _tables = new();
|
||||
public void Add(uint id, ChargenClothingTable table) => _tables[id] = table;
|
||||
public ChargenClothingTable? TryGetClothingTable(uint clothingTableId) =>
|
||||
_tables.TryGetValue(clothingTableId, out var t) ? t : null;
|
||||
}
|
||||
|
||||
/// <summary>Fixed-color fake: every (paletteId, index) pair the test
|
||||
/// registers resolves to an EXACT color, so averaging math can be
|
||||
/// checked by hand rather than against opaque installed-DAT pixels.</summary>
|
||||
private sealed class FakeColorSource : IChargenPaletteColorSource
|
||||
{
|
||||
private readonly Dictionary<(uint paletteId, int index), ChargenSwatchRgb> _colors = new();
|
||||
public void Add(uint paletteId, int index, byte r, byte g, byte b) =>
|
||||
_colors[(paletteId, index)] = new ChargenSwatchRgb(r, g, b);
|
||||
|
||||
public bool TryGetColor(uint paletteId, int index, out ChargenSwatchRgb color) =>
|
||||
_colors.TryGetValue((paletteId, index), out color);
|
||||
}
|
||||
|
||||
// ── TryGetPalSetAverageColor ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryGetPalSetAverageColor_AveragesEveryPaletteInTheSet()
|
||||
{
|
||||
var palSets = new FakePalSetSource();
|
||||
palSets.Add(0x0F00_0001u, 0x0400_0001u, 0x0400_0002u);
|
||||
var colors = new FakeColorSource();
|
||||
colors.Add(0x0400_0001u, 0xd0, r: 100, g: 0, b: 0);
|
||||
colors.Add(0x0400_0002u, 0xd0, r: 200, g: 0, b: 0);
|
||||
|
||||
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
|
||||
palSets, colors, 0x0F00_0001u, ChargenSwatchColorResolver.HairSampleIndex, out ChargenSwatchRgb color);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(new ChargenSwatchRgb(150, 0, 0), color);
|
||||
}
|
||||
|
||||
/// <summary>Retail's own loop (@0x0047e759-0x0047e80f) accumulates
|
||||
/// unconditionally and ALWAYS divides by the full num_pals — a per-
|
||||
/// entry miss contributes (0,0,0) rather than shrinking the divisor
|
||||
/// (GetColorFromPal's own miss path @0x005639b5 returns 0, it does not
|
||||
/// skip the accumulation).</summary>
|
||||
[Fact]
|
||||
public void TryGetPalSetAverageColor_MissingIndividualPaletteContributesBlackNotSkip()
|
||||
{
|
||||
var palSets = new FakePalSetSource();
|
||||
palSets.Add(0x0F00_0002u, 0x0400_0010u, 0x0400_0011u); // second id never registered in colors.
|
||||
var colors = new FakeColorSource();
|
||||
colors.Add(0x0400_0010u, 0xd0, r: 200, g: 100, b: 50);
|
||||
|
||||
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
|
||||
palSets, colors, 0x0F00_0002u, ChargenSwatchColorResolver.HairSampleIndex, out ChargenSwatchRgb color);
|
||||
|
||||
Assert.True(ok);
|
||||
// (200+0)/2=100, (100+0)/2=50, (50+0)/2=25 — divided by the FULL
|
||||
// count of 2, not 1.
|
||||
Assert.Equal(new ChargenSwatchRgb(100, 50, 25), color);
|
||||
}
|
||||
|
||||
/// <summary>Retail's explicit zero-init before the num_pals>0 guard
|
||||
/// (@0x0047e790) — an empty-but-resolved PalSet is BLACK, not a miss.</summary>
|
||||
[Fact]
|
||||
public void TryGetPalSetAverageColor_EmptyPalSet_ReturnsTrueBlack()
|
||||
{
|
||||
var palSets = new FakePalSetSource();
|
||||
palSets.Add(0x0F00_0003u); // zero palette ids.
|
||||
var colors = new FakeColorSource();
|
||||
|
||||
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
|
||||
palSets, colors, 0x0F00_0003u, ChargenSwatchColorResolver.HairSampleIndex, out ChargenSwatchRgb color);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(new ChargenSwatchRgb(0, 0, 0), color);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetPalSetAverageColor_UnresolvedPalSetId_ReturnsFalse()
|
||||
{
|
||||
var palSets = new FakePalSetSource();
|
||||
var colors = new FakeColorSource();
|
||||
|
||||
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
|
||||
palSets, colors, 0x0F00_DEADu, ChargenSwatchColorResolver.HairSampleIndex, out _);
|
||||
|
||||
Assert.False(ok);
|
||||
}
|
||||
|
||||
// ── TryGetDirectColor (Eyes) ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryGetDirectColor_ReadsThePaletteDirectly_NoPalSetIndirection()
|
||||
{
|
||||
var colors = new FakeColorSource();
|
||||
colors.Add(0x0400_0099u, ChargenSwatchColorResolver.EyeSampleIndex, r: 15, g: 63, b: 93);
|
||||
|
||||
bool ok = ChargenSwatchColorResolver.TryGetDirectColor(
|
||||
colors, 0x0400_0099u, ChargenSwatchColorResolver.EyeSampleIndex, out ChargenSwatchRgb color);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(new ChargenSwatchRgb(15, 63, 93), color);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetDirectColor_UnresolvedPaletteId_ReturnsFalse()
|
||||
{
|
||||
var colors = new FakeColorSource();
|
||||
|
||||
bool ok = ChargenSwatchColorResolver.TryGetDirectColor(
|
||||
colors, 0x0400_DEADu, ChargenSwatchColorResolver.EyeSampleIndex, out _);
|
||||
|
||||
Assert.False(ok);
|
||||
}
|
||||
|
||||
// ── TryGetClothingSwatchPalSetId ─────────────────────────────────────
|
||||
|
||||
private static ChargenClothingTable MakeClothingTable(uint templateId, uint firstChoicePalSetId, uint secondChoicePalSetId)
|
||||
{
|
||||
var template = new ChargenClothingPaletteTemplate(
|
||||
[
|
||||
new ChargenClothingSubPaletteChoice(firstChoicePalSetId, [new ChargenClothingSubPaletteRange(0, 8)]),
|
||||
new ChargenClothingSubPaletteChoice(secondChoicePalSetId, [new ChargenClothingSubPaletteRange(8, 8)]),
|
||||
]);
|
||||
return new ChargenClothingTable(
|
||||
new Dictionary<uint, ChargenClothingBaseEffect>(),
|
||||
new Dictionary<uint, ChargenClothingPaletteTemplate> { [templateId] = template });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetClothingSwatchPalSetId_ReturnsTheFIRSTChoicesPalSetId()
|
||||
{
|
||||
var clothingTables = new FakeClothingTableSource();
|
||||
clothingTables.Add(0x1900_0001u, MakeClothingTable(9u, 0x0F00_0010u, 0x0F00_0011u));
|
||||
|
||||
bool ok = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId(
|
||||
clothingTables, 0x1900_0001u, paletteTemplateId: 9u, out uint palSetId);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(0x0F00_0010u, palSetId); // choices[0], not choices[1].
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetClothingSwatchPalSetId_UnresolvedClothingTable_ReturnsFalse()
|
||||
{
|
||||
var clothingTables = new FakeClothingTableSource();
|
||||
|
||||
bool ok = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId(
|
||||
clothingTables, 0x1900_DEADu, paletteTemplateId: 9u, out _);
|
||||
|
||||
Assert.False(ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetClothingSwatchPalSetId_TemplateIdNotInThisGarmentsTable_ReturnsFalse()
|
||||
{
|
||||
var clothingTables = new FakeClothingTableSource();
|
||||
clothingTables.Add(0x1900_0002u, MakeClothingTable(9u, 0x0F00_0010u, 0x0F00_0011u));
|
||||
|
||||
bool ok = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId(
|
||||
clothingTables, 0x1900_0002u, paletteTemplateId: 999u, out _);
|
||||
|
||||
Assert.False(ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetClothingSwatchPalSetId_TemplateWithNoChoices_ReturnsFalse()
|
||||
{
|
||||
var clothingTables = new FakeClothingTableSource();
|
||||
var emptyTemplate = new ChargenClothingPaletteTemplate([]);
|
||||
clothingTables.Add(
|
||||
0x1900_0003u,
|
||||
new ChargenClothingTable(
|
||||
new Dictionary<uint, ChargenClothingBaseEffect>(),
|
||||
new Dictionary<uint, ChargenClothingPaletteTemplate> { [5u] = emptyTemplate }));
|
||||
|
||||
bool ok = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId(
|
||||
clothingTables, 0x1900_0003u, paletteTemplateId: 5u, out _);
|
||||
|
||||
Assert.False(ok);
|
||||
}
|
||||
|
||||
// ── Fixed sample-index constants (decomp anchors, gmCGAppearancePage::SetSelection) ──
|
||||
|
||||
[Fact]
|
||||
public void SampleIndexConstants_MatchTheDecompiledLiterals()
|
||||
{
|
||||
Assert.Equal(0xd0, ChargenSwatchColorResolver.HairSampleIndex);
|
||||
Assert.Equal(0xb0, ChargenSwatchColorResolver.SkinFamilySampleIndex);
|
||||
Assert.Equal(0x103, ChargenSwatchColorResolver.EyeSampleIndex);
|
||||
Assert.Equal(0x520, ChargenSwatchColorResolver.ClothingSampleIndex);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue