acdream/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.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

1472 lines
62 KiB
C#

using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign CC slice CC4 — controller binding tests for the character-
/// creation master shell + Heritage/Profession/Skills/Town pages, using a
/// hand-built layout fixture (no installed DAT — see
/// <see cref="CharacterCreationLiveDatTests"/> for the live-DAT id/type
/// sweep this pairs with). Mirrors <c>CharacterManagementUiControllerTests</c>'
/// fixture pattern.
/// </summary>
public sealed class CharacterCreationUiControllerTests
{
private const uint AluvianId = 1u;
private const uint OlthoiId = (uint)ChargenHeritageGroup.Olthoi;
private const uint GenderKey = 1u;
private const uint SkillTrainOnly = 1u;
private const uint SkillSpecializable = 2u;
[Fact]
public void ActiveScreen_KeepsAuthoredRootExtent_AndDefaultsToTheHeritagePage()
{
using var environment = new EnvironmentHarness();
Assert.False(environment.Controller.Root.Visible);
environment.Controller.Open();
Assert.True(environment.Controller.Root.Visible);
Assert.Equal(800f, environment.Controller.Root.Width);
Assert.Equal(600f, environment.Controller.Root.Height);
Assert.True(environment.Page(
CharacterCreationUiController.HeritagePageElementId).Visible);
Assert.False(environment.Page(
CharacterCreationUiController.ProfessionPageElementId).Visible);
Assert.True(environment.TabButton(
CharacterCreationUiController.HeritageTabElementId).Selected);
}
[Fact]
public void TabClick_SwitchesToTheClickedPage_FreeOfValidation()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
// Free navigation: jumping straight to Town from Heritage with
// nothing selected must still work (gmCharGenMainUI's tab dispatch
// is not gated — see ApplyProgressState's doc).
environment.TabButton(CharacterCreationUiController.TownTabElementId)
.OnClick!();
Assert.True(environment.Page(
CharacterCreationUiController.TownPageElementId).Visible);
Assert.False(environment.Page(
CharacterCreationUiController.HeritagePageElementId).Visible);
Assert.True(environment.TabButton(
CharacterCreationUiController.TownTabElementId).Selected);
}
[Fact]
public void Next_AdvancesOnePageAtATime_AndBackReturns()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Button(CharacterCreationUiController.NextElementId).OnClick!();
Assert.True(environment.Page(
CharacterCreationUiController.ProfessionPageElementId).Visible);
environment.Button(CharacterCreationUiController.BackElementId).OnClick!();
Assert.True(environment.Page(
CharacterCreationUiController.HeritagePageElementId).Visible);
}
[Fact]
public void Next_AtSummary_IsANoOp()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.TabButton(CharacterCreationUiController.SummaryTabElementId)
.OnClick!();
Assert.True(environment.Page(
CharacterCreationUiController.SummaryPageElementId).Visible);
environment.Button(CharacterCreationUiController.NextElementId).OnClick!();
Assert.True(environment.Page(
CharacterCreationUiController.SummaryPageElementId).Visible);
}
[Fact]
public void Finish_StaysGhosted_NoOnClickHandler()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
UiButton finish = environment.Button(CharacterCreationUiController.FinishElementId);
Assert.Null(finish.OnClick);
Assert.False(finish.Enabled);
}
[Fact]
public void Random_IsDisabledOnSkillsAppearanceAndSummaryPages()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
UiButton random = environment.Button(CharacterCreationUiController.RandomElementId);
Assert.True(random.Enabled);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
Assert.False(random.Enabled);
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
Assert.False(random.Enabled);
environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!();
Assert.False(random.Enabled);
environment.TabButton(CharacterCreationUiController.TownTabElementId).OnClick!();
Assert.True(random.Enabled);
}
/// <summary>gmCharGenMainUI::ListenToElementMessage @ 0x004e9450's
/// element 0x100003c6 case: at Heritage (the first page), Back opens
/// the exit confirmation instead of moving pages.</summary>
[Fact]
public void Back_AtHeritage_OpensExitConfirmation()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Button(CharacterCreationUiController.BackElementId).OnClick!();
Assert.True(environment.Dialogs.IsOpen);
}
[Fact]
public void Exit_Confirm_ClosesTheScreenAndCallsRequestExit()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Button(CharacterCreationUiController.ExitElementId).OnClick!();
Assert.True(environment.Dialogs.IsOpen);
environment.ConfirmActiveDialog(confirmed: true);
Assert.False(environment.Controller.Root.Visible);
Assert.Equal(1, environment.Runtime.RequestExitCalls);
}
[Fact]
public void Exit_Cancel_LeavesTheScreenOpen()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Button(CharacterCreationUiController.ExitElementId).OnClick!();
environment.ConfirmActiveDialog(confirmed: false);
Assert.True(environment.Controller.Root.Visible);
Assert.Equal(0, environment.Runtime.RequestExitCalls);
}
/// <summary>gmCGHeritagePage::ListenToElementMessage @ 0x00483860's
/// per-button SetHeritageGroup literal. AD-101 RETIRED at CC6b-MOUNT:
/// a heritage click no longer auto-selects a gender — the Appearance
/// page's real gender buttons are the only gender-selection path now
/// (see <see cref="AppearanceGenderButton_SelectsGender"/>).</summary>
[Fact]
public void HeritageButton_SelectsHeritage_WithNoGenderSideEffect()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Button(0x100003BFu).OnClick!(); // Aluvian
Assert.Equal(AluvianId, environment.Runtime.LastSelectedHeritage);
Assert.Equal(0u, environment.Runtime.LastSelectedGender);
}
/// <summary>gmCharGenMainUI::SetProgressState @ 0x004e7a10's Olthoi
/// branch: Profession/Skills/Town tabs hide, and paging past the
/// hidden range redirects to Appearance/Summary.</summary>
[Fact]
public void OlthoiHeritage_HidesProfessionSkillsAndTownTabs()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(OlthoiId);
environment.TabButton(CharacterCreationUiController.HeritageTabElementId)
.OnClick!();
Assert.False(environment.TabButton(
CharacterCreationUiController.ProfessionTabElementId).Visible);
Assert.False(environment.TabButton(
CharacterCreationUiController.SkillsTabElementId).Visible);
Assert.False(environment.TabButton(
CharacterCreationUiController.TownTabElementId).Visible);
// Next from Heritage would normally land on Profession; for an
// Olthoi heritage it must redirect straight to Appearance.
environment.Button(CharacterCreationUiController.NextElementId).OnClick!();
Assert.True(environment.Page(
CharacterCreationUiController.AppearancePageElementId).Visible);
}
[Fact]
public void ProfessionTemplateButton_SelectsTemplate()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId)
.OnClick!();
environment.Button(0x100003DAu).OnClick!(); // Bow Hunter = template index 1
Assert.Equal(1u, environment.Runtime.LastSelectedTemplate);
}
[Fact]
public void ProfessionSlider_ScalarChange_SetsTheAttribute()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId)
.OnClick!();
UiElement strengthContainer = Assert.IsAssignableFrom<UiElement>(
environment.Screen.FindElement(0x100003E6u));
var slider = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(strengthContainer, 0x100002EEu));
slider.ScalarChanged!(1f); // top of the [10,100] range
Assert.Equal(ChargenAttributeId.Strength, environment.Runtime.LastAttributeSet);
Assert.Equal(100, environment.Runtime.LastAttributeValue);
}
[Fact]
public void ProfessionValueField_DirectEntry_SetsTheAttribute()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId)
.OnClick!();
UiElement strengthContainer = Assert.IsAssignableFrom<UiElement>(
environment.Screen.FindElement(0x100003E6u));
var field = Assert.IsType<UiField>(
UiElement.FindDescendant(strengthContainer, 0x100002EFu));
field.OnSubmit!("42");
Assert.Equal(ChargenAttributeId.Strength, environment.Runtime.LastAttributeSet);
Assert.Equal(42, environment.Runtime.LastAttributeValue);
}
[Fact]
public void SkillsRow_Click_TrainsThenSpecializes()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId)
.OnClick!();
// Rows are built in ascending skill-id order (RebuildRows' 1..54
// walk over IsCostable ids) — SkillSpecializable's row is identified
// by its label prefix (FormatSkillLabel's "{name}: ..." shape)
// rather than instance identity, since the controller owns the
// row->skillId map privately.
string skillName = ItemAppraisalTextFormatter.SkillName((int)SkillSpecializable);
UiButton row = environment.SkillsList().ViewportForTest!.Children
.OfType<UiButton>()
.Single(candidate => candidate.Label!.StartsWith(
skillName + ":", StringComparison.Ordinal));
row.OnClick!();
Assert.Equal(ChargenSkillAdvancementClass.Trained,
environment.Runtime.GetSkillLevel(SkillSpecializable));
row.OnClick!();
Assert.Equal(ChargenSkillAdvancementClass.Specialized,
environment.Runtime.GetSkillLevel(SkillSpecializable));
row.OnDoubleClick!();
Assert.Equal(ChargenSkillAdvancementClass.Trained,
environment.Runtime.GetSkillLevel(SkillSpecializable));
}
[Fact]
public void TownButton_SelectsTheLiteralStartAreaIndex()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.TabButton(CharacterCreationUiController.TownTabElementId)
.OnClick!();
// Holtburg (0x1000040d) -> startArea 0 per SetTown's literal map.
environment.Button(0x1000040Du).OnClick!();
Assert.Equal(0, environment.Runtime.LastSelectedStartArea);
// Yaraq (0x1000040e) -> startArea 2.
environment.Button(0x1000040Eu).OnClick!();
Assert.Equal(2, environment.Runtime.LastSelectedStartArea);
}
/// <summary>Review fix round F4 (2026-08-15): <c>gmCGTownPage::SetTown
/// @ 0x0047c360</c> also sets the TOWN PAGE'S OWN retail state via a
/// literal per-town map — Holtburg-&gt;0x10000034, Yaraq-&gt;0x10000036 —
/// SEPARATE from the master page's per-page-index cycling
/// (<c>0x10000025+page</c>, already covered by the page-switch tests
/// above).</summary>
[Fact]
public void TownButton_Refresh_SetsThePagesOwnRetailStateLiteral()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.TabButton(CharacterCreationUiController.TownTabElementId)
.OnClick!();
var pageRoot = Assert.IsType<UiDatElement>(
environment.Page(CharacterCreationUiController.TownPageElementId));
environment.Button(0x1000040Du).OnClick!(); // Holtburg -> startArea 0
BumpRevisionAndTick(environment);
Assert.Equal("Holtburg", pageRoot.ActiveState);
environment.Button(0x1000040Eu).OnClick!(); // Yaraq -> startArea 2
BumpRevisionAndTick(environment);
Assert.Equal("Yaraq", pageRoot.ActiveState);
}
/// <summary>Review fix round F2 (2026-08-15), the display direction:
/// <c>gmCGProfessionPage::UpdateAttributeValues @ 0x0048251d</c> sets
/// the slider's scalar position to <c>value * 0.00999999978f</c>
/// (value/100), not a [10,100]-to-[0,1] rescale.</summary>
[Fact]
public void ProfessionSlider_Refresh_DisplaysScalarAsValueOverOneHundred()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId)
.OnClick!();
UiElement strengthContainer = Assert.IsAssignableFrom<UiElement>(
environment.Screen.FindElement(0x100003E6u));
var slider = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(strengthContainer, 0x100002EEu));
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
environment.Runtime.View.Snapshot = snapshot with
{
Revision = snapshot.Revision + 1,
Attributes = snapshot.Attributes with { Strength = 55 },
};
environment.Controller.Tick();
Assert.Equal(0.55f, slider.ScalarPosition);
}
/// <summary>Review fix round F2 (2026-08-15), the drag-inverse
/// direction: <c>ListenToElementMessage @ 0x004829c0</c>'s scrollbar-
/// drag case truncates <c>scalar*100</c> and clamps LOW only to 10 —
/// NOT the [10,100]&lt;-&gt;[0,1] rescale the previous (wrong) formula
/// used, which only coincidentally agreed with the correct one at
/// scalar=1 (the pre-existing
/// <see cref="ProfessionSlider_ScalarChange_SetsTheAttribute"/> case).</summary>
[Theory]
[InlineData(0.5f, 50)]
[InlineData(0f, 10)]
public void ProfessionSlider_ScalarChange_TruncatesAndClampsLowOnly(
float scalar,
int expectedValue)
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId)
.OnClick!();
UiElement strengthContainer = Assert.IsAssignableFrom<UiElement>(
environment.Screen.FindElement(0x100003E6u));
var slider = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(strengthContainer, 0x100002EEu));
slider.ScalarChanged!(scalar);
Assert.Equal(ChargenAttributeId.Strength, environment.Runtime.LastAttributeSet);
Assert.Equal(expectedValue, environment.Runtime.LastAttributeValue);
}
/// <summary>Review fix round F3 (2026-08-15):
/// <c>gmCharGenMainUI::ListenToElementMessage @ 0x004e9450</c>'s
/// heritage-button bubble arm shows/hides the Profession/Skills/Town
/// tabs SYNCHRONOUSLY at click time — independent of
/// <see cref="OlthoiHeritage_HidesProfessionSkillsAndTownTabs"/>'s
/// page-switch-time recompute (no tab/Back/Next click happens in this
/// test at all). Lugian (<c>0x100005f1</c>) sits outside BOTH the SHOW
/// and HIDE case lists in the decompiled switch — a genuine retail
/// quirk, reproduced faithfully.</summary>
[Fact]
public void HeritageButtonClick_RestoresHiddenTabsAtClickTime_ExceptLugian()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Button(0x100005C7u).OnClick!(); // Olthoi -> HIDE
Assert.False(environment.TabButton(
CharacterCreationUiController.ProfessionTabElementId).Visible);
Assert.False(environment.TabButton(
CharacterCreationUiController.SkillsTabElementId).Visible);
Assert.False(environment.TabButton(
CharacterCreationUiController.TownTabElementId).Visible);
environment.Button(0x100005F1u).OnClick!(); // Lugian -> no-op quirk
Assert.False(environment.TabButton(
CharacterCreationUiController.ProfessionTabElementId).Visible);
Assert.False(environment.TabButton(
CharacterCreationUiController.SkillsTabElementId).Visible);
Assert.False(environment.TabButton(
CharacterCreationUiController.TownTabElementId).Visible);
environment.Button(0x100003BFu).OnClick!(); // Aluvian -> SHOW
Assert.True(environment.TabButton(
CharacterCreationUiController.ProfessionTabElementId).Visible);
Assert.True(environment.TabButton(
CharacterCreationUiController.SkillsTabElementId).Visible);
Assert.True(environment.TabButton(
CharacterCreationUiController.TownTabElementId).Visible);
}
/// <summary>Review fix round F1 (2026-08-15): <c>Open()</c> sets
/// <c>UiRoot.FixedCanvasSize</c> once on the activation edge (matching
/// <c>CharacterManagementUiController</c>'s real, non-per-tick shape);
/// <c>Close()</c>/<c>Deactivate()</c>/<c>Dispose()</c> null it back out
/// symmetrically. Before this fix nothing ever nulled it, so an
/// 800x600-scaled canvas silently covered the in-world UI for the rest
/// of the session once this screen had ever been opened.</summary>
[Fact]
public void Open_SetsFixedCanvas_ExitConfirmClosesAndNullsIt()
{
using var environment = new EnvironmentHarness();
Assert.Null(environment.Host.FixedCanvasSize);
environment.Controller.Open();
Assert.Equal(new Vector2(800f, 600f), environment.Host.FixedCanvasSize);
environment.Button(CharacterCreationUiController.ExitElementId).OnClick!();
environment.ConfirmActiveDialog(confirmed: true);
Assert.Null(environment.Host.FixedCanvasSize);
}
[Fact]
public void Deactivate_NullsFixedCanvas_AndClosesTheScreen()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
Assert.NotNull(environment.Host.FixedCanvasSize);
// Runtime reporting the view inactive/gone (e.g. entering the
// world) must Deactivate -- previously nothing drove this because
// RuntimeCharacterCreationState had no CompleteEnter() analogue;
// this test exercises the CONTROLLER side of that fix directly by
// simulating the view disappearing.
environment.Runtime.ProvideView = false;
environment.Controller.Tick();
Assert.False(environment.Controller.Root.Visible);
Assert.Null(environment.Host.FixedCanvasSize);
}
[Fact]
public void Dispose_NullsFixedCanvas()
{
var environment = new EnvironmentHarness();
environment.Controller.Open();
Assert.NotNull(environment.Host.FixedCanvasSize);
environment.Dispose();
Assert.Null(environment.Host.FixedCanvasSize);
}
// ── Campaign CC slice CC6b-MOUNT: Appearance page ───────────────────
[Fact]
public void AppearanceGenderButton_SelectsGender()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
environment.Button(CharacterCreationAppearancePage.MaleButtonId).OnClick!();
Assert.Equal(1u, environment.Runtime.LastSelectedGender);
environment.Button(CharacterCreationAppearancePage.FemaleButtonId).OnClick!();
Assert.Equal(2u, environment.Runtime.LastSelectedGender);
}
/// <summary>Spin arrow geometry (live-DAT-measured, see
/// <see cref="CharacterCreationAppearancePage"/>'s own class doc):
/// x=[80,127) is the decrement child, x=[127,174) is the increment
/// child.</summary>
[Fact]
public void AppearanceSpin_IncrementZoneClick_CyclesStyleForwardFromUnset()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(150, 10);
Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot);
Assert.Equal(0u, environment.Runtime.LastAppearanceIndex);
}
/// <summary>
/// Fix round F1: this test previously asserted index 0 here, pinning a
/// doc claim ("no decomp-observable Unset-cycling case") the decomp
/// refutes. Hair's own decrement case
/// (<c>gmCGAppearancePage::ListenToElementMessage @0x0047f465-0x0047f086</c>,
/// the same shared tail the headgear ring reuses at
/// <c>label_47f065</c>/<c>label_47f6d9</c>) computes
/// <c>new = cur - 1</c> on the raw signed int32 (Unset = -1), giving
/// <c>new = -2</c>; since <c>-2 &lt; 0</c> it wraps to <c>count - 1</c>,
/// NOT 0 — see <see cref="CharacterCreationAppearancePage.CycleIndex"/>'s
/// own corrected doc. The fixture's Hair style count is 3
/// (<see cref="EnvironmentHarness.BuildOptions"/>), so the expected
/// landing index is 2.
/// </summary>
[Fact]
public void AppearanceSpin_DecrementZoneClick_FromUnset_WrapsToLastStyle()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(100, 10);
Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot);
Assert.Equal(2u, environment.Runtime.LastAppearanceIndex);
}
/// <summary>
/// Fix round F10: pins the SELECT zone — x=[174,200), right of the
/// increment arrow's own x=[127,174) (live-DAT-measured, both arrows
/// 47px wide — <c>CharacterCreationLiveDatTests</c>) — as a body click
/// that selects the part WITHOUT invoking another style cycle, once the
/// part already holds a real (non-Unset) index. Distinguishes the third
/// <c>OnClickAt</c> zone from the two arrow-zone tests above, which no
/// prior test isolated.
/// </summary>
[Fact]
public void AppearanceSpin_SelectZoneClick_SelectsPartWithoutChangingIndex()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
// Establish a real Hair index first (increment zone, x=150).
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(150, 10);
Assert.Equal(0u, environment.Runtime.LastAppearanceIndex);
int callsAfterCycle = environment.Runtime.AppearanceIndexCallCount;
// x=180 is inside [174,200) — past the increment arrow's own zone,
// still inside the 200px-wide spin — the spin's own BODY, not
// either arrow.
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(180, 10);
// Already in [0,count) — NormalizeChoiceOnSelect (F1) is a no-op,
// so the select zone must not re-invoke SetAppearanceIndex.
Assert.Equal(callsAfterCycle, environment.Runtime.AppearanceIndexCallCount);
Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot);
Assert.Equal(0u, environment.Runtime.LastAppearanceIndex);
}
/// <summary>
/// Fix round F1: pins the body-click normalize-and-write-back
/// (<see cref="CharacterCreationAppearancePage"/>'s own
/// <c>NormalizeChoiceOnSelect</c> doc) for the case that's actually
/// reachable in acdream — a part still Unset (AP-214 honest-blank open)
/// gets clicked in its SELECT zone (not an arrow) — wraps to
/// <c>count-1</c> and writes it back, exactly like a decrement click
/// would, even though no arrow was pressed.
/// </summary>
[Fact]
public void AppearanceSpin_SelectZoneClick_FromUnset_NormalizesToLastStyle()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
// Hair starts Unset (AP-214 honest-blank). x=180 is the select
// zone, not either arrow.
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(180, 10);
Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot);
Assert.Equal(2u, environment.Runtime.LastAppearanceIndex); // count-1, fixture has 3 hair styles.
}
/// <summary>CharGenState::SetHeadgearStyle's decomp-derived
/// (count+1)-position ring: incrementing from Unset lands on style 0;
/// decrementing FROM style 0 lands back on Unset. Headgear is the ONLY
/// spin with this ring — see <see cref="CharacterCreationAppearancePage.CycleIndex"/>'s
/// own citation.</summary>
[Fact]
public void AppearanceHeadgearSpin_RingIncludesTheUnsetPosition()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
environment.Button(CharacterCreationAppearancePage.ClothesButtonId).OnClick!();
environment.Button(CharacterCreationAppearancePage.HeadgearSpinId).OnClickAt!(150, 10); // increment
Assert.Equal(ChargenAppearanceSlot.HeadgearStyle, environment.Runtime.LastAppearanceSlot);
Assert.Equal(0u, environment.Runtime.LastAppearanceIndex);
environment.Button(CharacterCreationAppearancePage.HeadgearSpinId).OnClickAt!(100, 10); // decrement
Assert.Equal(RuntimeCharacterCreationAppearance.Unset, environment.Runtime.LastAppearanceIndex);
}
/// <summary>Pure wrap-semantics unit tests for
/// <see cref="CharacterCreationAppearancePage.CycleIndex"/> — the
/// decomp-derived arithmetic every spin's OnClickAt zone drives.
/// </summary>
[Theory]
[InlineData(0u, +1, 3, false, 1u)]
[InlineData(2u, +1, 3, false, 0u)] // plain wrap forward past the end.
[InlineData(1u, -1, 3, false, 0u)]
[InlineData(0u, -1, 3, false, 2u)] // plain wrap backward past the start.
[InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, false, 0u)]
// Fix round F1: decrement-from-Unset wraps to count-1 (2), not 0 — the
// decomp's shared decrement tail (label_47f065/label_47f6d9) computes
// new = cur - 1 = -2 on the raw signed int32, which is < 0, so it wraps
// to count-1 exactly like headgear's own ring does for its non-Unset
// range. See CycleIndex's own corrected doc.
[InlineData(RuntimeCharacterCreationAppearance.Unset, -1, 3, false, 2u)]
[InlineData(0u, -1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: 0 -> Unset.
[InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, true, 0u)] // headgear ring: Unset -> 0.
[InlineData(2u, +1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: last -> Unset.
[InlineData(RuntimeCharacterCreationAppearance.Unset, -1, 3, true, 2u)] // headgear ring: Unset -> last.
public void CycleIndex_MatchesRetailsDecompDerivedWrap(
uint current, int delta, int count, bool allowUnset, uint expected)
{
Assert.Equal(
expected,
CharacterCreationAppearancePage.CycleIndex(current, delta, count, allowUnset));
}
/// <summary>Skin has no style index — retail disables its arrow
/// children (SetAttribute_Bool(...,0xd,1)). Every click on the skin
/// spin only selects it as the current part.</summary>
[Fact]
public void AppearanceSkinSpin_Click_NeverCallsSetAppearanceIndex()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
environment.Button(CharacterCreationAppearancePage.SkinSpinId).OnClickAt!(100, 10);
environment.Button(CharacterCreationAppearancePage.SkinSpinId).OnClickAt!(150, 10);
Assert.Equal(0, environment.Runtime.AppearanceIndexCallCount);
}
/// <summary>gmCGAppearancePage::Update's heritage 6/0xc/0xd gate: the
/// Clothes sub-tab and the Nose/Mouth spins all hide.</summary>
[Fact]
public void OlthoiHeritage_HidesClothesButtonAndNoseMouthSpins()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(OlthoiId);
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
Assert.False(environment.Button(CharacterCreationAppearancePage.ClothesButtonId).Visible);
Assert.False(environment.Button(CharacterCreationAppearancePage.NoseSpinId).Visible);
Assert.False(environment.Button(CharacterCreationAppearancePage.MouthSpinId).Visible);
}
/// <summary>Fixture's Hair color list has 3 entries (indices 0-2) — a
/// swatch beyond that never reaches SetAppearanceIndex, matching
/// retail's own <c>iNumColors &gt; N</c> gate.</summary>
[Fact]
public void AppearanceSwatch_WithinColorCount_SetsColorForTheCurrentPart()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
// Part defaults to Hair on construction — no extra click needed.
environment.Button(CharacterCreationAppearancePage.SwatchIds[1]).OnClick!();
Assert.Equal(ChargenAppearanceSlot.HairColor, environment.Runtime.LastAppearanceSlot);
Assert.Equal(1u, environment.Runtime.LastAppearanceIndex);
}
[Fact]
public void AppearanceSwatch_BeyondColorCount_IsANoOp()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
environment.Button(CharacterCreationAppearancePage.SwatchIds[^1]).OnClick!(); // index 8, count 3.
Assert.Equal(0, environment.Runtime.AppearanceIndexCallCount);
}
[Fact]
public void AppearanceShadeScroll_ScalarChanged_SetsShadeForTheCurrentPart()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
environment.ShadeScroll().ScalarChanged!(0.75f);
Assert.Equal(ChargenShadeSlot.Hair, environment.Runtime.LastShadeSlot);
Assert.Equal(0.75, environment.Runtime.LastShadeValue, 3);
}
/// <summary>Nose/Mouth/Skin all route the shade scroll to SKIN shade —
/// SetShade's cases 2/3/4 share one body in the decompiled switch.
/// </summary>
[Fact]
public void AppearanceShadeScroll_ForNoseOrMouth_RoutesToSkinShade()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
// Select Nose as the current part: click its body (outside the
// arrow zones), which never changes the style index.
environment.Button(CharacterCreationAppearancePage.NoseSpinId).OnClickAt!(10, 10);
environment.ShadeScroll().ScalarChanged!(0.5f);
Assert.Equal(ChargenShadeSlot.Skin, environment.Runtime.LastShadeSlot);
}
[Fact]
public void AppearanceZoomAndRotateButtons_DelegateToThePreviewControl()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
var preview = new FakeChargenPreviewControl();
environment.Controller.AppearancePreviewControl = preview;
environment.Button(CharacterCreationAppearancePage.ZoomInId).OnClick!();
environment.Button(CharacterCreationAppearancePage.ZoomOutId).OnClick!();
environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!();
environment.Button(CharacterCreationAppearancePage.RotateCounterClockwiseId).OnClick!();
Assert.Equal(1, preview.ZoomInCalls);
Assert.Equal(1, preview.ZoomOutCalls);
Assert.Equal(1, preview.RotateClockwiseCalls);
Assert.Equal(1, preview.RotateCounterClockwiseCalls);
}
[Fact]
public void AppearanceZoomButtons_WithNoPreviewControlAssignedYet_AreHarmlessNoOps()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
// The page mounts before the graphics backend exists — every zoom/
// rotate click before LivePresentationComposition assigns a real
// control must be a silent no-op, not a NullReferenceException.
environment.Button(CharacterCreationAppearancePage.ZoomInId).OnClick!();
environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!();
}
private static void SelectAluvianMale(EnvironmentHarness environment)
{
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
environment.Button(CharacterCreationAppearancePage.MaleButtonId).OnClick!();
}
private sealed class FakeChargenPreviewControl : AcDream.App.Rendering.IChargenPreviewControl
{
public int ZoomInCalls { get; private set; }
public int ZoomOutCalls { get; private set; }
public int RotateClockwiseCalls { get; private set; }
public int RotateCounterClockwiseCalls { get; private set; }
public bool Rebuild(
ChargenOptions options,
uint heritageId,
int genderKey,
ChargenAppearanceSelection selection) => true;
public void ZoomIn() => ZoomInCalls++;
public void ZoomOut() => ZoomOutCalls++;
public void RotateClockwise() => RotateClockwiseCalls++;
public void RotateCounterClockwise() => RotateCounterClockwiseCalls++;
}
private static void BumpRevisionAndTick(EnvironmentHarness environment)
{
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
environment.Runtime.View.Snapshot = snapshot with { Revision = snapshot.Revision + 1 };
environment.Controller.Tick();
}
private static IEnumerable<UiElement> Descendants(UiElement root)
{
yield return root;
foreach (UiElement child in root.Children)
foreach (UiElement descendant in Descendants(child))
yield return descendant;
}
// ── Fixture ──────────────────────────────────────────────────────────
private sealed class EnvironmentHarness : IDisposable
{
private readonly List<ImportedLayout> _dialogLayouts = [];
public EnvironmentHarness()
{
Host = new UiRoot { Width = 800f, Height = 600f };
Screen = BuildScreen();
Runtime = new FakeRuntime();
Dialogs = new RetailDialogFactory(Host, type =>
{
ImportedLayout layout = RetailDialogFactoryTests.BuildDialogLayout(type);
_dialogLayouts.Add(layout);
return layout;
});
Controller = Assert.IsType<CharacterCreationUiController>(
CharacterCreationUiController.CreateDetached(
Host,
Screen,
ResolveSkillRowTemplate,
Dialogs,
Runtime.Bindings,
new CharacterCreationUiController.DialogStrings(
"Are you sure you want to leave?")));
Controller.AttachAndTick();
}
public UiRoot Host { get; }
public ImportedLayout Screen { get; }
public FakeRuntime Runtime { get; }
public RetailDialogFactory Dialogs { get; }
public CharacterCreationUiController Controller { get; }
public UiButton Button(uint id) =>
Assert.IsType<UiButton>(Screen.FindElement(id));
public UiButton TabButton(uint id) => Button(id);
public UiElement Page(uint id) =>
Assert.IsAssignableFrom<UiElement>(Screen.FindElement(id));
public UiTemplateListBox SkillsList() =>
Assert.IsType<UiTemplateListBox>(Screen.FindElement(0x100003F7u));
public UiScrollbar ShadeScroll() =>
Assert.IsType<UiScrollbar>(Screen.FindElement(CharacterCreationAppearancePage.ShadeScrollId));
/// <summary>Confirms or cancels the MOST RECENTLY opened confirmation
/// dialog, using <see cref="RetailConfirmationDialogView"/>'s real
/// button ids off the layout the factory's <c>createLayout</c>
/// callback actually returned — same lookup shape
/// <c>CharacterManagementUiControllerTests</c> uses.</summary>
public void ConfirmActiveDialog(bool confirmed)
{
ImportedLayout dialog = _dialogLayouts[^1];
uint buttonId = confirmed
? RetailConfirmationDialogView.AcceptButtonId
: RetailConfirmationDialogView.RejectButtonId;
UiButton button = Assert.IsType<UiButton>(dialog.FindElement(buttonId));
button.OnClick!();
}
private static UiElement? ResolveSkillRowTemplate(
uint templateLayoutId,
uint templateElementId) =>
BuildSkillRowTemplate(templateElementId);
public void Dispose()
{
Controller.Dispose();
Dialogs.Dispose();
}
}
private sealed class FakeRuntime
{
private static readonly RuntimeGenerationToken Generation = new(3u);
public FakeRuntime()
{
View = new FakeView(BuildOptions());
Bindings = new CharacterCreationRuntimeBindings(
() => ProvideView ? View : null,
SelectHeritage,
SelectGender,
SelectTemplate,
SetAttribute,
(_, _) => Result(RuntimeCommandStatus.Accepted),
skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Trained),
skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Specialized),
skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Untrained),
SelectStartArea,
_ => Result(RuntimeCommandStatus.Accepted),
() => RequestExitCalls++,
SetAppearanceIndex: SetAppearanceIndex,
SetShade: SetShade,
ResolveText: _ => null,
OpenOnStart: false);
}
public FakeView View { get; }
public CharacterCreationRuntimeBindings Bindings { get; }
public bool ProvideView { get; set; } = true;
public int RequestExitCalls { get; private set; }
public uint LastSelectedHeritage { get; private set; }
public uint LastSelectedGender { get; private set; }
public uint LastSelectedTemplate { get; private set; }
public ChargenAttributeId LastAttributeSet { get; private set; }
public int LastAttributeValue { get; private set; }
public int LastSelectedStartArea { get; private set; } = -1;
public ChargenAppearanceSlot? LastAppearanceSlot { get; private set; }
public uint LastAppearanceIndex { get; private set; }
public int AppearanceIndexCallCount { get; private set; }
public ChargenShadeSlot? LastShadeSlot { get; private set; }
public double LastShadeValue { get; private set; }
public void SelectHeritageDirect(uint heritageId) => SelectHeritage(heritageId);
public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) =>
View.GetSkillLevel(skillId);
private RuntimeCommandResult SelectHeritage(uint heritageId)
{
LastSelectedHeritage = heritageId;
View.Snapshot = View.Snapshot with { HeritageId = heritageId };
return Result(RuntimeCommandStatus.Accepted);
}
private RuntimeCommandResult SelectGender(uint genderKey)
{
LastSelectedGender = genderKey;
View.Snapshot = View.Snapshot with { GenderKey = genderKey };
return Result(RuntimeCommandStatus.Accepted);
}
private RuntimeCommandResult SelectTemplate(uint templateIndex)
{
LastSelectedTemplate = templateIndex;
View.Snapshot = View.Snapshot with { Template = templateIndex };
return Result(RuntimeCommandStatus.Accepted);
}
private RuntimeCommandResult SetAttribute(ChargenAttributeId attribute, int value)
{
LastAttributeSet = attribute;
LastAttributeValue = value;
return Result(RuntimeCommandStatus.Accepted);
}
private RuntimeCommandResult SetSkillLevel(
uint skillId,
ChargenSkillAdvancementClass targetClass)
{
View.SetSkillLevel(skillId, targetClass);
return Result(RuntimeCommandStatus.Accepted);
}
private RuntimeCommandResult SelectStartArea(int startAreaIndex)
{
LastSelectedStartArea = startAreaIndex;
View.Snapshot = View.Snapshot with { StartArea = startAreaIndex };
return Result(RuntimeCommandStatus.Accepted);
}
private RuntimeCommandResult SetAppearanceIndex(ChargenAppearanceSlot slot, uint index)
{
LastAppearanceSlot = slot;
LastAppearanceIndex = index;
AppearanceIndexCallCount++;
View.Snapshot = View.Snapshot with { Appearance = WithAppearanceIndex(View.Snapshot.Appearance, slot, index) };
return Result(RuntimeCommandStatus.Accepted);
}
private RuntimeCommandResult SetShade(ChargenShadeSlot slot, double value)
{
LastShadeSlot = slot;
LastShadeValue = value;
return Result(RuntimeCommandStatus.Accepted);
}
private static RuntimeCharacterCreationAppearance WithAppearanceIndex(
RuntimeCharacterCreationAppearance a,
ChargenAppearanceSlot slot,
uint index) => slot switch
{
ChargenAppearanceSlot.EyesStrip => a with { EyesStrip = index },
ChargenAppearanceSlot.NoseStrip => a with { NoseStrip = index },
ChargenAppearanceSlot.MouthStrip => a with { MouthStrip = index },
ChargenAppearanceSlot.HairStyle => a with { HairStyle = index },
ChargenAppearanceSlot.HairColor => a with { HairColor = index },
ChargenAppearanceSlot.EyeColor => a with { EyeColor = index },
ChargenAppearanceSlot.HeadgearStyle => a with { HeadgearStyle = index },
ChargenAppearanceSlot.HeadgearColor => a with { HeadgearColor = index },
ChargenAppearanceSlot.ShirtStyle => a with { ShirtStyle = index },
ChargenAppearanceSlot.ShirtColor => a with { ShirtColor = index },
ChargenAppearanceSlot.TrousersStyle => a with { TrousersStyle = index },
ChargenAppearanceSlot.TrousersColor => a with { TrousersColor = index },
ChargenAppearanceSlot.FootwearStyle => a with { FootwearStyle = index },
ChargenAppearanceSlot.FootwearColor => a with { FootwearColor = index },
_ => a,
};
private static RuntimeCommandResult Result(RuntimeCommandStatus status) =>
new(status, Generation);
private static ChargenOptions BuildOptions()
{
var gender = new ChargenGenderOptions(
GenderKey: (int)GenderKey,
Name: "Male",
Scale: 1u,
SetupId: 0x2000054u,
SoundTableId: 0u,
IconId: 0u,
BasePaletteId: 0u,
SkinPalSetId: 0u,
PhysicsTableId: 0u,
MotionTableId: 0u,
CombatTableId: 0u,
BaseObjDesc: ChargenObjDesc.Empty,
// Campaign CC slice CC6b-MOUNT: non-empty appearance lists
// so the Appearance page's spin-cycle/wrap and swatch/shade
// dispatch tests have real option counts to exercise (the
// CC4 fixture left these empty since no page read them yet).
HairColors: [0x1000u, 0x1001u, 0x1002u],
HairStyles:
[
new ChargenHairStyle(IconId: 1u, Bald: false, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty),
new ChargenHairStyle(IconId: 2u, Bald: false, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty),
new ChargenHairStyle(IconId: 3u, Bald: true, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty),
],
EyeColors: [0x2000u, 0x2001u],
EyeStrips:
[
new ChargenEyeStrip(IconId: 1u, BaldIconId: 1u, ObjDesc: ChargenObjDesc.Empty, BaldObjDesc: ChargenObjDesc.Empty),
new ChargenEyeStrip(IconId: 2u, BaldIconId: 2u, ObjDesc: ChargenObjDesc.Empty, BaldObjDesc: ChargenObjDesc.Empty),
],
NoseStrips: [new ChargenFaceStrip(IconId: 1u, ObjDesc: ChargenObjDesc.Empty)],
MouthStrips: [new ChargenFaceStrip(IconId: 1u, ObjDesc: ChargenObjDesc.Empty)],
Headgears:
[
new ChargenGearOption("Cloth Cap", ClothingTableId: 1u, WeenieDefaultId: 1u),
new ChargenGearOption("Leather Cap", ClothingTableId: 2u, WeenieDefaultId: 2u),
],
Shirts: [new ChargenGearOption("Tunic", ClothingTableId: 3u, WeenieDefaultId: 3u)],
Pants: [new ChargenGearOption("Trousers", ClothingTableId: 4u, WeenieDefaultId: 4u)],
Footwear: [new ChargenGearOption("Boots", ClothingTableId: 5u, WeenieDefaultId: 5u)],
ClothingColors: [0x3000u, 0x3001u, 0x3002u]);
var templates = new List<ChargenTemplate>
{
new(
"Custom",
IconId: 0u,
TitleStringId: 0u,
Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10),
NormalSkills: [],
PrimarySkills: []),
new(
"Bow Hunter",
IconId: 0u,
TitleStringId: 0u,
Attributes: new ChargenAttributeValues(16, 10, 10, 10, 10, 10),
NormalSkills: [SkillTrainOnly],
PrimarySkills: []),
};
var skillCosts = new Dictionary<uint, ChargenSkillCost>
{
[SkillTrainOnly] = new(SkillTrainOnly, NormalCost: 2, PrimaryCost: 6),
[SkillSpecializable] = new(SkillSpecializable, NormalCost: 2, PrimaryCost: 6),
};
var aluvian = new ChargenHeritageOptions(
AluvianId,
"Aluvian",
IconId: 0u,
SetupId: 0x2000054u,
EnvironmentSetupId: 0u,
AttributeCredits: 66u,
SkillCredits: 50u,
PrimaryStartAreaIndices: [0, 1],
SecondaryStartAreaIndices: [],
SkillCostsBySkillId: skillCosts,
Templates: templates,
GendersByKey: new Dictionary<int, ChargenGenderOptions> { [(int)GenderKey] = gender });
var olthoi = new ChargenHeritageOptions(
OlthoiId,
"Olthoi",
IconId: 0u,
SetupId: 0x2000054u,
EnvironmentSetupId: 0u,
AttributeCredits: 60u,
SkillCredits: 0u,
PrimaryStartAreaIndices: [0],
SecondaryStartAreaIndices: [],
SkillCostsBySkillId: new Dictionary<uint, ChargenSkillCost>(),
Templates:
[
new ChargenTemplate(
"Custom",
IconId: 0u,
TitleStringId: 0u,
Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10),
NormalSkills: [],
PrimarySkills: []),
],
GendersByKey: new Dictionary<int, ChargenGenderOptions> { [(int)GenderKey] = gender });
var starterAreas = new List<ChargenStarterArea>
{
new(0, "Holtburg", [new ChargenPosition(1u, Vector3.Zero, Quaternion.Identity)]),
new(1, "Shoushi", [new ChargenPosition(2u, Vector3.Zero, Quaternion.Identity)]),
new(2, "Yaraq", [new ChargenPosition(3u, Vector3.Zero, Quaternion.Identity)]),
new(3, "Sanamar", [new ChargenPosition(4u, Vector3.Zero, Quaternion.Identity)]),
};
return new ChargenOptions(
starterAreas,
new Dictionary<uint, ChargenHeritageOptions>
{
[AluvianId] = aluvian,
[OlthoiId] = olthoi,
},
new Dictionary<uint, ChargenSkillCost>());
}
}
private sealed class FakeView(ChargenOptions options) : IRuntimeCharacterCreationView
{
private readonly Dictionary<uint, ChargenSkillAdvancementClass> _skillLevels = [];
public RuntimeCharacterCreationSnapshot Snapshot { get; set; } =
new(
new RuntimeGenerationToken(3u),
IsActive: true,
Revision: 1,
HeritageId: 0u,
GenderKey: 0u,
Appearance: RuntimeCharacterCreationAppearance.Default,
Template: RuntimeCharacterCreationSnapshot.TemplateUnset,
Attributes: default,
AttributeLockMask: 0u,
TotalAttributeCredits: 66u,
RemainingAttributeCredits: 66,
TotalSkillCredits: 50u,
RemainingSkillCredits: 50,
Name: string.Empty,
StartArea: -1,
Slot: 0u,
VerificationPending: false,
LastLocalRefusal: default,
LastRejection: null,
LastCreated: null);
public ChargenOptions Options { get; } = options;
public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) =>
_skillLevels.TryGetValue(skillId, out ChargenSkillAdvancementClass level)
? level
: ChargenSkillAdvancementClass.Inactive;
public void SetSkillLevel(uint skillId, ChargenSkillAdvancementClass level) =>
_skillLevels[skillId] = level;
public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) =>
NullSubscription.Instance;
private sealed class NullSubscription : IDisposable
{
public static readonly NullSubscription Instance = new();
public void Dispose() { }
}
}
private static ImportedLayout BuildScreen()
{
var root = new ElementInfo
{
Id = CharacterCreationUiController.RootElementId,
Type = 3u,
Width = 800f,
Height = 600f,
};
root.Children.Add(ContainerInfo(CharacterCreationUiController.ProgressBarElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.BackElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.NextElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.FinishElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.HelpElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.ExitElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.RandomElementId));
root.Children.Add(ContainerInfo(CharacterCreationUiController.MasterPageElementId));
root.Children.Add(BuildHeritagePage());
root.Children.Add(BuildProfessionPage());
root.Children.Add(BuildSkillsPage());
root.Children.Add(BuildAppearancePage());
root.Children.Add(BuildTownPage());
root.Children.Add(ContainerInfo(CharacterCreationUiController.SummaryPageElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.HeritageTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.ProfessionTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.SkillsTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.AppearanceTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.TownTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.SummaryTabElementId));
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
}
private static ElementInfo BuildHeritagePage()
{
var page = new ElementInfo
{
Id = CharacterCreationUiController.HeritagePageElementId,
Type = 3u,
Width = 800f,
Height = 500f,
};
page.Children.Add(ButtonInfo(0x100003BFu)); // Aluvian
page.Children.Add(ButtonInfo(0x100005C7u)); // Olthoi
page.Children.Add(ButtonInfo(0x100005F1u)); // Lugian (F3 quirk: no tab-restore/hide)
page.Children.Add(TextInfo(0x100003C4u));
return page;
}
private static ElementInfo BuildProfessionPage()
{
var page = new ElementInfo
{
Id = CharacterCreationUiController.ProfessionPageElementId,
Type = 3u,
Width = 800f,
Height = 500f,
};
page.Children.Add(ButtonInfo(0x100003D9u)); // Custom
page.Children.Add(ButtonInfo(0x100003DAu)); // Bow Hunter
var strengthSlider = ContainerInfo(0x100003E6u);
strengthSlider.Children.Add(ButtonInfo(0x100002ECu));
strengthSlider.Children.Add(ScrollbarInfo(0x100002EEu));
strengthSlider.Children.Add(EditableFieldInfo(0x100002EFu));
page.Children.Add(strengthSlider);
page.Children.Add(ButtonInfo(0x100003E2u)); // Available (consumed-child badge)
page.Children.Add(ButtonInfo(0x100003E3u)); // Health
page.Children.Add(ButtonInfo(0x100003E4u)); // Stamina
page.Children.Add(ButtonInfo(0x100003E5u)); // Mana
return page;
}
private static ElementInfo BuildSkillsPage()
{
var page = new ElementInfo
{
Id = CharacterCreationUiController.SkillsPageElementId,
Type = 3u,
Width = 800f,
Height = 500f,
};
var list = new ElementInfo
{
Id = 0x100003F7u,
Type = 5u,
X = 20f,
Y = 40f,
Width = 300f,
Height = 320f,
};
list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100003FEu));
page.Children.Add(list);
page.Children.Add(ButtonInfo(0x100003F9u)); // credits badge
page.Children.Add(TextInfo(0x100003FBu));
page.Children.Add(TextInfo(0x100003FCu));
return page;
}
private static ElementInfo BuildTownPage()
{
var page = new ElementInfo
{
Id = CharacterCreationUiController.TownPageElementId,
Type = 3u,
Width = 800f,
Height = 500f,
};
page.Children.Add(ButtonInfo(0x1000040Bu)); // Sanamar
page.Children.Add(ButtonInfo(0x1000040Du)); // Holtburg
page.Children.Add(ButtonInfo(0x1000040Eu)); // Yaraq
page.Children.Add(ButtonInfo(0x1000040Fu)); // Shoushi
page.Children.Add(TextInfo(0x10000409u));
// F4: the page ROOT's own retail state literal map
// (gmCGTownPage::SetTown @ 0x0047c360), a separate state machine
// from the master page's per-page-index cycling.
page.States[0x10000034u] = new UiStateInfo { Id = 0x10000034u, Name = "Holtburg" };
page.States[0x10000035u] = new UiStateInfo { Id = 0x10000035u, Name = "Sanamar" };
page.States[0x10000036u] = new UiStateInfo { Id = 0x10000036u, Name = "Yaraq" };
page.States[0x10000037u] = new UiStateInfo { Id = 0x10000037u, Name = "Shoushi" };
return page;
}
/// <summary>Campaign CC slice CC6b-MOUNT: the Appearance page fixture.
/// Spin geometry (each 200px wide, arrow children at the live-DAT-
/// measured x=80/127 — see <see cref="CharacterCreationAppearancePage"/>'s
/// own doc comment) mirrors the real installed layout exactly so the
/// same OnClickAt zone math this page uses in production is what these
/// tests exercise.</summary>
private static ElementInfo BuildAppearancePage()
{
var page = new ElementInfo
{
Id = CharacterCreationUiController.AppearancePageElementId,
Type = 3u,
Width = 800f,
Height = 500f,
};
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.FemaleButtonId));
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.MaleButtonId));
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.FaceButtonId));
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ClothesButtonId));
page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.FaceChoicesId));
page.Children.Add(ContainerInfo(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,
})
{
page.Children.Add(SpinInfo(spinId));
}
foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds)
page.Children.Add(ButtonInfo(swatchId));
page.Children.Add(ScrollbarInfo(CharacterCreationAppearancePage.ShadeScrollId));
page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.GradCircleId));
var viewport = new ElementInfo
{
Id = CharacterCreationAppearancePage.ViewportId,
Type = 0xDu,
Width = 300f,
Height = 300f,
};
page.Children.Add(viewport);
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateClockwiseId));
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateCounterClockwiseId));
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ZoomInId));
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ZoomOutId));
return page;
}
private static ElementInfo SpinInfo(uint id)
{
var spin = new ElementInfo
{
Id = id,
Type = 1u,
Width = 200f,
Height = 24f,
};
spin.Children.Add(new ElementInfo { Id = 0x1000030Au, Type = 1u, X = 80f, Width = 47f, Height = 24f });
spin.Children.Add(new ElementInfo { Id = 0x1000030Bu, Type = 1u, X = 127f, Width = 47f, Height = 24f });
return spin;
}
private static UiElement BuildSkillRowTemplate(uint templateElementId) =>
LayoutImporter.Build(
new ElementInfo
{
Id = templateElementId,
Type = 1u,
Width = 280f,
Height = 16f,
},
_ => (0u, 0, 0),
null).Root;
private static ElementInfo ContainerInfo(uint id) => new()
{
Id = id,
Type = 3u,
Width = 200f,
Height = 60f,
};
private static ElementInfo ButtonInfo(uint id) => new()
{
Id = id,
Type = 1u,
Width = 100f,
Height = 30f,
};
private static ElementInfo TextInfo(uint id) => new()
{
Id = id,
Type = 12u,
Width = 200f,
Height = 60f,
};
private static ElementInfo ScrollbarInfo(uint id) => new()
{
Id = id,
Type = 11u,
Width = 120f,
Height = 12f,
};
private static ElementInfo EditableFieldInfo(uint id)
{
var info = new ElementInfo
{
Id = id,
Type = 12u,
Width = 40f,
Height = 16f,
};
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
state.Properties.Values[0x16u] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = true,
};
info.States[UiStateInfo.DirectStateId] = state;
return info;
}
}