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

@ -24,6 +24,53 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #408 — General importer-wide honor of dat property 0x3B (Invisible) is unshipped (1,083 elements client-wide)
**Status:** OPEN
**Severity:** LOW-MEDIUM (cosmetic — extra/leaked elements render where retail hides them; no gameplay/wire impact)
Found while fixing GF-13 (Campaign CC gate round 1, Batch A, 2026-08-16):
acdream's `LayoutImporter`/`DatWidgetFactory` never read dat property
`0x3B` (Invisible — `BoolBaseProperty`), which retail's
`UIElement::OnSetAttribute @0x00462d80` case 8
(`GetPropertyName()-0x33==8`) honors on EVERY element via
`SetVisible(value==0)`. The blast-radius sweep this fix's investigation
ran found **1,083 elements client-wide** author `P0x3B=true` — far
beyond the two chargen-Summary GM labels (`0x10000403`
"Non-Admin"/`0x10000494` "Non-Envoy") the user actually reported.
The fix (`fix(chargen): Campaign CC gate round 1 Batch A`) added the data
plumbing everywhere (`ElementInfo.Invisible`, read in
`ElementReader.ApplyCanonicalLegacyProjection`; `UiElement.AuthoredInvisible`,
set in `LayoutImporter.BuildWidget`) but deliberately does NOT act on it
in the shared importer path — only `CharacterCreationUiController`
(`HideAuthoredInvisibleElements`) walks its own mounted subtree and
hides what it finds, chargen-scoped only. Register row AP-230 records
the split.
Honoring the flag client-wide (setting `UiElement.Visible = false`
directly in `LayoutImporter.BuildWidget` when `info.Invisible` is true,
or an equivalent central chokepoint) is straightforward, but 1,083
elements is its own visual-regression surface: any one of them could be
an element some OTHER screen currently relies on being visible despite
authoring the flag (e.g. a state-conditional visibility toggle that
happens to leave `0x3B=true` on its default/direct state while a
controller separately manages `Visible` at runtime). This needs its own
sweep — dump the 1,083 ids grouped by owning LayoutDesc/screen, spot-check
a representative sample per screen against retail, then flip the
importer-wide switch with a dedicated visual gate — not a one-line
change folded into an unrelated fix.
Fix direction: (1) enumerate the 1,083 ids per LayoutDesc (a live-DAT
probe test, similar to `SpewBoxLayoutDumpDiagnostic`); (2) for each
distinct screen/LayoutDesc, confirm honoring the flag doesn't hide
something the runtime currently manages visibility of dynamically at that
SAME element id (would double-drive `Visible`); (3) flip the honor in
`LayoutImporter.BuildWidget` (mirroring the chargen-scoped code path
already proven live) and delete `CharacterCreationUiController`'s own
narrow `HideAuthoredInvisibleElements`/AP-230 in the same commit; (4) run
a full-client visual matrix, not just chargen.
## #407 — Windowed resolution offering starves on RDP/virtual displays (video-mode gating)
**Status:** DONE (`e601a496`, 2026-08-16 — same gate round, user-directed immediate fix)

File diff suppressed because one or more lines are too long

View file

