Binds LayoutDesc 0x2100005C through OP2's template-list mechanism and OP3's per-page OptionPage model: the General Options header + two DualHash-linked opacity sliders (Option_DefaultOpacity_Property 0x10000080 / Option_ActiveOpacity_Property 0x10000081, live-apply on drag through RetailWindowOpacityController, defaults read from the installed DAT's DBProperties collection at DID 0x78000001 via ChatOptionsDatDefaults), and the five per-window text-filter blocks (main window 12 rows minus Gameplay, four floaties 13 rows each — the byte-verified authored order cross-checked against the raw gmChatOptionsUI::InitOptions/AddCheckboxBitfield64Option pseudo-C, not just the research doc's own table) writing AcDream.Core.Chat. ChatWindowState directly, the same state CH6's chat windows already read. AP-195 retired: ported both halves left open at the OP2 re-review — the ALL-set LED media swap (new UiButton.FaceFileOverride, driven by the block-level P0x10000082/P0x10000083 sprites now threaded through ElementInfo/DatWidgetFactory) and the CreateChildren self-sizing tail (UiCheckboxBitfield64.Height grows with its stacked row content; the enclosing ListBox reflows around the block's FINAL height via the new UiTemplateListBox.AddPrebuiltRow, reusing the ListBox's own stacking rather than a third stacking path). AP-187 broadened to cover the main window's own filter (previously only the four floaties) and the new live-editing write path. The main chat window's filter (retail window id 8, ChatWindowState id 0) gains its own settings.json persistence (ChatSettings. ChatWindowMainFilter) alongside the pre-existing floaty 1-4 fields; opacity persistence is now wired on every live slider change, not only through the old dev-scaffold Settings panel. Fixture regeneration (ACDREAM_REGENERATE_UI_FIXTURES=1) picked up the new ElementInfo.LedCheckedSprite/LedUncheckedSprite fields across all 19 committed layout fixtures — purely additive, confirmed against the live installed DAT (0x10000520's own 0x82/0x83 properties resolve to 0x06004D17/0x06004D19 exactly as AP-195 documented). Conformance: FilterRows/FilterBlocks pinned against the byte-verified authored order and ChatWindowState's own default constants; the AP-195 LED swap and self-sizing behavior; the DAT opacity-default extraction against the live installed DAT; live filter/opacity writes reaching ChatWindowState/RetailWindowOpacityController; OnShown re-seed and Reset/Defaults ghosting per the OP4 binding-pattern discipline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
330 lines
16 KiB
C#
330 lines
16 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using AcDream.App.UI.Layout;
|
||
|
||
namespace AcDream.App.UI;
|
||
|
||
/// <summary>
|
||
/// Retail <c>UIOption_CheckboxBitfield64</c> (Type <c>0x10000044</c>) — a block of
|
||
/// 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>
|
||
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,
|
||
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;
|
||
|
||
/// <summary>Current low 64 bits of the option's bitfield value.</summary>
|
||
public ulong CurrentLow { get; private set; }
|
||
|
||
/// <summary>Current high 64 bits of the option's bitfield value.</summary>
|
||
public ulong CurrentHigh { get; private set; }
|
||
|
||
private ulong _defaultLow, _defaultHigh;
|
||
|
||
/// <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>
|
||
/// AP-195 (Campaign OP slice OP5): block-level "all mask bits set" LED media
|
||
/// (dat property <c>0x10000082</c> on THIS element, <c>0x06004D17</c> on the
|
||
/// installed DAT). 0 when the authoring template carries none — the LED override
|
||
/// is then simply never applied (rows keep their per-row authored art).
|
||
/// </summary>
|
||
public uint CheckedLedSprite { get; }
|
||
|
||
/// <summary>AP-195 counterpart of <see cref="CheckedLedSprite"/>: block-level
|
||
/// "checked but not every mask bit set" LED media (dat property
|
||
/// <c>0x10000083</c>, <c>0x06004D19</c>).</summary>
|
||
public uint UncheckedLedSprite { get; }
|
||
|
||
/// <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 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 — a LIVE
|
||
/// user edit only (retail's <c>Apply(1)</c> path). NEVER fired by
|
||
/// <see cref="SetCurrentValue"/> (the OnShown/Reset/Defaults re-seed path — pushing
|
||
/// an externally-sourced value must not loop back into "the user changed this").
|
||
/// </summary>
|
||
public Action<ulong, ulong>? ValueChanged { get; set; }
|
||
|
||
public UiCheckboxBitfield64(
|
||
IReadOnlyList<UiTemplateListEntry> templates,
|
||
uint checkedLedSprite = 0u,
|
||
uint uncheckedLedSprite = 0u)
|
||
{
|
||
Templates = templates;
|
||
CheckedLedSprite = checkedLedSprite;
|
||
UncheckedLedSprite = uncheckedLedSprite;
|
||
BackgroundColor = Vector4.Zero;
|
||
BorderColor = Vector4.Zero;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail <c>UIOption::SetDefaultValue(low, high)</c>: sets the restore-to-defaults
|
||
/// target. Also seeds the current value while no rows exist yet (construction
|
||
/// order mirrors retail: <c>SetDefaultValue</c> then N <c>AddChild</c> calls, so
|
||
/// each row's initial checked state reads the seeded default).
|
||
/// </summary>
|
||
public void SetDefaultValue(ulong low, ulong high)
|
||
{
|
||
_defaultLow = low;
|
||
_defaultHigh = high;
|
||
if (_rows.Count == 0)
|
||
{
|
||
CurrentLow = low;
|
||
CurrentHigh = high;
|
||
}
|
||
}
|
||
|
||
/// <summary>Reverts to the last <see cref="SetDefaultValue"/> target and refreshes
|
||
/// every row's checked state — the Defaults-button verb (research doc §3.3).</summary>
|
||
public void RestoreDefaultValue()
|
||
{
|
||
CurrentLow = _defaultLow;
|
||
CurrentHigh = _defaultHigh;
|
||
RefreshRowVisuals();
|
||
ValueChanged?.Invoke(CurrentLow, CurrentHigh);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Pushes an externally-sourced (low, high) value onto the widget — the page-model
|
||
/// re-seed path (retail's <c>Refresh</c> re-run after
|
||
/// <c>OptionPage::RestoreSavedValues</c>/<c>RestoreDefaultValues</c>/the OnShown
|
||
/// <c>SaveCurrentValue</c> re-read). Updates <see cref="CurrentLow"/>/
|
||
/// <see cref="CurrentHigh"/> and every row's checked state + AP-195 LED media, but
|
||
/// deliberately does NOT invoke <see cref="ValueChanged"/> — the caller (an
|
||
/// <c>IOptionRow</c> wrapper) already owns whatever notification it needs, and
|
||
/// firing here would loop a re-seed back into "the user changed this" (the same
|
||
/// live/refresh distinction <c>BoolOptionRow</c>'s <c>refresh</c> delegate makes).
|
||
/// </summary>
|
||
public void SetCurrentValue(ulong low, ulong high)
|
||
{
|
||
CurrentLow = low;
|
||
CurrentHigh = high;
|
||
RefreshRowVisuals();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail <c>UIOption_CheckboxBitfield64::AddChild(lowMask, highMask, labelId,
|
||
/// 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>
|
||
/// <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)
|
||
{
|
||
if (Templates.Count == 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)
|
||
{
|
||
Console.WriteLine("[D.2b] UiCheckboxBitfield64.AddChild: TemplateResolver not wired yet — cannot build a row.");
|
||
return null;
|
||
}
|
||
|
||
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;
|
||
// Retail CreateChildren @0x00485DF0 stamps the row tooltip via
|
||
// SetTooltip (OP2 re-review §2.2). Its @0x00485E3E companion — the
|
||
// SetAttribute_Int(cb, 0x10000084, i) row-INDEX stamp retail later
|
||
// reads back to find which row a click meant — is deliberately
|
||
// replaced by the typed mask closure below: equivalent routing,
|
||
// without a stringly attribute round-trip.
|
||
checkbox.TooltipText = tooltip;
|
||
checkbox.OnClick = () => ToggleRow(lowMask, highMask, checkbox);
|
||
|
||
row.Left = 0f;
|
||
row.Top = _contentHeight;
|
||
_contentHeight += row.Height;
|
||
base.AddChild(row);
|
||
|
||
// AP-195 self-sizing tail: retail's own CreateChildren ends with
|
||
// ResizeTo(GetWidth(), CalculatePaperSize(0, -1)) because
|
||
// UIOption_CheckboxBitfield64 IS a UIElement_ListBox (its PostInit
|
||
// tail-calls UIElement_Scrollable::PostInit) — the block grows to fit its
|
||
// rows instead of keeping its authored 272x100 extent. Width is untouched
|
||
// (GetWidth() — the ListBox's own authored column width); only Height
|
||
// grows, exactly matching the stacked _contentHeight this class already
|
||
// tracks per row. A dedicated ListBox-unification pass (reusing
|
||
// UiTemplateListBox's own viewport) remains future work per AP-195's own
|
||
// disposition menu; this direct resize is the recorded-rationale half of
|
||
// that menu — see UiTemplateListBox.AddPrebuiltRow for how a page
|
||
// controller reflows the ENCLOSING ListBox around this widget's now-final
|
||
// height instead of the pre-build authored one.
|
||
Height = _contentHeight;
|
||
|
||
var newRow = new Row(lowMask, highMask, label, tooltip, row, checkbox);
|
||
_rows.Add(newRow);
|
||
ApplyRowVisuals(newRow);
|
||
return checkbox;
|
||
}
|
||
|
||
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>'s ANY-set half:
|
||
/// 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 & mask.low; highHit =
|
||
/// current.high & mask.high; checked = (lowHit | highHit) != 0</c>.
|
||
/// </summary>
|
||
private bool IsAnySet(ulong lowMask, ulong highMask)
|
||
=> (CurrentLow & lowMask) != 0 || (CurrentHigh & highMask) != 0;
|
||
|
||
/// <summary>
|
||
/// AP-195: retail <c>Refresh</c>'s ALL-set half, computed by the SAME pass as
|
||
/// <see cref="IsAnySet"/> — <c>(current.low & mask.low) == mask.low &&
|
||
/// (current.high & mask.high) == mask.high</c>. Drives which of
|
||
/// <see cref="CheckedLedSprite"/>/<see cref="UncheckedLedSprite"/> a checked row's
|
||
/// LED shows: composite masks (e.g. the Gameplay row's <c>0x83912021</c>, five
|
||
/// bits) can be PARTIALLY satisfied — checked (any-set) but not fully (all-set) —
|
||
/// which single-bit rows (e.g. "General") can never observably distinguish.
|
||
/// </summary>
|
||
private bool IsAllSet(ulong lowMask, ulong highMask)
|
||
=> (CurrentLow & lowMask) == lowMask && (CurrentHigh & highMask) == highMask;
|
||
|
||
/// <summary>
|
||
/// Retail <c>UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0</c>:
|
||
/// <c>BitUtils::SetBitsOnOrOff(&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 = !IsAnySet(lowMask, highMask);
|
||
if (turnOn)
|
||
{
|
||
CurrentLow |= lowMask;
|
||
CurrentHigh |= highMask;
|
||
}
|
||
else
|
||
{
|
||
CurrentLow &= ~lowMask;
|
||
CurrentHigh &= ~highMask;
|
||
}
|
||
ApplyRowVisualsForMask(lowMask, highMask, toggle);
|
||
ValueChanged?.Invoke(CurrentLow, CurrentHigh);
|
||
}
|
||
|
||
private void RefreshRowVisuals()
|
||
{
|
||
foreach (Row row in _rows)
|
||
ApplyRowVisuals(row);
|
||
}
|
||
|
||
private void ApplyRowVisuals(Row row) => ApplyRowVisualsForMask(row.LowMask, row.HighMask, row.Toggle);
|
||
|
||
/// <summary>
|
||
/// One row's complete <c>Refresh</c> visual: the ANY-set checked bool
|
||
/// (<see cref="UiButton.Selected"/>, always) plus AP-195's ALL-set-driven LED
|
||
/// media swap (<see cref="UiButton.FaceFileOverride"/>) — retail's own gate: the
|
||
/// swap ONLY applies while ANY-set is true; a fully-unchecked row keeps its
|
||
/// authored per-row art untouched (<c>Refresh @0x004859C0</c>'s
|
||
/// <c>if (ebx == 0) { ... media swap ... }</c> branch, where <c>ebx == 0</c> is
|
||
/// the ANY-set case per the byte trace in this class's own research citations).
|
||
/// </summary>
|
||
private void ApplyRowVisualsForMask(ulong lowMask, ulong highMask, UiButton toggle)
|
||
{
|
||
bool anySet = IsAnySet(lowMask, highMask);
|
||
toggle.Selected = anySet;
|
||
if (!anySet)
|
||
{
|
||
toggle.FaceFileOverride = null;
|
||
return;
|
||
}
|
||
uint overrideSprite = IsAllSet(lowMask, highMask) ? CheckedLedSprite : UncheckedLedSprite;
|
||
toggle.FaceFileOverride = overrideSprite != 0u ? overrideSprite : null;
|
||
}
|
||
}
|