CT6 (Campaign CT slice 6): the resize clamp source is the SHARED gmPanelUI host (0x100005FE in LayoutDesc 0x2100006E), not 0x2100002E's own root and not the Character/Skills slot 0x1000018E either — live probe confirmed the host authors MinWidth=MaxWidth=310 (fixed — no horizontal Resizebar authored), MinHeight=372, MaxHeight=1000, and that the bottom Resizebar (0x10000660) and top Dragbar (0x1000065C) are direct children of the host, not the content parent. Decomp chain: UIElement_Resizebar::StartMouseResizing @0x0046B7E0 calls UIElement::StartResizing(this->GetParent(), ...), stashing drag state on that parent; UIElement::MouseResizeElement @0x00461130 then reads GetAttribute_Int(this, 0x3C..0x3F) off that same element every mouse-move. RetailUiRuntime.MountCharacter now imports the host element and passes it as RetailWindowFrame.Options.DatConstraintSource, matching the existing MountSideVitals pattern. CharacterStatController.RebuildActiveList now wraps BOTH the Attributes and Skills tabs' rows in the same UiScrollablePanel viewport (previously only Skills got one; Attributes rows had no clipping/scrolling and the shared scrollbar was force-hidden — owner report item 2). The shared scrollbar is now always bound + visible; UiScrollbar's own IsPresentationVisible/IsModelDisabled already draw the correct full-track "disabled" thumb when content fits. This surfaced and fixed a real #372/#412-class anchor-baseline bug: the viewport's Left|Top|Bottom anchor was capturing its baseline margins lazily on its own first ApplyAnchor call, which happens AFTER the ListBox has already grown from its raw DAT height to its mounted height, permanently capping the viewport short on every later resize. Fixed with an eager CaptureCurrentAnchorBaseline() call, mirroring UiTemplateListBox .Viewport's own lazy getter. CharacterTitlesController.Bind gained the same defensive Anchors = Left|Top|Bottom fallback for the Titles ListBox that CharacterStatController already had (a no-op on the real DAT — both the Titles page and its ListBox already carry a real authored LayoutPolicy that stretches correctly). Standardization audit: UiElement.MinWidth/MinHeight/MaxWidth/MaxHeight, set once at RetailWindowFrame.Mount, are the ONLY clamp fields — read identically by interactive drag, RetailWindowManager.ResizeTo, and RetailWindowLayoutPersistence's restore clamp. No gaps found; no register row (every number is a live-probed authored DAT value or a structural correctness fix, nothing inferred). Tests: CharacterStatControllerTests .CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar, CharacterTitlesControllerTests .TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips, RetailWindowFrameTests .NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds (shared-mechanism regression pin), CharacterPanelLiveDatTests .PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract (InstalledDat pin). Existing attribute-row tests updated from list.Children to Descendants(list) for the new nested-viewport shape (the pattern skill rows already needed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
628 lines
30 KiB
C#
628 lines
30 KiB
C#
using System.Numerics;
|
||
using AcDream.App.UI.Layout;
|
||
using AcDream.Content;
|
||
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 =>
|
||
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);
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
/// <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) — post-CT5 state:
|
||
// CharacterStatController.RowHighlightSprite already matches this
|
||
// (CT5 corrected it from the former 0x06001397, which belongs to
|
||
// the spellbook row's unrelated separate selected-overlay
|
||
// mechanism, per the SEALED VERDICT: gmAttributeUI::UpdateSelection
|
||
// SetState(6) -> InfoRegion::SetState on this exact template). This
|
||
// pin remains a regression guard against that fixed divergence
|
||
// recurring, not a still-open bug.
|
||
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>
|
||
/// CT6 (2026-08-25 live probe): the ACTUAL resize clamp source for the
|
||
/// Character/Skills window is the SHARED <c>gmPanelUI</c> host
|
||
/// (<c>0x100005FE</c> in LayoutDesc <c>0x2100006E</c>) — not
|
||
/// <c>0x2100002E</c>'s own root (see the pin above) and not the
|
||
/// Character/Skills slot <c>0x1000018E</c> either (asserted below to
|
||
/// author no constraints of its own). Decomp chain:
|
||
/// <c>UIElement_Resizebar::StartMouseResizing @0x0046B7E0</c> calls
|
||
/// <c>UIElement::StartResizing(this->GetParent(), ...)</c>, stashing
|
||
/// the drag state on that PARENT; <c>UIElement::MouseResizeElement
|
||
/// @0x00461130</c> then reads <c>GetAttribute_Int(this, 0x3C..0x3F)</c>
|
||
/// off that SAME element every mouse-move. The bottom Resizebar
|
||
/// (<c>0x10000660</c>) and top Dragbar (<c>0x1000065C</c>) are both
|
||
/// DIRECT CHILDREN of the host (siblings of the content parent
|
||
/// <c>0x10000180</c>), confirmed here — so the host's own authored
|
||
/// 0x3C..0x3F values are what <c>RetailUiRuntime.MountCharacter</c>
|
||
/// must plumb through as <see cref="RetailWindowFrame.Options.DatConstraintSource"/>.
|
||
/// </summary>
|
||
[InstalledDatFact]
|
||
public void PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract()
|
||
{
|
||
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
|
||
ElementInfo? host = LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x100005FEu);
|
||
Assert.NotNull(host);
|
||
Assert.Equal(310f, host!.Width);
|
||
Assert.Equal(372f, host.Height);
|
||
|
||
// MinWidth == MaxWidth: no horizontal Resizebar is authored on this
|
||
// host (only the top Dragbar and bottom Resizebar — MountCharacter's
|
||
// ResizeX=false matches this exactly). MinHeight == the host's own
|
||
// authored default height (372) — retail's Character/Skills window
|
||
// can only grow taller, never shrink below its own authored extent.
|
||
Assert.Equal(310, host.MinWidth);
|
||
Assert.Equal(310, host.MaxWidth);
|
||
Assert.Equal(372, host.MinHeight);
|
||
Assert.Equal(1000, host.MaxHeight);
|
||
|
||
// The Resizebar and Dragbar are DIRECT CHILDREN of the host, not the
|
||
// content parent (0x10000180) — the load-bearing parentage fact
|
||
// StartMouseResizing's GetParent() call depends on.
|
||
Assert.Contains(host.Children, c => c.Id == 0x10000660u && c.Type == 9u); // Resizebar
|
||
Assert.Contains(host.Children, c => c.Id == 0x1000065Cu && c.Type == 2u); // Dragbar
|
||
Assert.Contains(host.Children, c => c.Id == 0x10000180u); // content parent, a SIBLING
|
||
|
||
// The Character/Skills slot itself authors no constraints of its
|
||
// own — the clamp is exclusively the host's, not layered again on
|
||
// the slot.
|
||
ElementInfo? slot = LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x1000018Eu);
|
||
Assert.NotNull(slot);
|
||
Assert.Equal(300f, slot!.Width);
|
||
Assert.Equal(362f, slot.Height);
|
||
Assert.Null(slot.MinWidth);
|
||
Assert.Null(slot.MinHeight);
|
||
Assert.Null(slot.MaxWidth);
|
||
Assert.Null(slot.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>
|
||
/// CT4 fix round item 6: retail resolves gender via
|
||
/// <c>AppraisalSystem::InqGenderDisplayName @0x005b47c0</c> and heritage
|
||
/// via <c>InqHeritageGroupDisplayName @0x005b4710</c>, both through the
|
||
/// STATIC <c>EnumMapper::GetString(uint32_t enumValue, uint32_t queryId,
|
||
/// PStringBase<char>*) @0x0041ac40</c> overload, which itself calls
|
||
/// <c>DBObj::GetDIDByEnum(&did, enumValue, 1)</c> — master map
|
||
/// (<c>0x25000000</c>) category-1 sub-map (<c>0x25000001</c>), then
|
||
/// <c>ClientEnumToID[0x10000001]</c> (gender) / <c>[0x10000002]</c>
|
||
/// (heritage) resolve to EnumMapper DIDs <c>0x2200000A</c> /
|
||
/// <c>0x2200000B</c>. Both mappers' <c>IdToStringMap</c> 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 <see cref="CharacterIdentityText.GenderDisplayName"/> /
|
||
/// <see cref="CharacterIdentityText.HeritageGroupDisplayName"/> 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.
|
||
/// </summary>
|
||
[InstalledDatFact]
|
||
public void GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain()
|
||
{
|
||
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
|
||
|
||
bool gotMaster = dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(
|
||
(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<DatReaderWriter.DBObjs.EnumIDMap>(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<DatReaderWriter.DBObjs.EnumMapper>(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<DatReaderWriter.DBObjs.EnumMapper>(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<uint, string>
|
||
{
|
||
[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));
|
||
}
|
||
}
|
||
|
||
/// <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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign CT slice CT5 (AP-235): <c>gmAttributeUI::PostInit @0x0049DB70</c>
|
||
/// resolves each row's icon DID via <c>DBObj::GetDIDByEnum(statEnum, category
|
||
/// 0x10000002)</c> for the 6 primary attributes (statId order 1,2,4,3,5,6 —
|
||
/// matching <c>CharacterStatController.AttrRows</c>' authored display order)
|
||
/// and <c>DBObj::GetDIDByEnum(vitalEnum, category 0x10000003)</c> for the 3
|
||
/// vitals' <c>Attribute2ndInfoRegion</c> icon (verified live: the max-vital
|
||
/// enum ids 1/3/5 that <c>CharacterStatController.VitalRows</c> already
|
||
/// stores resolve to the SAME DID as the current-vital ids 2/4/6 retail's
|
||
/// own decomp literally passes, so either works — CT5 fix round (NOTE d)
|
||
/// added the 2/4/6 assertions below that actually ENFORCE this claim
|
||
/// instead of only stating it). Both categories route through the
|
||
/// shared <c>RetailDataIdResolver.Resolve</c> master-map -> category-map
|
||
/// -> value chain — this pin proves every hardcoded fallback DID in
|
||
/// <c>AttrRows</c>/<c>VitalRows</c> (iterated directly, not re-typed)
|
||
/// already matches the live-resolved value byte-exact, the same
|
||
/// "regression guard, not a bug pin" pattern as
|
||
/// <see cref="SkillSectionHeaderTemplates_MatchExistingSpriteConstants"/>.
|
||
/// </summary>
|
||
[InstalledDatFact]
|
||
public void AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain()
|
||
{
|
||
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
|
||
|
||
const uint attributeIconCategory = 0x10000002u;
|
||
// CT5 fix round (NOTE d): AttrRows/VitalRows are now internal, so
|
||
// this pin iterates THEM directly instead of a re-typed duplicate
|
||
// literal array — a divergence between the two would previously
|
||
// have gone undetected by this test.
|
||
foreach (var (_, iconDid, statId) in CharacterStatController.AttrRows)
|
||
{
|
||
uint resolved = RetailDataIdResolver.Resolve(dats, statId, attributeIconCategory);
|
||
Assert.Equal(iconDid, resolved);
|
||
}
|
||
|
||
const uint vitalIconCategory = 0x10000003u;
|
||
foreach (var (_, iconDid, maxStatId) in CharacterStatController.VitalRows)
|
||
{
|
||
uint resolved = RetailDataIdResolver.Resolve(dats, maxStatId, vitalIconCategory);
|
||
Assert.Equal(iconDid, resolved);
|
||
}
|
||
|
||
// CT5 fix round (NOTE d): enforce the "aliasing" claim this pin's
|
||
// own doc comment made but never actually checked — the
|
||
// CURRENT-vital enum ids (2/4/6, i.e. maxStatId+1) that retail's own
|
||
// gmAttributeUI::PostInit decomp literally passes must resolve to
|
||
// the SAME DID as the MAX-vital ids (1/3/5) VitalRows actually
|
||
// stores, live-verified against the installed DAT.
|
||
foreach (var (_, iconDid, maxStatId) in CharacterStatController.VitalRows)
|
||
{
|
||
uint currentStatId = maxStatId + 1u;
|
||
uint resolved = RetailDataIdResolver.Resolve(dats, currentStatId, vitalIconCategory);
|
||
Assert.Equal(iconDid, resolved);
|
||
}
|
||
}
|
||
}
|