fix(chat): CH6a/b rework — grip media, retail window-id model, floaty fixture

Applies docs/research/2026-08-10-ch6ab-review-findings.md in full:

- BLOCKER 1: UiResizeGrip now carries its ElementInfo/resolve pair and
  draws its own authored DirectState media (a synthetic parameterless
  grip still draws nothing, preserving existing resize-drag tests).
  DatWidgetFactory.BuildResizeGrip threads resolve through. All seven
  live grips on the main chat window now resolve a non-zero sprite,
  restoring the visible borders/corners CH6a silently dropped.

- SHOULD-FIX 2: ChatWindowState gains BroadcastTargetWindow, a sentinel
  distinct from every real window id (0-4), fixing the bug where the
  main window's explicit-addressing branch coincided with the broadcast
  check (both were literal 0). SetFilter's main-window no-op is dropped
  — the main window's filter is now genuinely settable. ChatWindowController
  .Bind takes a ChatWindowState (the same canonical instance the floating
  windows already share) and GetTranscriptLines builds a real accept
  predicate instead of accept:null. Verified safe: ClientLocal (0x1A)
  never reaches ChatLog (AddText routes it to the SpewBox and returns),
  so nothing observable regresses.

- SHOULD-FIX 3: UiButton.SuppressSelfToggle stops the four chat-window
  indicator buttons (DAT property 0x0B=true, no retail click handler)
  from flipping their own Selected mirror on a stray click.

- SHOULD-FIX 4: generated and committed chat_floaty_2100005b.json from
  the real installed dats; added the permanent RetailLayoutFixtureGenerator
  entry. All three flagged FloatingChatWindowController assumptions
  (input field, title bar, close button) are confirmed correct against
  real data — no controller code changes needed. New finding: unlike the
  main window, ALL EIGHT floaty border/corner elements are live Type-9
  grips (the floaty's own title bar is its move handle), so a floaty
  window resizes from every edge and corner.

- SHOULD-FIX 5: register row AP-189 documents the shared-500-entry/
  200-line-tail vs retail's per-window 10,000-line scrollback depth gap.

- NITs 1-5: documented the filter-persistence-only-on-/saveautoui
  asymmetry and the reconnect-preserves-filters intent; corrected the
  research doc's modifier-mask mislabel and the "ONLY function" false
  superlative; moved WrapText off ChatWindowController onto
  ChatTranscriptRenderer, closing the circular dependency.

Full Release suite: 12,420 passed / 4 skipped / 0 failed (baseline
12,392/4/0 at 22020ef2; net +28 tests, zero regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-10 13:09:32 +02:00
parent 56b84deeab
commit 1aa7709988
23 changed files with 6284 additions and 248 deletions

View file

@ -8,7 +8,13 @@ namespace AcDream.Core.Chat;
/// 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).
/// <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:
@ -16,38 +22,45 @@ namespace AcDream.Core.Chat;
/// <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>
/// 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 (target id
/// <c>0</c>) AND <c>W</c>'s filter accepts the line's
/// <see cref="RetailLogTextType"/> (<c>ChatInterface::TypeIsActive
/// (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>
/// <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).
/// <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 == 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.
/// (<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
@ -56,6 +69,15 @@ public sealed class ChatWindowState
public const int MinFloatingWindowId = 1;
public const int MaxFloatingWindowId = 4;
/// <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();
@ -83,8 +105,9 @@ public sealed class ChatWindowState
{
lock (_gate)
{
// "everything 0x00-0x1F except 0x1A" (m_oldState 1/8) — inert for
// routing (see class doc) but seeded for fidelity/inspection.
// "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] = 0xFBFFFFFFu;
// Speech, Tell, Speech_Direct_Send, Emote (m_oldState 2).
_filters[1] = 0x0000101Cu;
@ -110,13 +133,15 @@ public sealed class ChatWindowState
}
/// <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.
/// 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);
if (windowId == MainWindowId) return;
lock (_gate)
{
if (_filters[windowId] == filter) return;
@ -182,12 +207,15 @@ public sealed class ChatWindowState
/// <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 == 0u && TypeIsActive(windowId, logTextType);
return targetWindowId == BroadcastTargetWindow && TypeIsActive(windowId, logTextType);
}
private static void ValidateWindowId(int windowId)