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:
parent
6699e0f88c
commit
1d9de5e095
11 changed files with 823 additions and 78 deletions
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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, &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 -> Trained,
|
||||
/// Trained -> 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 -> Trained,
|
||||
/// Trained -> 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 -> Trained,
|
||||
/// Trained -> Untrained).</summary>
|
||||
/// <summary><c>pSkillDownButton</c> click: <c>DecreaseSkillLevel
|
||||
/// @0x00480d60</c> — Specialized -> Trained,
|
||||
/// Trained -> 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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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->vtable->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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 > 0x100003D0 >
|
||||
/// 0x100003D6 > {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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue