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:
Erik 2026-08-10 12:10:20 +02:00
parent 41b408f3e6
commit 22020ef2c4
21 changed files with 1699 additions and 46 deletions

View file

@ -596,7 +596,11 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
Host: host,
Assets: assets,
Vitals: new VitalsRuntimeBindings(vitals),
Chat: new ChatRuntimeBindings(chat, () => late.Session.Commands),
Chat: new ChatRuntimeBindings(
chat,
() => late.Session.Commands,
d.Communication.ChatWindows,
layoutStore),
Radar: new RadarRuntimeBindings(
late.Radar.Snapshot,
d.Actions.Selection,

View file

@ -10,6 +10,9 @@ namespace AcDream.App.Input;
internal interface IRetainedGameplayWindowCommands
{
void ToggleInventory();
/// <summary>Toggle floating chat window <paramref name="windowId"/> (1-4).</summary>
void ToggleFloatingChatWindow(int windowId);
}
internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
@ -19,6 +22,9 @@ internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
public void ToggleInventory() =>
_runtime?.ToggleWindow(WindowNames.Inventory);
public void ToggleFloatingChatWindow(int windowId) =>
_runtime?.ToggleFloatingChatWindow(windowId);
}
internal interface IDevToolsGameplayCommands
@ -188,6 +194,18 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
case InputAction.ToggleInventoryPanel:
_retained.ToggleInventory();
return true;
case InputAction.ToggleFloatingChatWindow1:
_retained.ToggleFloatingChatWindow(1);
return true;
case InputAction.ToggleFloatingChatWindow2:
_retained.ToggleFloatingChatWindow(2);
return true;
case InputAction.ToggleFloatingChatWindow3:
_retained.ToggleFloatingChatWindow(3);
return true;
case InputAction.ToggleFloatingChatWindow4:
_retained.ToggleFloatingChatWindow(4);
return true;
case InputAction.AcdreamToggleAudioMute:
_toggleAudioMute?.Invoke();
return true;

View file

@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Shared word-wrap + retail color-carry-forward transcript line builder.
/// Factored out of <see cref="ChatWindowController.GetTranscriptLines"/>
/// (Campaign CH slice CH6b) so <see cref="FloatingChatWindowController"/>
/// reuses the exact same algorithm instead of duplicating it — both the main
/// chat window and the four floating windows are views over the SAME
/// <see cref="ChatVM"/> transcript (J4.1 pattern: one canonical log, many
/// filtered presentations).
///
/// <para>
/// Pure function — callers own their own per-controller layout cache
/// (revision/wrap-width/font keyed), matching the caching each controller
/// already had before this extraction.
/// </para>
/// </summary>
internal static class ChatTranscriptRenderer
{
/// <param name="detailed">Tail of the shared chat log, formatted with retail metadata.</param>
/// <param name="maxW">Wrap width in pixels.</param>
/// <param name="measure">Glyph-width measurer for the active font.</param>
/// <param name="accept">
/// Optional per-line filter — retail's <c>ChatInterface::TypeIsActive</c>
/// (or the full <c>ShouldDisplay</c> predicate) for THIS window. Null
/// accepts every line (the main window has no user filter — color-table
/// research doc §4). A line that fails the filter is dropped from this
/// window's view WITHOUT advancing the carried-forward color, matching
/// retail's <c>m_curFontColor</c> only advancing for lines actually
/// appended to THIS window's own scroll (<c>AppendStringInfoWithFont</c>
/// only runs for displayed lines).
/// </param>
public static List<UiText.Line> BuildLines(
IReadOnlyList<FormattedLine> detailed,
float maxW,
Func<string, float> measure,
Func<uint, bool>? accept)
{
var result = new List<UiText.Line>(detailed.Count);
if (detailed.Count == 0)
return result;
// Retail's font-color state (m_curFontColor) persists across every
// line actually appended to this window — an out-of-range LogTextType
// leaves it unchanged rather than reverting to a default (color-table
// doc §3.2). Seed the carry with retail's own unfilled-slot default
// (colorGreen, index 0x00).
RetailChatColorTable.TryGetColor(0x00u, out Vector4 currentColor);
foreach (FormattedLine d in detailed)
{
if (accept is not null && !accept(d.LogTextType))
continue;
if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved))
currentColor = resolved;
foreach (string frag in ChatWindowController.WrapText(d.Text, maxW, measure))
result.Add(new UiText.Line(frag, currentColor));
}
return result;
}
}

View file

