docs #CT1: character-panel DAT ground truth + InstalledDat pins

Campaign CT slice CT1 (docs/plans/2026-08-24-character-panel-parity-campaign.md):
establishes the authored ground truth for LayoutDesc 0x2100002E that CT2-CT6
build against, so those slices port against verified DAT facts instead of
guessing. No production code changed.

Header findings: the PK line is authored pure white (the CT4 bug is in
CharacterStatController's runtime color choice, not a DAT gap); the level
color is a pale-gold (1, 0.949, 0.498) WITH an authored outline, diverging
from the current hardcoded Gold constant. The stat ListBox's row-template
list (LayoutDesc 0x21000045) is unreachable via the whole-layout
ImportInfos overload (the #375 same-layout template-list skip filter) --
the targeted ImportInfos(dats, layoutId, elementId) overload is required,
same as UiTemplateListBox's TemplateResolver already uses. The shared
attribute/skill row template (0x10000248) authors a 20x20 icon flush at
X=0 (current code: 16x16 at X=4), fixed 150px/100px name/value columns at
X=25/X=175 (current code: a width-fraction split), and a 7px gap between
the value's right edge and the row's own edge -- the scrollbar-gutter
margin the owner reported missing. The Titles page roster, row template
(LayoutDesc 0x2100005E), and window constraints are also pinned; the
character window's root authors NO min/max size properties at all (unlike
chat's self-contained window layout), and RetailUiRuntime.MountCharacter
never wires DatConstraintSource -- correcting the plan's "already in-tree"
claim for CT6.

Also derives and pins the full CharacterTitleTable::GetCharacterTitleFromID
chain (title id -> EnumMapper(0x22000041) canonical name -> compute_str_hash
-> StringTable(0x2300000E) localized text), resolved via the two-level
DBObj::GetDIDByEnum master-map indirection (0x25000000 -> category map ->
target DID) and verified end to end against ACE's CharacterTitle.WarMage=13
-> "War Mage". This independently cross-validates RetailKeyNames' existing
0x2300000A/0x2300000B/0x23000007 constants, which turn out to be the same
category-4 map's enum 4/5/3 entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-24 21:31:16 +02:00
parent 45e276d380
commit ca4100e76a
2 changed files with 747 additions and 0 deletions

View file

@ -0,0 +1,396 @@
# Campaign CT slice CT1 — DAT ground truth for the character panel (0x2100002E)
**Status:** RESEARCH COMPLETE 2026-08-24. No production code changed in this
slice. Findings feed CT2CT6 (`docs/plans/2026-08-24-character-panel-parity-campaign.md`).
Method: temporary `Assert.Fail` probe tests (deleted before commit; the
pattern is preserved in `tests/AcDream.App.Tests/UI/Layout/ScrollbarSkinLiveDatTests.cs`
and its sibling `CharacterPanelLiveDatTests.cs` written by this slice) against
the installed DAT set (`%USERPROFILE%\Documents\Asheron's Call`), driven
through `LayoutImporter.ImportInfos` / `ElementInfo`
(`src/AcDream.App/UI/Layout/ElementReader.cs`,
`src/AcDream.App/UI/Layout/LayoutImporter.cs`).
## 1. Header elements (gmStatManagementUI content, sub-layout under 0x2100002E)
All header elements are DUPLICATED — the imported 0x2100002E tree carries
two structurally identical copies of the whole header block, one reached
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.
| Element | Id | X,Y | W×H | HJustify | VJustify | FontDid | FontColor | Outline | Margins |
|---|---|---|---|---|---|---|---|---|---|
| Name | 0x10000231 | 0,0 | 230×20 | Center | Center | 0x40000001 | white (1,1,1,1) | false | 0 |
| Heritage line | 0x10000232 | 0,20 | 230×15 | Center | Center | 0x40000002 | white (1,1,1,1) | false | L5 R5 |
| PK line | 0x10000233 | 0,35 | 230×15 | Center | Center | 0x40000002 | **white (1,1,1,1)** | false | 0 |
| Level | 0x1000023B | 235,35 | 65×50 | Center | Center | 0x40000010 | **(1, 0.9490196, 0.49803922, 1)** | **true** | 0 |
| Total XP | 0x10000235 | 130,70 | 100×18 | Right | Center | 0x40000000 | white | false | 0 |
| XP meter | 0x10000236 (Type 7) | 0,88 | 230×17 | — | — | — | — | — | — |
| XP-next label (child of meter) | 0x10000237 | 0,0 | 130×17 | Left | Center | 0x40000000 | white | false | 0 |
| XP-to-level value (child of meter) | 0x10000238 | 130,0 | 100×17 | Right | Center | 0x40000000 | white | false | 0 |
| Luminance label | 0x100005C5 | 0,52 | 110×18 | Left | Center | 0x40000000 | white | false | 0 |
| Luminance value | 0x100005C6 | 110,52 | 120×18 | Right | Center | 0x40000000 | white | false | 0 |
Header block container (0x10000230) is 300×110 inside the shared prototype
layout 0x21000045 — the character content column itself is only 230px wide
(name/heritage/PK/XP all live in that 230px column), with a 5px vertical
divider (0x10000239, X=230, W=5) separating it from the level box
(X=235..300, matching the plan's "Level area (65,50)" spec).
**Confirmations vs. the owner report / plan:**
- Item 4 (PK line pure white): CONFIRMED — `0x10000233` FontColor is
exactly white, not the "color off" state the owner reported. The bug is
purely in `CharacterStatController.Bind`'s runtime color choice (`Body`
= parchment `(0.92,0.90,0.82,1)`), not a DAT-reading gap. CT4 must switch
the PK-line color to `Vector4.One`.
- Item 5 (level color): CONFIRMED DIVERGENT. DAT-authored level color is
`(1, 0.9490196, 0.49803922, 1)` (a pale gold, ~RGB 255/242/127) **with
Outline=true**. `CharacterStatController.Gold` is currently
`(1, 0.82, 0.36, 1)` (a deeper orange-gold) with no outline applied. CT4
should read the DAT FontColor + Outline directly instead of hand-picking
a runtime color, matching how `LevelId`'s dat FontDid is already honored.
## 2. Stat list (0x1000023D) + row templates
`0x1000023D` (Type 5 ListBox) is duplicated the same harmless way as the
header (once per Attributes/Skills page chain, identical geometry both
times): `X=0 Y=112 W=300 H=160`, `ScrollbarElementId=0x1000023E`
(scrollbar at `X=281 W=16 H=160`, i.e. always reserved, whether or not it's
shown). Its authored `TemplateList` (dat property 0x64) names FIVE
same-layout-family entries, all in **LayoutDesc 0x21000045**:
```
0x10000248, 0x10000249, 0x1000024A, 0x1000024B, 0x1000024C
```
These are NOT reachable by walking `ImportInfos(dats, 0x21000045u)`'s built
tree — `LayoutImporter.ImportInfos` intentionally filters out same-layout
template-list targets (the `#375` fix documented in
`LayoutImporter.ImportInfos`'s own comment: "retail never instantiates a
template-list element as a live widget… building them here parked two live
prototype rows… over and outside the framed panel"). The correct read path
— and the one CT5 must use — is the **targeted single-root overload**:
`LayoutImporter.ImportInfos(dats, 0x21000045u, templateElementId)`, the same
seam `UiTemplateListBox`'s `TemplateResolver` already uses for other
authored row templates.
Dumping all five with that overload:
### 0x10000248 — the ONE shared data-row template (icon + name + value)
```
Id=0x10000248 Type=3 (container) X=0 Y=0 W=282 H=20
StateMedia[Normal] File=0x06004CC2 DrawMode=1
StateMedia[Highlight] File=0x06000F93 DrawMode=1
Id=0x10000129 Type=3 (icon slot) X=0 Y=0 W=20 H=20 (no own media — set per-row at runtime)
Id=0x1000012A Type=0xC (name text) X=25 Y=0 W=150 H=20 HJustify=Left FontDid=0x40000001 white
Id=0x1000012B Type=0xC (value text)X=175 Y=0 W=100 H=20 HJustify=Right FontDid=0x40000001 white
```
This is the single row template used for BOTH the attribute rows AND the
skill rows (retail's `gmAttributeUI`/`gmSkillUI` share it). Ground truth
for item 1 (icon alignment) and item 2 (value-column gutter):
- **Icon: 20×20, flush at the row's left edge (X=0), full row height.**
Current code (`CharacterStatController.IconSize = 16f`,
`RowPadX = 4f`) draws a 16×16 icon at X=4 — smaller AND offset from
retail's flush-left 20×20. This is the "icons misaligned" bug (item 1).
- **Name column: X=25, W=150 (fixed pixel widths, not a width fraction).**
Current code computes `nameX = RowPadX + IconSize + IconGap` (4+16+6=26,
off by one from retail's 25) and `nameW = width * 0.60` (a
content-relative fraction retail does not use at all — retail's name
column is a FIXED 150px regardless of the 282px row width).
- **Value column: X=175, W=100, right-justified.** Value's right edge
sits at X=275. **The row template itself is 282px wide, so there is a
7px gap between the value's right edge and the row's own right edge**
— 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.
### 0x10000249 / 0x1000024A / 0x1000024B / 0x1000024C — skill SECTION HEADER captions
```
Id=0x10000249 Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F90
Id=0x1000024A Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F86
Id=0x1000024B Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F98
Id=0x1000024C Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F89
```
These are single full-width caption bars (no icon/name/value split), 280px
wide (2px narrower than the data row — no scrollbar-gutter inset needed
since they never scroll independently). Their sprites are an EXACT match
for the existing constants already in `CharacterStatController.cs`:
| Constant | Value | Probe file | Match |
|---|---|---|---|
| `SkillHeaderSpecializedSprite` | 0x06000F90 | 0x10000249 | YES |
| `SkillHeaderTrainedSprite` | 0x06000F86 | 0x1000024A | YES |
| `SkillHeaderUntrainedSprite` | 0x06000F98 | 0x1000024B | YES |
| `SkillHeaderUnusableSprite` | 0x06000F89 | 0x1000024C | YES |
These four are correctly ported already; no CT5 work needed here.
## 3. Titles page (0x10000539 subtree, imported as part of 0x2100002E)
`0x10000539` (Type 0x10000046, the Titles page container) is NOT
duplicated like the Attributes/Skills content — it appears once, as a
direct child of the tab-control root `0x10000227` (siblings with the
Attributes/Skills tab buttons and the Titles tab button `0x10000538`).
Geometry: `X=0 Y=25 W=300 H=575` (fills the window below the 25px tab bar).
| Element | Id | X,Y | W×H | Notes |
|---|---|---|---|---|
| (unlabeled caption) | 0x1000052E | 8,20 | 270×18 | Left, margin L6, FontDid 0x40000001 white — likely "Current Title:" caption |
| **Current display title text** | **0x1000052F** | 8,40 | 270×18 | Center, margins L5 R5, FontDid 0x40000001 white |
| divider | 0x10000530 | 0,60 | 300×9 | sprite 0x06001420 |
| (unlabeled caption) | 0x10000531 | 8,70 | 270×18 | Left, margin L6 — likely "Titles Earned:" caption |
| **Title ListBox** | **0x10000532** | 8,90 | 270×455 | ScrollbarElementId=0x10000533; TemplateList: LayoutDid=0x2100005E, ElementId=0x10000536 |
| Title scrollbar | 0x10000533 | 280,90 | 16×455 | shared `RetailScrollbarChrome` media (thumb/up/down ids identical to the base skin pinned by `ScrollbarSkinLiveDatTests`) |
| divider | 0x10000534 | 0,550 | 300×9 | sprite 0x06001420 |
| **"Set as Display Title" button** | **0x10000535** | 53,560 | 200×32 | MinWidth=65, margins L7 R7, FontDid 0x40000001 white, DefaultState=**Ghosted** (matches the plan's `UpdateButtons` ghost-when-current contract); three-slice chrome children 0x100002CE/CF/D0 with Normal/Normal_rollover/Normal_pressed/**Ghosted** states each |
### Title row template — LayoutDesc 0x2100005E, element 0x10000536
```
Id=0x10000536 Type=3 (container) X=0 Y=0 W=270 H=24
StateMedia[DirectState, key ""] File=0x06004CCA DrawMode=3
StateMedia[Highlight] File=0x06001AAF DrawMode=1
Id=0x10000537 Type=0xC (text) X=0 Y=0 W=270 H=24 HJustify=Left Margins L6 R6 FontDid=0x40000001 white
```
A single-line text row, no icon column (titles have no per-row icon in
retail) — 24px tall vs. the stat rows' 20px. Row width 270 matches the
ListBox content width exactly (`0x10000532` is 270 wide, with its
scrollbar `0x10000533` living OUTSIDE that width at X=280 — unlike the
stat list, the title row template does NOT need its own internal
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.
## 4. Window min/max constraints
### Character window (0x2100002E) root
`LayoutImporter.ImportInfos(dats, 0x2100002Eu)` returns element
`0x10000227` (Type 0x8, TabControl) as the tree root — this IS the
top-level element retail's `RetailUiRuntime.MountCharacter()` mounts via
`RetailWindowFrame.Mount(..., layout.Root, ...)`. **It authors NO
MinWidth/MinHeight/MaxWidth/MaxHeight properties** (dat properties
0x3F/0x3E/0x3D/0x3C all absent — probe shows every one of `MinW/MinH/MaxW/MaxH`
blank for 0x10000227 and every element under it, including the footer,
header, and Titles page).
### Chat window (0x2100006F) root, for comparison
`LayoutImporter.ImportInfos(dats, 0x2100006Fu)` returns element
`0x10000600` (Type 0x10000050, a self-contained "window" element that
directly includes its own dragbar (Type 2), border frame (Type 3), and
FOUR resize-grip corners (Type 9) as children — none of which the
character layout's root has). It DOES author constraints:
**MinWidth=300, MinHeight=100, MaxWidth=2000, MaxHeight=2000.**
### Correction to the plan
The plan's "Already in-tree" section states: *"The character window
registers with `DatConstraintSource` — authored min/max plumbing exists in
`RetailWindowFrame`; Y-resize for this window and the list-scrollbar
contract do not."* Read literally this implies the character window's
`RetailWindowFrame.Mount` call already sets `DatConstraintSource`. **It
does not.** `RetailUiRuntime.MountCharacter()` (`src/AcDream.App/UI/RetailUiRuntime.cs`,
~line 4043) constructs `RetailWindowFrame.Options` with `ResizeY = true`,
`ResizableEdges = ResizeEdges.Bottom`, `ConstrainResizeToParent = true`
but **no `DatConstraintSource`, `MinHeight`, or `MaxHeight` field at
all**, unlike e.g. `MountSideVitals()` (~line 1474) which explicitly sets
`DatConstraintSource = info` from its own imported root. This is
consistent with what CT1 also found in the DAT itself: 0x2100002E's root
authors no size constraints to plumb through in the first place — chat's
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.
## 5. The title-string table (DAT ground truth for `CharacterTitleTable::GetCharacterTitleFromID`)
### Decomp chain (named-retail, `docs/research/named-retail/acclient_2013_pseudo_c.txt`)
`CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0` does **not**
read a StringTable directly. It goes through TWO independent
enum-mapper indirections:
1. `EnumMapper::GetString(0x10000006, titleId, &rawName)` (the static
3-arg overload @`0x0041ac40`) — internally calls
`DBObj::GetDIDByEnum(&did, 0x10000006, /*category*/1)` to resolve the
**title EnumMapper object's DID**, then dispatches on
`MasterDBMap::DivineType` (0x24 = EnumMapper) to call
`EnumMapper::GetString(titleId, &rawName)` on it, giving a raw
canonical string name (NOT yet localized/hashed).
2. `compute_str_hash(rawName)` (ELF-style hash, already ported byte-exact
as `DatStringResolver.ComputeHash` — see its own citation of
`compute_str_hash @ 0x00413110`).
3. `StringInfo::SetStringIDandTableEnum(&info, hash, 0x10000007)`
(`@0x0042c760`) — internally calls
`DBObj::GetDIDByEnum(&did, 0x10000007, /*category*/4)` to resolve the
**title StringTable's DID**.
4. `StringInfo::GetString(&info)` (`@0x0042e760``InqString`
`StringTableMetaLanguage::UnescapeString`) resolves the final localized
text — the same `StringTable.Strings[hash]` lookup
`DatStringResolver.Resolve(tableId, stringId)` already performs.
`DBObj::GetDIDByEnum(enumValue, category)` (`@0x004153a0`
`DBCache::GetDIDFromEnum @0x00413940`) is itself a **two-level indirection**
through a master map object (`this->m_MasterMapID`): look up `category`
in the master map to get an intermediate category-map DID, then look up
`enumValue` in THAT map to get the final DID. This is the exact same
mechanism already ported (empirically, not by name) as `RetailKeyNames`'
`0x2300000A`/`0x2300000B`/`0x23000007` constants
(`src/AcDream.App/UI/Layout/RetailKeyNames.cs`, citing "`DBCache::GetDIDFromEnumStatic`
category 4") — this slice confirms those three constants ARE exactly the
category-4 (STRINGTABLE) map's enum 4/5/3 entries (see table below), so
the existing `RetailKeyNames` port is independently cross-validated by
this investigation.
### Live-DAT resolution (verified end-to-end this session)
`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
installed dat) is the flat id→string table type.
**Master map, DID `0x25000000`** (`ClientEnumToID`/`ClientEnumToName`,
22 entries) — the categories relevant here:
| category enum | name | category-map DID |
|---|---|---|
| 1 | EMAPPER | 0x25000001 |
| 4 | STRINGTABLE | 0x25000004 |
**Category 1 (EMAPPER) map, DID `0x25000001`** — relevant entry:
| enum | name | DID |
|---|---|---|
| 0x10000006 | CharacterTitle | **0x22000041** |
**Category 4 (STRINGTABLE) map, DID `0x25000004`** — full dump (12 entries),
confirming the `RetailKeyNames` constants along the way:
| enum | name | DID | cross-check |
|---|---|---|---|
| 0x00000003 | KeyMap | **0x23000007** | = `RetailKeyNames.DelimiterTableId` ✓ |
| 0x00000004 | KeyNameOverride | **0x2300000A** | = `RetailKeyNames.KeyNameTableId` ✓ |
| 0x00000005 | MetakeyNameOverride | **0x2300000B** | = `RetailKeyNames.MetaKeyNameTableId` ✓ |
| 0x10000007 | CharacterTitle | **0x2300000E** | (this slice's target) |
| 0x10000001 | UI | 0x23000001 | |
| 0x10000002 | UI_Pregame | 0x23000002 | |
| 0x10000003 | Preference | 0x23000003 | |
| 0x10000004 | UI_Options | 0x23000004 | |
| 0x10000006 | Options | 0x2300000D | |
| 0x00000002 | Calendar | 0x23000006 | |
| 0x00000006 | CommandSetup | 0x2300000C | |
| 0x00000007 | ActionDescription | 0x23000005 | |
| 0x00000008 | ServerEngine | 0x23000010 | |
So: **the title EnumMapper is DID `0x22000041`; the title StringTable is
DID `0x2300000E`.**
### End-to-end verification (ACE's `CharacterTitle` enum, `WarMage = 13`)
```
EnumMapper(0x22000041).IdToStringMap has 873 entries, including:
titleId 0 -> ID_CharacterTitle_Invalid
titleId 1 -> ID_CharacterTitle_Adventurer
titleId 5 -> ID_CharacterTitle_Life_Mage
titleId 13 -> ID_CharacterTitle_War_Mage
titleId 14 -> ID_CharacterTitle_Wayfarer
ComputeHash("ID_CharacterTitle_War_Mage") = 0x0543AF05
DatStringResolver(dats).Resolve(0x2300000E, 0x0543AF05) = "War Mage"
```
Byte-exact confirmation the chain is understood correctly end to end —
titleId 13 round-trips through the EnumMapper canonical-name lookup, the
retail hash function, and the StringTable localization lookup to produce
exactly "War Mage".
### What CT2 needs to port
1. Two `GetDIDByEnum`-shaped lookups (master map `0x25000000` → category
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.
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).
4. `DatStringResolver.Resolve(0x2300000Eu, hash)` (already exists, no new
code) for the final localized display string.
No new DAT-reading primitives are required — `EnumMapper`/`EnumIDMap` are
already exposed by `DatReaderWriter.DBObjs`, and `DatStringResolver`
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.
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
tree. CT5 must use `ImportInfos(dats, layoutId, elementId)` (the
targeted single-root overload) to read `0x10000248`
(`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.
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.

View file

@ -0,0 +1,351 @@
using System.Numerics;
using AcDream.App.UI.Layout;
using DatReaderWriter;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign CT slice CT1 (2026-08-24) pins: the DAT ground truth for the
/// character panel (LayoutDesc <c>0x2100002E</c>) that slices CT2CT6 build
/// against. Findings + raw probe excerpts live in
/// <c>docs/research/2026-08-24-campaign-ct-dat-ground-truth.md</c>. Follows
/// the durable-pin pattern of <see cref="ScrollbarSkinLiveDatTests"/> /
/// <see cref="TooltipSkinLiveDatTests"/> — assertions, not dumps — so a DAT
/// revision or importer regression that silently changes any of these
/// authored facts fails loudly instead of drifting unnoticed into CT2CT6.
///
/// <para>Decomp anchors: header elements are bound by
/// <c>gmStatManagementUI::PostInit @0x004EFD90</c>; the Titles page by
/// <c>gmCharacterTitleUI::PostInit @0x0049A610</c>.</para>
/// </summary>
[Trait("Lane", "InstalledDat")]
public sealed class CharacterPanelLiveDatTests
{
private static string DatDirectory =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
private static IEnumerable<ElementInfo> Flatten(ElementInfo e)
{
yield return e;
foreach (var c in e.Children)
foreach (var d in Flatten(c))
yield return d;
}
/// <summary>
/// Header identity + level elements, bound by
/// <c>gmStatManagementUI::PostInit @0x004EFD90</c>. Pins item 4 (the PK
/// line is authored pure white — the CT4 bug is in the controller's
/// runtime color choice, NOT a DAT-reading gap) and item 5 (the level
/// color is a pale gold with an authored outline, not
/// <c>CharacterStatController</c>'s current deeper-orange
/// <c>Gold</c> constant). Both header occurrences (Attributes-page and
/// Skills-page chains) are asserted identical — the harmless
/// "duplicated stat-management branches" quirk documented on
/// <c>PrepareSkillScrollbar</c>.
/// </summary>
[InstalledDatFact]
public void HeaderElements_AuthorExpectedFontsAndColors()
{
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x2100002Eu);
Assert.NotNull(tree);
var nameOccurrences = Flatten(tree!).Where(e => e.Id == CharacterStatController.NameId).ToList();
Assert.Equal(2, nameOccurrences.Count); // Attributes-page + Skills-page duplicate chains
foreach (var name in nameOccurrences)
{
Assert.Equal(0x40000001u, name.FontDid);
Assert.Equal(Vector4.One, name.FontColor);
}
foreach (var heritage in Flatten(tree!).Where(e => e.Id == CharacterStatController.HeritageId))
{
Assert.Equal(0x40000002u, heritage.FontDid);
Assert.Equal(Vector4.One, heritage.FontColor);
Assert.Equal(5, heritage.MarginLeft);
Assert.Equal(5, heritage.MarginRight);
}
// Item 4: PK status line is authored PURE WHITE.
foreach (var pk in Flatten(tree!).Where(e => e.Id == CharacterStatController.PkStatusId))
{
Assert.Equal(0x40000002u, pk.FontDid);
Assert.Equal(Vector4.One, pk.FontColor);
}
// Item 5: level color is a pale gold (~RGB 255/242/127) WITH an
// authored outline — CharacterStatController.Gold (1, 0.82, 0.36, 1)
// with no outline is a divergent hand-picked runtime color.
foreach (var level in Flatten(tree!).Where(e => e.Id == CharacterStatController.LevelId))
{
Assert.Equal(0x40000010u, level.FontDid);
Assert.True(level.Outline);
Assert.NotNull(level.FontColor);
Assert.Equal(1f, level.FontColor!.Value.X, precision: 3);
Assert.Equal(0.949f, level.FontColor!.Value.Y, precision: 2);
Assert.Equal(0.498f, level.FontColor!.Value.Z, precision: 2);
}
foreach (var xpLabel in Flatten(tree!).Where(e => e.Id == CharacterStatController.XpNextLabelId))
Assert.Equal(0x40000000u, xpLabel.FontDid);
foreach (var xpValue in Flatten(tree!).Where(e => e.Id == CharacterStatController.XpNextValueId))
Assert.Equal(0x40000000u, xpValue.FontDid);
}
/// <summary>
/// The stat ListBox (<c>0x1000023D</c>) authors a five-entry template
/// list in LayoutDesc <c>0x21000045</c>: one shared icon+name+value data
/// row (<c>0x10000248</c>) plus four skill section-header captions
/// (<c>0x10000249..0x1000024C</c>). Pins the roster + the ListBox's own
/// authored scrollbar linkage.
/// </summary>
[InstalledDatFact]
public void StatListBox_AuthorsFiveRowTemplatesInSharedLayout()
{
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x2100002Eu);
Assert.NotNull(tree);
// Duplicated once per Attributes-page/Skills-page chain, same as the
// header block — both copies are asserted identical.
var listBoxes = Flatten(tree!).Where(e => e.Id == CharacterStatController.ListBoxId).ToList();
Assert.Equal(2, listBoxes.Count);
uint[] expected =
{
0x10000248u, 0x10000249u, 0x1000024Au, 0x1000024Bu, 0x1000024Cu,
};
foreach (ElementInfo listBox in listBoxes)
{
Assert.Equal(CharacterStatController.ListScrollbarId, listBox.ScrollbarElementId);
Assert.Equal(5, listBox.TemplateList.Count);
foreach (uint id in expected)
{
Assert.Contains(
listBox.TemplateList,
t => t.TemplateLayoutId == 0x21000045u && t.TemplateElementId == id);
}
}
}
/// <summary>
/// The shared attribute/skill data-row template (<c>0x10000248</c> in
/// LayoutDesc <c>0x21000045</c>) — the icon/name/value geometry CT5
/// implements against. NOTE: this element is intentionally unreachable
/// via <see cref="LayoutImporter.ImportInfos(DatReaderWriter.IDatReaderWriter,uint)"/>'s
/// whole-layout overload (the <c>#375</c> same-layout template-list skip
/// filter) — the targeted single-root overload
/// (<c>ImportInfos(dats, layoutId, elementId)</c>) is required, the same
/// seam <c>UiTemplateListBox</c>'s <c>TemplateResolver</c> already uses.
/// </summary>
[InstalledDatFact]
public void AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns()
{
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
ElementInfo? row = LayoutImporter.ImportInfos(dats, 0x21000045u, 0x10000248u);
Assert.NotNull(row);
Assert.Equal(282f, row!.Width);
Assert.Equal(20f, row.Height);
Assert.Equal(0x06004CC2u, row.StateMedia["Normal"].File);
// Row-template Highlight sprite (0x06000F93) — CharacterStatController's
// current RowHighlightSprite private constant is 0x06001397, a
// divergence flagged (not fixed) by CT1; see the research doc's
// "Corrections to the plan" §3.
Assert.Equal(0x06000F93u, row.StateMedia["Highlight"].File);
ElementInfo icon = Assert.Single(row.Children, c => c.Id == 0x10000129u);
Assert.Equal(0f, icon.X);
Assert.Equal(0f, icon.Y);
Assert.Equal(20f, icon.Width);
Assert.Equal(20f, icon.Height);
ElementInfo name = Assert.Single(row.Children, c => c.Id == 0x1000012Au);
Assert.Equal(25f, name.X);
Assert.Equal(150f, name.Width);
Assert.Equal(HJustify.Left, name.HJustify);
Assert.Equal(0x40000001u, name.FontDid);
ElementInfo value = Assert.Single(row.Children, c => c.Id == 0x1000012Bu);
Assert.Equal(175f, value.X);
Assert.Equal(100f, value.Width);
Assert.Equal(HJustify.Right, value.HJustify);
Assert.Equal(0x40000001u, value.FontDid);
// Item 2: 7px gap between the value's right edge (275) and the row's
// own right edge (282) — the authored margin the owner reported.
Assert.Equal(7f, row.Width - (value.X + value.Width));
}
/// <summary>
/// The four skill section-header caption templates already match
/// <c>CharacterStatController</c>'s existing sprite constants exactly —
/// a regression guard, not a bug pin (unlike the data-row template
/// above).
/// </summary>
[InstalledDatFact]
public void SkillSectionHeaderTemplates_MatchExistingSpriteConstants()
{
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
(uint elementId, uint expectedSprite)[] headers =
{
(0x10000249u, 0x06000F90u), // SkillHeaderSpecializedSprite
(0x1000024Au, 0x06000F86u), // SkillHeaderTrainedSprite
(0x1000024Bu, 0x06000F98u), // SkillHeaderUntrainedSprite
(0x1000024Cu, 0x06000F89u), // SkillHeaderUnusableSprite
};
foreach (var (elementId, expectedSprite) in headers)
{
ElementInfo? header = LayoutImporter.ImportInfos(dats, 0x21000045u, elementId);
Assert.NotNull(header);
Assert.Equal(280f, header!.Width);
Assert.Equal(20f, header.Height);
Assert.Equal(expectedSprite, header.StateMedia[""].File);
}
}
/// <summary>
/// The Titles page (<c>gmCharacterTitleUI::PostInit @0x0049A610</c>)
/// element roster: display-title text <c>0x1000052F</c>, title ListBox
/// <c>0x10000532</c> (with its scrollbar linkage + row-template
/// reference), "Set as Display Title" button <c>0x10000535</c> (with the
/// authored Ghosted default state matching the ghost-when-current
/// contract).
/// </summary>
[InstalledDatFact]
public void TitlesPage_ElementRosterExists()
{
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x2100002Eu);
Assert.NotNull(tree);
ElementInfo page = Assert.Single(Flatten(tree!), e => e.Id == 0x10000539u);
Assert.Equal(300f, page.Width);
Assert.Equal(575f, page.Height);
ElementInfo displayTitle = Assert.Single(page.Children, c => c.Id == 0x1000052Fu);
Assert.Equal(0x40000001u, displayTitle.FontDid);
Assert.Equal(HJustify.Center, displayTitle.HJustify);
ElementInfo listBox = Assert.Single(page.Children, c => c.Id == 0x10000532u);
Assert.Equal(5u, listBox.Type); // Type-5 ListBox
Assert.Equal(0x10000533u, listBox.ScrollbarElementId);
UiTemplateListEntry template = Assert.Single(listBox.TemplateList);
Assert.Equal(0x2100005Eu, template.TemplateLayoutId);
Assert.Equal(0x10000536u, template.TemplateElementId);
ElementInfo setDisplayButton = Assert.Single(page.Children, c => c.Id == 0x10000535u);
Assert.Equal(1u, setDisplayButton.Type); // Type-1 button
Assert.Equal(65, setDisplayButton.MinWidth);
Assert.Equal("Ghosted", setDisplayButton.DefaultStateName);
}
/// <summary>
/// The title row template (<c>0x10000536</c> in LayoutDesc
/// <c>0x2100005E</c>) — a single-line text row, no icon column, 24px
/// tall. Same targeted-root-overload requirement as the stat row
/// templates.
/// </summary>
[InstalledDatFact]
public void TitleRowTemplate_IsSingleLineTextRowWithNoIconColumn()
{
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
ElementInfo? row = LayoutImporter.ImportInfos(dats, 0x2100005Eu, 0x10000536u);
Assert.NotNull(row);
Assert.Equal(270f, row!.Width);
Assert.Equal(24f, row.Height);
// DirectState (the "" key), not a named "Normal" state.
Assert.Equal(0x06004CCAu, row.StateMedia[""].File);
Assert.Equal(0x06001AAFu, row.StateMedia["Highlight"].File);
ElementInfo text = Assert.Single(row.Children);
Assert.Equal(0x10000537u, text.Id);
Assert.Equal(270f, text.Width);
Assert.Equal(24f, text.Height);
Assert.Equal(HJustify.Left, text.HJustify);
Assert.Equal(6, text.MarginLeft);
Assert.Equal(6, text.MarginRight);
Assert.Equal(0x40000001u, text.FontDid);
}
/// <summary>
/// Item 3/7 ground truth: the character window's imported root
/// (<c>0x10000227</c>, the Type-8 TabControl <c>ImportInfos</c> returns
/// and <c>RetailUiRuntime.MountCharacter</c> mounts) authors NO
/// MinWidth/MinHeight/MaxWidth/MaxHeight — unlike e.g. the side-vitals
/// window (LayoutDesc <c>0x21000075</c>) whose root DOES author
/// 0x3C..0x3F directly. Pinned so CT6 doesn't waste a cycle assuming a
/// simple "read the DAT property" fix is available for this window;
/// see the research doc's "Corrections to the plan" §1.
/// </summary>
[InstalledDatFact]
public void CharacterWindowRoot_AuthorsNoSizeConstraints()
{
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x2100002Eu);
Assert.NotNull(tree);
Assert.Equal(0x10000227u, tree!.Id);
Assert.Equal(8u, tree.Type); // Type-8 TabControl
Assert.Null(tree.MinWidth);
Assert.Null(tree.MinHeight);
Assert.Null(tree.MaxWidth);
Assert.Null(tree.MaxHeight);
}
/// <summary>
/// Comparison point for the pin above: the chat window's root DOES
/// author explicit size constraints, because unlike the character
/// panel's content-only layout, <c>0x2100006F</c>'s root IS a
/// self-contained window element (its own dragbar/border/resize-grip
/// children).
/// </summary>
[InstalledDatFact]
public void ChatWindowRoot_AuthorsExplicitSizeConstraints()
{
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x2100006Fu);
Assert.NotNull(tree);
Assert.Equal(0x10000600u, tree!.Id);
Assert.Equal(300, tree.MinWidth);
Assert.Equal(100, tree.MinHeight);
Assert.Equal(2000, tree.MaxWidth);
Assert.Equal(2000, tree.MaxHeight);
}
/// <summary>
/// The title-string table chain
/// (<c>CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0</c>):
/// title id → <c>EnumMapper(0x22000041)</c> canonical name →
/// <c>compute_str_hash</c> → <c>StringTable(0x2300000E)</c> localized
/// text. Both DIDs are resolved via the two-level
/// <c>DBObj::GetDIDByEnum</c> master-map chain (master map
/// <c>0x25000000</c> → category map → target DID) — see the research
/// doc §5 for the full derivation. Verified end to end against ACE's
/// <c>CharacterTitle.WarMage = 13</c>.
/// </summary>
[InstalledDatFact]
public void TitleStringTable_ResolvesWarMageEndToEnd()
{
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
bool gotMapper = dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumMapper>(
0x22000041u, out var titleEnumMapper);
Assert.True(gotMapper);
Assert.NotNull(titleEnumMapper);
string raw = titleEnumMapper!.IdToStringMap[13u].ToString();
Assert.Equal("ID_CharacterTitle_War_Mage", raw);
uint hash = DatStringResolver.ComputeHash(raw);
Assert.Equal(0x0543AF05u, hash);
var resolver = new DatStringResolver(dats);
string? resolved = resolver.Resolve(0x2300000Eu, hash);
Assert.Equal("War Mage", resolved);
}
}