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>
243 lines
10 KiB
C#
243 lines
10 KiB
C#
using System;
|
|
using System.Threading;
|
|
|
|
namespace AcDream.Core.Chat;
|
|
|
|
/// <summary>
|
|
/// Retail's per-chat-window text-type filter and open/visible state — window
|
|
/// id <c>0</c> is the main chat window, ids <c>1</c>-<c>4</c> are the four
|
|
/// floating chat windows (Campaign CH slice CH6b,
|
|
/// <c>docs/research/2026-08-09-chat-retail-window-shell.md</c> §1.2 and
|
|
/// <c>docs/research/2026-08-09-chat-retail-color-table.md</c> §4). These are
|
|
/// acdream's OWN compact indices, not retail's raw <c>m_eWindowID</c> values:
|
|
/// retail's actual main window is <c>m_eWindowID == 8</c>, its floaties are
|
|
/// <c>2</c>-<c>5</c>, and <c>m_eWindowID == 0</c> is retail's ctor-default
|
|
/// "unauthored" sentinel — none of that is <c>0</c>-<c>4</c> (CH6a/b
|
|
/// REJECT-review SHOULD-FIX 2,
|
|
/// <c>docs/research/2026-08-10-ch6ab-review-findings.md</c>).
|
|
///
|
|
/// <para>
|
|
/// Ports two retail mechanisms exactly:
|
|
/// <list type="bullet">
|
|
/// <item><c>ChatInterface::PostInit @0x004F3DD0</c>'s <c>m_oldState</c>
|
|
/// switch seeds each window's default 64-bit
|
|
/// <c>m_llTextTypeFilter</c> (color-table doc §4's table — the constants
|
|
/// below are byte-identical to that table; retail's own main-window default
|
|
/// 0xFBFFFFFF is shared by <c>m_eWindowID</c> 1 AND 8, and the floaty
|
|
/// defaults come from retail <c>m_eWindowID</c> 2-5, one higher than
|
|
/// acdream's compact 1-4).</item>
|
|
/// <item><c>ChatInterface::RecvNotice_DisplayFinalStringInfo
|
|
/// @0x004F4640</c>'s display predicate: a line shows in window
|
|
/// <c>W</c> when the message's target window id equals <c>W</c>
|
|
/// (explicit addressing) OR the message is broadcast
|
|
/// (<see cref="BroadcastTargetWindow"/>) AND <c>W</c>'s filter accepts the
|
|
/// line's <see cref="RetailLogTextType"/> (<c>ChatInterface::TypeIsActive
|
|
/// @0x004F2F10</c>).</item>
|
|
/// </list>
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <see cref="BroadcastTargetWindow"/> is a sentinel distinct from every real
|
|
/// window id (<c>0</c>-<c>4</c>) — the acdream-internal analogue of retail's
|
|
/// wire <c>arg5 == 0</c> broadcast marker, deliberately kept separate from
|
|
/// window id <c>0</c> (main). Earlier CH6b code conflated the two (both were
|
|
/// literal <c>0</c>), so <see cref="ShouldDisplay"/>'s explicit-addressing
|
|
/// branch (<c>targetWindowId == windowId</c>) short-circuited true for EVERY
|
|
/// broadcast line evaluated against the main window, regardless of its own
|
|
/// filter — the CH6a/b REJECT-review BLOCKER. With the sentinel separated
|
|
/// out, the main window's filter is genuinely consulted for broadcast lines,
|
|
/// <see cref="SetFilter"/> is a real (not inert) write for window <c>0</c>
|
|
/// too, and a future explicit <c>targetWindowId == MainWindowId</c> (AP-180's
|
|
/// <c>m_idCurrentCommandSource</c> per-window echo) stays distinguishable
|
|
/// from an ordinary broadcast line. <see cref="SetOpen"/>/<see cref="Toggle"/>
|
|
/// keep their own, UNRELATED no-op for window <c>0</c> — retail's main window
|
|
/// is simply never closable, independent of the broadcast-sentinel fix.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// No production <see cref="ChatEntry"/> carries an explicit target window
|
|
/// id yet (register row AP-180 — the <c>windowId</c> dual-destination echo
|
|
/// is deferred); every current line is effectively broadcast
|
|
/// (<c>targetWindowId == </c><see cref="BroadcastTargetWindow"/>).
|
|
/// <see cref="ShouldDisplay"/> still accepts the full retail shape so the
|
|
/// routing predicate does not need to change shape when AP-180 lands.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class ChatWindowState
|
|
{
|
|
public const int MainWindowId = 0;
|
|
public const int MinFloatingWindowId = 1;
|
|
public const int MaxFloatingWindowId = 4;
|
|
|
|
/// <summary>
|
|
/// Retail's authored per-window text-type filter defaults
|
|
/// (<c>gmChatOptionsUI::InitOptions @0x0049FC60</c> /
|
|
/// <c>AddCheckboxBitfield64Option @0x0049EDA0</c>'s <c>SetDefaultValue</c> calls,
|
|
/// <c>docs/research/2026-08-10-options-panel-structure.md</c> §5.2). Named here
|
|
/// (rather than only as the private literals in <see cref="ResetToDefaults"/>)
|
|
/// because Campaign OP slice OP5's Chat-tab Defaults button needs the SAME
|
|
/// values <see cref="ResetToDefaults"/> seeds — one source, not two independently
|
|
/// literal tables that could drift.
|
|
/// </summary>
|
|
public const ulong MainWindowDefaultFilter = 0xFBFFFFFFul;
|
|
public const ulong Floaty1DefaultFilter = 0x0000101Cul;
|
|
public const ulong Floaty2DefaultFilter = 0x00040C00ul;
|
|
public const ulong Floaty3DefaultFilter = 0x00080000ul;
|
|
public const ulong Floaty4DefaultFilter = 0x78000000ul;
|
|
|
|
/// <summary>
|
|
/// Sentinel <c>targetWindowId</c> meaning "broadcast to every window,
|
|
/// subject to each window's own filter" — the acdream-internal analogue
|
|
/// of retail's wire <c>arg5 == 0</c>. Deliberately outside the
|
|
/// <c>0</c>-<c>4</c> real-window-id range (see the class doc) so it can
|
|
/// never collide with <see cref="MainWindowId"/>.
|
|
/// </summary>
|
|
public const uint BroadcastTargetWindow = uint.MaxValue;
|
|
|
|
private const int WindowCount = MaxFloatingWindowId + 1;
|
|
|
|
private readonly object _gate = new();
|
|
private readonly ulong[] _filters = new ulong[WindowCount];
|
|
private readonly bool[] _open = new bool[WindowCount];
|
|
private long _revision;
|
|
|
|
public ChatWindowState() => ResetToDefaults();
|
|
|
|
/// <summary>
|
|
/// Monotonic counter bumped on every filter or open-state change.
|
|
/// Lets presentation caches (per-window transcript layout) detect a
|
|
/// filter/visibility change without re-deriving it from the raw arrays.
|
|
/// </summary>
|
|
public long Revision => Interlocked.Read(ref _revision);
|
|
|
|
/// <summary>
|
|
/// Reset every window to retail's <c>PostInit</c> defaults (color-table
|
|
/// doc §4). The high dword is always <c>0</c> for every window — Society
|
|
/// (<c>0x20</c>) and the reserved slot (<c>0x21</c>) are opt-in only,
|
|
/// matching retail. Windows 1-4 start closed; window 0 (main) is always
|
|
/// open.
|
|
/// </summary>
|
|
public void ResetToDefaults()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
// "everything 0x00-0x1F except 0x1A" (m_oldState 1/8) — genuinely
|
|
// consulted for broadcast lines now that BroadcastTargetWindow is
|
|
// distinct from MainWindowId (see class doc).
|
|
_filters[0] = MainWindowDefaultFilter;
|
|
// Speech, Tell, Speech_Direct_Send, Emote (m_oldState 2).
|
|
_filters[1] = Floaty1DefaultFilter;
|
|
// Social, Social_Send, Allegiance (m_oldState 3).
|
|
_filters[2] = Floaty2DefaultFilter;
|
|
// Fellowship (m_oldState 4).
|
|
_filters[3] = Floaty3DefaultFilter;
|
|
// Turbine General/Trade/LFG/Roleplay (m_oldState 5).
|
|
_filters[4] = Floaty4DefaultFilter;
|
|
|
|
_open[0] = true;
|
|
for (int i = MinFloatingWindowId; i <= MaxFloatingWindowId; i++)
|
|
_open[i] = false;
|
|
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
}
|
|
|
|
public ulong GetFilter(int windowId)
|
|
{
|
|
ValidateWindowId(windowId);
|
|
lock (_gate) return _filters[windowId];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set window <paramref name="windowId"/>'s 64-bit type filter — including
|
|
/// the main window (id <c>0</c>): CH6a/b REJECT-review SHOULD-FIX 2 drops
|
|
/// the old no-op (see the class doc's <see cref="BroadcastTargetWindow"/>
|
|
/// paragraph). Retail's main window IS user-settable through the options
|
|
/// page; only the settings UI to drive this is future work.
|
|
/// </summary>
|
|
public void SetFilter(int windowId, ulong filter)
|
|
{
|
|
ValidateWindowId(windowId);
|
|
lock (_gate)
|
|
{
|
|
if (_filters[windowId] == filter) return;
|
|
_filters[windowId] = filter;
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
}
|
|
|
|
/// <summary>Main window (id 0) is always open.</summary>
|
|
public bool IsOpen(int windowId)
|
|
{
|
|
ValidateWindowId(windowId);
|
|
if (windowId == MainWindowId) return true;
|
|
lock (_gate) return _open[windowId];
|
|
}
|
|
|
|
/// <summary>No-op for the main window — it cannot be closed.</summary>
|
|
public void SetOpen(int windowId, bool open)
|
|
{
|
|
ValidateWindowId(windowId);
|
|
if (windowId == MainWindowId) return;
|
|
lock (_gate)
|
|
{
|
|
if (_open[windowId] == open) return;
|
|
_open[windowId] = open;
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Flip window <paramref name="windowId"/>'s open state and return the
|
|
/// new value. Always returns <see langword="true"/> for the main window
|
|
/// (it cannot be toggled closed).
|
|
/// </summary>
|
|
public bool Toggle(int windowId)
|
|
{
|
|
ValidateWindowId(windowId);
|
|
if (windowId == MainWindowId) return true;
|
|
lock (_gate)
|
|
{
|
|
bool next = !_open[windowId];
|
|
_open[windowId] = next;
|
|
Interlocked.Increment(ref _revision);
|
|
return next;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>ChatInterface::TypeIsActive @0x004F2F10</c>: does window
|
|
/// <paramref name="windowId"/>'s filter accept retail
|
|
/// <see cref="RetailLogTextType"/> <paramref name="logTextType"/>?
|
|
/// Types <c>>= 64</c> are never active (there is no such bit).
|
|
/// </summary>
|
|
public bool TypeIsActive(int windowId, uint logTextType)
|
|
{
|
|
ValidateWindowId(windowId);
|
|
if (logTextType >= 64u) return false;
|
|
ulong filter;
|
|
lock (_gate) filter = _filters[windowId];
|
|
return ((1UL << (int)logTextType) & filter) != 0UL;
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640</c>'s
|
|
/// exact display predicate — see the class doc for the two branches.
|
|
/// <paramref name="targetWindowId"/> is either a real window id
|
|
/// (<c>0</c>-<c>4</c>, explicit addressing) or
|
|
/// <see cref="BroadcastTargetWindow"/> (broadcast, filtered per window).
|
|
/// </summary>
|
|
public bool ShouldDisplay(int windowId, uint targetWindowId, uint logTextType)
|
|
{
|
|
ValidateWindowId(windowId);
|
|
if (targetWindowId == (uint)windowId) return true;
|
|
return targetWindowId == BroadcastTargetWindow && TypeIsActive(windowId, logTextType);
|
|
}
|
|
|
|
private static void ValidateWindowId(int windowId)
|
|
{
|
|
if (windowId < MainWindowId || windowId > MaxFloatingWindowId)
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(windowId), windowId, "chat window id must be 0 (main) through 4 (floating).");
|
|
}
|
|
}
|