@ -15,20 +15,90 @@ ISSUES.md; this doc is the six-page batch.
description text that would confirm is itself broken, GF-2). ALSO: the
open-roll's own rolled heritage shows NO lit dot on entry — every dot
dark in the acdream screenshot.
- **GF-5 Skills page empty.** Nothing renders except the screen-description
textbox — no skill rows, no credits display. (CC5's residual round wired
`TemplateResolver` into the SKILLS page too — yet live rows are absent.)
- **GF-5 Skills page empty — FIXED (Campaign CC gate round
1, Batch A).** Root cause was `CharacterCreationSkillsPage.RebuildRows`
resolving `Templates[0]` (retail's own 3-child bucket-HEADER row,
`0x100002F4`) instead of `Templates[1]` (the REAL skill row,
`0x100002FF`, live-DAT-probe-confirmed 7 children) and requiring the
resolved root to be a `UiButton` (it's a plain container). Byte-traced
against `gmCGSkillsPage::DoSkillRecords @0x004817e0` +
`tagSkillRecord`'s copy-constructor field order to map every child id:
name (`0x10000301`), `pSkillLevelText` (`0x10000302`), `pUpCostText`
(`0x10000303`), `pSkillUpButton` (`0x10000304`), `pSkillDownButton`
(`0x10000305`), `pDownCostText` (`0x10000306`). Fixed to resolve
`Templates[1]`, wire the real per-row up/down arrow buttons to
`ListenToElementMessage @0x004814c0`'s own plain-click dispatch
(`IncreaseSkillLevel`/`DecreaseSkillLevel`), retiring AP-213's click-to-
advance/double-click-retreat single-button substitution (narrowed, not
fully retired — the flat-list-vs-four-bucket half stays). The credits-
caption clobber (`SkillsPage.cs:81-82`, now different line numbers) is
UNCHANGED — Batch C's scope.
- **GF-9 Appearance color swatches do nothing observable.** Clicking a
color produces no visible change (model recolor absent). Could be a dead
dispatch or could be working-but-invisible (AP-216 authored-art swatches
+ a recolor that fails); investigate, don't guess.
- **GF-11a Town description text does not change** when switching towns.
- **GF-13 Summary shows "-Non-admin or Non-envoy" below the name** — an
acdream-only text leak; retail's summary list has no such rows.
- **GF-15 Summary name entry DEAD + Finish unpressable.** Cannot type into
the name field at all; Finish cannot be pressed. Also the field is not
prefilled with retail's `[ Name` placeholder. This blocks the entire
create flow — the gate cannot proceed past Summary.
- **GF-13 Summary shows "-Non-admin or Non-envoy" below the name — FIXED
(Campaign CC gate round 1, Batch A) — this commit.** Root cause: dat
property `0x3B` (Invisible — `UIElement::OnSetAttribute @0x00462d80`
case 8) was never read by the importer at all; elements `0x10000403`
("Non-Admin") and `0x10000494` ("Non-Envoy") both author it `true`
(live-DAT-probe-confirmed, path `0x100003CC > 0x100003D0 > 0x100003D6 >
{0x10000403,0x10000494}`). Blast-radius sweep found **1,083 elements
client-wide** author the same flag — a blanket importer-wide honor is
its own visual gate, filed as ISSUES.md #408. This fix is CHARGEN-SCOPED
ONLY: `ElementInfo.Invisible`/`UiElement.AuthoredInvisible` are pure
data additions (read/stored everywhere, acted on nowhere by the shared
importer path), and `CharacterCreationUiController.HideAuthoredInvisibleElements`
walks its own mounted subtree once at construction and hides whatever
the dat itself marked hidden — by the authored flag, not a hardcoded id
list. Register AP-230 records the scoped-vs-general split.
- **GF-15 Summary name entry DEAD + Finish unpressable — FIXED
(Campaign CC gate round 1, Batch A) — this commit, LIVE-VERIFIED end to
end.** The live-repro investigation (offline `ACDREAM_OPEN_CHARGEN=1`
alone does NOT open the chargen screen — `RuntimeCharacterCreationState`
only activates via `LiveSessionController.StartAsync`'s authenticated-
connect path, `LiveSessionController.cs:791`; the repro required a real
connect to the project's own local ACE test server) showed the FIRST
click into the name field correctly focuses it and typing correctly
lands characters — the modal/pick/focus mechanics the earlier static
investigation examined were never broken. The REAL cause only surfaces
after the FIRST dialog opens: pressing Finish with an empty name
successfully creates the NoName `RetailMessageDialogView`
(`visible=true`, live-DAT-probe-confirmed nonzero popup/message/button
geometry — 400x95 popup, correctly centered) but it renders NOTHING and
silently absorbs every subsequent click across the WHOLE canvas,
including clicks aimed at the name field or Finish button underneath.
Root cause: `CharacterCreationUiController.Tick()` (and
`CharacterManagementUiController.Tick()`) call `UiRoot.BringToFront(Root)`
UNCONDITIONALLY every frame while their screen is open (needed so
chargen stays above the occluded character-management screen
underneath, register AP-229); a dialog's root is a direct sibling of
those screen roots under the same `UiRoot`, and
`RetailWindowManager.BringToFront` is a simple "highest ZOrder among
siblings + 1" — whichever sibling's own `BringToFront` call runs LAST in
a frame wins. `RetailDialogFactory.Tick()` never re-asserted its own
open dialogs' z-order, so the VERY NEXT frame's screen `Tick()` (which
always runs before the dialog factory's own `Tick()` in
`RetailUiRuntime.Tick(double)`'s per-frame sequence) silently buried the
dialog behind the screen's opaque backdrop — while the dialog remained
the registered `UiRoot.Modal` and kept EXCLUSIVE input priority
(`OnMouseDown`'s Modal-vs-bounds gate is independent of render/z-order).
Fixed by having `RetailDialogFactory.Tick()` re-raise every open dialog
(in `_openOrder`, so the most recently opened stays topmost) every tick,
matching retail's real always-on-top dialog behavior. Live-verified the
COMPLETE user sequence after the fix: click name field (focuses), type
(lands), press Finish empty (NoName dialog now VISIBLY renders: "You
must enter a name for this character!"), click OK (dismisses cleanly,
`Modal` clears), click the field again (still focusable/typable). The
`[ Name` prefill question is CLOSED, not a bug: byte-verified neither
`CharGenState::RandomizeCharacter @0x005c6d80` nor
`gmCGSummaryPage::InitializePage @0x0047bbf0` ever write text into the
name field (`InitializePage` only sets the input filter) — retail's
field is genuinely code-empty on a freshly-rolled character, matching
acdream's existing (correct) behavior; the `[ Name` the user saw was
most likely the field's own bracket-style empty-state chrome (GF-2/GF-12
textbox-decoration family), not a missing name-prefill feature.
## Presentation families (retail parity)
@ -72,8 +142,12 @@ ISSUES.md; this doc is the six-page batch.
GF-10 zoom art) — the AP-222 measured mechanism (state media authored
vs applied) across widget kinds.
4. Preview backdrop (GF-7/GF-14) — what gmCG3DView clears/draws.
5. Input routing on Summary (GF-15) — focus/typing path on the stacked
chargen screen.
5. ~~Input routing on Summary (GF-15) — focus/typing path on the stacked
chargen screen.~~ CLOSED: focus/typing routing was never broken (live-
verified); the real cause was `RetailDialogFactory` never re-asserting
its open dialogs' z-order against the chargen/char-management screens'
own per-tick `BringToFront` — see GF-15's own entry above. Batch A
fixed it.
## Process

View file

@ -23,16 +23,86 @@ namespace AcDream.App.UI.Layout;
/// <c>ChargenTableReaderInstalledDatTests</c>) are filtered out via the
/// same two-tier presence check <c>RuntimeCharacterCreationState</c>'s
/// <c>TryGetSkillCost</c> uses.
///
/// <para>
/// <b>GF-5 fix (Campaign CC gate round 1, Batch A, 2026-08-16):</b>
/// <c>RebuildRows</c> used to require <c>Templates[0]</c>'s resolved root to
/// be a <c>UiButton</c> and treat its own Label as the row's whole content —
/// both wrong. Live-DAT-probe-confirmed against the installed EoR dat and
/// <c>gmCGSkillsPage::DoSkillRecords @ 0x004817e0</c>:
/// <c>Templates[0]</c> (<c>0x100002F4</c>, 3 children) is retail's own
/// bucket-HEADER row (Specialized/Trained/UseableUntrained/
/// UnuseableUntrained — unused by this port's flat-list simplification,
/// AP-213), and the REAL skill row is <c>Templates[1]</c>
/// (<c>0x100002FF</c>, a plain container root, 7 children). Byte-traced
/// through <c>DoSkillRecords</c>' own <c>GetChildRecursive</c> calls +
/// <c>tagSkillRecord</c>'s copy-constructor field order
/// (<c>acclient.h</c> struct <c>gmCGSkillsPage::tagSkillRecord</c>):
/// <c>0x10000301</c> = the skill NAME (set once at row build, never
/// refreshed — retail has no per-refresh name write either),
/// <c>0x10000302</c> = <c>pSkillLevelText</c> (the numeric skill SCORE,
/// <c>CharGenState::GetSkillScore</c>), <c>0x10000303</c> =
/// <c>pUpCostText</c>, <c>0x10000306</c> = <c>pDownCostText</c>,
/// <c>0x10000304</c> = <c>pSkillUpButton</c> (fires
/// <c>IncreaseSkillLevel</c> on plain click,
/// <c>ListenToElementMessage @0x004814c0</c> case <c>0x10000304</c>),
/// <c>0x10000305</c> = <c>pSkillDownButton</c> (fires
/// <c>DecreaseSkillLevel</c>, same dispatcher's case <c>0x10000305</c>).
/// Both buttons fire on a PLAIN click, not click-vs-double-click on one
/// shared row — the row now wires exactly that, retiring AP-213's own
/// click-to-advance/double-click-retreat single-button substitution (the
/// row's still-simplified flat-list-vs-four-bucket half is untouched and
/// stays registered).
/// </para>
/// </summary>
internal sealed class CharacterCreationSkillsPage : IDisposable
{
/// <summary>Retail's own row-name id (set once at row build; retail
/// never re-writes it on refresh either — <c>DoSkillRecords</c>'
/// <c>UIElement_Text::SetText(id_2, &amp;var_138)</c> at
/// <c>0x00481d5d</c> runs OUTSIDE the per-refresh <c>SetSkillText</c>
/// call).</summary>
private const uint RowNameTextId = 0x10000301u;
/// <summary><c>tagSkillRecord::pSkillLevelText</c> — the numeric skill
/// SCORE (<c>SetSkillText @0x00480600</c>'s
/// <c>CharGenState::GetSkillScore</c> call, "%d" format).</summary>
private const uint RowLevelTextId = 0x10000302u;
/// <summary><c>tagSkillRecord::pUpCostText</c>.</summary>
private const uint RowUpCostTextId = 0x10000303u;
/// <summary><c>tagSkillRecord::pDownCostText</c>.</summary>
private const uint RowDownCostTextId = 0x10000306u;
/// <summary><c>tagSkillRecord::pSkillUpButton</c> —
/// <c>ListenToElementMessage</c>'s case <c>0x10000304</c> fires
/// <c>IncreaseSkillLevel</c> on a plain click (<c>idMessage==1</c>).</summary>
private const uint RowUpButtonId = 0x10000304u;
/// <summary><c>tagSkillRecord::pSkillDownButton</c> — same dispatcher's
/// case <c>0x10000305</c> fires <c>DecreaseSkillLevel</c>.</summary>
private const uint RowDownButtonId = 0x10000305u;
/// <summary>One built skill row: the resolved <c>Templates[1]</c>
/// subtree plus the child widgets <see cref="RefreshRowValues"/> needs
/// every tick, resolved once at build time rather than re-walked per
/// refresh.</summary>
private readonly record struct SkillRow(
UiElement Root,
uint SkillId,
UiText? LevelText,
UiText? UpCostText,
UiText? DownCostText,
UiButton? UpButton,
UiButton? DownButton);
private readonly CharacterCreationRuntimeBindings _bindings;
private readonly UiTemplateListBox? _list;
private readonly UiButton? _credits;
private readonly UiText? _infoTitle;
private readonly UiText? _infoText;
private readonly List<UiButton> _rows = [];
private readonly Dictionary<UiButton, uint> _rowSkillIds = [];
private readonly List<SkillRow> _rows = [];
private uint _lastHeritageId;
private bool _rowsBuilt;
private bool _disposed;
@ -55,6 +125,11 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
// faithful substitute is the button's own Label, which is exactly
// the mechanism our factory already uses to surface a consumed
// Type-12 child's text (register AD-103).
//
// GF-5 note: this clobbers the button's authored "Available Skill
// Credits" caption (retail's own m_pCreditsMeter is a SEPARATE
// widget from any caption text) — left as-is per the gate-round
// scope (Batch C owns the caption fix).
_credits = UiElement.FindDescendant(pageRoot, 0x100003F9u) as UiButton;
_infoTitle = UiElement.FindDescendant(pageRoot, 0x100003FBu) as UiText;
_infoText = UiElement.FindDescendant(pageRoot, 0x100003FCu) as UiText;
@ -71,12 +146,8 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
_rowsBuilt = true;
}
foreach (UiButton row in _rows)
{
if (!_rowSkillIds.TryGetValue(row, out uint skillId))
continue;
row.Label = FormatSkillLabel(view, snapshot.HeritageId, skillId);
}
foreach (SkillRow row in _rows)
RefreshRowValues(row, view, snapshot);
if (_credits is { } credits)
credits.Label = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture);
@ -84,45 +155,94 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
private void RebuildRows(IRuntimeCharacterCreationView view, uint heritageId)
{
foreach (UiButton row in _rows)
foreach (SkillRow row in _rows)
{
row.OnClick = null;
row.OnDoubleClick = null;
if (row.UpButton is not null) row.UpButton.OnClick = null;
if (row.DownButton is not null) row.DownButton.OnClick = null;
}
_rows.Clear();
_rowSkillIds.Clear();
_list?.Flush();
if (_list is null
|| _list.Templates.Count == 0
|| _list.Templates.Count < 2
|| _list.TemplateResolver is null
|| !view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage))
{
return;
}
UiTemplateListEntry template = _list.Templates[0];
// Templates[1] (0x100002FF) is the REAL skill row — see this
// class's own doc comment for the full byte trace.
UiTemplateListEntry template = _list.Templates[1];
for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++)
{
if (!IsCostable(heritage, view.Options, skillId))
continue;
if (_list.TemplateResolver(template.TemplateLayoutId, template.TemplateElementId)
is not UiButton row)
is not { } rowRoot)
{
continue;
}
_list.AddPrebuiltRow(row);
row.Enabled = true;
row.SuppressSelfToggle = true;
_list.AddPrebuiltRow(rowRoot);
if (UiElement.FindDescendant(rowRoot, RowNameTextId) is UiText nameText)
SetLine(nameText, ItemAppraisalTextFormatter.SkillName((int)skillId));
UiText? levelText = UiElement.FindDescendant(rowRoot, RowLevelTextId) as UiText;
UiText? upCostText = UiElement.FindDescendant(rowRoot, RowUpCostTextId) as UiText;
UiText? downCostText = UiElement.FindDescendant(rowRoot, RowDownCostTextId) as UiText;
UiButton? upButton = UiElement.FindDescendant(rowRoot, RowUpButtonId) as UiButton;
UiButton? downButton = UiElement.FindDescendant(rowRoot, RowDownButtonId) as UiButton;
uint capturedSkillId = skillId;
row.OnClick = () => Advance(capturedSkillId);
row.OnDoubleClick = () => Retreat(capturedSkillId);
_rows.Add(row);
_rowSkillIds[row] = skillId;
if (upButton is not null)
upButton.OnClick = () => Advance(capturedSkillId);
if (downButton is not null)
downButton.OnClick = () => Retreat(capturedSkillId);
_rows.Add(new SkillRow(
rowRoot, skillId, levelText, upCostText, downCostText, upButton, downButton));
}
}
private void RefreshRowValues(
SkillRow row,
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot)
{
ChargenSkillAdvancementClass level = view.GetSkillLevel(row.SkillId);
(int trainedCost, int specializedCost) = GetCosts(view, snapshot.HeritageId, row.SkillId);
uint score = _bindings.GetSkillScore?.Invoke(row.SkillId, snapshot.Attributes, level) ?? 0u;
if (row.LevelText is { } levelText)
SetLine(levelText, score.ToString(CultureInfo.InvariantCulture));
// SetSkillText @0x00480600's own per-state up/down cost pair: at
// Untrained, up=trainCost (down blank, nothing below Untrained); at
// Trained, up=(specCost-trainCost), down=trainCost; at Specialized,
// up=blank (nothing above Specialized), down=(specCost-trainCost).
// Retail also blanks a cost >= 999 (data_794320, an empty
// PStringBase) instead of showing the raw number.
(int? upCost, int? downCost) = level switch
{
ChargenSkillAdvancementClass.Specialized =>
((int?)null, (int?)(specializedCost - trainedCost)),
ChargenSkillAdvancementClass.Trained =>
((int?)(specializedCost - trainedCost), (int?)trainedCost),
_ => ((int?)trainedCost, (int?)null),
};
if (row.UpCostText is { } upCostText)
SetLine(upCostText, FormatCost(upCost));
if (row.DownCostText is { } downCostText)
SetLine(downCostText, FormatCost(downCost));
}
private static string FormatCost(int? cost) =>
cost is int c && c < 999 ? c.ToString(CultureInfo.InvariantCulture) : string.Empty;
private static void SetLine(UiText text, string content) =>
text.LinesProvider = () => [new UiText.Line(content, text.DefaultColor)];
/// <summary>Same dictionary-presence gate as
/// <c>RuntimeCharacterCreationState.TryGetSkillCost</c> — heritage list
/// first, global SkillTable fallback.</summary>
@ -133,19 +253,6 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
heritage.SkillCostsBySkillId.ContainsKey(skillId)
|| options.GlobalSkillCostsBySkillId.ContainsKey(skillId);
private string FormatSkillLabel(
IRuntimeCharacterCreationView view,
uint heritageId,
uint skillId)
{
string name = ItemAppraisalTextFormatter.SkillName((int)skillId);
ChargenSkillAdvancementClass level = view.GetSkillLevel(skillId);
(int trainedCost, int specializedCost) = GetCosts(view, heritageId, skillId);
return string.Create(
CultureInfo.InvariantCulture,
$"{name}: {level} (T{trainedCost}/S{specializedCost})");
}
private static (int Trained, int Specialized) GetCosts(
IRuntimeCharacterCreationView view,
uint heritageId,
@ -161,10 +268,9 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
return (0, 0);
}
/// <summary>OnClick: one step up (Untrained/Inactive -&gt; Trained,
/// Trained -&gt; Specialized). Simplified from retail's separate
/// Increase/Decrease affordances (<c>IncreaseSkillLevel</c>/
/// <c>DecreaseSkillLevel</c>) to one click target per row.</summary>
/// <summary><c>pSkillUpButton</c> click: <c>IncreaseSkillLevel
/// @0x00480ca0</c> — Untrained/Inactive -&gt; Trained,
/// Trained -&gt; Specialized.</summary>
private void Advance(uint skillId)
{
if (_disposed)
@ -177,8 +283,9 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
_bindings.SpecializeSkill(skillId);
}
/// <summary>OnDoubleClick: one step down (Specialized -&gt; Trained,
/// Trained -&gt; Untrained).</summary>
/// <summary><c>pSkillDownButton</c> click: <c>DecreaseSkillLevel
/// @0x00480d60</c> — Specialized -&gt; Trained,
/// Trained -&gt; Untrained.</summary>
private void Retreat(uint skillId)
{
if (_disposed)
@ -196,13 +303,12 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
if (_disposed)
return;
_disposed = true;
foreach (UiButton row in _rows)
foreach (SkillRow row in _rows)
{
row.OnClick = null;
row.OnDoubleClick = null;
if (row.UpButton is not null) row.UpButton.OnClick = null;
if (row.DownButton is not null) row.DownButton.OnClick = null;
}
_rows.Clear();
_rowSkillIds.Clear();
_list?.Flush();
if (_list is not null)
_list.TemplateResolver = null;

View file

@ -308,6 +308,41 @@ internal sealed class CharacterCreationUiController : IDisposable
_appearanceTab.OnClick = () => ApplyProgressState(Page.Appearance);
_townTab.OnClick = () => ApplyProgressState(Page.Town);
_summaryTab.OnClick = () => ApplyProgressState(Page.Summary);
// GF-13 (Campaign CC gate round 1, Batch A): honor the authored
// Invisible flag (dat property 0x3B) chargen-scoped only — see
// HideAuthoredInvisibleElements's own doc comment.
HideAuthoredInvisibleElements(Root);
}
/// <summary>
/// GF-13 (Campaign CC gate round 1, Batch A). The user's live gate
/// reported an acdream-only "-Non-admin or Non-envoy" text leak below the
/// Summary name field. Root cause: elements <c>0x10000403</c> ("Non-
/// Admin") and <c>0x10000494</c> ("Non-Envoy") author dat property
/// <c>0x3B</c> (Invisible) = <see langword="true"/> — retail's
/// <c>UIElement::OnSetAttribute @0x00462d80</c> case 8
/// (<c>GetPropertyName()-0x33 == 8</c>, property id <c>0x3B</c>) hides any
/// element authoring it via <c>SetVisible(value == 0)</c>. acdream's
/// shared <see cref="LayoutImporter"/> never read this property at all
/// (it now does, into <see cref="ElementInfo.Invisible"/> /
/// <see cref="UiElement.AuthoredInvisible"/>, a pure data addition), so
/// every one of the 1,083 elements client-wide that author it rendered
/// regardless. A blanket importer-wide honor is its own separately-gated
/// visual sweep (docs/ISSUES.md #408) — this method is the NARROW,
/// chargen-scoped fix: walk this screen's own mounted subtree once at
/// construction and hide anything the dat itself marked hidden, by the
/// AUTHORED FLAG rather than a hardcoded id list, so any other
/// authored-invisible element under this root (not just the two the user
/// happened to see) is honored the same way. Register AP-230 records the
/// scoped-vs-general split.
/// </summary>
private static void HideAuthoredInvisibleElements(UiElement element)
{
if (element.AuthoredInvisible)
element.Visible = false;
foreach (UiElement child in element.Children)
HideAuthoredInvisibleElements(child);
}
internal UiElement Root => _layout.Root;

