From 2886f79f37719f4ffd69292f3d4ad13f9043f31e Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 17:20:31 +0200 Subject: [PATCH] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20round=20?= =?UTF-8?q?1=20re-test=202=20=E2=80=94=20R3-5/R3-6=20color-key=20swatch/gr?= =?UTF-8?q?adient=20textures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-derived gmCGAppearancePage::DoColorSpots @0x0047d850 and DoGradDisk @0x0047da90: retail does NOT multiply-tint the swatch/grad-circle's authored sprite. It builds a fresh composited surface once (CreateLocalSurface + Blit), then calls SurfaceWindow::ReplaceColor against old-color RGBAColor(0,0,0,1) (opaque black — the spot template's own placeholder fill, live-DAT-pixel-confirmed: the 37x44 "spot" resource has a genuine solid-black CENTER and a genuine non-black RING) — swapping every exact opaque-black pixel for the swatch's real color while leaving the ring untouched. A multiply-tint (Batch G's mechanism) is architecturally wrong: black multiplied by any color stays black (never recolors the center), and multiplying the ring's own non-black pixels corrupts them — exactly the reported "we tint the ring" symptom. Beyond-count swatches (R3-5b) use a COMPLETELY DIFFERENT authored resource (enum 0x1000000f, "blank" — pixel-confirmed almost no black at all, i.e. genuinely different art) shown untinted, and retail's own pColor->SetVisible(1) is unconditional for all 9 swatches (never hidden). For Eyes (R3-6), DoGradDisk's Eyes branch blits the "grad plug" icon (enum 0x10000010) untinted, and SetSelection's own Eyes/non-Eyes tail never hides m_pGradCircle at all — a correction to this port's prior "_gradCircle.Visible = !isEyes" line. Ported via a new ChargenColorSpotComposer (CPU-side decode-once + per-color bake-and-cache-once through the existing TextureCache.UploadRgba8 seam — the same shape IconComposer.GetSpellComponentIcon already established for item icons, just matching black instead of white) and a new opt-in UiButton.ColorKeyFaceResolver / reuse of the existing UiDatElement.RuntimeImageTexture seam — both additive. Tint keeps its existing meaning for every reader/test; the grad circle's Tint stays a genuine multiply for the non-Eyes case (retail's own Blit_Multiply there). Wired as a fourth late-bound composition seam (SwatchTextureSource), same pattern/site as the existing three color-computation seams. Code-complete, unit/live-DAT-tested (including pixel-level proof of the spot/blank templates' actual content); the user's connected visual gate is owed — no client launches this batch. Co-Authored-By: Claude Fable 5 --- .../LivePresentationComposition.cs | 8 + .../Layout/CharacterCreationAppearancePage.cs | 101 ++++++++- .../Layout/CharacterCreationUiController.cs | 10 + .../UI/Layout/ChargenColorSpotComposer.cs | 206 ++++++++++++++++++ src/AcDream.App/UI/RetailUiRuntime.cs | 14 ++ ...rCreationAppearancePageSwatchColorTests.cs | 37 +++- .../Layout/ChargenColorSpotComposerTests.cs | 101 +++++++++ 7 files changed, 455 insertions(+), 22 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/ChargenColorSpotComposer.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/ChargenColorSpotComposerTests.cs 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 + } +}