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>
This commit is contained in:
Erik 2026-08-11 06:25:59 +02:00
parent 4798302226
commit e71e5a9614
52 changed files with 4540 additions and 40 deletions

View file

@ -70,6 +70,19 @@ public sealed class UiCheckboxBitfield64 : UiPanel
/// </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>
@ -84,12 +97,21 @@ public sealed class UiCheckboxBitfield64 : 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>
/// <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)
public UiCheckboxBitfield64(
IReadOnlyList<UiTemplateListEntry> templates,
uint checkedLedSprite = 0u,
uint uncheckedLedSprite = 0u)
{
Templates = templates;
CheckedLedSprite = checkedLedSprite;
UncheckedLedSprite = uncheckedLedSprite;
BackgroundColor = Vector4.Zero;
BorderColor = Vector4.Zero;
}
@ -121,6 +143,24 @@ public sealed class UiCheckboxBitfield64 : UiPanel
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
@ -174,7 +214,6 @@ public sealed class UiCheckboxBitfield64 : UiPanel
// replaced by the typed mask closure below: equivalent routing,
// without a stringly attribute round-trip.
checkbox.TooltipText = tooltip;
checkbox.Selected = IsSet(lowMask, highMask);
checkbox.OnClick = () => ToggleRow(lowMask, highMask, checkbox);
row.Left = 0f;
@ -182,7 +221,24 @@ public sealed class UiCheckboxBitfield64 : UiPanel
_contentHeight += row.Height;
base.AddChild(row);
_rows.Add(new Row(lowMask, highMask, label, tooltip, row, checkbox));
// 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;
}
@ -199,18 +255,26 @@ public sealed class UiCheckboxBitfield64 : UiPanel
}
/// <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>. This is HALF of Refresh:
/// the same pass also computes the ALL-set predicate to swap each row's LED
/// media between the two authored surfaces (<c>P0x10000082 = 0x06004D17</c> /
/// <c>P0x10000083 = 0x06004D19</c>) — not ported yet, register row AP-195,
/// due with the Chat tab (OP5) alongside the ListBox self-sizing tail.
/// 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 IsSet(ulong lowMask, ulong highMask)
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
@ -219,7 +283,7 @@ public sealed class UiCheckboxBitfield64 : UiPanel
/// </summary>
private void ToggleRow(ulong lowMask, ulong highMask, UiButton toggle)
{
bool turnOn = !IsSet(lowMask, highMask);
bool turnOn = !IsAnySet(lowMask, highMask);
if (turnOn)
{
CurrentLow |= lowMask;
@ -230,13 +294,37 @@ public sealed class UiCheckboxBitfield64 : UiPanel
CurrentLow &= ~lowMask;
CurrentHigh &= ~highMask;
}
toggle.Selected = turnOn;
ApplyRowVisualsForMask(lowMask, highMask, toggle);
ValueChanged?.Invoke(CurrentLow, CurrentHigh);
}
private void RefreshRowVisuals()
{
foreach (Row row in _rows)
row.Toggle.Selected = IsSet(row.LowMask, row.HighMask);
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;
}
}