docs(CT): CT1 fix round — sealed RowHighlightSprite verdict, verified resize mechanism, strengthened pins

Applies the Opus dual-lens review corrections to CT1's DAT ground-truth
research (docs/research/2026-08-24-campaign-ct-dat-ground-truth.md):

- Window constraints (BLOCKER): replaced the "likely a hardcoded
  ResizeTo/SetMinSize" guess with the verified mechanism —
  UIElement::ResizeTo clamps only via element attributes 0x3C-0x3F,
  nothing writes them at runtime, and retail resizes the SHARED
  gmPanelUI host (LayoutDesc 0x2100006E, slot 0x1000018E) rather than
  0x2100002E's own content root. Flags the unresolved 300x600-vs-300x362
  size tension for CT3/CT6 and marks the host elements NOT PROBED by
  CT1.
- RowHighlightSprite upgraded from a flagged hedge to a SEALED VERDICT:
  the stat row's selected-state media is 0x06000F93
  (gmAttributeUI::UpdateSelection -> InfoRegion::SetState on template
  0x10000248), not 0x06001397 (which is legitimately the spellbook
  row's separate selected-overlay mechanism). Falsifies the matching
  comment in CharacterStatController.cs and dated-corrects the older
  2026-06-26 doc at the spot that originated the wrong sprite id.
- Replaced the "18px gutter + 7px = 25px" derived story with the bare
  authored rectangles (the numbers don't compose cleanly: 300-281=19,
  and the 282px row overlaps the 281px scrollbar band by 1px) — CT5
  must implement the authored numbers directly, never a derived
  listWidth-18 formula.
- Plan doc: corrected the UpdateButtons ghost rule (no selection ->
  Ghosted, not "ghosts when selected == current") and added the
  AddTitleToList row-write contract for CT3.
- Pins: CharacterPanelLiveDatTests now honors ACDREAM_DAT_DIR first
  (matching InstalledDatFactAttribute and its sibling live-DAT test
  classes), hoists five vacuous bare-foreach assertions to counted
  .ToList() pins, and adds the stat ListBox + scrollbar rect pins that
  CT5/CT6 depend on.
- Doc hygiene: marked several probe-session observations (header
  geometry "identical" claim, 0x06004CC2 characterization, the
  master-map/category-map dump) as unpinned inference vs. committed
  fact, corrected the 0x1000052D "throwaway container" mislabel, and
  stated the header table's parent-relative coordinate frame.
- Recorded the CT5 gold this round found: InfoRegion::InfoRegion's
  icon-DID lookup (a third GetDIDByEnum consumer, category
  0x10000002) and gmSkillUI::RebuildSkillList's section-header order
  confirmation, plus the RowHeight=22-vs-authored-20 divergence for
  attribute rows.

Verified: ACDREAM_RUN_INSTALLED_DAT_TESTS=1 CharacterPanelLiveDatTests
filter 9/9 green; hermetic App suite filter (CI's Lane exclusion list)
6111/6111 green. No production code changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-24 21:58:27 +02:00
parent d3877f1c0e
commit e264d8392f
4 changed files with 297 additions and 65 deletions

View file

@ -31,6 +31,20 @@ the remaining polish on acdream's `CharacterStatController` (LayoutDesc 0x210000
- **Selected row (Strength):** highlighted with a **DARKER background + bars above/below** — retail's
selected-row sprite `0x06001397` (Button state 6). (✗ acdream uses a translucent GOLD tint — replace
with the dark-bar sprite.)
> **CORRECTION 2026-08-24 (Campaign CT, CT1 fix round).** The sprite id
> above is WRONG for this element. Campaign CT's live-DAT probe
> (`docs/research/2026-08-24-campaign-ct-dat-ground-truth.md`, §2
> "SEALED VERDICT") found the attribute/skill row's actual Highlight
> media is `0x06000F93`, drawn via `gmAttributeUI::UpdateSelection
> @0x0049DEE0`'s `SetState(6)` → `InfoRegion::SetState @0x004F0EE0` on
> the row template `0x10000248` itself. `0x06001397` is real, but it
> belongs to a DIFFERENT mechanism: the spellbook row's separate
> selected-overlay child element (`0x10000342` under prototype
> `0x10000343`, via `UIElement_UIItem::SetSelectedState @0x004E1240`).
> This note's "Button state 6" framing was accidentally right about the
> STATE number but wrong about which sprite that state resolves to on
> this element. CT5 is the owning slice for the fix.
- **Footer flips to:**
- Title: **"Strength: 200"** — **WHITE** text (✗ acdream uses the body/gold color).
- "Experience To Raise:" + **"Infinity!"** (Strength is maxed → cost is infinite; ✗ acdream shows a
@ -45,7 +59,8 @@ the remaining polish on acdream's `CharacterStatController` (LayoutDesc 0x210000
3. [ ] Add "Total Experience (XP):" caption.
4. [ ] Add "XP for next level:" caption + value (un-consume from the meter, or render alongside).
5. [ ] Row text larger (≈icon height) + rows tighter.
6. [ ] Selection highlight → sprite 0x06001397 (dark bars), not gold tint.
6. [ ] Selection highlight → sprite 0x06000F93 (dark bars), not gold tint. (Corrected 2026-08-24 —
see the CORRECTION note above; the sprite id in this checklist item was originally 0x06001397.)
7. [ ] Selected footer title → white.
8. [ ] Maxed attribute → "Experience To Raise: Infinity!".
9. [ ] Footer title wording = "Select an Attribute to Improve" (Attribute).

View file

@ -19,8 +19,21 @@ through the Attributes page chain (`0x10000227 > 0x1000022B > 0x10000226 >
0x10000230`) and one through the Skills page chain (`... > 0x1000022C >
...`). **The two copies are geometrically and stylistically IDENTICAL**
this is the "duplicated stat-management branches" quirk documented on
`PrepareSkillScrollbar` in `CharacterStatController.cs`, confirmed here to
be a harmless duplicate (not a divergent one) for every header id checked.
`PrepareSkillScrollbar` in `CharacterStatController.cs`. **Caveat: this
"identical" claim rests on the deleted probe tests, not the committed
pins.** `CharacterPanelLiveDatTests.HeaderElements_AuthorExpectedFontsAndColors`
only asserts font/color/margin equality across the two copies (and the
2-count) — it does not assert X/Y/W/H geometric equality between them.
Treat "geometrically identical" as probe-session observation, not a
pinned fact, until a future slice adds a geometry-equality assertion.
**Coordinate frame:** every `X,Y` below is **parent-relative**, not
page-relative — e.g. Name `0,0` means the top-left of its immediate
parent container (`0x10000230`), not the top-left of the Attributes/
Skills page. Cross-reference
`docs/research/2026-06-25-character-window-faithful-spec.md` for the
page-relative numbers if you need the header block's position within
the page itself; do not mix the two frames when placing elements.
| Element | Id | X,Y | W×H | HJustify | VJustify | FontDid | FontColor | Outline | Margins |
|---|---|---|---|---|---|---|---|---|---|
@ -110,23 +123,59 @@ for item 1 (icon alignment) and item 2 (value-column gutter):
— this is the "authored margin between the value column and the
border" the owner reported (item 2), confirmed as exactly 7px at the
row-template level.
- **The row (282px) is already inset from the ListBox's full width
(300px) by 18px** to clear the always-reserved scrollbar gutter
(scrollbar at X=281, W=16) — so the TOTAL space between the value
text's right edge and the ListBox's outer right edge is
`300 - 275 = 25px`, of which 18px is the permanent scrollbar gutter and
7px is the row template's own inset. Both numbers matter for CT5:
the row width (282, confirmed correct — matches the existing
`SkillContentWidth = 282f` constant already in the code) and the
internal 175/100 value-column placement (not currently matched).
- Row background: `Normal` state file `0x06004CC2` (the same generic
panel-chrome fill used elsewhere client-wide), `Highlight` state file
**`0x06000F93`**. `CharacterStatController.RowHighlightSprite` is
currently `0x06001397u`**this is a divergent constant**; CT5 should
either confirm `0x06001397` is deliberately used for a DIFFERENT
highlight surface (e.g. the vitals/skill list uses a shared sprite
elsewhere) or correct it to `0x06000F93` for the attribute/skill row
highlight specifically. Flagged, not fixed, in this slice.
- **The bare authored rectangles** (no derived arithmetic — see the
caveat below): ListBox `0x1000023D` is `X=0 Y=112 W=300 H=160`; its
scrollbar `0x1000023E` is `X=281 W=16 H=160`; the data row template is
`W=282`; the value column inside the row is `X=175 W=100`,
right-justified, 7px from the row's own right edge (`282 - (175+100) =
7`).
- **These numbers do NOT compose into a tidy "gutter" story — do not
infer one.** `300 - 281 = 19`, not the row's 282px width's complement
(`300 - 282 = 18`); and the 282px row actually OVERLAPS the 281px
scrollbar band by 1px (`281 < 282`). An earlier draft of this doc
described the row as "inset by 18px to clear an always-reserved
scrollbar gutter" and summed 18+7=25 — that decomposition does not
close against the authored numbers above and is **inference, not
fact**; withdrawn. CT5 must implement the authored numbers directly
(row width 282 — matches the existing `SkillContentWidth = 282f`
constant already in the code; value column X=175 W=100; scrollbar
X=281 W=16), never a derived `listWidth - 18` or similar formula.
- Row background: `Normal` state file `0x06004CC2`. **The description
"generic panel-chrome fill used elsewhere client-wide" is UNVERIFIED**
— no cross-reference sweep for other consumers of `0x06004CC2` was run
this slice; only the file id itself, as authored on this specific
template, is pinned. `Highlight` state file **`0x06000F93`**.
### SEALED VERDICT: RowHighlightSprite is wrong, not merely flagged
Retail's selected attribute/skill row draws the row template's Highlight
state media — `0x06000F93`, not `CharacterStatController`'s current
`0x06001397u`. This is no longer a hedge; the decomp confirms the
mechanism end to end:
- `gmAttributeUI::UpdateSelection @0x0049DEE0` calls
`SetState(selected ? 6 : 1)` on the row.
- `InfoRegion::SetState @0x004F0EE0` forwards that state to the row
element instantiated from template `0x10000248` — the exact template
this slice dumped, whose Highlight-state file is `0x06000F93`.
- State `6` IS `UIStateId.Highlight` — so the selected row draws
`0x10000248`'s own `Highlight` media, not a separately-chosen sprite.
`CharacterStatController.cs`'s comment near lines 111113 ("matches
retail... sprite 0x06001397 visual intent") is **falsified for this
element**. CT5 must correct `RowHighlightSprite` to `0x06000F93` for the
STAT rows and should reconsider `UseSelectionBars`/`HighlightBg`, which
currently emulate the wrong sprite's art (a translucent gold tint tuned
to look like `0x06001397`'s dark bars, not `0x06000F93`'s actual look).
**`0x06001397` is not a phantom constant — it is legitimate ELSEWHERE.**
The spellbook row prototype `0x10000343` has a separate selected-overlay
CHILD element `0x10000342` whose media IS `0x06001397`
(`UIElement_UIItem::SetSelectedState @0x004E1240` mechanism — a
different code path from `InfoRegion::SetState`, and a different visual
composition: an overlay child, not a state-swap on the row itself). CT5
must correct the STAT rows ONLY and must NOT touch `SpellbookRowStyle.cs`
or its tests — `0x06001397` is correct there.
### 0x10000249 / 0x1000024A / 0x1000024B / 0x1000024C — skill SECTION HEADER captions
@ -151,6 +200,38 @@ for the existing constants already in `CharacterStatController.cs`:
These four are correctly ported already; no CT5 work needed here.
### Row-height divergence (CT5 gold, found this fix round)
The row template above authors `H=20` (line `Id=0x10000248 Type=3
(container) X=0 Y=0 W=282 H=20`, already pinned by
`CharacterPanelLiveDatTests.AttributeRowTemplate_...`). Current code
matches this for skill rows (`CharacterStatController.SkillRowHeight =
20f`) but NOT for attribute rows, which use a separate
`CharacterStatController.RowHeight = 22f` constant. CT5 must fix the
attribute-row path to 20px; there is no authored basis for 22 anywhere
in the row template.
### Row instantiation + icon-DID anchors (CT5 gold, found this fix round)
`InfoRegion::InfoRegion @0x004F1450` instantiates each stat row via
`AddItemFromTemplateList(listBox, 0, ...)` — template index **0**, i.e.
`0x10000248`, the shared data row confirmed above — and binds
`0x1000012A` (label), `0x1000012B` (value), `0x10000129` (icon) via
`UIRegion::SetImageByDID(icon, did, 3)` (icon draw mode 3). Per-attribute
icon DIDs come from `DBObj::GetDIDByEnum(statEnum, category
0x10000002)` in `gmAttributeUI::PostInit @0x0049DB70` — a THIRD consumer
of the `GetDIDByEnum` master-map mechanism documented in §5 below
(alongside the title EnumMapper/StringTable pair and `RetailKeyNames`),
which is enough precedent that CT2/CT5 should factor a shared
`GetDIDByEnum(enumValue, category)` helper instead of hardcoding a third
independent DID pair.
`gmSkillUI::RebuildSkillList @0x0049C3A0` adds template indices **14**
(`0x10000249..0x1000024C`) for the section-header captions, confirming
the section-header order already pinned by
`SkillSectionHeaderTemplates_MatchExistingSpriteConstants` above:
Specialized, Trained, Untrained, Unusable.
## 3. Titles page (0x10000539 subtree, imported as part of 0x2100002E)
`0x10000539` (Type 0x10000046, the Titles page container) is NOT
@ -188,10 +269,19 @@ scrollbar-gutter inset because the ListBox width itself already excludes
the scrollbar column).
Same targeted-root import gotcha as the stat templates applies here:
`LayoutImporter.ImportInfos(dats, 0x2100005Eu)` (the whole-layout overload)
returns the SAME element (0x1000052D, root of a throwaway container) but
does NOT surface 0x10000536 as a reachable child — use
`LayoutImporter.ImportInfos(dats, 0x2100005Eu, 0x10000536u)` instead.
`LayoutImporter.ImportInfos(dats, 0x2100005Eu)` (the whole-layout
overload) returns the SAME element `0x1000052D`, but does NOT surface
`0x10000536` as a reachable child. **`0x1000052D` is not a throwaway
container** — per the `BaseElement`/`BaseLayoutId` table in
`docs/research/2026-06-25-character-window-faithful-spec.md` (line
~19), `0x1000052D` is the authored `BaseElement` that the Titles page
root `0x10000539` inherits its content from (`0x10000539`'s
`BaseLayoutId` is `0x2100005E`, `BaseElement` is `0x1000052D`) — it is
the real authored Titles-page content, just reached by a different path
than the live-mounted tree. Use
`LayoutImporter.ImportInfos(dats, 0x2100005Eu, 0x10000536u)` (the
targeted single-root overload) to reach the row template `0x10000536`
underneath it.
## 4. Window min/max constraints
@ -234,17 +324,64 @@ window-frame elements are its own self-contained LayoutDesc, while
0x2100002E is CONTENT ONLY (tab bar + pages), with retail's window chrome
supplied by a separate mechanism.
**Implication for CT6:** the character window's authored minimum height
(if one exists in retail) is not going to fall out of 0x2100002E's own
`MinHeight`/`MaxHeight` properties — those are simply absent. CT6 needs
to find where retail's `gmStatManagementUI`/its owning window-frame class
enforces a minimum window size (likely a hardcoded `ResizeTo`/`SetMinSize`
call in that class's C++, or a shared base window-frame behavior applied
uniformly — see the plan's own CT6 wording, "verified as the STANDARD path
for every registered window"). This is NOT a simple "read the DAT
property" fix like `MountSideVitals` was; treat the "Already in-tree"
plan bullet as **inaccurate** and start CT6 from the decomp for the
window-frame class instead of assuming the wiring is already 90% done.
Treat the "Already in-tree" plan bullet as **inaccurate**: start CT6
from the decomp for the window-frame class instead of assuming the
wiring is already 90% done. The paragraph below replaces this doc's
earlier "likely a hardcoded ResizeTo/SetMinSize call" guess with the
verified mechanism.
### Verified resize mechanism (2026-08-24, supersedes the hypothesis above)
`UIElement::ResizeTo @0x00463C30` clamps ONLY via element attributes —
`0x3C` (clamp-max-height), `0x3E` (clamp-min-height), `0x3D`
(clamp-max-width), `0x3F` (clamp-min-width) — read off `this`, the
element actually being resized. A decomp-wide grep for writers of those
four attributes turns up NOTHING: no runtime code ever sets them at
runtime. The clamp source is exclusively **authored DAT properties on
whichever element `ResizeTo` is called against, full stop.** There is no
hardcoded `SetMinSize` call anywhere in the class hierarchy; the earlier
"likely a hardcoded ResizeTo/SetMinSize call" phrasing in this doc was a
guess and is WRONG.
The element `ResizeTo` is called against is not `0x2100002E`'s own root.
Per `docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md`
(lines ~83-107) and the slot table in
`docs/research/2026-08-11-fa-panel-structure.md` (row for `0x1000018E`),
retail's Character/Skills tab content is one child slot inside the
SHARED `gmPanelUI` host, LayoutDesc `0x2100006E`:
```text
gmPanelUI host 0x100005FE 310 x 372
content parent 0x10000180 300 x 362 (anchored all edges)
Character/Skills slot 0x1000018E panel id 11, 300 x 362
top-center Dragbar 0x1000065C Type 2
bottom-center Resizebar 0x10000660 Type 9
```
`gmPanelUI::ResizeTo @0x004BC6E0` is a bare tailcall into
`UIElement::ResizeTo` — the HOST is what gets resized (via its
Resizebar), not `0x2100002E`'s content root; the content root's own
absent MinHeight/MaxHeight (confirmed above) is therefore consistent
with retail's actual mechanism, not evidence of a missing DAT property.
**NOT PROBED by CT1.** The host elements above (`0x100005FE`,
`0x10000180`, `0x1000018E`, `0x1000065C`, `0x10000660`) were read from
the cited pseudocode doc, not re-probed live against the installed DAT
this session. CT6's first step is to probe those host slots directly
(min/max + resize authoring) before porting anything, then read
`UIElement_Resizebar::StartMouseResizing @0x0046B7E0` verbatim for the
drag-time clamp application.
**Size tension for CT3/CT6 to resolve.** `0x2100002E`'s own root is
authored **300×600** (the Titles page alone is 300×575, plus the 25px
tab bar = 600 — §3), but retail mounts that content into the host's
**300×362** slot (`0x1000018E`). CT3's title ListBox height (`0x10000532`
is 270×455 per §3) and CT6's resize contract both assume a taller
available area than the host slot's authored 362px. This doc does not
resolve which number governs at runtime (scroll-clipped content inside a
fixed slot vs. the slot itself growing to accommodate) — CT6 must
resolve it from the decomp before implementing the resize contract, not
infer it from either number in isolation.
## 5. The title-string table (DAT ground truth for `CharacterTitleTable::GetCharacterTitleFromID`)
@ -288,6 +425,15 @@ this investigation.
### Live-DAT resolution (verified end-to-end this session)
**The master-map / category-map tables and the `RetailKeyNames`
cross-validation below are UNPINNED probe output** — they come from the
same deleted `Assert.Fail` probe tests as the rest of this doc and are
not backed by a committed `InstalledDatFact` assertion (unlike the
`TitleStringTable_ResolvesWarMageEndToEnd` pin, which DOES commit the
final two DIDs and the end-to-end string resolution). Treat the
category-4 dump and the `RetailKeyNames` match column as this session's
observation, re-derivable from the DAT but not regression-guarded.
`DatReaderWriter.DBObjs.EnumIDMap` (ACE's historical name: `DidMapper`,
file-type byte `0x25`) is the object type both master and category maps
use; `DatReaderWriter.DBObjs.EnumMapper` (file-type byte `0x22` on the
@ -354,11 +500,15 @@ exactly "War Mage".
map → target DID) — CT2 can either hardcode the two resolved DIDs
(`0x22000041` for the EnumMapper, `0x2300000E` for the StringTable, the
way `RetailKeyNames` hardcodes its three) or port the two-level
indirection generically. Given `RetailKeyNames` already established the
"just hardcode the resolved DIDs, cite the probe" precedent for this
exact category-4 family, CT2 should follow the same precedent unless a
THIRD consumer of `GetDIDByEnum` appears that would justify factoring
out a shared helper.
indirection generically. **A THIRD consumer has now appeared** (found
this fix round): `gmAttributeUI::PostInit @0x0049DB70` resolves
per-attribute icon DIDs via `DBObj::GetDIDByEnum(statEnum, category
0x10000002)` — see the "Row instantiation + icon-DID anchors" note in
§2. With `RetailKeyNames` (category 4) and the title chain (categories
1 and 4) already hardcoding resolved DIDs, this third independent
category (`0x10000002`) is the point where CT2/CT5 should factor a
shared `GetDIDByEnum(enumValue, category)` helper instead of adding a
fourth ad-hoc hardcoded pair.
2. `EnumMapper.IdToStringMap[titleId]` → raw canonical name (already
readable via `dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumMapper>`).
3. `DatStringResolver.ComputeHash(rawName)` (already exists, no new code).
@ -371,14 +521,18 @@ already does steps 34 for other consumers.
## Corrections to the plan (summary)
1. **Window constraints are NOT already 90% wired.** The plan's
"Already in-tree" bullet claims `DatConstraintSource` registration for
the character window; the actual `MountCharacter()` call sets no such
field, and the DAT layout itself authors no MinHeight/MaxHeight on its
root to source one from even if it were wired. CT6 needs decomp
research into where retail's authored minimum for this specific window
actually lives (likely a class-level constant/behavior, not a
per-window DAT property) before it can port anything.
1. **Window constraints are NOT already 90% wired — and the clamp
mechanism is now VERIFIED, not guessed.** The plan's "Already in-tree"
bullet claims `DatConstraintSource` registration for the character
window; the actual `MountCharacter()` call sets no such field, and the
DAT layout itself authors no MinHeight/MaxHeight on its root to source
one from even if it were wired. `UIElement::ResizeTo @0x00463C30`
clamps only via element attributes `0x3C``0x3F`, which nothing writes
at runtime — the clamp source is always authored DAT properties on the
resized element, full stop, and the resized element is the SHARED
`gmPanelUI` host (`0x2100006E`, slot `0x1000018E`), not `0x2100002E`'s
own root. See §4's "Verified resize mechanism" for the full chain and
the unresolved 300×600-vs-300×362 size tension CT6 must still resolve.
2. **The row-template elements are not walkable via the normal
`ImportInfos(dats, layoutId)` overload.** `#375`'s prototype-skip logic
deliberately excludes same-layout template-list targets from the built
@ -387,10 +541,25 @@ already does steps 34 for other consumers.
(`LayoutDesc 0x21000045`) and `0x10000536` (`LayoutDesc 0x2100005E`) —
documented here so CT5 doesn't waste a cycle rediscovering the same
"NOT FOUND" dead end this slice hit first.
3. **`RowHighlightSprite` may already be wrong.** The DAT's row-template
Highlight state uses `0x06000F93`; the current constant in
`CharacterStatController.cs` is `0x06001397`. Not fixed in this slice
(no production changes); flagged for CT5's review.
3. **`RowHighlightSprite` IS wrong — SEALED, not merely flagged.** The
DAT's row-template Highlight state is `0x06000F93`, reached via
`gmAttributeUI::UpdateSelection`'s `SetState(6)`
`InfoRegion::SetState` on the row itself; the current constant in
`CharacterStatController.cs` is `0x06001397`, which belongs to a
DIFFERENT mechanism (the spellbook row's selected-overlay child,
`UIElement_UIItem::SetSelectedState`). CT5 must correct the STAT rows'
`RowHighlightSprite` to `0x06000F93` and must NOT touch
`SpellbookRowStyle.cs` — see §2's "SEALED VERDICT" note for the full
anchor chain.
4. Everything else in the plan's "Retail recon" section (the Titles page
element roster, the header element ids, the PostInit binding order)
checks out exactly against the live DAT — no other corrections.
5. **Several findings above are probe-session observations, not
committed pins** — flagged this fix round so CT2CT6 don't cite them
as regression-guarded facts: the header block's "two copies
geometrically identical" claim (only fonts/colors are pinned, not
full geometry — §1), the `0x06004CC2` "generic panel chrome"
characterization (§2), and the master-map/category-map dump plus the
`RetailKeyNames` cross-validation table (§5). The title chain's final
two DIDs and end-to-end string resolution ARE pinned
(`TitleStringTable_ResolvesWarMageEndToEnd`).