feat(chat): Campaign CH slice CH6b — floating chat windows 1-4
Mounts retail's four floating chat windows as always-resident, born-hidden children per gmGamePlayUI::SetupChildren @0x004E9EC0, all sharing LayoutDesc 0x2100005B (window ids 0x10000505/0x1000050E/0x1000050F/0x10000510). New FloatingChatWindowController (AcDream.App/UI/Layout) binds each window's own widget tree — built fresh per instance from one shared imported ElementInfo — reusing ChatWindowController's word-wrap + retail color-carry algorithm via the extracted ChatTranscriptRenderer instead of duplicating it. A floaty window has no talk-focus menu (research doc §2.2), so its entry field always sends on Say; the mismatch against retail's possible shared-channel behavior is UNVERIFIED and filed as #369/AP-188. Runtime owns the per-window filter/open state: ChatWindowState (new, AcDream.Core.Chat) seeds retail's exact PostInit defaults per window (window 1 0x0000101C Speech/Tell/DirectSend/Emote, window 2 0x00040C00 Social/SocialSend/Allegiance, window 3 0x00080000 Fellowship, window 4 0x78000000 Turbine General/Trade/LFG/Roleplay) and implements the full ShouldDisplay(windowId, targetWindowId, logTextType) display predicate from ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640. It lives on RuntimeCommunicationState.ChatWindows so every host borrows the same instance. The main window's filter (0xFBFFFFFF, "no user filter") never actually gates anything because its own explicit-address branch already covers every broadcast line — that's why UpdateFromPlayerModule early-returns for window 0 in retail, ported here by construction rather than a special case. Keybind wiring: InputAction.ToggleFloatingChatWindow1..4 and their KeyBindings.RetailDefaults() chords already existed since Phase K.1c (unwired until now). The MetaKeys table confirms retail's default is Alt+1 through Alt+4 (index 3 = bit 0x00000004, cross-checked against the same file's Alt+A/D strafe and Alt+Enter/Tab/F4 rows). Routes through GameplayInputCommandController -> RetainedGameplayWindowCommands -> RetailUiRuntime.ToggleFloatingChatWindow -> the generic UiHost.ToggleWindow, whose visibility-change event is the single chokepoint that syncs ChatWindowState.SetOpen and mirrors the main window's 1-4 indicator button regardless of what changed a window's visibility (keybind, close button, or a restored layout). A direct decomp read of gmMainChatUI::ListenToElementMessage @0x004CDA80 — the only function in the whole binary that branches on a click message — settles what the research doc had left as a hedge: it handles exactly 0x1000046f (max/min) and the talk-focus menu's selection message, with NO case for 0x10000522-0x10000525. The four indicator buttons are PURE one-directional mirrors in retail; clicking them does nothing. ChatWindowController.SetIndicatorOpen ports this with no OnClick at all. Corrected research doc §1.4 accordingly. Persistence is local-only (register row AP-187; the retail 0x1000008C GameplayOptions wire remains deferred to CH6f): window geometry and open/visible state ride the existing generic RetailWindowLayoutPersistence path for free once each window registers under its own WindowNames entry; the four filter masks get a dedicated ChatSettings round-trip (ChatWindow1Filter..ChatWindow4Filter, defaulting to the retail PostInit constants) loaded at mount and saved alongside SaveLayout(). Tests: ChatWindowStateTests (defaults, TypeIsActive, the full display-rule matrix, toggle/reset, revision counter), FloatingChatWindowControllerTests (bind smoke tests against a synthetic 0x2100005B tree, per-window filter routing, filter-change cache invalidation, fixed-Say submit), new ChatWindowController.SetIndicatorOpen tests (Highlight/Normal state, cross-window isolation, range validation), GameplayInputCommandController routing for the four toggle actions, and a SettingsStore filter round-trip. Full Release suite: 12,392 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
41b408f3e6
commit
22020ef2c4
21 changed files with 1699 additions and 46 deletions
199
src/AcDream.Core/Chat/ChatWindowState.cs
Normal file
199
src/AcDream.Core/Chat/ChatWindowState.cs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
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).
|
||||
///
|
||||
/// <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).</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 (target id
|
||||
/// <c>0</c>) AND <c>W</c>'s filter accepts the line's
|
||||
/// <see cref="RetailLogTextType"/> (<c>ChatInterface::TypeIsActive
|
||||
/// @0x004F2F10</c>).</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <c>UpdateFromPlayerModule @0x004F3920</c> early-returns for window id
|
||||
/// <c>0</c> — the main window never has a user-settable filter. That
|
||||
/// invariant needs no special case here: for <c>windowId == 0</c>,
|
||||
/// <see cref="ShouldDisplay"/>'s first branch (<c>targetWindowId ==
|
||||
/// windowId</c>) is already true for every broadcast line (target id
|
||||
/// <c>0</c>), so the main window's filter is never actually consulted —
|
||||
/// exactly matching retail's "no user filter" behavior without a guard.
|
||||
/// <see cref="SetFilter"/> and <see cref="SetOpen"/> are still no-ops for
|
||||
/// window <c>0</c> (it is always open and its seeded filter is inert), for
|
||||
/// the same reason retail's setters gate on <c>m_eWindowID != 0</c>
|
||||
/// (persistence doc §4.2).
|
||||
/// </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 == 0</c>). <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;
|
||||
|
||||
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) — inert for
|
||||
// routing (see class doc) but seeded for fidelity/inspection.
|
||||
_filters[0] = 0xFBFFFFFFu;
|
||||
// Speech, Tell, Speech_Direct_Send, Emote (m_oldState 2).
|
||||
_filters[1] = 0x0000101Cu;
|
||||
// Social, Social_Send, Allegiance (m_oldState 3).
|
||||
_filters[2] = 0x00040C00u;
|
||||
// Fellowship (m_oldState 4).
|
||||
_filters[3] = 0x00080000u;
|
||||
// Turbine General/Trade/LFG/Roleplay (m_oldState 5).
|
||||
_filters[4] = 0x78000000u;
|
||||
|
||||
_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. No-op for
|
||||
/// the main window (id <c>0</c>) — see the class doc.
|
||||
/// </summary>
|
||||
public void SetFilter(int windowId, ulong filter)
|
||||
{
|
||||
ValidateWindowId(windowId);
|
||||
if (windowId == MainWindowId) return;
|
||||
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.
|
||||
/// </summary>
|
||||
public bool ShouldDisplay(int windowId, uint targetWindowId, uint logTextType)
|
||||
{
|
||||
ValidateWindowId(windowId);
|
||||
if (targetWindowId == (uint)windowId) return true;
|
||||
return targetWindowId == 0u && 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).");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue