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

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