fix(chargen): Campaign CC gate round 1 re-test 2 — R3-5/R3-6 color-key swatch/gradient textures
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 <noreply@anthropic.com>
This commit is contained in:
parent
c9313edc63
commit
2886f79f37
7 changed files with 455 additions and 22 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>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);
|
||||
}
|
||||
|
||||
/// <summary>AP-217: Eyes always hides the gradient disc — no tint is
|
||||
/// ever shown for Eyes, regardless of the selected eye color.</summary>
|
||||
/// <summary>
|
||||
/// 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 <c>gmCGAppearancePage::SetSelection @0x0047e260</c>'s own
|
||||
/// Eyes/non-Eyes tail (<c>@0x0047e859-0047e878</c>) shows NEITHER
|
||||
/// branch ever calls <c>m_pGradCircle->SetVisible</c> — only
|
||||
/// <c>DoGradDisk</c> (which swaps the SOURCE image) and
|
||||
/// <c>m_pShadeScroll->SetVisible</c> (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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.CharGen;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// R3-5/R3-6 (Campaign CC gate round 1 re-test 2): pure byte-level tests
|
||||
/// for <see cref="ChargenColorSpotComposer.ReplaceExactBlackWithColor"/> —
|
||||
/// no <c>TextureCache</c>/GL/dat needed. Live-DAT enum resolution +
|
||||
/// dimension pins live in <c>CharacterCreationLiveDatTests</c>.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue