fix(ui): OP2 rework — dormant UiDatElement subclasses, fixed Panel/CheckboxBitfield64 mechanism

OP2 (df9c7a35) was double-REJECTed: an unconditional Type-8/Type-5 factory
mapping silently re-classed 15 elements across 7 shipped panels (vendor
backdrop lost its fill, character/spellbook roots stopped passing clicks
through, combat gained a phantom import-time tab takeover, ten ListBoxes
gained a spurious hit-testable viewport) because the stale 27 pre-existing
fixtures never exercised the new fields — and the mechanism itself cited a
nonexistent "UIElement_TabControl" class, inverted UiCheckboxBitfield64's
checked-state predicate, and synthesized fake per-row geometry instead of
using the widget's own authored template.

Shape change: UiTabPanel (renamed from UiTabControl) and UiTemplateListBox
now derive from UiDatElement (unsealed) and stay DORMANT by default — an
imported Type-8/Type-5 element gets authored-media drawing, ClickThrough
generic-decoration default, and IUiDatStateful propagation identical to the
pre-OP2 UiDatElement fallback, with zero import-time side effects. The
factory's Type-8/Type-5 arms are unconditional again (no more guard whose
premise the blast-radius sweep proved false), because dormancy makes an
unactivated instance behaviorally indistinguishable from the old fallback.
UiTabPanel.ActivateTabBehavior() and UiTemplateListBox's lazy viewport
creation are the explicit, controller-driven opt-ins Campaign OP slice OP3+
will call; today nothing does, so the four pre-existing shipped Type-8
hosts (character/spellbook/vendor/combat) and ten pre-existing Type-5
ListBoxes keep their pre-OP2 behavior exactly. Filed AD-73 for this
dormant-vs-retail's-unconditional-activation adaptation.

Mechanism fixes (docs/research/2026-08-11-op2-review-mechanism.md):
- UiTabPanel cites UIElement_Panel (Type 8 is UIElement_Panel; no
  UIElement_TabControl exists in the PDB), resolves buttons/pages via a
  GetChildRecursive-equivalent descendant search (not direct-children-only),
  performs no switch when no entry authors 0x32 (deleted the _tabs[0]
  fallback), and surfaces unresolved tab-table entries via UnresolvedEntries
  + a diagnostic line instead of a silent no-op.
- ElementReader.ReadTabTable skips entries missing 0x30/0x31, matching
  retail's SetupTabPageHash @0x0046C2E0 entry filter.
- UiCheckboxBitfield64 now builds every row from its OWN authored template
  (property 0x64 -> {0x2100002B, 0x10000521}) via AddItemFromTemplateList,
  deleting the synthesized ElementInfo + invented RowHeight=14 — matching
  retail's CreateChildren @0x00485DF0, which is itself a UIElement_ListBox
  call. IsSet is now retail's ANY-bit-set predicate (Refresh @0x004859C0),
  not all-bits-set. TS-72 retired: the click-toggle bit math is now fully
  decomp-confirmed (SetBitsOnOrOff via ListenToElementMessage @0x00485AE0).

Regenerated all 32 UI fixtures against real DAT (ACDREAM_REGENERATE_UI_FIXTURES=1)
and committed them — 27 pre-existing fixtures now carry Outline/OutlineColor/
TabTable/TemplateList/ScrollbarElementId; the 5 Options fixtures were already
current. Updated EffectsUiControllerTests' now-correct UiTemplateListBox
class-identity assertion. Added: 6 built-widget behavior pins for all five
pre-existing Type-8 elements + a representative Type-5 element the dormancy
model protects (OP2ReworkBlastRadiusConformanceTests.cs); 5 reader-level
tests driving ReadTabTable/ReadTemplateList/the 0x72 reader from raw
property bags (ElementReaderTests.cs); a multi-bit-mask UiCheckboxBitfield64
test proving the any-bit predicate (the prior single-bit test couldn't
distinguish it from all-bits); an activation-idempotency test and a
before-activation click-is-inert test for UiTabPanel.

Full Release suite: 12,868 passed / 4 skipped / 0 failed (baseline 12,853/4/0
post-OP1-fixes; +15 net new tests, zero regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 01:06:31 +02:00
parent 8a05fda445
commit b236a44279
40 changed files with 6249 additions and 1145 deletions

View file

@ -93,21 +93,30 @@ public static class DatWidgetFactory
or IndicatorBarController.VitaeClassId => BuildButton(
info, resolve, elementFont, fontResolve, stringResolve),
// gmUIElement_*Indicator custom button classes
// UIElement_ListBox (Type 5) with an authored row-template list (dat
// property 0x64) — the Options panel's Character/Chat/Config ListBoxes
// (docs/research/2026-08-10-options-panel-structure.md §1.5). Gated on
// info.TemplateList.Count > 0 so an ORDINARY Type-5 ListBox that authors
// no template array (none currently reach this factory — vendor's category
// dropdown is drawn procedurally by UiMenu.Scrollable, not imported as a
// live Type-5 widget) keeps falling to the generic UiDatElement fallback
// unchanged (OP2 acceptance: no behavior change to any existing widget).
5 when info.TemplateList.Count > 0 => new UiTemplateListBox(
info.TemplateList, info.ScrollbarElementId),
// UIElement_ListBox (Type 5). OP2 rework (docs/research/2026-08-11-op2-
// review-blast.md MUST-FIX 2): every pre-existing Type-5 element that
// reaches this factory already authors a non-empty row-template array
// (dat property 0x64) — the original "none currently reach this factory"
// premise was false. UiTemplateListBox now derives from UiDatElement and
// stays DORMANT (no viewport, no behavior change) until a controller calls
// AddItemFromTemplateList, so mapping every Type-5 element unconditionally
// is safe: an element with an empty TemplateList behaves EXACTLY like the
// pre-OP2 UiDatElement fallback (media drawn, ClickThrough=true, state
// propagation) because nothing ever activates it.
5 => new UiTemplateListBox(info, resolve, info.TemplateList, info.ScrollbarElementId),
6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
// UIElement_TabControl (Type 8) — the Options panel's tab strip (dat
// property 0x2E; same research doc §1.3/§10.1).
8 => new UiTabControl(info.TabTable),
// UIElement_Panel (Type 8) — retail's tab-strip host (dat property 0x2E;
// research doc §1.3/§10.1). OP2 rework (docs/research/2026-08-11-op2-
// review-mechanism.md MUST-FIX 5): Type 8 is UIElement_Panel, NOT a class
// called "UIElement_TabControl" (that name does not exist in the named-
// retail PDB). UiTabPanel derives from UiDatElement and stays DORMANT (see
// its class doc) until a controller calls ActivateTabBehavior(), so mapping
// every Type-8 element unconditionally is safe for the same reason as the
// Type-5 arm above — including the vendor backdrop 0x1000008D, which has NO
// tab table and now keeps its authored DirectState fill via the UiDatElement
// base instead of losing it to a bare UiElement with no OnDraw.
8 => new UiTabPanel(info, resolve, info.TabTable),
9 => BuildResizeGrip(info, resolve), // UIElement_Resizebar (reg 0x0046B920)
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
@ -142,11 +151,15 @@ public static class DatWidgetFactory
// ChatWindowController wires the channel menu.
0x10000038u => new UiMenu(),
// UIOption_CheckboxBitfield64 (Type 0x10000044): the Chat tab's per-window
// text-filter block. Its authored template (0x10000520) carries no children
// or media at all — every row is added at runtime via AddChild(lowMask,
// highMask, label, tooltip), matching retail's own
// gmChatOptionsUI::AddCheckboxBitfield64Option call pattern (research doc §5.2).
0x10000044u => BuildCheckboxBitfield64(resolve, elementFont),
// text-filter block. OP2 rework (docs/research/2026-08-11-op2-review-
// mechanism.md MUST-FIX 4): the authored template (0x10000520) DOES author
// content — its own row-template list (dat property 0x64 -> {0x2100002B,
// 0x10000521}) — and retail CreateChildren @0x00485DF0 builds every row
// through AddItemFromTemplateList(this, 0, nullptr), matching retail's own
// gmChatOptionsUI::AddCheckboxBitfield64Option call pattern (research doc
// §5.2). AddChild(lowMask, highMask, label, tooltip) resolves that SAME
// template per row instead of synthesizing a fake ElementInfo.
0x10000044u => new UiCheckboxBitfield64(info.TemplateList),
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
};
@ -800,21 +813,6 @@ public static class DatWidgetFactory
return button;
}
/// <summary>
/// Builds a blank <see cref="UiCheckboxBitfield64"/> seeded with the sprite
/// resolver and dat font a page controller's later
/// <see cref="UiCheckboxBitfield64.AddChild(ulong, ulong, string, string?)"/>
/// calls need — the template authors no rows, so there is nothing else to read
/// from <c>ElementInfo</c> at import time.
/// </summary>
private static UiCheckboxBitfield64 BuildCheckboxBitfield64(
Func<uint, (uint, int, int)> resolve, UiDatFont? elementFont)
=> new()
{
SpriteResolve = resolve,
LabelFont = elementFont,
};
private static ElementInfo? FindStatefulFaceChild(ElementInfo info)
=> FindStatefulFaceChildren(info).FirstOrDefault();

