Fills TS-82's Summary placeholder with a faithful port of gmCGSummaryPage (name field with NameInputFilter + the retail commit-on-focus-lost/submit dispatch + the >32-char ID_CharGen_NameTooLong reject-and-revert path, the REAL three-row-template listbox confirmed against the installed EoR dat before writing any page code, and Summary's own independent gmCG3DView preview instance wired through a second ChargenPreviewController pair mirroring the Appearance page's exact composition shape). Ports CharGenState::RandomizeCharacter and its six sub-primitives into RuntimeCharacterCreationState — not approximated: the RandInt/RollDice semantics are independently confirmed from both the decompiled RNG bodies and the CharGenStateVtbl union struct in acclient.h. Three consumers: the chargen screen's open-roll (retiring AP-214's honest-blank deviation and reproducing the Appearance page's gender-flip-on-init quirk), the Summary page's Random button (behind the retail randomize-warning confirm), and the Appearance page's Random button (narrowing AP-212 to just Heritage/Profession/Town's still-approximated rolls and Skills' still-unported RandomizeSkills). Wires the Finish button (previously ghosted) with retail's NoName/ CreditWarning dialog pair, adds the F12 amendment's HeritageOrGenderUnset local refusal to TryBeginFinish (register AP-223) as a defensive backstop now that the screen-open roll normally makes it unreachable, and wires the four ID_Character_Err_* rejection dialogs for the 0xF643 response codes CC3 already parsed but nothing displayed. Register: TS-82 retired, AP-214 retired, AP-212 narrowed, AP-223/224/225 filed (heritage/gender Finish refusal, Summary's two-bucket skill-list narrowing, the 32-vs-33 name-length threshold reconciliation). Runtime 1722/0 (was 1713), App 5240/3 skips (was 5223/3), Headless 166/0 unchanged, full solution Release build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1986 lines
84 KiB
C#
1986 lines
84 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Campaign CC slice CC4 — controller binding tests for the character-
|
|
/// creation master shell + Heritage/Profession/Skills/Town pages, using a
|
|
/// hand-built layout fixture (no installed DAT — see
|
|
/// <see cref="CharacterCreationLiveDatTests"/> for the live-DAT id/type
|
|
/// sweep this pairs with). Mirrors <c>CharacterManagementUiControllerTests</c>'
|
|
/// fixture pattern.
|
|
/// </summary>
|
|
public sealed class CharacterCreationUiControllerTests
|
|
{
|
|
private const uint AluvianId = 1u;
|
|
private const uint OlthoiId = (uint)ChargenHeritageGroup.Olthoi;
|
|
private const uint GenderKey = 1u;
|
|
private const uint SkillTrainOnly = 1u;
|
|
private const uint SkillSpecializable = 2u;
|
|
|
|
[Fact]
|
|
public void ActiveScreen_KeepsAuthoredRootExtent_AndDefaultsToTheHeritagePage()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
Assert.False(environment.Controller.Root.Visible);
|
|
|
|
environment.Controller.Open();
|
|
|
|
Assert.True(environment.Controller.Root.Visible);
|
|
Assert.Equal(800f, environment.Controller.Root.Width);
|
|
Assert.Equal(600f, environment.Controller.Root.Height);
|
|
Assert.True(environment.Page(
|
|
CharacterCreationUiController.HeritagePageElementId).Visible);
|
|
Assert.False(environment.Page(
|
|
CharacterCreationUiController.ProfessionPageElementId).Visible);
|
|
Assert.True(environment.TabButton(
|
|
CharacterCreationUiController.HeritageTabElementId).Selected);
|
|
}
|
|
|
|
[Fact]
|
|
public void TabClick_SwitchesToTheClickedPage_FreeOfValidation()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
|
|
// Free navigation: jumping straight to Town from Heritage with
|
|
// nothing selected must still work (gmCharGenMainUI's tab dispatch
|
|
// is not gated — see ApplyProgressState's doc).
|
|
environment.TabButton(CharacterCreationUiController.TownTabElementId)
|
|
.OnClick!();
|
|
|
|
Assert.True(environment.Page(
|
|
CharacterCreationUiController.TownPageElementId).Visible);
|
|
Assert.False(environment.Page(
|
|
CharacterCreationUiController.HeritagePageElementId).Visible);
|
|
Assert.True(environment.TabButton(
|
|
CharacterCreationUiController.TownTabElementId).Selected);
|
|
}
|
|
|
|
[Fact]
|
|
public void Next_AdvancesOnePageAtATime_AndBackReturns()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
|
|
environment.Button(CharacterCreationUiController.NextElementId).OnClick!();
|
|
Assert.True(environment.Page(
|
|
CharacterCreationUiController.ProfessionPageElementId).Visible);
|
|
|
|
environment.Button(CharacterCreationUiController.BackElementId).OnClick!();
|
|
Assert.True(environment.Page(
|
|
CharacterCreationUiController.HeritagePageElementId).Visible);
|
|
}
|
|
|
|
[Fact]
|
|
public void Next_AtSummary_IsANoOp()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
environment.TabButton(CharacterCreationUiController.SummaryTabElementId)
|
|
.OnClick!();
|
|
Assert.True(environment.Page(
|
|
CharacterCreationUiController.SummaryPageElementId).Visible);
|
|
|
|
environment.Button(CharacterCreationUiController.NextElementId).OnClick!();
|
|
|
|
Assert.True(environment.Page(
|
|
CharacterCreationUiController.SummaryPageElementId).Visible);
|
|
}
|
|
|
|
/// <summary>Campaign CC slice CC5: Finish (<c>0x100003c8</c>) is
|
|
/// ghosted everywhere EXCEPT Summary — retail's own
|
|
/// <c>ListenToElementMessage</c> case only sends when
|
|
/// <c>m_eProgressState == ECG_SUMMARY</c>; off Summary the button now
|
|
/// has a real <c>OnClick</c> handler (<c>OnFinish</c>, which itself
|
|
/// re-checks the current page) but stays disabled.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Campaign CC slice CC5: Random (<c>0x100003cb</c>) 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.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>gmCharGenMainUI::ListenToElementMessage @ 0x004e9450's
|
|
/// element 0x100003c6 case: at Heritage (the first page), Back opens
|
|
/// the exit confirmation instead of moving pages.</summary>
|
|
[Fact]
|
|
public void Back_AtHeritage_OpensExitConfirmation()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
|
|
environment.Button(CharacterCreationUiController.BackElementId).OnClick!();
|
|
|
|
Assert.True(environment.Dialogs.IsOpen);
|
|
}
|
|
|
|
[Fact]
|
|
public void Exit_Confirm_ClosesTheScreenAndCallsRequestExit()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
|
|
environment.Button(CharacterCreationUiController.ExitElementId).OnClick!();
|
|
Assert.True(environment.Dialogs.IsOpen);
|
|
|
|
environment.ConfirmActiveDialog(confirmed: true);
|
|
|
|
Assert.False(environment.Controller.Root.Visible);
|
|
Assert.Equal(1, environment.Runtime.RequestExitCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public void Exit_Cancel_LeavesTheScreenOpen()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
|
|
environment.Button(CharacterCreationUiController.ExitElementId).OnClick!();
|
|
environment.ConfirmActiveDialog(confirmed: false);
|
|
|
|
Assert.True(environment.Controller.Root.Visible);
|
|
Assert.Equal(0, environment.Runtime.RequestExitCalls);
|
|
}
|
|
|
|
/// <summary>gmCGHeritagePage::ListenToElementMessage @ 0x00483860's
|
|
/// per-button SetHeritageGroup literal. AD-101 RETIRED at CC6b-MOUNT:
|
|
/// a heritage click no longer auto-selects a gender — the Appearance
|
|
/// page's real gender buttons are the only gender-selection path now
|
|
/// (see <see cref="AppearanceGenderButton_SelectsGender"/>).</summary>
|
|
[Fact]
|
|
public void HeritageButton_SelectsHeritage_WithNoGenderSideEffect()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
|
|
environment.Button(0x100003BFu).OnClick!(); // Aluvian
|
|
|
|
Assert.Equal(AluvianId, environment.Runtime.LastSelectedHeritage);
|
|
Assert.Equal(0u, environment.Runtime.LastSelectedGender);
|
|
}
|
|
|
|
/// <summary>gmCharGenMainUI::SetProgressState @ 0x004e7a10's Olthoi
|
|
/// branch: Profession/Skills/Town tabs hide, and paging past the
|
|
/// hidden range redirects to Appearance/Summary.</summary>
|
|
[Fact]
|
|
public void OlthoiHeritage_HidesProfessionSkillsAndTownTabs()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
environment.Runtime.SelectHeritageDirect(OlthoiId);
|
|
|
|
environment.TabButton(CharacterCreationUiController.HeritageTabElementId)
|
|
.OnClick!();
|
|
|
|
Assert.False(environment.TabButton(
|
|
CharacterCreationUiController.ProfessionTabElementId).Visible);
|
|
Assert.False(environment.TabButton(
|
|
CharacterCreationUiController.SkillsTabElementId).Visible);
|
|
Assert.False(environment.TabButton(
|
|
CharacterCreationUiController.TownTabElementId).Visible);
|
|
|
|
// Next from Heritage would normally land on Profession; for an
|
|
// Olthoi heritage it must redirect straight to Appearance.
|
|
environment.Button(CharacterCreationUiController.NextElementId).OnClick!();
|
|
Assert.True(environment.Page(
|
|
CharacterCreationUiController.AppearancePageElementId).Visible);
|
|
}
|
|
|
|
[Fact]
|
|
public void ProfessionTemplateButton_SelectsTemplate()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
environment.Runtime.SelectHeritageDirect(AluvianId);
|
|
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId)
|
|
.OnClick!();
|
|
|
|
environment.Button(0x100003DAu).OnClick!(); // Bow Hunter = template index 1
|
|
|
|
Assert.Equal(1u, environment.Runtime.LastSelectedTemplate);
|
|
}
|
|
|
|
[Fact]
|
|
public void ProfessionSlider_ScalarChange_SetsTheAttribute()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
environment.Runtime.SelectHeritageDirect(AluvianId);
|
|
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId)
|
|
.OnClick!();
|
|
|
|
UiElement strengthContainer = Assert.IsAssignableFrom<UiElement>(
|
|
environment.Screen.FindElement(0x100003E6u));
|
|
var slider = Assert.IsType<UiScrollbar>(
|
|
UiElement.FindDescendant(strengthContainer, 0x100002EEu));
|
|
|
|
slider.ScalarChanged!(1f); // top of the [10,100] range
|
|
|
|
Assert.Equal(ChargenAttributeId.Strength, environment.Runtime.LastAttributeSet);
|
|
Assert.Equal(100, environment.Runtime.LastAttributeValue);
|
|
}
|
|
|
|
[Fact]
|
|
public void ProfessionValueField_DirectEntry_SetsTheAttribute()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
environment.Runtime.SelectHeritageDirect(AluvianId);
|
|
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId)
|
|
.OnClick!();
|
|
|
|
UiElement strengthContainer = Assert.IsAssignableFrom<UiElement>(
|
|
environment.Screen.FindElement(0x100003E6u));
|
|
var field = Assert.IsType<UiField>(
|
|
UiElement.FindDescendant(strengthContainer, 0x100002EFu));
|
|
|
|
field.OnSubmit!("42");
|
|
|
|
Assert.Equal(ChargenAttributeId.Strength, environment.Runtime.LastAttributeSet);
|
|
Assert.Equal(42, environment.Runtime.LastAttributeValue);
|
|
}
|
|
|
|
[Fact]
|
|
public void SkillsRow_Click_TrainsThenSpecializes()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
environment.Runtime.SelectHeritageDirect(AluvianId);
|
|
environment.TabButton(CharacterCreationUiController.SkillsTabElementId)
|
|
.OnClick!();
|
|
|
|
// Rows are built in ascending skill-id order (RebuildRows' 1..54
|
|
// walk over IsCostable ids) — SkillSpecializable's row is identified
|
|
// by its label prefix (FormatSkillLabel's "{name}: ..." shape)
|
|
// rather than instance identity, since the controller owns the
|
|
// row->skillId map privately.
|
|
string skillName = ItemAppraisalTextFormatter.SkillName((int)SkillSpecializable);
|
|
UiButton row = environment.SkillsList().ViewportForTest!.Children
|
|
.OfType<UiButton>()
|
|
.Single(candidate => candidate.Label!.StartsWith(
|
|
skillName + ":", StringComparison.Ordinal));
|
|
|
|
row.OnClick!();
|
|
Assert.Equal(ChargenSkillAdvancementClass.Trained,
|
|
environment.Runtime.GetSkillLevel(SkillSpecializable));
|
|
|
|
row.OnClick!();
|
|
Assert.Equal(ChargenSkillAdvancementClass.Specialized,
|
|
environment.Runtime.GetSkillLevel(SkillSpecializable));
|
|
|
|
row.OnDoubleClick!();
|
|
Assert.Equal(ChargenSkillAdvancementClass.Trained,
|
|
environment.Runtime.GetSkillLevel(SkillSpecializable));
|
|
}
|
|
|
|
[Fact]
|
|
public void TownButton_SelectsTheLiteralStartAreaIndex()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
environment.TabButton(CharacterCreationUiController.TownTabElementId)
|
|
.OnClick!();
|
|
|
|
// Holtburg (0x1000040d) -> startArea 0 per SetTown's literal map.
|
|
environment.Button(0x1000040Du).OnClick!();
|
|
Assert.Equal(0, environment.Runtime.LastSelectedStartArea);
|
|
|
|
// Yaraq (0x1000040e) -> startArea 2.
|
|
environment.Button(0x1000040Eu).OnClick!();
|
|
Assert.Equal(2, environment.Runtime.LastSelectedStartArea);
|
|
}
|
|
|
|
/// <summary>Review fix round F4 (2026-08-15): <c>gmCGTownPage::SetTown
|
|
/// @ 0x0047c360</c> also sets the TOWN PAGE'S OWN retail state via a
|
|
/// literal per-town map — Holtburg->0x10000034, Yaraq->0x10000036 —
|
|
/// SEPARATE from the master page's per-page-index cycling
|
|
/// (<c>0x10000025+page</c>, already covered by the page-switch tests
|
|
/// above).</summary>
|
|
[Fact]
|
|
public void TownButton_Refresh_SetsThePagesOwnRetailStateLiteral()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
environment.TabButton(CharacterCreationUiController.TownTabElementId)
|
|
.OnClick!();
|
|
|
|
var pageRoot = Assert.IsType<UiDatElement>(
|
|
environment.Page(CharacterCreationUiController.TownPageElementId));
|
|
|
|
environment.Button(0x1000040Du).OnClick!(); // Holtburg -> startArea 0
|
|
BumpRevisionAndTick(environment);
|
|
Assert.Equal("Holtburg", pageRoot.ActiveState);
|
|
|
|
environment.Button(0x1000040Eu).OnClick!(); // Yaraq -> startArea 2
|
|
BumpRevisionAndTick(environment);
|
|
Assert.Equal("Yaraq", pageRoot.ActiveState);
|
|
}
|
|
|
|
/// <summary>Review fix round F2 (2026-08-15), the display direction:
|
|
/// <c>gmCGProfessionPage::UpdateAttributeValues @ 0x0048251d</c> sets
|
|
/// the slider's scalar position to <c>value * 0.00999999978f</c>
|
|
/// (value/100), not a [10,100]-to-[0,1] rescale.</summary>
|
|
[Fact]
|
|
public void ProfessionSlider_Refresh_DisplaysScalarAsValueOverOneHundred()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
environment.Runtime.SelectHeritageDirect(AluvianId);
|
|
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId)
|
|
.OnClick!();
|
|
|
|
UiElement strengthContainer = Assert.IsAssignableFrom<UiElement>(
|
|
environment.Screen.FindElement(0x100003E6u));
|
|
var slider = Assert.IsType<UiScrollbar>(
|
|
UiElement.FindDescendant(strengthContainer, 0x100002EEu));
|
|
|
|
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
|
|
environment.Runtime.View.Snapshot = snapshot with
|
|
{
|
|
Revision = snapshot.Revision + 1,
|
|
Attributes = snapshot.Attributes with { Strength = 55 },
|
|
};
|
|
environment.Controller.Tick();
|
|
|
|
Assert.Equal(0.55f, slider.ScalarPosition);
|
|
}
|
|
|
|
/// <summary>Review fix round F2 (2026-08-15), the drag-inverse
|
|
/// direction: <c>ListenToElementMessage @ 0x004829c0</c>'s scrollbar-
|
|
/// drag case truncates <c>scalar*100</c> and clamps LOW only to 10 —
|
|
/// NOT the [10,100]<->[0,1] rescale the previous (wrong) formula
|
|
/// used, which only coincidentally agreed with the correct one at
|
|
/// scalar=1 (the pre-existing
|
|
/// <see cref="ProfessionSlider_ScalarChange_SetsTheAttribute"/> case).</summary>
|
|
[Theory]
|
|
[InlineData(0.5f, 50)]
|
|
[InlineData(0f, 10)]
|
|
public void ProfessionSlider_ScalarChange_TruncatesAndClampsLowOnly(
|
|
float scalar,
|
|
int expectedValue)
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
environment.Runtime.SelectHeritageDirect(AluvianId);
|
|
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId)
|
|
.OnClick!();
|
|
|
|
UiElement strengthContainer = Assert.IsAssignableFrom<UiElement>(
|
|
environment.Screen.FindElement(0x100003E6u));
|
|
var slider = Assert.IsType<UiScrollbar>(
|
|
UiElement.FindDescendant(strengthContainer, 0x100002EEu));
|
|
|
|
slider.ScalarChanged!(scalar);
|
|
|
|
Assert.Equal(ChargenAttributeId.Strength, environment.Runtime.LastAttributeSet);
|
|
Assert.Equal(expectedValue, environment.Runtime.LastAttributeValue);
|
|
}
|
|
|
|
/// <summary>Review fix round F3 (2026-08-15):
|
|
/// <c>gmCharGenMainUI::ListenToElementMessage @ 0x004e9450</c>'s
|
|
/// heritage-button bubble arm shows/hides the Profession/Skills/Town
|
|
/// tabs SYNCHRONOUSLY at click time — independent of
|
|
/// <see cref="OlthoiHeritage_HidesProfessionSkillsAndTownTabs"/>'s
|
|
/// page-switch-time recompute (no tab/Back/Next click happens in this
|
|
/// test at all). Lugian (<c>0x100005f1</c>) sits outside BOTH the SHOW
|
|
/// and HIDE case lists in the decompiled switch — a genuine retail
|
|
/// quirk, reproduced faithfully.</summary>
|
|
[Fact]
|
|
public void HeritageButtonClick_RestoresHiddenTabsAtClickTime_ExceptLugian()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
|
|
environment.Button(0x100005C7u).OnClick!(); // Olthoi -> HIDE
|
|
Assert.False(environment.TabButton(
|
|
CharacterCreationUiController.ProfessionTabElementId).Visible);
|
|
Assert.False(environment.TabButton(
|
|
CharacterCreationUiController.SkillsTabElementId).Visible);
|
|
Assert.False(environment.TabButton(
|
|
CharacterCreationUiController.TownTabElementId).Visible);
|
|
|
|
environment.Button(0x100005F1u).OnClick!(); // Lugian -> no-op quirk
|
|
Assert.False(environment.TabButton(
|
|
CharacterCreationUiController.ProfessionTabElementId).Visible);
|
|
Assert.False(environment.TabButton(
|
|
CharacterCreationUiController.SkillsTabElementId).Visible);
|
|
Assert.False(environment.TabButton(
|
|
CharacterCreationUiController.TownTabElementId).Visible);
|
|
|
|
environment.Button(0x100003BFu).OnClick!(); // Aluvian -> SHOW
|
|
Assert.True(environment.TabButton(
|
|
CharacterCreationUiController.ProfessionTabElementId).Visible);
|
|
Assert.True(environment.TabButton(
|
|
CharacterCreationUiController.SkillsTabElementId).Visible);
|
|
Assert.True(environment.TabButton(
|
|
CharacterCreationUiController.TownTabElementId).Visible);
|
|
}
|
|
|
|
/// <summary>Review fix round F1 (2026-08-15): <c>Open()</c> sets
|
|
/// <c>UiRoot.FixedCanvasSize</c> once on the activation edge (matching
|
|
/// <c>CharacterManagementUiController</c>'s real, non-per-tick shape);
|
|
/// <c>Close()</c>/<c>Deactivate()</c>/<c>Dispose()</c> null it back out
|
|
/// symmetrically. Before this fix nothing ever nulled it, so an
|
|
/// 800x600-scaled canvas silently covered the in-world UI for the rest
|
|
/// of the session once this screen had ever been opened.</summary>
|
|
[Fact]
|
|
public void Open_SetsFixedCanvas_ExitConfirmClosesAndNullsIt()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
Assert.Null(environment.Host.FixedCanvasSize);
|
|
|
|
environment.Controller.Open();
|
|
Assert.Equal(new Vector2(800f, 600f), environment.Host.FixedCanvasSize);
|
|
|
|
environment.Button(CharacterCreationUiController.ExitElementId).OnClick!();
|
|
environment.ConfirmActiveDialog(confirmed: true);
|
|
|
|
Assert.Null(environment.Host.FixedCanvasSize);
|
|
}
|
|
|
|
[Fact]
|
|
public void Deactivate_NullsFixedCanvas_AndClosesTheScreen()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
Assert.NotNull(environment.Host.FixedCanvasSize);
|
|
|
|
// Runtime reporting the view inactive/gone (e.g. entering the
|
|
// world) must Deactivate -- previously nothing drove this because
|
|
// RuntimeCharacterCreationState had no CompleteEnter() analogue;
|
|
// this test exercises the CONTROLLER side of that fix directly by
|
|
// simulating the view disappearing.
|
|
environment.Runtime.ProvideView = false;
|
|
environment.Controller.Tick();
|
|
|
|
Assert.False(environment.Controller.Root.Visible);
|
|
Assert.Null(environment.Host.FixedCanvasSize);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_NullsFixedCanvas()
|
|
{
|
|
var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
Assert.NotNull(environment.Host.FixedCanvasSize);
|
|
|
|
environment.Dispose();
|
|
|
|
Assert.Null(environment.Host.FixedCanvasSize);
|
|
}
|
|
|
|
// ── Campaign CC slice CC6b-MOUNT: Appearance page ───────────────────
|
|
|
|
[Fact]
|
|
public void AppearanceGenderButton_SelectsGender()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
environment.Runtime.SelectHeritageDirect(AluvianId);
|
|
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
|
|
|
|
environment.Button(CharacterCreationAppearancePage.MaleButtonId).OnClick!();
|
|
Assert.Equal(1u, environment.Runtime.LastSelectedGender);
|
|
|
|
environment.Button(CharacterCreationAppearancePage.FemaleButtonId).OnClick!();
|
|
Assert.Equal(2u, environment.Runtime.LastSelectedGender);
|
|
}
|
|
|
|
/// <summary>Spin arrow geometry (live-DAT-measured, see
|
|
/// <see cref="CharacterCreationAppearancePage"/>'s own class doc):
|
|
/// x=[80,127) is the decrement child, x=[127,174) is the increment
|
|
/// child.</summary>
|
|
[Fact]
|
|
public void AppearanceSpin_IncrementZoneClick_CyclesStyleForwardFromUnset()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
SelectAluvianMale(environment);
|
|
|
|
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(150, 10);
|
|
|
|
Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot);
|
|
Assert.Equal(0u, environment.Runtime.LastAppearanceIndex);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix round F1: this test previously asserted index 0 here, pinning a
|
|
/// doc claim ("no decomp-observable Unset-cycling case") the decomp
|
|
/// refutes. Hair's own decrement case
|
|
/// (<c>gmCGAppearancePage::ListenToElementMessage @0x0047f465-0x0047f086</c>,
|
|
/// the same shared tail the headgear ring reuses at
|
|
/// <c>label_47f065</c>/<c>label_47f6d9</c>) computes
|
|
/// <c>new = cur - 1</c> on the raw signed int32 (Unset = -1), giving
|
|
/// <c>new = -2</c>; since <c>-2 < 0</c> it wraps to <c>count - 1</c>,
|
|
/// NOT 0 — see <see cref="CharacterCreationAppearancePage.CycleIndex"/>'s
|
|
/// own corrected doc. The fixture's Hair style count is 3
|
|
/// (<see cref="EnvironmentHarness.BuildOptions"/>), so the expected
|
|
/// landing index is 2.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AppearanceSpin_DecrementZoneClick_FromUnset_WrapsToLastStyle()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
SelectAluvianMale(environment);
|
|
|
|
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(100, 10);
|
|
|
|
Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot);
|
|
Assert.Equal(2u, environment.Runtime.LastAppearanceIndex);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix round F10: pins the SELECT zone — x=[174,200), right of the
|
|
/// increment arrow's own x=[127,174) (live-DAT-measured, both arrows
|
|
/// 47px wide — <c>CharacterCreationLiveDatTests</c>) — as a body click
|
|
/// that selects the part WITHOUT invoking another style cycle, once the
|
|
/// part already holds a real (non-Unset) index. Distinguishes the third
|
|
/// <c>OnClickAt</c> zone from the two arrow-zone tests above, which no
|
|
/// prior test isolated.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AppearanceSpin_SelectZoneClick_SelectsPartWithoutChangingIndex()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
SelectAluvianMale(environment);
|
|
|
|
// Establish a real Hair index first (increment zone, x=150).
|
|
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(150, 10);
|
|
Assert.Equal(0u, environment.Runtime.LastAppearanceIndex);
|
|
int callsAfterCycle = environment.Runtime.AppearanceIndexCallCount;
|
|
|
|
// x=180 is inside [174,200) — past the increment arrow's own zone,
|
|
// still inside the 200px-wide spin — the spin's own BODY, not
|
|
// either arrow.
|
|
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(180, 10);
|
|
|
|
// Already in [0,count) — NormalizeChoiceOnSelect (F1) is a no-op,
|
|
// so the select zone must not re-invoke SetAppearanceIndex.
|
|
Assert.Equal(callsAfterCycle, environment.Runtime.AppearanceIndexCallCount);
|
|
Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot);
|
|
Assert.Equal(0u, environment.Runtime.LastAppearanceIndex);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix round F1: pins the body-click normalize-and-write-back
|
|
/// (<see cref="CharacterCreationAppearancePage"/>'s own
|
|
/// <c>NormalizeChoiceOnSelect</c> doc) for the case that's actually
|
|
/// reachable in acdream — a part still Unset (AP-214 honest-blank open)
|
|
/// gets clicked in its SELECT zone (not an arrow) — wraps to
|
|
/// <c>count-1</c> and writes it back, exactly like a decrement click
|
|
/// would, even though no arrow was pressed.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AppearanceSpin_SelectZoneClick_FromUnset_NormalizesToLastStyle()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
SelectAluvianMale(environment);
|
|
|
|
// Hair starts Unset (AP-214 honest-blank). x=180 is the select
|
|
// zone, not either arrow.
|
|
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(180, 10);
|
|
|
|
Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot);
|
|
Assert.Equal(2u, environment.Runtime.LastAppearanceIndex); // count-1, fixture has 3 hair styles.
|
|
}
|
|
|
|
/// <summary>CharGenState::SetHeadgearStyle's decomp-derived
|
|
/// (count+1)-position ring: incrementing from Unset lands on style 0;
|
|
/// decrementing FROM style 0 lands back on Unset. Headgear is the ONLY
|
|
/// spin with this ring — see <see cref="CharacterCreationAppearancePage.CycleIndex"/>'s
|
|
/// own citation.</summary>
|
|
[Fact]
|
|
public void AppearanceHeadgearSpin_RingIncludesTheUnsetPosition()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
SelectAluvianMale(environment);
|
|
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
|
|
environment.Button(CharacterCreationAppearancePage.ClothesButtonId).OnClick!();
|
|
|
|
environment.Button(CharacterCreationAppearancePage.HeadgearSpinId).OnClickAt!(150, 10); // increment
|
|
Assert.Equal(ChargenAppearanceSlot.HeadgearStyle, environment.Runtime.LastAppearanceSlot);
|
|
Assert.Equal(0u, environment.Runtime.LastAppearanceIndex);
|
|
|
|
environment.Button(CharacterCreationAppearancePage.HeadgearSpinId).OnClickAt!(100, 10); // decrement
|
|
Assert.Equal(RuntimeCharacterCreationAppearance.Unset, environment.Runtime.LastAppearanceIndex);
|
|
}
|
|
|
|
/// <summary>Pure wrap-semantics unit tests for
|
|
/// <see cref="CharacterCreationAppearancePage.CycleIndex"/> — the
|
|
/// decomp-derived arithmetic every spin's OnClickAt zone drives.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(0u, +1, 3, false, 1u)]
|
|
[InlineData(2u, +1, 3, false, 0u)] // plain wrap forward past the end.
|
|
[InlineData(1u, -1, 3, false, 0u)]
|
|
[InlineData(0u, -1, 3, false, 2u)] // plain wrap backward past the start.
|
|
[InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, false, 0u)]
|
|
// Fix round F1: decrement-from-Unset wraps to count-1 (2), not 0 — the
|
|
// decomp's shared decrement tail (label_47f065/label_47f6d9) computes
|
|
// new = cur - 1 = -2 on the raw signed int32, which is < 0, so it wraps
|
|
// to count-1 exactly like headgear's own ring does for its non-Unset
|
|
// range. See CycleIndex's own corrected doc.
|
|
[InlineData(RuntimeCharacterCreationAppearance.Unset, -1, 3, false, 2u)]
|
|
[InlineData(0u, -1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: 0 -> Unset.
|
|
[InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, true, 0u)] // headgear ring: Unset -> 0.
|
|
[InlineData(2u, +1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: last -> Unset.
|
|
[InlineData(RuntimeCharacterCreationAppearance.Unset, -1, 3, true, 2u)] // headgear ring: Unset -> last.
|
|
public void CycleIndex_MatchesRetailsDecompDerivedWrap(
|
|
uint current, int delta, int count, bool allowUnset, uint expected)
|
|
{
|
|
Assert.Equal(
|
|
expected,
|
|
CharacterCreationAppearancePage.CycleIndex(current, delta, count, allowUnset));
|
|
}
|
|
|
|
/// <summary>Skin has no style index — retail disables its arrow
|
|
/// children (SetAttribute_Bool(...,0xd,1)). Every click on the skin
|
|
/// spin only selects it as the current part.</summary>
|
|
[Fact]
|
|
public void AppearanceSkinSpin_Click_NeverCallsSetAppearanceIndex()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
SelectAluvianMale(environment);
|
|
|
|
environment.Button(CharacterCreationAppearancePage.SkinSpinId).OnClickAt!(100, 10);
|
|
environment.Button(CharacterCreationAppearancePage.SkinSpinId).OnClickAt!(150, 10);
|
|
|
|
Assert.Equal(0, environment.Runtime.AppearanceIndexCallCount);
|
|
}
|
|
|
|
/// <summary>gmCGAppearancePage::Update's heritage 6/0xc/0xd gate: the
|
|
/// Clothes sub-tab and the Nose/Mouth spins all hide.</summary>
|
|
[Fact]
|
|
public void OlthoiHeritage_HidesClothesButtonAndNoseMouthSpins()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
environment.Runtime.SelectHeritageDirect(OlthoiId);
|
|
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
|
|
|
|
Assert.False(environment.Button(CharacterCreationAppearancePage.ClothesButtonId).Visible);
|
|
Assert.False(environment.Button(CharacterCreationAppearancePage.NoseSpinId).Visible);
|
|
Assert.False(environment.Button(CharacterCreationAppearancePage.MouthSpinId).Visible);
|
|
}
|
|
|
|
/// <summary>Fixture's Hair color list has 3 entries (indices 0-2) — a
|
|
/// swatch beyond that never reaches SetAppearanceIndex, matching
|
|
/// retail's own <c>iNumColors > N</c> gate.</summary>
|
|
[Fact]
|
|
public void AppearanceSwatch_WithinColorCount_SetsColorForTheCurrentPart()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
SelectAluvianMale(environment);
|
|
|
|
// Part defaults to Hair on construction — no extra click needed.
|
|
environment.Button(CharacterCreationAppearancePage.SwatchIds[1]).OnClick!();
|
|
|
|
Assert.Equal(ChargenAppearanceSlot.HairColor, environment.Runtime.LastAppearanceSlot);
|
|
Assert.Equal(1u, environment.Runtime.LastAppearanceIndex);
|
|
}
|
|
|
|
[Fact]
|
|
public void AppearanceSwatch_BeyondColorCount_IsANoOp()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
SelectAluvianMale(environment);
|
|
|
|
environment.Button(CharacterCreationAppearancePage.SwatchIds[^1]).OnClick!(); // index 8, count 3.
|
|
|
|
Assert.Equal(0, environment.Runtime.AppearanceIndexCallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void AppearanceShadeScroll_ScalarChanged_SetsShadeForTheCurrentPart()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
SelectAluvianMale(environment);
|
|
|
|
environment.ShadeScroll().ScalarChanged!(0.75f);
|
|
|
|
Assert.Equal(ChargenShadeSlot.Hair, environment.Runtime.LastShadeSlot);
|
|
Assert.Equal(0.75, environment.Runtime.LastShadeValue, 3);
|
|
}
|
|
|
|
/// <summary>Nose/Mouth/Skin all route the shade scroll to SKIN shade —
|
|
/// SetShade's cases 2/3/4 share one body in the decompiled switch.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AppearanceShadeScroll_ForNoseOrMouth_RoutesToSkinShade()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
SelectAluvianMale(environment);
|
|
// Select Nose as the current part: click its body (outside the
|
|
// arrow zones), which never changes the style index.
|
|
environment.Button(CharacterCreationAppearancePage.NoseSpinId).OnClickAt!(10, 10);
|
|
|
|
environment.ShadeScroll().ScalarChanged!(0.5f);
|
|
|
|
Assert.Equal(ChargenShadeSlot.Skin, environment.Runtime.LastShadeSlot);
|
|
}
|
|
|
|
[Fact]
|
|
public void AppearanceZoomAndRotateButtons_DelegateToThePreviewControl()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
var preview = new FakeChargenPreviewControl();
|
|
environment.Controller.AppearancePreviewControl = preview;
|
|
|
|
environment.Button(CharacterCreationAppearancePage.ZoomInId).OnClick!();
|
|
environment.Button(CharacterCreationAppearancePage.ZoomOutId).OnClick!();
|
|
environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!();
|
|
environment.Button(CharacterCreationAppearancePage.RotateCounterClockwiseId).OnClick!();
|
|
|
|
Assert.Equal(1, preview.ZoomInCalls);
|
|
Assert.Equal(1, preview.ZoomOutCalls);
|
|
Assert.Equal(1, preview.RotateClockwiseCalls);
|
|
Assert.Equal(1, preview.RotateCounterClockwiseCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public void AppearanceZoomButtons_WithNoPreviewControlAssignedYet_AreHarmlessNoOps()
|
|
{
|
|
using var environment = new EnvironmentHarness();
|
|
environment.Controller.Open();
|
|
|
|
// The page mounts before the graphics backend exists — every zoom/
|
|
// rotate click before LivePresentationComposition assigns a real
|
|
// control must be a silent no-op, not a NullReferenceException.
|
|
environment.Button(CharacterCreationAppearancePage.ZoomInId).OnClick!();
|
|
environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!();
|
|
}
|
|
|
|
private static void SelectAluvianMale(EnvironmentHarness environment)
|
|
{
|
|
environment.Runtime.SelectHeritageDirect(AluvianId);
|
|
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
|
|
environment.Button(CharacterCreationAppearancePage.MaleButtonId).OnClick!();
|
|
}
|
|
|
|
// ── CC5: RandomizeCharacter open-roll + gender flip ─────────────────
|
|
|
|
/// <summary>Ports <c>gmCharGenMainUI</c>'s ctor-time roll +
|
|
/// <c>gmCGAppearancePage::InitializePage</c>'s gender-flip
|
|
/// (<c>~0x004802da-0x00480303</c>) — retiring AP-214's honest-blank
|
|
/// deviation. <see cref="FakeRuntime.RandomizeCharacter"/> lands
|
|
/// deterministically on <see cref="FakeRuntime.RandomizedHeritageId"/>/
|
|
/// <see cref="FakeRuntime.RandomizedGenderKey"/> so the flip assertion
|
|
/// isn't flaky.</summary>
|
|
[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());
|
|
}
|
|
|
|
[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: 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);
|
|
}
|
|
|
|
[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<UiElement> Descendants(UiElement root)
|
|
{
|
|
yield return root;
|
|
foreach (UiElement child in root.Children)
|
|
foreach (UiElement descendant in Descendants(child))
|
|
yield return descendant;
|
|
}
|
|
|
|
// ── Fixture ──────────────────────────────────────────────────────────
|
|
|
|
private sealed class EnvironmentHarness : IDisposable
|
|
{
|
|
private readonly List<ImportedLayout> _dialogLayouts = [];
|
|
|
|
public EnvironmentHarness()
|
|
{
|
|
Host = new UiRoot { Width = 800f, Height = 600f };
|
|
Screen = BuildScreen();
|
|
Runtime = new FakeRuntime();
|
|
Dialogs = new RetailDialogFactory(Host, type =>
|
|
{
|
|
ImportedLayout layout = RetailDialogFactoryTests.BuildDialogLayout(type);
|
|
_dialogLayouts.Add(layout);
|
|
return layout;
|
|
});
|
|
Controller = Assert.IsType<CharacterCreationUiController>(
|
|
CharacterCreationUiController.CreateDetached(
|
|
Host,
|
|
Screen,
|
|
ResolveSkillRowTemplate,
|
|
Dialogs,
|
|
Runtime.Bindings,
|
|
new CharacterCreationUiController.DialogStrings(
|
|
"Are you sure you want to leave?",
|
|
"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<UiButton>(Screen.FindElement(id));
|
|
|
|
public UiButton TabButton(uint id) => Button(id);
|
|
|
|
public UiElement Page(uint id) =>
|
|
Assert.IsAssignableFrom<UiElement>(Screen.FindElement(id));
|
|
|
|
public UiTemplateListBox SkillsList() =>
|
|
Assert.IsType<UiTemplateListBox>(Screen.FindElement(0x100003F7u));
|
|
|
|
public UiScrollbar ShadeScroll() =>
|
|
Assert.IsType<UiScrollbar>(Screen.FindElement(CharacterCreationAppearancePage.ShadeScrollId));
|
|
|
|
public UiField SummaryNameField() =>
|
|
Assert.IsType<UiField>(Screen.FindElement(CharacterCreationSummaryPage.NameTextId));
|
|
|
|
/// <summary>Confirms or cancels the MOST RECENTLY opened confirmation
|
|
/// dialog, using <see cref="RetailConfirmationDialogView"/>'s real
|
|
/// button ids off the layout the factory's <c>createLayout</c>
|
|
/// callback actually returned — same lookup shape
|
|
/// <c>CharacterManagementUiControllerTests</c> uses.</summary>
|
|
public void ConfirmActiveDialog(bool confirmed)
|
|
{
|
|
ImportedLayout dialog = _dialogLayouts[^1];
|
|
uint buttonId = confirmed
|
|
? RetailConfirmationDialogView.AcceptButtonId
|
|
: RetailConfirmationDialogView.RejectButtonId;
|
|
UiButton button = Assert.IsType<UiButton>(dialog.FindElement(buttonId));
|
|
button.OnClick!();
|
|
}
|
|
|
|
/// <summary>Same shape as <see cref="ConfirmActiveDialog"/>, for a
|
|
/// plain informational (<see cref="RetailMessageDialogView"/>) OK
|
|
/// dialog — <see cref="ShowNoNameWarningDialog"/>/the 0xF643
|
|
/// rejection dialogs use this shape, not confirm/cancel.</summary>
|
|
public void DismissActiveMessageDialog()
|
|
{
|
|
ImportedLayout dialog = _dialogLayouts[^1];
|
|
UiButton button = Assert.IsType<UiButton>(
|
|
dialog.FindElement(RetailMessageDialogView.OkButtonId));
|
|
button.OnClick!();
|
|
}
|
|
|
|
/// <summary>The MOST RECENTLY opened dialog's message text —
|
|
/// mirrors <c>CharacterManagementUiControllerTests.Message</c>'s
|
|
/// own lookup shape (element id <c>0x3E</c>, every
|
|
/// <see cref="RetailDialogFactoryTests.BuildDialogLayout"/> popup's
|
|
/// text child).</summary>
|
|
public string LastDialogMessage() => string.Join(
|
|
" ",
|
|
Assert.IsType<UiText>(_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); },
|
|
OpenOnStart: false);
|
|
}
|
|
|
|
public FakeView View { get; }
|
|
public CharacterCreationRuntimeBindings Bindings { get; }
|
|
public bool ProvideView { get; set; } = true;
|
|
public int RequestExitCalls { get; private set; }
|
|
public uint LastSelectedHeritage { get; private set; }
|
|
public uint LastSelectedGender { get; private set; }
|
|
public uint LastSelectedTemplate { get; private set; }
|
|
public ChargenAttributeId LastAttributeSet { get; private set; }
|
|
public int LastAttributeValue { get; private set; }
|
|
public int LastSelectedStartArea { get; private set; } = -1;
|
|
public ChargenAppearanceSlot? LastAppearanceSlot { get; private set; }
|
|
public uint LastAppearanceIndex { get; private set; }
|
|
public int AppearanceIndexCallCount { get; private set; }
|
|
public ChargenShadeSlot? LastShadeSlot { get; private set; }
|
|
public double LastShadeValue { get; private set; }
|
|
public 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; }
|
|
|
|
/// <summary>
|
|
/// 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 <c>Open()</c> 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 <c>Open()</c> without every one of them
|
|
/// having to opt out individually; only the tests THIS slice adds
|
|
/// that specifically exercise the roll set these explicitly.
|
|
/// </summary>
|
|
public uint RandomizedHeritageId { get; set; }
|
|
public uint RandomizedGenderKey { get; set; }
|
|
|
|
/// <summary>Lets a test simulate the Runtime-inactive/rejected case
|
|
/// (e.g. a session already gone) without needing a real
|
|
/// <c>RuntimeCharacterCreationState</c>.</summary>
|
|
public bool RandomizeCharacterAccepts { get; set; } = true;
|
|
|
|
/// <summary>Populated by tests exercising the <c>ID_Character_Err_*</c>
|
|
/// rejection-dialog path — <c>ResolveText</c> above reads from it.</summary>
|
|
public Dictionary<string, string> 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);
|
|
}
|
|
|
|
/// <summary>Mirrors <c>RuntimeCharacterCreationState.TryBeginFinish</c>'s
|
|
/// refusal-priority chain closely enough to drive
|
|
/// <c>CharacterCreationUiController.TryFinish</c>'s own dialog
|
|
/// dispatch under test — NoName, then HeritageOrGenderUnset, then
|
|
/// (unless confirmed) AttributeCreditsUnspent, else Accepted.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Deterministic fake for
|
|
/// <c>RuntimeCharacterCreationState.TryRandomizeCharacter</c> — real
|
|
/// randomness would make the gender-flip assertion in
|
|
/// <c>Open_RollsARandomCharacterThenFlipsAppearancePageGender</c>
|
|
/// flaky, so this always lands on <see cref="RandomizedHeritageId"/>/
|
|
/// <see cref="RandomizedGenderKey"/> instead.</summary>
|
|
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<ChargenTemplate>
|
|
{
|
|
new(
|
|
"Custom",
|
|
IconId: 0u,
|
|
TitleStringId: 0u,
|
|
Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10),
|
|
NormalSkills: [],
|
|
PrimarySkills: []),
|
|
new(
|
|
"Bow Hunter",
|
|
IconId: 0u,
|
|
TitleStringId: 0u,
|
|
Attributes: new ChargenAttributeValues(16, 10, 10, 10, 10, 10),
|
|
NormalSkills: [SkillTrainOnly],
|
|
PrimarySkills: []),
|
|
};
|
|
|
|
var skillCosts = new Dictionary<uint, ChargenSkillCost>
|
|
{
|
|
[SkillTrainOnly] = new(SkillTrainOnly, NormalCost: 2, PrimaryCost: 6),
|
|
[SkillSpecializable] = new(SkillSpecializable, NormalCost: 2, PrimaryCost: 6),
|
|
};
|
|
|
|
var aluvian = new ChargenHeritageOptions(
|
|
AluvianId,
|
|
"Aluvian",
|
|
IconId: 0u,
|
|
SetupId: 0x2000054u,
|
|
EnvironmentSetupId: 0u,
|
|
AttributeCredits: 66u,
|
|
SkillCredits: 50u,
|
|
PrimaryStartAreaIndices: [0, 1],
|
|
SecondaryStartAreaIndices: [],
|
|
SkillCostsBySkillId: skillCosts,
|
|
Templates: templates,
|
|
GendersByKey: new Dictionary<int, ChargenGenderOptions> { [(int)GenderKey] = gender });
|
|
|
|
var olthoi = new ChargenHeritageOptions(
|
|
OlthoiId,
|
|
"Olthoi",
|
|
IconId: 0u,
|
|
SetupId: 0x2000054u,
|
|
EnvironmentSetupId: 0u,
|
|
AttributeCredits: 60u,
|
|
SkillCredits: 0u,
|
|
PrimaryStartAreaIndices: [0],
|
|
SecondaryStartAreaIndices: [],
|
|
SkillCostsBySkillId: new Dictionary<uint, ChargenSkillCost>(),
|
|
Templates:
|
|
[
|
|
new ChargenTemplate(
|
|
"Custom",
|
|
IconId: 0u,
|
|
TitleStringId: 0u,
|
|
Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10),
|
|
NormalSkills: [],
|
|
PrimarySkills: []),
|
|
],
|
|
GendersByKey: new Dictionary<int, ChargenGenderOptions> { [(int)GenderKey] = gender });
|
|
|
|
var starterAreas = new List<ChargenStarterArea>
|
|
{
|
|
new(0, "Holtburg", [new ChargenPosition(1u, Vector3.Zero, Quaternion.Identity)]),
|
|
new(1, "Shoushi", [new ChargenPosition(2u, Vector3.Zero, Quaternion.Identity)]),
|
|
new(2, "Yaraq", [new ChargenPosition(3u, Vector3.Zero, Quaternion.Identity)]),
|
|
new(3, "Sanamar", [new ChargenPosition(4u, Vector3.Zero, Quaternion.Identity)]),
|
|
};
|
|
|
|
return new ChargenOptions(
|
|
starterAreas,
|
|
new Dictionary<uint, ChargenHeritageOptions>
|
|
{
|
|
[AluvianId] = aluvian,
|
|
[OlthoiId] = olthoi,
|
|
},
|
|
new Dictionary<uint, ChargenSkillCost>());
|
|
}
|
|
}
|
|
|
|
private sealed class FakeView(ChargenOptions options) : IRuntimeCharacterCreationView
|
|
{
|
|
private readonly Dictionary<uint, ChargenSkillAdvancementClass> _skillLevels = [];
|
|
|
|
public RuntimeCharacterCreationSnapshot Snapshot { get; set; } =
|
|
new(
|
|
new RuntimeGenerationToken(3u),
|
|
IsActive: true,
|
|
Revision: 1,
|
|
HeritageId: 0u,
|
|
GenderKey: 0u,
|
|
Appearance: RuntimeCharacterCreationAppearance.Default,
|
|
Template: RuntimeCharacterCreationSnapshot.TemplateUnset,
|
|
Attributes: default,
|
|
AttributeLockMask: 0u,
|
|
TotalAttributeCredits: 66u,
|
|
RemainingAttributeCredits: 66,
|
|
TotalSkillCredits: 50u,
|
|
RemainingSkillCredits: 50,
|
|
Name: string.Empty,
|
|
StartArea: -1,
|
|
Slot: 0u,
|
|
VerificationPending: false,
|
|
LastLocalRefusal: default,
|
|
LastRejection: null,
|
|
LastCreated: null);
|
|
|
|
public ChargenOptions Options { get; } = options;
|
|
|
|
public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) =>
|
|
_skillLevels.TryGetValue(skillId, out ChargenSkillAdvancementClass level)
|
|
? level
|
|
: ChargenSkillAdvancementClass.Inactive;
|
|
|
|
public void SetSkillLevel(uint skillId, ChargenSkillAdvancementClass level) =>
|
|
_skillLevels[skillId] = level;
|
|
|
|
public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) =>
|
|
NullSubscription.Instance;
|
|
|
|
private sealed class NullSubscription : IDisposable
|
|
{
|
|
public static readonly NullSubscription Instance = new();
|
|
public void Dispose() { }
|
|
}
|
|
}
|
|
|
|
private static ImportedLayout BuildScreen()
|
|
{
|
|
var root = new ElementInfo
|
|
{
|
|
Id = CharacterCreationUiController.RootElementId,
|
|
Type = 3u,
|
|
Width = 800f,
|
|
Height = 600f,
|
|
};
|
|
|
|
root.Children.Add(ContainerInfo(CharacterCreationUiController.ProgressBarElementId));
|
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.BackElementId));
|
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.NextElementId));
|
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.FinishElementId));
|
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.HelpElementId));
|
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.ExitElementId));
|
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.RandomElementId));
|
|
root.Children.Add(ContainerInfo(CharacterCreationUiController.MasterPageElementId));
|
|
|
|
root.Children.Add(BuildHeritagePage());
|
|
root.Children.Add(BuildProfessionPage());
|
|
root.Children.Add(BuildSkillsPage());
|
|
root.Children.Add(BuildAppearancePage());
|
|
root.Children.Add(BuildTownPage());
|
|
root.Children.Add(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,
|
|
};
|
|
list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100003FEu));
|
|
page.Children.Add(list);
|
|
page.Children.Add(ButtonInfo(0x100003F9u)); // credits badge
|
|
page.Children.Add(TextInfo(0x100003FBu));
|
|
page.Children.Add(TextInfo(0x100003FCu));
|
|
return page;
|
|
}
|
|
|
|
private static ElementInfo BuildTownPage()
|
|
{
|
|
var page = new ElementInfo
|
|
{
|
|
Id = CharacterCreationUiController.TownPageElementId,
|
|
Type = 3u,
|
|
Width = 800f,
|
|
Height = 500f,
|
|
};
|
|
page.Children.Add(ButtonInfo(0x1000040Bu)); // Sanamar
|
|
page.Children.Add(ButtonInfo(0x1000040Du)); // Holtburg
|
|
page.Children.Add(ButtonInfo(0x1000040Eu)); // Yaraq
|
|
page.Children.Add(ButtonInfo(0x1000040Fu)); // Shoushi
|
|
page.Children.Add(TextInfo(0x10000409u));
|
|
|
|
// F4: the page ROOT's own retail state literal map
|
|
// (gmCGTownPage::SetTown @ 0x0047c360), a separate state machine
|
|
// from the master page's per-page-index cycling.
|
|
page.States[0x10000034u] = new UiStateInfo { Id = 0x10000034u, Name = "Holtburg" };
|
|
page.States[0x10000035u] = new UiStateInfo { Id = 0x10000035u, Name = "Sanamar" };
|
|
page.States[0x10000036u] = new UiStateInfo { Id = 0x10000036u, Name = "Yaraq" };
|
|
page.States[0x10000037u] = new UiStateInfo { Id = 0x10000037u, Name = "Shoushi" };
|
|
return page;
|
|
}
|
|
|
|
/// <summary>Campaign CC slice CC6b-MOUNT: the Appearance page fixture.
|
|
/// Spin geometry (each 200px wide, arrow children at the live-DAT-
|
|
/// measured x=80/127 — see <see cref="CharacterCreationAppearancePage"/>'s
|
|
/// own doc comment) mirrors the real installed layout exactly so the
|
|
/// same OnClickAt zone math this page uses in production is what these
|
|
/// tests exercise.</summary>
|
|
private static ElementInfo BuildAppearancePage()
|
|
{
|
|
var page = new ElementInfo
|
|
{
|
|
Id = CharacterCreationUiController.AppearancePageElementId,
|
|
Type = 3u,
|
|
Width = 800f,
|
|
Height = 500f,
|
|
};
|
|
|
|
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.FemaleButtonId));
|
|
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.MaleButtonId));
|
|
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.FaceButtonId));
|
|
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ClothesButtonId));
|
|
page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.FaceChoicesId));
|
|
page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.ClothesChoicesId));
|
|
|
|
foreach (uint spinId in new[]
|
|
{
|
|
CharacterCreationAppearancePage.HairSpinId,
|
|
CharacterCreationAppearancePage.EyesSpinId,
|
|
CharacterCreationAppearancePage.NoseSpinId,
|
|
CharacterCreationAppearancePage.MouthSpinId,
|
|
CharacterCreationAppearancePage.SkinSpinId,
|
|
CharacterCreationAppearancePage.HeadgearSpinId,
|
|
CharacterCreationAppearancePage.ShirtSpinId,
|
|
CharacterCreationAppearancePage.TrousersSpinId,
|
|
CharacterCreationAppearancePage.FootwearSpinId,
|
|
})
|
|
{
|
|
page.Children.Add(SpinInfo(spinId));
|
|
}
|
|
|
|
foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds)
|
|
page.Children.Add(ButtonInfo(swatchId));
|
|
|
|
page.Children.Add(ScrollbarInfo(CharacterCreationAppearancePage.ShadeScrollId));
|
|
page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.GradCircleId));
|
|
|
|
var viewport = new ElementInfo
|
|
{
|
|
Id = CharacterCreationAppearancePage.ViewportId,
|
|
Type = 0xDu,
|
|
Width = 300f,
|
|
Height = 300f,
|
|
};
|
|
page.Children.Add(viewport);
|
|
|
|
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateClockwiseId));
|
|
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateCounterClockwiseId));
|
|
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ZoomInId));
|
|
page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ZoomOutId));
|
|
|
|
return page;
|
|
}
|
|
|
|
private static ElementInfo SpinInfo(uint id)
|
|
{
|
|
var spin = new ElementInfo
|
|
{
|
|
Id = id,
|
|
Type = 1u,
|
|
Width = 200f,
|
|
Height = 24f,
|
|
};
|
|
spin.Children.Add(new ElementInfo { Id = 0x1000030Au, Type = 1u, X = 80f, Width = 47f, Height = 24f });
|
|
spin.Children.Add(new ElementInfo { Id = 0x1000030Bu, Type = 1u, X = 127f, Width = 47f, Height = 24f });
|
|
return spin;
|
|
}
|
|
|
|
private static UiElement BuildSkillRowTemplate(uint templateElementId) =>
|
|
LayoutImporter.Build(
|
|
new ElementInfo
|
|
{
|
|
Id = templateElementId,
|
|
Type = 1u,
|
|
Width = 280f,
|
|
Height = 16f,
|
|
},
|
|
_ => (0u, 0, 0),
|
|
null).Root;
|
|
|
|
// ── 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));
|
|
page.Children.Add(EditableFieldInfo(CharacterCreationSummaryPage.NameTextId));
|
|
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;
|
|
}
|
|
}
|