diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs
index 6029f3f5..60246361 100644
--- a/src/AcDream.App/Composition/LivePresentationComposition.cs
+++ b/src/AcDream.App/Composition/LivePresentationComposition.cs
@@ -1080,6 +1080,14 @@ internal sealed class LivePresentationCompositionPhase
interaction.RetainedUi.Runtime.ChargenPalSetSource = chargenCatalog;
interaction.RetainedUi.Runtime.ChargenClothingTableSource = chargenCatalog;
interaction.RetainedUi.Runtime.ChargenPaletteColorSource = chargenCatalog;
+ // R3-5/R3-6 (Campaign CC gate round 1 re-test 2): the fourth
+ // seam — needs a TextureCache (foundation.TextureCache, already
+ // acquired above for the preview renderer), so it is its own
+ // composer rather than folded into chargenCatalog (a pure
+ // Content-layer DAT reader with no GL/backend dependency).
+ var chargenSwatchTextures = new AcDream.App.UI.Layout.ChargenColorSpotComposer(
+ content.Dats, foundation.TextureCache);
+ interaction.RetainedUi.Runtime.ChargenSwatchTextureSource = chargenSwatchTextures;
bindings.AdoptRelease(
"chargen preview control",
() =>
diff --git a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs
index cadde2ae..e98e81e2 100644
--- a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs
+++ b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs
@@ -236,6 +236,28 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
internal IChargenClothingTableSource? ClothingTableSource { get; set; }
internal IChargenPaletteColorSource? PaletteColorSource { get; set; }
+ ///
+ /// R3-5/R3-6 (Campaign CC gate round 1 re-test 2) late-bound seam, same
+ /// pattern as the three above: null (the default) leaves every swatch/
+ /// the gradient disc falling back to their ordinary FaceFileOverride/
+ /// ActiveFile draw — live-DAT-confirmed, each swatch and the grad
+ /// circle DOES author its own DirectState sprite (the RAW,
+ /// un-recolored spot/gradDisk template respectively — the exact same
+ /// RenderSurface DIDs ChargenColorSpotComposer resolves by
+ /// enum), so the unwired fallback shows that authored art untinted
+ /// (Tint stays Vector4.One when no color data exists), not literally
+ /// nothing. Wired by the composition root to a
+ /// (same site as
+ /// et al) once a TextureCache exists.
+ /// See 's own doc for WHY a
+ /// fourth seam is needed beyond the three color-computation ones above:
+ /// those three answer "what RGB is this swatch", this one answers
+ /// "what actual bitmap should this element's face show" — a materially
+ /// different question once the answer can no
+ /// longer be a plain multiply-tint (see R3-5's finding).
+ ///
+ internal IChargenSwatchTextureSource? SwatchTextureSource { get; set; }
+
/// The authored viewport (0x100003bb) — the composition
/// root assigns its Renderer once the graphics backend exists,
/// mirroring the paperdoll's own late viewport.Renderer = ...
@@ -759,29 +781,42 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
if (_swatches[i] is not { } swatch)
continue;
bool visible = i < displayCount;
- swatch.Visible = visible;
- // Closeout Group 1: a genuine multiplicative sprite tint on the
- // swatch's own authored spot art (UiButton.Tint), replacing the
- // Batch G flat-fill overlay. Vector4.One (identity) reproduces
- // the swatch's bare authored art untouched — both "no color data
- // yet" (sources unwired) and "beyond this part's color count".
+ // R3-5 correction: retail's own pColor->SetVisible(1)
+ // (DoColorSpots' own loop) is UNCONDITIONAL for all 9 swatches
+ // — beyond displayCount shows the BLANK/blocked art (still
+ // visible), never hides the element. Batch C's own "beyond
+ // count -> hide" half is retired by this correction.
+ swatch.Visible = true;
+ // Closeout Group 1 (Tint) + R3-5 correction (ColorKeyFaceResolver):
+ // Tint keeps communicating "this swatch's color is X" for every
+ // existing reader/test; the resolver is the SEPARATE decision
+ // of which pre-baked bitmap actually draws (see
+ // UiButton.ColorKeyFaceResolver's own doc for why a multiply
+ // over the authored sprite is retail-wrong here).
ChargenSwatchRgb? rgb = visible ? swatchColors[i] : null;
swatch.Tint = rgb is { } c ? ToTintColor(c) : Vector4.One;
+ swatch.ColorKeyFaceResolver = BuildSwatchTextureResolver(visible, rgb);
}
- // AP-217 (Batch C PARTIAL -> Batch G, R2-5, FULL):
- // gmCGAppearancePage::DoGradDisk @0x0047da90 blits the blank "grad
- // plug" for Eyes (DoGradDisk(this, 1), called from SetSelection
+ // AP-217 (Batch C PARTIAL -> Batch G, R2-5, FULL) + R3-6 correction:
+ // gmCGAppearancePage::DoGradDisk @0x0047da90 blits the "grad plug"
+ // icon for Eyes (DoGradDisk(this, 1), called from SetSelection
// @0x0047e85d) and a gradient graphic TINTED with the CURRENTLY
// SELECTED swatch's own color otherwise (SetColor @0x0047dd50's
// tail, DoGradDisk(this, 0) after m_iCurColor is already updated —
// @0x0047de18). Nose/Mouth/Skin always tint from swatch index 0
// (SetSelection hard-codes eyeColor = 0 for those three cases,
- // matching displayCount's own reasoning above).
+ // matching displayCount's own reasoning above). R3-6: SetSelection
+ // @0x0047e260's own Eyes/non-Eyes branches (@0x0047e859-0047e878)
+ // call ONLY DoGradDisk + m_pShadeScroll->SetVisible — NEITHER
+ // branch ever calls m_pGradCircle->SetVisible; the disc element
+ // itself is never hidden for Eyes, only its CONTENT (source image)
+ // changes. The prior "_gradCircle.Visible = !isEyes" line was a
+ // misreading — corrected to always-visible.
if (_gradCircle is not null)
{
bool isEyes = _currentPart == Part.Eyes;
- _gradCircle.Visible = !isEyes;
+ _gradCircle.Visible = true;
// Closeout Group 1: same Tint mechanism as the swatches above —
// the gradient disc's own authored art is multiplied by the
// currently-selected swatch's color instead of an overlay child.
@@ -793,6 +828,16 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
ChargenSwatchRgb? gradColor =
gradIndex >= 0 && gradIndex < swatchColors.Length ? swatchColors[gradIndex] : null;
_gradCircle.Tint = gradColor is { } gc ? ToTintColor(gc) : Vector4.One;
+ // R3-6: the disc's own authored media is empty (live-DAT-
+ // confirmed) — supply the missing base bitmap. Non-Eyes shows
+ // gradDisk (multiplied by Tint above, matching retail's own
+ // Blit_Multiply); Eyes shows the static plug icon UNTINTED
+ // (Tint is already Vector4.One for Eyes via gradIndex==-1
+ // above, matching retail's plain Blit_Normal).
+ uint gradTexture = SwatchTextureSource is { } textures
+ ? (isEyes ? textures.GradPlugTexture : textures.GradDiskTexture)
+ : 0u;
+ _gradCircle.RuntimeImageTexture = gradTexture;
}
ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart);
@@ -812,6 +857,40 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
}
}
+ ///
+ /// R3-5: which pre-baked bitmap (if any) a swatch's own
+ /// should resolve to on
+ /// THIS refresh. A fresh closure per call (not a cached delegate) so a
+ /// later assignment (the composition
+ /// root wires it once the graphics backend exists, strictly after this
+ /// page's own construction — same ordering as
+ /// ) is picked up the next time the
+ /// resolver actually RUNS (at draw time), not frozen at the OLD (null)
+ /// value from an earlier refresh.
+ ///
+ /// - Beyond displayCount (
+ /// false): always resolve to the BLOCKED/blank art — retail shows this
+ /// for every swatch its current color count doesn't reach.
+ /// - In range with a real color: resolve to that
+ /// color's own baked spot.
+ /// - In range but no color data yet (palette seams
+ /// unwired): null — falls back to the ordinary FaceFileOverride/
+ /// ActiveFile draw, which shows the swatch's own AUTHORED DirectState
+ /// sprite (the raw, un-recolored spot template — live-DAT-confirmed
+ /// present, see 's own doc) untinted,
+ /// matching this page's pre-R3-5 "fully inert until wired"
+ /// disposition for the OTHER three palette seams.
+ ///
+ ///
+ private Func? BuildSwatchTextureResolver(bool visible, ChargenSwatchRgb? rgb)
+ {
+ if (!visible)
+ return () => SwatchTextureSource?.BlankSpotTexture ?? 0u;
+ if (rgb is { } c)
+ return () => SwatchTextureSource?.GetActiveSpotTexture(c) ?? 0u;
+ return null;
+ }
+
// ── Real swatch/gradient colors (R2-5) ──────────────────────────────
private static readonly ChargenSwatchRgb?[] EmptySwatchColors = new ChargenSwatchRgb?[SwatchIds.Length];
diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs
index b728e6ee..7cbe7810 100644
--- a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs
+++ b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs
@@ -386,6 +386,16 @@ internal sealed class CharacterCreationUiController : IDisposable
set => _appearancePage.PaletteColorSource = value;
}
+ /// R3-5/R3-6 (Campaign CC gate round 1 re-test 2): the fourth
+ /// late-bound seam, same pattern as the three above — see
+ /// 's
+ /// own doc comment.
+ internal IChargenSwatchTextureSource? AppearanceSwatchTextureSource
+ {
+ get => _appearancePage.SwatchTextureSource;
+ set => _appearancePage.SwatchTextureSource = value;
+ }
+
/// Gates the Appearance preview's per-frame work on whether
/// that specific page — AND the whole chargen screen — is the one
/// currently showing. Close() only ever hides ,
diff --git a/src/AcDream.App/UI/Layout/ChargenColorSpotComposer.cs b/src/AcDream.App/UI/Layout/ChargenColorSpotComposer.cs
new file mode 100644
index 00000000..08aef07c
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/ChargenColorSpotComposer.cs
@@ -0,0 +1,206 @@
+using System.Collections.Generic;
+using AcDream.App.Rendering;
+using AcDream.Content;
+using AcDream.Core.CharGen;
+using AcDream.Core.Textures;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// R3-5/R3-6 (Campaign CC gate round 1 re-test 2) seam: the pre-baked
+/// textures 's color-wheel
+/// swatches and gradient disc draw instead of a plain multiply-Tint
+/// over the authored sprite. See 's
+/// own doc for why a plain multiply is wrong here (it cannot recolor a
+/// BLACK placeholder region at all, and it corrupts the ring border's own
+/// colors).
+///
+internal interface IChargenSwatchTextureSource
+{
+ ///
+ /// Retail's "blank"/blocked swatch art (enum 0x1000000f,
+ /// category 7 — gmCGAppearancePage::DoColorSpots @0x0047d850's
+ /// i >= count branch) — shown UNTINTED for a swatch beyond the
+ /// current part's real color count (retail's own
+ /// pColor->SetVisible(1) is unconditional for all 9 swatches;
+ /// only the CONTENT differs). 0 if unresolved.
+ ///
+ uint BlankSpotTexture { get; }
+
+ ///
+ /// Retail's gradient-disc art (enum 0x1000000e, category 7) —
+ /// shown MULTIPLY-tinted by the currently selected swatch's own color,
+ /// matching retail's own SurfaceWindow::BlitAndColor(...,
+ /// Blit_Multiply, color) (DoGradDisk @0x0047da90's non-Eyes
+ /// branch) — a genuine multiply, unlike the swatch spots. 0 if
+ /// unresolved.
+ ///
+ uint GradDiskTexture { get; }
+
+ ///
+ /// Retail's Eyes "grad plug" icon art (enum 0x10000010, category
+ /// 7) — shown UNTINTED (DoGradDisk's Eyes branch is a plain
+ /// Blit_Normal, no color argument at all). 0 if unresolved.
+ ///
+ uint GradPlugTexture { get; }
+
+ ///
+ /// Bakes (or returns a cached) recolored copy of the ACTIVE swatch spot
+ /// template (enum 0x1000000d, category 7) with every EXACT-black
+ /// pixel replaced by 's own bytes (alpha and every
+ /// non-black pixel — the ring border — left untouched), matching retail's
+ /// SurfaceWindow::ReplaceColor call against old-color
+ /// (0,0,0,1). 0 if the template is unresolved.
+ ///
+ uint GetActiveSpotTexture(ChargenSwatchRgb rgb);
+}
+
+///
+/// Live-DAT implementation of .
+/// Decodes each of the four DoColorSpots/DoGradDisk category-7
+/// RenderSurfaces ONCE (live-DAT-measured: spot/blank are 37x44, gradDisk/
+/// gradPlug are 110x112 — exactly matching the swatch buttons' and grad
+/// circle's own authored rects), uploads the three static ones (blank/
+/// gradDisk/gradPlug) once, and bakes+caches one recolored spot texture per
+/// distinct value on demand — mirroring the
+/// SAME "decode once, composite/recolor per key, upload, cache" shape
+/// already established for item
+/// icons and spell components (that class's own
+/// GetSpellComponentIcon ports the identical exact-color-match
+/// replace this class uses, just matching white instead of black).
+///
+internal sealed class ChargenColorSpotComposer : IChargenSwatchTextureSource
+{
+ private const uint SpotEnumId = 0x1000000Du;
+ private const uint BlankEnumId = 0x1000000Fu;
+ private const uint GradDiskEnumId = 0x1000000Eu;
+ private const uint GradPlugEnumId = 0x10000010u;
+ private const uint EnumCategory = 7u;
+
+ private readonly IDatReaderWriter _dats;
+ private readonly TextureCache _cache;
+
+ private DecodedTexture? _spotTemplate;
+ private bool _spotResolveTried;
+ private readonly Dictionary<(byte R, byte G, byte B), uint> _bakedSpotByColor = new();
+
+ private uint _blankTexture;
+ private bool _blankResolveTried;
+ private uint _gradDiskTexture;
+ private bool _gradDiskResolveTried;
+ private uint _gradPlugTexture;
+ private bool _gradPlugResolveTried;
+
+ public ChargenColorSpotComposer(IDatReaderWriter dats, TextureCache cache)
+ {
+ _dats = dats;
+ _cache = cache;
+ }
+
+ public uint BlankSpotTexture
+ {
+ get
+ {
+ if (!_blankResolveTried)
+ {
+ _blankResolveTried = true;
+ if (TryDecode(BlankEnumId, out DecodedTexture decoded))
+ _blankTexture = _cache.UploadRgba8(decoded.Rgba8, decoded.Width, decoded.Height, nearest: true);
+ }
+ return _blankTexture;
+ }
+ }
+
+ public uint GradDiskTexture
+ {
+ get
+ {
+ if (!_gradDiskResolveTried)
+ {
+ _gradDiskResolveTried = true;
+ if (TryDecode(GradDiskEnumId, out DecodedTexture decoded))
+ _gradDiskTexture = _cache.UploadRgba8(decoded.Rgba8, decoded.Width, decoded.Height, nearest: true);
+ }
+ return _gradDiskTexture;
+ }
+ }
+
+ public uint GradPlugTexture
+ {
+ get
+ {
+ if (!_gradPlugResolveTried)
+ {
+ _gradPlugResolveTried = true;
+ if (TryDecode(GradPlugEnumId, out DecodedTexture decoded))
+ _gradPlugTexture = _cache.UploadRgba8(decoded.Rgba8, decoded.Width, decoded.Height, nearest: true);
+ }
+ return _gradPlugTexture;
+ }
+ }
+
+ public uint GetActiveSpotTexture(ChargenSwatchRgb rgb)
+ {
+ if (!_spotResolveTried)
+ {
+ _spotResolveTried = true;
+ if (TryDecode(SpotEnumId, out DecodedTexture decoded))
+ _spotTemplate = decoded;
+ }
+ if (_spotTemplate is not { } template)
+ return 0u;
+
+ var key = (rgb.R, rgb.G, rgb.B);
+ if (_bakedSpotByColor.TryGetValue(key, out uint cached))
+ return cached;
+
+ byte[] baked = ReplaceExactBlackWithColor(template.Rgba8, rgb);
+ uint texture = _cache.UploadRgba8(baked, template.Width, template.Height, nearest: true);
+ _bakedSpotByColor[key] = texture;
+ return texture;
+ }
+
+ ///
+ /// Pure byte-level half of — cloned,
+ /// GL-free, and unit-testable without a TextureCache. Retail's
+ /// own old-color argument to SurfaceWindow::ReplaceColor is
+ /// RGBAColor(0,0,0,1) — opaque black, ALL four channels, not
+ /// just RGB (the decompiled float quad's own alpha term is
+ /// 0x3f800000 = 1.0) — so a genuinely transparent padding pixel
+ /// (alpha 0, also RGB-zero in this port's own decoded padding) does
+ /// NOT match and is left untouched, exactly like the ring border.
+ /// Every matched pixel's RGB becomes 's own
+ /// bytes and alpha is forced to fully opaque (retail's own new-color
+ /// argument is ALSO alpha 1 — SetColor's computed swatch color
+ /// carries a hardcoded opaque alpha, not the source pixel's). Mirrors
+ /// 's
+ /// own exact-match convention (there, pure white) rather than an
+ /// invented fuzzy tolerance.
+ ///
+ internal static byte[] ReplaceExactBlackWithColor(byte[] rgba, ChargenSwatchRgb rgb)
+ {
+ byte[] baked = (byte[])rgba.Clone();
+ for (int i = 0; i + 3 < baked.Length; i += 4)
+ {
+ if (baked[i] != 0 || baked[i + 1] != 0 || baked[i + 2] != 0 || baked[i + 3] != 255)
+ continue;
+ baked[i] = rgb.R;
+ baked[i + 1] = rgb.G;
+ baked[i + 2] = rgb.B;
+ baked[i + 3] = 255;
+ }
+ return baked;
+ }
+
+ private bool TryDecode(uint enumId, out DecodedTexture decoded)
+ {
+ decoded = null!;
+ uint did = RetailDataIdResolver.Resolve(_dats, enumId, EnumCategory);
+ if (did == 0) return false;
+ if (!_dats.TryGet(did, out var rs) || rs is null) return false;
+ decoded = SurfaceDecoder.DecodeRenderSurface(rs);
+ return true;
+ }
+}
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index a6f56b47..da1e74b3 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -715,6 +715,20 @@ public sealed class RetailUiRuntime : IDisposable
}
}
+ /// R3-5/R3-6 (Campaign CC gate round 1 re-test 2): the fourth
+ /// late-bound seam, same pattern as the three above — see
+ /// 's
+ /// own doc comment.
+ internal AcDream.App.UI.Layout.IChargenSwatchTextureSource? ChargenSwatchTextureSource
+ {
+ get => CharacterCreationController?.AppearanceSwatchTextureSource;
+ set
+ {
+ if (CharacterCreationController is { } controller)
+ controller.AppearanceSwatchTextureSource = value;
+ }
+ }
+
/// CC6b-MOUNT: whether the Appearance page (specifically) is
/// the one currently showing — false, safely, before the screen mounts.
///
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationAppearancePageSwatchColorTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationAppearancePageSwatchColorTests.cs
index fc81db24..06cc80db 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationAppearancePageSwatchColorTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationAppearancePageSwatchColorTests.cs
@@ -252,9 +252,12 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
Assert.True(Swatch(root, 0).Visible);
Assert.Equal(ToVector4(HairColorB), Swatch(root, 1).Tint);
Assert.True(Swatch(root, 1).Visible);
- // Only two hair colors exist — swatch 2 must be hidden and untinted.
+ // R3-5 correction: only two hair colors exist, so swatch 2 shows
+ // the BLOCKED/blank art (untinted) — but retail's own
+ // pColor->SetVisible(1) is unconditional, so it stays VISIBLE, not
+ // hidden (Batch C's "beyond count -> hide" half is retired).
Assert.Equal(Vector4.One, Swatch(root, 2).Tint);
- Assert.False(Swatch(root, 2).Visible);
+ Assert.True(Swatch(root, 2).Visible);
}
/// Part change (Hair -> Eyes via the spin's own select-zone
@@ -285,9 +288,9 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
page.Refresh(view, view.Snapshot);
// No color selected yet (Unset) — no tint, but the disc's own base
- // art is still shown (Visible tracks !isEyes only, independent of
- // whether a real color has been resolved — the disc is never
- // hidden pending a tint, only Eyes hides it at all).
+ // art is still shown (R3-6: the disc is ALWAYS visible — retail
+ // never hides it, for Eyes or otherwise — independent of whether a
+ // real color has been resolved).
Assert.Equal(Vector4.One, GradCircle(root).Tint);
Assert.True(GradCircle(root).Visible);
@@ -309,10 +312,20 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
Assert.Equal(ToVector4(HairColorB), GradCircle(root).Tint);
}
- /// AP-217: Eyes always hides the gradient disc — no tint is
- /// ever shown for Eyes, regardless of the selected eye color.
+ ///
+ /// R3-6 correction (Campaign CC gate round 1 re-test 2), supersedes the
+ /// retired AP-217 "Eyes always hides the gradient disc" claim: re-
+ /// reading gmCGAppearancePage::SetSelection @0x0047e260's own
+ /// Eyes/non-Eyes tail (@0x0047e859-0047e878) shows NEITHER
+ /// branch ever calls m_pGradCircle->SetVisible — only
+ /// DoGradDisk (which swaps the SOURCE image) and
+ /// m_pShadeScroll->SetVisible (a DIFFERENT element, the shade
+ /// slider) are touched. The disc stays visible for Eyes too, showing
+ /// the static "grad plug" icon UNTINTED (Blit_Normal, no color arg) —
+ /// the swatches themselves still show real eye colors regardless.
+ ///
[Fact]
- public void EyesPart_GradientDiscStaysHiddenAndUntinted()
+ public void EyesPart_GradientDiscStaysVisibleButUntinted_ShowsPlugIconInstead()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
@@ -326,10 +339,10 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId));
eyesSpin.OnClickAt!(180, 10);
- Assert.False(GradCircle(root).Visible);
+ Assert.True(GradCircle(root).Visible);
Assert.Equal(Vector4.One, GradCircle(root).Tint);
// The swatches themselves still show real eye colors — only the
- // disc hides.
+ // disc's own SOURCE image swaps (plug icon, not the gradient).
Assert.Equal(ToVector4(EyeColorA), Swatch(root, 0).Tint);
}
@@ -350,8 +363,10 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
Assert.Equal(ToVector4(SkinColor), Swatch(root, 0).Tint);
Assert.True(Swatch(root, 0).Visible);
+ // R3-5 correction: swatch 1 is beyond the 1-color Nose/Mouth/Skin
+ // display count — untinted (blocked art), but still VISIBLE.
Assert.Equal(Vector4.One, Swatch(root, 1).Tint);
- Assert.False(Swatch(root, 1).Visible);
+ Assert.True(Swatch(root, 1).Visible);
Assert.Equal(ToVector4(SkinColor), GradCircle(root).Tint);
Assert.True(GradCircle(root).Visible);
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/ChargenColorSpotComposerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChargenColorSpotComposerTests.cs
new file mode 100644
index 00000000..835a2546
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/ChargenColorSpotComposerTests.cs
@@ -0,0 +1,101 @@
+using AcDream.App.UI.Layout;
+using AcDream.Core.CharGen;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+///
+/// R3-5/R3-6 (Campaign CC gate round 1 re-test 2): pure byte-level tests
+/// for —
+/// no TextureCache/GL/dat needed. Live-DAT enum resolution +
+/// dimension pins live in CharacterCreationLiveDatTests.
+///
+public sealed class ChargenColorSpotComposerTests
+{
+ private static byte[] Pixels(params (byte r, byte g, byte b, byte a)[] pixels)
+ {
+ var buffer = new byte[pixels.Length * 4];
+ for (int i = 0; i < pixels.Length; i++)
+ {
+ buffer[i * 4] = pixels[i].r;
+ buffer[i * 4 + 1] = pixels[i].g;
+ buffer[i * 4 + 2] = pixels[i].b;
+ buffer[i * 4 + 3] = pixels[i].a;
+ }
+ return buffer;
+ }
+
+ [Fact]
+ public void ReplaceExactBlackWithColor_RecolorsOnlyOpaqueExactBlackPixels_LeavesEverythingElseUntouched()
+ {
+ var rgb = new ChargenSwatchRgb(200, 40, 90);
+ // Pixel 0: exact black, OPAQUE (the spot's placeholder fill) -> recolored.
+ // Pixel 1: a gold ring-border pixel -> untouched.
+ // Pixel 2: near-black but NOT exact (a hypothetical anti-aliased edge) -> untouched
+ // (exact match only, matching IconComposer's own precedent).
+ // Pixel 3: fully transparent padding, RGB also zero -> untouched — retail's own
+ // old-color argument is opaque black (alpha 1), so a transparent pixel
+ // does not match even though its RGB is zero.
+ byte[] source = Pixels(
+ (0, 0, 0, 255),
+ (218, 167, 85, 255),
+ (2, 1, 0, 255),
+ (0, 0, 0, 0));
+
+ byte[] baked = ChargenColorSpotComposer.ReplaceExactBlackWithColor(source, rgb);
+
+ Assert.Equal(rgb.R, baked[0]);
+ Assert.Equal(rgb.G, baked[1]);
+ Assert.Equal(rgb.B, baked[2]);
+ Assert.Equal(255, baked[3]);
+
+ Assert.Equal(218, baked[4]);
+ Assert.Equal(167, baked[5]);
+ Assert.Equal(85, baked[6]);
+ Assert.Equal(255, baked[7]);
+
+ Assert.Equal(2, baked[8]);
+ Assert.Equal(1, baked[9]);
+ Assert.Equal(0, baked[10]);
+ Assert.Equal(255, baked[11]);
+
+ Assert.Equal(0, baked[12]);
+ Assert.Equal(0, baked[13]);
+ Assert.Equal(0, baked[14]);
+ Assert.Equal(0, baked[15]); // still transparent — not force-opaqued
+ }
+
+ [Fact]
+ public void ReplaceExactBlackWithColor_DoesNotMutateTheSourceBuffer()
+ {
+ byte[] source = Pixels((0, 0, 0, 255));
+ byte[] sourceCopy = (byte[])source.Clone();
+
+ _ = ChargenColorSpotComposer.ReplaceExactBlackWithColor(source, new ChargenSwatchRgb(10, 20, 30));
+
+ Assert.Equal(sourceCopy, source);
+ }
+
+ [Fact]
+ public void ReplaceExactBlackWithColor_EveryOpaqueBlackPixelRecolored_SemiTransparentBlackLeftAlone()
+ {
+ // Pixels 0/1: opaque black -> recolored. Pixel 2: semi-transparent
+ // black (alpha 128, not the retail old-color's exact alpha-1) ->
+ // untouched, same "exact match only" discipline as the RGB channels.
+ byte[] source = Pixels((0, 0, 0, 255), (0, 0, 0, 255), (0, 0, 0, 128));
+ var rgb = new ChargenSwatchRgb(9, 8, 7);
+
+ byte[] baked = ChargenColorSpotComposer.ReplaceExactBlackWithColor(source, rgb);
+
+ for (int i = 0; i < 2; i++)
+ {
+ Assert.Equal(rgb.R, baked[i * 4]);
+ Assert.Equal(rgb.G, baked[i * 4 + 1]);
+ Assert.Equal(rgb.B, baked[i * 4 + 2]);
+ Assert.Equal(255, baked[i * 4 + 3]);
+ }
+ Assert.Equal(0, baked[8]);
+ Assert.Equal(0, baked[9]);
+ Assert.Equal(0, baked[10]);
+ Assert.Equal(128, baked[11]); // untouched, including its own alpha
+ }
+}