acdream/src/AcDream.App/UI/UiCheckboxBitfield64.cs
Erik e71e5a9614 feat(ui): Campaign OP slice OP5 — the Chat tab
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>
2026-08-11 06:25:59 +02:00

330 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 &amp; mask.low; highHit =
/// current.high &amp; 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 &amp; mask.low) == mask.low &amp;&amp;
/// (current.high &amp; 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(&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 = !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;
}
}