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:
parent
45e276d380
commit
ca4100e76a
2 changed files with 747 additions and 0 deletions
396
docs/research/2026-08-24-campaign-ct-dat-ground-truth.md
Normal file
396
docs/research/2026-08-24-campaign-ct-dat-ground-truth.md
Normal 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 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<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 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.
|
||||||
351
tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs
Normal file
351
tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs
Normal 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 CT2–CT6 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 CT2–CT6.
|
||||||
|
///
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue