acdream/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs
Erik 0fed5fdd91 fix(chargen): Campaign CC gate round 1 closeout — Group 2: Skills page four-bucket model
Ports the last remaining half of retail's Skills page: the four-bucket
sorted skill list (Specialized/Trained/UseableUntrained/UnuseableUntrained,
UpdateSkillEntry's own iMinlevel <= 1 test), plus the info box's
description + formula completion.

- ChargenSkillDetail/ChargenSkillFormula (Core) thread SkillBase.MinLevel/
  Description/Formula from the global SkillTable, exposed via a new
  ChargenOptions.TryGetSkillDetail (nullable-with-default parameter, so
  every pre-existing ChargenOptions call site compiles unchanged).
  ChargenTableReader.Project populates it from the same SkillTable loop
  that already builds GlobalSkillCostsBySkillId.
- CharacterCreationSkillsPage.RebuildRows now groups every costable skill
  into SkillBucket, sorts each bucket alphabetically by name
  (InsertEntrySorted's wcscmp, ported as string.CompareOrdinal), and
  builds one Templates[0] header row per bucket ahead of that bucket's
  Templates[1] skill rows — DoSkillRecords' own unconditional
  4-header-then-populate order. A level change re-buckets the row
  (detected per-refresh against each row's own cached bucket, then a
  full rebuild with the current selection explicitly preserved).
- RefreshInfoBox now composes description (word-wrapped via
  DatRichText.Compose) + the level-gated bonus line (an exact, unwrapped
  literal — NOT routed through word-wrap, which would have collapsed its
  authored double-space formatting) + ComposeFormula's "Formula : ..."
  line (MakeSkillFormula ported with high confidence for the prefix/
  per-attribute-term/divisor/bonus-suffix shape; the two-attribute
  connector text is a disclosed approximation, register AP-231, since
  the decompiled function's own connector literals could not be
  recovered byte-exact by this session's static-only tooling).

Register: AP-213 RETIRED (160 active rows). Live-DAT gate: the installed
SkillTable's MinLevel distribution matches the investigation's own
recorded finding exactly (38 entries, 23 useable-untrained / 15
trained-required). 3 new fixture tests + 1 new live-DAT test; 3
pre-existing integration tests fixed (they captured row widget
references before a bucket-changing click, which now rebuilds and
discards those references — a real, correct consequence of the new
model, not a bug).

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

3126 lines
143 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;
/// <summary>Review fix round F2 (Batch F): a "free" skill (trained
/// cost 0 in Aluvian's own per-heritage cost list) — pins
/// <c>SetSkillText</c>'s <c>bUntrainable</c> re-derivation
/// (<c>trainedCost != 0</c>), which locks the down arrow at Trained for
/// exactly this shape.</summary>
private const uint SkillFreeTrained = 3u;
[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);
}
/// <summary>
/// GF-5 fix (2026-08-16): the row is now the REAL <c>Templates[1]</c>
/// (<c>0x100002FF</c>) subtree — a plain container root with the two
/// separate arrow buttons retail authors (<c>pSkillUpButton</c>
/// <c>0x10000304</c> / <c>pSkillDownButton</c> <c>0x10000305</c>), each
/// firing on a PLAIN click (<c>ListenToElementMessage @0x004814c0</c>),
/// not the old single-button click-vs-double-click substitution.
/// </summary>
[Fact]
public void SkillsRow_ArrowClick_TrainsThenSpecializes()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId)
.OnClick!();
(UiButton up, UiButton down) = environment.SkillRowArrows(SkillSpecializable);
up.OnClick!();
Assert.Equal(ChargenSkillAdvancementClass.Trained,
environment.Runtime.GetSkillLevel(SkillSpecializable));
up.OnClick!();
Assert.Equal(ChargenSkillAdvancementClass.Specialized,
environment.Runtime.GetSkillLevel(SkillSpecializable));
down.OnClick!();
Assert.Equal(ChargenSkillAdvancementClass.Trained,
environment.Runtime.GetSkillLevel(SkillSpecializable));
}
/// <summary>
/// GF-5: the listbox produces one row per costable skill through the
/// REAL template-resolver path (<c>Templates[1]</c>, not the bucket-
/// header <c>Templates[0]</c>), with name/level/cost values populated
/// from a known snapshot — the exact regression CC5's fixture tests
/// never had (they called <c>UiField.SetText</c>/<c>UiButton.OnClick</c>
/// directly, bypassing RebuildRows' own template resolution entirely).
/// </summary>
[Fact]
public void SkillsPage_Rows_RenderNameAndLevelCostValues_ThroughTheRealTemplate()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId)
.OnClick!();
IReadOnlyList<UiElement> children = environment.SkillsList().ViewportForTest!.Children;
// Group 2 closeout: the listbox now ALSO carries the four bucket-
// header rows (DoSkillRecords' own unconditional 4-header build,
// present regardless of whether a bucket is empty) — filter to
// genuine skill rows (carry a 0x10000301 name descendant; headers
// carry only 0x100002f6) before counting.
List<UiElement> skillRows = [.. children.Where(
candidate => UiElement.FindDescendant(candidate, 0x10000301u) is not null)];
Assert.Equal(4, children.Count - skillRows.Count); // four bucket headers, always built.
// Aluvian's fixture costs SkillTrainOnly(1)/SkillSpecializable(2)/
// SkillFreeTrained(3, added for the F2 arrow-lock coverage below).
Assert.Equal(3, skillRows.Count);
UiElement row = Assert.Single(skillRows, candidate =>
UiElement.FindDescendant(candidate, 0x10000301u) is UiText name
&& JoinedText(name) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly));
// FakeRuntime.GetSkillScore's deterministic stand-in: skillId * 10.
UiText level = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000302u));
Assert.Equal((SkillTrainOnly * 10u).ToString(), JoinedText(level));
// Default (never-touched) level: up cost = trained cost (2), down
// cost = literal "0" (nothing below Untrained/Inactive, but
// SetSkillText @0x00480600's own Untrained branch writes a literal
// 0, unconditional — review fix round F1, Batch F).
UiText upCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000303u));
UiText downCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000306u));
Assert.Equal("2", JoinedText(upCost));
Assert.Equal("0", JoinedText(downCost));
// Advancing to Trained flips the cost pair: up = specCost-trainCost
// (6-2=4), down = trainCost (2). FakeRuntime.SetSkillLevel is a
// lightweight stub that doesn't bump Revision itself (unlike
// production's TrySetSkillLevel, RuntimeCharacterCreationState.cs
// ~1045), so force one the same way the file's other post-click
// refresh assertions do.
//
// Group 2 closeout: advancing also moves the row from the
// UseableUntrained bucket to the Trained bucket, which rebuilds
// every row — row/upCost/downCost captured above are now stale, so
// re-fetch by name (the SAME lookup every other test in this file
// uses post-rebuild) instead of reusing them.
environment.SkillRowArrows(SkillTrainOnly).Up.OnClick!();
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
environment.Runtime.View.Snapshot = snapshot with { Revision = snapshot.Revision + 1 };
environment.Controller.Tick();
row = Assert.Single(environment.SkillsList().ViewportForTest!.Children, candidate =>
UiElement.FindDescendant(candidate, 0x10000301u) is UiText name
&& JoinedText(name) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly));
upCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000303u));
downCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000306u));
Assert.Equal("4", JoinedText(upCost));
Assert.Equal("2", JoinedText(downCost));
}
// ── Campaign CC gate round 1 Batch F: R2-4 + review F1/F2 ───────────
/// <summary>R2-4a: a plain row click selects the skill — the row's own
/// NAME text swaps to <c>Vector4.One</c> (the best-derived "brighter
/// white") and the info title
/// (<c>ShowSkillsText @0x00481250</c>'s <c>" (%d)"</c> score suffix)
/// populates. Group 2 closeout: Untrained/Inactive carries no BONUS
/// line, but the info TEXT pane is no longer blank — the DESCRIPTION
/// and <c>MakeSkillFormula</c> lines both render regardless of level
/// (see <see cref="CharacterCreationSkillsPage.RefreshInfoBox"/>'s own
/// doc for the composition order).</summary>
[Fact]
public void SkillsPage_RowClick_SelectsRow_HighlightsNameAndPopulatesInfoBoxTitle()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
(UiDatElement row, UiText nameText) = environment.SkillRow(SkillTrainOnly);
Vector4 unselectedColor = nameText.DefaultColor;
// Nothing selected yet.
Assert.Equal(string.Empty, JoinedText(environment.SkillInfoTitle()));
row.OnClick!();
Assert.Equal(Vector4.One, nameText.DefaultColor);
Assert.NotEqual(unselectedColor, nameText.DefaultColor);
// FakeRuntime.GetSkillScore's deterministic stand-in: skillId * 10.
string expectedTitle =
$"{ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly)} ({SkillTrainOnly * 10u})";
Assert.Equal(expectedTitle, JoinedText(environment.SkillInfoTitle()));
// SkillTrainOnly's fixture detail: description "A test skill
// description.", formula (2 x Strength) / 4 +2, no bonus line
// (Untrained). JoinedText's own single-space join collapses the
// description's own word-wrapped line break.
Assert.Equal(
"A test skill description. Formula : (2 x Strength) / 4 +2",
JoinedText(environment.SkillInfoText()));
}
/// <summary>R2-4a: retail re-selects the row after an arrow click too
/// (<c>ListenToElementMessage @0x004814c0</c>'s own
/// <c>SetSelectedItem(...,1)</c> call following
/// IncreaseSkillLevel/DecreaseSkillLevel) — the info TEXT pane tracks
/// the level-gated bonus line as the skill advances (the TWO-space
/// literal <c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c>,
/// matching the compiled string verbatim, unwrapped end to end — see
/// <see cref="CharacterCreationSkillsPage.RefreshInfoBox"/>'s own doc for
/// why the bonus line is never routed through the word-wrap the
/// description segment gets). Group 2 closeout: the description and
/// formula lines now bracket the bonus line on every assertion below.</summary>
[Fact]
public void SkillsPage_ArrowClick_AlsoSelectsRow_InfoBoxShowsLevelBonusLine()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
(UiButton up, _) = environment.SkillRowArrows(SkillTrainOnly);
up.OnClick!(); // Untrained/Inactive -> Trained.
BumpRevisionAndTick(environment);
Assert.Equal(
"A test skill description. Training Bonus +5 Formula : (2 x Strength) / 4 +2",
JoinedText(environment.SkillInfoText()));
// Group 2 closeout: the Untrained -> Trained move above re-buckets
// the row (rebuilding every row and nulling the OLD button's
// OnClick as teardown) — re-fetch by name before the second click
// instead of reusing the pre-rebuild `up` reference.
(up, _) = environment.SkillRowArrows(SkillTrainOnly);
up.OnClick!(); // Trained -> Specialized.
BumpRevisionAndTick(environment);
Assert.Equal(
"A test skill description. Specialization Bonus +10 Formula : (2 x Strength) / 4 +2",
JoinedText(environment.SkillInfoText()));
}
/// <summary>R2-4a: selecting a SECOND row restores the FIRST row's own
/// authored (unselected) color instead of leaving it stuck
/// highlighted.</summary>
[Fact]
public void SkillsPage_RowClick_DeselectsPreviousRow_RestoresItsOwnColor()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
(UiDatElement firstRow, UiText firstName) = environment.SkillRow(SkillTrainOnly);
Vector4 firstUnselected = firstName.DefaultColor;
(UiDatElement secondRow, UiText secondName) = environment.SkillRow(SkillSpecializable);
firstRow.OnClick!();
Assert.Equal(Vector4.One, firstName.DefaultColor);
secondRow.OnClick!();
Assert.Equal(Vector4.One, secondName.DefaultColor);
Assert.Equal(firstUnselected, firstName.DefaultColor);
}
/// <summary>Review fix round F1: <c>SetSkillText</c>'s Specialized
/// branch (<c>@0x00480679</c>) writes a literal <c>"0"</c> up-cost,
/// unconditional (nothing above Specialized needs the 999-blank gate),
/// and an UNCONDITIONAL down-cost — no gate even though this fixture's
/// value (4) happens to be well under 999.</summary>
[Fact]
public void SkillsPage_SpecializedCostText_UpCostIsLiteralZero_DownCostUnconditional()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
environment.Runtime.View.SetSkillLevel(SkillSpecializable, ChargenSkillAdvancementClass.Specialized);
BumpRevisionAndTick(environment);
(UiElement row, _) = environment.SkillRow(SkillSpecializable);
UiText upCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000303u));
UiText downCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000306u));
Assert.Equal("0", JoinedText(upCost));
Assert.Equal("4", JoinedText(downCost)); // specCost(6) - trainCost(2).
}
/// <summary>Review fix round F2: the Up/Down arrow Ghosted
/// (<c>0x1000001a</c>)/Enabled (<c>0x1000001b</c>) state pair —
/// Untrained's Down is ALWAYS ghosted (nothing below it); Up is gated
/// on <c>remainingSkillCredits</c>; a "free" skill (trained cost 0)
/// locks its OWN down arrow at Trained
/// (<c>bUntrainable</c> re-derived as <c>trainedCost != 0</c>) and
/// unlocks it again once Specialized (specialized cost is non-zero),
/// where its own Up arrow is then ALWAYS ghosted (nothing above
/// Specialized).</summary>
[Fact]
public void SkillsPage_ArrowStates_GatedOnCreditsAndFreeSkillLocksDownArrow()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
// Untrained/Inactive: Down ALWAYS ghosted; Up enabled (credits 50
// >= trainedCost 2).
(UiButton up, UiButton down) = environment.SkillRowArrows(SkillTrainOnly);
Assert.Equal(0x1000001Bu, up.ActiveRetailStateId);
Assert.Equal(0x1000001Au, down.ActiveRetailStateId);
// Group 2 closeout: advancing a skill can move its row into a NEW
// bucket (UpdateSkillEntry's own re-bucket), which rebuilds every
// row — up/down/freeUp/freeDown are re-fetched by name after each
// level-changing click instead of reusing the pre-click widget
// references, which would otherwise be silently stale (never
// refreshed again once their row is discarded).
up.OnClick!(); // -> Trained. trainedCost(2) != 0 -> Down enabled.
BumpRevisionAndTick(environment);
(up, down) = environment.SkillRowArrows(SkillTrainOnly);
Assert.Equal(0x1000001Bu, down.ActiveRetailStateId);
(UiButton freeUp, UiButton freeDown) = environment.SkillRowArrows(SkillFreeTrained);
freeUp.OnClick!(); // -> Trained. trainedCost(0) == 0 -> Down locked.
BumpRevisionAndTick(environment);
(freeUp, freeDown) = environment.SkillRowArrows(SkillFreeTrained);
Assert.Equal(0x1000001Au, freeDown.ActiveRetailStateId);
freeUp.OnClick!(); // -> Specialized. specCost(6) != 0 -> Down unlocks;
BumpRevisionAndTick(environment); // Up is now ALWAYS ghosted.
(freeUp, freeDown) = environment.SkillRowArrows(SkillFreeTrained);
Assert.Equal(0x1000001Au, freeUp.ActiveRetailStateId);
Assert.Equal(0x1000001Bu, freeDown.ActiveRetailStateId);
}
/// <summary>R2-4c: the listbox's own authored scrollbar link
/// (<see cref="UiTemplateListBox.ScrollbarElementId"/>) is wired to the
/// SAME listbox's <see cref="UiTemplateListBox.Scroll"/> model — the
/// ordinary page-level <c>UiScrollbar.Model</c> linkage, no widget
/// change.</summary>
[Fact]
public void SkillsPage_ListboxScrollbar_IsLinkedToTheListsOwnScroll()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
UiScrollbar scrollbar = environment.SkillsScrollbar();
Assert.Same(environment.SkillsList().Scroll, scrollbar.Model);
}
// ── Campaign CC gate round 1 closeout: Group 2 (four-bucket model) ──
/// <summary><c>DoSkillRecords</c>'s own unconditional 4-header build —
/// every bucket header is present, in Specialized/Trained/
/// UseableUntrained/UnuseableUntrained order, even though this
/// fixture's three skills leave the Specialized bucket empty.</summary>
[Fact]
public void SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_CharGen_Specialized"] = "Specialized";
environment.Runtime.ResolvedStrings["ID_CharGen_Trained"] = "Trained";
environment.Runtime.ResolvedStrings["ID_CharGen_UseableUntrained"] = "Useable Untrained";
environment.Runtime.ResolvedStrings["ID_CharGen_UnuseableUntrained"] = "Unuseable Untrained";
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
List<string> headerCaptions = [.. environment.SkillsList().ViewportForTest!.Children
.Where(candidate => UiElement.FindDescendant(candidate, 0x10000301u) is null)
.Select(candidate => Assert.IsType<UiButton>(
UiElement.FindDescendant(candidate, 0x100002F6u)).Label!)];
Assert.Equal(
["Specialized", "Trained", "Useable Untrained", "Unuseable Untrained"],
headerCaptions);
}
/// <summary><c>UpdateSkillEntry</c>'s own <c>iMinlevel &lt;= 1</c> split:
/// while Untrained, SkillTrainOnly (fixture MinLevel 1) is useable and
/// SkillSpecializable (fixture MinLevel 2) is not — they land in
/// DIFFERENT buckets even though both start Untrained (the default,
/// unset, <c>FakeView.GetSkillLevel</c> state).</summary>
[Fact]
public void SkillsPage_UntrainedSkill_BucketsByMinLevel()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_CharGen_UseableUntrained"] = "Useable Untrained";
environment.Runtime.ResolvedStrings["ID_CharGen_UnuseableUntrained"] = "Unuseable Untrained";
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
List<UiElement> children = [.. environment.SkillsList().ViewportForTest!.Children];
int useableHeaderIndex = children.FindIndex(c =>
UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Useable Untrained");
int unuseableHeaderIndex = children.FindIndex(c =>
UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Unuseable Untrained");
int trainOnlyRowIndex = children.FindIndex(c =>
UiElement.FindDescendant(c, 0x10000301u) is UiText n
&& JoinedText(n) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly));
int specializableRowIndex = children.FindIndex(c =>
UiElement.FindDescendant(c, 0x10000301u) is UiText n
&& JoinedText(n) == ItemAppraisalTextFormatter.SkillName((int)SkillSpecializable));
Assert.InRange(trainOnlyRowIndex, useableHeaderIndex + 1, unuseableHeaderIndex - 1);
Assert.True(specializableRowIndex > unuseableHeaderIndex);
}
/// <summary>Advancing a skill re-buckets its row — the row's position
/// moves from the UseableUntrained section to the Trained section,
/// matching <c>UpdateSkillEntry</c>'s own remove-and-reinsert (ported
/// here as a detected full rebuild, not an incremental single-row
/// move — see <see cref="CharacterCreationSkillsPage"/>'s own class doc
/// for why that substitution is faithful to the OBSERVABLE result).</summary>
[Fact]
public void SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_CharGen_Trained"] = "Trained";
environment.Runtime.ResolvedStrings["ID_CharGen_UseableUntrained"] = "Useable Untrained";
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
environment.SkillRowArrows(SkillTrainOnly).Up.OnClick!(); // Untrained -> Trained.
BumpRevisionAndTick(environment);
List<UiElement> children = [.. environment.SkillsList().ViewportForTest!.Children];
int trainedHeaderIndex = children.FindIndex(c =>
UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Trained");
int useableHeaderIndex = children.FindIndex(c =>
UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Useable Untrained");
int rowIndex = children.FindIndex(c =>
UiElement.FindDescendant(c, 0x10000301u) is UiText n
&& JoinedText(n) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly));
Assert.InRange(rowIndex, trainedHeaderIndex + 1, useableHeaderIndex - 1);
}
[Fact]
public void TownButton_SelectsTheLiteralStartAreaIndex()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.TabButton(CharacterCreationUiController.TownTabElementId)
.OnClick!();
// Holtburg (0x1000040d) -> startArea 0 per SetTown's literal map.
environment.Button(0x1000040Du).OnClick!();
Assert.Equal(0, environment.Runtime.LastSelectedStartArea);
// Yaraq (0x1000040e) -> startArea 2.
environment.Button(0x1000040Eu).OnClick!();
Assert.Equal(2, environment.Runtime.LastSelectedStartArea);
}
/// <summary>Review fix round F4 (2026-08-15): <c>gmCGTownPage::SetTown
/// @ 0x0047c360</c> also sets the TOWN PAGE'S OWN retail state via a
/// literal per-town map — Holtburg-&gt;0x10000034, Yaraq-&gt;0x10000036 —
/// SEPARATE from the master page's per-page-index cycling
/// (<c>0x10000025+page</c>, already covered by the page-switch tests
/// above).</summary>
[Fact]
public void TownButton_Refresh_SetsThePagesOwnRetailStateLiteral()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.TabButton(CharacterCreationUiController.TownTabElementId)
.OnClick!();
var pageRoot = Assert.IsType<UiDatElement>(
environment.Page(CharacterCreationUiController.TownPageElementId));
environment.Button(0x1000040Du).OnClick!(); // Holtburg -> startArea 0
BumpRevisionAndTick(environment);
Assert.Equal("Holtburg", pageRoot.ActiveState);
environment.Button(0x1000040Eu).OnClick!(); // Yaraq -> startArea 2
BumpRevisionAndTick(environment);
Assert.Equal("Yaraq", pageRoot.ActiveState);
}
/// <summary>Review fix round F2 (2026-08-15), the display direction:
/// <c>gmCGProfessionPage::UpdateAttributeValues @ 0x0048251d</c> sets
/// the slider's scalar position to <c>value * 0.00999999978f</c>
/// (value/100), not a [10,100]-to-[0,1] rescale.</summary>
[Fact]
public void ProfessionSlider_Refresh_DisplaysScalarAsValueOverOneHundred()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId)
.OnClick!();
UiElement strengthContainer = Assert.IsAssignableFrom<UiElement>(
environment.Screen.FindElement(0x100003E6u));
var slider = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(strengthContainer, 0x100002EEu));
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
environment.Runtime.View.Snapshot = snapshot with
{
Revision = snapshot.Revision + 1,
Attributes = snapshot.Attributes with { Strength = 55 },
};
environment.Controller.Tick();
Assert.Equal(0.55f, slider.ScalarPosition);
}
/// <summary>Review fix round F2 (2026-08-15), the drag-inverse
/// direction: <c>ListenToElementMessage @ 0x004829c0</c>'s scrollbar-
/// drag case truncates <c>scalar*100</c> and clamps LOW only to 10 —
/// NOT the [10,100]&lt;-&gt;[0,1] rescale the previous (wrong) formula
/// used, which only coincidentally agreed with the correct one at
/// scalar=1 (the pre-existing
/// <see cref="ProfessionSlider_ScalarChange_SetsTheAttribute"/> case).</summary>
[Theory]
[InlineData(0.5f, 50)]
[InlineData(0f, 10)]
public void ProfessionSlider_ScalarChange_TruncatesAndClampsLowOnly(
float scalar,
int expectedValue)
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId)
.OnClick!();
UiElement strengthContainer = Assert.IsAssignableFrom<UiElement>(
environment.Screen.FindElement(0x100003E6u));
var slider = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(strengthContainer, 0x100002EEu));
slider.ScalarChanged!(scalar);
Assert.Equal(ChargenAttributeId.Strength, environment.Runtime.LastAttributeSet);
Assert.Equal(expectedValue, environment.Runtime.LastAttributeValue);
}
/// <summary>Review fix round F3 (2026-08-15):
/// <c>gmCharGenMainUI::ListenToElementMessage @ 0x004e9450</c>'s
/// heritage-button bubble arm shows/hides the Profession/Skills/Town
/// tabs SYNCHRONOUSLY at click time — independent of
/// <see cref="OlthoiHeritage_HidesProfessionSkillsAndTownTabs"/>'s
/// page-switch-time recompute (no tab/Back/Next click happens in this
/// test at all). Lugian (<c>0x100005f1</c>) sits outside BOTH the SHOW
/// and HIDE case lists in the decompiled switch — a genuine retail
/// quirk, reproduced faithfully.</summary>
[Fact]
public void HeritageButtonClick_RestoresHiddenTabsAtClickTime_ExceptLugian()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Button(0x100005C7u).OnClick!(); // Olthoi -> HIDE
Assert.False(environment.TabButton(
CharacterCreationUiController.ProfessionTabElementId).Visible);
Assert.False(environment.TabButton(
CharacterCreationUiController.SkillsTabElementId).Visible);
Assert.False(environment.TabButton(
CharacterCreationUiController.TownTabElementId).Visible);
environment.Button(0x100005F1u).OnClick!(); // Lugian -> no-op quirk
Assert.False(environment.TabButton(
CharacterCreationUiController.ProfessionTabElementId).Visible);
Assert.False(environment.TabButton(
CharacterCreationUiController.SkillsTabElementId).Visible);
Assert.False(environment.TabButton(
CharacterCreationUiController.TownTabElementId).Visible);
environment.Button(0x100003BFu).OnClick!(); // Aluvian -> SHOW
Assert.True(environment.TabButton(
CharacterCreationUiController.ProfessionTabElementId).Visible);
Assert.True(environment.TabButton(
CharacterCreationUiController.SkillsTabElementId).Visible);
Assert.True(environment.TabButton(
CharacterCreationUiController.TownTabElementId).Visible);
}
/// <summary>Review fix round F1 (2026-08-15): <c>Open()</c> sets
/// <c>UiRoot.FixedCanvasSize</c> once on the activation edge (matching
/// <c>CharacterManagementUiController</c>'s real, non-per-tick shape);
/// <c>Close()</c>/<c>Deactivate()</c>/<c>Dispose()</c> null it back out
/// symmetrically. Before this fix nothing ever nulled it, so an
/// 800x600-scaled canvas silently covered the in-world UI for the rest
/// of the session once this screen had ever been opened.</summary>
[Fact]
public void Open_SetsFixedCanvas_ExitConfirmClosesAndNullsIt()
{
using var environment = new EnvironmentHarness();
Assert.Null(environment.Host.FixedCanvasSize);
environment.Controller.Open();
Assert.Equal(new Vector2(800f, 600f), environment.Host.FixedCanvasSize);
environment.Button(CharacterCreationUiController.ExitElementId).OnClick!();
environment.ConfirmActiveDialog(confirmed: true);
Assert.Null(environment.Host.FixedCanvasSize);
}
[Fact]
public void Deactivate_NullsFixedCanvas_AndClosesTheScreen()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
Assert.NotNull(environment.Host.FixedCanvasSize);
// Runtime reporting the view inactive/gone (e.g. entering the
// world) must Deactivate -- previously nothing drove this because
// RuntimeCharacterCreationState had no CompleteEnter() analogue;
// this test exercises the CONTROLLER side of that fix directly by
// simulating the view disappearing.
environment.Runtime.ProvideView = false;
environment.Controller.Tick();
Assert.False(environment.Controller.Root.Visible);
Assert.Null(environment.Host.FixedCanvasSize);
}
[Fact]
public void Dispose_NullsFixedCanvas()
{
var environment = new EnvironmentHarness();
environment.Controller.Open();
Assert.NotNull(environment.Host.FixedCanvasSize);
environment.Dispose();
Assert.Null(environment.Host.FixedCanvasSize);
}
// ── Campaign CC slice CC6b-MOUNT: Appearance page ───────────────────
[Fact]
public void AppearanceGenderButton_SelectsGender()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
environment.Button(CharacterCreationAppearancePage.MaleButtonId).OnClick!();
Assert.Equal(1u, environment.Runtime.LastSelectedGender);
environment.Button(CharacterCreationAppearancePage.FemaleButtonId).OnClick!();
Assert.Equal(2u, environment.Runtime.LastSelectedGender);
}
/// <summary>Spin arrow geometry (live-DAT-measured, see
/// <see cref="CharacterCreationAppearancePage"/>'s own class doc):
/// x=[80,127) is the decrement child, x=[127,174) is the increment
/// child.</summary>
[Fact]
public void AppearanceSpin_IncrementZoneClick_CyclesStyleForwardFromUnset()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(150, 10);
Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot);
Assert.Equal(0u, environment.Runtime.LastAppearanceIndex);
}
/// <summary>
/// Fix round F1: this test previously asserted index 0 here, pinning a
/// doc claim ("no decomp-observable Unset-cycling case") the decomp
/// refutes. Hair's own decrement case
/// (<c>gmCGAppearancePage::ListenToElementMessage @0x0047f465-0x0047f086</c>,
/// the same shared tail the headgear ring reuses at
/// <c>label_47f065</c>/<c>label_47f6d9</c>) computes
/// <c>new = cur - 1</c> on the raw signed int32 (Unset = -1), giving
/// <c>new = -2</c>; since <c>-2 &lt; 0</c> it wraps to <c>count - 1</c>,
/// NOT 0 — see <see cref="CharacterCreationAppearancePage.CycleIndex"/>'s
/// own corrected doc. The fixture's Hair style count is 3
/// (<see cref="EnvironmentHarness.BuildOptions"/>), so the expected
/// landing index is 2.
/// </summary>
[Fact]
public void AppearanceSpin_DecrementZoneClick_FromUnset_WrapsToLastStyle()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(100, 10);
Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot);
Assert.Equal(2u, environment.Runtime.LastAppearanceIndex);
}
/// <summary>
/// Fix round F10: pins the SELECT zone — x=[174,200), right of the
/// increment arrow's own x=[127,174) (live-DAT-measured, both arrows
/// 47px wide — <c>CharacterCreationLiveDatTests</c>) — as a body click
/// that selects the part WITHOUT invoking another style cycle, once the
/// part already holds a real (non-Unset) index. Distinguishes the third
/// <c>OnClickAt</c> zone from the two arrow-zone tests above, which no
/// prior test isolated.
/// </summary>
[Fact]
public void AppearanceSpin_SelectZoneClick_SelectsPartWithoutChangingIndex()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
// Establish a real Hair index first (increment zone, x=150).
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(150, 10);
Assert.Equal(0u, environment.Runtime.LastAppearanceIndex);
int callsAfterCycle = environment.Runtime.AppearanceIndexCallCount;
// x=180 is inside [174,200) — past the increment arrow's own zone,
// still inside the 200px-wide spin — the spin's own BODY, not
// either arrow.
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(180, 10);
// Already in [0,count) — NormalizeChoiceOnSelect (F1) is a no-op,
// so the select zone must not re-invoke SetAppearanceIndex.
Assert.Equal(callsAfterCycle, environment.Runtime.AppearanceIndexCallCount);
Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot);
Assert.Equal(0u, environment.Runtime.LastAppearanceIndex);
}
/// <summary>
/// Fix round F1: pins the body-click normalize-and-write-back
/// (<see cref="CharacterCreationAppearancePage"/>'s own
/// <c>NormalizeChoiceOnSelect</c> doc) for the case that's actually
/// reachable in acdream — a part still Unset (AP-214 honest-blank open)
/// gets clicked in its SELECT zone (not an arrow) — wraps to
/// <c>count-1</c> and writes it back, exactly like a decrement click
/// would, even though no arrow was pressed.
/// </summary>
[Fact]
public void AppearanceSpin_SelectZoneClick_FromUnset_NormalizesToLastStyle()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
// Hair starts Unset (AP-214 honest-blank). x=180 is the select
// zone, not either arrow.
environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(180, 10);
Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot);
Assert.Equal(2u, environment.Runtime.LastAppearanceIndex); // count-1, fixture has 3 hair styles.
}
/// <summary>CharGenState::SetHeadgearStyle's decomp-derived
/// (count+1)-position ring: incrementing from Unset lands on style 0;
/// decrementing FROM style 0 lands back on Unset. Headgear is the ONLY
/// spin with this ring — see <see cref="CharacterCreationAppearancePage.CycleIndex"/>'s
/// own citation.</summary>
[Fact]
public void AppearanceHeadgearSpin_RingIncludesTheUnsetPosition()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
environment.Button(CharacterCreationAppearancePage.ClothesButtonId).OnClick!();
environment.Button(CharacterCreationAppearancePage.HeadgearSpinId).OnClickAt!(150, 10); // increment
Assert.Equal(ChargenAppearanceSlot.HeadgearStyle, environment.Runtime.LastAppearanceSlot);
Assert.Equal(0u, environment.Runtime.LastAppearanceIndex);
environment.Button(CharacterCreationAppearancePage.HeadgearSpinId).OnClickAt!(100, 10); // decrement
Assert.Equal(RuntimeCharacterCreationAppearance.Unset, environment.Runtime.LastAppearanceIndex);
}
/// <summary>Pure wrap-semantics unit tests for
/// <see cref="CharacterCreationAppearancePage.CycleIndex"/> — the
/// decomp-derived arithmetic every spin's OnClickAt zone drives.
/// </summary>
[Theory]
[InlineData(0u, +1, 3, false, 1u)]
[InlineData(2u, +1, 3, false, 0u)] // plain wrap forward past the end.
[InlineData(1u, -1, 3, false, 0u)]
[InlineData(0u, -1, 3, false, 2u)] // plain wrap backward past the start.
[InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, false, 0u)]
// Fix round F1: decrement-from-Unset wraps to count-1 (2), not 0 — the
// decomp's shared decrement tail (label_47f065/label_47f6d9) computes
// new = cur - 1 = -2 on the raw signed int32, which is < 0, so it wraps
// to count-1 exactly like headgear's own ring does for its non-Unset
// range. See CycleIndex's own corrected doc.
[InlineData(RuntimeCharacterCreationAppearance.Unset, -1, 3, false, 2u)]
[InlineData(0u, -1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: 0 -> Unset.
[InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, true, 0u)] // headgear ring: Unset -> 0.
[InlineData(2u, +1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: last -> Unset.
[InlineData(RuntimeCharacterCreationAppearance.Unset, -1, 3, true, 2u)] // headgear ring: Unset -> last.
public void CycleIndex_MatchesRetailsDecompDerivedWrap(
uint current, int delta, int count, bool allowUnset, uint expected)
{
Assert.Equal(
expected,
CharacterCreationAppearancePage.CycleIndex(current, delta, count, allowUnset));
}
/// <summary>Skin has no style index — retail disables its arrow
/// children (SetAttribute_Bool(...,0xd,1)). Every click on the skin
/// spin only selects it as the current part.</summary>
[Fact]
public void AppearanceSkinSpin_Click_NeverCallsSetAppearanceIndex()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
environment.Button(CharacterCreationAppearancePage.SkinSpinId).OnClickAt!(100, 10);
environment.Button(CharacterCreationAppearancePage.SkinSpinId).OnClickAt!(150, 10);
Assert.Equal(0, environment.Runtime.AppearanceIndexCallCount);
}
/// <summary>gmCGAppearancePage::Update's heritage 6/0xc/0xd gate: the
/// Clothes sub-tab and the Nose/Mouth spins all hide.</summary>
[Fact]
public void OlthoiHeritage_HidesClothesButtonAndNoseMouthSpins()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(OlthoiId);
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
Assert.False(environment.Button(CharacterCreationAppearancePage.ClothesButtonId).Visible);
Assert.False(environment.Button(CharacterCreationAppearancePage.NoseSpinId).Visible);
Assert.False(environment.Button(CharacterCreationAppearancePage.MouthSpinId).Visible);
}
/// <summary>Fixture's Hair color list has 3 entries (indices 0-2) — a
/// swatch beyond that never reaches SetAppearanceIndex, matching
/// retail's own <c>iNumColors &gt; N</c> gate.</summary>
[Fact]
public void AppearanceSwatch_WithinColorCount_SetsColorForTheCurrentPart()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
// Part defaults to Hair on construction — no extra click needed.
environment.Button(CharacterCreationAppearancePage.SwatchIds[1]).OnClick!();
Assert.Equal(ChargenAppearanceSlot.HairColor, environment.Runtime.LastAppearanceSlot);
Assert.Equal(1u, environment.Runtime.LastAppearanceIndex);
}
[Fact]
public void AppearanceSwatch_BeyondColorCount_IsANoOp()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
environment.Button(CharacterCreationAppearancePage.SwatchIds[^1]).OnClick!(); // index 8, count 3.
Assert.Equal(0, environment.Runtime.AppearanceIndexCallCount);
}
/// <summary>
/// GF-9 (Campaign CC gate round 1 Batch B): retail's ACTUAL swatch click
/// feedback — exactly one companion overlay visible, tracking the
/// current part's own selected color index (<c>SetColor</c>'s
/// <c>m_tColorWheel[...][0x10][iCurColor*7]-&gt;SetVisible</c>). Drives
/// the snapshot directly (the fake binding only records what a click
/// SENDS, it doesn't feed it back) to exercise
/// <c>RefreshColorAndShadeControls</c>'s own overlay loop end to end.
/// </summary>
[Fact]
public void AppearanceSwatchOverlays_ExactlyOneVisible_TrackingTheCurrentPartsColorIndex()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
// Part defaults to Hair on construction — no extra click needed.
UiElement[] overlays = [.. CharacterCreationAppearancePage.SwatchOverlayIds
.Select(environment.Page)];
// No color selected yet (Unset) -> every overlay hidden.
Assert.All(overlays, overlay => Assert.False(overlay.Visible));
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
environment.Runtime.View.Snapshot = snapshot with
{
Revision = snapshot.Revision + 1,
Appearance = snapshot.Appearance with { HairColor = 1u },
};
environment.Controller.Tick();
for (int i = 0; i < overlays.Length; i++)
Assert.Equal(i == 1, overlays[i].Visible);
snapshot = environment.Runtime.View.Snapshot;
environment.Runtime.View.Snapshot = snapshot with
{
Revision = snapshot.Revision + 1,
Appearance = snapshot.Appearance with { HairColor = 2u },
};
environment.Controller.Tick();
for (int i = 0; i < overlays.Length; i++)
Assert.Equal(i == 2, overlays[i].Visible);
}
[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!();
}
/// <summary>
/// GF-10 (Campaign CC gate round 1 Batch B): ports
/// <c>gmCGAppearancePage::ZoomIn @0x0047CF00</c>
/// (<c>@0x0047d005/0x0047d00f</c>: ZoomInButton -&gt; Highlight(6),
/// ZoomOutButton -&gt; Normal(1)) and its <c>ZoomOut</c> mirror
/// (<c>@0x0047D050</c>, <c>@0x0047d140/0x0047d14a</c>). Both buttons
/// start at their DAT-authored "Normal" default — re-derived from
/// <c>InitializePage @0x0047fdd0-0048032e</c>: <c>m_bZoomedIn = 0</c> is
/// set at construction (<c>@0x004802c3</c>) but NO explicit initial
/// <c>SetState</c> call exists for either zoom button anywhere in
/// <c>InitializePage</c>, so this port does not force one either.
/// </summary>
[Fact]
public void AppearanceZoomButtons_ClickPath_TogglesMutualExclusiveHighlightPair()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
var preview = new FakeChargenPreviewControl();
environment.Controller.AppearancePreviewControl = preview;
UiButton zoomIn = environment.Button(CharacterCreationAppearancePage.ZoomInId);
UiButton zoomOut = environment.Button(CharacterCreationAppearancePage.ZoomOutId);
Assert.Equal("Normal", zoomIn.ActiveState);
Assert.Equal("Normal", zoomOut.ActiveState);
zoomIn.OnClick!();
Assert.Equal("Highlight", zoomIn.ActiveState);
Assert.Equal("Normal", zoomOut.ActiveState);
zoomOut.OnClick!();
Assert.Equal("Normal", zoomIn.ActiveState);
Assert.Equal("Highlight", zoomOut.ActiveState);
// Re-asserting the SAME direction is idempotent (retail's own early-
// return branch when already zoomed in/out — this port doesn't
// track m_bZoomedIn, but the RESULT is identical either way).
zoomOut.OnClick!();
Assert.Equal("Normal", zoomIn.ActiveState);
Assert.Equal("Highlight", zoomOut.ActiveState);
}
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());
}
/// <summary>
/// GF-15 (Campaign CC gate round 1, Batch A). The exact user sequence,
/// driven through the REAL <see cref="UiRoot.OnMouseDown"/>/
/// <see cref="UiRoot.OnChar"/> event pipeline — every OTHER Summary-page
/// test in this file calls <c>field.SetText</c>/<c>UiButton.OnClick!()</c>
/// directly, which bypasses <see cref="UiRoot"/>'s own pick/focus/Modal
/// dispatch entirely and is exactly why those tests kept passing while
/// the live screen was dead. Root cause (live-repro-confirmed):
/// <see cref="CharacterCreationUiController.Tick"/>'s own per-tick
/// <c>UiRoot.BringToFront(Root)</c> (needed so chargen stays above the
/// occluded character-management screen, AP-229) buried any dialog
/// opened while chargen is active on the VERY NEXT frame, because
/// <see cref="RetailDialogFactory.Tick"/> never re-asserted its own open
/// dialogs' z-order — fixed by having it do so, in open-order, every
/// tick.
/// </summary>
[Fact]
public void Finish_EmptyName_RealEventPath_DialogSurvivesTheNextFrameTick_AndFieldRefocusableAfterDismiss()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
// Heritage/gender must be selected so Finish's HeritageOrGenderUnset
// local refusal (which has no retail dialog) can't preempt the
// NoName refusal this test exercises.
SelectAluvianMale(environment);
GoToSummary(environment);
UiField nameField = environment.SummaryNameField();
UiButton finishButton = environment.Button(CharacterCreationUiController.FinishElementId);
// (1) A real mouse-down at the field's own screen rect sets
// KeyboardFocus to it.
Vector2 fieldPos = nameField.ScreenPosition;
environment.Host.OnMouseDown(UiMouseButton.Left, (int)fieldPos.X + 2, (int)fieldPos.Y + 2);
Assert.Same(nameField, environment.Host.KeyboardFocus);
// (2) A subsequent OnChar lands a character in the field through
// the real event pipeline (UiRoot.OnChar -> BubbleEvent ->
// UiField.OnEvent), not a direct SetText call -- then the user's
// own live-repro clear (repeated Backspace) empties it again, so
// the field is genuinely empty when Finish commits it below
// (clicking Finish blurs the field, which commits its text --
// CommitNameFromField -- exactly like a real click-away would).
environment.Host.OnChar('Z');
Assert.Equal("Z", nameField.Text);
nameField.Backspace();
Assert.Equal(string.Empty, nameField.Text);
// (3) A real click on Finish while the field is empty. UiButton's
// own click fires on the press-release pair (OnMouseUp with the
// same target still Captured from OnMouseDown), matching every
// other real click below.
Vector2 finishPos = finishButton.ScreenPosition;
environment.Host.OnMouseDown(UiMouseButton.Left, (int)finishPos.X + 2, (int)finishPos.Y + 2);
environment.Host.OnMouseUp(UiMouseButton.Left, (int)finishPos.X + 2, (int)finishPos.Y + 2);
Assert.Equal(1, environment.Runtime.FinishCallCount);
Assert.True(environment.Dialogs.IsOpen);
UiPanel dialogModal = Assert.IsAssignableFrom<UiPanel>(environment.Host.Modal);
// (4) Reproduce the exact bug window: one full frame's worth of
// ticks in production order (CharacterCreationController.Tick then
// DialogFactory.Tick, RetailUiRuntime.Tick(double)'s own sequence).
// Before the fix, step 4a alone buried the dialog; the dialog must
// still sit at or above the screen root's z-order once step 4b (the
// fix) runs, matching retail's always-on-top dialog behavior.
environment.Controller.Tick();
environment.Dialogs.Tick();
Assert.True(dialogModal.ZOrder >= environment.Controller.Root.ZOrder);
// (5) Dismiss through the real click path -- the OK button's own
// screen rect, not ConfirmActiveDialog's direct OnClick! shortcut.
UiButton okButton = Assert.IsType<UiButton>(
UiElement.FindDescendant(dialogModal, RetailMessageDialogView.OkButtonId));
Vector2 okPos = okButton.ScreenPosition;
environment.Host.OnMouseDown(UiMouseButton.Left, (int)okPos.X + 2, (int)okPos.Y + 2);
environment.Host.OnMouseUp(UiMouseButton.Left, (int)okPos.X + 2, (int)okPos.Y + 2);
Assert.False(environment.Dialogs.IsOpen);
Assert.Null(environment.Host.Modal);
// (6) The user's own final check: the field is still typable
// afterward, through the same real click+char path.
environment.Host.OnMouseDown(UiMouseButton.Left, (int)fieldPos.X + 2, (int)fieldPos.Y + 2);
Assert.Same(nameField, environment.Host.KeyboardFocus);
environment.Host.OnChar('Q');
Assert.Contains('Q', nameField.Text);
}
[Fact]
public void Finish_UnspentCredits_ShowsCreditWarning_ConfirmResendsWithConfirmedFlag()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with
{
Name = "Adventurer",
RemainingAttributeCredits = 6,
};
GoToSummary(environment);
environment.Button(CharacterCreationUiController.FinishElementId).OnClick!();
Assert.Equal(1, environment.Runtime.FinishCallCount);
Assert.False(environment.Runtime.LastConfirmedUnspentCredits);
Assert.True(environment.Dialogs.IsOpen);
Assert.Equal("You have unspent attribute credits.", environment.LastDialogMessage());
environment.ConfirmActiveDialog(confirmed: true);
Assert.Equal(2, environment.Runtime.FinishCallCount);
Assert.True(environment.Runtime.LastConfirmedUnspentCredits);
}
[Fact]
public void Finish_UnspentCredits_CancelDialog_DoesNotResend()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with
{
Name = "Adventurer",
RemainingAttributeCredits = 6,
};
GoToSummary(environment);
environment.Button(CharacterCreationUiController.FinishElementId).OnClick!();
environment.ConfirmActiveDialog(confirmed: false);
Assert.Equal(1, environment.Runtime.FinishCallCount);
}
[Fact]
public void Finish_Accepted_ShowsNoDialog()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with
{
Name = "Adventurer",
RemainingAttributeCredits = 0,
};
GoToSummary(environment);
environment.Button(CharacterCreationUiController.FinishElementId).OnClick!();
Assert.Equal(1, environment.Runtime.FinishCallCount);
Assert.False(environment.Dialogs.IsOpen);
}
[Fact]
public void Finish_OffSummaryPage_IsANoOp()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open(); // defaults to Heritage
environment.Button(CharacterCreationUiController.FinishElementId).OnClick!();
Assert.Equal(0, environment.Runtime.FinishCallCount);
}
// ── CC5: Random on Summary (MakeRandomizeWarningDialog @ 0x004e8a90) ─
[Fact]
public void RandomOnSummary_ShowsWarningDialog_ConfirmCallsRandomizeCharacter()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
GoToSummary(environment);
environment.Button(CharacterCreationUiController.RandomElementId).OnClick!();
Assert.True(environment.Dialogs.IsOpen);
Assert.Equal("This will randomize your character.", environment.LastDialogMessage());
int callsBeforeConfirm = environment.Runtime.RandomizeCharacterCalls;
environment.ConfirmActiveDialog(confirmed: true);
Assert.Equal(callsBeforeConfirm + 1, environment.Runtime.RandomizeCharacterCalls);
}
[Fact]
public void RandomOnSummary_CancelDialog_DoesNotRandomize()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
GoToSummary(environment);
int callsBeforeClick = environment.Runtime.RandomizeCharacterCalls;
environment.Button(CharacterCreationUiController.RandomElementId).OnClick!();
environment.ConfirmActiveDialog(confirmed: false);
Assert.Equal(callsBeforeClick, environment.Runtime.RandomizeCharacterCalls);
}
// ── CC5: Random on Appearance (DoRandom case 3) ──────────────────────
[Fact]
public void RandomOnAppearance_FaceSubTab_CallsRandomizeAppearance()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
environment.Button(CharacterCreationUiController.RandomElementId).OnClick!();
Assert.Equal(1, environment.Runtime.RandomizeAppearanceCalls);
Assert.Equal(0, environment.Runtime.RandomizeClothingCalls);
}
[Fact]
public void RandomOnAppearance_ClothesSubTab_CallsRandomizeClothing()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
environment.Button(CharacterCreationAppearancePage.ClothesButtonId).OnClick!();
environment.Button(CharacterCreationUiController.RandomElementId).OnClick!();
Assert.Equal(1, environment.Runtime.RandomizeClothingCalls);
Assert.Equal(0, environment.Runtime.RandomizeAppearanceCalls);
}
// ── CC5: Summary name field (ListenToElementMessage @ 0x0047bf40) ────
[Fact]
public void SummaryNameField_Submit_CommitsTheName()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
GoToSummary(environment);
UiField field = environment.SummaryNameField();
field.SetText("Adventurer");
field.Submit();
Assert.Equal("Adventurer", environment.Runtime.LastSetName);
}
[Fact]
public void SummaryNameField_TooLong_ShowsDialogAndRevertsToLastCommitted()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
GoToSummary(environment);
UiField field = environment.SummaryNameField();
field.SetText("Adventurer");
field.Submit();
Assert.Equal("Adventurer", environment.Runtime.LastSetName);
field.SetText(new string('a', 40));
field.Submit();
// The over-limit text never reached SetName, and the too-long
// dialog fired with the field reverted to the last commit.
Assert.Equal("Adventurer", environment.Runtime.LastSetName);
Assert.True(environment.Dialogs.IsOpen);
Assert.Equal("That name is too long.", environment.LastDialogMessage());
Assert.Equal("Adventurer", field.Text);
}
[Fact]
public void SummaryNameField_NameInputFilter_RejectsDigitsAndSymbols()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
GoToSummary(environment);
UiField field = environment.SummaryNameField();
Assert.NotNull(field.CharacterFilter);
Assert.True(field.CharacterFilter!('A'));
Assert.True(field.CharacterFilter!(' '));
Assert.True(field.CharacterFilter!('\''));
Assert.True(field.CharacterFilter!('-'));
Assert.False(field.CharacterFilter!('7'));
Assert.False(field.CharacterFilter!('$'));
}
/// <summary>
/// CC5 re-review residual round, R1 (2026-08-16): pins the F1 fix (the
/// deleted <c>_suppressNextFieldEvent</c> latch) against reintroduction.
/// The pre-fix bug needed BOTH halves to reproduce: (1) an EXTERNAL
/// change to <c>snapshot.Name</c> lands while the field is unfocused —
/// <c>CharacterCreationSummaryPage.Refresh</c>'s own field-sync block
/// (the F1 fix site) calls <c>field.SetText(...)</c> programmatically,
/// which armed the old latch — then (2) the PLAYER's own REAL commit
/// (<c>SetText</c> + <c>Submit</c>, the actual event path a keystroke +
/// Enter/blur drives — never a direct <c>CommitNameFromField</c> call)
/// arrives afterward. Pre-fix, that real commit hit the still-armed
/// latch and was silently dropped; <see cref="SummaryNameField_Submit_CommitsTheName"/>
/// alone never exercised this because it never drives step (1) first —
/// the review's own finding was that the claimed regression coverage
/// didn't actually touch the page.
/// </summary>
[Fact]
public void SummaryNameField_RealCommitAfterExternalRefreshWhileUnfocused_StillReachesSetName()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
GoToSummary(environment);
UiField field = environment.SummaryNameField();
// (1) External change while unfocused: something OTHER than this
// field bumps the Runtime revision with a changed Name (e.g. the
// player edited another page and came back) — Refresh's field-sync
// block programmatically overwrites the field via SetText, which
// never raises OnFocusLost/OnSubmit.
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
environment.Runtime.View.Snapshot = snapshot with
{
Revision = snapshot.Revision + 1,
Name = "Zorak",
};
environment.Controller.Tick();
Assert.Equal("Zorak", field.Text);
// (2) The player's own real commit afterward.
field.SetText("Adventurer");
field.Submit();
Assert.Equal("Adventurer", environment.Runtime.LastSetName);
}
// ── CC5 review fix round F12(d): RebuildListbox content ─────────────
/// <summary>
/// F12(d) (2026-08-16): pins the F3 fix directly against the listbox's
/// actual built rows (not just the header/pair TEMPLATE ids the fixture
/// wires) — a skill row uses the key/value pair template (KEY = skill
/// name, VALUE = <see cref="FakeRuntime.GetSkillScore"/>'s deterministic
/// stand-in), and BOTH bucket headers appear even though only ONE bucket
/// (Trained) has a matching skill — the case that actually exercises
/// "unconditional" (a header shown only because some row happened to
/// match would have passed even with the pre-fix lazy-header bug).
/// </summary>
[Fact]
public void Summary_RebuildListbox_SkillRows_UseKeyValueTemplate_WithUnconditionalHeaders()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
SelectAluvianMale(environment);
// SkillTrainOnly (1, "Axe") is Trained; SkillSpecializable (2,
// "Bow") is left Inactive entirely — no skill occupies the
// Specialized bucket.
environment.Runtime.View.SetSkillLevel(SkillTrainOnly, ChargenSkillAdvancementClass.Trained);
GoToSummary(environment);
UiTemplateListBox listBox = environment.SummaryListBox();
IReadOnlyList<UiElement> rows = Assert.IsType<UiScrollablePanel>(
listBox.ViewportForTest).Children;
// Ids match CharacterCreationSummaryPage's own private
// HeaderTextId/KeyTextId/ValueTextId constants — the same literals
// BuildSummaryHeaderTemplate/BuildSummaryPairTemplate below already
// hardcode for the fixture's row templates.
const uint headerTextId = 0x100000FEu;
const uint keyTextId = 0x100002FCu;
const uint valueTextId = 0x100002FDu;
var headers = new List<string>();
var pairs = new List<(string Key, string Value)>();
foreach (UiElement row in rows)
{
if (UiElement.FindDescendant(row, headerTextId) is UiText header)
headers.Add(JoinedText(header));
else if (UiElement.FindDescendant(row, keyTextId) is UiText key
&& UiElement.FindDescendant(row, valueTextId) is UiText value)
{
pairs.Add((JoinedText(key), JoinedText(value)));
}
}
Assert.Contains("Specialized Skills", headers);
Assert.Contains("Trained Skills", headers);
Assert.Single(pairs, p => p.Key == "Axe" && p.Value == "10");
Assert.DoesNotContain(pairs, p => p.Key == "Bow");
}
private static string JoinedText(UiText text) =>
string.Join(" ", text.LinesProvider().Select(static line => line.Text));
// ── CC5: 0xF643 rejection dialogs ─────────────────────────────────────
[Fact]
public void CreationFailed_NameInUse_ShowsTheRetailErrorDialog_AndAcknowledgesOnClose()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_Character_Err_NameReserved"] = "That name is in use.";
environment.Controller.Open();
environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with
{
LastRejection = new RuntimeCharacterCreationRejection(
3u,
CharGenVerificationResponse.Code.NameInUse,
"NameInUse",
"Adventurer"),
};
BumpRevisionAndTick(environment);
Assert.True(environment.Dialogs.IsOpen);
Assert.Equal("That name is in use.", environment.LastDialogMessage());
environment.DismissActiveMessageDialog();
Assert.Equal(1, environment.Runtime.AcknowledgeRejectionCalls);
}
/// <summary>
/// F12(c) (CC5 review-fix round, 2026-08-16): a SECOND rejection with
/// IDENTICAL field values (same RawCode/Code/Reason/AttemptedName —
/// e.g. the player retried Finish with the SAME already-taken name)
/// must still show the dialog. <c>ReconcileDialogs</c>' own
/// <c>_lastShownRejection</c> dedup only suppresses re-showing a value
/// that is STILL the current <c>LastRejection</c> across ticks
/// (<see cref="CreationFailed_SameRejectionAcrossTicks_ShowsOnlyOneDialog"/>);
/// once <see cref="FakeRuntime.AcknowledgeRejection"/> nulls the
/// snapshot's rejection (mirroring the real
/// <c>RuntimeCharacterCreationState.TryAcknowledgeRejection</c>),
/// <c>_lastShownRejection</c> resets to null too, so the identical
/// value arriving a second time is treated as new.
/// </summary>
[Fact]
public void CreationFailed_IdenticalRejectionAfterAcknowledge_ReshowsTheDialog()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_Character_Err_NameReserved"] = "That name is in use.";
environment.Controller.Open();
var rejection = new RuntimeCharacterCreationRejection(
3u, CharGenVerificationResponse.Code.NameInUse, "NameInUse", "Adventurer");
environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with { LastRejection = rejection };
BumpRevisionAndTick(environment);
Assert.True(environment.Dialogs.IsOpen);
environment.DismissActiveMessageDialog();
Assert.Equal(1, environment.Runtime.AcknowledgeRejectionCalls);
Assert.False(environment.Dialogs.IsOpen);
Assert.Null(environment.Runtime.View.Snapshot.LastRejection);
// One intervening Tick with LastRejection == null — exactly what
// happens continuously in the real game loop between the
// acknowledge callback and the player's next Finish attempt — lets
// ReconcileDialogs' own `if (rejection is null) _lastShownRejection
// = null;` branch run BEFORE the identical value arrives again.
// Without this, _lastShownRejection still holds the acknowledged
// value and the dedup would (correctly, per its OWN contract)
// suppress a value that never actually went away in between.
environment.Controller.Tick();
// The identical rejection value arrives again.
environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with { LastRejection = rejection };
BumpRevisionAndTick(environment);
Assert.True(environment.Dialogs.IsOpen);
Assert.Equal("That name is in use.", environment.LastDialogMessage());
environment.DismissActiveMessageDialog();
Assert.Equal(2, environment.Runtime.AcknowledgeRejectionCalls);
}
[Fact]
public void CreationFailed_SameRejectionAcrossTicks_ShowsOnlyOneDialog()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_Character_Err_NameBanned"] = "That name is banned.";
environment.Controller.Open();
environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with
{
LastRejection = new RuntimeCharacterCreationRejection(
4u, CharGenVerificationResponse.Code.NameBanned, "NameBanned", "Adventurer"),
};
BumpRevisionAndTick(environment);
Assert.Equal(1, environment.Dialogs.ActiveCount);
// A second Tick with the SAME rejection instance (no revision bump,
// no new value) must not reopen the dialog — ReconcileDialogs runs
// every Tick, not just on revision change.
environment.Controller.Tick();
Assert.Equal(1, environment.Dialogs.ActiveCount);
}
private sealed class FakeChargenPreviewControl : AcDream.App.Rendering.IChargenPreviewControl
{
public int ZoomInCalls { get; private set; }
public int ZoomOutCalls { get; private set; }
public int RotateClockwiseCalls { get; private set; }
public int RotateCounterClockwiseCalls { get; private set; }
public bool Rebuild(
ChargenOptions options,
uint heritageId,
int genderKey,
ChargenAppearanceSelection selection) => true;
public void ZoomIn() => ZoomInCalls++;
public void ZoomOut() => ZoomOutCalls++;
public void RotateClockwise() => RotateClockwiseCalls++;
public void RotateCounterClockwise() => RotateCounterClockwiseCalls++;
}
// ── Campaign CC gate round 1 Batch C ────────────────────────────────
/// <summary>
/// GF-2: the composed description routes through the shared rich-text
/// helper — header segments (palette index 1) render in a DIFFERENT
/// color than body segments (index 0), and each segment's own escape
/// sequence is normalized. The fixture's description element carries
/// no authored <c>FontColorPalette</c>, so this also exercises
/// <see cref="DatRichText.PaletteColor"/>'s fallback (green header /
/// white body).
/// </summary>
[Fact]
public void HeritageDescription_ComposesGreenHeaderAndWhiteBodySegments()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills_Header"] = "Trained Starting Skills:";
environment.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills"] = "Line one\\nLine two";
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
BumpRevisionAndTick(environment);
UiText description = Assert.IsType<UiText>(environment.Screen.FindElement(0x100003C4u));
var lines = description.LinesProvider().ToList();
Assert.Contains(lines, l => l.Text == "Trained Starting Skills:" && l.Color == new Vector4(0f, 1f, 0f, 1f));
// The literal "\n" escape in the body segment must become TWO
// separate lines, not render as a literal backslash-n.
Assert.Contains(lines, l => l.Text == "Line one" && l.Color == Vector4.One);
Assert.Contains(lines, l => l.Text == "Line two" && l.Color == Vector4.One);
Assert.DoesNotContain(lines, l => l.Text.Contains("\\n"));
}
/// <summary>GF-11a: switching towns changes the RENDERED (wrapped)
/// lines, not just an internal string that never becomes visible —
/// the diagnosed root cause of "description does not change" was a
/// single un-wrapped line whose differing suffix rendered past the
/// clipped viewport.</summary>
[Fact]
public void TownDescription_ChangesRenderedLinesWhenSwitchingTowns()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_CharGen_TownHowTo"] = "How to pick a town.";
environment.Runtime.ResolvedStrings["ID_CharGen_HoltText"] = "Holtburg is snowy.";
environment.Runtime.ResolvedStrings["ID_CharGen_ShoushiText"] = "Shoushi is sunny.";
environment.Controller.Open();
environment.TabButton(CharacterCreationUiController.TownTabElementId).OnClick!();
environment.Button(0x1000040Du).OnClick!(); // Holtburg
BumpRevisionAndTick(environment);
UiText description = Assert.IsType<UiText>(environment.Screen.FindElement(0x10000409u));
string holtburgText = JoinedText(description);
Assert.Contains("Holtburg is snowy.", holtburgText);
environment.Button(0x1000040Fu).OnClick!(); // Shoushi
BumpRevisionAndTick(environment);
string shoushiText = JoinedText(description);
Assert.Contains("Shoushi is sunny.", shoushiText);
Assert.DoesNotContain("Holtburg is snowy.", shoushiText);
}
/// <summary>GF-3: the Profession page's description textbox
/// (<c>0x100003e0</c>) binds and switches per selected template.</summary>
[Fact]
public void ProfessionDescription_BindsAndSwitchesPerTemplate()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_CharGen_CustomText"] = "Custom flexible build.";
environment.Runtime.ResolvedStrings["ID_CharGen_BowText"] = "Bow hunters use ranged attacks.";
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId).OnClick!();
environment.Button(0x100003DAu).OnClick!(); // Bow Hunter = template 1
BumpRevisionAndTick(environment);
UiText description = Assert.IsType<UiText>(environment.Screen.FindElement(0x100003E0u));
Assert.Contains("Bow hunters use ranged attacks.", JoinedText(description));
}
/// <summary>GF-4a: the display buttons' authored caption survives a
/// value write — the whole point of the ValueLabel coexistence
/// mechanism.</summary>
[Fact]
public void ProfessionAndSkillsDisplayButtons_ValueWriteDoesNotClobberLabel()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
UiButton available = environment.Button(0x100003E2u);
available.Label = "Attribute Credits"; // fixture authors no P0x17; simulate it
UiButton credits = environment.Button(0x100003F9u);
credits.Label = "Available Skill Credits";
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId).OnClick!();
BumpRevisionAndTick(environment);
Assert.Equal("Attribute Credits", available.Label);
// The fixture's default snapshot (BuildOptions' companion default)
// carries RemainingAttributeCredits=66 — an exact, non-vacuous
// pin, not just "some value got written somewhere".
Assert.Equal("66", available.ValueLabel);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
BumpRevisionAndTick(environment);
Assert.Equal("Available Skill Credits", credits.Label);
Assert.Equal("50", credits.ValueLabel); // RemainingSkillCredits=50
}
/// <summary>GF-6/AP-218: the Appearance page's Hair/Eyes/Skin spins
/// show a heritage-flavored STATIC caption, never a numeric ordinal —
/// and switch to the Gearknight/Olthoi variant per heritage.</summary>
[Fact]
public void AppearanceSpinCaptions_ArePartNames_NotOrdinals_AndVaryByHeritage()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_CharGen_HairStyle"] = "Hair Style";
environment.Runtime.ResolvedStrings["ID_CharGen_Eyes"] = "Eyes";
environment.Runtime.ResolvedStrings["ID_CharGen_GearText_HairButton"] = "Gear Hair";
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
BumpRevisionAndTick(environment);
UiButton hairSpin = environment.Button(CharacterCreationAppearancePage.HairSpinId);
Assert.Equal("Hair Style", hairSpin.Label);
Assert.DoesNotContain(hairSpin.Label, new[] { "1", "2", "-" });
environment.Runtime.SelectHeritageDirect((uint)ChargenHeritageGroup.Gearknight);
BumpRevisionAndTick(environment);
Assert.Equal("Gear Hair", hairSpin.Label);
}
/// <summary>Root 1d: the Heritage and Profession backdrops switch
/// state per selection.</summary>
[Fact]
public void HeritageAndProfessionBackdrops_SwitchStatePerSelection()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
var heritageBackdrop = Assert.IsAssignableFrom<IUiDatStateful>(
environment.Screen.FindElement(0x100003BEu));
environment.Runtime.SelectHeritageDirect(AluvianId);
BumpRevisionAndTick(environment);
Assert.Equal(0x10000021u, heritageBackdrop.ActiveRetailStateId);
environment.Runtime.SelectHeritageDirect((uint)ChargenHeritageGroup.Gharundim);
BumpRevisionAndTick(environment);
Assert.Equal(0x10000022u, heritageBackdrop.ActiveRetailStateId);
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId).OnClick!();
var professionBackdrop = Assert.IsAssignableFrom<IUiDatStateful>(
environment.Screen.FindElement(0x100003D8u));
environment.Button(0x100003DAu).OnClick!(); // Bow Hunter = template 1
BumpRevisionAndTick(environment);
Assert.Equal(0x1000002Cu, professionBackdrop.ActiveRetailStateId);
}
/// <summary>Commit 3: the Summary how-to text concatenates HowTo +
/// the heritage/gender name-suggestion list (heritages 1-4 only) +
/// HowToEnd, in that order, no separator inserted by code.</summary>
[Fact]
public void SummaryHowToText_ComposesHowToPlusNameSuggestionsPlusHowToEnd_ForNamedHeritages()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_CharGen_SummaryHowTo"] = "HOWTO.";
environment.Runtime.ResolvedStrings["ID_CharGen_SummaryHowToEnd"] = "HOWTOEND.";
environment.Runtime.ResolvedStrings["ID_CharGen_AluMaleNames"] = "Alucard, Aldric";
environment.Runtime.ResolvedStrings["ID_CharGen_AluFemaleNames"] = "Alura, Aldyth";
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.Runtime.SelectGenderDirect(1u); // male
environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!();
BumpRevisionAndTick(environment);
UiText howTo = Assert.IsType<UiText>(
environment.Screen.FindElement(CharacterCreationSummaryPage.HowToTextId));
string composed = JoinedText(howTo);
Assert.Contains("HOWTO.", composed);
Assert.Contains("Alucard, Aldric", composed);
Assert.DoesNotContain("Alura, Aldyth", composed);
Assert.Contains("HOWTOEND.", composed);
// No code-inserted separator: HowTo's own tail must be immediately
// followed by the name-list segment's own head, character for
// character (only whichever whitespace the AUTHORED strings
// themselves carry — none, in this test's fixture strings).
Assert.Contains("HOWTO.Alucard, Aldric", composed.Replace("\n", string.Empty));
environment.Runtime.SelectGenderDirect(2u); // female
BumpRevisionAndTick(environment);
string femaleComposed = JoinedText(howTo);
Assert.Contains("Alura, Aldyth", femaleComposed);
Assert.DoesNotContain("Alucard, Aldric", femaleComposed);
}
/// <summary>Heritages without a real retail name-suggestion string
/// (5-13, the decompiler-artifact cases) compose HowTo directly
/// against HowToEnd — no invented text, no crash.</summary>
[Fact]
public void SummaryHowToText_SkipsNameSuggestions_ForHeritagesWithNoRealString()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_CharGen_SummaryHowTo"] = "HOWTO.";
environment.Runtime.ResolvedStrings["ID_CharGen_SummaryHowToEnd"] = "HOWTOEND.";
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect((uint)ChargenHeritageGroup.Undead);
environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!();
BumpRevisionAndTick(environment);
UiText howTo = Assert.IsType<UiText>(
environment.Screen.FindElement(CharacterCreationSummaryPage.HowToTextId));
string composed = JoinedText(howTo);
Assert.Contains("HOWTO.", composed);
Assert.Contains("HOWTOEND.", composed);
}
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));
/// <summary>GF-5: locates a built skill row by its name text
/// (<c>0x10000301</c>) and returns its up (<c>0x10000304</c>,
/// <c>pSkillUpButton</c>) / down (<c>0x10000305</c>,
/// <c>pSkillDownButton</c>) arrow buttons.</summary>
public (UiButton Up, UiButton Down) SkillRowArrows(uint skillId)
{
string skillName = ItemAppraisalTextFormatter.SkillName((int)skillId);
UiElement row = Assert.Single(
SkillsList().ViewportForTest!.Children,
candidate => UiElement.FindDescendant(candidate, 0x10000301u) is UiText name
&& JoinedText(name) == skillName);
UiButton up = Assert.IsType<UiButton>(UiElement.FindDescendant(row, 0x10000304u));
UiButton down = Assert.IsType<UiButton>(UiElement.FindDescendant(row, 0x10000305u));
return (up, down);
}
/// <summary>R2-4a (Batch F): locates a built skill row's ROOT
/// element (<c>Templates[1]</c>'s own <c>UiDatElement</c>) by its
/// name text, for row-CLICK (not arrow-click) selection tests.
/// Also returns the row's own name <see cref="UiText"/> so a test
/// can assert its <see cref="UiText.DefaultColor"/> selection
/// highlight.</summary>
public (UiDatElement Row, UiText NameText) SkillRow(uint skillId)
{
string skillName = ItemAppraisalTextFormatter.SkillName((int)skillId);
UiElement row = Assert.Single(
SkillsList().ViewportForTest!.Children,
candidate => UiElement.FindDescendant(candidate, 0x10000301u) is UiText name
&& JoinedText(name) == skillName);
UiDatElement datRow = Assert.IsType<UiDatElement>(row);
UiText nameText = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000301u));
return (datRow, nameText);
}
public UiText SkillInfoTitle() =>
Assert.IsType<UiText>(Screen.FindElement(0x100003FBu));
public UiText SkillInfoText() =>
Assert.IsType<UiText>(Screen.FindElement(0x100003FCu));
public UiScrollbar SkillsScrollbar() =>
Assert.IsType<UiScrollbar>(Screen.FindElement(0x100003F8u));
public UiTemplateListBox SummaryListBox() =>
Assert.IsType<UiTemplateListBox>(Screen.FindElement(CharacterCreationSummaryPage.ListBoxId));
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); },
GetSkillScore: GetSkillScore,
OpenOnStart: false);
}
/// <summary>F3/F12(d) (CC5 review-fix round, 2026-08-16): deterministic
/// stand-in for <c>RetailSkillFormula.CalculateChargenScore</c> —
/// <c>skillId * 10</c> so tests can assert an exact, unambiguous value
/// without needing a real SkillTable.</summary>
private static uint GetSkillScore(
uint skillId,
ChargenAttributeValues attributes,
ChargenSkillAdvancementClass level) => skillId * 10u;
public FakeView View { get; }
public CharacterCreationRuntimeBindings Bindings { get; }
public bool ProvideView { get; set; } = true;
public int RequestExitCalls { get; private set; }
public uint LastSelectedHeritage { get; private set; }
public uint LastSelectedGender { get; private set; }
public uint LastSelectedTemplate { get; private set; }
public ChargenAttributeId LastAttributeSet { get; private set; }
public int LastAttributeValue { get; private set; }
public int LastSelectedStartArea { get; private set; } = -1;
public ChargenAppearanceSlot? LastAppearanceSlot { get; private set; }
public uint LastAppearanceIndex { get; private set; }
public int AppearanceIndexCallCount { get; private set; }
public ChargenShadeSlot? LastShadeSlot { get; private set; }
public double LastShadeValue { get; private set; }
public string? LastSetName { get; private set; }
public int FinishCallCount { get; private set; }
public bool LastConfirmedUnspentCredits { get; private set; }
public int AcknowledgeRejectionCalls { get; private set; }
public int RandomizeCharacterCalls { get; private set; }
public int RandomizeAppearanceCalls { get; private set; }
public int RandomizeClothingCalls { get; private set; }
/// <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 void SelectGenderDirect(uint genderKey) => SelectGender(genderKey);
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),
[SkillFreeTrained] = new(SkillFreeTrained, NormalCost: 0, PrimaryCost: 6),
};
// Group 2 closeout: global SkillTable detail (MinLevel/
// Description/Formula) — SkillTrainOnly is useable while
// Untrained (MinLevel 1) and carries a real description +
// single-attribute formula for the info-box completion tests;
// SkillSpecializable requires Trained first (MinLevel 2), the
// useable-vs-unuseable-untrained bucket split's own test case.
var skillDetails = new Dictionary<uint, ChargenSkillDetail>
{
[SkillTrainOnly] = new ChargenSkillDetail(
SkillTrainOnly,
MinLevel: 1u,
Description: "A test skill description.",
Formula: new ChargenSkillFormula(
AdditiveBonus: 2,
Attribute1Multiplier: 2,
Attribute2Multiplier: 0,
Divisor: 4,
Attribute1: (uint)ChargenAttributeId.Strength,
Attribute2: 0u)),
[SkillSpecializable] = new ChargenSkillDetail(
SkillSpecializable, MinLevel: 2u, Description: string.Empty, Formula: default),
[SkillFreeTrained] = new ChargenSkillDetail(
SkillFreeTrained, MinLevel: 1u, Description: string.Empty, Formula: default),
};
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>(),
skillDetails);
}
}
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));
// GF-15 fix round: FinishElementId needs a real, non-default
// position for the real-event-path regression test — see the
// matching comment on the Summary name field below. Clear of both
// the listbox (20,40)-(420,340) and the field (450,100)-(490,116).
ElementInfo finishInfo = ButtonInfo(CharacterCreationUiController.FinishElementId);
finishInfo.X = 600f;
finishInfo.Y = 500f;
root.Children.Add(finishInfo);
root.Children.Add(ButtonInfo(CharacterCreationUiController.HelpElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.ExitElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.RandomElementId));
root.Children.Add(ContainerInfo(CharacterCreationUiController.MasterPageElementId));
root.Children.Add(BuildHeritagePage());
root.Children.Add(BuildProfessionPage());
root.Children.Add(BuildSkillsPage());
root.Children.Add(BuildAppearancePage());
root.Children.Add(BuildTownPage());
root.Children.Add(BuildSummaryPage());
root.Children.Add(ButtonInfo(CharacterCreationUiController.HeritageTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.ProfessionTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.SkillsTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.AppearanceTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.TownTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.SummaryTabElementId));
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
}
private static ElementInfo BuildHeritagePage()
{
var page = new ElementInfo
{
Id = CharacterCreationUiController.HeritagePageElementId,
Type = 3u,
Width = 800f,
Height = 500f,
};
page.Children.Add(ButtonInfo(0x100003BFu)); // Aluvian
page.Children.Add(ButtonInfo(0x100005C7u)); // Olthoi
page.Children.Add(ButtonInfo(0x100005F1u)); // Lugian (F3 quirk: no tab-restore/hide)
page.Children.Add(TextInfo(0x100003C4u));
// Root 1d: the backdrop, live-DAT-measured 13 authored states
// (see CharacterCreationHeritagePage.BackdropStateByHeritage).
var backdrop = ContainerInfo(0x100003BEu);
foreach (uint stateId in new[]
{
0x10000021u, 0x10000022u, 0x10000023u, 0x10000024u, 0x10000058u,
0x10000059u, 0x1000005Au, 0x1000005Bu, 0x1000005Cu, 0x1000005Du,
0x1000005Eu, 0x1000005Fu, 0x10000060u,
})
{
backdrop.States[stateId] = new UiStateInfo { Id = stateId, Name = $"State_{stateId:X8}" };
}
page.Children.Add(backdrop);
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
page.Children.Add(TextInfo(0x100003E0u)); // GF-3: description textbox
// Root 1d: the backdrop, live-DAT-measured 7 authored states (see
// CharacterCreationProfessionPage.BackdropStateByTemplate).
var backdrop = ContainerInfo(0x100003D8u);
foreach (uint stateId in new[]
{
0x1000002Bu, 0x1000002Cu, 0x1000002Du,
0x1000002Eu, 0x1000002Fu, 0x10000030u, 0x10000031u,
})
{
backdrop.States[stateId] = new UiStateInfo { Id = stateId, Name = $"State_{stateId:X8}" };
}
page.Children.Add(backdrop);
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,
// R2-4c (Batch F): the listbox's own authored scrollbar link
// (dat property 0x72) — an arbitrary but plausible sibling id
// (retail's own "+1 from the listbox" convention, matching the
// hypothesis this batch's own investigation raised) since no
// live-DAT probe has pinned the real installed value yet.
ScrollbarElementId = 0x100003F8u,
};
// GF-5 (2026-08-16): [0] is retail's own bucket-HEADER row
// (0x100002F4, unused by this port's flat-list simplification);
// [1] (0x100002FF) is the REAL skill row RebuildRows now resolves —
// see CharacterCreationSkillsPage's own class doc for the byte
// trace pinning both ids and their child shapes.
list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100002F4u));
list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100002FFu));
page.Children.Add(list);
page.Children.Add(ScrollbarInfo(0x100003F8u));
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));
// GF-9: the nine companion overlay elements, live-DAT-measured as
// plain Type-3 siblings of the swatches under the color-wheel
// container.
foreach (uint overlayId in CharacterCreationAppearancePage.SwatchOverlayIds)
page.Children.Add(ContainerInfo(overlayId));
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));
// GF-10: unlike the plain ButtonInfo() used above, the zoom buttons
// need REAL Normal/Highlight media so AppearanceZoomButtons_
// ClickPath_TogglesMutualExclusiveHighlightPair can observe the
// actual mutual-exclusive state swap through TrySetRetailState —
// live-DAT-measured shape (both start "Normal", both author
// Highlight/rollover media).
page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomInId));
page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomOutId));
return page;
}
private static ElementInfo ZoomButtonInfo(uint id)
{
var info = new ElementInfo { Id = id, Type = 1u, Width = 81f, Height = 38f };
info.StateMedia["Normal"] = (0x06004D55u, 1);
info.StateMedia["Highlight"] = (0x06004D56u, 1);
info.DefaultStateName = "Normal";
return info;
}
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;
}
/// <summary>
/// GF-5: mirrors <c>Templates[1]</c>'s real installed-DAT shape
/// (<c>0x100002FF</c>, live-DAT-probe-confirmed against
/// <c>CharacterCreationSkillsPage</c>'s own class doc) — a plain
/// container root, NOT a <c>UiButton</c>, with the six children
/// <c>RebuildRows</c>/<c>RefreshRowValues</c> resolve by id. Template
/// <c>0x100002F4</c> (retail's unused bucket-header row) falls back to
/// a bare button shape since this port's flat-list simplification never
/// resolves it.
/// </summary>
/// <summary>Review fix round F2 (Batch F): retail's own custom
/// Ghosted(<c>0x1000001a</c>)/Enabled(<c>0x1000001b</c>) state pair for
/// <c>pSkillUpButton</c>/<c>pSkillDownButton</c> — mirrors
/// <c>CharacterCreationSkillsPage</c>'s own (private)
/// <c>ArrowGhostedStateId</c>/<c>ArrowEnabledStateId</c> consts so
/// <c>TrySetRetailState</c> has real, matching state descriptors to
/// resolve against (the raw-numeric-id lookup path,
/// <c>UiButton.TryFindState</c>) — the SAME "author arbitrary numeric
/// states directly" pattern <see cref="BuildAppearancePage"/> already
/// uses for its own custom pair.</summary>
private static ElementInfo ArrowButtonInfo(uint id)
{
ElementInfo info = ButtonInfo(id);
info.States[0x1000001Au] = new UiStateInfo { Id = 0x1000001Au, Name = "ArrowGhosted" };
info.States[0x1000001Bu] = new UiStateInfo { Id = 0x1000001Bu, Name = "ArrowEnabled" };
return info;
}
private static UiElement BuildSkillRowTemplate(uint templateElementId)
{
if (templateElementId == 0x100002FFu)
{
var row = new ElementInfo
{
Id = templateElementId,
Type = 3u,
Width = 280f,
Height = 16f,
};
// R2-4a (Batch F): an explicit, distinguishable unselected
// color (retail's own gold list-caption tone, the same
// 218,167,85 GF-11b measured) so selection tests can tell
// CharacterCreationSkillsPage.SelectedNameColor (pure white)
// apart from a row's own authored default.
ElementInfo nameInfo = TextInfo(0x10000301u);
nameInfo.FontColor = new System.Numerics.Vector4(218f / 255f, 167f / 255f, 85f / 255f, 1f);
row.Children.Add(ContainerInfo(0x10000300u)); // unreferenced icon/backdrop
row.Children.Add(nameInfo); // name
row.Children.Add(TextInfo(0x10000302u)); // pSkillLevelText
row.Children.Add(TextInfo(0x10000303u)); // pUpCostText
row.Children.Add(ArrowButtonInfo(0x10000304u)); // pSkillUpButton
row.Children.Add(ArrowButtonInfo(0x10000305u)); // pSkillDownButton
row.Children.Add(TextInfo(0x10000306u)); // pDownCostText
return LayoutImporter.Build(row, _ => (0u, 0, 0), null).Root;
}
// Group 2 closeout: Templates[0] (0x100002F4) — retail's own
// bucket-header row, now consumed by the four-bucket rebuild. A
// plain container root (Type 3, same shape as Templates[1]'s own
// row — a Type-1 UiButton root would CONSUME its own children and
// hide the caption), carrying the caption child (0x100002f6, a
// UiButton, read via .Label — CharacterCreationSkillsPage's own
// HeaderCaptionElementId doc).
var header = new ElementInfo
{
Id = templateElementId,
Type = 3u,
Width = 280f,
Height = 16f,
};
header.Children.Add(ButtonInfo(0x100002F6u));
return LayoutImporter.Build(header, _ => (0u, 0, 0), null).Root;
}
// ── Summary page fixture (CC5) ───────────────────────────────────────
// Template element ids match the LIVE-DAT-probe-confirmed retail ones
// (CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport)
// for readability, though this hand-built fixture doesn't require it.
private const uint SummaryLineTemplateId = 0x100002F8u;
private const uint SummaryHeaderTemplateId = 0x100002FAu;
private const uint SummaryPairTemplateId = 0x100002FBu;
private static ElementInfo BuildSummaryPage()
{
var page = new ElementInfo
{
Id = CharacterCreationUiController.SummaryPageElementId,
Type = 3u,
Width = 800f,
Height = 500f,
};
var list = new ElementInfo
{
Id = CharacterCreationSummaryPage.ListBoxId,
Type = 5u,
X = 20f,
Y = 40f,
Width = 400f,
Height = 300f,
};
list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, SummaryLineTemplateId));
list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, SummaryHeaderTemplateId));
list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, SummaryPairTemplateId));
page.Children.Add(list);
page.Children.Add(ScrollbarInfo(CharacterCreationSummaryPage.ScrollId));
// GF-15 fix round (2026-08-16): the real-event-path regression test
// (Finish_EmptyName_RealEventPath_...) drives UiRoot.OnMouseDown by
// actual screen coordinates, unlike every other test in this file.
// EditableFieldInfo's own default X/Y (0,0) would collide with the
// tab strip's own default (0,0) position (BuildScreen's tab buttons
// are never given explicit coordinates either — no prior test
// needed them, since they all click via .OnClick!() directly) —
// clear of both the listbox (20,40)-(420,340) and the default-
// positioned tab/nav buttons at Y=0.
ElementInfo nameFieldInfo = EditableFieldInfo(CharacterCreationSummaryPage.NameTextId);
nameFieldInfo.X = 450f;
nameFieldInfo.Y = 100f;
page.Children.Add(nameFieldInfo);
page.Children.Add(TextInfo(CharacterCreationSummaryPage.HowToTextId));
var viewport = new ElementInfo
{
Id = CharacterCreationSummaryPage.ViewportId,
Type = 0xDu,
Width = 300f,
Height = 300f,
};
page.Children.Add(viewport);
return page;
}
private static UiElement BuildSummaryLineTemplate()
{
var root = new ElementInfo { Id = 0x90001u, Type = 3u, Width = 380f, Height = 16f };
root.Children.Add(TextInfo(0x100002F9u));
return LayoutImporter.Build(root, _ => (0u, 0, 0), null).Root;
}
private static UiElement BuildSummaryHeaderTemplate()
{
var root = new ElementInfo { Id = 0x90002u, Type = 3u, Width = 380f, Height = 18f };
root.Children.Add(TextInfo(0x100000FEu));
return LayoutImporter.Build(root, _ => (0u, 0, 0), null).Root;
}
private static UiElement BuildSummaryPairTemplate()
{
var root = new ElementInfo { Id = 0x90003u, Type = 3u, Width = 380f, Height = 16f };
root.Children.Add(TextInfo(0x100002FCu));
root.Children.Add(TextInfo(0x100002FDu));
return LayoutImporter.Build(root, _ => (0u, 0, 0), null).Root;
}
private static ElementInfo ContainerInfo(uint id) => new()
{
Id = id,
Type = 3u,
Width = 200f,
Height = 60f,
};
private static ElementInfo ButtonInfo(uint id) => new()
{
Id = id,
Type = 1u,
Width = 100f,
Height = 30f,
};
private static ElementInfo TextInfo(uint id) => new()
{
Id = id,
Type = 12u,
Width = 200f,
Height = 60f,
};
private static ElementInfo ScrollbarInfo(uint id) => new()
{
Id = id,
Type = 11u,
Width = 120f,
Height = 12f,
};
private static ElementInfo EditableFieldInfo(uint id)
{
var info = new ElementInfo
{
Id = id,
Type = 12u,
Width = 40f,
Height = 16f,
};
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
state.Properties.Values[0x16u] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = true,
};
info.States[UiStateInfo.DirectStateId] = state;
return info;
}
}