From 91f84dec9f5a8c7fe04148b2c857459508a7736d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 17:20:42 +0200 Subject: [PATCH] =?UTF-8?q?test(chargen):=20Campaign=20CC=20gate=20round?= =?UTF-8?q?=201=20re-test=202=20=E2=80=94=20R3-8=20name-field=20exhaustive?= =?UTF-8?q?=20dump=20+=20closeout=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...-08-16-campaign-cc-gate-round1-findings.md | 246 +++++++++++++- .../Layout/CharacterCreationLiveDatTests.cs | 317 ++++++++++++++++++ 2 files changed, 562 insertions(+), 1 deletion(-) diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index a19b6023..734ed3e3 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -1,6 +1,6 @@ # 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: @@ -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 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 User's second visual pass with retail side-by-side screenshots (heritage diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 435efab7..0cab5bd2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -944,6 +944,29 @@ public sealed class CharacterCreationLiveDatTests $"the name field's state 0x{stateId:X} ('{state.Name}') must not " + "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)); } /// @@ -1583,6 +1606,272 @@ public sealed class CharacterCreationLiveDatTests + "regardless of the R2-1 margin fix"); } + /// + /// R3-1 (re-test 2): live-DAT pin for the wrap-mechanism fix's own + /// premise — the Coordination attribute-slider label (0x100002ed + /// under container 0x100003e8) authors OneLine=true (dat + /// property 0x20), the SAME retail default a caption with no + /// authored 0x20 resolves to for width-wrap purposes per + /// UIElement_Text::CalcJustification's per-glyph gate (see + /// 's own doc) — so this element + /// must never width-wrap regardless of font metrics. + /// + [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( + LayoutImporter.ImportInfos( + dats, layoutId, CharacterCreationUiController.RootElementId)); + + ElementInfo coordContainer = Assert.IsType(FindInfo(rootInfo, 0x100003E8u)); + ElementInfo coordLabel = Assert.IsType(FindInfo(coordContainer, 0x100002EDu)); + + Assert.True(coordLabel.TryGetEffectiveBool(0x20u, out bool oneLine) && oneLine); + } + + /// + /// 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", 0x100003f9) fits comfortably inside the button's + /// own FULL authored width (never needing to wrap), and its value + /// child (0x100002f3) 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 's + /// own doc). + /// + [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( + LayoutImporter.ImportInfos( + dats, layoutId, CharacterCreationUiController.RootElementId)); + var strings = new DatStringResolver(dats); + + ElementInfo skillsCredits = Assert.IsType(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(fontDid, out var font) && font is not null); + var glyphs = new Dictionary(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); + } + + /// + /// R3-3 (re-test 2): the info-box title (0x100003fb) and + /// description (0x100003fc) panes' own AUTHORED boxes overlap — + /// this is why 's constructor + /// forces both to VJustify.Top 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. + /// + [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( + LayoutImporter.ImportInfos( + dats, layoutId, CharacterCreationUiController.RootElementId)); + + ElementInfo title = Assert.IsType(FindInfo(rootInfo, 0x100003FBu)); + ElementInfo description = Assert.IsType(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); + } + + /// + /// R3-4/R3-7 (re-test 2): retail authors TWO distinct + /// UIElement_Scrollbar thumb shapes. Chat's own scrollbar + /// (0x10000012 under LayoutDesc 0x2100006f) is the + /// 3-slice composite '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 (0x100003f8) + /// 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 now resolves. + /// + [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(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); + } + + /// + /// R3-5/R3-6 (re-test 2): live-DAT pin for + /// 's four hardcoded enum ids — + /// resolves each through the SAME category-7 RetailDataIdResolver + /// chain gmCGAppearancePage::DoColorSpots/DoGradDisk use, + /// and pins the native dimensions those two decomp functions' own + /// CreateLocalSurface 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). + /// + [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(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( + 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(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(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); + } + + /// + /// R3-5: pixel-level ground truth for + /// 's + /// whole premise — the ACTIVE spot template (enum 0x1000000d) + /// has a genuinely near-black CENTER region and a genuinely non-black + /// RING/border region, and the BLANK template (enum 0x1000000f) + /// has almost no black pixels at all (it is a DIFFERENT piece of art, + /// not the spot with its center left un-recolored). + /// + [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(spotDid, out var spotRs) && spotRs is not null); + Assert.True(dats.TryGet(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) => Assert.IsType(layout.FindElement(elementId)); @@ -1603,4 +1892,32 @@ public sealed class CharacterCreationLiveDatTests null, new DatStringResolver(dats).Resolve); } + + /// + /// R3-4/R3-5c (re-test 2): the chargen shade slider's own thumb child + /// (0x10000321 > structural id 1) authors ONE DirectState + /// sprite of its own (0x06004D50) and ZERO children — the + /// EARLIER (Batch F11-era) investigation misread this as "genuinely + /// nothing authored here" from a string.Join 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 's R3-4/R3-7 fix + /// already handles (slices.Length == 0 -> ThumbSprite = + /// DefaultImage(thumb)) — no separate fix was needed for the shade + /// slider; this test proves the SAME code path already resolves it. + /// + [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(screen.FindElement(0x10000321u)); + Assert.False(shadeSlider.Horizontal); + Assert.NotEqual(0u, shadeSlider.ThumbSprite); + } }