View file

@ -225,6 +225,25 @@ public sealed class ElementInfo
/// </summary>
public uint ScrollbarElementId;
/// <summary>
/// GF-13 (Campaign CC gate round 1, Batch A): the authored Invisible flag
/// from dat property <c>0x3B</c> (<c>BoolBaseProperty</c>). Retail
/// <c>UIElement::OnSetAttribute @0x00462d80</c>'s case 8
/// (<c>BaseProperty::GetPropertyName(esi) - 0x33 == 8</c>, i.e. property
/// id <c>0x33 + 8 = 0x3B</c>): <c>this-&gt;vtable-&gt;SetVisible(value == 0)</c> —
/// an authored <c>true</c> HIDES the element at construction. Populated the
/// same way as <see cref="TabTable"/>/<see cref="ScrollbarElementId"/>
/// (recomputed fresh from the effective merged state every call), but this
/// is a PURE DATA ADDITION: the shared <see cref="LayoutImporter"/>/
/// <see cref="DatWidgetFactory"/> path does not act on it. 1,083 elements
/// author this flag client-wide (docs/ISSUES.md #408, its own separately-
/// gated general-honor item) — only screens that explicitly walk their own
/// mounted subtree and check this field may hide elements by it (see
/// <c>CharacterCreationUiController</c>'s chargen-scoped honor, register
/// AP-230).
/// </summary>
public bool Invisible;
/// <summary>
/// Resolves a property for a state using retail's DirectState-as-base rule. A
/// named state's key overrides DirectState by presence, including false/zero.
@ -529,6 +548,16 @@ public static class ElementReader
// (DataId), UnsignedValue 100683031/100683033 == 0x06004D17/0x06004D19).
info.LedCheckedSprite = ReadReferencedElementId(info, 0x10000082u);
info.LedUncheckedSprite = ReadReferencedElementId(info, 0x10000083u);
// GF-13: Invisible (0x3B), BoolBaseProperty. Retail
// UIElement::OnSetAttribute @0x00462d80 case 8 — SetVisible(value == 0),
// so an authored true HIDES the element. Read via the same
// TryGetEffectiveBool the DirectState/default-state resolution rules
// already use for every other canonical-projection property above.
if (info.TryGetEffectiveBool(0x3Bu, out bool invisible))
{
info.Invisible = invisible;
}
}
private static List<UiTabTableEntry> ReadTabTable(ElementInfo info)

