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
|
|
@ -284,6 +284,37 @@ no-workarounds rule forbids without explicit approval.
|
||||||
collision generation spans more than a couple of scheduler ticks (the
|
collision generation spans more than a couple of scheduler ticks (the
|
||||||
default case against a real DAT-loaded landblock).
|
default case against a real DAT-loaded landblock).
|
||||||
|
|
||||||
|
## #369 — Unconfirmed whether retail's floating chat windows share the main window's currently-selected talk-focus channel
|
||||||
|
|
||||||
|
**Status:** OPEN — filed 2026-08-10, Campaign CH slice CH6b (register row
|
||||||
|
AP-188). The floating chat window LayoutDesc (`0x2100005B`) authors no
|
||||||
|
talk-focus menu (`docs/research/2026-08-09-chat-retail-window-shell.md`
|
||||||
|
§2.2 — only the main window's `0x2100006F` has one, element `0x10000014`),
|
||||||
|
so acdream's `FloatingChatWindowController` hardcodes every floaty window's
|
||||||
|
chat entry to send on `ChatChannelKind.Say`. What is UNVERIFIED is retail's
|
||||||
|
actual send path: does a floaty `ChatInterface` instance's typed message go
|
||||||
|
out on a per-window channel (also always Say, since there's nothing to pick
|
||||||
|
from), or does it read the single globally-current talk-focus
|
||||||
|
channel/target the MAIN window's menu (and `gmMainChatUI::UseTime
|
||||||
|
@0x004CDB20`'s selected-target tracking) last set? If the latter, a real
|
||||||
|
retail floaty window sends on whatever channel the player most recently
|
||||||
|
picked from the main window — acdream would then need to promote
|
||||||
|
`ChatWindowController`'s private `_activeChannel` to a shared owner all
|
||||||
|
five window controllers read, rather than each owning its own (the main
|
||||||
|
window keeps its own local state; the four floaties currently have no
|
||||||
|
state at all, just the Say constant).
|
||||||
|
|
||||||
|
**Where:** `src/AcDream.App/UI/Layout/FloatingChatWindowController.cs`
|
||||||
|
(`Bind`'s `OnSubmit`); `src/AcDream.App/UI/Layout/ChatWindowController.cs`
|
||||||
|
(`_activeChannel`, the eventual shared-state candidate).
|
||||||
|
|
||||||
|
**Fix shape (needs research first):** trace `gmCCommunicationSystem`'s
|
||||||
|
send-command path starting from a floaty `ChatInterface` instance (not the
|
||||||
|
main window) to confirm which channel/target it actually uses; if it's
|
||||||
|
shared, wire a single shared active-channel owner (Runtime-level, matching
|
||||||
|
the J4.1 pattern the rest of chat state now follows) that all five
|
||||||
|
controllers read instead of the main window's private field.
|
||||||
|
|
||||||
## #366 — Chat window's new-unseen-text indicator (0x1000048C) imports but is never independently wired
|
## #366 — Chat window's new-unseen-text indicator (0x1000048C) imports but is never independently wired
|
||||||
|
|
||||||
**Status:** OPEN — filed 2026-08-10, Campaign CH slice CH6a. The retail main
|
**Status:** OPEN — filed 2026-08-10, Campaign CH slice CH6a. The retail main
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -43,10 +43,13 @@ mounts flush to the viewport top and resolves a real (smaller) retail dat
|
||||||
font instead of the unwired 15px debug fallback; bare `/help` and
|
font instead of the unwired 15px debug fallback; bare `/help` and
|
||||||
`/help <verb>` (including `/help death`) now print retail's exact
|
`/help <verb>` (including `/help death`) now print retail's exact
|
||||||
`DoHelp` shape — two scroll entries in the right order, not one
|
`DoHelp` shape — two scroll entries in the right order, not one
|
||||||
acdream-invented blob. Status stays CODE-COMPLETE pending the next user
|
acdream-invented blob. **CH6b (floating chat windows 1-4) landed
|
||||||
gate round (still needed for CH6a's own visual confirmation, CH6b/CH6c,
|
CODE-COMPLETE 2026-08-10** under this session's hard constraints (no
|
||||||
round 3's fixes, and a final in-client visual pass on everything fixed so
|
subagents, no client launches) — see its ledger row and Slices bullet;
|
||||||
far).
|
CH6c (opacity) remains not started. Status stays CODE-COMPLETE pending
|
||||||
|
the next user gate round (still needed for CH6a's own visual
|
||||||
|
confirmation, CH6b's keybind/mirror/filter behavior, CH6c, round 3's
|
||||||
|
fixes, and a final in-client visual pass on everything fixed so far).
|
||||||
|
|
||||||
**Why now:** first track of the alpha-release program (chat is the most
|
**Why now:** first track of the alpha-release program (chat is the most
|
||||||
visible daily surface for the friend-alpha). User-directed 2026-08-09.
|
visible daily surface for the friend-alpha). User-directed 2026-08-09.
|
||||||
|
|
@ -179,6 +182,37 @@ implementer per slice against a pinned contract (per
|
||||||
the toggle; per-window PostInit filter defaults (color research §4)
|
the toggle; per-window PostInit filter defaults (color research §4)
|
||||||
with the `windowId == m_eWindowID OR (windowId==0 && TypeIsActive)`
|
with the `windowId == m_eWindowID OR (windowId==0 && TypeIsActive)`
|
||||||
display rule.
|
display rule.
|
||||||
|
**CODE-COMPLETE 2026-08-10 (this commit — hard constraint: no
|
||||||
|
subagents, no client launches).** `ChatWindowState` (Runtime, borrowed
|
||||||
|
from `RuntimeCommunicationState.ChatWindows`) owns the exact
|
||||||
|
PostInit-default filters + open flags for ids 0-4 and the full
|
||||||
|
`ShouldDisplay(windowId, targetWindowId, logTextType)` predicate;
|
||||||
|
`FloatingChatWindowController` (new sibling to `ChatWindowController`,
|
||||||
|
sharing the wrap/color algorithm via the new `ChatTranscriptRenderer`)
|
||||||
|
binds all four windows, each importing its own widget tree from one
|
||||||
|
shared `0x2100005B` `ElementInfo` parse. The keymap default confirmed
|
||||||
|
**Alt+1..4** (`MetaKeys` index 3 = `0x00000004`, cross-checked against
|
||||||
|
the file's own Alt+A/D strafe and Alt+Enter/Tab/F4 rows — `KeyBindings`
|
||||||
|
already carried this binding since Phase K.1c). A direct decomp read of
|
||||||
|
`gmMainChatUI::ListenToElementMessage @0x004CDA80` (the only function
|
||||||
|
in the binary that branches on a click message) settled the
|
||||||
|
button-mirror-vs-toggle question the research doc had left as a
|
||||||
|
hedge: **the four indicator buttons carry NO click handler in
|
||||||
|
retail** — `ChatWindowController.SetIndicatorOpen` ports this as a
|
||||||
|
pure one-directional mirror, no `OnClick`. `RetailUiRuntime.
|
||||||
|
OnWindowVisibilityChanged` is the single chokepoint that both syncs
|
||||||
|
`ChatWindowState.SetOpen` and calls the indicator mirror, regardless
|
||||||
|
of what changed a window's visibility (keybind, close button, or a
|
||||||
|
restored layout). Geometry + open/visible persist for free through
|
||||||
|
the existing `RetailWindowLayoutPersistence` path once each window
|
||||||
|
registers under its own `WindowNames` entry; the filter masks get a
|
||||||
|
dedicated local `ChatSettings` round-trip (register row AP-187 — no
|
||||||
|
retail `0x1000008C` wire yet). One approximation, register row
|
||||||
|
AP-188: a floaty window's entry field always sends on `Say` (no
|
||||||
|
talk-focus menu is authored on `0x2100005B`, and whether retail's
|
||||||
|
ACTUAL send path reads a per-window or a shared globally-current
|
||||||
|
channel is unconfirmed). Full Release suite 12,392 passed / 4
|
||||||
|
skipped / 0 failed.
|
||||||
- **CH6c — opacity.** Implement `UiRenderContext.AlphaMod`
|
- **CH6c — opacity.** Implement `UiRenderContext.AlphaMod`
|
||||||
consumption (whole-composited-window alpha per
|
consumption (whole-composited-window alpha per
|
||||||
`ChatInterface::SetOpacity @0x004F3120`); the two GLOBAL retail
|
`ChatInterface::SetOpacity @0x004F3120`); the two GLOBAL retail
|
||||||
|
|
@ -209,11 +243,13 @@ implementer per slice against a pinned contract (per
|
||||||
| CH5 closeout | (this commit) | — (docs/memory only, no build) | — | pending (connected gate — see test script) |
|
| CH5 closeout | (this commit) | — (docs/memory only, no build) | — | pending (connected gate — see test script) |
|
||||||
| User gate round 1 | (this commit) | 12,221 passed / 4 skipped / 0 failed (baseline; items A–G fixed this commit) | — | items A–G user-gate round 1 fixed; ten findings total, see "User gate — round 1" below |
|
| User gate round 1 | (this commit) | 12,221 passed / 4 skipped / 0 failed (baseline; items A–G fixed this commit) | — | items A–G user-gate round 1 fixed; ten findings total, see "User gate — round 1" below |
|
||||||
| CH6a main-window layout + 8-grip resize | (this commit) | 12,317 passed / 4 skipped / 0 failed | pending (no subagent review pass this session — implementer-only) | pending — needs the next in-client round (items H/I round 1, item 6 round 2) |
|
| CH6a main-window layout + 8-grip resize | (this commit) | 12,317 passed / 4 skipped / 0 failed | pending (no subagent review pass this session — implementer-only) | pending — needs the next in-client round (items H/I round 1, item 6 round 2) |
|
||||||
| CH6b/CH6c floating windows + opacity | not started | — | — | not started |
|
| CH6b/CH6c floating windows + opacity | superseded — split below | — | — | superseded |
|
||||||
| User gate round 2 | (this commit) | 12,267 passed / 4 skipped / 0 failed | — | items 2/4/5 fixed this commit, item 3 confirmed-fixed, item 6 folded into CH6a's spec, item 1 NOT reproduced (see "User gate — round 2" below) |
|
| User gate round 2 | (this commit) | 12,267 passed / 4 skipped / 0 failed | — | items 2/4/5 fixed this commit, item 3 confirmed-fixed, item 6 folded into CH6a's spec, item 1 NOT reproduced (see "User gate — round 2" below) |
|
||||||
| CH6a main-window layout + 8-grip resize | `1fd51543` | 12,317 passed / 4 skipped / 0 failed | pending | pending — landed same day as round 2 |
|
| CH6a main-window layout + 8-grip resize | `1fd51543` | 12,317 passed / 4 skipped / 0 failed | pending | pending — landed same day as round 2 |
|
||||||
| Jump-in-air root cause (round-2 item 1, resolved) | `a5a7eb4f` | Runtime tests 1,323/0 | — | round-3 probe evidence pinpointed a missing `OnInterfaceText` wire on the production controller-commit path (`RuntimeLocalPlayerMovementState.CommitRuntimeOwnedController`); FIXED, regression test added |
|
| Jump-in-air root cause (round-2 item 1, resolved) | `a5a7eb4f` | Runtime tests 1,323/0 | — | round-3 probe evidence pinpointed a missing `OnInterfaceText` wire on the production controller-commit path (`RuntimeLocalPlayerMovementState.CommitRuntimeOwnedController`); FIXED, regression test added |
|
||||||
| User gate round 3 | (this commit) | Debug (all projects): 12,329 passed / 4 skipped / 1 failed (pre-existing #351 Debug-only flake — reproduces identically on the pristine pre-round-3 commit, not a regression); Release (every project reachable while a live `AcDream.App.exe` client — PID 15064, must not be killed per project policy — holds its own Release binaries locked, blocking `AcDream.App`/`AcDream.App.Tests`/`AcDream.Core.Tests` specifically): `AcDream.UI.Abstractions.Tests` (the layer this round's `/help` fix lives in) 867/867, plus `Core.Net.Tests` 823/823, `Runtime.Tests` 1,323/1,323, `Content.Tests` 130/130, `Headless.Tests` 89/89, `Bake.Tests` 15/15, `Cli.Tests` 4/4 — all 0 failed | — | findings (a)-(c) fixed this commit — SpewBox flush-top + retail dat font, `/help`/`/help death` exact retail print sequence (see "User gate — round 3" below) |
|
| User gate round 3 | (this commit) | Debug (all projects): 12,329 passed / 4 skipped / 1 failed (pre-existing #351 Debug-only flake — reproduces identically on the pristine pre-round-3 commit, not a regression); Release (every project reachable while a live `AcDream.App.exe` client — PID 15064, must not be killed per project policy — holds its own Release binaries locked, blocking `AcDream.App`/`AcDream.App.Tests`/`AcDream.Core.Tests` specifically): `AcDream.UI.Abstractions.Tests` (the layer this round's `/help` fix lives in) 867/867, plus `Core.Net.Tests` 823/823, `Runtime.Tests` 1,323/1,323, `Content.Tests` 130/130, `Headless.Tests` 89/89, `Bake.Tests` 15/15, `Cli.Tests` 4/4 — all 0 failed | — | findings (a)-(c) fixed this commit — SpewBox flush-top + retail dat font, `/help`/`/help death` exact retail print sequence (see "User gate — round 3" below) |
|
||||||
|
| CH6b floating windows 1–4 | (this commit) | 12,392 passed / 4 skipped / 0 failed | pending (no subagent review pass this session — implementer-only, per this session's HARD CONSTRAINT of no subagents) | pending — no client launches this session (hard constraint); needs the next connected round for keybind/mirror/filter visual confirmation |
|
||||||
|
| CH6c opacity | not started | — | — | not started |
|
||||||
|
|
||||||
### CH4 closeout (2026-08-09)
|
### CH4 closeout (2026-08-09)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,16 +151,28 @@ State 6 = "on/depressed", state 1 = "normal". No handler anywhere in the binary
|
||||||
switches on `0x10000522..0x10000525` as a *source* of a click — `grep` over the
|
switches on `0x10000522..0x10000525` as a *source* of a click — `grep` over the
|
||||||
whole pseudo-C returns only `gmMainChatUI::RecvNotice_SetPanelVisibility`.
|
whole pseudo-C returns only `gmMainChatUI::RecvNotice_SetPanelVisibility`.
|
||||||
|
|
||||||
UNVERIFIED (and the one place I would not guess): whether clicking those four
|
**RESOLVED 2026-08-10 at Campaign CH slice CH6b.** The prior UNVERIFIED
|
||||||
buttons does anything in retail at all. Two readings are consistent with the
|
paragraph's hedge ("safe to wire both") is superseded by a direct read of
|
||||||
decomp — (a) they are pure indicators, and (b) they carry the same LayoutDesc
|
`gmMainChatUI::ListenToElementMessage @0x004CDA80` — the ONLY function in the
|
||||||
property `0x24` input-action value as the windows themselves, so a click routes
|
whole 2013 binary that branches on `idMessage == 1` ("clicked"). It handles
|
||||||
through the generic `UIElement` action path rather than through any chat code.
|
exactly two element ids: `0x1000046f` (max/min, dispatching
|
||||||
(b) is the more likely reading given §1.3's generic mechanism. Cheapest
|
`HandleMaximizeButton`) and the talk-focus menu's selection message
|
||||||
resolution: the same LayoutDesc property dump as above, reading property `0x24`
|
(`idMessage == 7`, checked against `this->m_pCCS` / a `0x1000000b` attribute
|
||||||
on `0x10000522`–`0x10000525`. **For CH6 it is safe to wire both: keybind AND
|
read). There is no case, anywhere in that function or its base-class fallback
|
||||||
button click both call the same toggle**, because retail's observable behaviour
|
(`ChatInterface::ListenToElementMessage`, called unconditionally at the
|
||||||
(button lights up iff window is visible) is satisfied either way.
|
function's tail), for `0x10000522`–`0x10000525`. **Clicking a chat-window
|
||||||
|
indicator button does NOTHING in retail — reading (a), pure indicator, is
|
||||||
|
correct; reading (b) is refuted.** The `0x24` input-action-property theory
|
||||||
|
in reading (b) does not even apply to a mouse click on the button itself: that
|
||||||
|
property only wires *keyboard* dispatch (`UIElementManager::
|
||||||
|
DoVisibilityToggleAction` in §1.3), not a button's own `UIElement` click
|
||||||
|
message, which routes to its LISTENING PARENT — and that parent's handler has
|
||||||
|
no case for these four ids. acdream ports this exactly: the four indicator
|
||||||
|
buttons (`ChatWindowController._indicatorButtons`) carry no `OnClick` at all;
|
||||||
|
`ChatWindowController.SetIndicatorOpen` is their only writer, called only from
|
||||||
|
`RetailUiRuntime.OnWindowVisibilityChanged` in response to the floating
|
||||||
|
window's own visibility changing (keybind or otherwise) — a pure one-directional
|
||||||
|
mirror, matching retail exactly.
|
||||||
|
|
||||||
### 1.5 Closing a floaty window from its own title bar
|
### 1.5 Closing a floaty window from its own title bar
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -596,7 +596,11 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
Host: host,
|
Host: host,
|
||||||
Assets: assets,
|
Assets: assets,
|
||||||
Vitals: new VitalsRuntimeBindings(vitals),
|
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(
|
Radar: new RadarRuntimeBindings(
|
||||||
late.Radar.Snapshot,
|
late.Radar.Snapshot,
|
||||||
d.Actions.Selection,
|
d.Actions.Selection,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ namespace AcDream.App.Input;
|
||||||
internal interface IRetainedGameplayWindowCommands
|
internal interface IRetainedGameplayWindowCommands
|
||||||
{
|
{
|
||||||
void ToggleInventory();
|
void ToggleInventory();
|
||||||
|
|
||||||
|
/// <summary>Toggle floating chat window <paramref name="windowId"/> (1-4).</summary>
|
||||||
|
void ToggleFloatingChatWindow(int windowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
|
internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
|
||||||
|
|
@ -19,6 +22,9 @@ internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
|
||||||
|
|
||||||
public void ToggleInventory() =>
|
public void ToggleInventory() =>
|
||||||
_runtime?.ToggleWindow(WindowNames.Inventory);
|
_runtime?.ToggleWindow(WindowNames.Inventory);
|
||||||
|
|
||||||
|
public void ToggleFloatingChatWindow(int windowId) =>
|
||||||
|
_runtime?.ToggleFloatingChatWindow(windowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal interface IDevToolsGameplayCommands
|
internal interface IDevToolsGameplayCommands
|
||||||
|
|
@ -188,6 +194,18 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
||||||
case InputAction.ToggleInventoryPanel:
|
case InputAction.ToggleInventoryPanel:
|
||||||
_retained.ToggleInventory();
|
_retained.ToggleInventory();
|
||||||
return true;
|
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:
|
case InputAction.AcdreamToggleAudioMute:
|
||||||
_toggleAudioMute?.Invoke();
|
_toggleAudioMute?.Invoke();
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
65
src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs
Normal file
65
src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -62,9 +62,10 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
||||||
// Chat-window 1-4 state-mirror indicator buttons
|
// Chat-window 1-4 state-mirror indicator buttons
|
||||||
// (gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80): the button lights
|
// (gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80): the button lights
|
||||||
// up iff the corresponding floating chat window is visible. The mechanism is
|
// up iff the corresponding floating chat window is visible. The mechanism is
|
||||||
// one-directional (window visibility drives the button, not the reverse) and
|
// one-directional (window visibility drives the button, never the reverse —
|
||||||
// the floating windows themselves are CH6b's scope — these import generically
|
// see SetIndicatorOpen's doc for the decomp confirmation). Campaign CH slice
|
||||||
// (visible, inert) here and are left for CH6b to wire.
|
// 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 Indicator1Id = 0x10000522u;
|
||||||
private const uint Indicator2Id = 0x10000523u;
|
private const uint Indicator2Id = 0x10000523u;
|
||||||
private const uint Indicator3Id = 0x10000524u;
|
private const uint Indicator3Id = 0x10000524u;
|
||||||
|
|
@ -176,6 +177,11 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
||||||
private bool _maximized;
|
private bool _maximized;
|
||||||
private UiButton? _maxMinButton;
|
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 ────────────────────────────────────────────────────────────
|
// ── Factory ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -244,6 +250,14 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
||||||
if (layout.FindElement(id) is { } twin)
|
if (layout.FindElement(id) is { } twin)
|
||||||
twin.Visible = false;
|
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 ───────────────────────────────────────────────────
|
// ── Transcript ───────────────────────────────────────────────────
|
||||||
// The factory now builds the Type-12 transcript element (0x10000011) as a UiText.
|
// 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.
|
// 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);
|
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()
|
public RetainedWindowState CaptureWindowState()
|
||||||
=> new(
|
=> new(
|
||||||
Maximized: _maximized,
|
Maximized: _maximized,
|
||||||
|
|
@ -524,27 +567,13 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
||||||
: debugFont is { } bf ? s => bf.MeasureWidth(s)
|
: debugFont is { } bf ? s => bf.MeasureWidth(s)
|
||||||
: static s => s.Length * 7f;
|
: static s => s.Length * 7f;
|
||||||
|
|
||||||
// Retail's font-color state (m_curFontColor) persists across every
|
// Campaign CH slice CH6b: the wrap + retail color-carry-forward
|
||||||
// appended line — an out-of-range LogTextType leaves it unchanged
|
// algorithm is now shared with FloatingChatWindowController via
|
||||||
// rather than reverting to a default (research doc §3.2). Seed the
|
// ChatTranscriptRenderer (approximation note on the color carry
|
||||||
// carry with retail's own unfilled-slot default (colorGreen, index
|
// moved there). The main window passes accept:null — it has no
|
||||||
// 0x00) and fold forward across the transcript in order. This is an
|
// user filter (color-table research doc §4); this is behaviorally
|
||||||
// approximation of retail's LayoutDesc-initialized m_curFontColor:
|
// identical to the inline loop this replaced.
|
||||||
// acdream re-seeds at 0x00 and restarts the fold every render
|
var result = ChatTranscriptRenderer.BuildLines(detailed, maxW, measure, accept: null);
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
return StoreTranscriptLayout(result, revision, maxW, datFont, debugFont);
|
return StoreTranscriptLayout(result, revision, maxW, datFont, debugFont);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
296
src/AcDream.App/UI/Layout/FloatingChatWindowController.cs
Normal file
296
src/AcDream.App/UI/Layout/FloatingChatWindowController.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ using AcDream.App.Rendering;
|
||||||
using AcDream.App.Spells;
|
using AcDream.App.Spells;
|
||||||
using AcDream.App.UI.Layout;
|
using AcDream.App.UI.Layout;
|
||||||
using AcDream.App.UI.Testing;
|
using AcDream.App.UI.Testing;
|
||||||
|
using AcDream.Core.Chat;
|
||||||
using AcDream.Core.Combat;
|
using AcDream.Core.Combat;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
using AcDream.Core.Net;
|
using AcDream.Core.Net;
|
||||||
|
|
@ -36,7 +37,29 @@ public sealed record RetailUiAssets(
|
||||||
|
|
||||||
public sealed record VitalsRuntimeBindings(VitalsVM ViewModel);
|
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(
|
public sealed record RadarRuntimeBindings(
|
||||||
Func<UiRadarSnapshot> Snapshot,
|
Func<UiRadarSnapshot> Snapshot,
|
||||||
|
|
@ -225,6 +248,8 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
private RetailWindowLayoutPersistence? _persistence;
|
private RetailWindowLayoutPersistence? _persistence;
|
||||||
private RetailUiAutomationScriptRunner? _automation;
|
private RetailUiAutomationScriptRunner? _automation;
|
||||||
private readonly RetailPanelUiController _panelUi;
|
private readonly RetailPanelUiController _panelUi;
|
||||||
|
private ChatWindowController? _chatWindowController;
|
||||||
|
private readonly FloatingChatWindowController?[] _floatingChatControllers = new FloatingChatWindowController?[4];
|
||||||
private GameplayConfirmationController? _gameplayConfirmationController;
|
private GameplayConfirmationController? _gameplayConfirmationController;
|
||||||
private RetailItemConfirmationController? _itemConfirmationController;
|
private RetailItemConfirmationController? _itemConfirmationController;
|
||||||
private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController;
|
private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController;
|
||||||
|
|
@ -263,6 +288,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
MountVitals();
|
MountVitals();
|
||||||
MountRadar();
|
MountRadar();
|
||||||
MountChat();
|
MountChat();
|
||||||
|
MountFloatingChatWindows();
|
||||||
MountToolbar();
|
MountToolbar();
|
||||||
MountCombat();
|
MountCombat();
|
||||||
MountSpellbook();
|
MountSpellbook();
|
||||||
|
|
@ -500,7 +526,11 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
|
|
||||||
public void RestoreLayout() => _persistence?.RestoreAll();
|
public void RestoreLayout() => _persistence?.RestoreAll();
|
||||||
|
|
||||||
public void SaveLayout() => _persistence?.SaveAll();
|
public void SaveLayout()
|
||||||
|
{
|
||||||
|
_persistence?.SaveAll();
|
||||||
|
SaveChatWindowFilters();
|
||||||
|
}
|
||||||
|
|
||||||
public void SaveNamedLayout(string profileName) => _persistence?.SaveNamed(profileName);
|
public void SaveNamedLayout(string profileName) => _persistence?.SaveNamed(profileName);
|
||||||
|
|
||||||
|
|
@ -511,6 +541,55 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
? _panelUi.TogglePanel(panelId)
|
? _panelUi.TogglePanel(panelId)
|
||||||
: Host.ToggleWindow(name);
|
: 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)
|
public void CloseWindow(string name)
|
||||||
{
|
{
|
||||||
if (RetailPanelCatalog.TryGetPanelId(name, out uint panelId))
|
if (RetailPanelCatalog.TryGetPanelId(name, out uint panelId))
|
||||||
|
|
@ -543,6 +622,30 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
_panelUi.ObserveWindowVisibility(windowName, visible);
|
_panelUi.ObserveWindowVisibility(windowName, visible);
|
||||||
if (RetailPanelCatalog.TryGetPanelId(windowName, out uint panelId))
|
if (RetailPanelCatalog.TryGetPanelId(windowName, out uint panelId))
|
||||||
ToolbarController?.SetPanelOpen(panelId, visible);
|
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)
|
private ImportedLayout? Import(uint layoutId)
|
||||||
|
|
@ -755,9 +858,133 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
});
|
});
|
||||||
controller.AttachWindow(handle);
|
controller.AttachWindow(handle);
|
||||||
Host.Root.DefaultTextInput = controller.Input;
|
Host.Root.DefaultTextInput = controller.Input;
|
||||||
|
_chatWindowController = controller;
|
||||||
Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x2100006F).");
|
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()
|
private void MountToolbar()
|
||||||
{
|
{
|
||||||
ImportedLayout? layout = Import(0x21000016u);
|
ImportedLayout? layout = Import(0x21000016u);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ public static class WindowNames
|
||||||
public const string Inventory = "inventory";
|
public const string Inventory = "inventory";
|
||||||
public const string ExternalContainer = "external-container";
|
public const string ExternalContainer = "external-container";
|
||||||
public const string Chat = "chat";
|
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 Radar = "radar";
|
||||||
public const string Combat = "combat";
|
public const string Combat = "combat";
|
||||||
public const string JumpPowerbar = "jump-powerbar";
|
public const string JumpPowerbar = "jump-powerbar";
|
||||||
|
|
|
||||||
199
src/AcDream.Core/Chat/ChatWindowState.cs
Normal file
199
src/AcDream.Core/Chat/ChatWindowState.cs
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's per-chat-window text-type filter and open/visible state — window
|
||||||
|
/// id <c>0</c> is the main chat window, ids <c>1</c>-<c>4</c> are the four
|
||||||
|
/// floating chat windows (Campaign CH slice CH6b,
|
||||||
|
/// <c>docs/research/2026-08-09-chat-retail-window-shell.md</c> §1.2 and
|
||||||
|
/// <c>docs/research/2026-08-09-chat-retail-color-table.md</c> §4).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Ports two retail mechanisms exactly:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>ChatInterface::PostInit @0x004F3DD0</c>'s <c>m_oldState</c>
|
||||||
|
/// switch seeds each window's default 64-bit
|
||||||
|
/// <c>m_llTextTypeFilter</c> (color-table doc §4's table — the constants
|
||||||
|
/// below are byte-identical to that table).</item>
|
||||||
|
/// <item><c>ChatInterface::RecvNotice_DisplayFinalStringInfo
|
||||||
|
/// @0x004F4640</c>'s display predicate: a line shows in window
|
||||||
|
/// <c>W</c> when the message's target window id equals <c>W</c>
|
||||||
|
/// (explicit addressing) OR the message is broadcast (target id
|
||||||
|
/// <c>0</c>) AND <c>W</c>'s filter accepts the line's
|
||||||
|
/// <see cref="RetailLogTextType"/> (<c>ChatInterface::TypeIsActive
|
||||||
|
/// @0x004F2F10</c>).</item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <c>UpdateFromPlayerModule @0x004F3920</c> early-returns for window id
|
||||||
|
/// <c>0</c> — the main window never has a user-settable filter. That
|
||||||
|
/// invariant needs no special case here: for <c>windowId == 0</c>,
|
||||||
|
/// <see cref="ShouldDisplay"/>'s first branch (<c>targetWindowId ==
|
||||||
|
/// windowId</c>) is already true for every broadcast line (target id
|
||||||
|
/// <c>0</c>), so the main window's filter is never actually consulted —
|
||||||
|
/// exactly matching retail's "no user filter" behavior without a guard.
|
||||||
|
/// <see cref="SetFilter"/> and <see cref="SetOpen"/> are still no-ops for
|
||||||
|
/// window <c>0</c> (it is always open and its seeded filter is inert), for
|
||||||
|
/// the same reason retail's setters gate on <c>m_eWindowID != 0</c>
|
||||||
|
/// (persistence doc §4.2).
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// No production <see cref="ChatEntry"/> carries an explicit target window
|
||||||
|
/// id yet (register row AP-180 — the <c>windowId</c> dual-destination echo
|
||||||
|
/// is deferred); every current line is effectively broadcast
|
||||||
|
/// (<c>targetWindowId == 0</c>). <see cref="ShouldDisplay"/> still accepts
|
||||||
|
/// the full retail shape so the routing predicate does not need to change
|
||||||
|
/// shape when AP-180 lands.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChatWindowState
|
||||||
|
{
|
||||||
|
public const int MainWindowId = 0;
|
||||||
|
public const int MinFloatingWindowId = 1;
|
||||||
|
public const int MaxFloatingWindowId = 4;
|
||||||
|
|
||||||
|
private const int WindowCount = MaxFloatingWindowId + 1;
|
||||||
|
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly ulong[] _filters = new ulong[WindowCount];
|
||||||
|
private readonly bool[] _open = new bool[WindowCount];
|
||||||
|
private long _revision;
|
||||||
|
|
||||||
|
public ChatWindowState() => ResetToDefaults();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Monotonic counter bumped on every filter or open-state change.
|
||||||
|
/// Lets presentation caches (per-window transcript layout) detect a
|
||||||
|
/// filter/visibility change without re-deriving it from the raw arrays.
|
||||||
|
/// </summary>
|
||||||
|
public long Revision => Interlocked.Read(ref _revision);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reset every window to retail's <c>PostInit</c> defaults (color-table
|
||||||
|
/// doc §4). The high dword is always <c>0</c> for every window — Society
|
||||||
|
/// (<c>0x20</c>) and the reserved slot (<c>0x21</c>) are opt-in only,
|
||||||
|
/// matching retail. Windows 1-4 start closed; window 0 (main) is always
|
||||||
|
/// open.
|
||||||
|
/// </summary>
|
||||||
|
public void ResetToDefaults()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
// "everything 0x00-0x1F except 0x1A" (m_oldState 1/8) — inert for
|
||||||
|
// routing (see class doc) but seeded for fidelity/inspection.
|
||||||
|
_filters[0] = 0xFBFFFFFFu;
|
||||||
|
// Speech, Tell, Speech_Direct_Send, Emote (m_oldState 2).
|
||||||
|
_filters[1] = 0x0000101Cu;
|
||||||
|
// Social, Social_Send, Allegiance (m_oldState 3).
|
||||||
|
_filters[2] = 0x00040C00u;
|
||||||
|
// Fellowship (m_oldState 4).
|
||||||
|
_filters[3] = 0x00080000u;
|
||||||
|
// Turbine General/Trade/LFG/Roleplay (m_oldState 5).
|
||||||
|
_filters[4] = 0x78000000u;
|
||||||
|
|
||||||
|
_open[0] = true;
|
||||||
|
for (int i = MinFloatingWindowId; i <= MaxFloatingWindowId; i++)
|
||||||
|
_open[i] = false;
|
||||||
|
|
||||||
|
Interlocked.Increment(ref _revision);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ulong GetFilter(int windowId)
|
||||||
|
{
|
||||||
|
ValidateWindowId(windowId);
|
||||||
|
lock (_gate) return _filters[windowId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set window <paramref name="windowId"/>'s 64-bit type filter. No-op for
|
||||||
|
/// the main window (id <c>0</c>) — see the class doc.
|
||||||
|
/// </summary>
|
||||||
|
public void SetFilter(int windowId, ulong filter)
|
||||||
|
{
|
||||||
|
ValidateWindowId(windowId);
|
||||||
|
if (windowId == MainWindowId) return;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_filters[windowId] == filter) return;
|
||||||
|
_filters[windowId] = filter;
|
||||||
|
Interlocked.Increment(ref _revision);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Main window (id 0) is always open.</summary>
|
||||||
|
public bool IsOpen(int windowId)
|
||||||
|
{
|
||||||
|
ValidateWindowId(windowId);
|
||||||
|
if (windowId == MainWindowId) return true;
|
||||||
|
lock (_gate) return _open[windowId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>No-op for the main window — it cannot be closed.</summary>
|
||||||
|
public void SetOpen(int windowId, bool open)
|
||||||
|
{
|
||||||
|
ValidateWindowId(windowId);
|
||||||
|
if (windowId == MainWindowId) return;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_open[windowId] == open) return;
|
||||||
|
_open[windowId] = open;
|
||||||
|
Interlocked.Increment(ref _revision);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flip window <paramref name="windowId"/>'s open state and return the
|
||||||
|
/// new value. Always returns <see langword="true"/> for the main window
|
||||||
|
/// (it cannot be toggled closed).
|
||||||
|
/// </summary>
|
||||||
|
public bool Toggle(int windowId)
|
||||||
|
{
|
||||||
|
ValidateWindowId(windowId);
|
||||||
|
if (windowId == MainWindowId) return true;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
bool next = !_open[windowId];
|
||||||
|
_open[windowId] = next;
|
||||||
|
Interlocked.Increment(ref _revision);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>ChatInterface::TypeIsActive @0x004F2F10</c>: does window
|
||||||
|
/// <paramref name="windowId"/>'s filter accept retail
|
||||||
|
/// <see cref="RetailLogTextType"/> <paramref name="logTextType"/>?
|
||||||
|
/// Types <c>>= 64</c> are never active (there is no such bit).
|
||||||
|
/// </summary>
|
||||||
|
public bool TypeIsActive(int windowId, uint logTextType)
|
||||||
|
{
|
||||||
|
ValidateWindowId(windowId);
|
||||||
|
if (logTextType >= 64u) return false;
|
||||||
|
ulong filter;
|
||||||
|
lock (_gate) filter = _filters[windowId];
|
||||||
|
return ((1UL << (int)logTextType) & filter) != 0UL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640</c>'s
|
||||||
|
/// exact display predicate — see the class doc for the two branches.
|
||||||
|
/// </summary>
|
||||||
|
public bool ShouldDisplay(int windowId, uint targetWindowId, uint logTextType)
|
||||||
|
{
|
||||||
|
ValidateWindowId(windowId);
|
||||||
|
if (targetWindowId == (uint)windowId) return true;
|
||||||
|
return targetWindowId == 0u && TypeIsActive(windowId, logTextType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateWindowId(int windowId)
|
||||||
|
{
|
||||||
|
if (windowId < MainWindowId || windowId > MaxFloatingWindowId)
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(windowId), windowId, "chat window id must be 0 (main) through 4 (floating).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -66,6 +66,7 @@ public sealed class RuntimeCommunicationState : IDisposable
|
||||||
TurbineChat = new TurbineChatState();
|
TurbineChat = new TurbineChatState();
|
||||||
Friends = new FriendsState();
|
Friends = new FriendsState();
|
||||||
Squelch = new SquelchState();
|
Squelch = new SquelchState();
|
||||||
|
ChatWindows = new ChatWindowState();
|
||||||
View = new CommunicationView(Chat);
|
View = new CommunicationView(Chat);
|
||||||
SocialView = new CommunicationSocialView(
|
SocialView = new CommunicationSocialView(
|
||||||
TurbineChat,
|
TurbineChat,
|
||||||
|
|
@ -75,6 +76,15 @@ public sealed class RuntimeCommunicationState : IDisposable
|
||||||
|
|
||||||
public ChatLog Chat { get; }
|
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>
|
/// <summary>
|
||||||
/// Campaign CH slice CH2: retail's transient on-screen "interface text"
|
/// Campaign CH slice CH2: retail's transient on-screen "interface text"
|
||||||
/// queue (<c>gmSpewBoxUI</c>) — the SECOND sink <see cref="AddText"/>
|
/// queue (<c>gmSpewBoxUI</c>) — the SECOND sink <see cref="AddText"/>
|
||||||
|
|
@ -206,6 +216,7 @@ public sealed class RuntimeCommunicationState : IDisposable
|
||||||
Squelch.Clear();
|
Squelch.Clear();
|
||||||
Chat.ResetSessionIdentity();
|
Chat.ResetSessionIdentity();
|
||||||
SpewBox.Reset();
|
SpewBox.Reset();
|
||||||
|
ChatWindows.ResetToDefaults();
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class CommunicationView(ChatLog chat) : IRuntimeChatView
|
private sealed class CommunicationView(ChatLog chat) : IRuntimeChatView
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,22 @@ public sealed record ChatSettings(
|
||||||
bool ShowTimestamps, // 0x40 — TimeStamp prefix on chat lines
|
bool ShowTimestamps, // 0x40 — TimeStamp prefix on chat lines
|
||||||
bool FilterProfanity, // 0x20000 — FilterLanguage (Turbine's profanity filter)
|
bool FilterProfanity, // 0x20000 — FilterLanguage (Turbine's profanity filter)
|
||||||
// Visual / UX (no retail bitfield).
|
// 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>
|
/// <summary>
|
||||||
/// N4 (CH3 Opus review): matches ACE's ACTUAL
|
/// N4 (CH3 Opus review): matches ACE's ACTUAL
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,11 @@ public sealed class SettingsStore
|
||||||
AppearOffline: ReadBool (chat, "appearOffline", d.AppearOffline),
|
AppearOffline: ReadBool (chat, "appearOffline", d.AppearOffline),
|
||||||
ShowTimestamps: ReadBool (chat, "showTimestamps", d.ShowTimestamps),
|
ShowTimestamps: ReadBool (chat, "showTimestamps", d.ShowTimestamps),
|
||||||
FilterProfanity: ReadBool (chat, "filterProfanity", d.FilterProfanity),
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -537,7 +541,11 @@ public sealed class SettingsStore
|
||||||
private static SortedDictionary<string, object> BuildChatObject(ChatSettings c)
|
private static SortedDictionary<string, object> BuildChatObject(ChatSettings c)
|
||||||
=> new(StringComparer.Ordinal)
|
=> new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
["appearOffline"] = c.AppearOffline,
|
["appearOffline"] = c.AppearOffline,
|
||||||
|
["chatWindow1Filter"] = c.ChatWindow1Filter,
|
||||||
|
["chatWindow2Filter"] = c.ChatWindow2Filter,
|
||||||
|
["chatWindow3Filter"] = c.ChatWindow3Filter,
|
||||||
|
["chatWindow4Filter"] = c.ChatWindow4Filter,
|
||||||
["filterProfanity"] = c.FilterProfanity,
|
["filterProfanity"] = c.FilterProfanity,
|
||||||
["fontSize"] = c.FontSize,
|
["fontSize"] = c.FontSize,
|
||||||
["hearGeneralChat"] = c.HearGeneralChat,
|
["hearGeneralChat"] = c.HearGeneralChat,
|
||||||
|
|
@ -655,6 +663,13 @@ public sealed class SettingsStore
|
||||||
=> obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number
|
=> obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number
|
||||||
? el.GetSingle() : fallback;
|
? 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)
|
private static QualityPreset ReadQuality(JsonElement obj, string name, QualityPreset fallback)
|
||||||
{
|
{
|
||||||
if (!obj.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String)
|
if (!obj.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ public sealed class GameplayInputCommandControllerTests
|
||||||
[InlineData(InputAction.ToggleChatEntry, "chat")]
|
[InlineData(InputAction.ToggleChatEntry, "chat")]
|
||||||
[InlineData(InputAction.ToggleOptionsPanel, "settings")]
|
[InlineData(InputAction.ToggleOptionsPanel, "settings")]
|
||||||
[InlineData(InputAction.CombatToggleCombat, "combat")]
|
[InlineData(InputAction.CombatToggleCombat, "combat")]
|
||||||
|
[InlineData(InputAction.ToggleFloatingChatWindow1, "chat-window-1")]
|
||||||
|
[InlineData(InputAction.ToggleFloatingChatWindow2, "chat-window-2")]
|
||||||
|
[InlineData(InputAction.ToggleFloatingChatWindow3, "chat-window-3")]
|
||||||
|
[InlineData(InputAction.ToggleFloatingChatWindow4, "chat-window-4")]
|
||||||
public void RecognizedCommand_RoutesToTypedOwner(
|
public void RecognizedCommand_RoutesToTypedOwner(
|
||||||
InputAction action,
|
InputAction action,
|
||||||
string expected)
|
string expected)
|
||||||
|
|
@ -119,6 +123,9 @@ public sealed class GameplayInputCommandControllerTests
|
||||||
: IRetainedGameplayWindowCommands
|
: IRetainedGameplayWindowCommands
|
||||||
{
|
{
|
||||||
public void ToggleInventory() => calls.Add("inventory");
|
public void ToggleInventory() => calls.Add("inventory");
|
||||||
|
|
||||||
|
public void ToggleFloatingChatWindow(int windowId) =>
|
||||||
|
calls.Add($"chat-window-{windowId}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeDevTools(List<string> calls)
|
private sealed class FakeDevTools(List<string> calls)
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,24 @@ public class ChatWindowControllerTests
|
||||||
Id = 0x1000046Fu, Type = 3, X = 474, Y = 0, Width = 16, Height = 16,
|
Id = 0x1000046Fu, Type = 3, X = 474, Y = 0, Width = 16, Height = 16,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Type 1 -> UiButton (DatWidgetFactory.Create) so SetIndicatorOpen tests
|
||||||
|
// have a real IUiDatStateful to assert against. Normal/Highlight state
|
||||||
|
// MEDIA (not just a States entry) must be authored or TrySetRetailState
|
||||||
|
// can't resolve either name to an ActiveState (UiButton.HasStateMedia) —
|
||||||
|
// same fixture shape ToolbarControllerTests uses for its own
|
||||||
|
// SetPanelOpen(Normal/Highlight) mirror test.
|
||||||
|
ElementInfo MakeIndicator(uint id, float y)
|
||||||
|
{
|
||||||
|
var info = new ElementInfo { Id = id, Type = 1, X = 5, Y = y, Width = 16, Height = 16 };
|
||||||
|
info.StateMedia["Normal"] = (0x1u, 1);
|
||||||
|
info.StateMedia["Highlight"] = (0x2u, 1);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
var indicator1 = MakeIndicator(0x10000522u, 5);
|
||||||
|
var indicator2 = MakeIndicator(0x10000523u, 22);
|
||||||
|
var indicator3 = MakeIndicator(0x10000524u, 39);
|
||||||
|
var indicator4 = MakeIndicator(0x10000525u, 56);
|
||||||
|
|
||||||
var root = new ElementInfo
|
var root = new ElementInfo
|
||||||
{
|
{
|
||||||
Id = 0x10000600u, Type = 3, Width = 490, Height = 100,
|
Id = 0x10000600u, Type = 3, Width = 490, Height = 100,
|
||||||
|
|
@ -116,6 +134,10 @@ public class ChatWindowControllerTests
|
||||||
root.Children.Add(transcriptPanel);
|
root.Children.Add(transcriptPanel);
|
||||||
root.Children.Add(inputBar);
|
root.Children.Add(inputBar);
|
||||||
root.Children.Add(maxMinNode);
|
root.Children.Add(maxMinNode);
|
||||||
|
root.Children.Add(indicator1);
|
||||||
|
root.Children.Add(indicator2);
|
||||||
|
root.Children.Add(indicator3);
|
||||||
|
root.Children.Add(indicator4);
|
||||||
|
|
||||||
var layout = LayoutImporter.Build(root, NoTex, null);
|
var layout = LayoutImporter.Build(root, NoTex, null);
|
||||||
var vm = new ChatVM(log ?? new ChatLog());
|
var vm = new ChatVM(log ?? new ChatLog());
|
||||||
|
|
@ -487,4 +509,65 @@ public class ChatWindowControllerTests
|
||||||
|
|
||||||
Assert.Equal(new[] { "first", "", "third" }, lines);
|
Assert.Equal(new[] { "first", "", "third" }, lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── SetIndicatorOpen: Campaign CH slice CH6b — the button mirror ─────────
|
||||||
|
// gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80: State 6
|
||||||
|
// (Highlight) when the floating window is visible, State 1 (Normal)
|
||||||
|
// when it is not.
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1, 0x10000522u)]
|
||||||
|
[InlineData(2, 0x10000523u)]
|
||||||
|
[InlineData(3, 0x10000524u)]
|
||||||
|
[InlineData(4, 0x10000525u)]
|
||||||
|
public void SetIndicatorOpen_Open_SetsHighlightState(int windowId, uint indicatorId)
|
||||||
|
{
|
||||||
|
var (rootInfo, layout, vm) = BuildTestTree();
|
||||||
|
var bus = new CaptureBus();
|
||||||
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex)!;
|
||||||
|
var indicator = Assert.IsType<UiButton>(layout.FindElement(indicatorId));
|
||||||
|
|
||||||
|
ctrl.SetIndicatorOpen(windowId, open: true);
|
||||||
|
|
||||||
|
Assert.Equal(UiButtonStateMachine.Highlight, indicator.ActiveRetailStateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetIndicatorOpen_Closed_SetsNormalState()
|
||||||
|
{
|
||||||
|
var (rootInfo, layout, vm) = BuildTestTree();
|
||||||
|
var bus = new CaptureBus();
|
||||||
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex)!;
|
||||||
|
var indicator = Assert.IsType<UiButton>(layout.FindElement(0x10000522u));
|
||||||
|
|
||||||
|
ctrl.SetIndicatorOpen(1, open: true);
|
||||||
|
ctrl.SetIndicatorOpen(1, open: false);
|
||||||
|
|
||||||
|
Assert.Equal(UiButtonStateMachine.Normal, indicator.ActiveRetailStateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetIndicatorOpen_DoesNotAffectOtherWindowsIndicators()
|
||||||
|
{
|
||||||
|
var (rootInfo, layout, vm) = BuildTestTree();
|
||||||
|
var bus = new CaptureBus();
|
||||||
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex)!;
|
||||||
|
var indicator2 = Assert.IsType<UiButton>(layout.FindElement(0x10000523u));
|
||||||
|
|
||||||
|
ctrl.SetIndicatorOpen(1, open: true);
|
||||||
|
|
||||||
|
Assert.Equal(UiButtonStateMachine.Normal, indicator2.ActiveRetailStateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(5)]
|
||||||
|
public void SetIndicatorOpen_OutOfRangeWindowId_Throws(int windowId)
|
||||||
|
{
|
||||||
|
var (rootInfo, layout, vm) = BuildTestTree();
|
||||||
|
var bus = new CaptureBus();
|
||||||
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex)!;
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => ctrl.SetIndicatorOpen(windowId, open: true));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,282 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
using AcDream.Core.Chat;
|
||||||
|
using AcDream.UI.Abstractions;
|
||||||
|
using AcDream.UI.Abstractions.Panels.Chat;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Smoke + filter-routing tests for
|
||||||
|
/// <see cref="FloatingChatWindowController.Bind"/> — no dats, no GL,
|
||||||
|
/// mirroring <c>ChatWindowControllerTests</c>'s synthetic-tree approach but
|
||||||
|
/// against the floating layout's element topology
|
||||||
|
/// (<c>0x2100005B</c>, research doc §2.2): the input row id
|
||||||
|
/// (<c>0x10000509</c>) differs from the main window's
|
||||||
|
/// (<c>0x10000013</c>), and there is no talk-focus menu / max-min button /
|
||||||
|
/// indicator row.
|
||||||
|
/// </summary>
|
||||||
|
public class FloatingChatWindowControllerTests
|
||||||
|
{
|
||||||
|
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
|
||||||
|
|
||||||
|
private sealed class CaptureBus : ICommandBus
|
||||||
|
{
|
||||||
|
public readonly List<object> Published = new();
|
||||||
|
public void Publish<T>(T cmd) where T : notnull => Published.Add(cmd!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Synthetic tree mirroring the floating chat layout topology:
|
||||||
|
/// root (Type-3) [0x100004F7]
|
||||||
|
/// transcriptPanel (Type-3) [0x10000010]
|
||||||
|
/// transcript (Type-12, no media) [0x10000011]
|
||||||
|
/// track (Type-3) [0x10000012]
|
||||||
|
/// inputRow (Type-3) [0x10000509] ← the floaty-specific id
|
||||||
|
/// input (Type-12, Editable+Selectable) [0x10000016]
|
||||||
|
/// send (Type-3) [0x10000019]
|
||||||
|
/// titleBar (Type-3) [0x100004D9]
|
||||||
|
/// closeButton (Type-3) [0x1000052A]
|
||||||
|
/// </summary>
|
||||||
|
private static (ElementInfo rootInfo, ImportedLayout layout, ChatVM vm) BuildTestTree(
|
||||||
|
ChatLog? log = null)
|
||||||
|
{
|
||||||
|
var transcriptNode = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = 0x10000011u, Type = 12,
|
||||||
|
X = 5, Y = 20, Width = 224, Height = 60,
|
||||||
|
};
|
||||||
|
var trackNode = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = 0x10000012u, Type = 3,
|
||||||
|
X = 229, Y = 20, Width = 16, Height = 60,
|
||||||
|
};
|
||||||
|
var transcriptPanel = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = 0x10000010u, Type = 3, X = 0, Y = 20, Width = 250, Height = 60,
|
||||||
|
};
|
||||||
|
transcriptPanel.Children.Add(transcriptNode);
|
||||||
|
transcriptPanel.Children.Add(trackNode);
|
||||||
|
|
||||||
|
var inputNode = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = 0x10000016u, Type = 12,
|
||||||
|
X = 0, Y = 80, Width = 202, Height = 18,
|
||||||
|
};
|
||||||
|
var inputState = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
||||||
|
inputState.Properties.Values[0x16u] = new UiPropertyValue { Kind = UiPropertyKind.Bool, BoolValue = true };
|
||||||
|
inputState.Properties.Values[0x20u] = new UiPropertyValue { Kind = UiPropertyKind.Bool, BoolValue = true };
|
||||||
|
inputState.Properties.Values[0x27u] = new UiPropertyValue { Kind = UiPropertyKind.Bool, BoolValue = true };
|
||||||
|
inputNode.States[UiStateInfo.DirectStateId] = inputState;
|
||||||
|
var sendNode = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = 0x10000019u, Type = 3, X = 202, Y = 80, Width = 38, Height = 18,
|
||||||
|
};
|
||||||
|
var inputRow = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = 0x10000509u, Type = 3, X = 0, Y = 80, Width = 250, Height = 18,
|
||||||
|
};
|
||||||
|
inputRow.Children.Add(inputNode);
|
||||||
|
inputRow.Children.Add(sendNode);
|
||||||
|
|
||||||
|
var titleBar = new ElementInfo { Id = 0x100004D9u, Type = 3, X = 0, Y = 0, Width = 240, Height = 16 };
|
||||||
|
var closeButton = new ElementInfo { Id = 0x1000052Au, Type = 3, X = 225, Y = 1, Width = 14, Height = 14 };
|
||||||
|
|
||||||
|
var root = new ElementInfo { Id = 0x100004F7u, Type = 3, Width = 250, Height = 108 };
|
||||||
|
root.Children.Add(transcriptPanel);
|
||||||
|
root.Children.Add(inputRow);
|
||||||
|
root.Children.Add(titleBar);
|
||||||
|
root.Children.Add(closeButton);
|
||||||
|
|
||||||
|
var layout = LayoutImporter.Build(root, NoTex, null);
|
||||||
|
var vm = new ChatVM(log ?? new ChatLog());
|
||||||
|
return (root, layout, vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bind smoke tests ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_Returns_NonNull_OnValidTree()
|
||||||
|
{
|
||||||
|
var (rootInfo, layout, vm) = BuildTestTree();
|
||||||
|
var bus = new CaptureBus();
|
||||||
|
var filters = new ChatWindowState();
|
||||||
|
|
||||||
|
var ctrl = FloatingChatWindowController.Bind(
|
||||||
|
1, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
|
||||||
|
|
||||||
|
Assert.NotNull(ctrl);
|
||||||
|
Assert.Equal(1, ctrl!.WindowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(5)]
|
||||||
|
public void Bind_InvalidWindowId_Throws(int windowId)
|
||||||
|
{
|
||||||
|
var (rootInfo, layout, vm) = BuildTestTree();
|
||||||
|
var bus = new CaptureBus();
|
||||||
|
var filters = new ChatWindowState();
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||||
|
FloatingChatWindowController.Bind(
|
||||||
|
windowId, rootInfo, layout, vm, () => bus, filters, null, null, NoTex));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_Returns_Null_WhenTranscriptPanelMissing()
|
||||||
|
{
|
||||||
|
var root = new ElementInfo { Id = 0x100004F7u, Type = 3, Width = 250, Height = 108 };
|
||||||
|
var layout = LayoutImporter.Build(root, NoTex, null);
|
||||||
|
var vm = new ChatVM(new ChatLog());
|
||||||
|
var bus = new CaptureBus();
|
||||||
|
var filters = new ChatWindowState();
|
||||||
|
|
||||||
|
var ctrl = FloatingChatWindowController.Bind(
|
||||||
|
1, root, layout, vm, () => bus, filters, null, null, NoTex);
|
||||||
|
|
||||||
|
Assert.Null(ctrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_Transcript_IsChildOfTranscriptPanel()
|
||||||
|
{
|
||||||
|
var (rootInfo, layout, vm) = BuildTestTree();
|
||||||
|
var bus = new CaptureBus();
|
||||||
|
var filters = new ChatWindowState();
|
||||||
|
|
||||||
|
var ctrl = FloatingChatWindowController.Bind(
|
||||||
|
2, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
|
||||||
|
|
||||||
|
Assert.NotNull(ctrl);
|
||||||
|
var panel = layout.FindElement(0x10000010u);
|
||||||
|
Assert.NotNull(panel);
|
||||||
|
Assert.Contains(ctrl!.Transcript, panel!.Children);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_Input_IsChildOfFloatyInputRow_NotMainWindowInputBar()
|
||||||
|
{
|
||||||
|
var (rootInfo, layout, vm) = BuildTestTree();
|
||||||
|
var bus = new CaptureBus();
|
||||||
|
var filters = new ChatWindowState();
|
||||||
|
|
||||||
|
var ctrl = FloatingChatWindowController.Bind(
|
||||||
|
3, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
|
||||||
|
|
||||||
|
Assert.NotNull(ctrl);
|
||||||
|
var row = layout.FindElement(0x10000509u);
|
||||||
|
Assert.NotNull(row);
|
||||||
|
Assert.Contains(ctrl!.Input, row!.Children);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chat entry always sends on Say (no talk-focus menu authored) ───────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_InputSubmit_AlwaysSendsOnSayChannel()
|
||||||
|
{
|
||||||
|
var (rootInfo, layout, vm) = BuildTestTree();
|
||||||
|
var bus = new CaptureBus();
|
||||||
|
var filters = new ChatWindowState();
|
||||||
|
|
||||||
|
var ctrl = FloatingChatWindowController.Bind(
|
||||||
|
4, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
|
||||||
|
|
||||||
|
Assert.NotNull(ctrl);
|
||||||
|
ctrl!.Input.OnSubmit!.Invoke("hi everyone");
|
||||||
|
|
||||||
|
var cmd = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
|
||||||
|
Assert.Equal(ChatChannelKind.Say, cmd.Channel);
|
||||||
|
Assert.Equal("hi everyone", cmd.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display rule matrix: this window's filter accepts/rejects a line ───
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transcript_ShowsOnlyLinesThisWindowsFilterAccepts()
|
||||||
|
{
|
||||||
|
var log = new ChatLog();
|
||||||
|
var (rootInfo, layout, vm) = BuildTestTree(log);
|
||||||
|
var bus = new CaptureBus();
|
||||||
|
var filters = new ChatWindowState(); // window 1 default: Speech/Tell/SpeechDirectSend/Emote
|
||||||
|
|
||||||
|
var ctrl = FloatingChatWindowController.Bind(
|
||||||
|
1, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
|
||||||
|
Assert.NotNull(ctrl);
|
||||||
|
|
||||||
|
log.OnSystemMessage("visible speech", chatType: 0x02u); // Speech — in window 1's filter
|
||||||
|
log.OnSystemMessage("hidden social", chatType: 0x0Au); // Social — NOT in window 1's filter
|
||||||
|
|
||||||
|
var lines = ctrl!.Transcript.LinesProvider();
|
||||||
|
|
||||||
|
Assert.Single(lines);
|
||||||
|
Assert.Equal("visible speech", lines[0].Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transcript_DifferentWindow_AcceptsADifferentSubsetOfTypes()
|
||||||
|
{
|
||||||
|
var log = new ChatLog();
|
||||||
|
var (rootInfo, layout, vm) = BuildTestTree(log);
|
||||||
|
var bus = new CaptureBus();
|
||||||
|
var filters = new ChatWindowState(); // window 3 default: Fellowship only
|
||||||
|
|
||||||
|
var ctrl = FloatingChatWindowController.Bind(
|
||||||
|
3, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
|
||||||
|
Assert.NotNull(ctrl);
|
||||||
|
|
||||||
|
log.OnSystemMessage("speech line", chatType: 0x02u); // Speech — not window 3's filter
|
||||||
|
log.OnSystemMessage("fellowship line", chatType: 0x13u); // Fellowship — window 3's filter
|
||||||
|
|
||||||
|
var lines = ctrl!.Transcript.LinesProvider();
|
||||||
|
|
||||||
|
Assert.Single(lines);
|
||||||
|
Assert.Equal("fellowship line", lines[0].Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transcript_FilterChange_IsReflectedOnNextRebuild()
|
||||||
|
{
|
||||||
|
var log = new ChatLog();
|
||||||
|
var (rootInfo, layout, vm) = BuildTestTree(log);
|
||||||
|
var bus = new CaptureBus();
|
||||||
|
var filters = new ChatWindowState();
|
||||||
|
|
||||||
|
var ctrl = FloatingChatWindowController.Bind(
|
||||||
|
2, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
|
||||||
|
Assert.NotNull(ctrl);
|
||||||
|
|
||||||
|
log.OnSystemMessage("speech line", chatType: 0x02u); // Speech — not window 2's default filter
|
||||||
|
Assert.Empty(ctrl!.Transcript.LinesProvider());
|
||||||
|
|
||||||
|
// Widen window 2's filter to include Speech (bit 0x02 = 0x4) without
|
||||||
|
// touching the chat log — the cached transcript must invalidate on
|
||||||
|
// the filter change alone.
|
||||||
|
filters.SetFilter(2, filters.GetFilter(2) | (1UL << 0x02));
|
||||||
|
|
||||||
|
var lines = ctrl.Transcript.LinesProvider();
|
||||||
|
Assert.Single(lines);
|
||||||
|
Assert.Equal("speech line", lines[0].Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transcript_UnchangedFilterAndContent_ReusesCachedLayout()
|
||||||
|
{
|
||||||
|
var log = new ChatLog();
|
||||||
|
var (rootInfo, layout, vm) = BuildTestTree(log);
|
||||||
|
var bus = new CaptureBus();
|
||||||
|
var filters = new ChatWindowState();
|
||||||
|
|
||||||
|
var ctrl = FloatingChatWindowController.Bind(
|
||||||
|
1, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
|
||||||
|
Assert.NotNull(ctrl);
|
||||||
|
|
||||||
|
log.OnSystemMessage("speech line", chatType: 0x02u);
|
||||||
|
var first = ctrl!.Transcript.LinesProvider();
|
||||||
|
var unchanged = ctrl.Transcript.LinesProvider();
|
||||||
|
|
||||||
|
Assert.Same(first, unchanged);
|
||||||
|
Assert.Equal(1, ctrl.TranscriptLayoutBuildCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
253
tests/AcDream.Core.Tests/Chat/ChatWindowStateTests.cs
Normal file
253
tests/AcDream.Core.Tests/Chat/ChatWindowStateTests.cs
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
using AcDream.Core.Chat;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign CH slice CH6b: retail per-window text-type filter + open state
|
||||||
|
/// (<c>ChatInterface::PostInit @0x004F3DD0</c> defaults, color-table research
|
||||||
|
/// doc §4, and <c>RecvNotice_DisplayFinalStringInfo @0x004F4640</c>'s display
|
||||||
|
/// predicate).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChatWindowStateTests
|
||||||
|
{
|
||||||
|
// ── Defaults (research doc §4's table, verbatim) ────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 0xFBFFFFFFu)]
|
||||||
|
[InlineData(1, 0x0000101Cu)]
|
||||||
|
[InlineData(2, 0x00040C00u)]
|
||||||
|
[InlineData(3, 0x00080000u)]
|
||||||
|
[InlineData(4, 0x78000000u)]
|
||||||
|
public void Defaults_MatchRetailPostInitTable(int windowId, ulong expectedFilter)
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
|
||||||
|
Assert.Equal(expectedFilter, state.GetFilter(windowId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Defaults_MainWindowIsAlwaysOpen_FloatingWindowsStartClosed()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
|
||||||
|
Assert.True(state.IsOpen(0));
|
||||||
|
for (int windowId = 1; windowId <= 4; windowId++)
|
||||||
|
Assert.False(state.IsOpen(windowId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Window1_DefaultFilter_MatchesSpeechTellDirectSendEmote()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
Assert.True(state.TypeIsActive(1, 0x02u)); // Speech
|
||||||
|
Assert.True(state.TypeIsActive(1, 0x03u)); // Tell
|
||||||
|
Assert.True(state.TypeIsActive(1, 0x04u)); // Speech_Direct_Send
|
||||||
|
Assert.True(state.TypeIsActive(1, 0x0Cu)); // Emote
|
||||||
|
Assert.False(state.TypeIsActive(1, 0x0Au)); // Social — not in window 1's default
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Window2_DefaultFilter_MatchesSocialSocialSendAllegiance()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
Assert.True(state.TypeIsActive(2, 0x0Au)); // Social
|
||||||
|
Assert.True(state.TypeIsActive(2, 0x0Bu)); // Social_Send
|
||||||
|
Assert.True(state.TypeIsActive(2, 0x12u)); // Allegiance
|
||||||
|
Assert.False(state.TypeIsActive(2, 0x13u)); // Fellowship
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Window3_DefaultFilter_MatchesFellowshipOnly()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
Assert.True(state.TypeIsActive(3, 0x13u)); // Fellowship
|
||||||
|
Assert.False(state.TypeIsActive(3, 0x0Au)); // Social
|
||||||
|
Assert.False(state.TypeIsActive(3, 0x02u)); // Speech
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Window4_DefaultFilter_MatchesTurbineGeneralTradeLfgRoleplay()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
Assert.True(state.TypeIsActive(4, 0x1Bu)); // TurbineGeneral
|
||||||
|
Assert.True(state.TypeIsActive(4, 0x1Cu)); // TurbineTrade
|
||||||
|
Assert.True(state.TypeIsActive(4, 0x1Du)); // TurbineLFG
|
||||||
|
Assert.True(state.TypeIsActive(4, 0x1Eu)); // TurbineRoleplay
|
||||||
|
Assert.False(state.TypeIsActive(4, 0x20u)); // TurbineSociety — opt-in only
|
||||||
|
Assert.False(state.TypeIsActive(4, 0x12u)); // Allegiance
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EveryWindow_NeverActivatesSocietyOrReservedByDefault()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
for (int windowId = 0; windowId <= 4; windowId++)
|
||||||
|
{
|
||||||
|
Assert.False(state.TypeIsActive(windowId, 0x20u)); // Society
|
||||||
|
Assert.False(state.TypeIsActive(windowId, 0x21u)); // Reserved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TypeIsActive edge cases ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TypeIsActive_TypeAtOrAbove64_IsNeverActive()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
state.SetFilter(1, ulong.MaxValue);
|
||||||
|
|
||||||
|
Assert.False(state.TypeIsActive(1, 64u));
|
||||||
|
Assert.False(state.TypeIsActive(1, 1000u));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display rule matrix (windowId-addressed vs broadcast × filter hit/miss) ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldDisplay_ExplicitlyAddressed_AlwaysShowsRegardlessOfFilter()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
// Window 3's default filter has ONLY Fellowship (0x13) active — Speech
|
||||||
|
// (0x02) would fail the broadcast check, but explicit addressing wins.
|
||||||
|
Assert.True(state.ShouldDisplay(windowId: 3, targetWindowId: 3u, logTextType: 0x02u));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldDisplay_ExplicitlyAddressedToAnotherWindow_NeverShowsHereEvenOnBroadcastFilterHit()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
// Addressed to window 2, evaluated from window 1's perspective: not a
|
||||||
|
// broadcast (targetWindowId != 0) and not addressed to window 1.
|
||||||
|
Assert.False(state.ShouldDisplay(windowId: 1, targetWindowId: 2u, logTextType: 0x02u));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldDisplay_Broadcast_FilterHit_Shows()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
Assert.True(state.ShouldDisplay(windowId: 1, targetWindowId: 0u, logTextType: 0x02u)); // Speech
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldDisplay_Broadcast_FilterMiss_DoesNotShow()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
Assert.False(state.ShouldDisplay(windowId: 1, targetWindowId: 0u, logTextType: 0x0Au)); // Social
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldDisplay_MainWindow_ShowsEveryBroadcastRegardlessOfSeededFilter()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
// Window 0's own filter is 0xFBFFFFFF (excludes 0x1A) but that filter
|
||||||
|
// is never actually consulted for a broadcast message: targetWindowId
|
||||||
|
// (0) == windowId (0) is already true via the first branch.
|
||||||
|
Assert.True(state.ShouldDisplay(windowId: 0, targetWindowId: 0u, logTextType: 0x1Au));
|
||||||
|
Assert.True(state.ShouldDisplay(windowId: 0, targetWindowId: 0u, logTextType: 0x21u));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SetFilter / SetOpen / Toggle ─────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetFilter_MainWindow_IsANoOp()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
ulong before = state.GetFilter(0);
|
||||||
|
|
||||||
|
state.SetFilter(0, 0u);
|
||||||
|
|
||||||
|
Assert.Equal(before, state.GetFilter(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetFilter_FloatingWindow_Persists()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
|
||||||
|
state.SetFilter(2, 0x1u);
|
||||||
|
|
||||||
|
Assert.Equal(0x1u, state.GetFilter(2));
|
||||||
|
Assert.True(state.TypeIsActive(2, 0x00u));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetOpen_MainWindow_IsANoOp_AlwaysOpen()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
|
||||||
|
state.SetOpen(0, false);
|
||||||
|
|
||||||
|
Assert.True(state.IsOpen(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Toggle_FloatingWindow_FlipsOpenState_AndReturnsNewValue()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
Assert.False(state.IsOpen(1));
|
||||||
|
|
||||||
|
bool afterFirst = state.Toggle(1);
|
||||||
|
Assert.True(afterFirst);
|
||||||
|
Assert.True(state.IsOpen(1));
|
||||||
|
|
||||||
|
bool afterSecond = state.Toggle(1);
|
||||||
|
Assert.False(afterSecond);
|
||||||
|
Assert.False(state.IsOpen(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Toggle_MainWindow_AlwaysReturnsTrue_NeverCloses()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
|
||||||
|
bool result = state.Toggle(0);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.True(state.IsOpen(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResetToDefaults_RestoresSeededFiltersAndOpenState()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
state.SetFilter(1, 0u);
|
||||||
|
state.SetOpen(1, true);
|
||||||
|
|
||||||
|
state.ResetToDefaults();
|
||||||
|
|
||||||
|
Assert.Equal(0x0000101Cu, state.GetFilter(1));
|
||||||
|
Assert.False(state.IsOpen(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Argument validation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(-1)]
|
||||||
|
[InlineData(5)]
|
||||||
|
public void OutOfRangeWindowId_Throws(int windowId)
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => state.GetFilter(windowId));
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => state.IsOpen(windowId));
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => state.TypeIsActive(windowId, 0u));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Revision counter ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Revision_AdvancesOnFilterAndOpenChange_NotOnNoOpWrites()
|
||||||
|
{
|
||||||
|
var state = new ChatWindowState();
|
||||||
|
long baseline = state.Revision;
|
||||||
|
|
||||||
|
state.SetFilter(1, 0x1u);
|
||||||
|
Assert.True(state.Revision > baseline);
|
||||||
|
long afterFilter = state.Revision;
|
||||||
|
|
||||||
|
// No-op: same value.
|
||||||
|
state.SetFilter(1, 0x1u);
|
||||||
|
Assert.Equal(afterFilter, state.Revision);
|
||||||
|
|
||||||
|
state.SetOpen(1, true);
|
||||||
|
Assert.True(state.Revision > afterFilter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -254,6 +254,35 @@ public sealed class RuntimeCommunicationStateTests
|
||||||
Assert.Equal(0, state.SpewBox.Count);
|
Assert.Equal(0, state.SpewBox.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Campaign CH slice CH6b: ChatWindows is the canonical per-window
|
||||||
|
// filter/open owner every host borrows — no presentation-owned copy. ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChatWindows_SeededWithRetailPostInitDefaults_OnConstruction()
|
||||||
|
{
|
||||||
|
using var state = new RuntimeCommunicationState();
|
||||||
|
|
||||||
|
Assert.True(state.ChatWindows.IsOpen(0));
|
||||||
|
Assert.False(state.ChatWindows.IsOpen(1));
|
||||||
|
Assert.Equal(0x0000101Cu, state.ChatWindows.GetFilter(1));
|
||||||
|
Assert.Equal(0x00040C00u, state.ChatWindows.GetFilter(2));
|
||||||
|
Assert.Equal(0x00080000u, state.ChatWindows.GetFilter(3));
|
||||||
|
Assert.Equal(0x78000000u, state.ChatWindows.GetFilter(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_ResetsChatWindowsToRetailDefaults()
|
||||||
|
{
|
||||||
|
var state = new RuntimeCommunicationState();
|
||||||
|
state.ChatWindows.SetOpen(1, true);
|
||||||
|
state.ChatWindows.SetFilter(2, 0u);
|
||||||
|
|
||||||
|
state.Dispose();
|
||||||
|
|
||||||
|
Assert.False(state.ChatWindows.IsOpen(1));
|
||||||
|
Assert.Equal(0x00040C00u, state.ChatWindows.GetFilter(2));
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class RecordingObserver : IRuntimeCommunicationObserver
|
private sealed class RecordingObserver : IRuntimeCommunicationObserver
|
||||||
{
|
{
|
||||||
public List<RuntimeCommunicationEvent> Events { get; } = [];
|
public List<RuntimeCommunicationEvent> Events { get; } = [];
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,41 @@ public sealed class SettingsStoreTests : System.IDisposable
|
||||||
Assert.Equal(original, store.LoadChat());
|
Assert.Equal(original, store.LoadChat());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Campaign CH slice CH6b: floating chat window filter round-trip ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadChat_returns_retail_PostInit_filter_defaults_when_file_is_missing()
|
||||||
|
{
|
||||||
|
var store = new SettingsStore(_tempPath);
|
||||||
|
ChatSettings loaded = store.LoadChat();
|
||||||
|
|
||||||
|
Assert.Equal(0x0000101Cu, loaded.ChatWindow1Filter);
|
||||||
|
Assert.Equal(0x00040C00u, loaded.ChatWindow2Filter);
|
||||||
|
Assert.Equal(0x00080000u, loaded.ChatWindow3Filter);
|
||||||
|
Assert.Equal(0x78000000u, loaded.ChatWindow4Filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaveChat_then_LoadChat_round_trips_floating_window_filters()
|
||||||
|
{
|
||||||
|
var store = new SettingsStore(_tempPath);
|
||||||
|
var original = ChatSettings.Default with
|
||||||
|
{
|
||||||
|
ChatWindow1Filter = 0x1u,
|
||||||
|
ChatWindow2Filter = 0xFFFFFFFFu,
|
||||||
|
ChatWindow3Filter = 0x8000000000000000u, // exercises the high dword (Society/reserved bits)
|
||||||
|
ChatWindow4Filter = 0ul,
|
||||||
|
};
|
||||||
|
|
||||||
|
store.SaveChat(original);
|
||||||
|
ChatSettings loaded = store.LoadChat();
|
||||||
|
|
||||||
|
Assert.Equal(original.ChatWindow1Filter, loaded.ChatWindow1Filter);
|
||||||
|
Assert.Equal(original.ChatWindow2Filter, loaded.ChatWindow2Filter);
|
||||||
|
Assert.Equal(original.ChatWindow3Filter, loaded.ChatWindow3Filter);
|
||||||
|
Assert.Equal(original.ChatWindow4Filter, loaded.ChatWindow4Filter);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void All_four_sections_coexist_in_one_settings_json()
|
public void All_four_sections_coexist_in_one_settings_json()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue