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>
471 lines
16 KiB
C#
471 lines
16 KiB
C#
using AcDream.Core.Chat;
|
|
using AcDream.Core.Social;
|
|
|
|
namespace AcDream.Runtime.Gameplay;
|
|
|
|
public readonly record struct RuntimeCommunicationEvent(
|
|
ulong Sequence,
|
|
RuntimeChatEntry Entry);
|
|
|
|
public interface IRuntimeCommunicationObserver
|
|
{
|
|
void OnChat(in RuntimeCommunicationEvent delta);
|
|
}
|
|
|
|
public interface IRuntimeCommunicationEventSource
|
|
{
|
|
IDisposable Subscribe(IRuntimeCommunicationObserver observer);
|
|
}
|
|
|
|
public readonly record struct RuntimeCommunicationOwnershipSnapshot(
|
|
bool IsDisposed,
|
|
bool CommandTargetsDisposed,
|
|
int StreamSubscriberCount,
|
|
int PendingDispatchCount,
|
|
bool IsDispatching,
|
|
int FriendCount,
|
|
int SquelchAccountCount,
|
|
int SquelchCharacterCount,
|
|
int SquelchGlobalTypeCount,
|
|
int NegotiatedRoomCount,
|
|
bool HasReplyTarget,
|
|
bool HasRetellTarget,
|
|
long DispatchFailureCount)
|
|
{
|
|
public bool IsConverged =>
|
|
IsDisposed
|
|
&& CommandTargetsDisposed
|
|
&& StreamSubscriberCount == 0
|
|
&& PendingDispatchCount == 0
|
|
&& !IsDispatching
|
|
&& FriendCount == 0
|
|
&& SquelchAccountCount == 0
|
|
&& SquelchCharacterCount == 0
|
|
&& SquelchGlobalTypeCount == 0
|
|
&& NegotiatedRoomCount == 0
|
|
&& !HasReplyTarget
|
|
&& !HasRetellTarget;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Canonical presentation-independent owner for communication and social
|
|
/// state. Graphical, headless, plugin, and bot hosts borrow these exact
|
|
/// instances.
|
|
/// </summary>
|
|
public sealed class RuntimeCommunicationState : IDisposable
|
|
{
|
|
private readonly RuntimeCommunicationEventStream _events;
|
|
private bool _disposed;
|
|
|
|
public RuntimeCommunicationState(int maximumChatEntries = 500)
|
|
{
|
|
Chat = new ChatLog(maximumChatEntries);
|
|
SpewBox = new SpewBoxState();
|
|
CommandTargets = new ChatCommandTargetState(Chat);
|
|
_events = new RuntimeCommunicationEventStream(Chat);
|
|
TurbineChat = new TurbineChatState();
|
|
Friends = new FriendsState();
|
|
Squelch = new SquelchState();
|
|
ChatWindows = new ChatWindowState();
|
|
View = new CommunicationView(Chat);
|
|
SocialView = new CommunicationSocialView(
|
|
TurbineChat,
|
|
Friends,
|
|
Squelch);
|
|
}
|
|
|
|
public ChatLog Chat { get; }
|
|
|
|
/// <summary>
|
|
/// Campaign CH slice CH6b: retail's per-window text-type filter and
|
|
/// open/visible state for the main chat window (id 0) and the four
|
|
/// floating chat windows (ids 1-4). Graphical, headless, and plugin
|
|
/// hosts all borrow this exact instance — presentation never owns a
|
|
/// second copy of filter/open state.
|
|
/// </summary>
|
|
public ChatWindowState ChatWindows { get; }
|
|
|
|
/// <summary>
|
|
/// Campaign CH slice CH2: retail's transient on-screen "interface text"
|
|
/// queue (<c>gmSpewBoxUI</c>) — the SECOND sink <see cref="AddText"/>
|
|
/// can route to. Never written to directly by producers; always through
|
|
/// <see cref="AddText"/> so the <c>type == ClientLocal</c> routing rule
|
|
/// stays centralized at one chokepoint, matching retail's
|
|
/// <c>ClientSystem::AddTextToScroll</c>.
|
|
/// </summary>
|
|
public SpewBoxState SpewBox { get; }
|
|
|
|
public ChatCommandTargetState CommandTargets { get; }
|
|
public TurbineChatState TurbineChat { get; }
|
|
public FriendsState Friends { get; }
|
|
public SquelchState Squelch { get; }
|
|
public IRuntimeChatView View { get; }
|
|
public IRuntimeSocialView SocialView { get; }
|
|
public IRuntimeCommunicationEventSource Events => _events;
|
|
|
|
public bool IsDisposed => _disposed;
|
|
public ulong LastSequence => _events.LastSequence;
|
|
public int SubscriberCount => _events.SubscriberCount;
|
|
public int PendingDispatchCount => _events.PendingDispatchCount;
|
|
public bool IsDispatching => _events.IsDispatching;
|
|
public long DispatchFailureCount => _events.DispatchFailureCount;
|
|
public Exception? LastDispatchFailure => _events.LastDispatchFailure;
|
|
|
|
public RuntimeCommunicationOwnershipSnapshot CaptureOwnership()
|
|
{
|
|
SquelchDatabase squelch = Squelch.Snapshot();
|
|
return new RuntimeCommunicationOwnershipSnapshot(
|
|
_disposed,
|
|
CommandTargets.IsDisposed,
|
|
SubscriberCount,
|
|
PendingDispatchCount,
|
|
IsDispatching,
|
|
Friends.Count,
|
|
squelch.Accounts.Count,
|
|
squelch.Characters.Count,
|
|
squelch.Global.MessageTypes.Count,
|
|
CountRooms(TurbineChat),
|
|
CommandTargets.LastIncomingTellSender is not null,
|
|
CommandTargets.LastOutgoingTellTarget is not null,
|
|
DispatchFailureCount);
|
|
}
|
|
|
|
public void ResetCommandTargets() => CommandTargets.ResetSession();
|
|
public void ResetChatIdentity() => Chat.ResetSessionIdentity();
|
|
public void ResetNegotiatedChannels() => TurbineChat.Reset();
|
|
public void ResetFriends() => Friends.Clear();
|
|
public void ResetSquelch() => Squelch.Clear();
|
|
|
|
/// <summary>
|
|
/// Campaign CH slice CH2: SpewBox lines are purely transient screen
|
|
/// flash — unlike the chat transcript (which survives a reconnect via
|
|
/// <see cref="ResetChatIdentity"/>'s preserve-content reset), a fresh
|
|
/// generation must not resurrect a stale refusal line.
|
|
/// </summary>
|
|
public void ResetSpewBox() => SpewBox.Reset();
|
|
|
|
/// <summary>
|
|
/// The single chokepoint every producer of player-visible interface
|
|
/// text must go through — the direct analogue of retail's
|
|
/// <c>ClientSystem::AddTextToScroll(text, type, allowPluginFilter,
|
|
/// windowId) @0x00563C50</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Retail's routing rule (research doc §2.1) is a receiver-side type
|
|
/// filter, not a sender-side destination switch:
|
|
/// <list type="bullet">
|
|
/// <item><c>type == RetailLogTextType.ClientLocal (0x1A)</c> → the
|
|
/// SpewBox ONLY. Every <c>ChatInterface</c> window is born with that
|
|
/// exact bit cleared from its type filter
|
|
/// (<c>ChatInterface::ChatInterface @0x004F4550</c>,
|
|
/// <c>m_llTextTypeFilter &= 0xFBFFFFFF</c>) — <c>0x1A</c> is
|
|
/// precisely what every chat window refuses. Retail also skips the
|
|
/// timestamp prefix and the chat-log-file write for this type
|
|
/// (§2.1 step 4); since this branch never touches
|
|
/// <see cref="Chat"/> at all, that behaviour falls out for free.
|
|
/// </item>
|
|
/// <item>Every other type → the existing chat transcript, tagged
|
|
/// with <paramref name="type"/> exactly as before.</item>
|
|
/// </list>
|
|
/// <paramref name="windowId"/> is accepted for future parity with
|
|
/// retail's per-window echo (a non-zero window ID lands in BOTH the
|
|
/// SpewBox AND that specific chat window, ~40 slash-command-output
|
|
/// sites — research doc §2.3) but is not yet consumed; every current
|
|
/// production caller passes the default <c>0</c>. This dual-destination
|
|
/// gap is filed as register row AP-180 — implementing it is CH4/CH5
|
|
/// scope at the earliest.
|
|
/// </remarks>
|
|
public void AddText(string text, RetailLogTextType type, uint windowId = 0)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(text);
|
|
|
|
// CH2 REJECT-review rework (SHOULD-FIX 2,
|
|
// docs/research/2026-08-09-ch2-review-findings.md): retail's own
|
|
// first step (ClientSystem::AddTextToScroll @0x00563C50) is
|
|
// trim(&str, 1, 1, ws) — BOTH ends, not trailing-only (the
|
|
// trailing-only trim in research doc §3.1 belongs to
|
|
// gmSpewBoxUI::Update, a SEPARATE later call on the SpewBox's own
|
|
// display path, not this chokepoint). Retail also has no empty-
|
|
// string guard here — AddTextToScroll broadcasts empty strings
|
|
// deliberately (the type-7/Magic s_NullBuffer sites reuse a shared
|
|
// buffer that can legitimately be empty between calls); inventing
|
|
// an early-return for empty text was an unregistered acdream-only
|
|
// divergence, now retired rather than kept as a guessed
|
|
// approximation.
|
|
text = text.Trim();
|
|
|
|
if (type == RetailLogTextType.ClientLocal)
|
|
{
|
|
SpewBox.Enqueue(text);
|
|
return;
|
|
}
|
|
|
|
Chat.OnSystemMessage(text, (uint)type);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_disposed = true;
|
|
_events.Dispose();
|
|
CommandTargets.ResetSession();
|
|
CommandTargets.Dispose();
|
|
TurbineChat.Reset();
|
|
Friends.Clear();
|
|
Squelch.Clear();
|
|
Chat.ResetSessionIdentity();
|
|
SpewBox.Reset();
|
|
// CH6a/b REJECT-review NIT 2: this only runs here, at full teardown
|
|
// (process exit / GameRuntime disposal) — NOT on an ordinary
|
|
// reconnect, which never calls Dispose. Deliberate: a user's chat-
|
|
// window filter customization and open/closed state are client-side
|
|
// presentation preferences, the same class as window geometry
|
|
// (RetailWindowLayoutPersistence, which also survives reconnect) —
|
|
// reconnecting should not silently discard them.
|
|
ChatWindows.ResetToDefaults();
|
|
}
|
|
|
|
private sealed class CommunicationView(ChatLog chat) : IRuntimeChatView
|
|
{
|
|
public long Revision => chat.Revision;
|
|
public int Count => chat.Count;
|
|
}
|
|
|
|
private sealed class CommunicationSocialView(
|
|
TurbineChatState turbineChat,
|
|
FriendsState friends,
|
|
SquelchState squelch)
|
|
: IRuntimeSocialView
|
|
{
|
|
public RuntimeSocialSnapshot Snapshot
|
|
{
|
|
get
|
|
{
|
|
SquelchDatabase database = squelch.Snapshot();
|
|
return new RuntimeSocialSnapshot(
|
|
friends.Revision,
|
|
friends.Count,
|
|
squelch.Revision,
|
|
database.Accounts.Count,
|
|
database.Characters.Count,
|
|
database.Global.MessageTypes.Count,
|
|
CountRooms(turbineChat));
|
|
}
|
|
}
|
|
|
|
public bool TryGetFriend(
|
|
uint characterId,
|
|
out RuntimeFriendSnapshot friend)
|
|
{
|
|
if (!friends.TryGet(characterId, out FriendEntry? current)
|
|
|| current is null)
|
|
{
|
|
friend = default;
|
|
return false;
|
|
}
|
|
|
|
friend = new RuntimeFriendSnapshot(
|
|
current.Id,
|
|
current.Name,
|
|
current.Online,
|
|
current.AppearOffline);
|
|
return true;
|
|
}
|
|
|
|
}
|
|
|
|
private static int CountRooms(TurbineChatState state)
|
|
{
|
|
int count = 0;
|
|
if (state.AllegianceRoom != 0u) count++;
|
|
if (state.GeneralRoom != 0u) count++;
|
|
if (state.TradeRoom != 0u) count++;
|
|
if (state.LfgRoom != 0u) count++;
|
|
if (state.RoleplayRoom != 0u) count++;
|
|
if (state.SocietyRoom != 0u) count++;
|
|
if (state.OlthoiRoom != 0u) count++;
|
|
return count;
|
|
}
|
|
}
|
|
|
|
internal sealed class RuntimeCommunicationEventStream
|
|
: IRuntimeCommunicationEventSource,
|
|
IDisposable
|
|
{
|
|
private readonly ChatLog _chat;
|
|
private readonly object _gate = new();
|
|
private readonly List<RuntimeCommunicationEvent> _pendingDispatch = [];
|
|
private IRuntimeCommunicationObserver[] _observers = [];
|
|
private long _sequence;
|
|
private long _dispatchFailureCount;
|
|
private bool _dispatching;
|
|
private bool _disposed;
|
|
|
|
public RuntimeCommunicationEventStream(ChatLog chat)
|
|
{
|
|
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
|
|
_chat.EntryAppended += OnEntryAppended;
|
|
}
|
|
|
|
public int SubscriberCount => Volatile.Read(ref _observers).Length;
|
|
public ulong LastSequence
|
|
{
|
|
get
|
|
{
|
|
lock (_gate)
|
|
return unchecked((ulong)_sequence);
|
|
}
|
|
}
|
|
public int PendingDispatchCount
|
|
{
|
|
get
|
|
{
|
|
lock (_gate)
|
|
return _pendingDispatch.Count;
|
|
}
|
|
}
|
|
public bool IsDispatching
|
|
{
|
|
get
|
|
{
|
|
lock (_gate)
|
|
return _dispatching;
|
|
}
|
|
}
|
|
public long DispatchFailureCount => Interlocked.Read(ref _dispatchFailureCount);
|
|
public Exception? LastDispatchFailure { get; private set; }
|
|
|
|
public IDisposable Subscribe(IRuntimeCommunicationObserver observer)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(observer);
|
|
lock (_gate)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
IRuntimeCommunicationObserver[] current = _observers;
|
|
if (Array.IndexOf(current, observer) >= 0)
|
|
throw new InvalidOperationException(
|
|
"The communication observer is already subscribed.");
|
|
|
|
var replacement =
|
|
new IRuntimeCommunicationObserver[current.Length + 1];
|
|
Array.Copy(current, replacement, current.Length);
|
|
replacement[^1] = observer;
|
|
Volatile.Write(ref _observers, replacement);
|
|
}
|
|
return new Subscription(this, observer);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_disposed = true;
|
|
_chat.EntryAppended -= OnEntryAppended;
|
|
_pendingDispatch.Clear();
|
|
_dispatching = false;
|
|
Volatile.Write(ref _observers, []);
|
|
}
|
|
}
|
|
|
|
private void OnEntryAppended(ChatEntry entry)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
ulong sequence = unchecked((ulong)++_sequence);
|
|
_pendingDispatch.Add(new RuntimeCommunicationEvent(
|
|
sequence,
|
|
new RuntimeChatEntry(
|
|
_chat.Revision,
|
|
entry.SenderGuid,
|
|
(int)entry.Kind,
|
|
entry.Sender,
|
|
entry.Text,
|
|
entry.ChannelName)));
|
|
if (_dispatching)
|
|
return;
|
|
_dispatching = true;
|
|
}
|
|
|
|
int index = 0;
|
|
while (true)
|
|
{
|
|
RuntimeCommunicationEvent pending;
|
|
lock (_gate)
|
|
{
|
|
if (index >= _pendingDispatch.Count)
|
|
{
|
|
_pendingDispatch.Clear();
|
|
_dispatching = false;
|
|
return;
|
|
}
|
|
pending = _pendingDispatch[index++];
|
|
}
|
|
Dispatch(in pending);
|
|
}
|
|
}
|
|
|
|
private void Dispatch(in RuntimeCommunicationEvent delta)
|
|
{
|
|
IRuntimeCommunicationObserver[] observers =
|
|
Volatile.Read(ref _observers);
|
|
foreach (IRuntimeCommunicationObserver observer in observers)
|
|
{
|
|
try
|
|
{
|
|
observer.OnChat(in delta);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
Interlocked.Increment(ref _dispatchFailureCount);
|
|
LastDispatchFailure = error;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void Unsubscribe(IRuntimeCommunicationObserver observer)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
IRuntimeCommunicationObserver[] current = _observers;
|
|
int index = Array.IndexOf(current, observer);
|
|
if (index < 0)
|
|
return;
|
|
if (current.Length == 1)
|
|
{
|
|
Volatile.Write(ref _observers, []);
|
|
return;
|
|
}
|
|
|
|
var replacement =
|
|
new IRuntimeCommunicationObserver[current.Length - 1];
|
|
if (index > 0)
|
|
Array.Copy(current, 0, replacement, 0, index);
|
|
if (index < current.Length - 1)
|
|
{
|
|
Array.Copy(
|
|
current,
|
|
index + 1,
|
|
replacement,
|
|
index,
|
|
current.Length - index - 1);
|
|
}
|
|
Volatile.Write(ref _observers, replacement);
|
|
}
|
|
}
|
|
|
|
private sealed class Subscription(
|
|
RuntimeCommunicationEventStream owner,
|
|
IRuntimeCommunicationObserver observer)
|
|
: IDisposable
|
|
{
|
|
private RuntimeCommunicationEventStream? _owner = owner;
|
|
|
|
public void Dispose() =>
|
|
Interlocked.Exchange(ref _owner, null)?.Unsubscribe(observer);
|
|
}
|
|
}
|