R2-4/review F1-F2 (gmCGSkillsPage): row click (and arrow click, matching retail's own post-Increase/DecreaseSkillLevel re-select) now selects a skill, highlights its row name, and writes the info panes' title (name + score) and a level-gated bonus line; the description/formula halves stay unported (SkillBase._description/_formula unreachable from this page's current data surface, documented on RefreshInfoBox). The listbox's own authored scrollbar link is wired to its Scroll model (live-DAT-confirmed at 0x100003F8, matching the "+1 from the listbox" hypothesis). Cost text now matches SetSkillText @0x00480600 exactly: Untrained's down-cost and Specialized's up-cost are literal "0", unconditional, where the port previously rendered blank; the 999-blank gate applies to the up-cost only, never to a down-cost. Arrow Ghosted/Enabled state (0x1000001a/ 0x1000001b) is now gated per branch, including bUntrainable/ bUnspecializable re-derived as "this row's own effective cost is nonzero" — no new data needed since the page already resolves that cost. R2-4b (the four-bucket sorted model) is NOT implemented — its Useable- vs-Unuseable-Untrained split reads SkillBase.MinLevel, confirmed present in the installed dat (SkillTable_MinLevelDistribution_NeverExceedsTrained) but not threaded through ChargenOptions/ChargenHeritageOptions/ CharacterCreationRuntimeBindings. AP-213 row records the exact channel a future fix needs. Also live-DAT-pinned: Templates[0]'s header-caption child (0x100002f6) resolves as a UiButton, not UiText, in the real dat — the same UIElement_Button-is-DynamicCast(0xc)-compatible-with-Text quirk already ported for GF-4b's slider labels. App suite (live-DAT env) 5321/3 -> 5328/3 (+7, zero regressions). Runtime 1735/0 unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1398 lines
67 KiB
C#
1398 lines
67 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) -> 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]->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",
|
|
// GF-3: Profession template description keys.
|
|
"ID_CharGen_CustomText",
|
|
"ID_CharGen_BowText",
|
|
"ID_CharGen_SwashText",
|
|
"ID_CharGen_LifeText",
|
|
"ID_CharGen_WarText",
|
|
"ID_CharGen_WayText",
|
|
"ID_CharGen_SoldierText",
|
|
// GF-6/AP-218: Appearance spin caption keys.
|
|
"ID_CharGen_HairStyle",
|
|
"ID_CharGen_Eyes",
|
|
"ID_CharGen_Skin",
|
|
"ID_CharGen_GearText_HairButton",
|
|
"ID_CharGen_GearText_EyesButton",
|
|
"ID_CharGen_GearText_SkinButton",
|
|
"ID_CharGen_OlthoiText_HairButton",
|
|
"ID_CharGen_OlthoiText_EyesButton",
|
|
"ID_CharGen_OlthoiText_SkinButton",
|
|
// Commit 3: Summary how-to text keys.
|
|
"ID_CharGen_SummaryHowTo",
|
|
"ID_CharGen_SummaryHowToEnd",
|
|
"ID_CharGen_AluMaleNames",
|
|
"ID_CharGen_AluFemaleNames",
|
|
"ID_CharGen_GharuMaleNames",
|
|
"ID_CharGen_GharuFemaleNames",
|
|
"ID_CharGen_ShoMaleNames",
|
|
"ID_CharGen_ShoFemaleNames",
|
|
"ID_CharGen_ViaMaleNames",
|
|
"ID_CharGen_ViaFemaleNames",
|
|
];
|
|
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 > 0x100003D0 >
|
|
/// 0x100003D6 > {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>
|
|
/// R2-4c (Campaign CC gate round 1 Batch F). Live-DAT probe: pins the
|
|
/// Skills listbox's own authored scrollbar link (dat property
|
|
/// <c>0x72</c>, <see cref="UiTemplateListBox.ScrollbarElementId"/>) —
|
|
/// CONFIRMED as <c>0x100003F8</c>, exactly the "+1 from the listbox"
|
|
/// hypothesis this batch's own investigation raised — and confirms a
|
|
/// real <see cref="UiScrollbar"/> resolves at that id under the Skills
|
|
/// page root, the exact fact
|
|
/// <see cref="CharacterCreationSkillsPage"/>'s constructor now wires
|
|
/// (<c>scrollbar.Model = list.Scroll</c>).
|
|
///
|
|
/// <para>
|
|
/// Also pins <c>Templates[0]</c>'s (<c>0x100002F4</c>) own header-
|
|
/// caption child id (<c>0x100002f6</c>, <c>DoSkillRecords @0x00481840</c>'s
|
|
/// own <c>GetChildRecursive</c> call) as prep evidence for whoever ports
|
|
/// the four-bucket sorted model (R2-4b, still open — see the AP-213
|
|
/// row): live-DAT-measured as a <see cref="UiButton"/>, NOT a
|
|
/// <see cref="UiText"/> — the SAME <c>UIElement_Button</c>-is-
|
|
/// <c>DynamicCast(0xc)</c>-compatible-with-<c>UIElement_Text</c> quirk
|
|
/// this campaign already ported for the six attribute-slider labels
|
|
/// (GF-4b) — retail's own <c>DynamicCast(0xc)</c> cast at
|
|
/// <c>0x00481855</c> would return null on a REAL Button object
|
|
/// otherwise, and the very next line unconditionally calls
|
|
/// <c>UIElement_Text::SetStringInfo</c> on it. A future port reads the
|
|
/// header caption through <c>UiButton.Label</c>, the same seam GF-4b
|
|
/// already established.
|
|
/// </para>
|
|
/// </summary>
|
|
[InstalledDatFact]
|
|
public void SkillsPage_Listbox_HasAScrollbarLink_AndHeaderTemplateHasACaptionChild()
|
|
{
|
|
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));
|
|
|
|
Console.WriteLine(
|
|
$"[CC-Batch-F-DAT] Skills listbox ScrollbarElementId=0x{list.ScrollbarElementId:X8}");
|
|
Assert.Equal(0x100003F8u, list.ScrollbarElementId);
|
|
Assert.IsType<UiScrollbar>(
|
|
UiElement.FindDescendant(skillsRoot, list.ScrollbarElementId));
|
|
|
|
UiTemplateListEntry headerTemplate = list.Templates[0];
|
|
Assert.Equal(0x100002F4u, headerTemplate.TemplateElementId);
|
|
UiElement? headerRow = LayoutImporter.Import(
|
|
dats,
|
|
headerTemplate.TemplateLayoutId,
|
|
headerTemplate.TemplateElementId,
|
|
_ => (0u, 0, 0),
|
|
null)?.Root;
|
|
UiElement realHeaderRow = Assert.IsAssignableFrom<UiElement>(headerRow);
|
|
Assert.IsType<UiButton>(UiElement.FindDescendant(realHeaderRow, 0x100002F6u));
|
|
}
|
|
|
|
/// <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->SetState</c>
|
|
/// literals and <c>gmCGProfessionPage::UpdateProfession</c>'s
|
|
/// per-template <c>eax_2->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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Commit 3: the Heritage description box (which ALSO carries the
|
|
/// linked scrollbar — live-DAT-measured, unlike Profession/Town's
|
|
/// shorter description boxes) resolves it as a real widget too, and
|
|
/// the mount wires <see cref="UiScrollbar.Model"/> to the box's own
|
|
/// <see cref="UiText.Scroll"/>.
|
|
/// </summary>
|
|
[InstalledDatFact]
|
|
public void HeritageDescription_ScrollbarBuildsAndLinksToTextScroll()
|
|
{
|
|
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));
|
|
UiText description = Assert.IsType<UiText>(
|
|
UiElement.FindDescendant(heritageRoot, 0x100003C4u));
|
|
UiScrollbar scroll = Assert.IsType<UiScrollbar>(
|
|
UiElement.FindDescendant(description, 0x100002E7u));
|
|
|
|
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();
|
|
|
|
Assert.Same(description.Scroll, scroll.Model);
|
|
|
|
controller.Dispose();
|
|
dialogs.Dispose();
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|