acdream/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs
Erik ed652ed8ad feat(ui): Campaign CT slice CT4 — header identity block retail-exact
Retires the rest of AP-109's UI half: the character panel's Name/Heritage/
PkStatus/Level header identity block is now live and DAT-faithful on both
Attributes and Skills pages (verified: CharacterStatController.Bind already
scopes Label/LabelAuthoredColor to the ONE physically-visible page container,
so both tabs share the same bound widgets).

- Name/Heritage/PkStatus/Level switch from hand-picked Body/Gold runtime
  colors to the widget's own authored DefaultColor (LabelAuthoredColor) —
  CT1's live-DAT pin (HeaderElements_AuthorExpectedFontsAndColors) confirmed
  all four already carry the correct FontColor (white/white/white/pale-gold
  with Outline); the former "runtime color, dat carries none" comment was
  false.
- PkStatus resolves through StringTable 0x23000001 by key
  (ID_StatManagement_Header_PKStatus_PK/_PKL/_NPK) with a bitwise
  IsPK/IsPKLite test (gmStatManagementUI::UpdatePKStatus @0x004F00A0) instead
  of the prior exact-equality switch, which silently dropped combined-flag
  PlayerKillerStatus values. Live-DAT-verified strings: "Player Killer" /
  "Player Killer Lite" / "Non-Player Killer" (new InstalledDat pin
  PkStatusKeys_ResolveExpectedAuthoredStrings).
- Level shows "%d"-formatted InqInt(0x19) or the PE-recovered literal "???"
  when absent (CharacterSheet.Level is now int?).
- Heritage line appends CT2/CT3's resolved RuntimeCharacterTitleState
  display title through CharacterTitleResolver, refreshing live on both
  TableReplaced (0x0029) and DisplayTitleChanged (0x002B) —
  CharacterSheetProvider's ChangeBinding now subscribes to both.
- Name-line ruling: ships the PLAIN-NAME case only. Retail's allegiance
  rank-title prefix (AllegianceData::GetFullName @0x005B6950 ->
  AllegianceSystem::GetTitle @0x005B8DD0) needs a ~200-string, 22-function
  heritage x gender table (verbatim decomp literals, e.g.
  GetAluvianMaleTitle @0x005B7BC0's Yeoman/Baronet/.../High King) judged out
  of reasonable size for this slice. RuntimeAllegianceState already carries
  the local player's own rank; only the string table is missing. Registered,
  not silently omitted.
- Luminance pair (0x100005C5/0x100005C6): CharacterSheet.AvailableLuminance/
  MaximumLuminance (PropertyInt64 6/7) already flow generically through both
  the PlayerDescription snapshot and the live 0x02CF private-update parsers
  (no wiring gap). The retail show/hide gate (Level >= 200 &&
  MaximumLuminance != 0, UpdateExperience @0x004F0A70) is wired and toggles
  Visible on both elements every sheet refresh; the exact caption/value text
  could not be recovered this slice (retail's SetText source resolves
  through a Binary-Ninja-mislabeled data pointer, not a StringTable key — a
  DAT string-table sweep found no match), so content stays unbound rather
  than guessed.
- AP-109 narrowed accordingly (register row amended in the same commit).

Tests: CharacterStatControllerTests (heritage composition + live title
update, name stays plain, level int/"???" with authored — not constant —
color across 3 cases, PK line shows resolved text in authored color across
3 statuses, luminance visibility across 5 level/luminance combinations) and
CharacterSheetProviderTests (PK key-by-status resolution including a
combined-flag case, no-resolver leaves PkStatus null, Level null-vs-present,
title resolution + live refresh on both title events + unsubscribe-on-
dispose, luminance Int64 read-through). Full hermetic solution suite green
under Release (0 failures across all 14 test projects); InstalledDat pins
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 00:02:11 +02:00

419 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 CT2CT6 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 CT2CT6.
///
/// <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 =>
Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? 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);
}
// 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);
}
/// <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)
{
// 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);
}
}
/// <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. 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));
}
/// <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);
}
/// <summary>
/// Campaign CT slice CT4: <c>gmStatManagementUI::UpdatePKStatus</c>
/// (0x004f00a0) resolves its three-way PK status text through
/// StringTable <c>0x23000001</c> by key (the same compute_str_hash
/// mechanism <c>ChatWindowController</c>'s chat labels already use — no
/// EnumMapper indirection needed here, unlike the title chain above).
/// Pins the exact authored strings <c>CharacterSheetProvider.PkStatusText</c>
/// resolves against, discovered by a live probe against the installed
/// DAT set (not guessed): "Player Killer" / "Player Killer Lite" /
/// "Non-Player Killer".
/// </summary>
[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);
}
}