From f532f28c5b6d68d93bc2cb5b37f039aa56572621 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 01:15:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Campaign=20CT=20slice=20CT5=20?= =?UTF-8?q?=E2=80=94=20attribute/skill=20row=20geometry=20+=20selection=20?= =?UTF-8?q?media?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the hand-built attribute/skill rows in CharacterStatController with the authored shared row template 0x10000248 (LayoutDesc 0x21000045, InfoRegion::InfoRegion @0x004F1450 template index 0 — the same template gmAttributeUI and gmSkillUI both instantiate): - Row geometry replaced with AUTHORED PIXEL VALUES instead of derived fractions: icon flush left 20x20 (was 16x16 at X=4, vertically centered), name column X=25 W=150 fixed (was RowPadX+IconSize+IconGap offset with a width*0.60 fraction), value column X=175 W=100 right-justified (its right edge sits 7px short of the row's 282px right edge — the authored gutter the owner reported). Row width itself now clamps to the authored 282px template width (RowContentWidth) rather than the ListBox's raw 300px container width. Attribute-row height fixed at 20px (was 22px, no dat basis); SkillRowHeight folded into the same RowHeight constant since both row kinds share H=20. - RowHighlightSprite corrected from 0x06001397 to 0x06000F93 — CT1's ground-truth research sealed the verdict that gmAttributeUI:: UpdateSelection @0x0049DEE0 (SetState(6) -> InfoRegion::SetState @0x004F0EE0) swaps the row's Highlight-state media (0x06000F93), a full-row background swap. 0x06001397 belongs to a different mechanism entirely (the spellbook row's UIElement_UIItem::SetSelectedState overlay child) and SpellbookRowStyle.cs is untouched. - UiClickablePanel.UseSelectionBars/SelectionBarHeight retired outright (UiPanel.cs): they existed only to emulate 0x06001397's dark-bars art; the correct retail rendering is the full-panel sprite stretch the base UiPanel.OnDraw already performs, so the override is dead code once the correct sprite is used. No consumer existed outside CharacterStatController. - Per-attribute/per-vital icon DIDs now resolve through the live DBObj::GetDIDByEnum chain (RetailDataIdResolver.Resolve, AP-235's unification seam) when a resolver is supplied — RetailUiRuntime. MountCharacter wires one under the shared DatLock — falling back to the hardcoded AttrRows/VitalRows column otherwise (tests, no dat). gmAttributeUI::PostInit @0x0049DB70 read verbatim: attributes resolve via category 0x10000002 (statId order 1,2,4,3,5,6, matching AttrRows' authored display order exactly); vitals via category 0x10000003. Live-DAT-verified: every hardcoded fallback value already matched the resolved DID byte-exact (new InstalledDat pin AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain). - RetailAppraisalNameResolver.ResolveHeritage's independent re-implementation of the 2/5/13 heritage overrides deleted; it now delegates straight to CharacterIdentityText.HeritageGroupDisplayName (which already bakes in the same overrides) — one owner, byte-identical behavior. AP-235's register row updated to reflect the single-owner fix (the underlying hardcoded-vs-live-DAT mechanism divergence itself stays open — out of CT5's scope). Hand-built-vs-template ruling: rows stay HAND-BUILT rather than converting to UiTemplateListBox instantiation. The hand-built path hits every authored number byte-exact (proven by the CT1 InstalledDat pin AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns), while conversion would touch ~15 call sites (raise-button affordability, footer State A/B, per-row tooltip, section bucketing, live-refresh, selection-highlight) for a geometry-only slice — smaller-risk path per the task's own judgment-call guidance. Tests: CharacterStatControllerTests' sprite/UseSelectionBars assertions corrected to the authored geometry; new InstalledDat pin for the icon-DID chain. Full hermetic solution suite green (App/Core/Runtime/Headless/ Launcher/Content/etc., 0 failures) and the full InstalledDat lane green (203 App.Tests pins, TowerAscentReplayTests' known Status=KnownFailure case excluded per the acceptance filter). Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- .../UI/Layout/CharacterStatController.cs | 221 +++++++++++++----- .../UI/Layout/RetailAppraisalNameResolver.cs | 19 +- src/AcDream.App/UI/RetailUiRuntime.cs | 14 +- src/AcDream.App/UI/UiPanel.cs | 62 ++--- .../UI/Layout/CharacterPanelLiveDatTests.cs | 52 +++++ .../UI/Layout/CharacterStatControllerTests.cs | 21 +- 7 files changed, 266 insertions(+), 125 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a1cc6b97..157c7281 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -220,7 +220,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| AP-235 | **Filed 2026-08-25 at the Campaign CT4 fix round.** Retail resolves gender display text via `AppraisalSystem::InqGenderDisplayName @0x005b47c0` and heritage via `InqHeritageGroupDisplayName @0x005b4710`, both through the static `EnumMapper::GetString(uint32_t enumValue, uint32_t queryId, PStringBase*) @0x0041ac40` overload — `DBObj::GetDIDByEnum(&did, enumValue, 1)` (master map `0x25000000` → category-1 sub-map `0x25000001` → `ClientEnumToID[0x10000001]`/`[0x10000002]` → EnumMapper DIDs `0x2200000A`/`0x2200000B`) — reading each id's `IdToStringMap` entry live, with heritage ids 2/5/0xd hardcoded to `"Gharu'ndim"`/`"Umbraen"`/`"Olthoi"` in place of the raw internal names `"Gharundim"`/`"Shadowbound"`/`"OlthoiAcid"`. `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# `switch` tables instead — a mechanism divergence (compile-time constant vs. live DAT read), not a content one: `CharacterPanelLiveDatTests.GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain` (filed the same round) walks the live EnumMapper chain and asserts every table entry byte-exact, including the two entries (10 "Penumbraen", 12 "Olthoi") the CT4 review had flagged as unverified guesses — both are correct. | `src/AcDream.App/UI/Layout/CharacterIdentityText.cs` (`GenderDisplayName`, `HeritageGroupDisplayName`); `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs` (`ResolveHeritage` — a SECOND, independent re-implementation of the same 2/5/13 overrides, its own instance of this row's divergence) | `RetailDataIdResolver.Resolve` (`src/AcDream.Content/RetailDataIdResolver.cs`) already ports the generic two-level `GetDIDByEnum` chain (used today for layout/material DIDs); unifying gender/heritage onto it needs only `Resolve(dats, enumValue: 0x10000001u/0x10000002u, enumCategory: 1u)` plus an `EnumMapper.IdToStringMap` read — a live-DAT-only path with no bespoke traversal code to write, which is why the tables stayed hardcoded this round rather than porting live-read on the spot; CT5 is the natural landing slot since it already owns this same DAT-lookup family for the Titles page | A future DAT/game update that renames or reorders a heritage/gender enum entry would silently desync acdream's hardcoded tables from retail's live text with no build-time or runtime signal, and `RetailAppraisalNameResolver.ResolveHeritage`'s independent copy could drift from `CharacterIdentityText`'s even absent any DAT change | `AppraisalSystem::InqGenderDisplayName @ 0x005B47C0`; `InqHeritageGroupDisplayName @ 0x005B4710`; `EnumMapper::GetString @ 0x0041AC40`; `DBObj::GetDIDByEnum @ 0x004153A0` | +| AP-235 | **Filed 2026-08-25 at the Campaign CT4 fix round.** Retail resolves gender display text via `AppraisalSystem::InqGenderDisplayName @0x005b47c0` and heritage via `InqHeritageGroupDisplayName @0x005b4710`, both through the static `EnumMapper::GetString(uint32_t enumValue, uint32_t queryId, PStringBase*) @0x0041ac40` overload — `DBObj::GetDIDByEnum(&did, enumValue, 1)` (master map `0x25000000` → category-1 sub-map `0x25000001` → `ClientEnumToID[0x10000001]`/`[0x10000002]` → EnumMapper DIDs `0x2200000A`/`0x2200000B`) — reading each id's `IdToStringMap` entry live, with heritage ids 2/5/0xd hardcoded to `"Gharu'ndim"`/`"Umbraen"`/`"Olthoi"` in place of the raw internal names `"Gharundim"`/`"Shadowbound"`/`"OlthoiAcid"`. `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# `switch` tables instead — a mechanism divergence (compile-time constant vs. live DAT read), not a content one: `CharacterPanelLiveDatTests.GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain` (filed the same round) walks the live EnumMapper chain and asserts every table entry byte-exact, including the two entries (10 "Penumbraen", 12 "Olthoi") the CT4 review had flagged as unverified guesses — both are correct. | `src/AcDream.App/UI/Layout/CharacterIdentityText.cs` (`GenderDisplayName`, `HeritageGroupDisplayName`); `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs` (`ResolveHeritage` — CT5 fix round 2026-08-25 deleted its independent re-implementation of the same 2/5/13 overrides; it now delegates straight to `CharacterIdentityText.HeritageGroupDisplayName`, so this row's divergence has exactly ONE owner, not two) | `RetailDataIdResolver.Resolve` (`src/AcDream.Content/RetailDataIdResolver.cs`) already ports the generic two-level `GetDIDByEnum` chain (used today for layout/material DIDs); unifying gender/heritage onto it needs only `Resolve(dats, enumValue: 0x10000001u/0x10000002u, enumCategory: 1u)` plus an `EnumMapper.IdToStringMap` read — a live-DAT-only path with no bespoke traversal code to write, which is why the tables stayed hardcoded this round rather than porting live-read on the spot; CT5 is the natural landing slot since it already owns this same DAT-lookup family for the Titles page | A future DAT/game update that renames or reorders a heritage/gender enum entry would silently desync acdream's hardcoded tables from retail's live text with no build-time or runtime signal — the CT5 fix round retired the second-copy drift risk (`ResolveHeritage` now reads the same single table), but the core hardcoded-vs-live-DAT divergence itself remains open | `AppraisalSystem::InqGenderDisplayName @ 0x005B47C0`; `InqHeritageGroupDisplayName @ 0x005B4710`; `EnumMapper::GetString @ 0x0041AC40`; `DBObj::GetDIDByEnum @ 0x004153A0` | | AP-234 | **Filed 2026-08-23 at the #426 solid-face extraction fix.** Cell-wall (EnvCell/CellStruct) geometry approximates retail's "skip untextured subsets inside a cell interior" with the polygon's own `Stippling.NoPos` flag rather than resolving the Surface's own `Type` (`Base1Image`/`Base1ClipMap`) before the per-polygon draw decision — the same NoPos-vs-surface-type conflation #426 fixed for ordinary GfxObj extraction (`PrepareGfxObjMeshData`/`GfxObjMesh.Build`), deliberately LEFT in place here | `src/AcDream.Core/Meshing/CellMesh.cs:45`; `src/AcDream.Content/MeshExtractor.cs`'s `PrepareCellStructMeshData` `hasPos` gate carries the identical rule | Cells are the one retail context that genuinely skips untextured subsets (`DrawEnvCell`), so approximating "untextured" with NoPos is directionally correct for the common case — a solid-colour polygon always carries NoPos since it has no UVs to carry; resolving Surface.Type first would need a per-polygon dat lookup this code doesn't currently perform before the emit/skip decision | A textured polygon whose author left NoPos set (no positive UVs authored despite a real texture) would be wrongly skipped as if untextured, or an untextured polygon whose author left NoPos unset would wrongly draw — either edge case shows as a cell wall gaining or losing a face relative to retail | `RenderDeviceD3D::DrawEnvCell` @0x0059f170 → `D3DPolyRender::DrawMesh(..., arg4=1)`; `RetailUntexturedSurfacePolicy`/`RetailUntexturedSubsetPolicy` (`src/AcDream.Core/Meshing/RetailUntexturedSurfacePolicy.cs`) | | AP-233 | **Filed 2026-08-23 at the Holtburg windmill fix (row owed since the R1-P5 sequencer cutover).** `AnimationSequencer.BuildBlendedFrame` blends each part between `floor(FrameNumber)` and the next frame in the playback direction using the retail slerp (`SlerpRetailClient`). Retail never blends animation frames: `CPartArray::UpdateParts` applies `CSequence::get_curr_animframe` = `get_part_frame(floor(frame_number))`, holding every authored 30 fps frame for its whole interval. Since 2026-08-23 the blend holds the boundary frame at BOTH ends of a node's window — including the cyclic seam — so a cycle's last→first transition is retail's hard cut, not a blend. | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`BuildBlendedFrame`); tests `AnimationSequencerTests.Advance_LinkTailDoesNotBlendIntoLinkFrame0` (#61), `Advance_CyclicSeamHoldsLastFrameInsteadOfBlendingIntoFrame0` (windmill) | The blend only smooths between authored interior frames of one node; at every seam the pose is exactly retail's held frame. Authored cycles that loop by symmetry (the Holtburg windmill's 60-frame quarter turn, `0x0300061B`) or by design read identically at the seam; link tails hold their end pose (#61). The owner chose this over dropping the blend (retail's 30 fps stepping) on 2026-08-23. | Any two adjacent authored frames that are NOT meant to be traversed smoothly (a deliberate authored pop inside a node) would be smoothed where retail pops; none known. A per-frame hitch of one held 33 ms interval at each cycle seam is the price of the cut (1.5° on the windmill). | `CPartArray::UpdateParts @0x005190F0`; `CSequence::get_curr_animframe @0x00524970`; `CSequence::get_curr_frame_number @0x005249D0` | | AP-232 | **Filed 2026-08-22 at Campaign VM slice VM1 (the #226 single-pass re-port; deviation introduced at `05970306`, row owed since then).** Retail's single-pass detail combine produces ONE pixel per subset whose OUTPUT alpha is stage 1's `MODULATE(TEXTURE, CURRENT)` (`D3DPolyRender::SetSurface @0x0059c4d0`, op at `0x0059c549`) — for a delayed-alpha (translucent) subset that product is the framebuffer blend weight. acdream draws the base subset with its own alpha, then a second `mesh_detail` draw weighted by `detail.a * instanceOpacity` under `SRCALPHA + INVSRCALPHA`. For OPAQUE subsets (base alpha 1) the two compose to exactly `lerp(base, detail, detail.a*opacity)` and, with both draws fogged, to retail's fog-after-combine pixel (identity pinned by `RetailDetailTextureContractTests`). For TRANSLUCENT building/EnvCell subsets the destination after the base draw is `mix(behind, foggedBase, baseAlpha)`, not `foggedBase`, so the detail weight differs from retail's single product. | `src/AcDream.App/Rendering/Shaders/mesh_detail.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs` (transparent interleave); `src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs` (transparent interleave) | Opaque subsets are the overwhelming majority of building shells and interior walls and are exact; translucent detail-bearing subsets (ClipMap/alpha/additive/inverse-alpha glass and grates) get a bounded weight difference that never exceeds the detail texture's own alpha (mean 0.132 on the live Dereth category texture). Collapsing to one draw would require the base pipelines to sample the detail texture, i.e. a second `mesh_modern` variant on the retail path. | A translucent building/EnvCell surface with the detail preference on reads visibly different from retail against a bright background. Separate from AP-34 (queue ORDER); this row is about the blend WEIGHT. | `D3DPolyRender::SetSurface @0x0059c4d0` (stage table), `RenderMeshSubset @0x0059ca10`; VM2 cdb note `docs/research/2026-08-22-vm2-retail-detail-path-cdb.md` | diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 1aa203f3..6e1e0703 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -134,8 +134,14 @@ public static class CharacterStatController // the level element authors its own pale-gold FontColor (+ Outline); // LabelAuthoredColor reads it from the widget instead. - /// Row highlight color — semi-translucent gold, matches retail - /// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent. + /// Row highlight FALLBACK color — used only when + /// spriteResolve is unavailable (tests, headless mode) and the + /// real art cannot be drawn. CT5 fix + /// round: the former comment here claimed this tint "matches retail + /// sprite 0x06001397 visual intent" — that was never accurate (0x06001397 + /// is the spellbook's unrelated overlay sprite, and this solid tint was + /// never tuned to any specific sprite's actual pixels either way); it is + /// a synthetic no-art placeholder, not a retail-faithful color. private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f); // LayoutDesc 0x2100002E, FooterTitle 0x1000024E property 0x1B: // [0]=white, [1]=green, [2]=red, [3]=light blue (#7FFFFF). @@ -144,24 +150,73 @@ public static class CharacterStatController private static readonly Vector4 RetailVitaeBlue = new(127f / 255f, 1f, 1f, 1f); // ── Row layout constants ───────────────────────────────────────────────── - // RowHeight 22px + IconSize 16px: retail spec (2026-06-26) says icons ~icon-height - // and rows tighter. 16px icon fits inside 22px row with 3px vertical padding each side. - // The larger row font (0x40000001, MaxCharHeight=18) is clipped to the 22px height which - // gives a tight-but-readable line. Retail spec (2026-06-26 ref): "rows tighter, text ≈ icon height". - private const float RowHeight = 22f; - private const float IconSize = 16f; - private const float RowPadX = 4f; - private const float IconGap = 6f; + // Campaign CT slice CT5 (2026-08-25): the shared attribute/skill data-row + // template 0x10000248 (LayoutDesc 0x21000045 — InfoRegion::InfoRegion + // @0x004F1450 template index 0, used for BOTH gmAttributeUI and + // gmSkillUI rows) authors W=282 H=20 with icon 0x10000129 FLUSH LEFT + // (X=0 Y=0 W=20 H=20, full row height), name 0x1000012A at FIXED X=25 + // W=150, and value 0x1000012B at FIXED X=175 W=100 right-justified — + // its right edge (275) sits 7px short of the row's own 282px right + // edge, the authored gutter the owner reported (item 2). These are + // AUTHORED PIXEL VALUES, not width-relative fractions or derived + // offsets — the former RowHeight=22/IconSize=16px-at-X=4/ + // nameW=width*0.60 geometry had no dat basis at all. Ground truth + + // the explicit warning against composing a derived "gutter" formula: + // docs/research/2026-08-24-campaign-ct-dat-ground-truth.md §2, pinned + // by CharacterPanelLiveDatTests.AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns. + private const float RowHeight = 20f; + private const float RowIconX = 0f; + private const float RowIconSize = 20f; + private const float RowNameX = 25f; + private const float RowNameW = 150f; + private const float RowValueX = 175f; + private const float RowValueW = 100f; + + // Section-header caption inset ONLY (AddSkillHeader's 0x10000249..0x1000024C + // captions carry their own authored L5/R5 margins property, distinct from + // the data-row template above) — retained at its pre-CT5 value; CT1 found + // no divergence in the header captions, so this constant is out of CT5's + // scope. + private const float RowPadX = 4f; - private const float SkillRowHeight = 20f; private const float SkillHeaderHeight = 20f; + // Authored row-template width (0x10000248 W=282) — CT5: the row's own + // width now comes from THIS authored constant directly rather than the + // ListBox's raw 300px Width (see RowContentWidth below); do not derive + // a "listWidth minus gutter" formula, per the ground-truth doc's + // explicit warning. private const float SkillContentWidth = 282f; private const uint SkillHeaderSpecializedSprite = 0x06000F90u; private const uint SkillHeaderTrainedSprite = 0x06000F86u; private const uint SkillHeaderUntrainedSprite = 0x06000F98u; private const uint SkillHeaderUnusableSprite = 0x06000F89u; - private const uint RowHighlightSprite = 0x06001397u; + + // CT5 SEALED VERDICT (ground-truth doc §2 "SEALED VERDICT: RowHighlightSprite + // is wrong, not merely flagged"): gmAttributeUI::UpdateSelection + // @0x0049DEE0 calls SetState(selected ? 6 : 1) on the row; InfoRegion:: + // SetState @0x004F0EE0 forwards that to the row instantiated from THIS + // exact template (0x10000248), whose Highlight-state media is + // 0x06000F93 — a full-row background SWAP, not an overlay. The former + // 0x06001397 constant belongs to a DIFFERENT mechanism entirely: the + // spellbook row's separate selected-overlay CHILD element + // (UIElement_UIItem::SetSelectedState @0x004E1240, SpellbookRowStyle.cs) + // — that file and its tests are correct and must NOT be touched. + private const uint RowHighlightSprite = 0x06000F93u; + + // CT5 (AP-235 unification, gmAttributeUI::PostInit @0x0049DB70 verbatim): + // per-attribute icon DIDs resolve via DBObj::GetDIDByEnum(statEnum, + // category 0x10000002); per-vital (Attribute2ndInfoRegion) icon DIDs via + // DBObj::GetDIDByEnum(vitalEnum, category 0x10000003). Both categories + // are consumed through the shared RetailDataIdResolver.Resolve seam + // (the same master-map -> category-map -> value indirection already + // ported for the title chain and RetailKeyNames) rather than a fourth + // ad-hoc hardcoded DID table. Live-DAT-verified (2026-08-25): every + // hardcoded fallback value in AttrRows/VitalRows below already matches + // the resolved DID byte-exact — see + // CharacterPanelLiveDatTests.AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain. + private const uint AttributeIconCategory = 0x10000002u; + private const uint VitalIconCategory = 0x10000003u; // Scrollbar chrome from base layout 0x2100003E, shared with chat/ // inventory — sprite set + retail button seating live in @@ -270,6 +325,17 @@ public static class CharacterStatController /// a (GL tex handle, pixel width, pixel height) triple. Pass null in tests where /// icon rendering is not asserted. /// + /// + /// + /// CT5: ports DBObj::GetDIDByEnum(enumValue, + /// category) (master map -> category map -> value) for the per-attribute/ + /// per-vital row icon DIDs — pass (enumValue, category) => + /// RetailDataIdResolver.Resolve(dats, enumValue, category) under the + /// caller's dat lock. null (tests, or no live dat) falls back to + /// /' hardcoded DID column, + /// which the CT5 InstalledDat pin proves already matches the live-resolved + /// value byte-exact. + /// /// /// /// #431-CA5 gate fix (2026-08-24): the data-changed refresh. Retail's @@ -288,7 +354,8 @@ public static class CharacterStatController UiDatFont? rowDatFont = null, Func? spriteResolve = null, RaiseRequestHandler? onRaiseRequest = null, - Action? onClose = null) + Action? onClose = null, + Func? iconDidResolve = null) { // rowDatFont: larger font for attribute row name/value text (18px vs 16px default). // Falls back to datFont when null (tests, or dat missing). @@ -656,7 +723,7 @@ public static class CharacterStatController skillScrollbar.Visible = false; } currentAttributeRows = BuildAttributeRows(statList, rowDatFont, spriteResolve, data, attrSel, - allRaise1, allRaise10, SetFooterSelected); + allRaise1, allRaise10, SetFooterSelected, iconDidResolve); activeListEntries.AddRange(currentAttributeRows); } else @@ -668,7 +735,7 @@ public static class CharacterStatController Top = 0f, Width = contentW, Height = statList.Height, - LineHeight = (int)SkillRowHeight, + LineHeight = (int)RowHeight, Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, }; statList.AddChild(viewport); @@ -823,6 +890,34 @@ public static class CharacterStatController : SkillContentWidth; } + /// + /// CT5: the row's own rendered width is the AUTHORED row-template width + /// (282px, ), never the ListBox's raw + /// container width (300px, 's + /// own dat rect) — the two numbers do not compose into a "gutter" formula + /// (ground-truth doc §2's explicit warning); a shorter container clamps + /// the row down, but a wider one never stretches it past 282. + /// + private static float RowContentWidth(UiElement list) + => list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth; + + /// + /// CT5 (AP-235): resolve a row icon DID through the live + /// DBObj::GetDIDByEnum chain when a resolver is available, + /// falling back to the hardcoded / + /// column value otherwise (tests, or a resolve miss). + /// + private static uint ResolveIconDid( + Func? resolver, + uint enumValue, + uint category, + uint fallback) + { + if (resolver is null) return fallback; + uint resolved = resolver(enumValue, category); + return resolved != 0u ? resolved : fallback; + } + // ── 9-row attribute list ───────────────────────────────────────────────── private static List BuildAttributeRows( @@ -833,9 +928,10 @@ public static class CharacterStatController int[] sel, List allRaise1, List allRaise10, - Action setFooterSelected) + Action setFooterSelected, + Func? iconDidResolve) { - float listW = list.Width; + float listW = RowContentWidth(list); float y = 0f; var rows = new List(); @@ -846,7 +942,7 @@ public static class CharacterStatController var row = AddRow(list, datFont, spriteResolve, left: 0f, top: y, width: listW, height: RowHeight, - iconDid: iconDid, + iconDid: ResolveIconDid(iconDidResolve, statId, AttributeIconCategory, iconDid), nameText: rowName, valueProvider: () => { @@ -883,7 +979,7 @@ public static class CharacterStatController var row = AddRow(list, datFont, spriteResolve, left: 0f, top: y, width: listW, height: RowHeight, - iconDid: iconDid, + iconDid: ResolveIconDid(iconDidResolve, maxStatId, VitalIconCategory, iconDid), nameText: rowName, valueProvider: () => { @@ -922,7 +1018,7 @@ public static class CharacterStatController Action setFooterSelected, out List skillRows) { - float listW = list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth; + float listW = RowContentWidth(list); float y = 0f; var entries = new List(); var bindings = new List(); @@ -951,7 +1047,7 @@ public static class CharacterStatController CharacterSkill LiveSkill() => FindSkill(data(), skill.Id) ?? skill; var row = AddRow(list, datFont, spriteResolve, - left: 0f, top: y, width: listW, height: SkillRowHeight, + left: 0f, top: y, width: listW, height: RowHeight, iconDid: skill.IconDid, nameText: skill.Name, valueProvider: () => LiveSkill().CurrentLevel.ToString(), @@ -967,7 +1063,7 @@ public static class CharacterStatController }; bindings.Add(new SkillRowBinding(row, skill)); entries.Add(row); - y += SkillRowHeight; + y += RowHeight; } } } @@ -1131,10 +1227,10 @@ public static class CharacterStatController Console.WriteLine($"[CharacterStat] Row click: index={clickedIndex} → selected={newSel} ({rowName})"); // Update highlight on all rows. - // Retail uses sprite 0x06001397 (Button state 6 — the dark horizontal bars) - // for the selected row background. When spriteResolve is available, apply the - // sprite; otherwise fall back to the translucent gold tint. - const uint HighlightSprite = 0x06001397u; + // CT5: retail's InfoRegion::SetState swaps the row's whole background + // to the template's Highlight-state media (RowHighlightSprite, + // 0x06000F93 — see its own doc comment) when spriteResolve is + // available; otherwise fall back to the translucent gold tint. for (int i = 0; i < rows.Count; i++) { var row = rows[i]; @@ -1143,7 +1239,7 @@ public static class CharacterStatController if (spriteResolve is not null) { row.BackgroundColor = Vector4.Zero; - row.BackgroundSprite = HighlightSprite; + row.BackgroundSprite = RowHighlightSprite; row.SpriteResolve = spriteResolve; } else @@ -1201,14 +1297,12 @@ public static class CharacterStatController row.BackgroundColor = Vector4.Zero; row.BackgroundSprite = RowHighlightSprite; row.SpriteResolve = id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }; - row.UseSelectionBars = true; } else { row.BackgroundColor = HighlightBg; row.BackgroundSprite = 0u; row.SpriteResolve = null; - row.UseSelectionBars = false; } } else @@ -1216,7 +1310,6 @@ public static class CharacterStatController row.BackgroundColor = Vector4.Zero; row.BackgroundSprite = 0u; row.SpriteResolve = null; - row.UseSelectionBars = true; } } } @@ -1615,9 +1708,24 @@ public static class CharacterStatController } /// - /// Add a single attribute/vital row to as a - /// containing icon + name + value children. - /// Returns the panel so the caller can wire . + /// Add a single attribute/vital/skill row to as a + /// containing icon + name + value children, + /// laid out at the AUTHORED template 0x10000248 pixel geometry (icon + /// flush left 20x20, name at X=25 W=150, value at X=175 W=100 + /// right-justified — see the row-layout-constants block above). Returns + /// the panel so the caller can wire . + /// + /// CT5 hand-built-vs-template ruling: this row stays HAND-BUILT + /// (not converted to UiTemplateListBox row instantiation). + /// Converting would touch every one of the ~15 call sites this method's + /// row feeds — raise-button affordability, footer State A/B, per-row + /// tooltip, section-header bucketing, live-refresh-on-quality-change, + /// and the selection-highlight swap — for a purely cosmetic slice whose + /// authored numbers this hand-built path can already hit byte-exact + /// (proven by CharacterPanelLiveDatTests.AttributeRowTemplate_...). + /// That is a large-blast-radius rewrite for a geometry-only fix; the + /// smaller-risk path is implementing the authored constants directly + /// here, which this method now does. /// private static UiClickablePanel AddRow( UiElement list, @@ -1628,9 +1736,7 @@ public static class CharacterStatController string nameText, Func valueProvider, Func? valueColorProvider = null, - Vector4? nameColor = null, - uint backgroundSprite = 0u, - bool useSelectionBars = true) + Vector4? nameColor = null) { var row = new UiClickablePanel { @@ -1649,23 +1755,20 @@ public static class CharacterStatController Width = width, Height = height, BackgroundColor = Vector4.Zero, // transparent until selected - BackgroundSprite = spriteResolve is not null ? backgroundSprite : 0u, - SpriteResolve = spriteResolve is not null && backgroundSprite != 0u - ? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); } - : null, + BackgroundSprite = 0u, + SpriteResolve = null, BorderColor = Vector4.Zero, - UseSelectionBars = useSelectionBars, Anchors = AnchorEdges.Left | AnchorEdges.Top, }; - float iconY = (height - IconSize) * 0.5f; - + // Icon 0x10000129: flush left (X=0), Y=0, 20x20 — full row height, + // no vertical centering math needed since RowIconSize == RowHeight. var iconEl = new UiText { - Left = RowPadX, - Top = iconY, - Width = IconSize, - Height = IconSize, + Left = RowIconX, + Top = 0f, + Width = RowIconSize, + Height = RowIconSize, ClickThrough = true, DatFont = null, BackgroundSprite = spriteResolve is not null ? iconDid : 0u, @@ -1676,17 +1779,15 @@ public static class CharacterStatController Anchors = AnchorEdges.Left | AnchorEdges.Top, }; - float nameX = RowPadX + IconSize + IconGap; - float nameW = width * 0.60f; - float nameY = 0f; - + // Name 0x1000012A: X=25 W=150 — fixed authored pixels, not a + // width-relative fraction. string capturedName = nameText; Vector4 capturedNameColor = nameColor ?? Body; var nameEl = new UiText { - Left = nameX, - Top = nameY, - Width = nameW, + Left = RowNameX, + Top = 0f, + Width = RowNameW, Height = height, DatFont = datFont, ClickThrough = true, @@ -1697,14 +1798,14 @@ public static class CharacterStatController }; nameEl.LinesProvider = () => new[] { new UiText.Line(capturedName, capturedNameColor) }; - float valueW = width - nameX - nameW - RowPadX; - float valueX = nameX + nameW; - + // Value 0x1000012B: X=175 W=100, right-justified — its right edge + // (275) sits 7px short of the row's own 282px right edge, the + // authored gutter the owner reported. var valueEl = new UiText { - Left = valueX, - Top = nameY, - Width = valueW > 0f ? valueW : 40f, + Left = RowValueX, + Top = 0f, + Width = RowValueW, Height = height, DatFont = datFont, ClickThrough = true, diff --git a/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs b/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs index 7546525f..7b0ede81 100644 --- a/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs +++ b/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs @@ -71,15 +71,18 @@ public sealed class RetailAppraisalNameResolver public string ResolveCreature(int creatureType) => _creatures.Resolve(creatureType); + // Campaign CT slice CT5 (2026-08-25): the three hardcoded overrides this + // method used to re-implement inline (2/5/13 -> "Gharu'ndim"/"Umbraen"/ + // "Olthoi") are dead duplication — CharacterIdentityText.HeritageGroupDisplayName + // already bakes the identical AppraisalSystem::InqHeritageGroupDisplayName + // overrides into its own switch (including id 12, which this method used + // to leave to the fallback branch and get the same "Olthoi" answer + // anyway). One owner for the override table now; behavior is unchanged + // (byte-identical for every heritage id — verified by the existing + // GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain pin, + // which exercises the shared table directly). public string ResolveHeritage(int heritageGroup) - => heritageGroup switch - { - 2 => "Gharu'ndim", - 5 => "Umbraen", - 13 => "Olthoi", - _ => CharacterIdentityText.HeritageGroupDisplayName(heritageGroup) - ?? string.Empty, - }; + => CharacterIdentityText.HeritageGroupDisplayName(heritageGroup) ?? string.Empty; public string ResolveMaterial(int materialType) => materialType > 0 diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 0bfa426a..dc8539a3 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -4038,6 +4038,17 @@ public sealed class RetailUiRuntime : IDisposable } CharacterSheetProvider provider = _bindings.Character.Provider; CharacterSheet currentSheet = provider.BuildSheet(); + // CT5 (AP-235): resolve per-attribute/per-vital row icon DIDs through + // the live DBObj::GetDIDByEnum chain (RetailDataIdResolver.Resolve) + // instead of leaving CharacterStatController's hardcoded fallback as + // the only source. DatCollection is documented not thread-safe, so + // this closure takes the same DatLock every other dat-touching + // delegate below (TitleTemplateResolver/TitleResolver) already uses. + uint IconDidResolve(uint enumValue, uint category) + { + lock (_bindings.Assets.DatLock) + return RetailDataIdResolver.Resolve(_bindings.Assets.Dats, enumValue, category); + } Action refreshRows = CharacterStatController.Bind( layout, () => currentSheet, @@ -4045,7 +4056,8 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Assets.ResolveFont(0x40000001u) ?? _bindings.Assets.DefaultFont, _bindings.Assets.ResolveSprite, (request, completed) => HandleCharacterRaise(provider, request, completed), - () => CloseWindow(WindowNames.Character)); + () => CloseWindow(WindowNames.Character), + IconDidResolve); // #431-CA5 gate fix: rebuild the ROWS on every authoritative sheet // change, not only on clicks — a train's skill record must move the // row to the trained section (and un-ghost the raise controls) the diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs index f346432f..5ad5b063 100644 --- a/src/AcDream.App/UI/UiPanel.cs +++ b/src/AcDream.App/UI/UiPanel.cs @@ -26,8 +26,10 @@ public class UiPanel : UiElement /// Optional dat RenderSurface id for the panel background sprite, drawn /// in place of (or alongside) . 0 = none. - /// When set, the sprite is stretched to fill the panel rect. - /// Used by the attribute-list selected-row highlight (sprite 0x06001397 = Button state 6). + /// When set, the sprite is stretched to fill the panel rect — the same + /// full-swap semantics retail's InfoRegion::SetState uses. Used by + /// the character-panel attribute/skill row's selected-row highlight + /// (sprite 0x06000F93, template 0x10000248's Highlight-state media). public uint BackgroundSprite { get; set; } /// Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height). @@ -191,29 +193,22 @@ public class UiSimpleButton : UiPanel /// parent window. In acdream we wire the equivalent via this action callback instead of /// the retail message bus. /// -/// When is true and -/// is non-zero, draws the sprite as a thin full-width bar at the TOP and BOTTOM edges of -/// the row (not stretched to fill). This matches retail's selection highlight which shows -/// a horizontal dark bar on both the top and bottom edge of the selected attribute row, -/// with NO left/right end-caps. Bar height is pixels -/// (default 3px). +/// Campaign CT slice CT5 (2026-08-25): the selected-row highlight draws through the +/// inherited full-panel +/// stretch — no override here. This class previously had its own "selection bars" draw +/// mode (a thin top/bottom-bar rendering tuned to look like sprite 0x06001397's dark +/// bars); CT1's ground-truth research found that sprite belongs to a DIFFERENT retail +/// mechanism entirely (the spellbook row's overlay child), and the row's actual retail +/// Highlight state (InfoRegion::SetState, media 0x06000F93) is a plain full-row +/// background SWAP — exactly what the inherited already +/// draws. The bars mode was therefore retired rather than reconfigured to a wrong sprite's +/// geometry; no consumer outside CharacterStatController ever set it. /// public class UiClickablePanel : UiPanel { /// Called when the user releases the left mouse button over this panel. public Action? OnClick { get; set; } - /// When true and is non-zero, draws - /// the sprite as a thin horizontal bar at the top AND bottom edges of the panel, - /// NOT as a full-height stretched fill. Matches retail's selected-row highlight - /// (sprite 0x06001397 — 300×32 px — shown as bars, not a block fill). - /// Default false (preserves legacy full-stretch behavior). - public bool UseSelectionBars { get; set; } - - /// Height in pixels of each selection bar (top and bottom). Default 3px. - /// Ignored when is false. - public float SelectionBarHeight { get; set; } = 3f; - /// Settable tooltip, surfaced through the shared /// hover pipeline (same pattern as /// / ). TS-85's @@ -250,33 +245,4 @@ public class UiClickablePanel : UiPanel } return false; } - - protected override void OnDraw(UiRenderContext ctx) - { - if (UseSelectionBars && BackgroundSprite != 0 && SpriteResolve is { } sr) - { - // Draw the selection highlight as a thin bar at the TOP and BOTTOM of the row. - // The sprite (0x06001397) is 300×32 px — we draw it as horizontal strips at - // native height (SelectionBarHeight), stretched to full panel width (UV tile - // horizontally). No left/right end-caps: u0=0, u1=Width/nativeW (UV repeat). - var (tex, tw, th) = sr(BackgroundSprite); - if (tex != 0 && tw > 0 && th > 0) - { - float barH = SelectionBarHeight; - float uTile = tw > 0 ? Width / tw : 1f; - // Top bar: shows the top barH px of the sprite (v = 0 → barH/th). - float vBot = th > 0 ? barH / th : 1f; - ctx.DrawSprite(tex, 0f, 0f, Width, barH, 0f, 0f, uTile, vBot, Vector4.One); - // Bottom bar: shows the bottom barH px of the sprite (v = 1−barH/th → 1). - float vTop2 = th > 0 ? 1f - barH / th : 0f; - ctx.DrawSprite(tex, 0f, Height - barH, Width, barH, 0f, vTop2, uTile, 1f, Vector4.One); - } - // Selection-bar mode draws no border (rows have BorderColor=Zero by design). - } - else - { - // Default UiPanel draw: handles BackgroundSprite, BackgroundColor, AND border. - base.OnDraw(ctx); - } - } } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs index 032eb077..3eae10da 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.App.UI.Layout; +using AcDream.Content; using DatReaderWriter; namespace AcDream.App.Tests.UI.Layout; @@ -511,4 +512,55 @@ public sealed class CharacterPanelLiveDatTests Assert.Equal("Player Killer Lite", pkLite); Assert.Equal("Non-Player Killer", npk); } + + /// + /// Campaign CT slice CT5 (AP-235): gmAttributeUI::PostInit @0x0049DB70 + /// resolves each row's icon DID via DBObj::GetDIDByEnum(statEnum, category + /// 0x10000002) for the 6 primary attributes (statId order 1,2,4,3,5,6 — + /// matching CharacterStatController.AttrRows' authored display order) + /// and DBObj::GetDIDByEnum(vitalEnum, category 0x10000003) for the 3 + /// vitals' Attribute2ndInfoRegion icon (verified live: the max-vital + /// enum ids 1/3/5 that CharacterStatController.VitalRows already + /// stores resolve to the SAME DID as the current-vital ids 2/4/6 retail's + /// own decomp literally passes, so either works). Both categories route + /// through the shared RetailDataIdResolver.Resolve master-map -> + /// category-map -> value chain — this pin proves every hardcoded fallback + /// DID in AttrRows/VitalRows already matches the live-resolved + /// value byte-exact, the same "regression guard, not a bug pin" pattern as + /// . + /// + [InstalledDatFact] + public void AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + + const uint attributeIconCategory = 0x10000002u; + (uint statId, uint expectedDid)[] attributes = + { + (1u, 0x060002C8u), // Strength + (2u, 0x060002C4u), // Endurance + (4u, 0x060002C9u), // Coordination + (3u, 0x060002C6u), // Quickness + (5u, 0x060002C5u), // Focus + (6u, 0x060002C7u), // Self + }; + foreach (var (statId, expectedDid) in attributes) + { + uint resolved = RetailDataIdResolver.Resolve(dats, statId, attributeIconCategory); + Assert.Equal(expectedDid, resolved); + } + + const uint vitalIconCategory = 0x10000003u; + (uint maxStatId, uint expectedDid)[] vitals = + { + (1u, 0x06004C3Bu), // Health + (3u, 0x06004C3Cu), // Stamina + (5u, 0x06004C3Du), // Mana + }; + foreach (var (maxStatId, expectedDid) in vitals) + { + uint resolved = RetailDataIdResolver.Resolve(dats, maxStatId, vitalIconCategory); + Assert.Equal(expectedDid, resolved); + } + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 8640fd93..26ce051a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -747,8 +747,12 @@ public class CharacterStatControllerTests [Fact] public void RowClick_WithSpriteResolve_SelectedRowHasHighlightSprite() { - // When spriteResolve is provided, the selected row must use sprite 0x06001397 - // (retail Button-state-6 dark bar) instead of the translucent gold BackgroundColor. + // CT5 SEALED VERDICT (docs/research/2026-08-24-campaign-ct-dat-ground-truth.md + // §2): the row template's Highlight-state media is 0x06000F93 + // (gmAttributeUI::UpdateSelection @0x0049DEE0 SetState(6) -> + // InfoRegion::SetState @0x004F0EE0 on template 0x10000248) — NOT + // 0x06001397, which belongs to the spellbook row's separate + // selected-overlay mechanism (SpellbookRowStyle.cs, untouched here). var list = new UiPanel(); var layout = Fake((CharacterStatController.ListBoxId, list)); @@ -761,7 +765,7 @@ public class CharacterStatControllerTests var rows = list.Children.OfType().ToList(); rows[2].OnClick!(); - Assert.Equal(0x06001397u, rows[2].BackgroundSprite); // selected → sprite + Assert.Equal(0x06000F93u, rows[2].BackgroundSprite); // selected → sprite Assert.Equal(0f, rows[2].BackgroundColor.W); // no tint Assert.Equal(0u, rows[0].BackgroundSprite); // others cleared Assert.Equal(0u, rows[1].BackgroundSprite); @@ -1110,7 +1114,6 @@ public class CharacterStatControllerTests { Assert.Equal(Vector4.Zero, row.BackgroundColor); Assert.Equal(0u, row.BackgroundSprite); - Assert.True(row.UseSelectionBars); }); var rowNames = rows .Select(r => r.Children.OfType().ToList()[1].LinesProvider()[0].Text) @@ -1326,11 +1329,15 @@ public class CharacterStatControllerTests Assert.Equal((11_100_000L).ToString("N0"), l1Value.LinesProvider()[0].Text); Assert.Equal("Unassigned Experience:", l2Label.LinesProvider()[0].Text); Assert.Equal((87_757_321_741L).ToString("N0"), l2Value.LinesProvider()[0].Text); - Assert.Equal(0x06001397u, rows[1].BackgroundSprite); - Assert.True(rows[1].UseSelectionBars); + // CT5: RowHighlightSprite corrected to 0x06000F93 (see the sealed + // verdict on RowClick_WithSpriteResolve_SelectedRowHasHighlightSprite + // above); UseSelectionBars was retired outright (retail's actual + // Highlight state is a full-row background swap, which the + // inherited UiPanel.OnDraw already renders — no bars-only mode + // needed). + Assert.Equal(0x06000F93u, rows[1].BackgroundSprite); Assert.Equal(Vector4.Zero, rows[1].BackgroundColor); Assert.Equal(0u, rows[0].BackgroundSprite); - Assert.True(rows[0].UseSelectionBars); Assert.Equal(Vector4.Zero, rows[0].BackgroundColor); }