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>
253 lines
8.7 KiB
C#
253 lines
8.7 KiB
C#
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);
|
||
}
|
||
}
|