View file

@ -517,6 +517,13 @@ public static class ElementReader
if (item.Kind != UiPropertyKind.Struct) continue;
uint buttonId = ReadStructMemberId(item.StructValue, 0x30u);
uint pageId = ReadStructMemberId(item.StructValue, 0x31u);
// Retail UIElement_Panel::SetupTabPageHash @0x0046C2E0 skips an entry whose
// InqProperty(0x30) (@0x0046C3E5) or InqProperty(0x31) (@0x0046C3FC) is
// missing — a partial entry never enters m_TabPageHash. ReadStructMemberId
// returns 0 for a missing/uncoercible member, and 0 is never a valid dat
// element id, so treat it the same way: skip, don't admit a zero-id entry
// that could still participate in SwitchTo and hide a page.
if (buttonId == 0u || pageId == 0u) continue;
bool isDefault = item.StructValue.TryGetValue(0x32u, out var flag)
&& flag.Kind == UiPropertyKind.Bool
&& flag.BoolValue;

View file

@ -169,7 +169,7 @@ public static class LayoutImporter
stateful.TrySetRetailState(stateful.ActiveRetailStateId);
// See IUiChildrenAttachedListener: a widget that must resolve OTHER children by
// dat element id (e.g. UiTabControl's tab table) can only do so once its subtree
// dat element id (e.g. UiTabPanel's tab table) can only do so once its subtree
// is actually attached, which just happened above.
if (w is IUiChildrenAttachedListener childrenAttached)
childrenAttached.OnChildrenAttached();

View file

