test(chargen): Campaign CC gate round 1 re-test 2 — R3-8 name-field exhaustive dump + closeout docs

R3-8: dumped EVERY property present on 0x10000402 (not just P0x17) across
every state, cross-referenced against UIElement_Text::OnSetAttribute's
complete case list (no UIElement_TextInput class exists in retail — the
name field is a plain UIElement_Text/m_filter-bearing field). The full
recognized-property space has no placeholder/prompt mechanism independent
of P0x17. The BaseElement/prototype-inheritance hypothesis is also ruled
out — the existing regression test already probes the fully-merged
ElementInfo (post BaseElement resolution) and finds nothing. The only
StringInfo-kind property present, 0x49, resolves to "Your name can be 32
characters long and cannot contain numbers or symbols." — but 0x49 is
part of the same five-property tooltip family ISSUES #409/GF-16 already
document client-wide (0x48's own DID, 0x21000041, is the EXACT tooltip
popup LayoutDesc #409 cites) — a hover tooltip, not an in-field
placeholder. No code change, per this batch's own "do not invent a
placeholder" contract — third independent negative result on this
question via three different mechanisms. The lead should request a live
retail screenshot before any further investigation.

Also carries the shared live-DAT regression suite for R3-1 through R3-7
(CharacterCreationLiveDatTests.cs holds tests spanning multiple findings
in one file, so they land together) and the RE-TEST 2 findings-doc
closeout writeup for all eight items.

App suite live-DAT env 5358/3 -> 5372/3 (+14, zero regressions). Runtime
1735/0 unchanged (untouched this round). Full solution: 14578 tests / 4
skips / 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 17:20:42 +02:00
parent 2886f79f37
commit 91f84dec9f
2 changed files with 562 additions and 1 deletions

View file

@ -1,6 +1,6 @@
# Campaign CC connected gate — round 1 findings (2026-08-16) # Campaign CC connected gate — round 1 findings (2026-08-16)
## RE-TEST 2 (build `1.0.2-cc.k`, post-E/F/G/closeout) — findings R3-1..R3-9 ## RE-TEST 2 (build `1.0.2-cc.k`, post-E/F/G/closeout) — findings R3-1..R3-8
Heritage PASSES. Remaining, with retail side-by-side screenshots: Heritage PASSES. Remaining, with retail side-by-side screenshots:
@ -40,6 +40,250 @@ Heritage PASSES. Remaining, with retail side-by-side screenshots:
is also empty, STOP and request a live retail screenshot of the field is also empty, STOP and request a live retail screenshot of the field
before any further work. before any further work.
**RE-TEST 2 fix batch (2026-08-16, R3-1..R3-8) is CODE-COMPLETE, pending the
user's visual gate.** All eight findings investigated and fixed except R3-8
(genuinely no dat-authored placeholder mechanism exists — see its own
disposition below). App suite live-DAT env 5358/3 → 5372/3 (+14, zero
regressions); Runtime 1735/0 unchanged (untouched this round); full
solution 14578 tests / 4 skips / 0 failures (0 Core/Content changes this
round, so those suites are unaffected by construction, not merely by
measurement). No client launches.
- **R3-1/R3-2 FIXED.** Root cause: Batch E's `UiButton.DrawBlockLabel`/
`WrapBlockLines` auto-wrapped ANY caption that didn't fit its box width —
live-DAT-probed, this is the WRONG mechanism. `UIElement_Text::
CalcJustification @0x00467260` (shared by the horizontal/vertical
branches) shows retail's real per-glyph break decision — BOTH the
width-triggered wrap AND the explicit-newline break — sits behind ONE
gate keyed on the `OneLine` flag passed into `GlyphList::Recalculate`;
nothing in the decomp confines a caption's wrap width to a SIBLING
element's rect (the ValueBox confinement Batch E added for the
coexisting-value-label shape). Live-DAT evidence: the Coordination
attribute-slider label (`0x100002ed`) authors `OneLine=true` (so it
should never wrap, regardless of width — decomp-confirmed, not just
measurement); the Skills credits button's "Available Skill Credits"
caption measures 193px against its OWN full 231px button width (fits
comfortably) — the 113px confined width Batch E fed into the wrap
decision was never a real retail quantity. Fixed by making
`UiButton.WrapBlockLines` split ONLY on the explicit (already-
normalized) `\n` — never width-based — a strict superset of the
pre-Batch-E single-line draw for every already-correct caption, and the
exact "Attribute\n Credits" authored-break shape still works unchanged.
The `ValueBox` confinement computation itself STAYS in
`UiButton.OnDraw` (still feeds the Center-alignment tx formula and the
now-unreachable-for-wrap clip rect for the rare multi-line+ValueBox
case) — deliberately not deleted, since it's harmless now that nothing
reads it for the wrap decision, and removing it would be unrelated
scope. **Known residual risk, NOT re-solved here:** the original
Batch E root-cause diagnosis for R2-2/R2-3 ("24dits"/"Credit0Credits")
was that the caption's own unconfined single-line render visually
overlapped the coexisting value label's rect (live-DAT-measured: the
Skills credits caption's rendered span reaches x≈196, the value box
starts at local x=116). Removing the WRAP does not reintroduce a
confinement CLIP either (deliberately — see `DrawBlockLabel`'s own doc
for why inventing a new clip boundary here would be exactly the
guessing this project forbids), so this specific button's caption and
value MAY visually overlap again in the live client. This was NOT
something the current re-test (R3-2) flagged as broken — it only
reported the wrap — so no action was taken beyond documenting the risk
for the user's own re-check.
Files: `src/AcDream.App/UI/UiButton.cs` (`DrawBlockLabel`,
`WrapBlockLines`, `OnDraw`'s Label block). Tests: `UiButtonTests.cs`
(`WrapBlockLines_LongSingleParagraph_NeverWordWraps` replaces the retired
Batch E word-wrap expectation; new
`WrapBlockLines_SingleWordNarrowerThanBoxButWiderThanOffsetAdjustedWidth_StaysOneLine`);
2 new live-DAT pins in `CharacterCreationLiveDatTests.cs`
(`CoordinationAttributeLabel_AuthorsOneLineTrue`,
`SkillsCreditsButton_CaptionFitsFullWidth_ValueChildStartsAtMidpoint`).
- **R3-3 FIXED.** Root cause: the title (`0x100003fb`, Y=435 H=100) and
description (`0x100003fc`, Y=460 H=100) panes' own AUTHORED boxes
overlap by 75px, live-DAT-measured — retail relies on VERTICAL
JUSTIFICATION, not disjoint rects, to keep them visually separate.
Neither pane authors dat property `0x15` (live-DAT-confirmed absent on
both), so both fall to this port's shared unauthored-VJustify default —
currently `Center`. Byte-tracing retail's real ctor default
(`UIElement_Text::UIElement_Text @0x004685ff`,
`m_eVerticalJustification = 4`) against `UIElement_Text::
CalcJustification @0x00467260`'s ACTUAL enum semantics (`ecx_5==1` →
Center; `ecx_5==3||5` → the far edge/Bottom; anything else, INCLUDING
the ctor's own default of 4 → the near edge/Top) shows the correct
unauthored default is **Top, not Center** — a genuine, client-wide
enum-mapping bug in this port (`ElementReader.cs`'s import-time switch,
`DatWidgetFactory.cs`'s build-time switch, and `ElementInfo.VJustify`'s
field default all currently resolve an absent `0x15` to Center). Under
the CORRECT Top default both panes render near their own box's TOP edge
(25px apart — no collision); under the current Center default both
cluster toward the middle of their overlapping boxes (collision).
**Scoped fix, not the systemic one:** `CharacterCreationSkillsPage`'s
constructor now force-sets `VerticalJustify = VJustify.Top` on both
panes directly, rather than fixing the shared mapping/default. The
shared bug is CLIENT-WIDE (every DAT-imported `UiText` reaching the
`Centered`/`RightAligned`/`OneLine` static paths or the multi-line
honored-justification path) and could regress already-shipped, visually
-verified, FROZEN surfaces (vitals numbers, chat, main game UI, Options
panel) that may rely on the CURRENT Center default — fixing it properly
needs its own dedicated investigation + full regression sweep, filed as
**ISSUES.md #410** and register **AD-104**.
Files: `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs`
(constructor). Tests: new
`CharacterCreationUiControllerTests.SkillsPage_InfoBoxPanes_ForceTopVerticalJustify_ToAvoidTitleDescriptionOverlap`
(fixture); new live-DAT
`CharacterCreationLiveDatTests.SkillsInfoBoxTitleAndDescription_AuthoredBoxesOverlap`
(pins the overlap premise itself, so a future DAT re-extract that makes
the boxes genuinely disjoint is visible).
- **R3-4/R3-7 FIXED (single shared mechanism, confirmed).** Root cause:
retail authors TWO DISTINCT `UIElement_Scrollbar` thumb shapes.
`DatWidgetFactory.BuildScrollbar`'s existing vertical-thumb detection was
built against chat's own scrollbar (`0x10000012`) — a 3-slice composite
where the thumb CHILD carries no media of its own and three Type-3
grandchildren supply the top-cap/middle/bottom-cap sprites. The chargen
Skills listbox scrollbar (`0x100003f8`), Summary's OVERVIEW listbox
scrollbar (`0x10000401`), the Summary how-to box's scrollbar
(`0x100002e7`), AND the shade slider (`0x10000321`) all instead author a
SIMPLE single-sprite thumb: the same structural child (Type 1, id 1, not
the inc/dec button) carries its OWN direct Normal/Normal_rollover/
Normal_pressed (or, for the shade slider, a single DirectState) media
and has ZERO children — the 3-slice-only search found nothing for this
shape, so every `Thumb*Sprite` stayed 0 regardless of overflow. Fixed by
falling back to the thumb's own `DefaultImage` when the slice search
finds nothing — additive; a thumb WITH real slice children (chat) is
unaffected. This ONE fix covers R3-4's three listbox thumbs AND R3-7
AND, as a natural consequence (same code path, same structural shape),
the shade-slider indicator half of R3-5(c) — no separate fix was needed
for the shade slider.
**Process note:** the ORIGINAL live-DAT probe for this investigation
mis-reported the shade slider's own thumb child (and, separately, the
swatch/gradCircle elements investigated for R3-5/R3-6) as authoring
"zero media" — a `string.Join(",", StateMedia.Keys)` display artifact
(a single `""` DirectState key joins to an EMPTY STRING, indistinguishable
from zero entries in a printed diagnostic — not a code defect, a
diagnostic-only mistake caught and corrected mid-investigation by
re-probing with an exact `.Count`/dictionary-content check instead of a
joined string).
Files: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`
(`BuildScrollbar`). Tests: new `DatWidgetFactoryTests.cs`
(`Type11_VerticalScrollbar_SingleSpriteThumbWithNoSliceChildren_SetsThumbSprite`,
`Type11_VerticalScrollbar_ThumbWithSliceChildren_StillUsesSliceMedia`
negative companion); 3 new live-DAT pins in
`CharacterCreationLiveDatTests.cs`
(`SkillsListboxScrollbar_SingleSpriteThumbShape_BuildsWithNonZeroThumbSprite`,
`ShadeSlider_ThumbAuthorsItsOwnDirectStateSprite_BuildsWithNonZeroThumbSprite`);
existing `ChatFixture_ScrollbarImportsInheritedMediaRoles` (3-slice shape)
re-verified unaffected.
- **R3-5/R3-6 CODE-COMPLETE.** Root cause, re-derived from
`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 region and a
genuine non-black RING/border region) — 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 here: black
multiplied by ANY color stays black (never recolors the center at all),
and multiplying the ring's own non-black pixels shifts their hue,
corrupting them — exactly the reported "we tint the ring" symptom.
Beyond-count swatches (R3-5b) use a COMPLETELY DIFFERENT authored
resource (enum `0x1000000f`, "blank" — live-DAT-pixel-confirmed almost
no black pixels at all, i.e. genuinely different art, not "the spot with
its center left un-recolored") shown UNTINTED — and retail's own
`pColor->SetVisible(1)` is UNCONDITIONAL for all 9 swatches (never
hidden, only the CONTENT differs). For Eyes (R3-6), `DoGradDisk`'s Eyes
branch (`arg2=1`) blits the "grad plug" icon (enum `0x10000010`)
UNTINTED (`Blit_Normal`, no color argument at all) — and, re-reading
`SetSelection @0x0047e260`'s own Eyes/non-Eyes tail
(`@0x0047e859-0047e878`), NEITHER branch ever calls
`m_pGradCircle->SetVisible` — only `DoGradDisk` (swaps the source image)
and `m_pShadeScroll->SetVisible` (a DIFFERENT element) are touched, so
the disc itself is NEVER hidden for Eyes — a correction to this port's
prior `_gradCircle.Visible = !isEyes` line (and its own now-renamed
test).
**Mechanism ported faithfully** via a new `ChargenColorSpotComposer`
(CPU-side decode-once + per-color bake-and-cache-once, uploaded through
the existing `TextureCache.UploadRgba8` seam — the SAME "decode,
recolor by exact-match, upload, cache" shape `AcDream.App.UI.
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, zero behavior
change for any element that doesn't set them. `Tint` itself is
UNCHANGED in meaning (still the "this swatch's color is X" signal every
existing test reads) for the swatches; the grad circle's own Tint STAYS
a genuine multiply for the non-Eyes case, matching retail's OWN
`Blit_Multiply` there (the ONE place a multiply tint is actually
correct). Wired as a fourth late-bound composition seam
(`SwatchTextureSource`), same pattern and same site as the existing
three color-computation seams (`PalSetSource`/`ClothingTableSource`/
`PaletteColorSource`), constructed in `LivePresentationComposition.cs`
once `TextureCache` exists.
**STOPPED item, same shape as Batch G's own two STOPPED items:** this
is CODE-COMPLETE and unit/live-DAT-tested (including a real pixel-level
proof that the spot template genuinely has a black center + non-black
ring, and that the blank template genuinely doesn't), but has NOT been
visually verified in the live client this round (no client launches per
this batch's own constraint) — the user's connected gate is owed.
Files: `src/AcDream.App/UI/Layout/ChargenColorSpotComposer.cs` (new),
`src/AcDream.App/UI/UiButton.cs` (`ColorKeyFaceResolver`, `OnDraw`),
`src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs`
(`SwatchTextureSource`, `BuildSwatchTextureResolver`, the swatch/
gradCircle refresh block), `src/AcDream.App/UI/Layout/
CharacterCreationUiController.cs` + `src/AcDream.App/UI/
RetailUiRuntime.cs` (pass-through seam), `src/AcDream.App/Composition/
LivePresentationComposition.cs` (composition-root wiring). Tests: new
`ChargenColorSpotComposerTests.cs` (3 pure byte-level tests for the
exact-match recolor), 4 live-DAT tests in `CharacterCreationLiveDatTests.cs`
(`ColorSpotAndGradDiskResources_ResolveToExpectedDimensions`,
`SpotTemplate_HasBlackCenterAndNonBlackRing_BlankTemplateHasNeitherBlack`),
8 `CharacterCreationAppearancePageSwatchColorTests.cs` assertions updated
(`Visible` now always true; `EyesPart_GradientDiscStaysHiddenAndUntinted`
renamed+rewritten to `EyesPart_GradientDiscStaysVisibleButUntinted_ShowsPlugIconInstead`).
- **R3-8 DISPOSITION: genuinely nothing authored — no code change, per
this batch's own contract ("DO NOT invent a placeholder").** Dumped
EVERY property present on `0x10000402` (not just P0x17) across every
state, cross-referenced against `UIElement_Text::OnSetAttribute`'s
COMPLETE case list (there is no `UIElement_TextInput` class in retail —
the name field is a plain `UIElement_Text`/`m_filter`-bearing field,
`DynamicCast(0xc)`-confirmed in `gmCGSummaryPage::InitializePage`; the
task's own reference to that class name doesn't exist in the named
decomp). The full recognized-property space (every id `0x14`-`0x29`
plus the sparse high ids `0xC7`/`0xCB`/`0xCC` for
TruncateTextToFit/LoseFocusOnEscape/LoseFocusOnAcceptInput) has NO
mechanism for a placeholder/prompt string independent of the committed
P0x17 caption. The BaseElement/prototype-inheritance hypothesis is ALSO
ruled out — not by assumption, but because the EXISTING regression test
(`SummaryNameField_AuthorsNoP0x17OnAnyState`) already probes the FULLY
MERGED `ElementInfo` (post `LayoutImporter`'s BaseElement resolution,
confirmed by reading `ElementReader.Merge`/`UiStateInfo.Merge`'s own
"derived overrides, else inherit base" property-bag semantics) and finds
no P0x17 anywhere. The live dump found exactly ONE StringInfo-kind
property on the whole element: **`0x49`, resolving to "Your name can be
32 characters long and cannot contain numbers or symbols."** — but
`0x49` is part of the SAME five-property tooltip family ISSUES.md
#409/GF-16 already documents client-wide (`0x47` tooltip behavior
enum = `0x10000487`, `0x48` the tooltip popup LayoutDesc DID =
`0x21000041` — the EXACT DID #409 cites, `0x49` the tooltip TEXT,
`0x4B` tooltip-enabled = true) — a HOVER TOOLTIP describing naming
rules, not an in-field placeholder, and #409's tooltip system is
unshipped so this text is authored but never shown anywhere yet. The
field's 8 children are the SAME gold-frame family (`0x100002DE-E3`/
`0x100000E8`/`0xEA`) GF-12 already renders — reinforcing GF-15's
existing hypothesis that the `[ Name` the user perceives is this frame's
own bracket-style chrome around an empty box, not text content.
**Batch A's closure and Batch E's re-check both stand; this is the
THIRD independent negative result on the same question via three
different mechanisms (retail code, DAT P0x17, now the complete property
space + inheritance chain).** No further code-side avenue remains — the
lead should request a live retail screenshot of the field per the
batch's own contract before any further investigation.
Test: `CharacterCreationLiveDatTests.SummaryNameField_AuthorsNoP0x17OnAnyState`
extended with the exhaustive `0x49`-is-the-tooltip assertion (a
regression pin, not just a probe finding).
## ROUND 1 RE-TEST (build `1.0.2-cc.i`, post-Batches B/C/D) — findings R2-1..R2-8 ## ROUND 1 RE-TEST (build `1.0.2-cc.i`, post-Batches B/C/D) — findings R2-1..R2-8
User's second visual pass with retail side-by-side screenshots (heritage User's second visual pass with retail side-by-side screenshots (heritage

View file

@ -944,6 +944,29 @@ public sealed class CharacterCreationLiveDatTests
$"the name field's state 0x{stateId:X} ('{state.Name}') must not " $"the name field's state 0x{stateId:X} ('{state.Name}') must not "
+ "author a P0x17 caption either."); + "author a P0x17 caption either.");
} }
// R3-8 (re-test 2, third assertion): EXHAUSTIVE — every property
// this element authors, cross-referenced against
// UIElement_Text::OnSetAttribute's COMPLETE case list (every
// property id that class recognizes at all: 0x14-0x29 plus the
// sparse high ids 0xC7/0xCB/0xCC), not just P0x17. The ONLY
// StringInfo-kind property present is 0x49 — part of the tooltip
// family (0x47 TooltipBehavior/0x48 the tooltip popup LayoutDesc
// DID 0x21000041/0x49 the tooltip TEXT/0x4B TooltipEnabled — the
// SAME five-property family ISSUES.md #409/GF-16 already
// documents client-wide) and resolves to "Your name can be 32
// characters long and cannot contain numbers or symbols." — a
// HOVER TOOLTIP, not an in-field placeholder; #409's tooltip
// system is unshipped, so this text is authored but never shown
// anywhere yet. No other property on this element (or its 8 gold-
// frame children, the SAME 0x100002DE-E3/0x100000E8/0xEA family
// GF-12 already renders) carries any string content.
Assert.True(nameField.TryGetEffectiveProperty(0x49u, out var tooltip));
Assert.Equal(UiPropertyKind.StringInfo, tooltip.Kind);
var strings = new DatStringResolver(dats);
Assert.Equal(
"Your name can be 32 characters long and cannot contain numbers or symbols.",
strings.Resolve(tooltip.StringInfoValue));
} }
/// <summary> /// <summary>
@ -1583,6 +1606,272 @@ public sealed class CharacterCreationLiveDatTests
+ "regardless of the R2-1 margin fix"); + "regardless of the R2-1 margin fix");
} }
/// <summary>
/// R3-1 (re-test 2): live-DAT pin for the wrap-mechanism fix's own
/// premise — the Coordination attribute-slider label (<c>0x100002ed</c>
/// under container <c>0x100003e8</c>) authors <c>OneLine=true</c> (dat
/// property <c>0x20</c>), the SAME retail default a caption with no
/// authored <c>0x20</c> resolves to for width-wrap purposes per
/// <c>UIElement_Text::CalcJustification</c>'s per-glyph gate (see
/// <see cref="UiButton.DrawBlockLabel"/>'s own doc) — so this element
/// must never width-wrap regardless of font metrics.
/// </summary>
[InstalledDatFact]
public void CoordinationAttributeLabel_AuthorsOneLineTrue()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats, layoutId, CharacterCreationUiController.RootElementId));
ElementInfo coordContainer = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x100003E8u));
ElementInfo coordLabel = Assert.IsType<ElementInfo>(FindInfo(coordContainer, 0x100002EDu));
Assert.True(coordLabel.TryGetEffectiveBool(0x20u, out bool oneLine) && oneLine);
}
/// <summary>
/// R3-2 (re-test 2): live-DAT pin for the wrap-mechanism fix's own
/// premise — the Skills credits button's caption ("Available Skill
/// Credits", <c>0x100003f9</c>) fits comfortably inside the button's
/// own FULL authored width (never needing to wrap), and its value
/// child (<c>0x100002f3</c>) sits at local X=116 — the confinement
/// figure Batch E used to force a false wrap, no longer consulted for
/// the wrap decision post-fix (see <see cref="UiButton.DrawBlockLabel"/>'s
/// own doc).
/// </summary>
[InstalledDatFact]
public void SkillsCreditsButton_CaptionFitsFullWidth_ValueChildStartsAtMidpoint()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats, layoutId, CharacterCreationUiController.RootElementId));
var strings = new DatStringResolver(dats);
ElementInfo skillsCredits = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x100003F9u));
Assert.True(skillsCredits.TryGetEffectiveProperty(0x17u, out var caption));
string? captionText = strings.Resolve(caption.StringInfoValue);
Assert.Equal("Available Skill Credits", captionText);
uint fontDid = skillsCredits.FontDid != 0 ? skillsCredits.FontDid : rootInfo.FontDid;
Assert.True(dats.TryGet<DatReaderWriter.DBObjs.Font>(fontDid, out var font) && font is not null);
var glyphs = new Dictionary<char, DatReaderWriter.Types.FontCharDesc>(font!.CharDescs.Count);
foreach (var cd in font.CharDescs) glyphs[(char)cd.Unicode] = cd;
var datFont = new UiDatFont(0, 0, 0, 0, 0, 0, font.MaxCharHeight, font.BaselineOffset, glyphs);
float measured = datFont.MeasureWidth(captionText!);
Assert.True(
measured < skillsCredits.Width,
$"caption measured {measured}px must fit the button's own full {skillsCredits.Width}px width");
ElementInfo valueChild = Assert.Single(skillsCredits.Children);
Assert.Equal(116f, valueChild.X);
}
/// <summary>
/// R3-3 (re-test 2): the info-box title (<c>0x100003fb</c>) and
/// description (<c>0x100003fc</c>) panes' own AUTHORED boxes overlap —
/// this is why <see cref="CharacterCreationSkillsPage"/>'s constructor
/// forces both to <c>VJustify.Top</c> rather than relying on disjoint
/// rects (see that constructor's own comment for the full decomp
/// citation). Pinned so a future DAT re-extract that changes these
/// boxes to genuinely disjoint rects is visible here, not silently
/// contradicting the fix's own premise.
/// </summary>
[InstalledDatFact]
public void SkillsInfoBoxTitleAndDescription_AuthoredBoxesOverlap()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats, layoutId, CharacterCreationUiController.RootElementId));
ElementInfo title = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x100003FBu));
ElementInfo description = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x100003FCu));
// Neither pane authors an explicit vertical-justify property — both
// fall to this port's shared (currently Center) unauthored default,
// ISSUES.md #410.
Assert.False(title.TryGetEffectiveProperty(0x15u, out _));
Assert.False(description.TryGetEffectiveProperty(0x15u, out _));
float titleBottom = title.Y + title.Height;
float descriptionTop = description.Y;
Assert.True(
titleBottom > descriptionTop,
$"expected the title's own box (Y={title.Y} H={title.Height}, bottom={titleBottom}) to "
+ $"overlap the description's box (Y={description.Y}) — if it no longer does, the "
+ "VerticalJustify.Top override in CharacterCreationSkillsPage may no longer be needed");
// The two boxes' own TOP edges still leave enough of a gap for
// Top-justified content not to collide — the fix's actual premise.
Assert.True(description.Y > title.Y);
}
/// <summary>
/// R3-4/R3-7 (re-test 2): retail authors TWO distinct
/// <c>UIElement_Scrollbar</c> thumb shapes. Chat's own scrollbar
/// (<c>0x10000012</c> under LayoutDesc <c>0x2100006f</c>) is the
/// 3-slice composite <see cref="DatWidgetFactory.BuildScrollbar"/>'s
/// original thumb-detection was built against (the thumb child itself
/// carries NO media; three Type-3 grandchildren supply the cap/middle/
/// cap sprites). The chargen Skills listbox scrollbar (<c>0x100003f8</c>)
/// instead authors a SIMPLE single-sprite thumb: the same structural
/// child (Type 1, id 1, not the inc/dec button) carries its OWN direct
/// Normal/Normal_rollover/Normal_pressed media and has ZERO children —
/// before the fix, the 3-slice-only search found nothing and every
/// Thumb*Sprite stayed 0. This test builds the REAL scrollbar end to
/// end and asserts <see cref="UiScrollbar.ThumbSprite"/> now resolves.
/// </summary>
[InstalledDatFact]
public void SkillsListboxScrollbar_SingleSpriteThumbShape_BuildsWithNonZeroThumbSprite()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiScrollbar scrollbar = Assert.IsType<UiScrollbar>(screen.FindElement(0x100003F8u));
Assert.False(scrollbar.Horizontal);
Assert.NotEqual(0u, scrollbar.ThumbSprite);
// The 3-slice caps stay unset for this shape — OnDraw's own
// single-tile fallback (ThumbTopSprite/ThumbBotSprite both 0)
// draws the whole thumb from ThumbSprite alone.
Assert.Equal(0u, scrollbar.ThumbTopSprite);
Assert.Equal(0u, scrollbar.ThumbBotSprite);
}
/// <summary>
/// R3-5/R3-6 (re-test 2): live-DAT pin for
/// <see cref="ChargenColorSpotComposer"/>'s four hardcoded enum ids —
/// resolves each through the SAME category-7 <c>RetailDataIdResolver</c>
/// chain <c>gmCGAppearancePage::DoColorSpots</c>/<c>DoGradDisk</c> use,
/// and pins the native dimensions those two decomp functions' own
/// <c>CreateLocalSurface</c> calls size their composite surfaces to
/// (spot/blank match the swatch buttons' own 37x44 authored rect;
/// gradDisk/gradPlug match the grad circle's own 110x112 rect).
/// </summary>
[InstalledDatFact]
public void ColorSpotAndGradDiskResources_ResolveToExpectedDimensions()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
(uint enumId, int width, int height)[] expected =
[
(0x1000000Du, 37, 44), // spot (active)
(0x1000000Fu, 37, 44), // blank (blocked)
(0x1000000Eu, 110, 112), // gradDisk
(0x10000010u, 110, 112), // gradPlug
];
foreach ((uint enumId, int width, int height) in expected)
{
uint did = RetailDataIdResolver.Resolve(dats, enumId, 7u);
Assert.NotEqual(0u, did);
Assert.True(dats.TryGet<DatReaderWriter.DBObjs.RenderSurface>(did, out var rs) && rs is not null);
Assert.Equal(width, (int)rs!.Width);
Assert.Equal(height, (int)rs.Height);
}
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats, layoutId, CharacterCreationUiController.RootElementId));
uint spotDid = RetailDataIdResolver.Resolve(dats, 0x1000000Du, 7u);
uint gradDiskDid = RetailDataIdResolver.Resolve(dats, 0x1000000Eu, 7u);
// The nine pColor swatch elements (retail's DoColorSpots targets)
// author exactly ONE DirectState sprite — and it resolves to the
// SAME RenderSurface as the "spot" enum resource above, i.e. the
// button's own static authored art already IS the un-recolored
// (black-center) spot template. This is exactly why the OLD Tint-
// multiply mechanism visibly tinted the ring: it was multiplying
// this real authored sprite, not drawing over nothing. No Normal/
// Highlight states exist (GF-9's own finding — the swatch's click
// feedback is a SEPARATE overlay element, not a state swap here).
ElementInfo spotElement = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x1000030Fu));
Assert.Equal(37f, spotElement.Width);
Assert.Equal(44f, spotElement.Height);
var spotDirectState = Assert.Single(spotElement.StateMedia);
Assert.Equal(string.Empty, spotDirectState.Key);
Assert.Equal(spotDid, spotDirectState.Value.File);
// The grad circle likewise authors its own DirectState sprite —
// resolving to the SAME RenderSurface as the "gradDisk" enum
// resource — so the pre-fix Tint-multiply mechanism DID show
// something for the disc too (the un-recolored gradient wheel,
// multiplied); R3-6's actual gap was that Eyes needs a DIFFERENT
// source image (the plug icon) which no per-state authored data
// provides — SetSelection swaps it procedurally in retail, exactly
// what RuntimeImageTexture now reproduces.
ElementInfo gradCircleElement = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x1000030Eu));
Assert.Equal(110f, gradCircleElement.Width);
Assert.Equal(112f, gradCircleElement.Height);
var gradDirectState = Assert.Single(gradCircleElement.StateMedia);
Assert.Equal(string.Empty, gradDirectState.Key);
Assert.Equal(gradDiskDid, gradDirectState.Value.File);
}
/// <summary>
/// R3-5: pixel-level ground truth for
/// <see cref="ChargenColorSpotComposer.ReplaceExactBlackWithColor"/>'s
/// whole premise — the ACTIVE spot template (enum <c>0x1000000d</c>)
/// has a genuinely near-black CENTER region and a genuinely non-black
/// RING/border region, and the BLANK template (enum <c>0x1000000f</c>)
/// has almost no black pixels at all (it is a DIFFERENT piece of art,
/// not the spot with its center left un-recolored).
/// </summary>
[InstalledDatFact]
public void SpotTemplate_HasBlackCenterAndNonBlackRing_BlankTemplateHasNeitherBlack()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint spotDid = RetailDataIdResolver.Resolve(dats, 0x1000000Du, 7u);
uint blankDid = RetailDataIdResolver.Resolve(dats, 0x1000000Fu, 7u);
Assert.True(dats.TryGet<DatReaderWriter.DBObjs.RenderSurface>(spotDid, out var spotRs) && spotRs is not null);
Assert.True(dats.TryGet<DatReaderWriter.DBObjs.RenderSurface>(blankDid, out var blankRs) && blankRs is not null);
var spot = AcDream.Core.Textures.SurfaceDecoder.DecodeRenderSurface(spotRs!);
var blank = AcDream.Core.Textures.SurfaceDecoder.DecodeRenderSurface(blankRs!);
(int black, int nonBlackOpaque, int total) CountPixels(byte[] rgba)
{
int black = 0, nonBlackOpaque = 0, total = 0;
for (int i = 0; i + 3 < rgba.Length; i += 4)
{
byte a = rgba[i + 3];
if (a < 10) continue;
total++;
if (rgba[i] == 0 && rgba[i + 1] == 0 && rgba[i + 2] == 0) black++;
else nonBlackOpaque++;
}
return (black, nonBlackOpaque, total);
}
var spotCounts = CountPixels(spot.Rgba8);
var blankCounts = CountPixels(blank.Rgba8);
// The spot genuinely has both a substantial black region (the
// center, to recolor) AND a substantial non-black region (the
// ring, to leave alone) — proves this isn't an all-black or
// all-colored template.
Assert.True(spotCounts.black > 100, $"expected a real black center, got {spotCounts.black} black pixels");
Assert.True(spotCounts.nonBlackOpaque > 100, $"expected a real non-black ring, got {spotCounts.nonBlackOpaque}");
// The blank template is a DIFFERENT asset, not "spot with an
// un-recolored center" — near-zero black pixels.
Assert.True(
blankCounts.black < spotCounts.black / 10,
$"blank template has {blankCounts.black} black pixels, expected far fewer than the spot's {spotCounts.black}");
}
private static void AssertButton(ImportedLayout layout, uint elementId) => private static void AssertButton(ImportedLayout layout, uint elementId) =>
Assert.IsType<UiButton>(layout.FindElement(elementId)); Assert.IsType<UiButton>(layout.FindElement(elementId));
@ -1603,4 +1892,32 @@ public sealed class CharacterCreationLiveDatTests
null, null,
new DatStringResolver(dats).Resolve); new DatStringResolver(dats).Resolve);
} }
/// <summary>
/// R3-4/R3-5c (re-test 2): the chargen shade slider's own thumb child
/// (<c>0x10000321</c> &gt; structural id 1) authors ONE DirectState
/// sprite of its own (<c>0x06004D50</c>) and ZERO children — the
/// EARLIER (Batch F11-era) investigation misread this as "genuinely
/// nothing authored here" from a <c>string.Join</c> display artifact (a
/// single "" DirectState key joins to an empty string, indistinguishable
/// from zero entries in a printed log — NOT a code defect, a
/// diagnostic-only mistake). This is the EXACT SAME single-sprite-thumb
/// shape <see cref="DatWidgetFactory.BuildScrollbar"/>'s R3-4/R3-7 fix
/// already handles (<c>slices.Length == 0 -&gt; ThumbSprite =
/// DefaultImage(thumb)</c>) — no separate fix was needed for the shade
/// slider; this test proves the SAME code path already resolves it.
/// </summary>
[InstalledDatFact]
public void ShadeSlider_ThumbAuthorsItsOwnDirectStateSprite_BuildsWithNonZeroThumbSprite()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiScrollbar shadeSlider = Assert.IsType<UiScrollbar>(screen.FindElement(0x10000321u));
Assert.False(shadeSlider.Horizontal);
Assert.NotEqual(0u, shadeSlider.ThumbSprite);
}
} }