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>
22 KiB
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 CT2–CT6 (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 —
0x10000233FontColor is exactly white, not the "color off" state the owner reported. The bug is purely inCharacterStatController.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 toVector4.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.Goldis 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 howLevelId'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) andnameW = 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 existingSkillContentWidth = 282fconstant already in the code) and the internal 175/100 value-column placement (not currently matched). - Row background:
Normalstate file0x06004CC2(the same generic panel-chrome fill used elsewhere client-wide),Highlightstate file0x06000F93.CharacterStatController.RowHighlightSpriteis currently0x06001397u— this is a divergent constant; CT5 should either confirm0x06001397is deliberately used for a DIFFERENT highlight surface (e.g. the vitals/skill list uses a shared sprite elsewhere) or correct it to0x06000F93for 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:
EnumMapper::GetString(0x10000006, titleId, &rawName)(the static 3-arg overload @0x0041ac40) — internally callsDBObj::GetDIDByEnum(&did, 0x10000006, /*category*/1)to resolve the title EnumMapper object's DID, then dispatches onMasterDBMap::DivineType(0x24 = EnumMapper) to callEnumMapper::GetString(titleId, &rawName)on it, giving a raw canonical string name (NOT yet localized/hashed).compute_str_hash(rawName)(ELF-style hash, already ported byte-exact asDatStringResolver.ComputeHash— see its own citation ofcompute_str_hash @ 0x00413110).StringInfo::SetStringIDandTableEnum(&info, hash, 0x10000007)(@0x0042c760) — internally callsDBObj::GetDIDByEnum(&did, 0x10000007, /*category*/4)to resolve the title StringTable's DID.StringInfo::GetString(&info)(@0x0042e760→InqString→StringTableMetaLanguage::UnescapeString) resolves the final localized text — the sameStringTable.Strings[hash]lookupDatStringResolver.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
- Two
GetDIDByEnum-shaped lookups (master map0x25000000→ category map → target DID) — CT2 can either hardcode the two resolved DIDs (0x22000041for the EnumMapper,0x2300000Efor the StringTable, the wayRetailKeyNameshardcodes its three) or port the two-level indirection generically. GivenRetailKeyNamesalready 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 ofGetDIDByEnumappears that would justify factoring out a shared helper. EnumMapper.IdToStringMap[titleId]→ raw canonical name (already readable viadats.Portal.TryGet<DatReaderWriter.DBObjs.EnumMapper>).DatStringResolver.ComputeHash(rawName)(already exists, no new code).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 3–4 for other consumers.
Corrections to the plan (summary)
- Window constraints are NOT already 90% wired. The plan's
"Already in-tree" bullet claims
DatConstraintSourceregistration for the character window; the actualMountCharacter()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. - 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 useImportInfos(dats, layoutId, elementId)(the targeted single-root overload) to read0x10000248(LayoutDesc 0x21000045) and0x10000536(LayoutDesc 0x2100005E) — documented here so CT5 doesn't waste a cycle rediscovering the same "NOT FOUND" dead end this slice hit first. RowHighlightSpritemay already be wrong. The DAT's row-template Highlight state uses0x06000F93; the current constant inCharacterStatController.csis0x06001397. Not fixed in this slice (no production changes); flagged for CT5's review.- 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.