@ -27,8 +27,20 @@ namespace AcDream.App.UI.Layout;
/// <see cref="AcDream.App.Rendering.TextureCache.UploadRgba8"/> sets
/// <c>GL_REPEAT</c> on both S and T, so vertical tiling is always active.
/// </para>
///
/// <para>
/// <b>OP2 rework (2026-08-11):</b> unsealed so behavioral Type-8/Type-5 widgets
/// (<see cref="UiTabPanel"/>, <see cref="UiTemplateListBox"/>) can subclass it and stay
/// DORMANT by default — every element of those two dat Types that reaches
/// <see cref="DatWidgetFactory"/> gets this exact media-draw / <c>ClickThrough</c> /
/// <see cref="IUiDatStateful"/> behavior unless a controller explicitly opts a specific
/// instance into its behavioral mechanism. This is what makes the unconditional Type-8/
/// Type-5 factory mappings safe for the pre-existing shipped panels the OP2 REJECT
/// findings named (`docs/research/2026-08-11-op2-review-blast.md`,
/// `docs/research/2026-08-11-op2-review-mechanism.md`).
/// </para>
/// </summary>
public sealed class UiDatElement : UiElement, IUiDatStateful
public class UiDatElement : UiElement, IUiDatStateful
{
// DrawModeType enum values from DatReaderWriter.Enums.
// See docs/research/2026-06-15-layoutdesc-format.md §6.
@ -39,13 +51,16 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
private const int DrawAlphablend = 3;
#pragma warning restore IDE0051
private readonly ElementInfo _info;
/// <summary>Protected so subclasses (e.g. <see cref="UiTabPanel"/>,
/// <see cref="UiTemplateListBox"/>) can read authored state/media for their own
/// mechanism-specific fields without a second copy of the merged snapshot.</summary>
protected readonly ElementInfo Info;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
/// <summary>The dat element id from <see cref="ElementInfo.Id"/>. Exposed so controllers
/// can identify which logical element a UiDatElement represents when walking subtrees
/// (e.g. footer state groups that appear once per tab page but share the same dat id).</summary>
public uint ElementId => _info.Id;
public uint ElementId => Info.Id;
/// <summary>Which state name to render. <c>""</c> = the unnamed DirectState.
/// Falls back to DirectState if the named state is absent.</summary>
@ -57,7 +72,7 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
{
if (string.IsNullOrEmpty(ActiveState))
return UiStateInfo.DirectStateId;
foreach (var (id, state) in _info.States)
foreach (var (id, state) in Info.States)
if (string.Equals(state.Name, ActiveState, StringComparison.Ordinal))
return id;
return UiButtonStateMachine.TryStateId(ActiveState, out uint standard)
@ -73,12 +88,12 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
UiStateInfo? selectedState = null;
if (stateId == UiStateInfo.DirectStateId)
{
if (!_info.States.TryGetValue(stateId, out selectedState)
&& !_info.StateMedia.ContainsKey(""))
if (!Info.States.TryGetValue(stateId, out selectedState)
&& !Info.StateMedia.ContainsKey(""))
return false;
ActiveState = "";
}
else if (_info.States.TryGetValue(stateId, out selectedState))
else if (Info.States.TryGetValue(stateId, out selectedState))
{
ActiveState = selectedState.Name;
}
@ -87,7 +102,7 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
string stateName = UiButtonStateMachine.StateName(stateId);
if (string.IsNullOrEmpty(stateName))
stateName = RetailUiStateIds.StateName(stateId);
if (string.IsNullOrEmpty(stateName) || !_info.StateMedia.ContainsKey(stateName))
if (string.IsNullOrEmpty(stateName) || !Info.StateMedia.ContainsKey(stateName))
return false;
ActiveState = stateName;
}
@ -106,7 +121,7 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
public UiDatElement(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
{
_info = info;
Info = info;
_resolve = resolve;
ClickThrough = true; // generic decoration; behavioral widgets opt back in
@ -133,8 +148,8 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
/// </summary>
// exposed for unit testing
public (uint File, int DrawMode) ActiveMedia()
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m
: _info.StateMedia.TryGetValue("", out var d) ? d
=> Info.StateMedia.TryGetValue(ActiveState, out var m) ? m
: Info.StateMedia.TryGetValue("", out var d) ? d
: (0u, 0);
/// <summary>Optional click handler. Set by a controller for interactive dat

View file

@ -7,37 +7,50 @@ namespace AcDream.App.UI;
/// <summary>
/// Retail <c>UIOption_CheckboxBitfield64</c> (Type <c>0x10000044</c>) — a block of
/// individually toggleable mask checkboxes, built ENTIRELY at runtime. Its authored
/// dat template (<c>0x10000520</c> in <c>0x2100002B</c>, 272x100) carries NO children
/// and NO media — verified against the regenerated <c>options_2100002B.json</c>
/// fixture. Retail C++ populates it by calling <c>AddChild(lowMask, highMask,
/// labelId, tooltipId)</c> once per checkbox (research doc §5.2:
/// <c>gmChatOptionsUI::InitOptions</c> / <c>AddCheckboxBitfield64Option</c>), N times
/// per window, then <c>CreateChildren()</c>. This widget ports that shape:
/// <see cref="AddChild(ulong, ulong, string, string?)"/> appends one row, reusing
/// <see cref="UiButton"/> (with retail's toggle-behavior property <c>0x0B</c> seeded)
/// for the clickable/labelled row itself rather than drawing anything new —
/// consistent with the campaign contract's "compose existing primitives" directive.
/// individually toggleable mask checkboxes (the Chat tab's per-window text-filter
/// block).
///
/// <para>
/// <b>OP2 rework (2026-08-11 — `docs/research/2026-08-11-op2-review-mechanism.md`
/// MUST-FIX 3/4):</b> the OP2 slice's class doc claimed the authored template
/// (<c>0x10000520</c> in <c>0x2100002B</c>) "carries NO children and NO media" and
/// synthesized a fake per-row <see cref="ElementInfo"/> (<c>Type=1</c>, invented
/// <c>RowHeight=14</c>) for every added row. Both claims were refuted by the committed
/// fixture: <c>0x10000520</c> authors its OWN row-template list (dat property
/// <c>0x64</c> → <c>{0x2100002B, 0x10000521}</c>), and retail
/// <c>UIOption_CheckboxBitfield64::CreateChildren @0x00485DF0</c> builds every row by
/// calling <c>UIElement_ListBox::AddItemFromTemplateList(this, 0, nullptr)</c> in a
/// loop — i.e. <c>UIOption_CheckboxBitfield64</c> genuinely IS a
/// <c>UIElement_ListBox</c>: it calls the ListBox mechanism on itself
/// (<c>AddItemFromTemplateList</c>/<c>GetItem</c>/<c>CalculatePaperSize</c>/
/// <c>ListenToElementMessage</c>). The template <c>0x10000521</c> is a 272×20 container
/// holding checkbox <c>0x10000219</c> (260×14, Type 1) which itself holds the 13×13
/// five-state LED child <c>0x10000328</c> — every row's art/font/insets now come from
/// that authored subtree via <see cref="TemplateResolver"/>, the SAME seam
/// <see cref="UiTemplateListBox"/> exposes (this class does not wrap a
/// <see cref="UiTemplateListBox"/> instance because retail's block does not itself
/// scroll — the ENCLOSING Chat page ListBox scrolls the whole option list, and this
/// block is one composite row within it — but it reuses the identical
/// resolve-and-stack row-instantiation shape).
/// </para>
/// </summary>
/// <remarks>
/// Click semantics: retail's exact bit-toggle algorithm (what
/// <c>UIOption_CheckboxBitfield64</c>'s own click handler does to <c>m_current</c>, as
/// opposed to <c>UIOption_CheckboxBitfield64::Apply</c>'s documented WRITE side in
/// research doc §3.7) is not in the decompiled excerpt this campaign pulled. This
/// widget applies the conservative, retail-consistent reading — a row is "on" when
/// ALL its mask bits are set, and a click SETS (turns on) or CLEARS (turns off)
/// exactly those bits — and raises <see cref="ValueChanged"/> so a page controller can
/// verify/replace the exact algorithm against the real decomp before wiring the
/// authoritative <c>PlayerModule::SetChatWindowOption</c> transaction (Campaign OP
/// slice OP5).
/// </remarks>
public sealed class UiCheckboxBitfield64 : UiPanel
{
/// <summary>
/// Retail element id of the checkbox inside the authored row template
/// (<c>0x10000521</c>'s sole child). Hardcoded in retail's own C++
/// (<c>GetChildRecursive(row, 0x10000219)</c> inside <c>CreateChildren</c>), not
/// something generically derivable from the template — ports that literal constant.
/// </summary>
public const uint TemplateCheckboxElementId = 0x10000219u;
/// <summary>One added checkbox row.</summary>
public readonly record struct Row(
ulong LowMask, ulong HighMask, string Label, string? Tooltip, UiButton Toggle);
ulong LowMask, ulong HighMask, string Label, string? Tooltip,
UiElement RowRoot, UiButton Toggle);
private readonly List<Row> _rows = new();
private float _contentHeight;
/// <summary>The rows added so far, in <see cref="AddChild(ulong, ulong, string, string?)"/> call order.</summary>
public IReadOnlyList<Row> Rows => _rows;
@ -50,23 +63,33 @@ public sealed class UiCheckboxBitfield64 : UiPanel
private ulong _defaultLow, _defaultHigh;
/// <summary>Row height in px for the stacked layout. Rows lay out top-to-bottom in
/// call order, matching retail's authored call sequence. No authored row height
/// exists for this block (it is unauthored in the dat) — a page controller may
/// override before the first <see cref="AddChild(ulong, ulong, string, string?)"/> call.</summary>
public float RowHeight { get; set; } = 14f;
/// <summary>
/// The authored row template (dat property 0x64 on THIS element). Retail authors
/// exactly one entry, <c>{0x2100002B, 0x10000521}</c>, and reuses it for every row
/// (<c>AddItemFromTemplateList(this, 0, nullptr)</c> called once per row).
/// </summary>
public IReadOnlyList<UiTemplateListEntry> Templates { get; }
/// <summary>Dat font for row labels.</summary>
/// <summary>Same seam as <see cref="UiTemplateListBox.TemplateResolver"/> — a page
/// controller wires this with real DAT access (Campaign OP slice OP5+). Left null
/// by <c>DatWidgetFactory</c> itself.</summary>
public Func<uint, uint, UiElement?>? TemplateResolver { get; set; }
/// <summary>Dat font for row labels — kept for callers that built rows before this
/// rework shipped; unused now that labels are set directly on the resolved
/// template's own checkbox widget, whose font already comes from the authored
/// template.</summary>
public UiDatFont? LabelFont { get; set; }
// SpriteResolve (forwarded to each row's UiButton) is inherited from UiPanel —
// SpriteResolve (forwarded to each row's built subtree) is inherited from UiPanel —
// same resolver shape, no need to redeclare it.
/// <summary>Fired with the new (low, high) value after any row toggles.</summary>
public Action<ulong, ulong>? ValueChanged { get; set; }
public UiCheckboxBitfield64()
public UiCheckboxBitfield64(IReadOnlyList<UiTemplateListEntry> templates)
{
Templates = templates;
BackgroundColor = Vector4.Zero;
BorderColor = Vector4.Zero;
}
@ -100,43 +123,89 @@ public sealed class UiCheckboxBitfield64 : UiPanel
/// <summary>
/// Retail <c>UIOption_CheckboxBitfield64::AddChild(lowMask, highMask, labelId,
/// tooltipId)</c>: appends one toggleable row for the given mask, stacked below
/// the previous row. <paramref name="label"/>/<paramref name="tooltip"/> are
/// already-resolved strings — string-table lookup is the caller's job (matches
/// tooltipId)</c> → <c>CreateChildren</c>'s per-row loop: instantiates the authored
/// row template (<see cref="Templates"/>[0], the only entry) through
/// <see cref="TemplateResolver"/>, locates its embedded checkbox
/// (<see cref="TemplateCheckboxElementId"/>) via retail's own
/// <c>GetChildRecursive</c>, and stamps this row's label/click/checked-state onto
/// it. <paramref name="label"/>/<paramref name="tooltip"/> are already-resolved
/// strings — string-table lookup is the caller's job (matches
/// <see cref="UiMenu.MenuItem"/>'s own already-resolved-Label convention).
/// </summary>
public UiButton AddChild(ulong lowMask, ulong highMask, string label, string? tooltip = null)
/// <returns>The row's checkbox widget, or null (loud-logged) when no template is
/// authored, no resolver is wired, the resolver produced nothing, or the resolved
/// subtree does not contain <see cref="TemplateCheckboxElementId"/> — any of which
/// means retail's authored shape is missing or the resolver seam is unwired, not
/// something this widget should silently paper over.</returns>
public UiButton? AddChild(ulong lowMask, ulong highMask, string label, string? tooltip = null)
{
var rowInfo = new ElementInfo { Type = 1u, Width = Width, Height = RowHeight };
var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId, Name = "" };
direct.Properties.Values[0x0Bu] = new UiPropertyValue
if (Templates.Count == 0)
{
Kind = UiPropertyKind.Bool,
BoolValue = true, // ToggleBehavior (property 0x0B) — UiButton reads this in its ctor.
};
rowInfo.States[UiStateInfo.DirectStateId] = direct;
var toggle = new UiButton(rowInfo, SpriteResolve ?? (_ => (0u, 0, 0)))
Console.WriteLine("[D.2b] UiCheckboxBitfield64.AddChild: no authored row template (property 0x64 empty) — cannot build a row.");
return null;
}
Func<uint, uint, UiElement?>? resolver = TemplateResolver;
if (resolver is null)
{
Label = label,
LabelFont = LabelFont,
LabelAlign = UiButton.LabelAlignment.Left,
Left = 0f,
Top = _rows.Count * RowHeight,
Width = Width,
Height = RowHeight,
Selected = IsSet(lowMask, highMask),
};
toggle.OnClick = () => ToggleRow(lowMask, highMask, toggle);
Console.WriteLine("[D.2b] UiCheckboxBitfield64.AddChild: TemplateResolver not wired yet — cannot build a row.");
return null;
}
_rows.Add(new Row(lowMask, highMask, label, tooltip, toggle));
AddChild(toggle);
return toggle;
UiTemplateListEntry entry = Templates[0]; // retail always reuses template index 0
UiElement? row = resolver(entry.TemplateLayoutId, entry.TemplateElementId);
if (row is null)
{
Console.WriteLine($"[D.2b] UiCheckboxBitfield64.AddChild: resolver returned null for template 0x{entry.TemplateLayoutId:X8}/0x{entry.TemplateElementId:X8}.");
return null;
}
UiButton? checkbox = FindCheckboxRecursive(row);
if (checkbox is null)
{
Console.WriteLine($"[D.2b] UiCheckboxBitfield64.AddChild: resolved row template did not contain checkbox 0x{TemplateCheckboxElementId:X8} — row will not respond to clicks.");
return null;
}
checkbox.Label = label;
checkbox.Selected = IsSet(lowMask, highMask);
checkbox.OnClick = () => ToggleRow(lowMask, highMask, checkbox);
row.Left = 0f;
row.Top = _contentHeight;
_contentHeight += row.Height;
base.AddChild(row);
_rows.Add(new Row(lowMask, highMask, label, tooltip, row, checkbox));
return checkbox;
}
private bool IsSet(ulong lowMask, ulong highMask)
=> (CurrentLow & lowMask) == lowMask && (CurrentHigh & highMask) == highMask;
private static UiButton? FindCheckboxRecursive(UiElement node)
{
if (node.DatElementId == TemplateCheckboxElementId && node is UiButton button)
return button;
foreach (UiElement child in node.Children)
{
UiButton? found = FindCheckboxRecursive(child);
if (found is not null) return found;
}
return null;
}
/// <summary>
/// Retail <c>UIOption_CheckboxBitfield64::Refresh @0x004859C0</c>: a row is checked
/// when ANY bit of its mask is set in the current value — NOT when every bit is
/// set. <c>lowHit = current.low &amp; mask.low; highHit = current.high &amp;
/// mask.high; checked = (lowHit | highHit) != 0</c>.
/// </summary>
private bool IsSet(ulong lowMask, ulong highMask)
=> (CurrentLow & lowMask) != 0 || (CurrentHigh & highMask) != 0;
/// <summary>
/// Retail <c>UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0</c>:
/// <c>BitUtils::SetBitsOnOrOff(&amp;m_current, mask, onOff)</c> — decomp-confirmed
/// OR-in-on / AND-NOT-off (TS-72's toggle-math half was always correct; only the
/// checked-state predicate above needed fixing).
/// </summary>
private void ToggleRow(ulong lowMask, ulong highMask, UiButton toggle)
{
bool turnOn = !IsSet(lowMask, highMask);

View file

@ -1,112 +0,0 @@
using System.Collections.Generic;
using AcDream.App.UI.Layout;
namespace AcDream.App.UI;
/// <summary>
/// Retail <c>UIElement_TabControl</c> (Type 8) — the Options panel's tab strip +
/// mounted-page switcher. Owns the authored tab table (dat property <c>0x2E</c>,
/// see <see cref="UiTabTableEntry"/>): activating a tab shows exactly ONE page-slot
/// child and hides the rest, and updates the corresponding tab button's Open/Closed
/// visual state.
///
/// <para>
/// The tab buttons and page-slot children are ORDINARY imported dat elements — this
/// widget does <b>not</b> set <see cref="UiElement.ConsumesDatChildren"/>, so
/// <c>LayoutImporter</c> builds them the normal recursive way (buttons build as
/// <see cref="UiText"/> with Open/Closed states — retail's tab buttons are Type 0xC
/// text elements, not Type-1 buttons; page slots build as generic containers whose
/// OWN children are the mounted page's content via <c>BaseLayoutId</c>/
/// <c>BaseElement</c> inheritance). This widget only wires cross-references the tab
/// table names by id, which it can only resolve once that subtree exists — see
/// <see cref="IUiChildrenAttachedListener"/>.
/// </para>
///
/// <para>
/// Retail anchors: <c>docs/research/2026-08-10-options-panel-structure.md</c> §1.3
/// (the tab table property <c>0x2E</c> struct shape), §10.1 (structural inventory —
/// tab buttons `0x1000020D..0x1000020F`/`0x1000050B`, page slots
/// `0x10000211..0x10000213`/`0x1000050C`, Gameplay the authored default). Verified
/// byte-for-byte against the regenerated <c>options_2100002B.json</c> fixture: four
/// entries, Gameplay (`0x1000020D`/`0x10000212`) is the sole <c>IsDefault</c> row.
/// </para>
///
/// <para>
/// Retail's <c>OnVisibilityChanged</c> auto-apply/auto-revert semantics (research doc
/// §3.6 — hiding a page reverts uncommitted edits, showing one applies + commits) are
/// OUT of this widget's scope: they belong to the <c>OptionPage</c>/
/// <c>PlayerOptionPage</c> model a page controller owns (Campaign OP slice OP3+).
/// This widget only switches which page slot is <see cref="UiElement.Visible"/>.
/// </para>
/// </summary>
public sealed class UiTabControl : UiElement, IUiChildrenAttachedListener
{
private readonly IReadOnlyList<UiTabTableEntry> _tabs;
public UiTabControl(IReadOnlyList<UiTabTableEntry> tabs)
{
_tabs = tabs;
}
/// <summary>The authored tab table this control was built from, in authored array order.</summary>
public IReadOnlyList<UiTabTableEntry> Tabs => _tabs;
/// <summary>Dat element id of the currently active page slot. 0 before the first mount.</summary>
public uint ActivePageElementId { get; private set; }
/// <summary>
/// Retail wires the tab table's cross-references (button ↔ page ↔ default) once
/// the imported subtree exists — see <see cref="IUiChildrenAttachedListener"/>'s
/// doc for why this can't happen during <c>DatWidgetFactory.Create</c>. Every tab
/// button's click is bound to switch to its paired page, then the authored
/// default tab (the entry with <see cref="UiTabTableEntry.IsDefault"/> true, or
/// the first entry if the dat authored none) is activated.
/// </summary>
public void OnChildrenAttached()
{
UiTabTableEntry? defaultEntry = null;
foreach (UiTabTableEntry entry in _tabs)
{
UiElement? button = FindDirectChild(entry.ButtonElementId);
uint pageId = entry.PageElementId;
RetailTabBinding.SetClick(button, () => SwitchTo(pageId));
if (entry.IsDefault)
defaultEntry = entry;
}
defaultEntry ??= _tabs.Count > 0 ? _tabs[0] : null;
if (defaultEntry is { } def)
SwitchTo(def.PageElementId);
}
/// <summary>
/// Activates the page slot named <paramref name="pageElementId"/>: shows it, hides
/// every other authored page slot, and sets each tab button's Open/Closed state to
/// match (<see cref="RetailTabBinding.SetOpen"/>). No-op if the page is already active.
/// </summary>
public void SwitchTo(uint pageElementId)
{
if (ActivePageElementId == pageElementId) return;
foreach (UiTabTableEntry entry in _tabs)
{
bool active = entry.PageElementId == pageElementId;
UiElement? page = FindDirectChild(entry.PageElementId);
if (page is not null) page.Visible = active;
UiElement? button = FindDirectChild(entry.ButtonElementId);
RetailTabBinding.SetOpen(button, active);
}
ActivePageElementId = pageElementId;
}
private UiElement? FindDirectChild(uint datElementId)
{
foreach (UiElement child in Children)
if (child.DatElementId == datElementId)
return child;
return null;
}
}

View file

@ -0,0 +1,220 @@
using System;
using System.Collections.Generic;
using AcDream.App.UI.Layout;
namespace AcDream.App.UI;
/// <summary>
/// Retail <c>UIElement_Panel</c> (dat class Type <c>8</c>) — the tab-strip host used by
/// the Options panel and, in principle, any panel that authors a tab table (dat property
/// <c>0x2E</c>, see <see cref="UiTabTableEntry"/>).
///
/// <para>
/// <b>Naming correction (OP2 rework):</b> the OP2 slice named this class/mechanism
/// <c>UIElement_TabControl</c>. No such class exists in the named-retail PDB — Type 8 is
/// registered as <c>UIElement_Panel::Create</c> (<c>UIElement::RegisterElementClass(8,
/// UIElement_Panel::Create) @0x0046C6B7</c>). Correct anchors:
/// <c>UIElement_Panel::SetupTabPageHash @0x0046C2E0</c> (tab-table read + default
/// selection), <c>::Update @0x0046BD00</c> (the one-visible-page switch + tab
/// Open/Closed state write), <c>::OpenTab @0x0046BE20</c> / <c>::InqTabFromPage
/// @0x0046BEB0</c> (the click entry points), <c>::ListenToElementMessage @0x0046BF90</c>.
/// </para>
///
/// <para>
/// <b>Shape correction (OP2 rework, `docs/research/2026-08-11-op2-review-blast.md` +
/// `docs/research/2026-08-11-op2-review-mechanism.md`):</b> OP2 mapped every dat Type-8
/// element unconditionally to this class and wired its tab table AT IMPORT TIME
/// (<c>OnChildrenAttached</c>). Four already-shipped panels (vendor `0x100000B8`,
/// character sheet root `0x10000227`, spellbook root `0x100002A8`, combat
/// `0x100000A2`) are Type 8 and authored a tab table, so they silently gained a SECOND,
/// import-time tab-switcher racing their own existing C# controllers
/// (<c>CharacterStatController</c>/<c>SpellbookWindowController</c>/
/// <c>VendorUiController</c> already own this exact switching for their panels; combat
/// has no re-binder at all — the table would have taken over outright). A fifth Type-8
/// element, vendor's media-bearing backdrop `0x1000008D`, has NO tab table at all and
/// simply lost its authored fill because the old bare-<see cref="UiElement"/> base drew
/// nothing and defaulted <see cref="UiElement.ClickThrough"/> to <c>false</c>.
/// </para>
///
/// <para>
/// This class now derives from <see cref="UiDatElement"/> and is DORMANT by default:
/// importing a Type-8 element only gets authored-media drawing, retail's
/// <c>ClickThrough = true</c> generic-decoration default, and <see cref="IUiDatStateful"/>
/// state propagation — identical to the pre-OP2 fallback for every element that doesn't
/// opt in. The tab-table switching mechanism (button ↔ page ↔ default, Open/Closed state)
/// only activates when a controller explicitly calls <see cref="ActivateTabBehavior"/> —
/// today, nobody does (OP2 ships the mechanism only; the Options panel controller,
/// Campaign OP slice OP3+, is the first caller). This is filed as an intentional
/// deviation from retail's unconditional per-instance activation — see the register row
/// added in this same commit.
/// </para>
///
/// <para>
/// The tab buttons and page-slot children are ORDINARY imported dat elements — this
/// widget does <b>not</b> set <see cref="UiElement.ConsumesDatChildren"/>, so
/// <c>LayoutImporter</c> builds them the normal recursive way (buttons build as
/// <see cref="UiText"/> with Open/Closed states — retail's tab buttons are Type 0xC
/// text elements, not Type-1 buttons; page slots build as generic containers whose
/// OWN children are the mounted page's content via <c>BaseLayoutId</c>/
/// <c>BaseElement</c> inheritance).
/// </para>
///
/// <para>
/// Retail anchors: <c>docs/research/2026-08-10-options-panel-structure.md</c> §1.3
/// (the tab table property <c>0x2E</c> struct shape), §10.1 (structural inventory —
/// tab buttons `0x1000020D..0x1000020F`/`0x1000050B`, page slots
/// `0x10000211..0x10000213`/`0x1000050C`, Gameplay the authored default). Verified
/// byte-for-byte against the regenerated <c>options_2100002B.json</c> fixture: four
/// entries, Gameplay (`0x1000020D`/`0x10000212`) is the sole <c>IsDefault</c> row.
/// </para>
///
/// <para>
/// Retail's <c>OnVisibilityChanged</c> auto-apply/auto-revert semantics (research doc
/// §3.6 — hiding a page reverts uncommitted edits, showing one applies + commits) are
/// OUT of this widget's scope: they belong to the <c>OptionPage</c>/
/// <c>PlayerOptionPage</c> model a page controller owns (Campaign OP slice OP3+).
/// This widget only switches which page slot is <see cref="UiElement.Visible"/>.
/// </para>
/// </summary>
public sealed class UiTabPanel : UiDatElement, IUiChildrenAttachedListener
{
/// <summary>Retail element id this class was ported for: Type 8 = 8.</summary>
public const uint RetailTypeId = 8u;
private readonly IReadOnlyList<UiTabTableEntry> _tabs;
private readonly List<UiTabTableEntry> _unresolved = new();
private bool _behaviorActive;
public UiTabPanel(
ElementInfo info,
Func<uint, (uint tex, int w, int h)> resolve,
IReadOnlyList<UiTabTableEntry> tabs)
: base(info, resolve)
{
_tabs = tabs;
}
/// <summary>The authored tab table this control was built from, in authored array order.</summary>
public IReadOnlyList<UiTabTableEntry> Tabs => _tabs;
/// <summary>Dat element id of the currently active page slot. 0 before the first
/// switch (either <see cref="ActivateTabBehavior"/>'s default-entry switch, or a
/// direct <see cref="SwitchTo"/> call).</summary>
public uint ActivePageElementId { get; private set; }
/// <summary>
/// True once <see cref="ActivateTabBehavior"/> has run. Dormant instances (every
/// pre-existing Type-8 host today) never flip this.
/// </summary>
public bool BehaviorActive => _behaviorActive;
/// <summary>
/// Tab-table entries whose button, page, or both did not resolve against this
/// element's built subtree the last time <see cref="ActivateTabBehavior"/> ran — a
/// silently-empty tab table and one that entirely fails to resolve are otherwise
/// indistinguishable (round-2 review SHOULD-FIX 3), so this is loud instead of a
/// mere no-op. Each miss is also logged via <see cref="Console.WriteLine"/>.
/// </summary>
public IReadOnlyList<UiTabTableEntry> UnresolvedEntries => _unresolved;
/// <summary>
/// Dormant by design (see class doc) — importing a Type-8 element performs no
/// wiring. <see cref="ActivateTabBehavior"/> is the explicit, controller-driven
/// opt-in that does the equivalent work once the subtree exists AND a controller
/// actually wants this element to own tab switching.
/// </summary>
void IUiChildrenAttachedListener.OnChildrenAttached()
{
}
/// <summary>
/// Opts this instance into retail's tab-table switching mechanism: binds every tab
/// button's click to switch to its paired page (<c>UIElement_Panel::OpenTab</c>),
/// then activates the authored default entry — the entry with
/// <see cref="UiTabTableEntry.IsDefault"/> true. Idempotent; a second call is a
/// no-op. Retail resolves both the button and the page via
/// <c>GetChildRecursive</c> (a descendant search, not direct-children-only), which
/// this ports so cross-layout mounts (a tab page's content built from a different
/// LayoutDesc, e.g. Configure Keyboard) still resolve.
/// </summary>
public void ActivateTabBehavior()
{
if (_behaviorActive) return;
_behaviorActive = true;
_unresolved.Clear();
UiTabTableEntry? defaultEntry = null;
foreach (UiTabTableEntry entry in _tabs)
{
UiElement? button = FindDescendant(this, entry.ButtonElementId);
UiElement? page = FindDescendant(this, entry.PageElementId);
if (button is null || page is null)
{
_unresolved.Add(entry);
Console.WriteLine(
$"[D.2b] UiTabPanel 0x{Info.Id:X8}: tab entry button=0x{entry.ButtonElementId:X8} "
+ $"page=0x{entry.PageElementId:X8} did not resolve against the built subtree "
+ $"(button {(button is null ? "MISSING" : "ok")}, page {(page is null ? "MISSING" : "ok")}).");
}
uint pageId = entry.PageElementId;
RetailTabBinding.SetClick(button, () => SwitchTo(pageId));
if (entry.IsDefault)
defaultEntry = entry;
}
// Retail UIElement_Panel::Update(0, 0) — the guard when neither
// m_OpenPageToken nor m_OpenTabToken has been seeded — performs no switch at
// all. No entry authoring 0x32==true means retail never activates a page here;
// do not fabricate a fallback to the first entry (the OP2 REJECT-review
// SHOULD-FIX 2 finding).
if (defaultEntry is { } def)
SwitchTo(def.PageElementId);
}
/// <summary>
/// Activates the page slot named <paramref name="pageElementId"/>: shows it, hides
/// every other authored page slot, and sets each tab button's Open/Closed state to
/// match (<see cref="RetailTabBinding.SetOpen"/>). No-op if the page is already
/// active. Safe to call directly (e.g. from a test or a controller that wants
/// programmatic navigation) without going through <see cref="ActivateTabBehavior"/>
/// first, though the authored click bindings only exist after activation.
/// </summary>
public void SwitchTo(uint pageElementId)
{
if (ActivePageElementId == pageElementId) return;
foreach (UiTabTableEntry entry in _tabs)
{
bool active = entry.PageElementId == pageElementId;
UiElement? page = FindDescendant(this, entry.PageElementId);
if (page is not null) page.Visible = active;
UiElement? button = FindDescendant(this, entry.ButtonElementId);
RetailTabBinding.SetOpen(button, active);
}
ActivePageElementId = pageElementId;
}
/// <summary>
/// Retail <c>GetChildRecursive</c>: a depth-first search of every descendant (not
/// just direct children) for a widget carrying <paramref name="datElementId"/>.
/// Direct-children-only search (the pre-rework <c>FindDirectChild</c>) missed
/// cross-layout mounts where the tab table's ids name elements nested below an
/// intermediate incorporated container.
/// </summary>
private static UiElement? FindDescendant(UiElement node, uint datElementId)
{
foreach (UiElement child in node.Children)
{
if (child.DatElementId == datElementId)
return child;
UiElement? found = FindDescendant(child, datElementId);
if (found is not null)
return found;
}
return null;
}
}

View file

@ -1,32 +1,68 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.UI.Layout;
namespace AcDream.App.UI;
/// <summary>
/// Retail <c>UIElement_ListBox</c> (Type 5) with an authored row-template list (dat
/// property <c>0x64</c>). Port of <c>AddItemFromTemplateList(index)</c>: instantiates
/// row <paramref name="index"/>'s template — a <see cref="UiTemplateListEntry"/>
/// Retail <c>UIElement_ListBox</c> (dat class Type <c>5</c>) with an authored row-template
/// list (dat property <c>0x64</c>). Port of <c>AddItemFromTemplateList(index)</c>:
/// instantiates row <paramref name="index"/>'s template — a <see cref="UiTemplateListEntry"/>
/// naming a cross-layout <c>{LayoutDesc DID, element id}</c> pair, per
/// <c>docs/research/2026-08-10-options-panel-structure.md</c> §1.5 — through the SAME
/// import machinery every other retained window uses, and appends it as one
/// scrollable row.
/// import machinery every other retained window uses, and appends it as one row.
///
/// <para>
/// Wraps a <see cref="UiScrollablePanel"/> as its single child rather than
/// duplicating its scroll logic — <see cref="UiScrollablePanel"/> is sealed, and this
/// is the SAME "controller-built row list" viewport pattern
/// <c>CharacterStatController.RebuildActiveList</c> already uses for the skill list
/// (a <see cref="UiScrollablePanel"/> child sized to its host, rows added through it).
/// <b>OP2 rework (2026-08-11):</b> derives from <see cref="UiDatElement"/> and stays
/// DORMANT until a controller adds its first row. The OP2 REJECT-review blast-radius
/// finding (`docs/research/2026-08-11-op2-review-blast.md`) established that EVERY
/// pre-existing Type-5 element reaching <see cref="DatWidgetFactory"/> already authors a
/// non-empty <c>0x64</c> template array (character `0x1000023D`/`0x10000532`, effects
/// positive/negative `0x10000123`, examine `0x10000149`/`0x10000335`/`0x1000032D`,
/// mini-game `0x10000174`, spellbook `0x10000464`) — so the original Type-5 factory
/// guard's premise ("none currently reach this factory") was false, and the old
/// unconditional viewport injection in the constructor (<c>base.AddChild(_viewport)</c>)
/// gave every one of those ten elements a spurious hit-testable
/// <see cref="UiScrollablePanel"/> child plus lost their authored media (the old class
/// derived from bare <see cref="UiPanel"/>, zeroed its background/border, and drew
/// nothing dat-authored). None of the ten pre-existing elements author real dat
/// children of their own (verified against every regenerated fixture — their row
/// content comes ONLY from the cross-layout <c>0x64</c> array, never from a literal
/// child in their own LayoutDesc subtree), so making this class dormant-by-default is a
/// pure behavior restoration: an un-activated instance renders and hit-tests EXACTLY
/// like the pre-OP2 <see cref="UiDatElement"/> fallback. The Options panel's three
/// ListBoxes (Character/Config/Chat, Campaign OP slice OP4+) are the only elements a
/// controller will ever call <see cref="AddItemFromTemplateList"/> against.
/// </para>
///
/// <para>
/// <see cref="ConsumesDatChildren"/> is <c>true</c>: retail ListBox rows come ONLY from
/// <c>AddItemFromTemplateList</c>, never from static dat children of the ListBox element
/// itself, so this widget owns its own child construction the same way
/// Meter/Menu/Button/Scrollbar/Text/Field do — <c>LayoutImporter</c> does not attempt to
/// recurse into any literal dat children this element might (incorrectly) author.
/// </para>
///
/// <para>
/// Row content is stacked inside a lazily-created <see cref="UiScrollablePanel"/> —
/// created on the FIRST successful <see cref="AddItemFromTemplateList"/> call, not in the
/// constructor. This is what makes dormancy free: an instance that never gets a row
/// (every pre-existing panel today) never allocates or attaches the viewport, so there is
/// no spurious hit-testable child competing with whatever the controller separately adds
/// via <see cref="UiElement.AddChild"/> (the same "resolve → not a UiItemList → build one
/// and attach it directly to the host" pattern <c>EffectsUiController</c>/
/// <c>AppraisalUiController</c>/<c>SpellbookWindowController</c>/
/// <c>CharacterStatController</c> already use for these exact ten elements).
/// <see cref="UiScrollablePanel"/> is sealed, hence composition rather than inheritance —
/// the SAME "controller-built row list" viewport pattern
/// <c>CharacterStatController.RebuildActiveList</c> already uses for the skill list.
/// The wrapped panel is anchored to fill this box, so scrolling, per-row visibility
/// clipping, and the pixel scroll model (<see cref="Scroll"/>) all come from the
/// SAME code CH6/the character sheet already exercise — no new scroll model. Rows
/// stack in call order: each new row's <see cref="UiElement.Top"/> is set to the
/// viewport's current <see cref="UiScrollablePanel.ContentHeight"/> before it is
/// added, exactly retail's own ListBox layout (each row is authored at its own
/// template-local Y=0; the box stacks instances).
/// clipping, and the pixel scroll model (<see cref="Scroll"/>) all come from the SAME
/// code CH6/the character sheet already exercise — no new scroll model. Rows stack in
/// call order: each new row's <see cref="UiElement.Top"/> is set to the viewport's
/// current <see cref="UiScrollablePanel.ContentHeight"/> before it is added, exactly
/// retail's own ListBox layout (each row is authored at its own template-local Y=0; the
/// box stacks instances).
/// </para>
///
/// <para>
@ -42,12 +78,15 @@ namespace AcDream.App.UI;
/// <c>IDatReaderWriter</c>.
/// </para>
/// </summary>
public sealed class UiTemplateListBox : UiPanel
public sealed class UiTemplateListBox : UiDatElement
{
private readonly UiScrollablePanel _viewport = new()
{
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
};
/// <summary>Retail element id this class was ported for: Type 5 = 5.</summary>
public const uint RetailTypeId = 5u;
private const int DefaultLineHeight = 16;
private UiScrollablePanel? _viewport;
private int _pendingLineHeight = DefaultLineHeight;
/// <summary>The authored row-template list (dat property 0x64), in authored array order.</summary>
public IReadOnlyList<UiTemplateListEntry> Templates { get; }
@ -63,19 +102,25 @@ public sealed class UiTemplateListBox : UiPanel
public uint ScrollbarElementId { get; }
/// <summary>The wrapped viewport's pixel scroll model — link a page controller's
/// resolved scrollbar (<see cref="ScrollbarElementId"/>) to this.</summary>
public UiScrollable Scroll => _viewport.Scroll;
/// resolved scrollbar (<see cref="ScrollbarElementId"/>) to this. Creates the
/// viewport on first access (see class doc — dormancy).</summary>
public UiScrollable Scroll => Viewport.Scroll;
/// <summary>Total stacked row height in px — the same value <see cref="Scroll"/>'s
/// content extent uses.</summary>
public int ContentHeight => _viewport.ContentHeight;
/// content extent uses. 0 while dormant (no viewport created yet).</summary>
public int ContentHeight => _viewport?.ContentHeight ?? 0;
/// <summary>Row height for the scroll model's line-scroll quantum. Set once template
/// heights are known; defaults to the viewport's own default (16px).</summary>
/// heights are known; defaults to the viewport's own default (16px). Safe to set
/// before the viewport exists — the value is applied once it's created.</summary>
public int LineHeight
{
get => _viewport.LineHeight;
set => _viewport.LineHeight = value;
get => _viewport?.LineHeight ?? _pendingLineHeight;
set
{
_pendingLineHeight = value;
if (_viewport is not null) _viewport.LineHeight = value;
}
}
/// <summary>
@ -85,21 +130,44 @@ public sealed class UiTemplateListBox : UiPanel
/// </summary>
public Func<uint, uint, UiElement?>? TemplateResolver { get; set; }
public UiTemplateListBox(IReadOnlyList<UiTemplateListEntry> templates, uint scrollbarElementId)
public UiTemplateListBox(
ElementInfo info,
Func<uint, (uint tex, int w, int h)> resolve,
IReadOnlyList<UiTemplateListEntry> templates,
uint scrollbarElementId)
: base(info, resolve)
{
Templates = templates;
ScrollbarElementId = scrollbarElementId;
BackgroundColor = Vector4.Zero;
BorderColor = Vector4.Zero;
base.AddChild(_viewport);
}
/// <summary>Retail ListBox rows never come from static dat children — see class doc.</summary>
public override bool ConsumesDatChildren => true;
private UiScrollablePanel Viewport
{
get
{
if (_viewport is null)
{
_viewport = new UiScrollablePanel
{
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
LineHeight = _pendingLineHeight,
};
base.AddChild(_viewport);
}
return _viewport;
}
}
/// <summary>
/// Retail <c>UIElement_ListBox::AddItemFromTemplateList(m_pOptionBox, index,
/// nullptr)</c>: resolves <c>Templates[index]</c> through <see cref="TemplateResolver"/>
/// and appends the built subtree as the next row, stacked below the previous one.
/// Returns the built row widget, or null when <paramref name="index"/> is out of
/// range, no resolver is wired, or the resolver produced nothing.
/// Lazily creates the internal viewport on the FIRST successful call (see class doc —
/// dormancy). Returns the built row widget, or null when <paramref name="index"/> is
/// out of range, no resolver is wired, or the resolver produced nothing.
/// </summary>
public UiElement? AddItemFromTemplateList(int index)
{
@ -111,9 +179,10 @@ public sealed class UiTemplateListBox : UiPanel
UiElement? row = resolver(entry.TemplateLayoutId, entry.TemplateElementId);
if (row is null) return null;
UiScrollablePanel viewport = Viewport;
row.Left = 0f;
row.Top = _viewport.ContentHeight;
_viewport.AddChild(row);
row.Top = viewport.ContentHeight;
viewport.AddChild(row);
return row;
}
}