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

@ -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>