Opus review of CT5 (f532f28c) found 0 blockers, 4 SHOULD-FIX, 6 NOTEs; all
applied here.
SHOULD-FIX 1 (visible retail gap): unselected attribute/skill rows now draw
the row template's Normal-state media (0x06004CC2 — pinned by
CharacterPanelLiveDatTests.AttributeRowTemplate_...) instead of drawing
nothing. Independently decoded against the installed DAT: PFID_A8R8G8B8,
48x48, uniform (0,0,0,175) — a ~69%-opaque black tile the native-size
copy-or-tile blit repeats across the row. Wired at all three sites
(HandleRowClick, ApplySkillSelectionVisuals, AddRow). Selected rows keep
0x06000F93 (RowHighlightSprite) unchanged.
SHOULD-FIX 2: added Bind_AttributeRow_/Bind_VitalRow_/Bind_SkillRow_
geometry tests asserting the authored template pixels against BUILT rows
(not DAT pins) — width, height, icon/name/value column positions. The
skill-row case reproduces the real production scrollbar (X=281, per
CharacterPanelLiveDatTests.StatListBox_AuthorsFiveRowTemplatesInSharedLayout)
to prove the documented 281px clamp (scrollbar.Left - list.Left), one pixel
short of the attribute/vital rows' 282px ceiling.
SHOULD-FIX 3: AddRow's name-column Padding corrected from 1f to 0f — the
authored template carries no margin on 0x1000012A; Padding=1f re-created
the X=26 glyph-start bug this slice existed to fix.
SHOULD-FIX 4: reworded both UiPanel.BackgroundSprite doc comments — the
draw is a native-size copy-or-tile blit (UV-repeat), never a stretch.
Decoded 0x06000F93 as exactly 282x20 (matches the row natively, draws as a
plain copy) vs 0x06004CC2's 48x48 tile. Retail's UIRegion::SetImageByDID
(@0x0069F960) decompiles to a pure BlitMode selector switch — param_2==2 ->
Blit_3Alpha, ==3 -> Blit_4Alpha, else Blit_Normal — with no width/height
touched anywhere in the function, answering CT1's open "draw mode 3"
question: it's an alpha-blend selector, not a resize flag.
NOTEs:
a. AddRow's nameEl now sets OneLine=true so the authored VJustify=Center
takes the same single-line vertical-centering path the value column
already uses.
b. Tempered the "row width is 282" wording in the SkillContentWidth /
RowContentWidth doc comments — that's a ceiling attribute/vital rows
land on, not a fact true for skill rows (281, via SkillViewportWidth's
scrollbar-gutter measurement).
c. Reworded the section-header (RowPadX) comment — CT1 verified only the
four header SPRITES; the caption label's own authored margins were
never checked. Recorded as an open residual, not a cleared divergence.
d. AttrRows/VitalRows are now internal (InternalsVisibleTo("AcDream.App.Tests")
already covers AcDream.App.Tests); CharacterPanelLiveDatTests iterates
them directly instead of a re-typed duplicate array, and now also
asserts the vitals 2/4/6 current-enum aliasing claim the doc comment
made but never enforced.
e. Deleted the stale pre-CT5 0x06001397 narrative in
CharacterPanelLiveDatTests; the pin's comment now describes the
post-CT5 state (a regression guard, not an open bug).
f. Unified ApplySkillSelectionVisuals' selected-branch SpriteResolve
wrapper closure with HandleRowClick's direct assignment.
Build green; full hermetic solution suite green (Release,
Lane!=InstalledDat&...&Status!=KnownFailure filter); InstalledDat lane
green (ACDREAM_RUN_INSTALLED_DAT_TESTS=1, Lane=InstalledDat&Status!=KnownFailure).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
571 lines
27 KiB
C#
571 lines
27 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>
|
||
/// 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);
|
||
}
|
||
}
|
||
}
|