@ -62,9 +62,10 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
// Chat-window 1-4 state-mirror indicator buttons
// (gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80): the button lights
// up iff the corresponding floating chat window is visible. The mechanism is
// one-directional (window visibility drives the button, not the reverse) and
// the floating windows themselves are CH6b's scope — these import generically
// (visible, inert) here and are left for CH6b to wire.
// one-directional (window visibility drives the button, never the reverse —
// see SetIndicatorOpen's doc for the decomp confirmation). Campaign CH slice
// CH6b resolves these to _indicatorButtons and wires them via SetIndicatorOpen,
// called by RetailUiRuntime whenever a floating chat window's visibility changes.
private const uint Indicator1Id = 0x10000522u;
private const uint Indicator2Id = 0x10000523u;
private const uint Indicator3Id = 0x10000524u;
@ -176,6 +177,11 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
private bool _maximized;
private UiButton? _maxMinButton;
// Chat-window 1-4 indicator buttons, indexed [windowId - 1]. Resolved at
// Bind() time (see the Indicator1Id..Indicator4Id class doc above);
// CH6b wires SetIndicatorOpen as their only writer.
private readonly UiButton?[] _indicatorButtons = new UiButton?[4];
// ── Factory ────────────────────────────────────────────────────────────
/// <summary>
@ -244,6 +250,14 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
if (layout.FindElement(id) is { } twin)
twin.Visible = false;
// ── Chat-window 1-4 indicator buttons — resolve now, wired later by
// RetailUiRuntime via SetIndicatorOpen as each floating window's own
// visibility changes (gmMainChatUI::RecvNotice_SetPanelVisibility
// @0x004CCD80 — see this class's doc + Indicator1Id..Indicator4Id). ──
uint[] indicatorIds = { Indicator1Id, Indicator2Id, Indicator3Id, Indicator4Id };
for (int i = 0; i < indicatorIds.Length; i++)
c._indicatorButtons[i] = layout.FindElement(indicatorIds[i]) as UiButton;
// ── Transcript ───────────────────────────────────────────────────
// The factory now builds the Type-12 transcript element (0x10000011) as a UiText.
// Find it in the widget tree and bind the live providers — no remove/add needed.
@ -460,6 +474,35 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
handle.MoveTo(frame.Left, targetTop);
}
/// <summary>
/// Mirror a floating chat window's (<paramref name="windowId"/> 1-4)
/// open/closed state onto its main-window indicator button — the exact
/// state transition <c>gmMainChatUI::RecvNotice_SetPanelVisibility
/// @0x004CCD80</c> performs: <c>State 6</c> (Highlight/"lit") when the
/// floating window is visible, <c>State 1</c> (Normal) when it is not.
///
/// <para>
/// One-directional by design — a decomp read of
/// <c>gmMainChatUI::ListenToElementMessage @0x004CDA80</c> (the ONLY
/// function in the whole 2013 binary that branches on
/// <c>idMessage == 1</c>, i.e. "clicked") shows it handles exactly two
/// element ids: <c>0x1000046f</c> (max/min) and the talk-focus menu's
/// selection message. There is no case for
/// <c>0x10000522</c>-<c>0x10000525</c> — clicking a chat-window
/// indicator button does NOTHING in retail. acdream ports this exactly:
/// these buttons have no <c>OnClick</c> (research doc §1.4 corrected —
/// its own hedge that "it is safe to wire both" is a weaker reading than
/// this direct decomp confirmation, and is now superseded by it).
/// </para>
/// </summary>
public void SetIndicatorOpen(int windowId, bool open)
{
if (windowId < 1 || windowId > _indicatorButtons.Length)
throw new ArgumentOutOfRangeException(nameof(windowId));
_indicatorButtons[windowId - 1]?.TrySetRetailState(
open ? UiButtonStateMachine.Highlight : UiButtonStateMachine.Normal);
}
public RetainedWindowState CaptureWindowState()
=> new(
Maximized: _maximized,
@ -524,27 +567,13 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
: debugFont is { } bf ? s => bf.MeasureWidth(s)
: static s => s.Length * 7f;
// Retail's font-color state (m_curFontColor) persists across every
// appended line — an out-of-range LogTextType leaves it unchanged
// rather than reverting to a default (research doc §3.2). Seed the
// carry with retail's own unfilled-slot default (colorGreen, index
// 0x00) and fold forward across the transcript in order. This is an
// approximation of retail's LayoutDesc-initialized m_curFontColor:
// acdream re-seeds at 0x00 and restarts the fold every render
// window rather than carrying one persistent field across the
// window's whole lifetime. Unreachable in practice today — no
// producer emits a LogTextType >= 0x22 (RetailChatColorTable.Colors
// covers the full 0x00-0x21 retail index space), so the carry path
// below never actually fires outside tests.
RetailChatColorTable.TryGetColor(0x00u, out Vector4 currentColor);
var result = new List<UiText.Line>(detailed.Count);
foreach (var d in detailed)
{
if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved))
currentColor = resolved;
foreach (var frag in WrapText(d.Text, maxW, measure))
result.Add(new UiText.Line(frag, currentColor));
}
// Campaign CH slice CH6b: the wrap + retail color-carry-forward
// algorithm is now shared with FloatingChatWindowController via
// ChatTranscriptRenderer (approximation note on the color carry
// moved there). The main window passes accept:null — it has no
// user filter (color-table research doc §4); this is behaviorally
// identical to the inline loop this replaced.
var result = ChatTranscriptRenderer.BuildLines(detailed, maxW, measure, accept: null);
return StoreTranscriptLayout(result, revision, maxW, datFont, debugFont);
}

