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; /// /// 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); } [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); } /// 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, plus CC4's interim /// auto-gender-select seam (register AD-100). [Fact] public void HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); environment.Button(0x100003BFu).OnClick!(); // Aluvian Assert.Equal(AluvianId, environment.Runtime.LastSelectedHeritage); Assert.Equal(GenderKey, 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); } [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() .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); } /// 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); } 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?"))); 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)); /// 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!(); } 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++, 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 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 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, HairColors: [], HairStyles: [], EyeColors: [], EyeStrips: [], NoseStrips: [], MouthStrips: [], Headgears: [], Shirts: [], Pants: [], Footwear: [], ClothingColors: []); 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)); 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(ContainerInfo(CharacterCreationUiController.AppearancePageElementId)); 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; } 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; } }