fix(chargen): Campaign CC gate round 1 Batch A — GF-15 input, GF-5 skills rows, GF-13 GM toggles

GF-15 (the gate blocker): the Summary name field and Finish button were
NOT structurally broken — live repro over the project's own local ACE
test server showed clicks correctly focus the field and land characters.
The real bug only surfaces after the first dialog opens: pressing Finish
empty successfully creates the NoName RetailMessageDialogView (visible,
correct 400x95 geometry) but it renders nothing and silently absorbs
every click across the whole canvas. Root cause: CharacterCreationUiController.Tick
and CharacterManagementUiController.Tick both call UiRoot.BringToFront(Root)
unconditionally every frame (needed so chargen stays above the occluded
management screen, AP-229); a dialog root is a direct sibling under the
same UiRoot, and RetailWindowManager.BringToFront is "highest ZOrder among
siblings + 1" — whichever BringToFront runs last in a frame wins.
RetailDialogFactory.Tick never re-asserted its own dialogs' z-order, so
the next frame's screen Tick buried the dialog behind the screen's opaque
backdrop while it stayed the registered Modal with exclusive input
priority. Fixed by having RetailDialogFactory.Tick re-raise every open
dialog (in open-order) each tick, matching retail's always-on-top dialog
behavior. Live-verified the complete user sequence end to end: click
field, type, press Finish empty, dialog now visibly renders, OK dismisses
cleanly, field still typable afterward. The "[ Name" prefill question is
closed as a non-bug: neither CharGenState::RandomizeCharacter nor
gmCGSummaryPage::InitializePage write text into the field in the decomp;
retail's field is genuinely empty on open, matching acdream already.