View file

@ -0,0 +1,296 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds one of retail's four floating chat windows — LayoutDesc
/// <c>0x2100005B</c>, window elements <c>0x10000505</c>/<c>0x1000050E</c>/
/// <c>0x1000050F</c>/<c>0x10000510</c> — to live behavior (Campaign CH
/// slice CH6b). All four windows share the SAME LayoutDesc; only the
/// window-id attribute (<c>0x1000007E</c>, carried here as
/// <see cref="WindowId"/> rather than re-read from the dat, since acdream's
/// importer does not resolve LayoutDesc attributes into runtime state) and
/// the mounted screen position differ per instance.
///
/// <para>
/// Shares <see cref="ChatTranscriptRenderer"/> with
/// <see cref="ChatWindowController"/> (the main window) rather than
/// duplicating the word-wrap/color-carry algorithm — both are views over the
/// SAME <see cref="ChatVM"/> transcript (research doc §6.1's "one
/// ChatWindowController instance per window" recommendation, generalized to
/// two sibling classes because the main and floaty layouts diverge enough
/// in their own element sets — talk-focus menu, max/min button, four
/// indicator buttons on main; title bar and close button on floaty,
/// research doc §2.1 vs §2.2 — to make one shared Bind() unreadable).
/// </para>
///
/// <para>
/// A floaty window has no talk-focus menu (research doc §2.2), so its chat
/// entry always sends on <see cref="ChatChannelKind.Say"/> — there is no
/// authored control to pick another channel from this window.
/// </para>
/// </summary>
public sealed class FloatingChatWindowController : IRetainedPanelController
{
public const uint LayoutId = 0x2100005Bu;
// Element ids from floating chat LayoutDesc 0x2100005B (research doc §2.2).
private const uint RootId = 0x100004F7u; // window root, 250x108
private const uint TranscriptPanelId = 0x10000010u;
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
private const uint TrackId = 0x10000012u;
private const uint InputRowId = 0x10000509u; // NOTE: differs from the main window's 0x10000013
private const uint InputId = 0x10000016u; // Type-12 Text + Editable 0x16 -> UiField
private const uint SendId = 0x10000019u;
private const uint TitleBarId = 0x100004D9u; // gmFloatyChatUI::SetWindowTitle target
private const uint CloseButtonId = 0x1000052Au;
private bool _disposed;
/// <summary>The retail window id this instance is bound to (1-4).</summary>
public int WindowId { get; }
public UiElement Root { get; private set; } = null!;
public UiText Transcript { get; private set; } = null!;
public UiField Input { get; private set; } = null!;
public UiScrollbar? Scrollbar { get; private set; }
/// <summary>Resolved window-root metadata, including DAT size constraints.</summary>
public ElementInfo DatWindowInfo { get; private set; } = null!;
public RetailWindowHandle? WindowHandle { get; private set; }
// Same idle-frame caching shape as ChatWindowController — an unchanged
// transcript/filter/wrap-width does not re-wrap or re-resolve colors.
private IReadOnlyList<UiText.Line> _cachedTranscriptLines = Array.Empty<UiText.Line>();
private long _cachedTranscriptRevision = -1;
private ulong _cachedFilter;
private float _cachedTranscriptWrapWidth = float.NaN;
private UiDatFont? _cachedTranscriptDatFont;
private BitmapFont? _cachedTranscriptDebugFont;
internal int TranscriptLayoutBuildCount { get; private set; }
private FloatingChatWindowController(int windowId)
{
WindowId = windowId;
}
/// <summary>
/// Bind an imported floating-chat layout to live behavior.
/// </summary>
/// <param name="windowId">Retail chat window id, 1 through 4.</param>
/// <param name="rootInfo">Full <see cref="ElementInfo"/> tree from an
/// <see cref="LayoutImporter.ImportInfos"/> call against
/// <see cref="LayoutId"/>.</param>
/// <param name="layout">Widget tree from a matching
/// <see cref="LayoutImporter.Build"/> call — a FRESH call per window
/// instance, since four independent windows need four independent
/// widget trees even though they share one imported <c>rootInfo</c>.</param>
/// <param name="vm">The SAME chat view-model the main window binds —
/// one canonical transcript, filtered per window.</param>
/// <param name="busProvider">Factory for the live command bus at submit time.</param>
/// <param name="windowFilters">
/// Runtime's canonical per-window filter/open state
/// (<see cref="RuntimeCommunicationState.ChatWindows"/>). Read live on
/// every transcript rebuild — never copied — so a filter change is
/// visible on the next frame without a separate notification.
/// </param>
public static FloatingChatWindowController? Bind(
int windowId,
ElementInfo rootInfo,
ImportedLayout layout,
ChatVM vm,
Func<ICommandBus> busProvider,
ChatWindowState windowFilters,
UiDatFont? datFont,
BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> resolve)
{
if (windowId < ChatWindowState.MinFloatingWindowId || windowId > ChatWindowState.MaxFloatingWindowId)
throw new ArgumentOutOfRangeException(nameof(windowId));
ArgumentNullException.ThrowIfNull(windowFilters);
var transcriptPanel = layout.FindElement(TranscriptPanelId);
var inputRow = layout.FindElement(InputRowId);
var input = layout.FindElement(InputId) as UiField;
if (input is null || transcriptPanel is null || inputRow is null)
{
Console.WriteLine(
$"[D.2b] FloatingChatWindowController.Bind(window {windowId}): missing required elements " +
$"(input={input is not null}, panel={transcriptPanel is not null}, row={inputRow is not null}) — " +
$"floating chat window will not be interactive.");
return null;
}
var window = layout.FindElement(RootId) ?? layout.Root;
var c = new FloatingChatWindowController(windowId)
{
Root = window,
DatWindowInfo = FindInfo(rootInfo, RootId) ?? rootInfo,
};
// ── Transcript ───────────────────────────────────────────────────
c.Transcript = layout.FindElement(TranscriptId) as UiText
?? throw new InvalidOperationException("floating chat transcript 0x10000011 not built as UiText");
c.Transcript.DatFont = datFont;
c.Transcript.Font = debugFont;
c.Transcript.Centered = false;
c.Transcript.RightAligned = false;
c.Transcript.OneLine = false;
c.Transcript.Selectable = true;
c.Transcript.LinesProvider = () => c.GetTranscriptLines(vm, windowFilters);
// ── Input — no talk-focus menu on the floaty layout, so the
// channel is always Say (class doc). ─────────────────────────────
c.Input = input;
c.Input.DatFont = datFont;
c.Input.Font = debugFont;
c.Input.SpriteResolve = resolve;
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), ChatChannelKind.Say);
// Same right-edge-tracks-resize fix as the main window
// (ChatWindowController.Bind) — see that method's comment for the
// full retail edge-mode citation.
if (c.Input.LayoutPolicy is { } inputPolicy)
{
c.Input.LayoutPolicy = new UiLayoutPolicy(
inputPolicy.LeftMode,
inputPolicy.TopMode,
rightMode: 1u,
inputPolicy.BottomMode,
inputPolicy.OriginalChild,
inputPolicy.OriginalParent);
}
else
{
c.Input.Anchors |= AnchorEdges.Right;
}
// ── Scrollbar — bind the factory-built Type-11 track element ────
if (layout.FindElement(TrackId) is UiScrollbar bar)
{
bar.Model = c.Transcript.Scroll;
bar.SpriteResolve ??= resolve;
c.Scrollbar = bar;
}
// ── Send button ─────────────────────────────────────────────────
if (layout.FindElement(SendId) is UiButton sendEl)
{
sendEl.OnClick = () => c.Input.Submit();
sendEl.Label = "Send";
sendEl.LabelFont = datFont;
sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f);
}
// ── Title bar — gmFloatyChatUI::SetWindowTitle @0x004CEAA0 sets a
// localized "Chat N" string here; acdream has no LayoutDesc string
// table wired for it yet, so this is a hardcoded English label —
// the same stopgap the channel menu's item labels already use
// (ChatWindowController.ChannelItems). ──────────────────────────
if (layout.FindElement(TitleBarId) is UiText titleText)
{
titleText.DatFont = datFont;
titleText.Font = debugFont;
titleText.OneLine = true;
string title = $"Chat {windowId}";
var titleColor = new Vector4(1f, 0.92f, 0.72f, 1f);
titleText.LinesProvider = () => new[] { new UiText.Line(title, titleColor) };
}
// ── Close button — gmFloatyChatUI::ListenToElementMessage
// @0x004CE330: idMessage==1 (clicked) on 0x1000052A -> SetVisible(false). ──
if (layout.FindElement(CloseButtonId) is UiButton closeEl)
{
closeEl.OnClick = () => c.WindowHandle?.Hide();
}
return c;
}
/// <summary>
/// Attach the typed outer-frame handle after the controller's imported
/// content has been mounted. The close button needs this to hide the
/// window (retail's own <c>SetVisible(false)</c>).
/// </summary>
public void AttachWindow(RetailWindowHandle handle)
{
ArgumentNullException.ThrowIfNull(handle);
if (!ReferenceEquals(handle.ContentRoot, Root))
throw new ArgumentException(
"Floating chat handle content root does not match the bound layout.", nameof(handle));
if (WindowHandle is not null && !ReferenceEquals(WindowHandle, handle))
throw new InvalidOperationException("Floating chat controller is already attached to another window.");
WindowHandle = handle;
}
private static ElementInfo? FindInfo(ElementInfo node, uint id)
{
if (node.Id == id) return node;
foreach (ElementInfo child in node.Children)
{
ElementInfo? found = FindInfo(child, id);
if (found is not null) return found;
}
return null;
}
/// <summary>
/// Convert the shared ChatVM's detailed lines to this window's filtered,
/// wrapped, colored transcript — <see cref="ChatWindowState.TypeIsActive"/>
/// is retail's broadcast half of the display rule
/// (<c>ChatInterface::RecvNotice_DisplayFinalStringInfo</c>); the
/// explicit-address half is inert today because no production
/// <c>ChatEntry</c> carries a target window id yet (register row
/// AP-180 — see <see cref="ChatWindowState"/>'s class doc).
/// </summary>
private IReadOnlyList<UiText.Line> GetTranscriptLines(ChatVM vm, ChatWindowState windowFilters)
{
float maxW = Transcript.Width - 2f * Transcript.Padding;
UiDatFont? datFont = Transcript.DatFont;
BitmapFont? debugFont = Transcript.Font;
long revision = vm.Revision;
ulong filter = windowFilters.GetFilter(WindowId);
if (_cachedTranscriptRevision == revision
&& _cachedFilter == filter
&& _cachedTranscriptWrapWidth.Equals(maxW)
&& ReferenceEquals(_cachedTranscriptDatFont, datFont)
&& ReferenceEquals(_cachedTranscriptDebugFont, debugFont))
{
return _cachedTranscriptLines;
}
var detailed = vm.RecentLinesDetailed();
Func<string, float> measure =
datFont is { } df ? s => df.MeasureWidth(s)
: debugFont is { } bf ? s => bf.MeasureWidth(s)
: static s => s.Length * 7f;
bool Accept(uint logTextType) => windowFilters.ShouldDisplay(WindowId, targetWindowId: 0u, logTextType);
var result = ChatTranscriptRenderer.BuildLines(detailed, maxW, measure, Accept);
_cachedTranscriptRevision = revision;
_cachedFilter = filter;
_cachedTranscriptWrapWidth = maxW;
_cachedTranscriptDatFont = datFont;
_cachedTranscriptDebugFont = debugFont;
_cachedTranscriptLines = result;
TranscriptLayoutBuildCount++;
return result;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
}
}

