Merge campaign-cc-batch-g: Batch G — real color wheel mechanism (inert until closeout wiring)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> # Conflicts: # docs/research/2026-08-16-campaign-cc-gate-round1-findings.md
This commit is contained in:
commit
1f6365d3e5
10 changed files with 1558 additions and 30 deletions
|
|
@ -0,0 +1,447 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.CharGen;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC gate round 1 Batch G (R2-5, register AP-216/AP-217): fixture
|
||||
/// tests for <see cref="CharacterCreationAppearancePage"/>'s real swatch/
|
||||
/// gradient-disc color computation — a hand-built layout + fake DAT sources
|
||||
/// (no installed dat), so recompute triggers can be asserted deterministically.
|
||||
/// Real installed-DAT color values are pinned separately in
|
||||
/// <c>AcDream.Content.Tests.CharGen.ChargenAppearanceCatalogColorTests</c>.
|
||||
/// </summary>
|
||||
public sealed class CharacterCreationAppearancePageSwatchColorTests
|
||||
{
|
||||
private const uint HeritageId = 1u;
|
||||
private const int GenderKey = 1;
|
||||
|
||||
private const uint HairPalSetIdA = 0x0F00_0001u;
|
||||
private const uint HairPalSetIdB = 0x0F00_0002u;
|
||||
private const uint EyePaletteIdA = 0x0400_0010u;
|
||||
private const uint EyePaletteIdB = 0x0400_0011u;
|
||||
private const uint HeadgearClothingTableId = 0x1900_0001u;
|
||||
private const uint HeadgearDyePalSetId = 0x0F00_0010u;
|
||||
private const uint SkinPalSetId = 0x0F00_0099u;
|
||||
|
||||
private static readonly ChargenSwatchRgb HairColorA = new(10, 20, 30);
|
||||
private static readonly ChargenSwatchRgb HairColorB = new(40, 50, 60);
|
||||
private static readonly ChargenSwatchRgb EyeColorA = new(70, 80, 90);
|
||||
private static readonly ChargenSwatchRgb EyeColorB = new(100, 110, 120);
|
||||
private static readonly ChargenSwatchRgb HeadgearColorA = new(130, 140, 150);
|
||||
private static readonly ChargenSwatchRgb SkinColor = new(160, 170, 180);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private sealed class FakeColorSource : IChargenPaletteColorSource
|
||||
{
|
||||
private readonly Dictionary<uint, ChargenSwatchRgb> _colors = new();
|
||||
public void Add(uint paletteId, ChargenSwatchRgb color) => _colors[paletteId] = color;
|
||||
public bool TryGetColor(uint paletteId, int index, out ChargenSwatchRgb color) =>
|
||||
_colors.TryGetValue(paletteId, out color);
|
||||
}
|
||||
|
||||
private static ChargenGenderOptions MakeGender() => new(
|
||||
GenderKey: GenderKey,
|
||||
Name: "Male",
|
||||
Scale: 100u,
|
||||
SetupId: 0x0200_0001u,
|
||||
SoundTableId: 0u,
|
||||
IconId: 0u,
|
||||
BasePaletteId: 0u,
|
||||
SkinPalSetId: SkinPalSetId,
|
||||
PhysicsTableId: 0u,
|
||||
MotionTableId: 0u,
|
||||
CombatTableId: 0u,
|
||||
BaseObjDesc: ChargenObjDesc.Empty,
|
||||
HairColors: [HairPalSetIdA, HairPalSetIdB],
|
||||
HairStyles: [new ChargenHairStyle(0u, false, 0u, ChargenObjDesc.Empty)],
|
||||
EyeColors: [EyePaletteIdA, EyePaletteIdB],
|
||||
EyeStrips: [new ChargenEyeStrip(0u, 0u, ChargenObjDesc.Empty, ChargenObjDesc.Empty)],
|
||||
NoseStrips: [new ChargenFaceStrip(0u, ChargenObjDesc.Empty)],
|
||||
MouthStrips: [new ChargenFaceStrip(0u, ChargenObjDesc.Empty)],
|
||||
Headgears: [new ChargenGearOption("Cap", HeadgearClothingTableId, 0u)],
|
||||
Shirts: [],
|
||||
Pants: [],
|
||||
Footwear: [],
|
||||
ClothingColors: [9u]);
|
||||
|
||||
private static ChargenOptions MakeOptions(uint heritageId, ChargenGenderOptions gender)
|
||||
{
|
||||
var heritage = new ChargenHeritageOptions(
|
||||
heritageId, "Test", 0u, 0x0200_0001u, 0u,
|
||||
180u, 100u, [0], [],
|
||||
new Dictionary<uint, ChargenSkillCost>(), [],
|
||||
new Dictionary<int, ChargenGenderOptions> { [GenderKey] = gender });
|
||||
return new ChargenOptions(
|
||||
[],
|
||||
new Dictionary<uint, ChargenHeritageOptions> { [heritageId] = heritage },
|
||||
new Dictionary<uint, ChargenSkillCost>());
|
||||
}
|
||||
|
||||
private static (FakePalSetSource pal, FakeClothingTableSource clothing, FakeColorSource colors) MakeSources()
|
||||
{
|
||||
var pal = new FakePalSetSource();
|
||||
pal.Add(HairPalSetIdA, 0x0400_0001u);
|
||||
pal.Add(HairPalSetIdB, 0x0400_0002u);
|
||||
pal.Add(HeadgearDyePalSetId, 0x0400_0003u);
|
||||
pal.Add(SkinPalSetId, 0x0400_0004u);
|
||||
|
||||
var colors = new FakeColorSource();
|
||||
colors.Add(0x0400_0001u, HairColorA);
|
||||
colors.Add(0x0400_0002u, HairColorB);
|
||||
colors.Add(EyePaletteIdA, EyeColorA);
|
||||
colors.Add(EyePaletteIdB, EyeColorB);
|
||||
colors.Add(0x0400_0003u, HeadgearColorA);
|
||||
colors.Add(0x0400_0004u, SkinColor);
|
||||
|
||||
var choice = new ChargenClothingSubPaletteChoice(HeadgearDyePalSetId, [new ChargenClothingSubPaletteRange(0, 8)]);
|
||||
var templates = new Dictionary<uint, ChargenClothingPaletteTemplate>
|
||||
{
|
||||
[9u] = new ChargenClothingPaletteTemplate([choice]),
|
||||
};
|
||||
var clothing = new FakeClothingTableSource();
|
||||
clothing.Add(HeadgearClothingTableId, new ChargenClothingTable(
|
||||
new Dictionary<uint, ChargenClothingBaseEffect>(), templates));
|
||||
|
||||
return (pal, clothing, colors);
|
||||
}
|
||||
|
||||
private sealed class FakeView(ChargenOptions options, uint heritageId) : IRuntimeCharacterCreationView
|
||||
{
|
||||
public RuntimeCharacterCreationSnapshot Snapshot { get; set; } = new(
|
||||
new RuntimeGenerationToken(1u),
|
||||
IsActive: true,
|
||||
Revision: 1,
|
||||
HeritageId: heritageId,
|
||||
GenderKey: (uint)GenderKey,
|
||||
Appearance: RuntimeCharacterCreationAppearance.Default,
|
||||
Template: RuntimeCharacterCreationSnapshot.TemplateUnset,
|
||||
Attributes: default,
|
||||
AttributeLockMask: 0u,
|
||||
TotalAttributeCredits: 0u,
|
||||
RemainingAttributeCredits: 0,
|
||||
TotalSkillCredits: 0u,
|
||||
RemainingSkillCredits: 0,
|
||||
Name: string.Empty,
|
||||
StartArea: -1,
|
||||
Slot: 0u,
|
||||
VerificationPending: false,
|
||||
LastLocalRefusal: default,
|
||||
LastRejection: null,
|
||||
LastCreated: null);
|
||||
|
||||
public ChargenOptions Options { get; } = options;
|
||||
public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) => ChargenSkillAdvancementClass.Inactive;
|
||||
public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) => NullSubscription.Instance;
|
||||
|
||||
private sealed class NullSubscription : IDisposable
|
||||
{
|
||||
public static readonly NullSubscription Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Minimal appearance-page fixture — just what
|
||||
/// <see cref="CharacterCreationAppearancePage"/>'s constructor resolves
|
||||
/// (spins for Hair/Eyes/Headgear used to drive <c>_currentPart</c> via
|
||||
/// their own select-zone click, matching every other test in this
|
||||
/// campaign's <c>CharacterCreationUiControllerTests</c> harness; the
|
||||
/// nine swatches + the gradient disc, this test's own subject).</summary>
|
||||
private static UiElement BuildPageRoot()
|
||||
{
|
||||
var page = new ElementInfo
|
||||
{
|
||||
Id = 0x100003D4u,
|
||||
Type = 3u,
|
||||
Width = 800f,
|
||||
Height = 500f,
|
||||
};
|
||||
|
||||
page.Children.Add(SpinInfo(CharacterCreationAppearancePage.HairSpinId));
|
||||
page.Children.Add(SpinInfo(CharacterCreationAppearancePage.EyesSpinId));
|
||||
page.Children.Add(SpinInfo(CharacterCreationAppearancePage.SkinSpinId));
|
||||
page.Children.Add(SpinInfo(CharacterCreationAppearancePage.HeadgearSpinId));
|
||||
|
||||
foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds)
|
||||
page.Children.Add(ButtonInfo(swatchId));
|
||||
foreach (uint overlayId in CharacterCreationAppearancePage.SwatchOverlayIds)
|
||||
page.Children.Add(ContainerInfo(overlayId));
|
||||
|
||||
page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.GradCircleId));
|
||||
|
||||
return LayoutImporter.Build(page, _ => (0u, 0, 0), null).Root;
|
||||
}
|
||||
|
||||
private static ElementInfo SpinInfo(uint id)
|
||||
{
|
||||
var spin = new ElementInfo { Id = id, Type = 1u, Width = 200f, Height = 24f };
|
||||
spin.Children.Add(new ElementInfo { Id = 0x1000030Au, Type = 1u, X = 80f, Width = 47f, Height = 24f });
|
||||
spin.Children.Add(new ElementInfo { Id = 0x1000030Bu, Type = 1u, X = 127f, Width = 47f, Height = 24f });
|
||||
return spin;
|
||||
}
|
||||
|
||||
private static ElementInfo ButtonInfo(uint id) => new() { Id = id, Type = 1u, Width = 20f, Height = 20f };
|
||||
private static ElementInfo ContainerInfo(uint id) => new() { Id = id, Type = 3u, Width = 64f, Height = 64f };
|
||||
|
||||
private static (CharacterCreationAppearancePage Page, FakeView View, UiElement Root) BuildPage(
|
||||
FakePalSetSource pal, FakeClothingTableSource clothing, FakeColorSource colors)
|
||||
{
|
||||
var view = new FakeView(MakeOptions(HeritageId, MakeGender()), HeritageId);
|
||||
var bindings = new CharacterCreationRuntimeBindings(
|
||||
() => view,
|
||||
_ => default,
|
||||
_ => default,
|
||||
_ => default,
|
||||
(_, _) => default,
|
||||
(_, _) => default,
|
||||
_ => default,
|
||||
_ => default,
|
||||
_ => default,
|
||||
_ => default,
|
||||
_ => default,
|
||||
() => { },
|
||||
SetAppearanceIndex: (_, _) => default);
|
||||
|
||||
UiElement pageRoot = BuildPageRoot();
|
||||
var page = new CharacterCreationAppearancePage(pageRoot, bindings)
|
||||
{
|
||||
PalSetSource = pal,
|
||||
ClothingTableSource = clothing,
|
||||
PaletteColorSource = colors,
|
||||
};
|
||||
return (page, view, pageRoot);
|
||||
}
|
||||
|
||||
private static UiButton Swatch(UiElement pageRoot, int index) =>
|
||||
Assert.IsType<UiButton>(UiElement.FindDescendant(
|
||||
pageRoot, CharacterCreationAppearancePage.SwatchIds[index]));
|
||||
|
||||
private static ChargenSwatchColorTile SwatchTile(UiElement pageRoot, int index) =>
|
||||
Assert.IsType<ChargenSwatchColorTile>(Assert.Single(Swatch(pageRoot, index).Children));
|
||||
|
||||
private static ChargenSwatchColorTile GradTile(UiElement pageRoot) =>
|
||||
Assert.IsType<ChargenSwatchColorTile>(Assert.Single(
|
||||
UiElement.FindDescendant(pageRoot, CharacterCreationAppearancePage.GradCircleId)!.Children));
|
||||
|
||||
private static Vector4 ToVector4(ChargenSwatchRgb rgb) => new(rgb.R / 255f, rgb.G / 255f, rgb.B / 255f, 1f);
|
||||
|
||||
[Fact]
|
||||
public void HairPart_PaintsBothSwatchesWithTheirOwnDistinctColors()
|
||||
{
|
||||
var (pal, clothing, colors) = MakeSources();
|
||||
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
|
||||
|
||||
page.Refresh(view, view.Snapshot);
|
||||
|
||||
Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color);
|
||||
Assert.True(SwatchTile(root, 0).Visible);
|
||||
Assert.Equal(ToVector4(HairColorB), SwatchTile(root, 1).Color);
|
||||
Assert.True(SwatchTile(root, 1).Visible);
|
||||
// Only two hair colors exist — swatch 2 must be blank.
|
||||
Assert.Null(SwatchTile(root, 2).Color);
|
||||
Assert.False(SwatchTile(root, 2).Visible);
|
||||
}
|
||||
|
||||
/// <summary>Part change (Hair -> Eyes via the spin's own select-zone
|
||||
/// click) must recompute the whole swatch set from the NEW part's own
|
||||
/// color source.</summary>
|
||||
[Fact]
|
||||
public void PartChange_RecomputesTheSwatchSet()
|
||||
{
|
||||
var (pal, clothing, colors) = MakeSources();
|
||||
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
|
||||
page.Refresh(view, view.Snapshot);
|
||||
Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color);
|
||||
|
||||
UiButton eyesSpin = Assert.IsType<UiButton>(
|
||||
UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId));
|
||||
eyesSpin.OnClickAt!(180, 10); // select zone — switches _currentPart, no index change.
|
||||
|
||||
Assert.Equal(ToVector4(EyeColorA), SwatchTile(root, 0).Color);
|
||||
Assert.Equal(ToVector4(EyeColorB), SwatchTile(root, 1).Color);
|
||||
}
|
||||
|
||||
/// <summary>Color change (clicking a different swatch) must retint the
|
||||
/// gradient disc to the NEWLY selected swatch's own color.</summary>
|
||||
[Fact]
|
||||
public void ColorChange_RetintsTheGradientDiscToTheNewlySelectedSwatch()
|
||||
{
|
||||
var (pal, clothing, colors) = MakeSources();
|
||||
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
|
||||
page.Refresh(view, view.Snapshot);
|
||||
// No color selected yet (Unset) — no tint.
|
||||
Assert.Null(GradTile(root).Color);
|
||||
Assert.False(GradTile(root).Visible);
|
||||
|
||||
Swatch(root, 0).OnClick!();
|
||||
view.Snapshot = view.Snapshot with
|
||||
{
|
||||
Appearance = view.Snapshot.Appearance with { HairColor = 0u },
|
||||
};
|
||||
page.Refresh(view, view.Snapshot);
|
||||
Assert.Equal(ToVector4(HairColorA), GradTile(root).Color);
|
||||
Assert.True(GradTile(root).Visible);
|
||||
|
||||
Swatch(root, 1).OnClick!();
|
||||
view.Snapshot = view.Snapshot with
|
||||
{
|
||||
Appearance = view.Snapshot.Appearance with { HairColor = 1u },
|
||||
};
|
||||
page.Refresh(view, view.Snapshot);
|
||||
Assert.Equal(ToVector4(HairColorB), GradTile(root).Color);
|
||||
}
|
||||
|
||||
/// <summary>AP-217: Eyes always blanks the gradient disc's tile — no
|
||||
/// tint is ever shown for Eyes, regardless of the selected eye color.</summary>
|
||||
[Fact]
|
||||
public void EyesPart_GradientDiscTileStaysBlank()
|
||||
{
|
||||
var (pal, clothing, colors) = MakeSources();
|
||||
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
|
||||
view.Snapshot = view.Snapshot with
|
||||
{
|
||||
Appearance = view.Snapshot.Appearance with { EyeColor = 0u },
|
||||
};
|
||||
page.Refresh(view, view.Snapshot);
|
||||
|
||||
UiButton eyesSpin = Assert.IsType<UiButton>(
|
||||
UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId));
|
||||
eyesSpin.OnClickAt!(180, 10);
|
||||
|
||||
Assert.False(GradTile(root).Visible);
|
||||
Assert.Null(GradTile(root).Color);
|
||||
// The swatches themselves still show real eye colors — only the
|
||||
// disc blanks.
|
||||
Assert.Equal(ToVector4(EyeColorA), SwatchTile(root, 0).Color);
|
||||
}
|
||||
|
||||
/// <summary>Nose/Mouth/Skin (colorSlot == null): retail still shows
|
||||
/// exactly ONE representative swatch, sourced from the shared skin
|
||||
/// PalSet, and the gradient disc tints from that SAME swatch — see
|
||||
/// <c>ComputeSwatchColors</c>'s own doc.</summary>
|
||||
[Fact]
|
||||
public void SkinPart_ShowsExactlyOneRepresentativeSwatchAndTintsTheDiscFromIt()
|
||||
{
|
||||
var (pal, clothing, colors) = MakeSources();
|
||||
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
|
||||
page.Refresh(view, view.Snapshot);
|
||||
|
||||
UiButton skinSpin = Assert.IsType<UiButton>(
|
||||
UiElement.FindDescendant(root, CharacterCreationAppearancePage.SkinSpinId));
|
||||
skinSpin.OnClickAt!(10, 10); // skin has no arrow zones — every click selects it.
|
||||
|
||||
Assert.Equal(ToVector4(SkinColor), SwatchTile(root, 0).Color);
|
||||
Assert.True(SwatchTile(root, 0).Visible);
|
||||
Assert.Null(SwatchTile(root, 1).Color);
|
||||
Assert.False(SwatchTile(root, 1).Visible);
|
||||
Assert.Equal(ToVector4(SkinColor), GradTile(root).Color);
|
||||
Assert.True(GradTile(root).Visible);
|
||||
}
|
||||
|
||||
/// <summary>Headgear's swatch resolves through the CURRENTLY EQUIPPED
|
||||
/// garment's own ClothingTable — an Unset headgear style (no garment)
|
||||
/// shows no swatches at all.</summary>
|
||||
[Fact]
|
||||
public void HeadgearPart_ResolvesThroughTheEquippedGarment_UnsetShowsNoSwatches()
|
||||
{
|
||||
var (pal, clothing, colors) = MakeSources();
|
||||
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
|
||||
view.Snapshot = view.Snapshot with
|
||||
{
|
||||
Appearance = view.Snapshot.Appearance with { HeadgearStyle = 0u },
|
||||
};
|
||||
page.Refresh(view, view.Snapshot);
|
||||
|
||||
UiButton headgearSpin = Assert.IsType<UiButton>(
|
||||
UiElement.FindDescendant(root, CharacterCreationAppearancePage.HeadgearSpinId));
|
||||
headgearSpin.OnClickAt!(180, 10);
|
||||
|
||||
Assert.Equal(ToVector4(HeadgearColorA), SwatchTile(root, 0).Color);
|
||||
|
||||
// Now un-equip (Unset) and refresh again — no garment, no colors.
|
||||
view.Snapshot = view.Snapshot with
|
||||
{
|
||||
Appearance = view.Snapshot.Appearance with { HeadgearStyle = RuntimeCharacterCreationAppearance.Unset },
|
||||
};
|
||||
page.Refresh(view, view.Snapshot);
|
||||
Assert.Null(SwatchTile(root, 0).Color);
|
||||
Assert.False(SwatchTile(root, 0).Visible);
|
||||
}
|
||||
|
||||
/// <summary>Heritage/gender change must re-source the color computation
|
||||
/// from the NEW gender's own option lists — a second, differently-
|
||||
/// colored fixture proves the recompute isn't cached from the first.</summary>
|
||||
[Fact]
|
||||
public void HeritageChange_RecomputesFromTheNewGendersOwnColorLists()
|
||||
{
|
||||
var (pal, clothing, colors) = MakeSources();
|
||||
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
|
||||
page.Refresh(view, view.Snapshot);
|
||||
Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color);
|
||||
|
||||
// A second heritage with a DIFFERENT hair-color list.
|
||||
const uint otherHairPalSetId = 0x0F00_00AAu;
|
||||
var otherHairColor = new ChargenSwatchRgb(200, 201, 202);
|
||||
pal.Add(otherHairPalSetId, 0x0400_00AAu);
|
||||
colors.Add(0x0400_00AAu, otherHairColor);
|
||||
const uint otherHeritageId = 2u;
|
||||
ChargenGenderOptions otherGender = MakeGender() with { HairColors = [otherHairPalSetId] };
|
||||
ChargenOptions otherOptions = MakeOptions(otherHeritageId, otherGender);
|
||||
var otherView = new FakeView(otherOptions, otherHeritageId);
|
||||
|
||||
page.Refresh(otherView, otherView.Snapshot);
|
||||
|
||||
Assert.Equal(ToVector4(otherHairColor), SwatchTile(root, 0).Color);
|
||||
}
|
||||
|
||||
/// <summary>Before <see cref="CharacterCreationAppearancePage.PalSetSource"/>/
|
||||
/// <see cref="CharacterCreationAppearancePage.ClothingTableSource"/>/
|
||||
/// <see cref="CharacterCreationAppearancePage.PaletteColorSource"/> are
|
||||
/// wired (composition-root STOPPED item), every tile MUST stay
|
||||
/// invisible — the mechanism is fully inert, not a half-broken draw.</summary>
|
||||
[Fact]
|
||||
public void UnwiredSources_LeaveEveryTileInvisible()
|
||||
{
|
||||
var view = new FakeView(MakeOptions(HeritageId, MakeGender()), HeritageId);
|
||||
var bindings = new CharacterCreationRuntimeBindings(
|
||||
() => view,
|
||||
_ => default,
|
||||
_ => default,
|
||||
_ => default,
|
||||
(_, _) => default,
|
||||
(_, _) => default,
|
||||
_ => default,
|
||||
_ => default,
|
||||
_ => default,
|
||||
_ => default,
|
||||
_ => default,
|
||||
() => { },
|
||||
SetAppearanceIndex: (_, _) => default);
|
||||
UiElement root = BuildPageRoot();
|
||||
var page = new CharacterCreationAppearancePage(root, bindings); // sources left null.
|
||||
|
||||
page.Refresh(view, view.Snapshot);
|
||||
|
||||
Assert.False(GradTile(root).Visible);
|
||||
for (int i = 0; i < CharacterCreationAppearancePage.SwatchIds.Length; i++)
|
||||
Assert.False(SwatchTile(root, i).Visible);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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