using System.Numerics; using AcDream.App.UI.Layout; using AcDream.Content; 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 => Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") ?? 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); } // CT1 fix round: hoisted to .ToList() + an explicit count assertion. // A bare `foreach (x in seq.Where(...))` with no count check passes // vacuously if the importer ever returns an empty sequence — it // proves nothing about the two Attributes/Skills duplicate chains // actually being present. var heritageOccurrences = Flatten(tree!).Where(e => e.Id == CharacterStatController.HeritageId).ToList(); Assert.Equal(2, heritageOccurrences.Count); foreach (var heritage in heritageOccurrences) { 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. var pkOccurrences = Flatten(tree!).Where(e => e.Id == CharacterStatController.PkStatusId).ToList(); Assert.Equal(2, pkOccurrences.Count); foreach (var pk in pkOccurrences) { 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. var levelOccurrences = Flatten(tree!).Where(e => e.Id == CharacterStatController.LevelId).ToList(); Assert.Equal(2, levelOccurrences.Count); foreach (var level in levelOccurrences) { 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); } var xpLabelOccurrences = Flatten(tree!).Where(e => e.Id == CharacterStatController.XpNextLabelId).ToList(); Assert.Equal(2, xpLabelOccurrences.Count); foreach (var xpLabel in xpLabelOccurrences) Assert.Equal(0x40000000u, xpLabel.FontDid); var xpValueOccurrences = Flatten(tree!).Where(e => e.Id == CharacterStatController.XpNextValueId).ToList(); Assert.Equal(2, xpValueOccurrences.Count); foreach (var xpValue in xpValueOccurrences) Assert.Equal(0x40000000u, xpValue.FontDid); // CT4 fix round (2026-08-25, SHOULD-FIX 5): the luminance pair's own // binding-seam pin — same occurrence-count pattern as every other // header id above (Attributes-page + Skills-page duplicate chains). // Font/color ground truth from // docs/research/2026-08-24-campaign-ct-dat-ground-truth.md: both // elements author font 0x40000000, pure white, no outline. var luminanceLabelOccurrences = Flatten(tree!).Where(e => e.Id == CharacterStatController.LuminanceLabelId).ToList(); Assert.Equal(2, luminanceLabelOccurrences.Count); foreach (var luminanceLabel in luminanceLabelOccurrences) { Assert.Equal(0x40000000u, luminanceLabel.FontDid); Assert.Equal(Vector4.One, luminanceLabel.FontColor); } var luminanceValueOccurrences = Flatten(tree!).Where(e => e.Id == CharacterStatController.LuminanceValueId).ToList(); Assert.Equal(2, luminanceValueOccurrences.Count); foreach (var luminanceValue in luminanceValueOccurrences) { Assert.Equal(0x40000000u, luminanceValue.FontDid); Assert.Equal(Vector4.One, luminanceValue.FontColor); } } /// /// 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) { // CT1 fix round: authored ListBox rect — CT5's row alignment // and CT6's resize/scrollbar contract both depend on this. Assert.Equal(0f, listBox.X); Assert.Equal(112f, listBox.Y); Assert.Equal(300f, listBox.Width); Assert.Equal(160f, listBox.Height); 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); } } // CT1 fix round: the ListBox's own authored scrollbar rect — the // "always reserved" gutter CT5/CT6 both depend on. var scrollbars = Flatten(tree!).Where(e => e.Id == CharacterStatController.ListScrollbarId).ToList(); Assert.Equal(2, scrollbars.Count); foreach (ElementInfo scrollbar in scrollbars) { Assert.Equal(281f, scrollbar.X); Assert.Equal(16f, scrollbar.Width); Assert.Equal(160f, scrollbar.Height); } } /// /// 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. CT1's // fix round upgraded this from a flagged divergence to a SEALED // VERDICT (gmAttributeUI::UpdateSelection SetState(6) -> // InfoRegion::SetState on this exact template); 0x06001397 belongs // to the spellbook row's separate selected-overlay mechanism. CT5 // fixes RowHighlightSprite for the stat rows only; 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); } /// /// CT4 fix round item 6: retail resolves gender via /// AppraisalSystem::InqGenderDisplayName @0x005b47c0 and heritage /// via InqHeritageGroupDisplayName @0x005b4710, both through the /// STATIC EnumMapper::GetString(uint32_t enumValue, uint32_t queryId, /// PStringBase<char>*) @0x0041ac40 overload, which itself calls /// DBObj::GetDIDByEnum(&did, enumValue, 1) — master map /// (0x25000000) category-1 sub-map (0x25000001), then /// ClientEnumToID[0x10000001] (gender) / [0x10000002] /// (heritage) resolve to EnumMapper DIDs 0x2200000A / /// 0x2200000B. Both mappers' IdToStringMap entries are used /// VERBATIM as final display text (no further StringTable hash step, per /// the decomp) EXCEPT heritage ids 2/5/0xd, which retail hardcodes to /// "Gharu'ndim"/"Umbraen"/"Olthoi" instead of the raw /// "Gharundim"/"Shadowbound"/"OlthoiAcid" internal names. This pin proves /// every / /// table /// entry matches that exact algorithm against the live installed DAT — /// catching a wrong guess (none found: ids 10 "Penumbraen" and 12 /// "Olthoi", flagged as unverified guesses in the CT4 review, both come /// back byte-exact). See the register's CT4 mechanism-divergence row for /// why the tables stay hardcoded rather than reading this chain live. /// [InstalledDatFact] public void GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain() { using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); bool gotMaster = dats.Portal.TryGet( (uint)dats.Portal.Header.MasterMapId, out var master); Assert.True(gotMaster); Assert.NotNull(master); Assert.True(master!.ClientEnumToID.TryGetValue(1u, out uint categoryDid)); bool gotCategoryMap = dats.Portal.TryGet(categoryDid, out var categoryMap); Assert.True(gotCategoryMap); Assert.NotNull(categoryMap); Assert.True(categoryMap!.ClientEnumToID.TryGetValue(0x10000001u, out uint genderDid)); Assert.Equal(0x2200000Au, genderDid); Assert.True(dats.Portal.TryGet(genderDid, out var genderMapper)); Assert.NotNull(genderMapper); foreach (var (id, raw) in genderMapper!.IdToStringMap) { string? expected = raw.Value == "Invalid" ? null : raw.Value; Assert.Equal(expected, CharacterIdentityText.GenderDisplayName((int)id)); } Assert.True(categoryMap.ClientEnumToID.TryGetValue(0x10000002u, out uint heritageDid)); Assert.Equal(0x2200000Bu, heritageDid); Assert.True(dats.Portal.TryGet(heritageDid, out var heritageMapper)); Assert.NotNull(heritageMapper); // AppraisalSystem::InqHeritageGroupDisplayName's three hardcoded // overrides (@0x005b4718/0x005b4732/0x005b474c) — applied ahead of // the raw EnumMapper text, exactly like the retail branch order. var overrides = new Dictionary { [2u] = "Gharu'ndim", [5u] = "Umbraen", [0xDu] = "Olthoi", }; foreach (var (id, raw) in heritageMapper!.IdToStringMap) { string? expected = overrides.TryGetValue(id, out string? overridden) ? overridden : raw.Value == "Invalid" ? null : raw.Value; Assert.Equal(expected, CharacterIdentityText.HeritageGroupDisplayName((int)id)); } } /// /// Campaign CT slice CT4: gmStatManagementUI::UpdatePKStatus /// (0x004f00a0) resolves its three-way PK status text through /// StringTable 0x23000001 by key (the same compute_str_hash /// mechanism ChatWindowController's chat labels already use — no /// EnumMapper indirection needed here, unlike the title chain above). /// Pins the exact authored strings CharacterSheetProvider.PkStatusText /// resolves against, discovered by a live probe against the installed /// DAT set (not guessed): "Player Killer" / "Player Killer Lite" / /// "Non-Player Killer". /// [InstalledDatFact] public void PkStatusKeys_ResolveExpectedAuthoredStrings() { using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); var resolver = new DatStringResolver(dats); string? pk = resolver.Resolve(0x23000001u, DatStringResolver.ComputeHash("ID_StatManagement_Header_PKStatus_PK")); string? pkLite = resolver.Resolve(0x23000001u, DatStringResolver.ComputeHash("ID_StatManagement_Header_PKStatus_PKL")); string? npk = resolver.Resolve(0x23000001u, DatStringResolver.ComputeHash("ID_StatManagement_Header_PKStatus_NPK")); Assert.Equal("Player Killer", pk); Assert.Equal("Player Killer Lite", pkLite); Assert.Equal("Non-Player Killer", npk); } /// /// Campaign CT slice CT5 (AP-235): gmAttributeUI::PostInit @0x0049DB70 /// resolves each row's icon DID via DBObj::GetDIDByEnum(statEnum, category /// 0x10000002) for the 6 primary attributes (statId order 1,2,4,3,5,6 — /// matching CharacterStatController.AttrRows' authored display order) /// and DBObj::GetDIDByEnum(vitalEnum, category 0x10000003) for the 3 /// vitals' Attribute2ndInfoRegion icon (verified live: the max-vital /// enum ids 1/3/5 that CharacterStatController.VitalRows already /// stores resolve to the SAME DID as the current-vital ids 2/4/6 retail's /// own decomp literally passes, so either works). Both categories route /// through the shared RetailDataIdResolver.Resolve master-map -> /// category-map -> value chain — this pin proves every hardcoded fallback /// DID in AttrRows/VitalRows already matches the live-resolved /// value byte-exact, the same "regression guard, not a bug pin" pattern as /// . /// [InstalledDatFact] public void AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain() { using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); const uint attributeIconCategory = 0x10000002u; (uint statId, uint expectedDid)[] attributes = { (1u, 0x060002C8u), // Strength (2u, 0x060002C4u), // Endurance (4u, 0x060002C9u), // Coordination (3u, 0x060002C6u), // Quickness (5u, 0x060002C5u), // Focus (6u, 0x060002C7u), // Self }; foreach (var (statId, expectedDid) in attributes) { uint resolved = RetailDataIdResolver.Resolve(dats, statId, attributeIconCategory); Assert.Equal(expectedDid, resolved); } const uint vitalIconCategory = 0x10000003u; (uint maxStatId, uint expectedDid)[] vitals = { (1u, 0x06004C3Bu), // Health (3u, 0x06004C3Cu), // Stamina (5u, 0x06004C3Du), // Mana }; foreach (var (maxStatId, expectedDid) in vitals) { uint resolved = RetailDataIdResolver.Resolve(dats, maxStatId, vitalIconCategory); Assert.Equal(expectedDid, resolved); } } }