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
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue