using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.CharGen; using AcDream.Core.Net.Messages; using AcDream.Runtime; using AcDream.Runtime.Session; namespace AcDream.App.Tests.UI.Layout; /// /// 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 /// for the live-DAT id/type /// sweep this pairs with). Mirrors CharacterManagementUiControllerTests' /// fixture pattern. /// 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); } /// Campaign CC slice CC5: Finish (0x100003c8) is /// ghosted everywhere EXCEPT Summary — retail's own /// ListenToElementMessage case only sends when /// m_eProgressState == ECG_SUMMARY; off Summary the button now /// has a real OnClick handler (OnFinish, which itself /// re-checks the current page) but stays disabled. [Fact] public void Finish_GhostedExceptOnSummary() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); UiButton finish = environment.Button(CharacterCreationUiController.FinishElementId); Assert.NotNull(finish.OnClick); Assert.False(finish.Enabled); environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!(); Assert.True(finish.Enabled); } /// Campaign CC slice CC5: Random (0x100003cb) is now /// enabled on Appearance and Summary too — CC5 ports /// RandomizeAppearance/RandomizeClothing/RandomizeCharacter, retiring /// both gaps AP-212 used to track. Only Skills' unported /// RandomizeSkills keeps Random disabled. [Fact] public void Random_IsDisabledOnSkillsPageOnly() { 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.True(random.Enabled); environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!(); Assert.True(random.Enabled); environment.TabButton(CharacterCreationUiController.TownTabElementId).OnClick!(); Assert.True(random.Enabled); } /// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450's /// element 0x100003c6 case: at Heritage (the first page), Back opens /// the exit confirmation instead of moving pages. [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); } /// 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 ). [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); } /// gmCharGenMainUI::SetProgressState @ 0x004e7a10's Olthoi /// branch: Profession/Skills/Town tabs hide, and paging past the /// hidden range redirects to Appearance/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( environment.Screen.FindElement(0x100003E6u)); var slider = Assert.IsType( 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( environment.Screen.FindElement(0x100003E6u)); var field = Assert.IsType( UiElement.FindDescendant(strengthContainer, 0x100002EFu)); field.OnSubmit!("42"); Assert.Equal(ChargenAttributeId.Strength, environment.Runtime.LastAttributeSet); Assert.Equal(42, environment.Runtime.LastAttributeValue); } /// /// GF-5 fix (2026-08-16): the row is now the REAL Templates[1] /// (0x100002FF) subtree — a plain container root with the two /// separate arrow buttons retail authors (pSkillUpButton /// 0x10000304 / pSkillDownButton 0x10000305), each /// firing on a PLAIN click (ListenToElementMessage @0x004814c0), /// not the old single-button click-vs-double-click substitution. /// [Fact] public void SkillsRow_ArrowClick_TrainsThenSpecializes() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); environment.Runtime.SelectHeritageDirect(AluvianId); environment.TabButton(CharacterCreationUiController.SkillsTabElementId) .OnClick!(); (UiButton up, UiButton down) = environment.SkillRowArrows(SkillSpecializable); up.OnClick!(); Assert.Equal(ChargenSkillAdvancementClass.Trained, environment.Runtime.GetSkillLevel(SkillSpecializable)); up.OnClick!(); Assert.Equal(ChargenSkillAdvancementClass.Specialized, environment.Runtime.GetSkillLevel(SkillSpecializable)); down.OnClick!(); Assert.Equal(ChargenSkillAdvancementClass.Trained, environment.Runtime.GetSkillLevel(SkillSpecializable)); } /// /// GF-5: the listbox produces one row per costable skill through the /// REAL template-resolver path (Templates[1], not the bucket- /// header Templates[0]), with name/level/cost values populated /// from a known snapshot — the exact regression CC5's fixture tests /// never had (they called UiField.SetText/UiButton.OnClick /// directly, bypassing RebuildRows' own template resolution entirely). /// [Fact] public void SkillsPage_Rows_RenderNameAndLevelCostValues_ThroughTheRealTemplate() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); environment.Runtime.SelectHeritageDirect(AluvianId); environment.TabButton(CharacterCreationUiController.SkillsTabElementId) .OnClick!(); IReadOnlyList rows = environment.SkillsList().ViewportForTest!.Children; // Aluvian's fixture only costs SkillTrainOnly(1)/SkillSpecializable(2). Assert.Equal(2, rows.Count); UiElement row = Assert.Single(rows, candidate => UiElement.FindDescendant(candidate, 0x10000301u) is UiText name && JoinedText(name) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly)); // FakeRuntime.GetSkillScore's deterministic stand-in: skillId * 10. UiText level = Assert.IsType(UiElement.FindDescendant(row, 0x10000302u)); Assert.Equal((SkillTrainOnly * 10u).ToString(), JoinedText(level)); // Default (never-touched) level: up cost = trained cost (2), down // cost blank (nothing below Untrained/Inactive). UiText upCost = Assert.IsType(UiElement.FindDescendant(row, 0x10000303u)); UiText downCost = Assert.IsType(UiElement.FindDescendant(row, 0x10000306u)); Assert.Equal("2", JoinedText(upCost)); Assert.Equal(string.Empty, JoinedText(downCost)); // Advancing to Trained flips the cost pair: up = specCost-trainCost // (6-2=4), down = trainCost (2). FakeRuntime.SetSkillLevel is a // lightweight stub that doesn't bump Revision itself (unlike // production's TrySetSkillLevel, RuntimeCharacterCreationState.cs // ~1045), so force one the same way the file's other post-click // refresh assertions do. environment.SkillRowArrows(SkillTrainOnly).Up.OnClick!(); RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot; environment.Runtime.View.Snapshot = snapshot with { Revision = snapshot.Revision + 1 }; environment.Controller.Tick(); Assert.Equal("4", JoinedText(upCost)); Assert.Equal("2", JoinedText(downCost)); } [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); } /// Review fix round F4 (2026-08-15): gmCGTownPage::SetTown /// @ 0x0047c360 also sets the TOWN PAGE'S OWN retail state via a /// literal per-town map — Holtburg->0x10000034, Yaraq->0x10000036 — /// SEPARATE from the master page's per-page-index cycling /// (0x10000025+page, already covered by the page-switch tests /// above). [Fact] public void TownButton_Refresh_SetsThePagesOwnRetailStateLiteral() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); environment.TabButton(CharacterCreationUiController.TownTabElementId) .OnClick!(); var pageRoot = Assert.IsType( 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); } /// Review fix round F2 (2026-08-15), the display direction: /// gmCGProfessionPage::UpdateAttributeValues @ 0x0048251d sets /// the slider's scalar position to value * 0.00999999978f /// (value/100), not a [10,100]-to-[0,1] rescale. [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( environment.Screen.FindElement(0x100003E6u)); var slider = Assert.IsType( 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); } /// Review fix round F2 (2026-08-15), the drag-inverse /// direction: ListenToElementMessage @ 0x004829c0's scrollbar- /// drag case truncates scalar*100 and clamps LOW only to 10 — /// NOT the [10,100]<->[0,1] rescale the previous (wrong) formula /// used, which only coincidentally agreed with the correct one at /// scalar=1 (the pre-existing /// case). [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( environment.Screen.FindElement(0x100003E6u)); var slider = Assert.IsType( UiElement.FindDescendant(strengthContainer, 0x100002EEu)); slider.ScalarChanged!(scalar); Assert.Equal(ChargenAttributeId.Strength, environment.Runtime.LastAttributeSet); Assert.Equal(expectedValue, environment.Runtime.LastAttributeValue); } /// Review fix round F3 (2026-08-15): /// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450's /// heritage-button bubble arm shows/hides the Profession/Skills/Town /// tabs SYNCHRONOUSLY at click time — independent of /// 's /// page-switch-time recompute (no tab/Back/Next click happens in this /// test at all). Lugian (0x100005f1) sits outside BOTH the SHOW /// and HIDE case lists in the decompiled switch — a genuine retail /// quirk, reproduced faithfully. [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); } /// Review fix round F1 (2026-08-15): Open() sets /// UiRoot.FixedCanvasSize once on the activation edge (matching /// CharacterManagementUiController's real, non-per-tick shape); /// Close()/Deactivate()/Dispose() 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. [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); } /// Spin arrow geometry (live-DAT-measured, see /// 's own class doc): /// x=[80,127) is the decrement child, x=[127,174) is the increment /// child. [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); } /// /// 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 /// (gmCGAppearancePage::ListenToElementMessage @0x0047f465-0x0047f086, /// the same shared tail the headgear ring reuses at /// label_47f065/label_47f6d9) computes /// new = cur - 1 on the raw signed int32 (Unset = -1), giving /// new = -2; since -2 < 0 it wraps to count - 1, /// NOT 0 — see 's /// own corrected doc. The fixture's Hair style count is 3 /// (), so the expected /// landing index is 2. /// [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); } /// /// 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 — CharacterCreationLiveDatTests) — 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 /// OnClickAt zone from the two arrow-zone tests above, which no /// prior test isolated. /// [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); } /// /// Fix round F1: pins the body-click normalize-and-write-back /// ('s own /// NormalizeChoiceOnSelect 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 /// count-1 and writes it back, exactly like a decrement click /// would, even though no arrow was pressed. /// [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. } /// 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 's /// own citation. [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); } /// Pure wrap-semantics unit tests for /// — the /// decomp-derived arithmetic every spin's OnClickAt zone drives. /// [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)); } /// 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. [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); } /// gmCGAppearancePage::Update's heritage 6/0xc/0xd gate: the /// Clothes sub-tab and the Nose/Mouth spins all hide. [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); } /// Fixture's Hair color list has 3 entries (indices 0-2) — a /// swatch beyond that never reaches SetAppearanceIndex, matching /// retail's own iNumColors > N gate. [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); } /// Nose/Mouth/Skin all route the shade scroll to SKIN shade — /// SetShade's cases 2/3/4 share one body in the decompiled switch. /// [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!(); } // ── CC5: RandomizeCharacter open-roll + gender flip ───────────────── /// Ports gmCharGenMainUI's ctor-time roll + /// gmCGAppearancePage::InitializePage's gender-flip /// (~0x004802da-0x00480303) — retiring AP-214's honest-blank /// deviation. lands /// deterministically on / /// so the flip assertion /// isn't flaky. [Fact] public void Open_RollsACharacterThenFlipsTheGenderToTheOpposite() { using var environment = new EnvironmentHarness(); environment.Runtime.RandomizedHeritageId = AluvianId; environment.Runtime.RandomizedGenderKey = GenderKey; // Male = 1 environment.Controller.Open(); Assert.Equal(1, environment.Runtime.RandomizeCharacterCalls); Assert.Equal(AluvianId, environment.Runtime.View.Snapshot.HeritageId); // RandomizeCharacter rolled Male (1); InitializePage's own flip // immediately inverts it to Female (2). Assert.Equal(2u, environment.Runtime.LastSelectedGender); Assert.Equal(2u, environment.Runtime.View.Snapshot.GenderKey); } [Fact] public void Open_RandomizeCharacterRejected_DoesNotAttemptTheGenderFlip() { using var environment = new EnvironmentHarness(); environment.Runtime.RandomizeCharacterAccepts = false; environment.Controller.Open(); Assert.Equal(0u, environment.Runtime.LastSelectedGender); } // ── CC5: Finish (DoFinish @ 0x004E9170) ────────────────────────────── private static void GoToSummary(EnvironmentHarness environment) => environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!(); [Fact] public void Finish_EmptyName_ShowsNoNameWarningDialog_AndDoesNotSend() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); GoToSummary(environment); environment.Button(CharacterCreationUiController.FinishElementId).OnClick!(); Assert.Equal(1, environment.Runtime.FinishCallCount); Assert.True(environment.Dialogs.IsOpen); Assert.Equal("No name entered.", environment.LastDialogMessage()); } /// /// GF-15 (Campaign CC gate round 1, Batch A). The exact user sequence, /// driven through the REAL / /// event pipeline — every OTHER Summary-page /// test in this file calls field.SetText/UiButton.OnClick!() /// directly, which bypasses 's own pick/focus/Modal /// dispatch entirely and is exactly why those tests kept passing while /// the live screen was dead. Root cause (live-repro-confirmed): /// 's own per-tick /// UiRoot.BringToFront(Root) (needed so chargen stays above the /// occluded character-management screen, AP-229) buried any dialog /// opened while chargen is active on the VERY NEXT frame, because /// never re-asserted its own open /// dialogs' z-order — fixed by having it do so, in open-order, every /// tick. /// [Fact] public void Finish_EmptyName_RealEventPath_DialogSurvivesTheNextFrameTick_AndFieldRefocusableAfterDismiss() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); // Heritage/gender must be selected so Finish's HeritageOrGenderUnset // local refusal (which has no retail dialog) can't preempt the // NoName refusal this test exercises. SelectAluvianMale(environment); GoToSummary(environment); UiField nameField = environment.SummaryNameField(); UiButton finishButton = environment.Button(CharacterCreationUiController.FinishElementId); // (1) A real mouse-down at the field's own screen rect sets // KeyboardFocus to it. Vector2 fieldPos = nameField.ScreenPosition; environment.Host.OnMouseDown(UiMouseButton.Left, (int)fieldPos.X + 2, (int)fieldPos.Y + 2); Assert.Same(nameField, environment.Host.KeyboardFocus); // (2) A subsequent OnChar lands a character in the field through // the real event pipeline (UiRoot.OnChar -> BubbleEvent -> // UiField.OnEvent), not a direct SetText call -- then the user's // own live-repro clear (repeated Backspace) empties it again, so // the field is genuinely empty when Finish commits it below // (clicking Finish blurs the field, which commits its text -- // CommitNameFromField -- exactly like a real click-away would). environment.Host.OnChar('Z'); Assert.Equal("Z", nameField.Text); nameField.Backspace(); Assert.Equal(string.Empty, nameField.Text); // (3) A real click on Finish while the field is empty. UiButton's // own click fires on the press-release pair (OnMouseUp with the // same target still Captured from OnMouseDown), matching every // other real click below. Vector2 finishPos = finishButton.ScreenPosition; environment.Host.OnMouseDown(UiMouseButton.Left, (int)finishPos.X + 2, (int)finishPos.Y + 2); environment.Host.OnMouseUp(UiMouseButton.Left, (int)finishPos.X + 2, (int)finishPos.Y + 2); Assert.Equal(1, environment.Runtime.FinishCallCount); Assert.True(environment.Dialogs.IsOpen); UiPanel dialogModal = Assert.IsAssignableFrom(environment.Host.Modal); // (4) Reproduce the exact bug window: one full frame's worth of // ticks in production order (CharacterCreationController.Tick then // DialogFactory.Tick, RetailUiRuntime.Tick(double)'s own sequence). // Before the fix, step 4a alone buried the dialog; the dialog must // still sit at or above the screen root's z-order once step 4b (the // fix) runs, matching retail's always-on-top dialog behavior. environment.Controller.Tick(); environment.Dialogs.Tick(); Assert.True(dialogModal.ZOrder >= environment.Controller.Root.ZOrder); // (5) Dismiss through the real click path -- the OK button's own // screen rect, not ConfirmActiveDialog's direct OnClick! shortcut. UiButton okButton = Assert.IsType( UiElement.FindDescendant(dialogModal, RetailMessageDialogView.OkButtonId)); Vector2 okPos = okButton.ScreenPosition; environment.Host.OnMouseDown(UiMouseButton.Left, (int)okPos.X + 2, (int)okPos.Y + 2); environment.Host.OnMouseUp(UiMouseButton.Left, (int)okPos.X + 2, (int)okPos.Y + 2); Assert.False(environment.Dialogs.IsOpen); Assert.Null(environment.Host.Modal); // (6) The user's own final check: the field is still typable // afterward, through the same real click+char path. environment.Host.OnMouseDown(UiMouseButton.Left, (int)fieldPos.X + 2, (int)fieldPos.Y + 2); Assert.Same(nameField, environment.Host.KeyboardFocus); environment.Host.OnChar('Q'); Assert.Contains('Q', nameField.Text); } [Fact] public void Finish_UnspentCredits_ShowsCreditWarning_ConfirmResendsWithConfirmedFlag() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); SelectAluvianMale(environment); environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with { Name = "Adventurer", RemainingAttributeCredits = 6, }; GoToSummary(environment); environment.Button(CharacterCreationUiController.FinishElementId).OnClick!(); Assert.Equal(1, environment.Runtime.FinishCallCount); Assert.False(environment.Runtime.LastConfirmedUnspentCredits); Assert.True(environment.Dialogs.IsOpen); Assert.Equal("You have unspent attribute credits.", environment.LastDialogMessage()); environment.ConfirmActiveDialog(confirmed: true); Assert.Equal(2, environment.Runtime.FinishCallCount); Assert.True(environment.Runtime.LastConfirmedUnspentCredits); } [Fact] public void Finish_UnspentCredits_CancelDialog_DoesNotResend() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); SelectAluvianMale(environment); environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with { Name = "Adventurer", RemainingAttributeCredits = 6, }; GoToSummary(environment); environment.Button(CharacterCreationUiController.FinishElementId).OnClick!(); environment.ConfirmActiveDialog(confirmed: false); Assert.Equal(1, environment.Runtime.FinishCallCount); } [Fact] public void Finish_Accepted_ShowsNoDialog() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); SelectAluvianMale(environment); environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with { Name = "Adventurer", RemainingAttributeCredits = 0, }; GoToSummary(environment); environment.Button(CharacterCreationUiController.FinishElementId).OnClick!(); Assert.Equal(1, environment.Runtime.FinishCallCount); Assert.False(environment.Dialogs.IsOpen); } [Fact] public void Finish_OffSummaryPage_IsANoOp() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); // defaults to Heritage environment.Button(CharacterCreationUiController.FinishElementId).OnClick!(); Assert.Equal(0, environment.Runtime.FinishCallCount); } // ── CC5: Random on Summary (MakeRandomizeWarningDialog @ 0x004e8a90) ─ [Fact] public void RandomOnSummary_ShowsWarningDialog_ConfirmCallsRandomizeCharacter() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); GoToSummary(environment); environment.Button(CharacterCreationUiController.RandomElementId).OnClick!(); Assert.True(environment.Dialogs.IsOpen); Assert.Equal("This will randomize your character.", environment.LastDialogMessage()); int callsBeforeConfirm = environment.Runtime.RandomizeCharacterCalls; environment.ConfirmActiveDialog(confirmed: true); Assert.Equal(callsBeforeConfirm + 1, environment.Runtime.RandomizeCharacterCalls); } [Fact] public void RandomOnSummary_CancelDialog_DoesNotRandomize() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); GoToSummary(environment); int callsBeforeClick = environment.Runtime.RandomizeCharacterCalls; environment.Button(CharacterCreationUiController.RandomElementId).OnClick!(); environment.ConfirmActiveDialog(confirmed: false); Assert.Equal(callsBeforeClick, environment.Runtime.RandomizeCharacterCalls); } // ── CC5: Random on Appearance (DoRandom case 3) ────────────────────── [Fact] public void RandomOnAppearance_FaceSubTab_CallsRandomizeAppearance() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); environment.Button(CharacterCreationUiController.RandomElementId).OnClick!(); Assert.Equal(1, environment.Runtime.RandomizeAppearanceCalls); Assert.Equal(0, environment.Runtime.RandomizeClothingCalls); } [Fact] public void RandomOnAppearance_ClothesSubTab_CallsRandomizeClothing() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); environment.Button(CharacterCreationAppearancePage.ClothesButtonId).OnClick!(); environment.Button(CharacterCreationUiController.RandomElementId).OnClick!(); Assert.Equal(1, environment.Runtime.RandomizeClothingCalls); Assert.Equal(0, environment.Runtime.RandomizeAppearanceCalls); } // ── CC5: Summary name field (ListenToElementMessage @ 0x0047bf40) ──── [Fact] public void SummaryNameField_Submit_CommitsTheName() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); GoToSummary(environment); UiField field = environment.SummaryNameField(); field.SetText("Adventurer"); field.Submit(); Assert.Equal("Adventurer", environment.Runtime.LastSetName); } [Fact] public void SummaryNameField_TooLong_ShowsDialogAndRevertsToLastCommitted() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); GoToSummary(environment); UiField field = environment.SummaryNameField(); field.SetText("Adventurer"); field.Submit(); Assert.Equal("Adventurer", environment.Runtime.LastSetName); field.SetText(new string('a', 40)); field.Submit(); // The over-limit text never reached SetName, and the too-long // dialog fired with the field reverted to the last commit. Assert.Equal("Adventurer", environment.Runtime.LastSetName); Assert.True(environment.Dialogs.IsOpen); Assert.Equal("That name is too long.", environment.LastDialogMessage()); Assert.Equal("Adventurer", field.Text); } [Fact] public void SummaryNameField_NameInputFilter_RejectsDigitsAndSymbols() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); GoToSummary(environment); UiField field = environment.SummaryNameField(); Assert.NotNull(field.CharacterFilter); Assert.True(field.CharacterFilter!('A')); Assert.True(field.CharacterFilter!(' ')); Assert.True(field.CharacterFilter!('\'')); Assert.True(field.CharacterFilter!('-')); Assert.False(field.CharacterFilter!('7')); Assert.False(field.CharacterFilter!('$')); } /// /// CC5 re-review residual round, R1 (2026-08-16): pins the F1 fix (the /// deleted _suppressNextFieldEvent latch) against reintroduction. /// The pre-fix bug needed BOTH halves to reproduce: (1) an EXTERNAL /// change to snapshot.Name lands while the field is unfocused — /// CharacterCreationSummaryPage.Refresh's own field-sync block /// (the F1 fix site) calls field.SetText(...) programmatically, /// which armed the old latch — then (2) the PLAYER's own REAL commit /// (SetText + Submit, the actual event path a keystroke + /// Enter/blur drives — never a direct CommitNameFromField call) /// arrives afterward. Pre-fix, that real commit hit the still-armed /// latch and was silently dropped; /// alone never exercised this because it never drives step (1) first — /// the review's own finding was that the claimed regression coverage /// didn't actually touch the page. /// [Fact] public void SummaryNameField_RealCommitAfterExternalRefreshWhileUnfocused_StillReachesSetName() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); GoToSummary(environment); UiField field = environment.SummaryNameField(); // (1) External change while unfocused: something OTHER than this // field bumps the Runtime revision with a changed Name (e.g. the // player edited another page and came back) — Refresh's field-sync // block programmatically overwrites the field via SetText, which // never raises OnFocusLost/OnSubmit. RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot; environment.Runtime.View.Snapshot = snapshot with { Revision = snapshot.Revision + 1, Name = "Zorak", }; environment.Controller.Tick(); Assert.Equal("Zorak", field.Text); // (2) The player's own real commit afterward. field.SetText("Adventurer"); field.Submit(); Assert.Equal("Adventurer", environment.Runtime.LastSetName); } // ── CC5 review fix round F12(d): RebuildListbox content ───────────── /// /// F12(d) (2026-08-16): pins the F3 fix directly against the listbox's /// actual built rows (not just the header/pair TEMPLATE ids the fixture /// wires) — a skill row uses the key/value pair template (KEY = skill /// name, VALUE = 's deterministic /// stand-in), and BOTH bucket headers appear even though only ONE bucket /// (Trained) has a matching skill — the case that actually exercises /// "unconditional" (a header shown only because some row happened to /// match would have passed even with the pre-fix lazy-header bug). /// [Fact] public void Summary_RebuildListbox_SkillRows_UseKeyValueTemplate_WithUnconditionalHeaders() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); SelectAluvianMale(environment); // SkillTrainOnly (1, "Axe") is Trained; SkillSpecializable (2, // "Bow") is left Inactive entirely — no skill occupies the // Specialized bucket. environment.Runtime.View.SetSkillLevel(SkillTrainOnly, ChargenSkillAdvancementClass.Trained); GoToSummary(environment); UiTemplateListBox listBox = environment.SummaryListBox(); IReadOnlyList rows = Assert.IsType( listBox.ViewportForTest).Children; // Ids match CharacterCreationSummaryPage's own private // HeaderTextId/KeyTextId/ValueTextId constants — the same literals // BuildSummaryHeaderTemplate/BuildSummaryPairTemplate below already // hardcode for the fixture's row templates. const uint headerTextId = 0x100000FEu; const uint keyTextId = 0x100002FCu; const uint valueTextId = 0x100002FDu; var headers = new List(); var pairs = new List<(string Key, string Value)>(); foreach (UiElement row in rows) { if (UiElement.FindDescendant(row, headerTextId) is UiText header) headers.Add(JoinedText(header)); else if (UiElement.FindDescendant(row, keyTextId) is UiText key && UiElement.FindDescendant(row, valueTextId) is UiText value) { pairs.Add((JoinedText(key), JoinedText(value))); } } Assert.Contains("Specialized Skills", headers); Assert.Contains("Trained Skills", headers); Assert.Single(pairs, p => p.Key == "Axe" && p.Value == "10"); Assert.DoesNotContain(pairs, p => p.Key == "Bow"); } private static string JoinedText(UiText text) => string.Join(" ", text.LinesProvider().Select(static line => line.Text)); // ── CC5: 0xF643 rejection dialogs ───────────────────────────────────── [Fact] public void CreationFailed_NameInUse_ShowsTheRetailErrorDialog_AndAcknowledgesOnClose() { using var environment = new EnvironmentHarness(); environment.Runtime.ResolvedStrings["ID_Character_Err_NameReserved"] = "That name is in use."; environment.Controller.Open(); environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with { LastRejection = new RuntimeCharacterCreationRejection( 3u, CharGenVerificationResponse.Code.NameInUse, "NameInUse", "Adventurer"), }; BumpRevisionAndTick(environment); Assert.True(environment.Dialogs.IsOpen); Assert.Equal("That name is in use.", environment.LastDialogMessage()); environment.DismissActiveMessageDialog(); Assert.Equal(1, environment.Runtime.AcknowledgeRejectionCalls); } /// /// F12(c) (CC5 review-fix round, 2026-08-16): a SECOND rejection with /// IDENTICAL field values (same RawCode/Code/Reason/AttemptedName — /// e.g. the player retried Finish with the SAME already-taken name) /// must still show the dialog. ReconcileDialogs' own /// _lastShownRejection dedup only suppresses re-showing a value /// that is STILL the current LastRejection across ticks /// (); /// once nulls the /// snapshot's rejection (mirroring the real /// RuntimeCharacterCreationState.TryAcknowledgeRejection), /// _lastShownRejection resets to null too, so the identical /// value arriving a second time is treated as new. /// [Fact] public void CreationFailed_IdenticalRejectionAfterAcknowledge_ReshowsTheDialog() { using var environment = new EnvironmentHarness(); environment.Runtime.ResolvedStrings["ID_Character_Err_NameReserved"] = "That name is in use."; environment.Controller.Open(); var rejection = new RuntimeCharacterCreationRejection( 3u, CharGenVerificationResponse.Code.NameInUse, "NameInUse", "Adventurer"); environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with { LastRejection = rejection }; BumpRevisionAndTick(environment); Assert.True(environment.Dialogs.IsOpen); environment.DismissActiveMessageDialog(); Assert.Equal(1, environment.Runtime.AcknowledgeRejectionCalls); Assert.False(environment.Dialogs.IsOpen); Assert.Null(environment.Runtime.View.Snapshot.LastRejection); // One intervening Tick with LastRejection == null — exactly what // happens continuously in the real game loop between the // acknowledge callback and the player's next Finish attempt — lets // ReconcileDialogs' own `if (rejection is null) _lastShownRejection // = null;` branch run BEFORE the identical value arrives again. // Without this, _lastShownRejection still holds the acknowledged // value and the dedup would (correctly, per its OWN contract) // suppress a value that never actually went away in between. environment.Controller.Tick(); // The identical rejection value arrives again. environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with { LastRejection = rejection }; BumpRevisionAndTick(environment); Assert.True(environment.Dialogs.IsOpen); Assert.Equal("That name is in use.", environment.LastDialogMessage()); environment.DismissActiveMessageDialog(); Assert.Equal(2, environment.Runtime.AcknowledgeRejectionCalls); } [Fact] public void CreationFailed_SameRejectionAcrossTicks_ShowsOnlyOneDialog() { using var environment = new EnvironmentHarness(); environment.Runtime.ResolvedStrings["ID_Character_Err_NameBanned"] = "That name is banned."; environment.Controller.Open(); environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with { LastRejection = new RuntimeCharacterCreationRejection( 4u, CharGenVerificationResponse.Code.NameBanned, "NameBanned", "Adventurer"), }; BumpRevisionAndTick(environment); Assert.Equal(1, environment.Dialogs.ActiveCount); // A second Tick with the SAME rejection instance (no revision bump, // no new value) must not reopen the dialog — ReconcileDialogs runs // every Tick, not just on revision change. environment.Controller.Tick(); Assert.Equal(1, environment.Dialogs.ActiveCount); } 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 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 _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.CreateDetached( Host, Screen, ResolveSkillRowTemplate, Dialogs, Runtime.Bindings, new CharacterCreationUiController.DialogStrings( "Are you sure you want to leave?", "No name entered.", "You have unspent attribute credits.", "This will randomize your character.", "That name is too long."))); 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(Screen.FindElement(id)); public UiButton TabButton(uint id) => Button(id); public UiElement Page(uint id) => Assert.IsAssignableFrom(Screen.FindElement(id)); public UiTemplateListBox SkillsList() => Assert.IsType(Screen.FindElement(0x100003F7u)); /// GF-5: locates a built skill row by its name text /// (0x10000301) and returns its up (0x10000304, /// pSkillUpButton) / down (0x10000305, /// pSkillDownButton) arrow buttons. public (UiButton Up, UiButton Down) SkillRowArrows(uint skillId) { string skillName = ItemAppraisalTextFormatter.SkillName((int)skillId); UiElement row = Assert.Single( SkillsList().ViewportForTest!.Children, candidate => UiElement.FindDescendant(candidate, 0x10000301u) is UiText name && JoinedText(name) == skillName); UiButton up = Assert.IsType(UiElement.FindDescendant(row, 0x10000304u)); UiButton down = Assert.IsType(UiElement.FindDescendant(row, 0x10000305u)); return (up, down); } public UiTemplateListBox SummaryListBox() => Assert.IsType(Screen.FindElement(CharacterCreationSummaryPage.ListBoxId)); public UiScrollbar ShadeScroll() => Assert.IsType(Screen.FindElement(CharacterCreationAppearancePage.ShadeScrollId)); public UiField SummaryNameField() => Assert.IsType(Screen.FindElement(CharacterCreationSummaryPage.NameTextId)); /// Confirms or cancels the MOST RECENTLY opened confirmation /// dialog, using 's real /// button ids off the layout the factory's createLayout /// callback actually returned — same lookup shape /// CharacterManagementUiControllerTests uses. public void ConfirmActiveDialog(bool confirmed) { ImportedLayout dialog = _dialogLayouts[^1]; uint buttonId = confirmed ? RetailConfirmationDialogView.AcceptButtonId : RetailConfirmationDialogView.RejectButtonId; UiButton button = Assert.IsType(dialog.FindElement(buttonId)); button.OnClick!(); } /// Same shape as , for a /// plain informational () OK /// dialog — /the 0xF643 /// rejection dialogs use this shape, not confirm/cancel. public void DismissActiveMessageDialog() { ImportedLayout dialog = _dialogLayouts[^1]; UiButton button = Assert.IsType( dialog.FindElement(RetailMessageDialogView.OkButtonId)); button.OnClick!(); } /// The MOST RECENTLY opened dialog's message text — /// mirrors CharacterManagementUiControllerTests.Message's /// own lookup shape (element id 0x3E, every /// popup's /// text child). public string LastDialogMessage() => string.Join( " ", Assert.IsType(_dialogLayouts[^1].FindElement(0x3Eu)) .LinesProvider() .Select(static line => line.Text)); private static UiElement? ResolveSkillRowTemplate( uint templateLayoutId, uint templateElementId) => templateElementId switch { SummaryLineTemplateId => BuildSummaryLineTemplate(), SummaryHeaderTemplateId => BuildSummaryHeaderTemplate(), SummaryPairTemplateId => BuildSummaryPairTemplate(), _ => 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, Finish, () => RequestExitCalls++, SetAppearanceIndex: SetAppearanceIndex, SetShade: SetShade, ResolveText: key => ResolvedStrings.TryGetValue(key, out string? value) ? value : null, SetName: SetName, AcknowledgeRejection: AcknowledgeRejection, RandomizeCharacter: RandomizeCharacter, RandomizeAppearance: () => { RandomizeAppearanceCalls++; return Result(RuntimeCommandStatus.Accepted); }, RandomizeClothing: () => { RandomizeClothingCalls++; return Result(RuntimeCommandStatus.Accepted); }, GetSkillScore: GetSkillScore, OpenOnStart: false); } /// F3/F12(d) (CC5 review-fix round, 2026-08-16): deterministic /// stand-in for RetailSkillFormula.CalculateChargenScore — /// skillId * 10 so tests can assert an exact, unambiguous value /// without needing a real SkillTable. private static uint GetSkillScore( uint skillId, ChargenAttributeValues attributes, ChargenSkillAdvancementClass level) => skillId * 10u; 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 string? LastSetName { get; private set; } public int FinishCallCount { get; private set; } public bool LastConfirmedUnspentCredits { get; private set; } public int AcknowledgeRejectionCalls { get; private set; } public int RandomizeCharacterCalls { get; private set; } public int RandomizeAppearanceCalls { get; private set; } public int RandomizeClothingCalls { get; private set; } /// /// The exact HeritageId/GenderKey a fake RandomizeCharacter roll /// lands on — deterministic (not random) so tests can assert the /// flip-to-opposite-gender behavior precisely. Defaults to 0/0 (a /// no-op "roll") so the 37 PRE-EXISTING Open() call sites in /// this file — written against the honest-blank-open contract /// AP-214 tracked before this slice — keep observing a blank /// heritage/gender after Open() without every one of them /// having to opt out individually; only the tests THIS slice adds /// that specifically exercise the roll set these explicitly. /// public uint RandomizedHeritageId { get; set; } public uint RandomizedGenderKey { get; set; } /// Lets a test simulate the Runtime-inactive/rejected case /// (e.g. a session already gone) without needing a real /// RuntimeCharacterCreationState. public bool RandomizeCharacterAccepts { get; set; } = true; /// Populated by tests exercising the ID_Character_Err_* /// rejection-dialog path — ResolveText above reads from it. public Dictionary ResolvedStrings { get; } = []; 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 RuntimeCommandResult SetName(string name) { LastSetName = name; View.Snapshot = View.Snapshot with { Name = name }; return Result(RuntimeCommandStatus.Accepted); } /// Mirrors RuntimeCharacterCreationState.TryBeginFinish's /// refusal-priority chain closely enough to drive /// CharacterCreationUiController.TryFinish's own dialog /// dispatch under test — NoName, then HeritageOrGenderUnset, then /// (unless confirmed) AttributeCreditsUnspent, else Accepted. private RuntimeCommandResult Finish(bool confirmUnspentCredits) { FinishCallCount++; LastConfirmedUnspentCredits = confirmUnspentCredits; RuntimeCharacterCreationSnapshot snapshot = View.Snapshot; string trimmed = snapshot.Name.Trim(); RuntimeCharacterCreationLocalRefusal refusal = trimmed.Length == 0 ? new RuntimeCharacterCreationLocalRefusal(NoName: true, false, false, false) : snapshot.HeritageId == 0u || snapshot.GenderKey == 0u ? new RuntimeCharacterCreationLocalRefusal( false, false, false, false, HeritageOrGenderUnset: true) : !confirmUnspentCredits && snapshot.RemainingAttributeCredits > 0 ? new RuntimeCharacterCreationLocalRefusal( false, AttributeCreditsUnspent: true, false, false) : RuntimeCharacterCreationLocalRefusal.None; View.Snapshot = snapshot with { Name = trimmed, LastLocalRefusal = refusal }; return Result(refusal.Any ? RuntimeCommandStatus.Rejected : RuntimeCommandStatus.Accepted); } private RuntimeCommandResult AcknowledgeRejection() { AcknowledgeRejectionCalls++; View.Snapshot = View.Snapshot with { LastRejection = null }; return Result(RuntimeCommandStatus.Accepted); } /// Deterministic fake for /// RuntimeCharacterCreationState.TryRandomizeCharacter — real /// randomness would make the gender-flip assertion in /// Open_RollsARandomCharacterThenFlipsAppearancePageGender /// flaky, so this always lands on / /// instead. private RuntimeCommandResult RandomizeCharacter() { RandomizeCharacterCalls++; if (!RandomizeCharacterAccepts) return Result(RuntimeCommandStatus.Rejected); View.Snapshot = View.Snapshot with { HeritageId = RandomizedHeritageId, GenderKey = RandomizedGenderKey, }; 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 { 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 { [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)GenderKey] = gender }); var olthoi = new ChargenHeritageOptions( OlthoiId, "Olthoi", IconId: 0u, SetupId: 0x2000054u, EnvironmentSetupId: 0u, AttributeCredits: 60u, SkillCredits: 0u, PrimaryStartAreaIndices: [0], SecondaryStartAreaIndices: [], SkillCostsBySkillId: new Dictionary(), Templates: [ new ChargenTemplate( "Custom", IconId: 0u, TitleStringId: 0u, Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10), NormalSkills: [], PrimarySkills: []), ], GendersByKey: new Dictionary { [(int)GenderKey] = gender }); var starterAreas = new List { 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 { [AluvianId] = aluvian, [OlthoiId] = olthoi, }, new Dictionary()); } } private sealed class FakeView(ChargenOptions options) : IRuntimeCharacterCreationView { private readonly Dictionary _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)); // GF-15 fix round: FinishElementId needs a real, non-default // position for the real-event-path regression test — see the // matching comment on the Summary name field below. Clear of both // the listbox (20,40)-(420,340) and the field (450,100)-(490,116). ElementInfo finishInfo = ButtonInfo(CharacterCreationUiController.FinishElementId); finishInfo.X = 600f; finishInfo.Y = 500f; root.Children.Add(finishInfo); 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(BuildSummaryPage()); 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, }; // GF-5 (2026-08-16): [0] is retail's own bucket-HEADER row // (0x100002F4, unused by this port's flat-list simplification); // [1] (0x100002FF) is the REAL skill row RebuildRows now resolves — // see CharacterCreationSkillsPage's own class doc for the byte // trace pinning both ids and their child shapes. list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100002F4u)); list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100002FFu)); 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; } /// 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 '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. 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; } /// /// GF-5: mirrors Templates[1]'s real installed-DAT shape /// (0x100002FF, live-DAT-probe-confirmed against /// CharacterCreationSkillsPage's own class doc) — a plain /// container root, NOT a UiButton, with the six children /// RebuildRows/RefreshRowValues resolve by id. Template /// 0x100002F4 (retail's unused bucket-header row) falls back to /// a bare button shape since this port's flat-list simplification never /// resolves it. /// private static UiElement BuildSkillRowTemplate(uint templateElementId) { if (templateElementId == 0x100002FFu) { var row = new ElementInfo { Id = templateElementId, Type = 3u, Width = 280f, Height = 16f, }; row.Children.Add(ContainerInfo(0x10000300u)); // unreferenced icon/backdrop row.Children.Add(TextInfo(0x10000301u)); // name row.Children.Add(TextInfo(0x10000302u)); // pSkillLevelText row.Children.Add(TextInfo(0x10000303u)); // pUpCostText row.Children.Add(ButtonInfo(0x10000304u)); // pSkillUpButton row.Children.Add(ButtonInfo(0x10000305u)); // pSkillDownButton row.Children.Add(TextInfo(0x10000306u)); // pDownCostText return LayoutImporter.Build(row, _ => (0u, 0, 0), null).Root; } return LayoutImporter.Build( new ElementInfo { Id = templateElementId, Type = 1u, Width = 280f, Height = 16f, }, _ => (0u, 0, 0), null).Root; } // ── Summary page fixture (CC5) ─────────────────────────────────────── // Template element ids match the LIVE-DAT-probe-confirmed retail ones // (CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport) // for readability, though this hand-built fixture doesn't require it. private const uint SummaryLineTemplateId = 0x100002F8u; private const uint SummaryHeaderTemplateId = 0x100002FAu; private const uint SummaryPairTemplateId = 0x100002FBu; private static ElementInfo BuildSummaryPage() { var page = new ElementInfo { Id = CharacterCreationUiController.SummaryPageElementId, Type = 3u, Width = 800f, Height = 500f, }; var list = new ElementInfo { Id = CharacterCreationSummaryPage.ListBoxId, Type = 5u, X = 20f, Y = 40f, Width = 400f, Height = 300f, }; list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, SummaryLineTemplateId)); list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, SummaryHeaderTemplateId)); list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, SummaryPairTemplateId)); page.Children.Add(list); page.Children.Add(ScrollbarInfo(CharacterCreationSummaryPage.ScrollId)); // GF-15 fix round (2026-08-16): the real-event-path regression test // (Finish_EmptyName_RealEventPath_...) drives UiRoot.OnMouseDown by // actual screen coordinates, unlike every other test in this file. // EditableFieldInfo's own default X/Y (0,0) would collide with the // tab strip's own default (0,0) position (BuildScreen's tab buttons // are never given explicit coordinates either — no prior test // needed them, since they all click via .OnClick!() directly) — // clear of both the listbox (20,40)-(420,340) and the default- // positioned tab/nav buttons at Y=0. ElementInfo nameFieldInfo = EditableFieldInfo(CharacterCreationSummaryPage.NameTextId); nameFieldInfo.X = 450f; nameFieldInfo.Y = 100f; page.Children.Add(nameFieldInfo); page.Children.Add(TextInfo(CharacterCreationSummaryPage.HowToTextId)); var viewport = new ElementInfo { Id = CharacterCreationSummaryPage.ViewportId, Type = 0xDu, Width = 300f, Height = 300f, }; page.Children.Add(viewport); return page; } private static UiElement BuildSummaryLineTemplate() { var root = new ElementInfo { Id = 0x90001u, Type = 3u, Width = 380f, Height = 16f }; root.Children.Add(TextInfo(0x100002F9u)); return LayoutImporter.Build(root, _ => (0u, 0, 0), null).Root; } private static UiElement BuildSummaryHeaderTemplate() { var root = new ElementInfo { Id = 0x90002u, Type = 3u, Width = 380f, Height = 18f }; root.Children.Add(TextInfo(0x100000FEu)); return LayoutImporter.Build(root, _ => (0u, 0, 0), null).Root; } private static UiElement BuildSummaryPairTemplate() { var root = new ElementInfo { Id = 0x90003u, Type = 3u, Width = 380f, Height = 16f }; root.Children.Add(TextInfo(0x100002FCu)); root.Children.Add(TextInfo(0x100002FDu)); return LayoutImporter.Build(root, _ => (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; } }