acdream/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs
Erik 5190e16915 fix(chargen): Campaign CC gate round 1 Batch C — un-consume media-bearing children
Commit 2/3: CLIENT-WIDE blast radius — un-consume media-bearing dat
children on UiText/UiField.

UiText.ConsumesDatChildren (true unless a state authors PassToChildren)
and UiField.ConsumesDatChildren (true, unconditional) used to drop
EVERY dat child at import time, including ones that carry their own
renderable media — retail's UIElement_Text/Field genuinely composites
those as real chrome/controls (frame pieces, linked scrollbars), not
swallowed caption/face art the way a Button's or Meter's children are.

LayoutImporter.BuildWidget gains a new carve-out (mirroring the
existing UiMeter one): when a UiText/UiField's ConsumesDatChildren is
true, build any child whose OWN StateMedia is non-empty (it carries a
real sprite/track) instead of dropping it outright. Purely structural/
property-only children (StateMedia.Count == 0) stay dropped exactly as
before — this is additive, not a relaxation of the PassToChildren gate.

Independently re-derived blast-radius sweep (walks every installed
LayoutDesc via DatCollection.GetAllIdsOfType<LayoutDesc>, new
LayoutImporterMediaBearingChildSweepTests): 37 distinct (layout,
element) pairs — 41 raw tree positions, since a handful of element ids
recur at multiple subtree positions within the same layout — across 15
layouts. Full list:

  0x21000005/0x10000011 (x5 tree positions — chat-adjacent template
    reused across the layout), 0x21000005/0x1000059A [MAIN GAME UI],
  0x21000006/0x10000011, 0x2100000F/0x1000059A,
  0x21000038/{0x100003AB,0x100003BA,0x100003C4,0x100003E0,0x100003EC,
    0x100003F6,0x100003FA,0x100003FD,0x100003FF,0x10000402,0x10000404,
    0x10000405,0x10000409} [character creation],
  0x21000043/0x10000362,
  0x21000046/0x100003C4, 0x21000047/{0x100003E0,0x100003EC},
  0x21000048/{0x100003F6,0x100003FA,0x100003FD},
  0x21000049/{0x100003AB,0x100003BA}, 0x2100004A/0x10000409,
  0x2100004B/{0x100003FF,0x10000402,0x10000404,0x10000405},
  0x2100004C/{0x100002DD,0x100002E5,0x100002E6},
  0x2100005B/0x10000011, 0x21000068/0x1000059A,
  0x2100006F/0x10000011 [CHAT INPUT].