View file

@ -6,6 +6,7 @@ using AcDream.App.Rendering;
using AcDream.App.Spells;
using AcDream.App.UI.Layout;
using AcDream.App.UI.Testing;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
@ -36,7 +37,29 @@ public sealed record RetailUiAssets(
public sealed record VitalsRuntimeBindings(VitalsVM ViewModel);
public sealed record ChatRuntimeBindings(ChatVM ViewModel, Func<ICommandBus> CommandBus);
/// <param name="Windows">
/// Runtime's canonical per-window filter/open state
/// (<see cref="AcDream.Runtime.Gameplay.RuntimeCommunicationState.ChatWindows"/>).
/// Campaign CH slice CH6b: the four floating chat windows read/write this
/// exact instance — never a presentation-owned copy.
/// </param>
/// <param name="Store">
/// Optional local settings store used to persist the four floating
/// windows' text-type filters (open/visible + geometry already persist for
/// free through the generic <see cref="RetailWindowLayoutPersistence"/>
/// path once each window is registered under a distinct
/// <see cref="WindowNames"/> entry). Null in hosts that don't have a
/// settings store wired (e.g. some tests) — filters then simply keep their
/// retail <c>PostInit</c> defaults for the session. The wire format
/// (retail's <c>0x1000008B</c>/<c>0x1000008C</c> gameplay-options blob) is
/// explicitly deferred — see
/// <c>docs/plans/2026-08-09-chat-parity-campaign.md</c>'s CH6f row.
/// </param>
public sealed record ChatRuntimeBindings(
ChatVM ViewModel,
Func<ICommandBus> CommandBus,
ChatWindowState Windows,
SettingsStore? Store = null);
public sealed record RadarRuntimeBindings(
Func<UiRadarSnapshot> Snapshot,
@ -225,6 +248,8 @@ public sealed class RetailUiRuntime : IDisposable
private RetailWindowLayoutPersistence? _persistence;
private RetailUiAutomationScriptRunner? _automation;
private readonly RetailPanelUiController _panelUi;
private ChatWindowController? _chatWindowController;
private readonly FloatingChatWindowController?[] _floatingChatControllers = new FloatingChatWindowController?[4];
private GameplayConfirmationController? _gameplayConfirmationController;
private RetailItemConfirmationController? _itemConfirmationController;
private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController;
@ -263,6 +288,7 @@ public sealed class RetailUiRuntime : IDisposable
MountVitals();
MountRadar();
MountChat();
MountFloatingChatWindows();
MountToolbar();
MountCombat();
MountSpellbook();
@ -500,7 +526,11 @@ public sealed class RetailUiRuntime : IDisposable
public void RestoreLayout() => _persistence?.RestoreAll();
public void SaveLayout() => _persistence?.SaveAll();
public void SaveLayout()
{
_persistence?.SaveAll();
SaveChatWindowFilters();
}
public void SaveNamedLayout(string profileName) => _persistence?.SaveNamed(profileName);
@ -511,6 +541,55 @@ public sealed class RetailUiRuntime : IDisposable
? _panelUi.TogglePanel(panelId)
: Host.ToggleWindow(name);
/// <summary>
/// The four floating chat window controllers, indexed <c>[windowId - 1]</c>
/// (null entries are windows whose required elements were missing —
/// see <see cref="MountFloatingChatWindows"/>).
/// </summary>
public IReadOnlyList<FloatingChatWindowController?> FloatingChatWindows => _floatingChatControllers;
/// <summary>
/// Toggle floating chat window <paramref name="windowId"/> (1-4) — the
/// <c>ToggleFloatingChatWindow1..4</c> keybind's landing point
/// (research doc §1.3: the keybind mechanism, not a button click, is
/// what drives this in retail). Routes through the generic
/// <see cref="UiHost.ToggleWindow"/>, whose visibility-change event
/// drives both the persisted open state
/// (<see cref="OnWindowVisibilityChanged"/> syncing
/// <see cref="ChatWindowState"/>) and the main window's indicator-button
/// mirror — one chokepoint for every trigger.
/// </summary>
public bool ToggleFloatingChatWindow(int windowId)
=> Host.ToggleWindow(FloatingChatWindowName(windowId));
private static string FloatingChatWindowName(int windowId) => windowId switch
{
1 => WindowNames.ChatWindow1,
2 => WindowNames.ChatWindow2,
3 => WindowNames.ChatWindow3,
4 => WindowNames.ChatWindow4,
_ => throw new ArgumentOutOfRangeException(nameof(windowId), windowId, "floating chat window id must be 1-4."),
};
/// <summary>
/// Capture the four floating windows' current text-type filters into
/// the local settings store (research doc §4.4/§6.1 — local-only until
/// CH6f's wire format). No-op when no store was wired.
/// </summary>
private void SaveChatWindowFilters()
{
if (_bindings.Chat.Store is not { } store) return;
ChatWindowState windows = _bindings.Chat.Windows;
ChatSettings current = store.LoadChat();
store.SaveChat(current with
{
ChatWindow1Filter = windows.GetFilter(1),
ChatWindow2Filter = windows.GetFilter(2),
ChatWindow3Filter = windows.GetFilter(3),
ChatWindow4Filter = windows.GetFilter(4),
});
}
public void CloseWindow(string name)
{
if (RetailPanelCatalog.TryGetPanelId(name, out uint panelId))
@ -543,6 +622,30 @@ public sealed class RetailUiRuntime : IDisposable
_panelUi.ObserveWindowVisibility(windowName, visible);
if (RetailPanelCatalog.TryGetPanelId(windowName, out uint panelId))
ToolbarController?.SetPanelOpen(panelId, visible);
if (TryGetFloatingChatWindowId(windowName, out int chatWindowId))
{
// Keep Runtime's canonical open flag in sync regardless of what
// triggered the visibility change (keybind, restored layout,
// or the window's own close button) — one write path, matching
// gmFloatyChatUI::SetVisible's own single call site
// (persistence research doc §4.2).
_bindings.Chat.Windows.SetOpen(chatWindowId, visible);
_chatWindowController?.SetIndicatorOpen(chatWindowId, visible);
}
}
private static bool TryGetFloatingChatWindowId(string windowName, out int windowId)
{
windowId = windowName switch
{
WindowNames.ChatWindow1 => 1,
WindowNames.ChatWindow2 => 2,
WindowNames.ChatWindow3 => 3,
WindowNames.ChatWindow4 => 4,
_ => 0,
};
return windowId != 0;
}
private ImportedLayout? Import(uint layoutId)
@ -755,9 +858,133 @@ public sealed class RetailUiRuntime : IDisposable
});
controller.AttachWindow(handle);
Host.Root.DefaultTextInput = controller.Input;
_chatWindowController = controller;
Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x2100006F).");
}
/// <summary>
/// Mount the four retail floating chat windows — always-resident
/// authored children per <c>gmGamePlayUI::SetupChildren @0x004E9EC0</c>
/// (research doc §1.1), all four sharing LayoutDesc <c>0x2100005B</c>
/// but each with its own widget tree, window-registry name, and
/// screen position; every one starts hidden
/// (<c>gmFloatyChatUI</c> instances are born closed and toggled by
/// keybind/indicator button — research doc §1.3-§1.4). Must run after
/// <see cref="MountChat"/> so <see cref="_chatWindowController"/> is
/// available for the indicator-button mirror wired in
/// <see cref="OnWindowVisibilityChanged"/>.
/// </summary>
private void MountFloatingChatWindows()
{
ElementInfo? info;
lock (_bindings.Assets.DatLock)
{
info = LayoutImporter.ImportInfos(_bindings.Assets.Dats, FloatingChatWindowController.LayoutId);
}
if (info is null)
{
Console.WriteLine("[D.2b] floating chat: LayoutDesc 0x2100005B not found.");
return;
}
ChatWindowState windowFilters = _bindings.Chat.Windows;
if (_bindings.Chat.Store is { } store)
{
// Local-only persistence (research doc §4.4/§6.1 — the retail
// 0x1000008B/0x1000008C gameplay-options wire is explicitly
// deferred to CH6f). Open/visible + geometry persist for free
// through the generic RetailWindowLayoutPersistence path once
// each window registers below; only the filter mask needs its
// own load/save leg.
ChatSettings chat = store.LoadChat();
windowFilters.SetFilter(1, chat.ChatWindow1Filter);
windowFilters.SetFilter(2, chat.ChatWindow2Filter);
windowFilters.SetFilter(3, chat.ChatWindow3Filter);
windowFilters.SetFilter(4, chat.ChatWindow4Filter);
}
// acdream-chosen default screen positions — retail's own authored
// rect (0,80 in the shared LayoutDesc's local space) is identical
// for all four windows, so a literal port would stack every window
// at the same spot on first open; stagger them the same way the
// main chat window's own (10,440) default is an acdream placement
// choice, not a retail-authored screen position (MountChat above).
(string windowName, float left, float top)[] slots =
{
(WindowNames.ChatWindow1, 440f, 40f),
(WindowNames.ChatWindow2, 440f, 170f),
(WindowNames.ChatWindow3, 440f, 300f),
(WindowNames.ChatWindow4, 440f, 430f),
};
var strings = new DatStringResolver(_bindings.Assets.Dats);
for (int windowId = 1; windowId <= 4; windowId++)
{
ImportedLayout layout;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve);
}
FloatingChatWindowController? controller = FloatingChatWindowController.Bind(
windowId,
info,
layout,
_bindings.Chat.ViewModel,
_bindings.Chat.CommandBus,
windowFilters,
_bindings.Assets.DefaultFont,
_bindings.Assets.DebugFont,
_bindings.Assets.ResolveSprite);
if (controller is null)
{
Console.WriteLine($"[D.2b] floating chat window {windowId}: required role elements missing in 0x2100005B.");
continue;
}
controller.Transcript.Keyboard = Host.Keyboard;
controller.Input.Keyboard = Host.Keyboard;
(string windowName, float left, float top) = slots[windowId - 1];
UiElement root = controller.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = windowName,
// Same reasoning as MountChat: 0x2100005B's root is its
// own complete chrome (title bar, close button, and
// whichever mix of Resizebar/Dragbar grips the DAT
// authors for this layout — CH6a's importer/UiRoot
// priority logic is fully data-driven and needs no
// per-layout grip-count knowledge here).
Chrome = RetailWindowChrome.Imported,
Left = left,
Top = top,
DatConstraintSource = controller.DatWindowInfo,
AuthoredGeometryRevision = 1,
ResizeX = true,
ResizeY = true,
// Born hidden (research doc §1.1) — opened only via
// ToggleFloatingChatWindow (keybind) or a saved
// "visible" layout entry restored by
// RetailWindowLayoutPersistence.
Visible = false,
Controller = controller,
});
controller.AttachWindow(handle);
_floatingChatControllers[windowId - 1] = controller;
}
Console.WriteLine("[D.2b] retail floating chat windows 1-4 from LayoutDesc importer (0x2100005B).");
}
private void MountToolbar()
{
ImportedLayout? layout = Import(0x21000016u);

View file

@ -11,6 +11,10 @@ public static class WindowNames
public const string Inventory = "inventory";
public const string ExternalContainer = "external-container";
public const string Chat = "chat";
public const string ChatWindow1 = "chat-window-1";
public const string ChatWindow2 = "chat-window-2";
public const string ChatWindow3 = "chat-window-3";
public const string ChatWindow4 = "chat-window-4";
public const string Radar = "radar";
public const string Combat = "combat";
public const string JumpPowerbar = "jump-powerbar";

View 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>&gt;= 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).");
}
}

