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
|
|
@ -1080,6 +1080,14 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
interaction.RetainedUi.Runtime.ChargenPalSetSource = chargenCatalog;
|
interaction.RetainedUi.Runtime.ChargenPalSetSource = chargenCatalog;
|
||||||
interaction.RetainedUi.Runtime.ChargenClothingTableSource = chargenCatalog;
|
interaction.RetainedUi.Runtime.ChargenClothingTableSource = chargenCatalog;
|
||||||
interaction.RetainedUi.Runtime.ChargenPaletteColorSource = 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(
|
bindings.AdoptRelease(
|
||||||
"chargen preview control",
|
"chargen preview control",
|
||||||
() =>
|
() =>
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,28 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
||||||
internal IChargenClothingTableSource? ClothingTableSource { get; set; }
|
internal IChargenClothingTableSource? ClothingTableSource { get; set; }
|
||||||
internal IChargenPaletteColorSource? PaletteColorSource { get; set; }
|
internal IChargenPaletteColorSource? PaletteColorSource { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>ChargenColorSpotComposer</c> 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
|
||||||
|
/// <see cref="ChargenColorSpotComposer"/> (same site as
|
||||||
|
/// <see cref="PalSetSource"/> et al) once a <c>TextureCache</c> exists.
|
||||||
|
/// See <see cref="UiButton.ColorKeyFaceResolver"/>'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).
|
||||||
|
/// </summary>
|
||||||
|
internal IChargenSwatchTextureSource? SwatchTextureSource { get; set; }
|
||||||
|
|
||||||
/// <summary>The authored viewport (<c>0x100003bb</c>) — the composition
|
/// <summary>The authored viewport (<c>0x100003bb</c>) — the composition
|
||||||
/// root assigns its <c>Renderer</c> once the graphics backend exists,
|
/// root assigns its <c>Renderer</c> once the graphics backend exists,
|
||||||
/// mirroring the paperdoll's own late <c>viewport.Renderer = ...</c>
|
/// mirroring the paperdoll's own late <c>viewport.Renderer = ...</c>
|
||||||
|
|
@ -759,29 +781,42 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
||||||
if (_swatches[i] is not { } swatch)
|
if (_swatches[i] is not { } swatch)
|
||||||
continue;
|
continue;
|
||||||
bool visible = i < displayCount;
|
bool visible = i < displayCount;
|
||||||
swatch.Visible = visible;
|
// R3-5 correction: retail's own pColor->SetVisible(1)
|
||||||
// Closeout Group 1: a genuine multiplicative sprite tint on the
|
// (DoColorSpots' own loop) is UNCONDITIONAL for all 9 swatches
|
||||||
// swatch's own authored spot art (UiButton.Tint), replacing the
|
// — beyond displayCount shows the BLANK/blocked art (still
|
||||||
// Batch G flat-fill overlay. Vector4.One (identity) reproduces
|
// visible), never hides the element. Batch C's own "beyond
|
||||||
// the swatch's bare authored art untouched — both "no color data
|
// count -> hide" half is retired by this correction.
|
||||||
// yet" (sources unwired) and "beyond this part's color count".
|
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;
|
ChargenSwatchRgb? rgb = visible ? swatchColors[i] : null;
|
||||||
swatch.Tint = rgb is { } c ? ToTintColor(c) : Vector4.One;
|
swatch.Tint = rgb is { } c ? ToTintColor(c) : Vector4.One;
|
||||||
|
swatch.ColorKeyFaceResolver = BuildSwatchTextureResolver(visible, rgb);
|
||||||
}
|
}
|
||||||
|
|
||||||
// AP-217 (Batch C PARTIAL -> Batch G, R2-5, FULL):
|
// AP-217 (Batch C PARTIAL -> Batch G, R2-5, FULL) + R3-6 correction:
|
||||||
// gmCGAppearancePage::DoGradDisk @0x0047da90 blits the blank "grad
|
// gmCGAppearancePage::DoGradDisk @0x0047da90 blits the "grad plug"
|
||||||
// plug" for Eyes (DoGradDisk(this, 1), called from SetSelection
|
// icon for Eyes (DoGradDisk(this, 1), called from SetSelection
|
||||||
// @0x0047e85d) and a gradient graphic TINTED with the CURRENTLY
|
// @0x0047e85d) and a gradient graphic TINTED with the CURRENTLY
|
||||||
// SELECTED swatch's own color otherwise (SetColor @0x0047dd50's
|
// SELECTED swatch's own color otherwise (SetColor @0x0047dd50's
|
||||||
// tail, DoGradDisk(this, 0) after m_iCurColor is already updated —
|
// tail, DoGradDisk(this, 0) after m_iCurColor is already updated —
|
||||||
// @0x0047de18). Nose/Mouth/Skin always tint from swatch index 0
|
// @0x0047de18). Nose/Mouth/Skin always tint from swatch index 0
|
||||||
// (SetSelection hard-codes eyeColor = 0 for those three cases,
|
// (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)
|
if (_gradCircle is not null)
|
||||||
{
|
{
|
||||||
bool isEyes = _currentPart == Part.Eyes;
|
bool isEyes = _currentPart == Part.Eyes;
|
||||||
_gradCircle.Visible = !isEyes;
|
_gradCircle.Visible = true;
|
||||||
// Closeout Group 1: same Tint mechanism as the swatches above —
|
// Closeout Group 1: same Tint mechanism as the swatches above —
|
||||||
// the gradient disc's own authored art is multiplied by the
|
// the gradient disc's own authored art is multiplied by the
|
||||||
// currently-selected swatch's color instead of an overlay child.
|
// currently-selected swatch's color instead of an overlay child.
|
||||||
|
|
@ -793,6 +828,16 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
||||||
ChargenSwatchRgb? gradColor =
|
ChargenSwatchRgb? gradColor =
|
||||||
gradIndex >= 0 && gradIndex < swatchColors.Length ? swatchColors[gradIndex] : null;
|
gradIndex >= 0 && gradIndex < swatchColors.Length ? swatchColors[gradIndex] : null;
|
||||||
_gradCircle.Tint = gradColor is { } gc ? ToTintColor(gc) : Vector4.One;
|
_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);
|
ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart);
|
||||||
|
|
@ -812,6 +857,40 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R3-5: which pre-baked bitmap (if any) a swatch's own
|
||||||
|
/// <see cref="UiButton.ColorKeyFaceResolver"/> should resolve to on
|
||||||
|
/// THIS refresh. A fresh closure per call (not a cached delegate) so a
|
||||||
|
/// later <see cref="SwatchTextureSource"/> assignment (the composition
|
||||||
|
/// root wires it once the graphics backend exists, strictly after this
|
||||||
|
/// page's own construction — same ordering as
|
||||||
|
/// <see cref="PreviewControl"/>) is picked up the next time the
|
||||||
|
/// resolver actually RUNS (at draw time), not frozen at the OLD (null)
|
||||||
|
/// value from an earlier refresh.
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>Beyond <c>displayCount</c> (<paramref name="visible"/>
|
||||||
|
/// false): always resolve to the BLOCKED/blank art — retail shows this
|
||||||
|
/// for every swatch its current color count doesn't reach.</description></item>
|
||||||
|
/// <item><description>In range with a real color: resolve to that
|
||||||
|
/// color's own baked spot.</description></item>
|
||||||
|
/// <item><description>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 <see cref="SwatchTextureSource"/>'s own doc) untinted,
|
||||||
|
/// matching this page's pre-R3-5 "fully inert until wired"
|
||||||
|
/// disposition for the OTHER three palette seams.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
private Func<uint>? 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) ──────────────────────────────
|
// ── Real swatch/gradient colors (R2-5) ──────────────────────────────
|
||||||
|
|
||||||
private static readonly ChargenSwatchRgb?[] EmptySwatchColors = new ChargenSwatchRgb?[SwatchIds.Length];
|
private static readonly ChargenSwatchRgb?[] EmptySwatchColors = new ChargenSwatchRgb?[SwatchIds.Length];
|
||||||
|
|
|
||||||
|
|
@ -386,6 +386,16 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
set => _appearancePage.PaletteColorSource = value;
|
set => _appearancePage.PaletteColorSource = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>R3-5/R3-6 (Campaign CC gate round 1 re-test 2): the fourth
|
||||||
|
/// late-bound seam, same pattern as the three above — see
|
||||||
|
/// <see cref="CharacterCreationAppearancePage.SwatchTextureSource"/>'s
|
||||||
|
/// own doc comment.</summary>
|
||||||
|
internal IChargenSwatchTextureSource? AppearanceSwatchTextureSource
|
||||||
|
{
|
||||||
|
get => _appearancePage.SwatchTextureSource;
|
||||||
|
set => _appearancePage.SwatchTextureSource = value;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Gates the Appearance preview's per-frame work on whether
|
/// <summary>Gates the Appearance preview's per-frame work on whether
|
||||||
/// that specific page — AND the whole chargen screen — is the one
|
/// that specific page — AND the whole chargen screen — is the one
|
||||||
/// currently showing. <c>Close()</c> only ever hides <see cref="Root"/>,
|
/// currently showing. <c>Close()</c> only ever hides <see cref="Root"/>,
|
||||||
|
|
|
||||||
206
src/AcDream.App/UI/Layout/ChargenColorSpotComposer.cs
Normal file
206
src/AcDream.App/UI/Layout/ChargenColorSpotComposer.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R3-5/R3-6 (Campaign CC gate round 1 re-test 2) seam: the pre-baked
|
||||||
|
/// textures <see cref="CharacterCreationAppearancePage"/>'s color-wheel
|
||||||
|
/// swatches and gradient disc draw instead of a plain multiply-<c>Tint</c>
|
||||||
|
/// over the authored sprite. See <see cref="UiButton.ColorKeyFaceResolver"/>'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).
|
||||||
|
/// </summary>
|
||||||
|
internal interface IChargenSwatchTextureSource
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's "blank"/blocked swatch art (enum <c>0x1000000f</c>,
|
||||||
|
/// category 7 — <c>gmCGAppearancePage::DoColorSpots @0x0047d850</c>'s
|
||||||
|
/// <c>i >= count</c> branch) — shown UNTINTED for a swatch beyond the
|
||||||
|
/// current part's real color count (retail's own
|
||||||
|
/// <c>pColor->SetVisible(1)</c> is unconditional for all 9 swatches;
|
||||||
|
/// only the CONTENT differs). 0 if unresolved.
|
||||||
|
/// </summary>
|
||||||
|
uint BlankSpotTexture { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's gradient-disc art (enum <c>0x1000000e</c>, category 7) —
|
||||||
|
/// shown MULTIPLY-tinted by the currently selected swatch's own color,
|
||||||
|
/// matching retail's own <c>SurfaceWindow::BlitAndColor(...,
|
||||||
|
/// Blit_Multiply, color)</c> (<c>DoGradDisk @0x0047da90</c>'s non-Eyes
|
||||||
|
/// branch) — a genuine multiply, unlike the swatch spots. 0 if
|
||||||
|
/// unresolved.
|
||||||
|
/// </summary>
|
||||||
|
uint GradDiskTexture { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's Eyes "grad plug" icon art (enum <c>0x10000010</c>, category
|
||||||
|
/// 7) — shown UNTINTED (<c>DoGradDisk</c>'s Eyes branch is a plain
|
||||||
|
/// <c>Blit_Normal</c>, no color argument at all). 0 if unresolved.
|
||||||
|
/// </summary>
|
||||||
|
uint GradPlugTexture { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bakes (or returns a cached) recolored copy of the ACTIVE swatch spot
|
||||||
|
/// template (enum <c>0x1000000d</c>, category 7) with every EXACT-black
|
||||||
|
/// pixel replaced by <paramref name="rgb"/>'s own bytes (alpha and every
|
||||||
|
/// non-black pixel — the ring border — left untouched), matching retail's
|
||||||
|
/// <c>SurfaceWindow::ReplaceColor</c> call against old-color
|
||||||
|
/// <c>(0,0,0,1)</c>. 0 if the template is unresolved.
|
||||||
|
/// </summary>
|
||||||
|
uint GetActiveSpotTexture(ChargenSwatchRgb rgb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live-DAT implementation of <see cref="IChargenSwatchTextureSource"/>.
|
||||||
|
/// Decodes each of the four <c>DoColorSpots</c>/<c>DoGradDisk</c> 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 <see cref="ChargenSwatchRgb"/> value on demand — mirroring the
|
||||||
|
/// SAME "decode once, composite/recolor per key, upload, cache" shape
|
||||||
|
/// <see cref="AcDream.App.UI.IconComposer"/> already established for item
|
||||||
|
/// icons and spell components (that class's own
|
||||||
|
/// <c>GetSpellComponentIcon</c> ports the identical exact-color-match
|
||||||
|
/// replace this class uses, just matching white instead of black).
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure byte-level half of <see cref="GetActiveSpotTexture"/> — cloned,
|
||||||
|
/// GL-free, and unit-testable without a <c>TextureCache</c>. Retail's
|
||||||
|
/// own old-color argument to <c>SurfaceWindow::ReplaceColor</c> is
|
||||||
|
/// <c>RGBAColor(0,0,0,1)</c> — opaque black, ALL four channels, not
|
||||||
|
/// just RGB (the decompiled float quad's own alpha term is
|
||||||
|
/// <c>0x3f800000</c> = 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 <paramref name="rgb"/>'s own
|
||||||
|
/// bytes and alpha is forced to fully opaque (retail's own new-color
|
||||||
|
/// argument is ALSO alpha 1 — <c>SetColor</c>'s computed swatch color
|
||||||
|
/// carries a hardcoded opaque alpha, not the source pixel's). Mirrors
|
||||||
|
/// <see cref="AcDream.App.UI.IconComposer.GetSpellComponentIcon"/>'s
|
||||||
|
/// own exact-match convention (there, pure white) rather than an
|
||||||
|
/// invented fuzzy tolerance.
|
||||||
|
/// </summary>
|
||||||
|
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<RenderSurface>(did, out var rs) || rs is null) return false;
|
||||||
|
decoded = SurfaceDecoder.DecodeRenderSurface(rs);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -715,6 +715,20 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>R3-5/R3-6 (Campaign CC gate round 1 re-test 2): the fourth
|
||||||
|
/// late-bound seam, same pattern as the three above — see
|
||||||
|
/// <see cref="AcDream.App.UI.Layout.CharacterCreationAppearancePage.SwatchTextureSource"/>'s
|
||||||
|
/// own doc comment.</summary>
|
||||||
|
internal AcDream.App.UI.Layout.IChargenSwatchTextureSource? ChargenSwatchTextureSource
|
||||||
|
{
|
||||||
|
get => CharacterCreationController?.AppearanceSwatchTextureSource;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (CharacterCreationController is { } controller)
|
||||||
|
controller.AppearanceSwatchTextureSource = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>CC6b-MOUNT: whether the Appearance page (specifically) is
|
/// <summary>CC6b-MOUNT: whether the Appearance page (specifically) is
|
||||||
/// the one currently showing — false, safely, before the screen mounts.
|
/// the one currently showing — false, safely, before the screen mounts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -252,9 +252,12 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
|
||||||
Assert.True(Swatch(root, 0).Visible);
|
Assert.True(Swatch(root, 0).Visible);
|
||||||
Assert.Equal(ToVector4(HairColorB), Swatch(root, 1).Tint);
|
Assert.Equal(ToVector4(HairColorB), Swatch(root, 1).Tint);
|
||||||
Assert.True(Swatch(root, 1).Visible);
|
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.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
|
/// <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);
|
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
|
||||||
page.Refresh(view, view.Snapshot);
|
page.Refresh(view, view.Snapshot);
|
||||||
// No color selected yet (Unset) — no tint, but the disc's own base
|
// No color selected yet (Unset) — no tint, but the disc's own base
|
||||||
// art is still shown (Visible tracks !isEyes only, independent of
|
// art is still shown (R3-6: the disc is ALWAYS visible — retail
|
||||||
// whether a real color has been resolved — the disc is never
|
// never hides it, for Eyes or otherwise — independent of whether a
|
||||||
// hidden pending a tint, only Eyes hides it at all).
|
// real color has been resolved).
|
||||||
Assert.Equal(Vector4.One, GradCircle(root).Tint);
|
Assert.Equal(Vector4.One, GradCircle(root).Tint);
|
||||||
Assert.True(GradCircle(root).Visible);
|
Assert.True(GradCircle(root).Visible);
|
||||||
|
|
||||||
|
|
@ -309,10 +312,20 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
|
||||||
Assert.Equal(ToVector4(HairColorB), GradCircle(root).Tint);
|
Assert.Equal(ToVector4(HairColorB), GradCircle(root).Tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>AP-217: Eyes always hides the gradient disc — no tint is
|
/// <summary>
|
||||||
/// ever shown for Eyes, regardless of the selected eye color.</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]
|
[Fact]
|
||||||
public void EyesPart_GradientDiscStaysHiddenAndUntinted()
|
public void EyesPart_GradientDiscStaysVisibleButUntinted_ShowsPlugIconInstead()
|
||||||
{
|
{
|
||||||
var (pal, clothing, colors) = MakeSources();
|
var (pal, clothing, colors) = MakeSources();
|
||||||
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
|
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
|
||||||
|
|
@ -326,10 +339,10 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests
|
||||||
UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId));
|
UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId));
|
||||||
eyesSpin.OnClickAt!(180, 10);
|
eyesSpin.OnClickAt!(180, 10);
|
||||||
|
|
||||||
Assert.False(GradCircle(root).Visible);
|
Assert.True(GradCircle(root).Visible);
|
||||||
Assert.Equal(Vector4.One, GradCircle(root).Tint);
|
Assert.Equal(Vector4.One, GradCircle(root).Tint);
|
||||||
// The swatches themselves still show real eye colors — only the
|
// 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);
|
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.Equal(ToVector4(SkinColor), Swatch(root, 0).Tint);
|
||||||
Assert.True(Swatch(root, 0).Visible);
|
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.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.Equal(ToVector4(SkinColor), GradCircle(root).Tint);
|
||||||
Assert.True(GradCircle(root).Visible);
|
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