acdream/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md
Erik ca4100e76a 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>
2026-08-24 21:31:16 +02:00

22 KiB
Raw Blame History

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 0x06001397uthis 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) (@0x0042e760InqStringStringTableMetaLanguage::UnescapeString) resolves the final localized text — the same StringTable.Strings[hash] lookup DatStringResolver.Resolve(tableId, stringId) already performs.

DBObj::GetDIDByEnum(enumValue, category) (@0x004153a0DBCache::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.