GF-5: CharacterCreationSkillsPage.RebuildRows resolved the wrong listbox
template (Templates[0], retail's own 3-child bucket-header row) and
required the root to be a UiButton (it's a plain container). Byte-traced
gmCGSkillsPage::DoSkillRecords + tagSkillRecord's copy-ctor field order
to map every child id in the real row (Templates[1]): name, level/cost
text, and the two real per-row up/down arrow buttons. Wired the arrows to
retail's own plain-click dispatch, retiring (narrowing) AP-213's
click-to-advance/double-click-retreat single-button substitution.

GF-13: dat property 0x3B (Invisible) was never read by the importer.
Elements 0x10000403/0x10000494 ("Non-Admin"/"Non-Envoy") author it true.
A blast-radius sweep found 1,083 elements client-wide author the same
flag, so this fix stays chargen-scoped only (ElementInfo.Invisible /
UiElement.AuthoredInvisible are pure data additions; only
CharacterCreationUiController acts on them, by the authored flag, not a
hardcoded id list). General importer-wide honor filed as ISSUES.md #408;
register row AP-230 records the split.

Gates: solution build green; App 5266/3 skips/0 failed; Runtime 1735/0;
full-solution run 0 failures anywhere. Register: AP-230 filed, AP-213
narrowed. ISSUES: #408 filed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 10:54:41 +02:00
parent 6699e0f88c
commit 1d9de5e095
11 changed files with 823 additions and 78 deletions

View file

@ -617,6 +617,189 @@ public sealed class CharacterCreationLiveDatTests
+ "gate argument needs re-verification for this skill.");
}
/// <summary>
/// GF-13 (Campaign CC gate round 1, Batch A). Live-DAT-probe-confirmed:
/// the GM-only labels <c>0x10000403</c> ("Non-Admin") and
/// <c>0x10000494</c> ("Non-Envoy") both live under the Summary page
/// (<c>0x100003D6</c>, path <c>0x100003CC &gt; 0x100003D0 &gt;
/// 0x100003D6 &gt; {0x10000403,0x10000494}</c>) and both author dat
/// property <c>0x3B</c> (Invisible) = <see langword="true"/> — the exact
/// mechanism retail's <c>UIElement::OnSetAttribute @0x00462d80</c> case 8
/// hides them by. Pins the DATA half (<see cref="ElementInfo.Invisible"/>)
/// against the installed EoR dat.
/// </summary>
[InstalledDatFact]
public void SummaryPage_NonAdminNonEnvoyLabels_AuthorInvisibleTrue()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats, layoutId, CharacterCreationUiController.RootElementId));
foreach (uint targetId in new[] { 0x10000403u, 0x10000494u })
{
ElementInfo? found = FindInfo(rootInfo, targetId);
Assert.NotNull(found);
Assert.True(
found!.Invisible,
$"element 0x{targetId:X8} must author dat property 0x3B (Invisible) = true.");
}
}
/// <summary>
/// GF-13: the BEHAVIOR half — after the real controller mounts through
/// <see cref="CharacterCreationUiController.CreateDetached"/> (not a raw
/// <see cref="LayoutImporter.Build"/> call), the two authored-invisible
/// elements are not <see cref="UiElement.Visible"/>. Exercises
/// <c>HideAuthoredInvisibleElements</c>'s real chargen-scoped honor path,
/// not just the data plumbing the sibling test above pins.
/// </summary>
[InstalledDatFact]
public void SummaryPage_NonAdminNonEnvoyLabels_HiddenAfterControllerMount()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
var host = new UiRoot();
var dialogs = MakeDialogFactory(dats, host);
var bindings = new CharacterCreationRuntimeBindings(
() => null,
_ => default,
_ => default,
_ => default,
(_, _) => default,
(_, _) => default,
_ => default,
_ => default,
_ => default,
_ => default,
_ => default,
() => { });
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) =>
LayoutImporter.Import(
dats, templateLayoutId, templateElementId, _ => (0u, 0, 0), null)?.Root;
CharacterCreationUiController? controller =
CharacterCreationUiController.CreateDetached(
host, screen, ResolveTemplate, dialogs, bindings,
new CharacterCreationUiController.DialogStrings(
"Are you sure?", "No name", "Unspent credits", "Randomize?", "Name too long"));
Assert.NotNull(controller);
foreach (uint targetId in new[] { 0x10000403u, 0x10000494u })
{
UiElement found = Assert.IsAssignableFrom<UiElement>(
UiElement.FindDescendant(controller!.Root, targetId));
Assert.True(found.AuthoredInvisible, $"0x{targetId:X8} must carry AuthoredInvisible.");
Assert.False(found.Visible, $"0x{targetId:X8} must be hidden after mount.");
}
controller!.Dispose();
dialogs.Dispose();
}
/// <summary>
/// GF-5 (Campaign CC gate round 1, Batch A). Live-DAT-probe-confirmed
/// (raw <c>ElementDesc.Type</c> — the same id space as retail's
/// <c>DynamicCast</c> tags, 1=Button, 12=Text): the Skills listbox
/// authors exactly two templates. <c>Templates[0]</c>
/// (<c>0x100002F4</c>) is retail's own 3-child bucket-HEADER row
/// (unused by this port's flat-list simplification, AP-213).
/// <c>Templates[1]</c> (<c>0x100002FF</c>) is the REAL skill row — a
/// plain container root (rawType 3, NOT a Button), 7 children, byte-
/// traced against <c>gmCGSkillsPage::DoSkillRecords @ 0x004817e0</c> +
/// <c>tagSkillRecord</c>'s copy-constructor field order
/// (<c>acclient.h</c>): name (<c>0x10000301</c>, Text), pSkillLevelText
/// (<c>0x10000302</c>, Text), pUpCostText (<c>0x10000303</c>, Text),
/// pSkillUpButton (<c>0x10000304</c>, Button), pSkillDownButton
/// (<c>0x10000305</c>, Button), pDownCostText (<c>0x10000306</c>, Text).
/// See <see cref="CharacterCreationSkillsPage"/>'s own class doc for the
/// full trace.
/// </summary>
[InstalledDatFact]
public void SkillsPage_RealRowTemplate_HasNameLevelCostTextAndArrowButtons()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement skillsRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.SkillsPageElementId));
UiTemplateListBox list = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(skillsRoot, 0x100003F7u));
Assert.Equal(2, list.Templates.Count);
UiTemplateListEntry realRowTemplate = list.Templates[1];
Assert.Equal(0x100002FFu, realRowTemplate.TemplateElementId);
UiElement? row = LayoutImporter.Import(
dats,
realRowTemplate.TemplateLayoutId,
realRowTemplate.TemplateElementId,
_ => (0u, 0, 0),
null)?.Root;
UiElement realRow = Assert.IsAssignableFrom<UiElement>(row);
Assert.IsNotType<UiButton>(realRow);
Assert.IsType<UiText>(UiElement.FindDescendant(realRow, 0x10000301u));
Assert.IsType<UiText>(UiElement.FindDescendant(realRow, 0x10000302u));
Assert.IsType<UiText>(UiElement.FindDescendant(realRow, 0x10000303u));
Assert.IsType<UiText>(UiElement.FindDescendant(realRow, 0x10000306u));
Assert.IsType<UiButton>(UiElement.FindDescendant(realRow, 0x10000304u));
Assert.IsType<UiButton>(UiElement.FindDescendant(realRow, 0x10000305u));
// Templates[0] (0x100002F4) is retail's bucket-header row — unused
// by RebuildRows, still confirmed present so a future revision that
// drops it or changes its shape shows up here.
Assert.Equal(0x100002F4u, list.Templates[0].TemplateElementId);
}
/// <summary>
/// GF-15 (Campaign CC gate round 1, Batch A). Live-DAT-probe-confirmed
/// during the investigation: the Message dialog catalog's popup
/// (<c>0x3D</c>), message text (<c>0x3E</c>), and OK button
/// (<c>0x26</c>) all author REAL, nonzero geometry (popup 400x95,
/// centered by <see cref="RetailMessageDialogView.SizeAndCenter"/>) —
/// ruling out a zero-size/collapsed-layout explanation for the dialog
/// rendering nothing. The actual root cause was a Z-ORDER bug (the
/// chargen screen's own per-tick <c>BringToFront</c> burying the dialog
/// behind its opaque backdrop while the dialog kept exclusive
/// <see cref="UiRoot.Modal"/> input priority — fixed in
/// <see cref="RetailDialogFactory.Tick"/>). This test pins the geometry
/// half so a future DAT revision that collapses the popup/message/button
/// to zero size is caught here instead of silently reintroducing an
/// invisible dialog.
/// </summary>
[InstalledDatFact]
public void MessageDialogCatalog_PopupMessageAndOkButton_AuthorNonzeroGeometry()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u);
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(dats, dialogDid, 0x24u));
Assert.Equal(800f, rootInfo.Width);
Assert.Equal(600f, rootInfo.Height);
ElementInfo popup = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x3Du));
Assert.True(popup.Width > 0f && popup.Height > 0f);
ElementInfo message = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x3Eu));
Assert.True(message.Width > 0f && message.Height > 0f);
ElementInfo okButton = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x26u));
Assert.True(okButton.Width > 0f && okButton.Height > 0f);
}
private static void AssertButton(ImportedLayout layout, uint elementId) =>
Assert.IsType<UiButton>(layout.FindElement(elementId));