View file

@ -66,6 +66,7 @@ public sealed class RuntimeCommunicationState : IDisposable
TurbineChat = new TurbineChatState();
Friends = new FriendsState();
Squelch = new SquelchState();
ChatWindows = new ChatWindowState();
View = new CommunicationView(Chat);
SocialView = new CommunicationSocialView(
TurbineChat,
@ -75,6 +76,15 @@ public sealed class RuntimeCommunicationState : IDisposable
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"/>
@ -206,6 +216,7 @@ public sealed class RuntimeCommunicationState : IDisposable
Squelch.Clear();
Chat.ResetSessionIdentity();
SpewBox.Reset();
ChatWindows.ResetToDefaults();
}
private sealed class CommunicationView(ChatLog chat) : IRuntimeChatView

View file

@ -46,7 +46,22 @@ public sealed record ChatSettings(
bool ShowTimestamps, // 0x40 — TimeStamp prefix on chat lines
bool FilterProfanity, // 0x20000 — FilterLanguage (Turbine's profanity filter)
// Visual / UX (no retail bitfield).
float FontSize) // chat panel font, 10..20 pt
float FontSize, // chat panel font, 10..20 pt
// Campaign CH slice CH6b: local-only persistence of the four floating
// chat windows' 64-bit text-type filters
// (AcDream.Core.Chat.ChatWindowState — retail's per-window
// 0x1000007F option, docs/research/2026-08-09-chat-retail-color-table.md
// §4). Retail persists these server-side inside the opaque
// 0x1000008C GameplayOptions blob (window-shell research doc §4.4);
// acdream has no writer for that blob yet, so these fields are the
// interim local store, with default values matching retail's own
// ChatInterface::PostInit @0x004F3DD0 seed exactly. Trailing with
// defaults so no existing positional/named ChatSettings construction
// site needed to change.
ulong ChatWindow1Filter = 0x0000101Cu, // Speech, Tell, Speech_Direct_Send, Emote
ulong ChatWindow2Filter = 0x00040C00u, // Social, Social_Send, Allegiance
ulong ChatWindow3Filter = 0x00080000u, // Fellowship
ulong ChatWindow4Filter = 0x78000000u) // Turbine General/Trade/LFG/Roleplay
{
/// <summary>
/// N4 (CH3 Opus review): matches ACE's ACTUAL

View file

@ -195,7 +195,11 @@ public sealed class SettingsStore
AppearOffline: ReadBool (chat, "appearOffline", d.AppearOffline),
ShowTimestamps: ReadBool (chat, "showTimestamps", d.ShowTimestamps),
FilterProfanity: ReadBool (chat, "filterProfanity", d.FilterProfanity),
FontSize: ReadFloat(chat, "fontSize", d.FontSize));
FontSize: ReadFloat(chat, "fontSize", d.FontSize),
ChatWindow1Filter: ReadULong(chat, "chatWindow1Filter", d.ChatWindow1Filter),
ChatWindow2Filter: ReadULong(chat, "chatWindow2Filter", d.ChatWindow2Filter),
ChatWindow3Filter: ReadULong(chat, "chatWindow3Filter", d.ChatWindow3Filter),
ChatWindow4Filter: ReadULong(chat, "chatWindow4Filter", d.ChatWindow4Filter));
}
catch (Exception ex)
{
@ -537,7 +541,11 @@ public sealed class SettingsStore
private static SortedDictionary<string, object> BuildChatObject(ChatSettings c)
=> new(StringComparer.Ordinal)
{
["appearOffline"] = c.AppearOffline,
["appearOffline"] = c.AppearOffline,
["chatWindow1Filter"] = c.ChatWindow1Filter,
["chatWindow2Filter"] = c.ChatWindow2Filter,
["chatWindow3Filter"] = c.ChatWindow3Filter,
["chatWindow4Filter"] = c.ChatWindow4Filter,
["filterProfanity"] = c.FilterProfanity,
["fontSize"] = c.FontSize,
["hearGeneralChat"] = c.HearGeneralChat,
@ -655,6 +663,13 @@ public sealed class SettingsStore
=> obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number
? el.GetSingle() : fallback;
private static ulong ReadULong(JsonElement obj, string name, ulong fallback)
{
if (!obj.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.Number)
return fallback;
return el.TryGetUInt64(out ulong value) ? value : fallback;
}
private static QualityPreset ReadQuality(JsonElement obj, string name, QualityPreset fallback)
{
if (!obj.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String)