View file

@ -117,6 +117,10 @@ public static class LayoutImporter
var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve, stringResolve);
if (w is null) return null; // Type-12 style prototype — skip
// GF-13: pure data passthrough — see UiElement.AuthoredInvisible's own
// doc comment for why this does NOT set Visible here.
w.AuthoredInvisible = info.Invisible;
if (info.Id != 0) byId[info.Id] = w;
// Behavioral widgets that draw their full appearance + reproduce their dat

View file

@ -255,11 +255,53 @@ public sealed class RetailDialogFactory : IDisposable
return false;
}
/// <summary>
/// GF-15 fix (Campaign CC gate round 1, Batch A, 2026-08-16). Live-repro-
/// confirmed root cause: <c>CharacterCreationUiController.Tick</c> and
/// <c>CharacterManagementUiController.Tick</c> both call
/// <c>UiRoot.BringToFront(Root)</c> UNCONDITIONALLY on every frame while
/// their screen is open — a per-tick "stay on top of my sibling screen"
/// assertion (needed so chargen never bleeds input to the occluded
/// char-management screen underneath it, register AP-229). A dialog this
/// factory opens is ALSO a direct sibling of those screen roots under
/// the same <c>UiRoot</c> (<c>_host.AddChild(view.Root)</c> in
/// <see cref="TryCreateDialog"/>), competing for the SAME z-order slot.
/// <see cref="RetailWindowManager.BringToFront"/> is a simple "highest
/// ZOrder among <c>_root</c>'s direct children + 1" — whichever sibling's
/// own <c>BringToFront</c> call runs LAST in a frame wins the top slot.
/// Before this fix, this method never re-asserted a dialog's own
/// z-order after the one-time raise in <see cref="TryCreateDialog"/>, so
/// the VERY NEXT frame's screen <c>Tick()</c> (which always runs before
/// this factory's own <c>Tick()</c> in
/// <c>RetailUiRuntime.Tick(double)</c>'s per-frame sequence) silently
/// buried the dialog behind the screen's opaque backdrop — while the
/// dialog remained the registered <see cref="UiRoot.Modal"/> and kept
/// EXCLUSIVE input priority (<c>OnMouseDown</c>'s Modal-vs-bounds gate is
/// independent of render/z-order). The user-visible symptom: press
/// Finish empty → the NoName dialog is created successfully
/// (<c>visible=true</c>, correct geometry, live-DAT-probe-confirmed) but
/// renders NOTHING, and every subsequent click across the WHOLE canvas
/// resolves to the invisible dialog root instead of the name field or
/// Finish button underneath — both GF-15 symptoms from one mechanism.
/// Retail's real dialogs are always-on-top overlays by construction (a
/// separate presentation layer, not a z-ordered sibling of the game UI);
/// re-asserting every open dialog's z-order here, every tick, in
/// <see cref="_openOrder"/> order (so the MOST RECENTLY opened dialog —
/// the same one <see cref="RefreshModal"/> already treats as
/// authoritative — ends up on top) reproduces that invariant without
/// touching either screen controller's own already-verified raise.
/// </summary>
public void Tick()
{
RetryFailedDialogs();
foreach (DialogInfo info in _openOrder.ToArray())
info.View?.Tick();
{
if (info.View is { } view)
{
_host.BringToFront(view.Root);
view.Tick();
}
}
}
/// <summary>

View file

@ -57,6 +57,19 @@ public abstract class UiElement
/// <summary>Human-readable name for debugging / FindByName.</summary>
public string? Name { get; init; }
/// <summary>
/// GF-13 (Campaign CC gate round 1, Batch A): mirrors
/// <c>ElementInfo.Invisible</c> (dat property <c>0x3B</c>) — a PURE DATA
/// PASSTHROUGH set by <c>LayoutImporter.BuildWidget</c> at construction.
/// The shared importer does NOT act on this flag (1,083 elements author
/// it client-wide, docs/ISSUES.md #408); it exists only so a screen that
/// owns its own mounted subtree can honor it explicitly, the way
/// <c>CharacterCreationUiController</c> does for the chargen screen
/// (register AP-230). Reading this never changes <see cref="Visible"/> by
/// itself.
/// </summary>
public bool AuthoredInvisible { get; internal set; }
private readonly Dictionary<string, UiCursorMedia> _stateCursors = new();
/// <summary>Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState.</summary>

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