feat(ui): Campaign CT slice CT5 — attribute/skill row geometry + selection media
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 <noreply@anthropic.com>
This commit is contained in:
parent
01e44a7018
commit
f532f28c5b
7 changed files with 266 additions and 125 deletions
|
|
@ -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<char>*) @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<char>*) @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` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
/// <summary>Row highlight color — semi-translucent gold, matches retail
|
||||
/// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent.</summary>
|
||||
/// <summary>Row highlight FALLBACK color — used only when
|
||||
/// <c>spriteResolve</c> is unavailable (tests, headless mode) and the
|
||||
/// real <see cref="RowHighlightSprite"/> 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.</summary>
|
||||
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 <c>null</c> in tests where
|
||||
/// icon rendering is not asserted.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// CT5: <paramref name="iconDidResolve"/> ports <c>DBObj::GetDIDByEnum(enumValue,
|
||||
/// category)</c> (master map -> category map -> value) for the per-attribute/
|
||||
/// per-vital row icon DIDs — pass <c>(enumValue, category) =>
|
||||
/// RetailDataIdResolver.Resolve(dats, enumValue, category)</c> under the
|
||||
/// caller's dat lock. <c>null</c> (tests, or no live dat) falls back to
|
||||
/// <see cref="AttrRows"/>/<see cref="VitalRows"/>' hardcoded DID column,
|
||||
/// which the CT5 InstalledDat pin proves already matches the live-resolved
|
||||
/// value byte-exact.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// #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<uint, (uint handle, int w, int h)>? spriteResolve = null,
|
||||
RaiseRequestHandler? onRaiseRequest = null,
|
||||
Action? onClose = null)
|
||||
Action? onClose = null,
|
||||
Func<uint, uint, uint>? 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CT5: the row's own rendered width is the AUTHORED row-template width
|
||||
/// (282px, <see cref="SkillContentWidth"/>), never the ListBox's raw
|
||||
/// container width (300px, <see cref="CharacterStatController.ListBoxId"/>'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.
|
||||
/// </summary>
|
||||
private static float RowContentWidth(UiElement list)
|
||||
=> list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth;
|
||||
|
||||
/// <summary>
|
||||
/// CT5 (AP-235): resolve a row icon DID through the live
|
||||
/// <c>DBObj::GetDIDByEnum</c> chain when a resolver is available,
|
||||
/// falling back to the hardcoded <see cref="AttrRows"/>/<see cref="VitalRows"/>
|
||||
/// column value otherwise (tests, or a resolve miss).
|
||||
/// </summary>
|
||||
private static uint ResolveIconDid(
|
||||
Func<uint, uint, uint>? 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<UiClickablePanel> BuildAttributeRows(
|
||||
|
|
@ -833,9 +928,10 @@ public static class CharacterStatController
|
|||
int[] sel,
|
||||
List<UiButton> allRaise1,
|
||||
List<UiButton> allRaise10,
|
||||
Action<bool> setFooterSelected)
|
||||
Action<bool> setFooterSelected,
|
||||
Func<uint, uint, uint>? iconDidResolve)
|
||||
{
|
||||
float listW = list.Width;
|
||||
float listW = RowContentWidth(list);
|
||||
float y = 0f;
|
||||
var rows = new List<UiClickablePanel>();
|
||||
|
||||
|
|
@ -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<bool> setFooterSelected,
|
||||
out List<SkillRowBinding> skillRows)
|
||||
{
|
||||
float listW = list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth;
|
||||
float listW = RowContentWidth(list);
|
||||
float y = 0f;
|
||||
var entries = new List<UiElement>();
|
||||
var bindings = new List<SkillRowBinding>();
|
||||
|
|
@ -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
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a single attribute/vital row to <paramref name="list"/> as a
|
||||
/// <see cref="UiClickablePanel"/> containing icon + name + value children.
|
||||
/// Returns the panel so the caller can wire <see cref="UiClickablePanel.OnClick"/>.
|
||||
/// Add a single attribute/vital/skill row to <paramref name="list"/> as a
|
||||
/// <see cref="UiClickablePanel"/> 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 <see cref="UiClickablePanel.OnClick"/>.
|
||||
///
|
||||
/// <para>CT5 hand-built-vs-template ruling: this row stays HAND-BUILT
|
||||
/// (not converted to <c>UiTemplateListBox</c> 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 <c>CharacterPanelLiveDatTests.AttributeRowTemplate_...</c>).
|
||||
/// 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.</para>
|
||||
/// </summary>
|
||||
private static UiClickablePanel AddRow(
|
||||
UiElement list,
|
||||
|
|
@ -1628,9 +1736,7 @@ public static class CharacterStatController
|
|||
string nameText,
|
||||
Func<string> valueProvider,
|
||||
Func<Vector4>? 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -26,8 +26,10 @@ public class UiPanel : UiElement
|
|||
|
||||
/// <summary>Optional dat RenderSurface id for the panel background sprite, drawn
|
||||
/// in place of (or alongside) <see cref="BackgroundColor"/>. 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).</summary>
|
||||
/// When set, the sprite is stretched to fill the panel rect — the same
|
||||
/// full-swap semantics retail's <c>InfoRegion::SetState</c> uses. Used by
|
||||
/// the character-panel attribute/skill row's selected-row highlight
|
||||
/// (sprite 0x06000F93, template 0x10000248's Highlight-state media).</summary>
|
||||
public uint BackgroundSprite { get; set; }
|
||||
|
||||
/// <summary>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.</para>
|
||||
///
|
||||
/// <para>When <see cref="UseSelectionBars"/> is true and <see cref="UiPanel.BackgroundSprite"/>
|
||||
/// 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 <see cref="SelectionBarHeight"/> pixels
|
||||
/// (default 3px).</para>
|
||||
/// <para>Campaign CT slice CT5 (2026-08-25): the selected-row highlight draws through the
|
||||
/// inherited <see cref="UiPanel.OnDraw"/> full-panel <see cref="UiPanel.BackgroundSprite"/>
|
||||
/// 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 (<c>InfoRegion::SetState</c>, media 0x06000F93) is a plain full-row
|
||||
/// background SWAP — exactly what the inherited <see cref="UiPanel.OnDraw"/> already
|
||||
/// draws. The bars mode was therefore retired rather than reconfigured to a wrong sprite's
|
||||
/// geometry; no consumer outside <c>CharacterStatController</c> ever set it.</para>
|
||||
/// </summary>
|
||||
public class UiClickablePanel : UiPanel
|
||||
{
|
||||
/// <summary>Called when the user releases the left mouse button over this panel.</summary>
|
||||
public Action? OnClick { get; set; }
|
||||
|
||||
/// <summary>When true and <see cref="UiPanel.BackgroundSprite"/> 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).</summary>
|
||||
public bool UseSelectionBars { get; set; }
|
||||
|
||||
/// <summary>Height in pixels of each selection bar (top and bottom). Default 3px.
|
||||
/// Ignored when <see cref="UseSelectionBars"/> is false.</summary>
|
||||
public float SelectionBarHeight { get; set; } = 3f;
|
||||
|
||||
/// <summary>Settable tooltip, surfaced through the shared
|
||||
/// <see cref="UiElement.GetTooltipText"/> hover pipeline (same pattern as
|
||||
/// <see cref="UiButton.TooltipText"/> / <see cref="UiCatalogSlot"/>). 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CT slice CT5 (AP-235): <c>gmAttributeUI::PostInit @0x0049DB70</c>
|
||||
/// resolves each row's icon DID via <c>DBObj::GetDIDByEnum(statEnum, category
|
||||
/// 0x10000002)</c> for the 6 primary attributes (statId order 1,2,4,3,5,6 —
|
||||
/// matching <c>CharacterStatController.AttrRows</c>' authored display order)
|
||||
/// and <c>DBObj::GetDIDByEnum(vitalEnum, category 0x10000003)</c> for the 3
|
||||
/// vitals' <c>Attribute2ndInfoRegion</c> icon (verified live: the max-vital
|
||||
/// enum ids 1/3/5 that <c>CharacterStatController.VitalRows</c> 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 <c>RetailDataIdResolver.Resolve</c> master-map ->
|
||||
/// category-map -> value chain — this pin proves every hardcoded fallback
|
||||
/// DID in <c>AttrRows</c>/<c>VitalRows</c> already matches the live-resolved
|
||||
/// value byte-exact, the same "regression guard, not a bug pin" pattern as
|
||||
/// <see cref="SkillSectionHeaderTemplates_MatchExistingSpriteConstants"/>.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<UiClickablePanel>().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<UiText>().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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue