acdream/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs
Erik d2a71152d2 fix(chargen): Campaign CC CC6b-MOUNT review fix round — F1-F13
Fixes every finding from the dual-lens review of 34c6fceab0 (architectural
PASS-with-items, retail-fidelity FAIL). Re-derived every decomp citation
against docs/research/named-retail/acclient_2013_pseudo_c.txt directly
rather than trusting the reviewer's transcription.

Wrap/normalize semantics (F1): CycleIndex's decrement-from-Unset landed on
0; the decomp's shared decrement tail (label_47f065/label_47f6d9, the same
switch the headgear ring was ported from) computes new=cur-1=-2 on the raw
signed int32, which wraps to count-1 — matching headgear's own ring shape.
Also ports the spin body-click normalize-and-write-back retail's cases
0xa5-0xae all share (NormalizeChoiceOnSelect), which acdream had dropped
entirely. Flips the one test that pinned the wrong expectation and adds
select-zone coverage no prior test isolated.

Heritage gate (F3): Update's Gearknight/Olthoi/OlthoiAcid branches reset
SetChoice(FACE)/SetSelection(HAIR) unconditionally, not only when Clothes
was showing — a conditional gate stranded Nose/Mouth as the current part
under a Face-tab session.

Doc corrections propagated everywhere they repeated (F4, F5, F6, plus the
plan doc's own CC6b-MOUNT ledger row for F1/F3): the gmBarberUI heading
citation conflated PostInit with InitializePage; Random's Appearance
disable was mislabeled a placeholder when it's really AP-212's unported
RandomizeAppearance/RandomizeClothing gap; the master-page doc still called
the Appearance page content-inert after this campaign made it real.

Visual substitutions widened (F2): AP-215 named only two of the Appearance
page's swatch/spin substitutions. Ports the two cheap ones directly —
current-part highlight via SetSelection's SetState(1)/SetState(6), routed
through the existing UiButtonStateMachine.Normal/Highlight ids and
IUiDatStateful.TrySetRetailState seam (installed-DAT-confirmed
ToggleBehavior=true on all nine spins); the shade scrollbar's SetVisible(0)
for Eyes vs acdream's Enabled=false. Files the other five (DoColorSpots,
the inert GradCircle, spin-caption/heritage-caption loss, the Skin-spin
MoveTo reposition, the Gearknight-boundary randomize calls) as new register
rows AP-216..AP-220 and corrects the plan doc's false claim that AP-215
already named the GradCircle.

Unlocked DAT read (F7, BLOCKER): ChargenPreviewController.Rebuild called
ChargenAppearanceFactory.TryCompose outside _datLock while the very next
line correctly locked TryBuildAnimated — CC6a's own F4 class of bug,
reintroduced at this catalog's first production call site. Wrapped in the
same lock; documented the invariant on ChargenAppearanceCatalog itself.

One-shot preview mount (F8): LivePresentationComposition reads
ChargenPreviewViewportWidget once, but its underlying mount
(CharacterCreationUiMountCoordinator) is explicitly retryable while this
GPU-resource composition pass is not — unlike PaperdollViewportWidget,
which IS eager/non-retryable, so the "mirrors Paperdoll" doc claim was
false. Retrofitting cross-frame retry here would mean restructuring this
composition's one-shot contract for every private viewport (paperdoll,
creature appraisal) and FrameRootComposition's fixed frame-group array —
out of this round's blast radius. Corrected the doc and made the failure
loud (a diagnostic log) instead of silent.

Dispose leak (F9): ChargenPreviewController.Dispose left the preview
WorldEntity referenced by the leased renderer until the renderer's own,
later disposal. Releases it on its own teardown now.

Test-quality items (F10, F11, F13): pinned the spin arrow widths
(47px, both arrows) the 174 zone boundary is derived from, plus a
controller test for the previously-uncovered select zone. Measured the
shade scrollbar's authored orientation instead of assuming it — it is
VERTICAL (33x85) — which is a real production bug: UiScrollbar only routed
scalar-mode mouse events when Horizontal was true, so the shade control
never fired in production. Added OnVerticalScalarEvent/DrawVerticalScalar
mirroring the existing horizontal scalar path. Converted
ChargenPreviewControllerTests from silent-pass [Fact] to the shared
InstalledDatFactAttribute skip-reporting pattern.

Adjudication (F12): AD-101's retirement leaves TryBeginFinish's four local
refusals (NoName/AttributeCreditsUnspent/AlreadyPending/RosterFull) with no
heritage/gender gate — currently latent since Finish stays hard-disabled
this round. Amended the campaign plan's CC5 slice scope to require BOTH a
heritage/gender refusal AND a real RandomizeCharacter port before the
connected user gate opens Finish; noted the interaction on AP-214's own
register row. No CC5 implementation in this commit.

Gates: dotnet build -c Release green across the full solution. App suite
(Release, ACDREAM_PROBE_LIVE_MOUNT=1) 5223/3 skips, Runtime suite
1713/0 — both clean across repeated runs. A full-solution run surfaced
three pre-existing, previously-documented flakes unrelated to this change
(Streaming.LandblockBuildFactoryTests/LandblockPresentationPipelineTests
#402, Core.Net.Tests.NakEmissionTests loss soak) — each confirmed passing
in isolation, consistent with their known full-suite-parallelism-timing
history; none touch any file this commit changes.

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

508 lines
23 KiB
C#

using System.IO;
using System.Linq;
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?"));
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>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.");
}
// 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);
}
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);
}
}