(This is an independent re-derivation, not a re-statement of the
investigation's earlier "42/14" estimate — the small difference is
expected from measuring with this commit's own criteria.)

New tests: the sweep itself (pins the two flagged landmarks —
MAIN GAME UI 0x21000005/0x1000059A and CHAT INPUT 0x2100006F/
0x10000011 — plus the three chargen boxes), a build-through regression
test confirming those two landmarks' children resolve as real widgets
post-fix, and a chargen-scoped test confirming the eight gold-frame
pieces + linked scrollbar on all three description boxes now resolve
via UiElement.FindDescendant.

Full App suite (Debug and Release, live-DAT): 5304 passed / 0 failed /
3 skipped — ZERO regressions across the whole client, including every
existing chat and main-UI test. Runtime suite: 1735/0, unaffected
(this is an App-layer-only change).

FLAG FOR THE LEAD: automated coverage cannot catch a purely VISUAL
regression (a frame drawing in the wrong place, a scrollbar overlapping
text). Chat and the main game UI both got new dat children rendered for
the first time this commit — schedule the user's own visual check of
both before considering this closed, per the campaign's oracle
discipline.

The scrollbar linkage (wiring the description boxes' UiScrollbar to
actual text scrolling) is NOT done in this commit — the scrollbar
widget now BUILDS, but CharacterCreationHeritagePage/TownPage/
ProfessionPage/SummaryPage do not yet bind its ScalarChanged to
UiText.Scroll. Filed as follow-up (see report).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:27:03 +02:00

1262 lines
60 KiB
C#

using System.IO;
using System.Linq;
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Installed-retail-DAT acceptance gate for Campaign CC slice CC4. Opt in
/// with <c>ACDREAM_PROBE_LIVE_MOUNT=1</c>; <c>ACDREAM_DAT_DIR</c> can
/// override the ordinary Documents/Asheron's Call location. Mirrors
/// <see cref="CharacterManagementLiveDatTests"/>'s pattern: sweeps the
/// authored master-shell and page ids the campaign plan and CC4's own
/// decomp research cite, pinning them against the real installed layout.
/// </summary>
public sealed class CharacterCreationLiveDatTests
{
private static string DatDirectory =>
Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
[InstalledDatFact]
public void EnumTable5_ResolvesTheMasterShellRootAndChildren()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats,
CharacterCreationUiController.RootEnum,
5u);
Assert.NotEqual(0u, layoutId);
Console.WriteLine(
$"[CC4-DAT] category=5 enum=0x10000039 -> DID=0x{layoutId:X8}");
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
Assert.Equal(
CharacterCreationUiController.RootElementId,
screen.Root.DatElementId);
// CC4 re-review R5: the fixed-canvas arbiter THROWS if two concurrent
// screens declare different sizes, and char-management's root is
// DAT-pinned at 800x600 (CharacterManagementLiveDatTests). Chargen
// declares on top of it on the exact user-gate path, so its authored
// extent must be pinned too — an unequal extent is now a crash at
// Open(), not a cosmetic drift. Observe, don't infer (C4 closeout).
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats,
layoutId,
CharacterCreationUiController.RootElementId));
Assert.Equal(800f, rootInfo.Width);
Assert.Equal(600f, rootInfo.Height);
Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.ProgressBarElementId));
AssertButton(screen, CharacterCreationUiController.BackElementId);
AssertButton(screen, CharacterCreationUiController.NextElementId);
AssertButton(screen, CharacterCreationUiController.FinishElementId);
AssertButton(screen, CharacterCreationUiController.HelpElementId);
AssertButton(screen, CharacterCreationUiController.ExitElementId);
AssertButton(screen, CharacterCreationUiController.RandomElementId);
Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.MasterPageElementId));
foreach (uint pageId in new[]
{
CharacterCreationUiController.HeritagePageElementId,
CharacterCreationUiController.ProfessionPageElementId,
CharacterCreationUiController.SkillsPageElementId,
CharacterCreationUiController.AppearancePageElementId,
CharacterCreationUiController.TownPageElementId,
CharacterCreationUiController.SummaryPageElementId,
})
{
Assert.IsAssignableFrom<UiElement>(screen.FindElement(pageId));
}
AssertButton(screen, CharacterCreationUiController.HeritageTabElementId);
AssertButton(screen, CharacterCreationUiController.ProfessionTabElementId);
AssertButton(screen, CharacterCreationUiController.SkillsTabElementId);
AssertButton(screen, CharacterCreationUiController.AppearanceTabElementId);
AssertButton(screen, CharacterCreationUiController.TownTabElementId);
AssertButton(screen, CharacterCreationUiController.SummaryTabElementId);
}
[InstalledDatFact]
public void MountsThroughTheControllerAgainstLiveResources()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats,
CharacterCreationUiController.RootEnum,
5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
var host = new UiRoot();
var dialogs = MakeDialogFactory(dats, host);
var bindings = new CharacterCreationRuntimeBindings(
() => null,
_ => default,
_ => default,
_ => default,
(_, _) => default,
(_, _) => default,
_ => default,
_ => default,
_ => default,
_ => default,
_ => default,
() => { });
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) =>
LayoutImporter.Import(
dats, templateLayoutId, templateElementId, _ => (0u, 0, 0), null)?.Root;
CharacterCreationUiController? controller =
CharacterCreationUiController.CreateDetached(
host, screen, ResolveTemplate, dialogs, bindings,
new CharacterCreationUiController.DialogStrings(
"Are you sure?", "No name", "Unspent credits", "Randomize?", "Name too long"));
Assert.NotNull(controller);
controller!.AttachAndTick();
controller.Dispose();
dialogs.Dispose();
}
/// <summary>13 heritage buttons, the description text, all present as
/// authored (Heritage page — <c>gmCGHeritagePage::InitializePage @
/// 0x00483a10</c>).</summary>
[InstalledDatFact]
public void HeritagePage_HasAllThirteenRaceButtonsAndDescriptionText()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats,
CharacterCreationUiController.RootEnum,
5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement heritageRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.HeritagePageElementId));
uint[] heritageButtonIds =
[
0x100003BFu, 0x100003C1u, 0x100003C2u, 0x100003C3u,
0x10000590u, 0x100005A9u, 0x100005E8u, 0x100005F1u,
0x100005C4u, 0x10000591u, 0x100005BFu, 0x100005C7u,
0x100005C8u,
];
foreach (uint buttonId in heritageButtonIds)
{
Assert.IsType<UiButton>(
UiElement.FindDescendant(heritageRoot, buttonId));
}
Assert.IsType<UiText>(
UiElement.FindDescendant(heritageRoot, 0x100003C4u));
}
/// <summary>Seven template buttons, six attribute sliders (each with a
/// lock button + scrollbar + value text), and the four derived
/// displays (Profession page —
/// <c>gmCGProfessionPage::InitializePage @ 0x00482d50</c>).</summary>
[InstalledDatFact]
public void ProfessionPage_HasTemplateButtonsSlidersAndDisplays()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats,
CharacterCreationUiController.RootEnum,
5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement professionRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.ProfessionPageElementId));
uint[] templateButtonIds =
[
0x100003D9u, 0x100003DAu, 0x100003DBu,
0x100003DCu, 0x100003DDu, 0x100003DEu, 0x100003DFu,
];
foreach (uint buttonId in templateButtonIds)
{
Assert.IsType<UiButton>(
UiElement.FindDescendant(professionRoot, buttonId));
}
uint[] sliderContainerIds =
[
0x100003E6u, 0x100003E7u, 0x100003E8u,
0x100003E9u, 0x100003EAu, 0x100003EBu,
];
foreach (uint containerId in sliderContainerIds)
{
UiElement container = Assert.IsAssignableFrom<UiElement>(
UiElement.FindDescendant(professionRoot, containerId));
Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(container, 0x100002EEu));
// The value display authors as an editable Type-12 (retail's
// NumberInputFilter) — DatWidgetFactory maps that to UiField,
// not UiText. See CharacterCreationProfessionPage's ctor comment.
Assert.IsType<UiField>(
UiElement.FindDescendant(container, 0x100002EFu));
}
// Avail/health/stamina/mana each author as a Button whose Type-12
// value child is swallowed by UiButton.ConsumesDatChildren — the
// same substitution the Skills page's credits meter needed. See
// CharacterCreationProfessionPage's ctor comment.
foreach (uint containerId in new[]
{ 0x100003E2u, 0x100003E3u, 0x100003E4u, 0x100003E5u })
{
Assert.IsType<UiButton>(
UiElement.FindDescendant(professionRoot, containerId));
}
}
/// <summary>Skills listbox, credits meter, info panes (Skills page —
/// <c>gmCGSkillsPage::InitializePage @ 0x00481dd0</c>).</summary>
[InstalledDatFact]
public void SkillsPage_HasListboxCreditsAndInfoPanes()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats,
CharacterCreationUiController.RootEnum,
5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement skillsRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.SkillsPageElementId));
Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(skillsRoot, 0x100003F7u));
// The credits meter (decomp id 0x100002f3) authors as a raw dat
// child of button 0x100003f9; UiButton.ConsumesDatChildren swallows
// it before it becomes an addressable widget, so the faithful
// acdream substitute is the button's own Label — see
// CharacterCreationSkillsPage's ctor comment.
Assert.IsType<UiButton>(
UiElement.FindDescendant(skillsRoot, 0x100003F9u));
Assert.IsType<UiText>(
UiElement.FindDescendant(skillsRoot, 0x100003FBu));
Assert.IsType<UiText>(
UiElement.FindDescendant(skillsRoot, 0x100003FCu));
}
/// <summary>Four town buttons + description text (Town page —
/// <c>gmCGTownPage::InitializePage @ 0x0047c6d0</c>).</summary>
[InstalledDatFact]
public void TownPage_HasFourStarterAreaButtons()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats,
CharacterCreationUiController.RootEnum,
5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement townRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.TownPageElementId));
foreach (uint buttonId in new[] { 0x1000040Bu, 0x1000040Du, 0x1000040Eu, 0x1000040Fu })
{
Assert.IsType<UiButton>(
UiElement.FindDescendant(townRoot, buttonId));
}
Assert.IsType<UiText>(
UiElement.FindDescendant(townRoot, 0x10000409u));
}
/// <summary>
/// GF-11b/GF-11c (Campaign CC gate round 1 Batch B). Live-DAT-measured:
/// each town button's marker (<c>0x1000040D</c>'s own child
/// <c>0x1000040C</c>) and its per-button name caption both carry the
/// SAME numeric id <c>0x10000409</c> as the page-level description
/// panel found by the sibling test above — a genuine id collision in
/// the installed dat between two DIFFERENT elements in DIFFERENT
/// subtrees (harmless for <c>DatWidgetFactory.BuildButton</c>'s lift,
/// which walks the button's OWN <c>ElementInfo.Children</c> list rather
/// than resolving by a global id lookup — but it means this test
/// verifies the BUILT BUTTON's own <see cref="UiButton.Label"/>/
/// <see cref="UiButton.LabelBox"/>/<see cref="UiButton.LabelColor"/>,
/// not a second <c>FindDescendant</c> call, which would ambiguously
/// return the unrelated page-level panel). Pins: the caption's own
/// authored rect (0,4,100,37) survives instead of being overwritten by
/// the marker-face-relative offset (GF-11c), and its color swaps
/// Normal (218,167,85) -&gt; Highlight/white (255,255,255) on selection
/// (GF-11b).
/// </summary>
[InstalledDatFact]
public void TownPage_HoltburgButton_CaptionHonorsOwnRectAndRecolorsWhiteOnSelection()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement townRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.TownPageElementId));
UiButton holtburg = AssertButton(townRoot, 0x1000040Du);
Assert.Equal("Holtburg", holtburg.Label);
Assert.Equal(UiButton.LabelAlignment.Center, holtburg.LabelAlign);
Assert.Equal((0f, 4f, 100f, 37f), holtburg.LabelBox);
holtburg.Selected = false;
Assert.Equal(new Vector4(218f / 255f, 167f / 255f, 85f / 255f, 1f), holtburg.LabelColor);
holtburg.Selected = true;
Assert.Equal(new Vector4(1f, 1f, 1f, 1f), holtburg.LabelColor);
}
/// <summary>
/// GF-1 (Campaign CC gate round 1 Batch B). Live-DAT-measured: the
/// Heritage row (<c>0x100003BFu</c>, Aluvian) authors retail's custom
/// "Unselected"/"Selected" radio-pair state DESCRIPTORS directly on the
/// row (property-only, no media), with the actual per-state art on its
/// single stateful dot child (<c>0x100003C0</c>). Before this fix,
/// <see cref="UiButton.Selected"/> committed nothing here.
/// </summary>
[InstalledDatFact]
public void HeritagePage_Row_SelectedTogglesTheAuthoredRadioDotState()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement heritageRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.HeritagePageElementId));
UiButton aluvian = AssertButton(heritageRoot, 0x100003BFu);
Assert.Equal("Unselected", aluvian.ActiveState);
aluvian.Selected = true;
Assert.Equal("Selected", aluvian.ActiveState);
Assert.Equal(RetailUiStateIds.Selected, aluvian.ActiveRetailStateId);
aluvian.Selected = false;
Assert.Equal("Unselected", aluvian.ActiveState);
}
/// <summary>
/// GF-1 counterpart: the Profession template row (<c>0x100003D9u</c>,
/// Custom/Adventurer) authors the identical custom radio-pair shape on
/// its own icon child (<c>0x100002E9</c>).
/// </summary>
[InstalledDatFact]
public void ProfessionPage_TemplateButton_SelectedTogglesTheAuthoredIconState()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement professionRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.ProfessionPageElementId));
UiButton template = AssertButton(professionRoot, 0x100003D9u);
Assert.Equal("Unselected", template.ActiveState);
template.Selected = true;
Assert.Equal("Selected", template.ActiveState);
template.Selected = false;
Assert.Equal("Unselected", template.ActiveState);
}
/// <summary>
/// GF-8: the Appearance page's Face/Clothes sub-tab buttons
/// (<c>0x100003A9u</c>/<c>0x100003AAu</c>) author the SAME custom
/// radio-pair shape on their own icon child (<c>0x100002E9</c>) — same
/// mechanism as the heritage/template rows, different page.
/// </summary>
[InstalledDatFact]
public void AppearancePage_FaceClothesSubTabButtons_SelectedTogglesTheAuthoredState()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement appearanceRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.AppearancePageElementId));
foreach (uint buttonId in new[]
{
CharacterCreationAppearancePage.FaceButtonId,
CharacterCreationAppearancePage.ClothesButtonId,
})
{
UiButton button = AssertButton(appearanceRoot, buttonId);
Assert.Equal("Unselected", button.ActiveState);
button.Selected = true;
Assert.Equal("Selected", button.ActiveState);
button.Selected = false;
Assert.Equal("Unselected", button.ActiveState);
}
}
/// <summary>
/// GF-1 family, gender-button shape: <c>0x100003A7u</c>/
/// <c>0x100003A8u</c> author the custom Unselected/Selected media
/// DIRECTLY on the button's own StateMedia (no separate face-segment
/// child) — live-DAT-measured, distinct from the heritage/template/
/// sub-tab family above. Exercises <see cref="UiButton"/>'s OTHER
/// custom-selection-pair code path (media on the button itself, so
/// <c>info.StateMedia.Count != 0</c> and no face child is ever
/// computed).
/// </summary>
[InstalledDatFact]
public void AppearancePage_GenderButtons_SelectedTogglesTheAuthoredState()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement appearanceRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.AppearancePageElementId));
foreach (uint buttonId in new[]
{
CharacterCreationAppearancePage.FemaleButtonId,
CharacterCreationAppearancePage.MaleButtonId,
})
{
UiButton button = AssertButton(appearanceRoot, buttonId);
Assert.Equal("Unselected", button.ActiveState);
button.Selected = true;
Assert.Equal("Selected", button.ActiveState);
button.Selected = false;
Assert.Equal("Unselected", button.ActiveState);
}
}
/// <summary>
/// GF-9 (Campaign CC gate round 1 Batch B). Live-DAT-measured: all nine
/// companion overlay elements (<c>SwatchOverlayIds</c>) resolve as
/// siblings of the swatches under the color-wheel container — retail's
/// ACTUAL click-feedback mechanism (<c>SetColor</c>'s
/// <c>m_tColorWheel[...][0x10][iCurColor*7]-&gt;SetVisible</c>), not a
/// state swap on the swatch buttons themselves (which author only an
/// unnamed DirectState sprite — no Normal/Highlight media at all).
/// </summary>
[InstalledDatFact]
public void AppearancePage_SwatchOverlays_AllNinePresent()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement appearanceRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.AppearancePageElementId));
foreach (uint overlayId in CharacterCreationAppearancePage.SwatchOverlayIds)
{
UiElement overlay = Assert.IsAssignableFrom<UiElement>(
UiElement.FindDescendant(appearanceRoot, overlayId));
// Retail's overlay ring starts hidden — SetColor only shows the
// one at the current color index; nothing is selected before
// any color choice runs.
Assert.True(overlay.Visible);
}
}
/// <summary>
/// GF-10 (Campaign CC gate round 1 Batch B). Live-DAT-measured: both
/// zoom buttons author a STANDARD Normal/Highlight(/rollover) pair —
/// unlike the custom radio-pair family above, this is pure wiring
/// (<see cref="CharacterCreationAppearancePage"/>'s click handlers), not
/// a new UiButton mechanism.
/// </summary>
[InstalledDatFact]
public void AppearancePage_ZoomButtons_AuthorStandardNormalHighlightPair()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement appearanceRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.AppearancePageElementId));
foreach (uint buttonId in new[]
{
CharacterCreationAppearancePage.ZoomInId,
CharacterCreationAppearancePage.ZoomOutId,
})
{
UiButton button = AssertButton(appearanceRoot, buttonId);
Assert.Equal("Normal", button.ActiveState);
Assert.True(button.TrySetRetailState(UiButtonStateMachine.Highlight));
Assert.Equal("Highlight", button.ActiveState);
Assert.True(button.TrySetRetailState(UiButtonStateMachine.Normal));
Assert.Equal("Normal", button.ActiveState);
}
}
/// <summary>The exit-warning + all per-heritage/per-town DAT string
/// keys this slice cites actually resolve in the installed table.
/// </summary>
[InstalledDatFact]
public void RequiredChargenStringsResolve()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
var strings = new DatStringResolver(dats);
const uint table = 0x23000002u;
string[] keys =
[
"ID_CharGen_ExitWarning",
"ID_CharGen_Heritage_StartingSkills_Header",
"ID_CharGen_Heritage_StartingSkills",
"ID_CharGen_Heritage_BonusSkills_Trained_Header",
"ID_CharGen_AluvianText_BonusSkills_Trained",
"ID_CharGen_GaruText_BonusSkills_Trained",
"ID_CharGen_ShoText_BonusSkills_Trained",
"ID_CharGen_ViaText_BonusSkills_Trained",
"ID_CharGen_ShadText_BonusSkills_Trained",
"ID_CharGen_GearText_BonusSkills_Trained",
"ID_CharGen_AunTText_BonusSkills_Trained",
"ID_CharGen_EmpText_BonusSkills_Trained",
"ID_CharGen_UndText_BonusSkills_Trained",
"ID_CharGen_TownHowTo",
"ID_CharGen_HoltText",
"ID_CharGen_ShoushiText",
"ID_CharGen_YaraqText",
"ID_CharGen_SanamarText",
];
foreach (string key in keys)
{
string? resolved = strings.Resolve(table, DatStringResolver.ComputeHash(key));
Assert.True(resolved is not null, $"missing string: {key}");
}
}
/// <summary>
/// Campaign CC slice CC6b-MOUNT — the Appearance page's full authored
/// widget catalog. Pins the campaign plan's risk item 4 finding (the
/// color-wheel/gradient family resolves through EXISTING
/// <c>DatWidgetFactory</c> mappings; no new widget type was needed —
/// see <see cref="CharacterCreationAppearancePage"/>'s own class doc)
/// against the real installed DAT: gender/Face/Clothes buttons, all
/// nine spins, all nine color swatches, the shade scrollbar, and the
/// viewport.
/// </summary>
[InstalledDatFact]
public void AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement appearanceRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.AppearancePageElementId));
AssertButton(appearanceRoot, CharacterCreationAppearancePage.FemaleButtonId);
AssertButton(appearanceRoot, CharacterCreationAppearancePage.MaleButtonId);
AssertButton(appearanceRoot, CharacterCreationAppearancePage.FaceButtonId);
AssertButton(appearanceRoot, CharacterCreationAppearancePage.ClothesButtonId);
Assert.IsAssignableFrom<UiElement>(
UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.FaceChoicesId));
Assert.IsAssignableFrom<UiElement>(
UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.ClothesChoicesId));
foreach (uint spinId in new[]
{
CharacterCreationAppearancePage.HairSpinId,
CharacterCreationAppearancePage.EyesSpinId,
CharacterCreationAppearancePage.NoseSpinId,
CharacterCreationAppearancePage.MouthSpinId,
CharacterCreationAppearancePage.SkinSpinId,
CharacterCreationAppearancePage.HeadgearSpinId,
CharacterCreationAppearancePage.ShirtSpinId,
CharacterCreationAppearancePage.TrousersSpinId,
CharacterCreationAppearancePage.FootwearSpinId,
})
{
UiButton spin = AssertButton(appearanceRoot, spinId);
// Fix round F2 item 2: the current-part highlight
// (TrySetRetailState(Normal/Highlight)) only has a visible
// effect through UiButton's ToggleBehavior branch when the
// authored spin actually sets DAT property 0x0B — measured
// (not assumed, matching this file's own discipline for the
// arrow geometry above) True for all nine spins against the
// installed EoR dat. Pinned so a future DAT revision that
// drops it shows up here instead of as a silently-dead
// highlight.
Assert.True(spin.ToggleBehavior, $"spin 0x{spinId:X8} must author ToggleBehavior for the current-part highlight to work.");
// Re-review nit N2 (2026-08-15): ToggleBehavior alone is necessary
// but not sufficient — UiButton.UpdateVisualState only COMMITS the
// requested state when _availableStates actually contains it
// (UiButton.cs's TrySetRetailState -> Selected setter ->
// UpdateVisualState chain). MEASURED (not assumed) against the
// installed EoR dat: TrySetRetailState(Highlight) itself always
// reports success (the ToggleBehavior branch commits
// unconditionally, matching TrySetRetailState's own contract),
// but NONE of the nine spins actually carries Highlight /
// Highlight_rollover / Highlight_pressed media on either of
// their two consumed arrow face segments — every one of them
// authors only Normal / Normal_rollover / Ghosted. So F2 item 2's
// current-part highlight is CURRENTLY A NO-OP for every spin:
// ActiveState silently stays at its prior value ("Normal")
// instead of ever becoming "Highlight". The pre-existing
// ToggleBehavior pin above never caught this because it only
// checks the PROPERTY that gates the state-machine branch, not
// whether that branch has anything to actually draw. Filed as
// AP-222 (retail-vs-acdream status unresolved — retail's own
// gmCGAppearancePage::SetSelection call sites are cited for
// the SetState(1)/SetState(6) calls, not for whether retail's
// OWN spin art authors Highlight media either). Pinned to
// "Normal" so a future DAT revision that adds real Highlight
// media is what makes this assertion start failing — the
// correct trigger to update it to "Highlight" instead of a
// silently-reintroduced dead highlight going unnoticed either way.
Assert.True(
spin.TrySetRetailState(UiButtonStateMachine.Highlight),
$"spin 0x{spinId:X8} must accept a Highlight state request.");
Assert.Equal("Normal", spin.ActiveState);
// AP-222 CORRECTED + RESOLVED (Campaign CC gate round 1 Batch B):
// the art half of the "no-op" stays a genuine no-op (ActiveState
// pinned to "Normal" above, unchanged) — retail's own spin art
// authors no Highlight media either, matching acdream. But the
// current-part highlight is NOT presentation-dead: retail's
// SetState(6) also recolors the spin's caption text (dat
// property 0x1B), live-DAT-measured 218,167,85 (Normal) ->
// 255,221,131 (Highlight), plus outline off -> on (property
// 0x21). DatWidgetFactory.BuildButton wires this per-state style
// unconditionally, so it is already active on `spin` from
// TrySetRetailState(Highlight) above, independent of whether the
// page has assigned a Label string yet.
Assert.Equal(new Vector4(255f / 255f, 221f / 255f, 131f / 255f, 1f), spin.LabelColor);
Assert.True(spin.Outline, $"spin 0x{spinId:X8} must outline its label in the Highlight state.");
Assert.True(spin.TrySetRetailState(UiButtonStateMachine.Normal));
Assert.Equal(new Vector4(218f / 255f, 167f / 255f, 85f / 255f, 1f), spin.LabelColor);
Assert.False(spin.Outline, $"spin 0x{spinId:X8} must not outline its label in the Normal state.");
}
// Every color-wheel-family id resolves through EXISTING
// DatWidgetFactory mappings (Button=1, Scrollbar=0xB, the generic
// Type-3 fallback) — the risk-item-4 scouting result, pinned.
foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds)
AssertButton(appearanceRoot, swatchId);
UiScrollbar shadeScroll = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.ShadeScrollId));
// Fix round F11: measured (not assumed) against the installed EoR
// dat — the shade scrollbar (0x10000321) is authored VERTICAL
// (33x85, taller than wide). Before this fix, UiScrollbar.OnEvent
// only routed to ScalarChanged when Horizontal was true, so mouse
// input on this control never reached SetShadeFromScalar in
// production. Pinned so a future DAT revision that flips this
// orientation is caught here rather than silently reintroducing the
// dead-input bug (UiScrollbar's OnVerticalScalarEvent handles this
// orientation now, but ONLY this orientation gets exercised in
// production).
Assert.False(shadeScroll.Horizontal);
Assert.True(shadeScroll.Height > shadeScroll.Width);
Assert.IsType<UiDatElement>(
UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.GradCircleId));
AssertButton(appearanceRoot, CharacterCreationAppearancePage.RotateClockwiseId);
AssertButton(appearanceRoot, CharacterCreationAppearancePage.RotateCounterClockwiseId);
AssertButton(appearanceRoot, CharacterCreationAppearancePage.ZoomInId);
AssertButton(appearanceRoot, CharacterCreationAppearancePage.ZoomOutId);
Assert.IsType<UiViewport>(
UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.ViewportId));
}
/// <summary>
/// Live-DAT-measured arrow geometry the page's spin OnClickAt zones are
/// built from — every one of the nine spins is uniformly 200px wide
/// with the two locally-reused arrow child ids
/// (<c>0x1000030a</c> decrement / <c>0x1000030b</c> increment) at the
/// SAME local positions. If a future DAT revision changes this shared
/// template's geometry, this test (not a silent behavior change) is
/// where it shows up.
/// </summary>
[InstalledDatFact]
public void AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats, layoutId, CharacterCreationUiController.RootElementId));
ElementInfo? appearanceInfo = FindInfo(
rootInfo, CharacterCreationUiController.AppearancePageElementId);
Assert.NotNull(appearanceInfo);
foreach (uint spinId in new[]
{
CharacterCreationAppearancePage.HairSpinId,
CharacterCreationAppearancePage.EyesSpinId,
CharacterCreationAppearancePage.NoseSpinId,
CharacterCreationAppearancePage.MouthSpinId,
CharacterCreationAppearancePage.SkinSpinId,
CharacterCreationAppearancePage.HeadgearSpinId,
CharacterCreationAppearancePage.ShirtSpinId,
CharacterCreationAppearancePage.TrousersSpinId,
CharacterCreationAppearancePage.FootwearSpinId,
})
{
ElementInfo? spin = FindInfo(appearanceInfo!, spinId);
Assert.NotNull(spin);
Assert.Equal(200f, spin!.Width);
ElementInfo? decrement = spin.Children.FirstOrDefault(c => c.Id == 0x1000030Au);
ElementInfo? increment = spin.Children.FirstOrDefault(c => c.Id == 0x1000030Bu);
Assert.NotNull(decrement);
Assert.NotNull(increment);
Assert.Equal(80f, decrement!.X);
Assert.Equal(127f, increment!.X);
// Fix round F10: the arrow WIDTHS are what actually derive
// CharacterCreationAppearancePage.IncrementZoneEnd (174 =
// IncrementZoneStart 127 + this measured 47px width) — X alone
// pins the LEFT edge of each zone, not where the increment zone
// ends and the select-as-current-part body zone begins. Measured
// against the installed EoR dat: both arrows are 47px wide,
// uniformly, across all nine spins.
Assert.Equal(47f, decrement.Width);
Assert.Equal(47f, increment.Width);
}
}
private static ElementInfo? FindInfo(ElementInfo node, uint id)
{
if (node.Id == id) return node;
foreach (ElementInfo child in node.Children)
{
ElementInfo? found = FindInfo(child, id);
if (found is not null) return found;
}
return null;
}
private static RetailDialogFactory MakeDialogFactory(IDatReaderWriter dats, UiRoot host)
{
uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u);
ImportedLayout? CreateLayout(RetailDialogType type)
{
uint rootElementId = RetailDialogFactory.RootElementId(type);
return rootElementId == 0u
? null
: LayoutImporter.Import(
dats, dialogDid, rootElementId, _ => (0u, 0, 0), null);
}
return new RetailDialogFactory(host, CreateLayout);
}
/// <summary>
/// Campaign CC slice CC5 — the Summary page's full authored widget
/// catalog: the name field (with <c>NameInputFilter</c>), the how-to
/// text, the viewport (Summary's OWN <c>gmCG3DView</c>), and the
/// listbox's THREE row templates (single-line, category-header,
/// key/value pair) confirmed against the installed EoR dat — see
/// <see cref="CharacterCreationSummaryPage"/>'s own class doc for the
/// decomp citation (<c>SetSummaryText @ 0x0047b1d0</c>) each template
/// maps to.
/// </summary>
[InstalledDatFact]
public void SummaryPage_HasNameFieldListboxTemplatesAndViewport()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats,
CharacterCreationUiController.RootEnum,
5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement summaryRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.SummaryPageElementId));
Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ScrollId));
Assert.IsType<UiField>(
UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.NameTextId));
Assert.IsType<UiText>(
UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.HowToTextId));
Assert.IsType<UiViewport>(
UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ViewportId));
UiTemplateListBox list = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ListBoxId));
Assert.Equal(3, list.Templates.Count);
UiElement? ResolveRow(int index) =>
LayoutImporter.Import(
dats,
list.Templates[index].TemplateLayoutId,
list.Templates[index].TemplateElementId,
_ => (0u, 0, 0),
null)?.Root;
UiElement lineRow = Assert.IsAssignableFrom<UiElement>(ResolveRow(0));
Assert.IsType<UiText>(UiElement.FindDescendant(lineRow, 0x100002F9u));
UiElement headerRow = Assert.IsAssignableFrom<UiElement>(ResolveRow(1));
Assert.IsType<UiText>(UiElement.FindDescendant(headerRow, 0x100000FEu));
UiElement pairRow = Assert.IsAssignableFrom<UiElement>(ResolveRow(2));
Assert.IsType<UiText>(UiElement.FindDescendant(pairRow, 0x100002FCu));
Assert.IsType<UiText>(UiElement.FindDescendant(pairRow, 0x100002FDu));
}
/// <summary>
/// CC5 re-review residual round, R3 (2026-08-16): MEASURES the
/// installed global SkillTable's (portal.dat <c>0x0E000004</c>)
/// <c>MinLevel</c> distribution instead of inferring it — the C4
/// closeout's "observe, don't infer" lesson
/// (<c>docs/research/2026-08-05-c4-closeout-handoff.md</c>).
/// <see cref="AcDream.App.Net.RetailSkillFormula.CalculateChargenScore"/>'s
/// own doc comment used to claim "no retail-authored skill sets
/// MinLevel above Untrained=1" without ever reading the field; ACE's own
/// <c>SkillBase.cs</c> annotates the same field <c>// 1-2?</c> (a hedge
/// that observed 2s exist). This is that measurement — see the method's
/// own real-DAT finding recorded in <c>RetailSkillFormula.cs</c>'s doc
/// comment, corrected from this test's result.
/// </summary>
[InstalledDatFact]
public void SkillTable_MinLevelDistribution_NeverExceedsTrained()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
DatReaderWriter.DBObjs.SkillTable? skillTable =
dats.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
Assert.NotNull(skillTable);
var byMinLevel = skillTable!.Skills.Values
.GroupBy(skill => skill.MinLevel)
.OrderBy(g => g.Key)
.ToDictionary(g => g.Key, g => g.Count());
Console.WriteLine(
"[CC5-R3-DAT] SkillTable MinLevel distribution (level=count): "
+ string.Join(", ", byMinLevel.Select(p => $"{p.Key}={p.Value}"))
+ $" (total skills: {skillTable.Skills.Count})");
// RetailSkillFormula.CalculateChargenScore's own gate argument ("the
// decomp's own `if (edi_1 >= MinLevel)` gate passes for both callers,
// Trained=2 and Specialized=3") only holds while every skill's
// MinLevel stays at or below Trained(2) — pin that as a real
// assertion instead of leaving it as prose, so a future DAT
// revision that adds a MinLevel=3+ skill fails HERE, not silently
// under-credits that skill's chargen score.
Assert.True(
byMinLevel.Keys.All(minLevel => minLevel <= 2),
"A skill's MinLevel exceeded 2 (Trained) in the installed DAT — "
+ "RetailSkillFormula.CalculateChargenScore's Trained/Specialized "
+ "gate argument needs re-verification for this skill.");
}
/// <summary>
/// GF-13 (Campaign CC gate round 1, Batch A). Live-DAT-probe-confirmed:
/// the GM-only labels <c>0x10000403</c> ("Non-Admin") and
/// <c>0x10000494</c> ("Non-Envoy") both live under the Summary page
/// (<c>0x100003D6</c>, path <c>0x100003CC &gt; 0x100003D0 &gt;
/// 0x100003D6 &gt; {0x10000403,0x10000494}</c>) and both author dat
/// property <c>0x3B</c> (Invisible) = <see langword="true"/> — the exact
/// mechanism retail's <c>UIElement::OnSetAttribute @0x00462d80</c> case 8
/// hides them by. Pins the DATA half (<see cref="ElementInfo.Invisible"/>)
/// against the installed EoR dat.
/// </summary>
[InstalledDatFact]
public void SummaryPage_NonAdminNonEnvoyLabels_AuthorInvisibleTrue()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats, layoutId, CharacterCreationUiController.RootElementId));
foreach (uint targetId in new[] { 0x10000403u, 0x10000494u })
{
ElementInfo? found = FindInfo(rootInfo, targetId);
Assert.NotNull(found);
Assert.True(
found!.Invisible,
$"element 0x{targetId:X8} must author dat property 0x3B (Invisible) = true.");
}
}
/// <summary>
/// GF-13: the BEHAVIOR half — after the real controller mounts through
/// <see cref="CharacterCreationUiController.CreateDetached"/> (not a raw
/// <see cref="LayoutImporter.Build"/> call), the two authored-invisible
/// elements are not <see cref="UiElement.Visible"/>. Exercises
/// <c>HideAuthoredInvisibleElements</c>'s real chargen-scoped honor path,
/// not just the data plumbing the sibling test above pins.
/// </summary>
[InstalledDatFact]
public void SummaryPage_NonAdminNonEnvoyLabels_HiddenAfterControllerMount()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
var host = new UiRoot();
var dialogs = MakeDialogFactory(dats, host);
var bindings = new CharacterCreationRuntimeBindings(
() => null,
_ => default,
_ => default,
_ => default,
(_, _) => default,
(_, _) => default,
_ => default,
_ => default,
_ => default,
_ => default,
_ => default,
() => { });
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) =>
LayoutImporter.Import(
dats, templateLayoutId, templateElementId, _ => (0u, 0, 0), null)?.Root;
CharacterCreationUiController? controller =
CharacterCreationUiController.CreateDetached(
host, screen, ResolveTemplate, dialogs, bindings,
new CharacterCreationUiController.DialogStrings(
"Are you sure?", "No name", "Unspent credits", "Randomize?", "Name too long"));
Assert.NotNull(controller);
foreach (uint targetId in new[] { 0x10000403u, 0x10000494u })
{
UiElement found = Assert.IsAssignableFrom<UiElement>(
UiElement.FindDescendant(controller!.Root, targetId));
Assert.True(found.AuthoredInvisible, $"0x{targetId:X8} must carry AuthoredInvisible.");
Assert.False(found.Visible, $"0x{targetId:X8} must be hidden after mount.");
}
controller!.Dispose();
dialogs.Dispose();
}
/// <summary>
/// GF-5 (Campaign CC gate round 1, Batch A). Live-DAT-probe-confirmed
/// (raw <c>ElementDesc.Type</c> — the same id space as retail's
/// <c>DynamicCast</c> tags, 1=Button, 12=Text): the Skills listbox
/// authors exactly two templates. <c>Templates[0]</c>
/// (<c>0x100002F4</c>) is retail's own 3-child bucket-HEADER row
/// (unused by this port's flat-list simplification, AP-213).
/// <c>Templates[1]</c> (<c>0x100002FF</c>) is the REAL skill row — a
/// plain container root (rawType 3, NOT a Button), 7 children, byte-
/// traced against <c>gmCGSkillsPage::DoSkillRecords @ 0x004817e0</c> +
/// <c>tagSkillRecord</c>'s copy-constructor field order
/// (<c>acclient.h</c>): name (<c>0x10000301</c>, Text), pSkillLevelText
/// (<c>0x10000302</c>, Text), pUpCostText (<c>0x10000303</c>, Text),
/// pSkillUpButton (<c>0x10000304</c>, Button), pSkillDownButton
/// (<c>0x10000305</c>, Button), pDownCostText (<c>0x10000306</c>, Text).
/// See <see cref="CharacterCreationSkillsPage"/>'s own class doc for the
/// full trace.
/// </summary>
[InstalledDatFact]
public void SkillsPage_RealRowTemplate_HasNameLevelCostTextAndArrowButtons()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement skillsRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.SkillsPageElementId));
UiTemplateListBox list = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(skillsRoot, 0x100003F7u));
Assert.Equal(2, list.Templates.Count);
UiTemplateListEntry realRowTemplate = list.Templates[1];
Assert.Equal(0x100002FFu, realRowTemplate.TemplateElementId);
UiElement? row = LayoutImporter.Import(
dats,
realRowTemplate.TemplateLayoutId,
realRowTemplate.TemplateElementId,
_ => (0u, 0, 0),
null)?.Root;
UiElement realRow = Assert.IsAssignableFrom<UiElement>(row);
Assert.IsNotType<UiButton>(realRow);
Assert.IsType<UiText>(UiElement.FindDescendant(realRow, 0x10000301u));
Assert.IsType<UiText>(UiElement.FindDescendant(realRow, 0x10000302u));
Assert.IsType<UiText>(UiElement.FindDescendant(realRow, 0x10000303u));
Assert.IsType<UiText>(UiElement.FindDescendant(realRow, 0x10000306u));
Assert.IsType<UiButton>(UiElement.FindDescendant(realRow, 0x10000304u));
Assert.IsType<UiButton>(UiElement.FindDescendant(realRow, 0x10000305u));
// Templates[0] (0x100002F4) is retail's bucket-header row — unused
// by RebuildRows, still confirmed present so a future revision that
// drops it or changes its shape shows up here.
Assert.Equal(0x100002F4u, list.Templates[0].TemplateElementId);
}
/// <summary>
/// GF-15 (Campaign CC gate round 1, Batch A). Live-DAT-probe-confirmed
/// during the investigation: the Message dialog catalog's popup
/// (<c>0x3D</c>), message text (<c>0x3E</c>), and OK button
/// (<c>0x26</c>) all author REAL, nonzero geometry (popup 400x95,
/// centered by <see cref="RetailMessageDialogView.SizeAndCenter"/>) —
/// ruling out a zero-size/collapsed-layout explanation for the dialog
/// rendering nothing. The actual root cause was a Z-ORDER bug (the
/// chargen screen's own per-tick <c>BringToFront</c> burying the dialog
/// behind its opaque backdrop while the dialog kept exclusive
/// <see cref="UiRoot.Modal"/> input priority — fixed in
/// <see cref="RetailDialogFactory.Tick"/>). This test pins the geometry
/// half so a future DAT revision that collapses the popup/message/button
/// to zero size is caught here instead of silently reintroducing an
/// invisible dialog.
/// </summary>
[InstalledDatFact]
public void MessageDialogCatalog_PopupMessageAndOkButton_AuthorNonzeroGeometry()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u);
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(dats, dialogDid, 0x24u));
Assert.Equal(800f, rootInfo.Width);
Assert.Equal(600f, rootInfo.Height);
ElementInfo popup = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x3Du));
Assert.True(popup.Width > 0f && popup.Height > 0f);
ElementInfo message = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x3Eu));
Assert.True(message.Width > 0f && message.Height > 0f);
ElementInfo okButton = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x26u));
Assert.True(okButton.Width > 0f && okButton.Height > 0f);
}
/// <summary>
/// Campaign CC gate round 1 Batch C (GF-4a). Live-DAT-measured: each of
/// the four Profession display buttons (avail/health/stamina/mana
/// credits) and the Skills credits button author the CAPTION directly
/// as their OWN P0x17 property and carry exactly ONE Type-12 child with
/// NO state media of its own — the live VALUE slot
/// (<c>gmCGProfessionPage::InitializePage @0x00482f90-0x00483062</c>,
/// <c>gmCGSkillsPage::InitializePage @0x00481e1c</c>). Pins the shape
/// <see cref="DatWidgetFactory.BuildButton"/>'s <c>ValueLabel</c>
/// detection depends on.
/// </summary>
[InstalledDatFact]
public void ProfessionAndSkillsDisplayButtons_OwnCaptionPlusOneMediaLessValueChild()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats, layoutId, CharacterCreationUiController.RootElementId));
(uint Button, uint ValueChild)[] shapes =
[
(0x100003E2u, 0x100002F1u), // Profession available attribute credits
(0x100003E3u, 0x100002F3u), // Profession health
(0x100003E4u, 0x100002F3u), // Profession stamina
(0x100003E5u, 0x100002F3u), // Profession mana
(0x100003F9u, 0x100002F3u), // Skills credits
];
foreach ((uint buttonId, uint valueChildId) in shapes)
{
ElementInfo button = Assert.IsType<ElementInfo>(FindInfo(rootInfo, buttonId));
Assert.Equal(1u, button.Type);
Assert.True(
button.TryGetEffectiveProperty(0x17u, out UiPropertyValue caption)
&& caption.Kind == UiPropertyKind.StringInfo,
$"button 0x{buttonId:X8} must author its own P0x17 caption.");
ElementInfo singleChild = Assert.Single(button.Children);
Assert.Equal(valueChildId, singleChild.Id);
Assert.Equal(12u, singleChild.Type);
Assert.Empty(singleChild.StateMedia);
}
}
/// <summary>
/// GF-4b: the Profession page's six slider containers each carry a
/// name-label CHILD at the SAME relative id (<c>0x100002ed</c>) —
/// live-DAT-measured as Type 1 (<c>UIElement_Button</c>), matching
/// retail's own declared pointer type
/// (<c>class UIElement_Button* m_pHairSpin</c>-shaped fields
/// throughout <c>gmCGAppearancePage</c>/<c>gmCGProfessionPage</c> that
/// still receive <c>UIElement_Text::SetText</c> calls — retail's
/// <c>UIElement_Button</c> is DynamicCast-compatible with
/// <c>UIElement_Text</c> (id <c>0xc</c>), i.e. buttons carry their own
/// text-rendering capability). acdream's <c>UiButton.Label</c> is that
/// exact capability, so this element resolves as <see cref="UiButton"/>
/// in our port too, not <see cref="UiText"/>.
/// </summary>
[InstalledDatFact]
public void ProfessionPage_SliderContainers_HaveNameLabelButtonChild()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement professionRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.ProfessionPageElementId));
foreach (uint containerId in new[]
{
0x100003E6u, 0x100003E7u, 0x100003E8u,
0x100003E9u, 0x100003EAu, 0x100003EBu,
})
{
UiElement container = Assert.IsAssignableFrom<UiElement>(
UiElement.FindDescendant(professionRoot, containerId));
Assert.IsType<UiButton>(UiElement.FindDescendant(container, 0x100002EDu));
}
}
/// <summary>
/// Root 1d (Campaign CC gate round 1 Batch C): the Heritage
/// (<c>0x100003be</c>, 13 states) and Profession (<c>0x100003d8</c>,
/// 7 states) backdrops, live-DAT-measured against
/// <c>gmCGHeritagePage::Update</c>'s <c>m_pBackground-&gt;SetState</c>
/// literals and <c>gmCGProfessionPage::UpdateProfession</c>'s
/// per-template <c>eax_2-&gt;SetState</c> literals.
/// </summary>
[InstalledDatFact]
public void HeritageAndProfessionBackdrops_AuthorEveryRetailState()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats, layoutId, CharacterCreationUiController.RootElementId));
ElementInfo heritageBackdrop = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x100003BEu));
uint[] heritageStates =
[
0x10000021u, 0x10000022u, 0x10000023u, 0x10000024u, 0x10000058u,
0x10000059u, 0x1000005Au, 0x1000005Bu, 0x1000005Cu, 0x1000005Du,
0x1000005Eu, 0x1000005Fu, 0x10000060u,
];
foreach (uint stateId in heritageStates)
Assert.True(heritageBackdrop.States.ContainsKey(stateId), $"heritage backdrop missing state 0x{stateId:X8}");
ElementInfo professionBackdrop = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x100003D8u));
uint[] professionStates =
[
0x1000002Bu, 0x1000002Cu, 0x1000002Du,
0x1000002Eu, 0x1000002Fu, 0x10000030u, 0x10000031u,
];
foreach (uint stateId in professionStates)
Assert.True(professionBackdrop.States.ContainsKey(stateId), $"profession backdrop missing state 0x{stateId:X8}");
}
/// <summary>
/// GF-3: the Profession page's description textbox
/// (<c>0x100003e0</c>) resolves as <see cref="UiText"/> and (Commit-2
/// scope, pinned here for completeness) carries the SAME eight
/// gold-frame child ids the Town description (<c>0x10000409</c>) and
/// Summary how-to (<c>0x10000404</c>) boxes carry — one shared box
/// template reused across pages.
/// </summary>
[InstalledDatFact]
public void DescriptionTextboxes_ShareTheSameGoldFrameChildTemplate()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats, layoutId, CharacterCreationUiController.RootElementId));
uint[] frameChildIds =
[
0x100002DEu, 0x100002DFu, 0x100002E0u, 0x100002E1u,
0x100000E8u, 0x100002E2u, 0x100002E3u, 0x100000EAu,
];
foreach (uint boxId in new[] { 0x100003E0u, 0x10000409u, 0x10000404u })
{
ElementInfo box = Assert.IsType<ElementInfo>(FindInfo(rootInfo, boxId));
Assert.Equal(12u, box.Type);
foreach (uint frameChildId in frameChildIds)
Assert.Contains(box.Children, c => c.Id == frameChildId);
}
// The Summary how-to box additionally carries a linked scrollbar.
ElementInfo summaryHowTo = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x10000404u));
Assert.Contains(summaryHowTo.Children, c => c.Id == 0x100002E7u);
}
/// <summary>
/// Campaign CC gate round 1 Batch C, Commit 2: the eight gold-frame
/// pieces and the linked scrollbar — previously dropped outright by
/// <c>UiText.ConsumesDatChildren</c> — now build as REAL widgets
/// reachable via <see cref="UiElement.FindDescendant"/>, on all three
/// chargen description boxes.
/// </summary>
[InstalledDatFact]
public void DescriptionTextboxes_FramesAndScrollbarBuildAsRealWidgets()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
uint[] frameChildIds =
[
0x100002DEu, 0x100002DFu, 0x100002E0u, 0x100002E1u,
0x100000E8u, 0x100002E2u, 0x100002E3u, 0x100000EAu,
];
foreach (uint boxId in new[] { 0x100003E0u, 0x10000409u, 0x10000404u })
{
UiText box = Assert.IsType<UiText>(screen.FindElement(boxId));
foreach (uint frameChildId in frameChildIds)
Assert.NotNull(UiElement.FindDescendant(box, frameChildId));
}
UiText summaryHowTo = Assert.IsType<UiText>(screen.FindElement(0x10000404u));
Assert.IsType<UiScrollbar>(UiElement.FindDescendant(summaryHowTo, 0x100002E7u));
}
private static void AssertButton(ImportedLayout layout, uint elementId) =>
Assert.IsType<UiButton>(layout.FindElement(elementId));
private static UiButton AssertButton(UiElement root, uint elementId) =>
Assert.IsType<UiButton>(UiElement.FindDescendant(root, elementId));
private static ImportedLayout BuildSelected(
IDatReaderWriter dats,
uint layoutDid,
uint rootId)
{
ElementInfo info = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(dats, layoutDid, rootId));
return LayoutImporter.Build(
info,
_ => (0u, 0, 0),
null,
null,
new DatStringResolver(dats).Resolve);
}
}