diff --git a/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md b/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md new file mode 100644 index 00000000..ad302fb4 --- /dev/null +++ b/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md @@ -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 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 — `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`). +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 3–4 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. diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs new file mode 100644 index 00000000..c8265233 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs @@ -0,0 +1,351 @@ +using System.Numerics; +using AcDream.App.UI.Layout; +using DatReaderWriter; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign CT slice CT1 (2026-08-24) pins: the DAT ground truth for the +/// character panel (LayoutDesc 0x2100002E) that slices CT2–CT6 build +/// against. Findings + raw probe excerpts live in +/// docs/research/2026-08-24-campaign-ct-dat-ground-truth.md. Follows +/// the durable-pin pattern of / +/// — 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 CT2–CT6. +/// +/// Decomp anchors: header elements are bound by +/// gmStatManagementUI::PostInit @0x004EFD90; the Titles page by +/// gmCharacterTitleUI::PostInit @0x0049A610. +/// +[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 Flatten(ElementInfo e) + { + yield return e; + foreach (var c in e.Children) + foreach (var d in Flatten(c)) + yield return d; + } + + /// + /// Header identity + level elements, bound by + /// gmStatManagementUI::PostInit @0x004EFD90. 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 + /// CharacterStatController's current deeper-orange + /// Gold constant). Both header occurrences (Attributes-page and + /// Skills-page chains) are asserted identical — the harmless + /// "duplicated stat-management branches" quirk documented on + /// PrepareSkillScrollbar. + /// + [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); + } + + /// + /// The stat ListBox (0x1000023D) authors a five-entry template + /// list in LayoutDesc 0x21000045: one shared icon+name+value data + /// row (0x10000248) plus four skill section-header captions + /// (0x10000249..0x1000024C). Pins the roster + the ListBox's own + /// authored scrollbar linkage. + /// + [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); + } + } + } + + /// + /// The shared attribute/skill data-row template (0x10000248 in + /// LayoutDesc 0x21000045) — the icon/name/value geometry CT5 + /// implements against. NOTE: this element is intentionally unreachable + /// via 's + /// whole-layout overload (the #375 same-layout template-list skip + /// filter) — the targeted single-root overload + /// (ImportInfos(dats, layoutId, elementId)) is required, the same + /// seam UiTemplateListBox's TemplateResolver already uses. + /// + [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)); + } + + /// + /// The four skill section-header caption templates already match + /// CharacterStatController's existing sprite constants exactly — + /// a regression guard, not a bug pin (unlike the data-row template + /// above). + /// + [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); + } + } + + /// + /// The Titles page (gmCharacterTitleUI::PostInit @0x0049A610) + /// element roster: display-title text 0x1000052F, title ListBox + /// 0x10000532 (with its scrollbar linkage + row-template + /// reference), "Set as Display Title" button 0x10000535 (with the + /// authored Ghosted default state matching the ghost-when-current + /// contract). + /// + [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); + } + + /// + /// The title row template (0x10000536 in LayoutDesc + /// 0x2100005E) — a single-line text row, no icon column, 24px + /// tall. Same targeted-root-overload requirement as the stat row + /// templates. + /// + [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); + } + + /// + /// Item 3/7 ground truth: the character window's imported root + /// (0x10000227, the Type-8 TabControl ImportInfos returns + /// and RetailUiRuntime.MountCharacter mounts) authors NO + /// MinWidth/MinHeight/MaxWidth/MaxHeight — unlike e.g. the side-vitals + /// window (LayoutDesc 0x21000075) 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. + /// + [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); + } + + /// + /// 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, 0x2100006F's root IS a + /// self-contained window element (its own dragbar/border/resize-grip + /// children). + /// + [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); + } + + /// + /// The title-string table chain + /// (CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0): + /// title id → EnumMapper(0x22000041) canonical name → + /// compute_str_hashStringTable(0x2300000E) localized + /// text. Both DIDs are resolved via the two-level + /// DBObj::GetDIDByEnum master-map chain (master map + /// 0x25000000 → category map → target DID) — see the research + /// doc §5 for the full derivation. Verified end to end against ACE's + /// CharacterTitle.WarMage = 13. + /// + [InstalledDatFact] + public void TitleStringTable_ResolvesWarMageEndToEnd() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + + bool gotMapper = dats.Portal.TryGet( + 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); + } +}