View file

@ -279,8 +279,16 @@ public sealed class CharacterCreationUiControllerTests
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_Click_TrainsThenSpecializes()
public void SkillsRow_ArrowClick_TrainsThenSpecializes()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
@ -288,30 +296,71 @@ public sealed class CharacterCreationUiControllerTests
environment.TabButton(CharacterCreationUiController.SkillsTabElementId)
.OnClick!();
// Rows are built in ascending skill-id order (RebuildRows' 1..54
// walk over IsCostable ids) — SkillSpecializable's row is identified
// by its label prefix (FormatSkillLabel's "{name}: ..." shape)
// rather than instance identity, since the controller owns the
// row->skillId map privately.
string skillName = ItemAppraisalTextFormatter.SkillName((int)SkillSpecializable);
UiButton row = environment.SkillsList().ViewportForTest!.Children
.OfType<UiButton>()
.Single(candidate => candidate.Label!.StartsWith(
skillName + ":", StringComparison.Ordinal));
(UiButton up, UiButton down) = environment.SkillRowArrows(SkillSpecializable);
row.OnClick!();
up.OnClick!();
Assert.Equal(ChargenSkillAdvancementClass.Trained,
environment.Runtime.GetSkillLevel(SkillSpecializable));
row.OnClick!();
up.OnClick!();
Assert.Equal(ChargenSkillAdvancementClass.Specialized,
environment.Runtime.GetSkillLevel(SkillSpecializable));
row.OnDoubleClick!();
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> rows = environment.SkillsList().ViewportForTest!.Children;
// Aluvian's fixture only costs SkillTrainOnly(1)/SkillSpecializable(2).
Assert.Equal(2, rows.Count);
UiElement row = Assert.Single(rows, candidate =>
UiElement.FindDescendant(candidate, 0x10000301u) is UiText name
&& JoinedText(name) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly));
// FakeRuntime.GetSkillScore's deterministic stand-in: skillId * 10.
UiText level = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000302u));
Assert.Equal((SkillTrainOnly * 10u).ToString(), JoinedText(level));
// Default (never-touched) level: up cost = trained cost (2), down
// cost blank (nothing below Untrained/Inactive).
UiText upCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000303u));
UiText downCost = Assert.IsType<UiText>(UiElement.FindDescendant(row, 0x10000306u));
Assert.Equal("2", JoinedText(upCost));
Assert.Equal(string.Empty, JoinedText(downCost));
// Advancing to Trained flips the cost pair: up = specCost-trainCost
// (6-2=4), down = trainCost (2). FakeRuntime.SetSkillLevel is a
// lightweight stub that doesn't bump Revision itself (unlike
// production's TrySetSkillLevel, RuntimeCharacterCreationState.cs
// ~1045), so force one the same way the file's other post-click
// refresh assertions do.
environment.SkillRowArrows(SkillTrainOnly).Up.OnClick!();
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
environment.Runtime.View.Snapshot = snapshot with { Revision = snapshot.Revision + 1 };
environment.Controller.Tick();
Assert.Equal("4", JoinedText(upCost));
Assert.Equal("2", JoinedText(downCost));
}
[Fact]
public void TownButton_SelectsTheLiteralStartAreaIndex()
{
@ -862,6 +911,94 @@ public sealed class CharacterCreationUiControllerTests
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()
{
@ -1345,6 +1482,22 @@ public sealed class CharacterCreationUiControllerTests
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);
}
public UiTemplateListBox SummaryListBox() =>
Assert.IsType<UiTemplateListBox>(Screen.FindElement(CharacterCreationSummaryPage.ListBoxId));
@ -1829,7 +1982,14 @@ public sealed class CharacterCreationUiControllerTests
root.Children.Add(ContainerInfo(CharacterCreationUiController.ProgressBarElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.BackElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.NextElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.FinishElementId));
// 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));
@ -1911,7 +2071,13 @@ public sealed class CharacterCreationUiControllerTests
Width = 300f,
Height = 320f,
};
list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100003FEu));
// GF-5 (2026-08-16): [0] is retail's own bucket-HEADER row
// (0x100002F4, unused by this port's flat-list simplification);
// [1] (0x100002FF) is the REAL skill row RebuildRows now resolves —
// see CharacterCreationSkillsPage's own class doc for the byte
// trace pinning both ids and their child shapes.
list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100002F4u));
list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100002FFu));
page.Children.Add(list);
page.Children.Add(ButtonInfo(0x100003F9u)); // credits badge
page.Children.Add(TextInfo(0x100003FBu));
@ -2020,8 +2186,38 @@ public sealed class CharacterCreationUiControllerTests
return spin;
}
private static UiElement BuildSkillRowTemplate(uint templateElementId) =>
LayoutImporter.Build(
/// <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>
private static UiElement BuildSkillRowTemplate(uint templateElementId)
{
if (templateElementId == 0x100002FFu)
{
var row = new ElementInfo
{
Id = templateElementId,
Type = 3u,
Width = 280f,
Height = 16f,
};
row.Children.Add(ContainerInfo(0x10000300u)); // unreferenced icon/backdrop
row.Children.Add(TextInfo(0x10000301u)); // name
row.Children.Add(TextInfo(0x10000302u)); // pSkillLevelText
row.Children.Add(TextInfo(0x10000303u)); // pUpCostText
row.Children.Add(ButtonInfo(0x10000304u)); // pSkillUpButton
row.Children.Add(ButtonInfo(0x10000305u)); // pSkillDownButton
row.Children.Add(TextInfo(0x10000306u)); // pDownCostText
return LayoutImporter.Build(row, _ => (0u, 0, 0), null).Root;
}
return LayoutImporter.Build(
new ElementInfo
{
Id = templateElementId,
@ -2031,6 +2227,7 @@ public sealed class CharacterCreationUiControllerTests
},
_ => (0u, 0, 0),
null).Root;
}
// ── Summary page fixture (CC5) ───────────────────────────────────────
// Template element ids match the LIVE-DAT-probe-confirmed retail ones
@ -2066,7 +2263,21 @@ public sealed class CharacterCreationUiControllerTests
page.Children.Add(list);
page.Children.Add(ScrollbarInfo(CharacterCreationSummaryPage.ScrollId));
page.Children.Add(EditableFieldInfo(CharacterCreationSummaryPage.NameTextId));
// 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