fix(chat): CH6a/b rework — grip media, retail window-id model, floaty fixture

Applies docs/research/2026-08-10-ch6ab-review-findings.md in full:

- BLOCKER 1: UiResizeGrip now carries its ElementInfo/resolve pair and
  draws its own authored DirectState media (a synthetic parameterless
  grip still draws nothing, preserving existing resize-drag tests).
  DatWidgetFactory.BuildResizeGrip threads resolve through. All seven
  live grips on the main chat window now resolve a non-zero sprite,
  restoring the visible borders/corners CH6a silently dropped.

- SHOULD-FIX 2: ChatWindowState gains BroadcastTargetWindow, a sentinel
  distinct from every real window id (0-4), fixing the bug where the
  main window's explicit-addressing branch coincided with the broadcast
  check (both were literal 0). SetFilter's main-window no-op is dropped
  — the main window's filter is now genuinely settable. ChatWindowController
  .Bind takes a ChatWindowState (the same canonical instance the floating
  windows already share) and GetTranscriptLines builds a real accept
  predicate instead of accept:null. Verified safe: ClientLocal (0x1A)
  never reaches ChatLog (AddText routes it to the SpewBox and returns),
  so nothing observable regresses.

- SHOULD-FIX 3: UiButton.SuppressSelfToggle stops the four chat-window
  indicator buttons (DAT property 0x0B=true, no retail click handler)
  from flipping their own Selected mirror on a stray click.

- SHOULD-FIX 4: generated and committed chat_floaty_2100005b.json from
  the real installed dats; added the permanent RetailLayoutFixtureGenerator
  entry. All three flagged FloatingChatWindowController assumptions
  (input field, title bar, close button) are confirmed correct against
  real data — no controller code changes needed. New finding: unlike the
  main window, ALL EIGHT floaty border/corner elements are live Type-9
  grips (the floaty's own title bar is its move handle), so a floaty
  window resizes from every edge and corner.

- SHOULD-FIX 5: register row AP-189 documents the shared-500-entry/
  200-line-tail vs retail's per-window 10,000-line scrollback depth gap.

- NITs 1-5: documented the filter-persistence-only-on-/saveautoui
  asymmetry and the reconnect-preserves-filters intent; corrected the
  research doc's modifier-mask mislabel and the "ONLY function" false
  superlative; moved WrapText off ChatWindowController onto
  ChatTranscriptRenderer, closing the circular dependency.

Full Release suite: 12,420 passed / 4 skipped / 0 failed (baseline
12,392/4/0 at 22020ef2; net +28 tests, zero regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-10 13:09:32 +02:00
parent 56b84deeab
commit 1aa7709988
23 changed files with 6284 additions and 248 deletions

File diff suppressed because one or more lines are too long

View file

@ -245,10 +245,10 @@ implementer per slice against a pinned contract (per
| 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 | superseded — split below | — | — | superseded | | 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`, reworked (this commit) | 12,420 passed / 4 skipped / 0 failed | REJECT (docs/research/2026-08-10-ch6ab-review-findings.md, BLOCKER 1) → reworked (this commit) | pending — needs the next in-client round to confirm the border/corner art now renders |
| 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 14 | (this commit) | 12,392 passed / 4 skipped / 0 failed | REJECT (docs/research/2026-08-10-ch6ab-review-findings.md); rework in progress | pending — no client launches this session (hard constraint); needs the next connected round for keybind/mirror/filter visual confirmation | | CH6b floating windows 14 | `22020ef2`, reworked (this commit) | 12,420 passed / 4 skipped / 0 failed | REJECT (docs/research/2026-08-10-ch6ab-review-findings.md) → reworked (this commit) — SHOULD-FIXES 2/3/4/5 + NITs 1-5 applied | pending — no client launches this session (hard constraint); needs the next connected round for keybind/mirror/filter visual confirmation, plus the new 0x2100005B fixture's resolved-type assumptions |
| CH6c opacity | not started | — | — | not started | | CH6c opacity | not started | — | — | not started |
### CH4 closeout (2026-08-09) ### CH4 closeout (2026-08-09)
@ -722,3 +722,98 @@ retail LayoutDesc dumps) to find the smallest font id actually CONFIRMED
in use by any retail UI import acdream has ported, rather than merely in use by any retail UI import acdream has ported, rather than merely
present in the DAT. `0x40000025` (11px) was both the global minimum across present in the DAT. `0x40000025` (11px) was both the global minimum across
those fixtures AND the chat window's own minimum — no tie-break needed. those fixtures AND the chat window's own minimum — no tie-break needed.
## CH6a/b REJECT-review rework (2026-08-10)
Applied everything in `docs/research/2026-08-10-ch6ab-review-findings.md`
under the session's hard constraints (no subagents, no client launches).
The retail research itself held up under independent re-derivation
(masks, Alt+1..4, pure-mirror indicators all CONFIRMED); the rework was
entirely against the FIX list, not the research.
- **BLOCKER 1 (CH6a — invisible borders).** `UiResizeGrip` (`src/AcDream.App/UI/UiResizeGrip.cs`)
gained a second constructor carrying its `ElementInfo`/sprite-resolve
pair and an `OnDraw` override that stamps the active DirectState sprite
exactly like `UiDatElement` — a synthetic (parameterless) grip still
draws nothing, matching every existing resize-drag unit test.
`DatWidgetFactory.BuildResizeGrip` now threads `resolve` through.
`SpriteFile` exposes the raw dat sprite id for conformance tests without
needing a live GL context; `ChatLayoutConformanceTests
.MountedChatWindow_LiveGrip_ResolvesNonZeroSprite` pins all seven live
grips against the committed `chat_2100006f.json` fixture.
- **SHOULD-FIX 2 (CH6b — window-id model).** `ChatWindowState` gained
`BroadcastTargetWindow` (a sentinel distinct from every real window id
`0`-`4`) so `ShouldDisplay`'s explicit-addressing branch can no longer
coincide with the broadcast check for the main window (id `0`).
`SetFilter`'s main-window no-op is dropped — the main window's filter is
now genuinely settable, matching retail's options-page-driven main
filter. `ChatWindowController.Bind` gained a `ChatWindowState
windowFilters` parameter (the same canonical
`RuntimeCommunicationState.ChatWindows` instance the floating windows
already read) and `GetTranscriptLines` now builds a real accept
predicate from it instead of `accept: null` — verified safe: `0x1A`
(`ClientLocal`) never reaches `ChatLog` in the first place
(`RuntimeCommunicationState.AddText` routes it to the SpewBox and
returns before `Chat.OnSystemMessage`), so excluding it from the main
filter has zero effect on WeenieError/refusal text. Every production
gameplay chat type (Speech/Tell/Social/Fellowship/Allegiance/Turbine
rooms) stays within the default filter's covered bits. Register row
AP-189 documents the resulting shallower shared-log scrollback depth
(SHOULD-FIX 5, below) as the one behavioral residual.
- **SHOULD-FIX 3 (CH6b — indicator self-toggle).** `UiButton` gained
`SuppressSelfToggle`, checked alongside `ToggleBehavior` at the
press-release toggle site; `ChatWindowController.Bind` sets it on all
four chat-window indicator buttons (which carry DAT property `0x0B` =
true but have no retail click handler). New `UiButtonTests
.SuppressSelfToggle_PressReleaseDoesNotFlipSelected` and an extended
`MountedChatWindow_IndicatorButtons_ImportVisibleAndInert` (now presses
and releases the real fixture's indicator, asserting `Selected`
unchanged) replace the "wrong level" `OnClick == null` check.
- **SHOULD-FIX 4 (CH6b — 0x2100005B never dumped).** The floaty layout
DAT dir at `%USERPROFILE%\Documents\Asheron's Call\` (the default
`ConformanceDats.ResolveDatDir()`/`RetailLayoutFixtureGenerator` path)
IS reachable in this environment — `chat_floaty_2100005b.json` (171 KB,
5,473 lines) was generated for real and committed;
`RetailLayoutFixtureGenerator.Layouts` gained the permanent entry for
future regenerations. All three flagged untested assumptions in
`FloatingChatWindowController` turned out CORRECT against the real
fixture — no controller code changed: `0x10000016` is Type 12 with
property `0x16` (Editable) = true, resolving to `UiField`; `0x100004D9`
is Type 12 with NO `0x16`, resolving to `UiText`; `0x1000052A` is Type 1,
resolving to `UiButton`. One extra finding beyond the three named
assumptions: unlike the main window (whose plain top strip is a Type-2
Dragbar move handle because the main window has no title bar), the
floaty window's move handle is its own title bar group (`0x10000529`,
Type 2) — so ALL EIGHT of the floaty's border/corner elements are live
Type-9 grips, not seven; a floaty window resizes from every edge and
corner. New `FloatingChatLayoutConformanceTests.cs` pins the resolved
widget types and extends BLOCKER 1's sprite-non-zero guard to all eight
floaty grips.
- **SHOULD-FIX 5 (register row).** AP-189 files the shared-500-entry/
200-line-tail vs. retail's per-window 10,000-line log scrollback-depth
divergence — the accumulate-while-closed and independent-per-window-
scroll BEHAVIORS both reproduce correctly; only the numeric DEPTH ceiling
differs.
- **NITs 1-5.** N1: documented the filter-persistence-only-on-`/saveautoui`
asymmetry on `RetailUiRuntime.SaveChatWindowFilters`, deferred to CH6e.
N2: documented why `ChatWindows.ResetToDefaults()` only runs in
`RuntimeCommunicationState.Dispose` (full teardown), not on reconnect —
deliberate, matches window-geometry persistence. N3: replaced the §1.3
UNVERIFIED modifier-mapping hedge and the "`0x00000002` = shift" mislabel
with `retail-default.keymap.txt`'s own `MetaKeys` legend (Shift=`0x1`,
Ctrl=`0x2`, Alt=`0x4`, Win=`0x8`) — no code change, `KeyBindings` already
had `ModifierMask.Alt` right. N4: fixed the false "ONLY function that
branches on `idMessage == 1`" superlative in §1.4 and
`ChatWindowController.SetIndicatorOpen`'s doc
(`gmFloatyChatUI::ListenToElementMessage @0x004CE330` also does); the
substantive "no case for the indicator ids" claim stands. N5: moved
`WrapText`/`WrapSingleLine` off `ChatWindowController` onto
`ChatTranscriptRenderer` (closing the circular dependency where
`BuildLines` called back into one of its own two consumers); updated the
two unrelated external callers (`IndicatorDetailText.cs`,
`ItemAppraisalReport.cs`) and moved the WrapText tests into the new
`ChatTranscriptRendererTests.cs`.
Full Release suite: 12,420 passed / 4 skipped / 0 failed (baseline
12,392/4/0 at `22020ef2`; net +28 tests, all new coverage from this
rework, zero regressions).

View file

@ -87,11 +87,30 @@ ToggleFloatingChatWindow4 [ "" [ 0 DIK_4 ] 0x00000004 ]
``` ```
`0x00000004` is the modifier mask (the same mask the `UseQuickSlot_14..18` rows `0x00000004` is the modifier mask (the same mask the `UseQuickSlot_14..18` rows
use for `Alt`+digit, versus `0x00000002` for the shift-digit quickslot rows). use for `Alt`+digit, versus `0x00000002` for the CTRL-digit quickslot rows —
corrected below; the original filing mislabeled `0x00000002` as "shift").
So retail's default is **Alt+1 … Alt+4**, not bare 14. So retail's default is **Alt+1 … Alt+4**, not bare 14.
UNVERIFIED: the exact modifier bit → key mapping (4 = Alt vs 4 = Ctrl). Cheapest RESOLVED at Campaign CH slice CH6b: `retail-default.keymap.txt`'s own
resolution: read `keymap-default.txt`'s modifier legend, or one cdb breakpoint on `MetaKeys` legend (not a guess — the keymap file's literal index table) reads
`UIElementManager::KeyPressEvent`. The *mechanism* below is independent of this. ```
MetaKeys
[
1 [ 0 DIK_LSHIFT ]
2 [ 0 DIK_LCONTROL ]
2 [ 0 DIK_RCONTROL ]
3 [ 0 DIK_LMENU ]
3 [ 0 DIK_RALT ]
4 [ 0 DIK_LWIN ]
4 [ 0 DIK_RWIN ]
]
```
so the modifier-mask bit for MetaKeys index *N* is `1 << (N-1)`: **index 1 =
Shift → `0x1`, index 2 = Ctrl → `0x2`, index 3 = Alt → `0x4`, index 4 = Win →
`0x8`**. `0x00000004` on the `ToggleFloatingChatWindow1..4` rows is therefore
unambiguously **Alt**, cross-checked against the same file's own Alt+A/D
strafe and Alt+Enter/Tab/F4 rows (all `0x00000004`). `KeyBindings` already
carried `ModifierMask.Alt` for these four actions since Phase K.1c — no code
change was needed, only removing this hedge.
The dispatch chain is fully generic — there is no chat-specific code in it: The dispatch chain is fully generic — there is no chat-specific code in it:
@ -151,10 +170,15 @@ 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`.
**RESOLVED 2026-08-10 at Campaign CH slice CH6b.** The prior UNVERIFIED **RESOLVED 2026-08-10 at Campaign CH slice CH6b; wording corrected at the
paragraph's hedge ("safe to wire both") is superseded by a direct read of CH6a/b REJECT-review (NIT 4) — the original "ONLY function in the whole 2013
`gmMainChatUI::ListenToElementMessage @0x004CDA80` — the ONLY function in the binary that branches on `idMessage == 1`" superlative was false**
whole 2013 binary that branches on `idMessage == 1` ("clicked"). It handles (`gmFloatyChatUI::ListenToElementMessage @0x004CE330`, the floaty windows'
close-button handler, also branches on `idMessage == 1`; §1.5). **The
substantive claim stands: within `gmMainChatUI::ListenToElementMessage`
specifically, there is no case for `0x10000522`-`0x10000525`.** The prior
UNVERIFIED paragraph's hedge ("safe to wire both") is superseded by a direct
read of `gmMainChatUI::ListenToElementMessage @0x004CDA80`. It handles
exactly two element ids: `0x1000046f` (max/min, dispatching exactly two element ids: `0x1000046f` (max/min, dispatching
`HandleMaximizeButton`) and the talk-focus menu's selection message `HandleMaximizeButton`) and the talk-focus menu's selection message
(`idMessage == 7`, checked against `this->m_pCCS` / a `0x1000000b` attribute (`idMessage == 7`, checked against `this->m_pCCS` / a `0x1000000b` attribute

View file

@ -19,6 +19,13 @@ namespace AcDream.App.UI.Layout;
/// (revision/wrap-width/font keyed), matching the caching each controller /// (revision/wrap-width/font keyed), matching the caching each controller
/// already had before this extraction. /// already had before this extraction.
/// </para> /// </para>
///
/// <para>
/// <see cref="WrapText"/> also lives here (CH6a/b REJECT-review NIT 5 — it
/// used to live on <see cref="ChatWindowController"/>, which made
/// <see cref="BuildLines"/> call BACK into its own caller's class, a circular
/// dependency between this "shared" module and one of its two consumers).
/// </para>
/// </summary> /// </summary>
internal static class ChatTranscriptRenderer internal static class ChatTranscriptRenderer
{ {
@ -28,12 +35,15 @@ internal static class ChatTranscriptRenderer
/// <param name="accept"> /// <param name="accept">
/// Optional per-line filter — retail's <c>ChatInterface::TypeIsActive</c> /// Optional per-line filter — retail's <c>ChatInterface::TypeIsActive</c>
/// (or the full <c>ShouldDisplay</c> predicate) for THIS window. Null /// (or the full <c>ShouldDisplay</c> predicate) for THIS window. Null
/// accepts every line (the main window has no user filter — color-table /// accepts every line. CH6a/b REJECT-review SHOULD-FIX 2: the main
/// research doc §4). A line that fails the filter is dropped from this /// window now ALWAYS passes a real predicate too (its own retail default
/// window's view WITHOUT advancing the carried-forward color, matching /// 0xFBFFFFFF filter via <c>ChatWindowState</c>) — null remains supported
/// retail's <c>m_curFontColor</c> only advancing for lines actually /// for callers with no filter concept at all (there are none in
/// appended to THIS window's own scroll (<c>AppendStringInfoWithFont</c> /// production today, but the shape stays general). A line that fails the
/// only runs for displayed lines). /// 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> /// </param>
public static List<UiText.Line> BuildLines( public static List<UiText.Line> BuildLines(
IReadOnlyList<FormattedLine> detailed, IReadOnlyList<FormattedLine> detailed,
@ -57,9 +67,89 @@ internal static class ChatTranscriptRenderer
continue; continue;
if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved)) if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved))
currentColor = resolved; currentColor = resolved;
foreach (string frag in ChatWindowController.WrapText(d.Text, maxW, measure)) foreach (string frag in WrapText(d.Text, maxW, measure))
result.Add(new UiText.Line(frag, currentColor)); result.Add(new UiText.Line(frag, currentColor));
} }
return result; return result;
} }
/// <summary>
/// Greedy word-wrap: split <paramref name="text"/> into fragments that each fit in
/// <paramref name="maxW"/> pixels (per <paramref name="measure"/>), breaking at spaces.
/// A word that is itself wider than the line is broken at CHARACTER boundaries (no
/// hyphen), packed onto the current line first — so a long unbroken token (e.g. a URL
/// or "wwwww…") wraps instead of overflowing, and a "You say," prefix stays on the same
/// row as the start of the message. Mirrors retail GlyphList::Recalculate's per-GlyphLine
/// emission (which breaks mid-glyph-run when a run exceeds the wrap width).
/// </summary>
public static IEnumerable<string> WrapText(string text, float maxW, Func<string, float> measure)
{
if (string.IsNullOrEmpty(text))
{
yield return string.Empty;
yield break;
}
// Campaign CH user-gate round 1 (item F): server text (e.g. /help's
// reply) carries embedded '\n's. This function used to hand the
// WHOLE blob — newlines and all — to the single early-out below,
// rendering multi-line text as one UiText.Line with literal newline
// characters in it instead of one rendered line per segment. Split
// on '\n' FIRST (normalizing "\r\n"/bare "\r" the same way), then
// word-wrap each segment independently; the early-out is now scoped
// to one already-newline-free segment, so it only ever collapses a
// single-segment text to one line, never a multi-line one.
string normalized = text.Replace("\r\n", "\n").Replace('\r', '\n');
foreach (string segment in normalized.Split('\n'))
{
foreach (string frag in WrapSingleLine(segment, maxW, measure))
yield return frag;
}
}
/// <summary>
/// Greedy word-wrap for a single, already newline-free line. Split out of
/// <see cref="WrapText"/> (Campaign CH user-gate round 1, item F) so the
/// multi-segment split there can call this once per '\n'-delimited
/// segment without re-deriving the per-line wrap algorithm.
/// </summary>
private static IEnumerable<string> WrapSingleLine(string text, float maxW, Func<string, float> measure)
{
if (text.Length == 0 || maxW <= 0f || measure(text) <= maxW)
{
yield return text;
yield break;
}
var line = new System.Text.StringBuilder();
foreach (var word in text.Split(' '))
{
string sep = line.Length > 0 ? " " : string.Empty;
if (measure(line.ToString() + sep + word) <= maxW)
{
line.Append(sep).Append(word); // fits on the current line
continue;
}
if (line.Length > 0 && measure(word) <= maxW)
{
yield return line.ToString(); // word fits alone → push to a new line
line.Clear();
line.Append(word);
continue;
}
// Word too long for any single line: char-wrap it, packing onto the current
// line's remaining space first (keeps the prefix with the message start).
if (line.Length > 0) line.Append(' ');
foreach (char ch in word)
{
if (line.Length > 0 && measure(line.ToString() + ch) > maxW)
{
yield return line.ToString();
line.Clear();
}
line.Append(ch);
}
}
if (line.Length > 0) yield return line.ToString();
}
} }

View file

@ -119,12 +119,18 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
private ChatChannelKind _activeChannel = ChatChannelKind.Say; private ChatChannelKind _activeChannel = ChatChannelKind.Say;
// The main window's own per-window filter/open state (CH6a/b REJECT-review
// SHOULD-FIX 2) — the SAME canonical instance the floating windows read
// (RuntimeCommunicationState.ChatWindows), never a presentation-owned copy.
private ChatWindowState _windowFilters = null!;
// UiText polls LinesProvider while drawing and hit-testing. Keep the fully // UiText polls LinesProvider while drawing and hit-testing. Keep the fully
// formatted + wrapped transcript until either its source revision or the // formatted + wrapped transcript until either its source revision or the
// metrics that determine wrapping change. This makes an idle chat window // metrics that determine wrapping change. This makes an idle chat window
// allocation-free instead of snapshotting/formatting/wrapping every frame. // allocation-free instead of snapshotting/formatting/wrapping every frame.
private IReadOnlyList<UiText.Line> _cachedTranscriptLines = Array.Empty<UiText.Line>(); private IReadOnlyList<UiText.Line> _cachedTranscriptLines = Array.Empty<UiText.Line>();
private long _cachedTranscriptRevision = -1; private long _cachedTranscriptRevision = -1;
private ulong _cachedFilter;
private float _cachedTranscriptWrapWidth = float.NaN; private float _cachedTranscriptWrapWidth = float.NaN;
private UiDatFont? _cachedTranscriptDatFont; private UiDatFont? _cachedTranscriptDatFont;
private BitmapFont? _cachedTranscriptDebugFont; private BitmapFont? _cachedTranscriptDebugFont;
@ -202,6 +208,11 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
/// Called on every chat submit so it resolves <see cref="AcDream.UI.Abstractions.LiveCommandBus"/> /// Called on every chat submit so it resolves <see cref="AcDream.UI.Abstractions.LiveCommandBus"/>
/// even when the live session is established AFTER <see cref="Bind"/> runs /// even when the live session is established AFTER <see cref="Bind"/> runs
/// (mirrors the ImGui <c>ChatPanel</c> which re-reads the bus each frame).</param> /// (mirrors the ImGui <c>ChatPanel</c> which re-reads the bus each frame).</param>
/// <param name="windowFilters">Runtime's canonical per-window filter/open state
/// (<see cref="AcDream.Runtime.Gameplay.RuntimeCommunicationState.ChatWindows"/>) — the
/// SAME instance the floating windows read. Read live on every transcript rebuild
/// (CH6a/b REJECT-review SHOULD-FIX 2: the main window's own filter is a real,
/// user-settable predicate now, not an inert no-op).</param>
/// <param name="datFont">Retail dat font for transcript + input rendering.</param> /// <param name="datFont">Retail dat font for transcript + input rendering.</param>
/// <param name="debugFont">Fallback debug bitmap font (used when /// <param name="debugFont">Fallback debug bitmap font (used when
/// <paramref name="datFont"/> is null).</param> /// <paramref name="datFont"/> is null).</param>
@ -212,10 +223,13 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
ImportedLayout layout, ImportedLayout layout,
ChatVM vm, ChatVM vm,
Func<ICommandBus> busProvider, Func<ICommandBus> busProvider,
ChatWindowState windowFilters,
UiDatFont? datFont, UiDatFont? datFont,
BitmapFont? debugFont, BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> resolve) Func<uint, (uint tex, int w, int h)> resolve)
{ {
ArgumentNullException.ThrowIfNull(windowFilters);
// Their parent panels must exist as real widgets in the layout tree. // Their parent panels must exist as real widgets in the layout tree.
var transcriptPanel = layout.FindElement(TranscriptPanelId); var transcriptPanel = layout.FindElement(TranscriptPanelId);
var inputBar = layout.FindElement(InputBarId); var inputBar = layout.FindElement(InputBarId);
@ -240,6 +254,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
{ {
Root = window, Root = window,
DatWindowInfo = FindInfo(rootInfo, RootId) ?? rootInfo, DatWindowInfo = FindInfo(rootInfo, RootId) ?? rootInfo,
_windowFilters = windowFilters,
}; };
// The 8 cosmetic "_Locked" border-art twins default HIDDEN — CH6a does not // The 8 cosmetic "_Locked" border-art twins default HIDDEN — CH6a does not
@ -253,10 +268,22 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
// ── Chat-window 1-4 indicator buttons — resolve now, wired later by // ── Chat-window 1-4 indicator buttons — resolve now, wired later by
// RetailUiRuntime via SetIndicatorOpen as each floating window's own // RetailUiRuntime via SetIndicatorOpen as each floating window's own
// visibility changes (gmMainChatUI::RecvNotice_SetPanelVisibility // visibility changes (gmMainChatUI::RecvNotice_SetPanelVisibility
// @0x004CCD80 — see this class's doc + Indicator1Id..Indicator4Id). ── // @0x004CCD80 — see this class's doc + Indicator1Id..Indicator4Id).
// CH6a/b REJECT-review SHOULD-FIX 3: these carry DAT property 0x0B
// (ToggleBehavior) = true, but retail's own click dispatch
// (gmMainChatUI::ListenToElementMessage @0x004CDA80) has no case for
// any of their element ids — clicking one does NOTHING in retail.
// SuppressSelfToggle keeps SetIndicatorOpen the ONLY writer of their
// Selected mirror; without it, a click would flip the Highlight/Normal
// art with no underlying visibility change. ──
uint[] indicatorIds = { Indicator1Id, Indicator2Id, Indicator3Id, Indicator4Id }; uint[] indicatorIds = { Indicator1Id, Indicator2Id, Indicator3Id, Indicator4Id };
for (int i = 0; i < indicatorIds.Length; i++) for (int i = 0; i < indicatorIds.Length; i++)
c._indicatorButtons[i] = layout.FindElement(indicatorIds[i]) as UiButton; {
var indicator = layout.FindElement(indicatorIds[i]) as UiButton;
if (indicator is not null)
indicator.SuppressSelfToggle = true;
c._indicatorButtons[i] = indicator;
}
// ── 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.
@ -483,11 +510,14 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
/// ///
/// <para> /// <para>
/// One-directional by design — a decomp read of /// One-directional by design — a decomp read of
/// <c>gmMainChatUI::ListenToElementMessage @0x004CDA80</c> (the ONLY /// <c>gmMainChatUI::ListenToElementMessage @0x004CDA80</c> (one of
/// function in the whole 2013 binary that branches on /// several functions in the 2013 binary that branch on
/// <c>idMessage == 1</c>, i.e. "clicked") shows it handles exactly two /// <c>idMessage == 1</c>, i.e. "clicked" — CH6a/b REJECT-review NIT 4
/// element ids: <c>0x1000046f</c> (max/min) and the talk-focus menu's /// corrected the earlier "the ONLY function" superlative, e.g.
/// selection message. There is no case for /// <c>gmFloatyChatUI::ListenToElementMessage @0x004CE330</c> also
/// branches on it for the floaty close button) shows THIS function
/// 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 /// <c>0x10000522</c>-<c>0x10000525</c> — clicking a chat-window
/// indicator button does NOTHING in retail. acdream ports this exactly: /// indicator button does NOTHING in retail. acdream ports this exactly:
/// these buttons have no <c>OnClick</c> (research doc §1.4 corrected — /// these buttons have no <c>OnClick</c> (research doc §1.4 corrected —
@ -543,8 +573,10 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
UiDatFont? datFont = Transcript.DatFont; UiDatFont? datFont = Transcript.DatFont;
BitmapFont? debugFont = Transcript.Font; BitmapFont? debugFont = Transcript.Font;
long revision = vm.Revision; long revision = vm.Revision;
ulong filter = _windowFilters.GetFilter(ChatWindowState.MainWindowId);
if (_cachedTranscriptRevision == revision if (_cachedTranscriptRevision == revision
&& _cachedFilter == filter
&& _cachedTranscriptWrapWidth.Equals(maxW) && _cachedTranscriptWrapWidth.Equals(maxW)
&& ReferenceEquals(_cachedTranscriptDatFont, datFont) && ReferenceEquals(_cachedTranscriptDatFont, datFont)
&& ReferenceEquals(_cachedTranscriptDebugFont, debugFont)) && ReferenceEquals(_cachedTranscriptDebugFont, debugFont))
@ -556,7 +588,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
if (detailed.Count == 0) if (detailed.Count == 0)
{ {
return StoreTranscriptLayout( return StoreTranscriptLayout(
Array.Empty<UiText.Line>(), revision, maxW, datFont, debugFont); Array.Empty<UiText.Line>(), revision, filter, maxW, datFont, debugFont);
} }
// Word-wrap each message to the transcript's current pixel width (ports retail // Word-wrap each message to the transcript's current pixel width (ports retail
@ -570,21 +602,27 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
// Campaign CH slice CH6b: the wrap + retail color-carry-forward // Campaign CH slice CH6b: the wrap + retail color-carry-forward
// algorithm is now shared with FloatingChatWindowController via // algorithm is now shared with FloatingChatWindowController via
// ChatTranscriptRenderer (approximation note on the color carry // ChatTranscriptRenderer (approximation note on the color carry
// moved there). The main window passes accept:null — it has no // moved there). CH6a/b REJECT-review SHOULD-FIX 2: the main window
// user filter (color-table research doc §4); this is behaviorally // now has a REAL accept predicate (retail's default 0xFBFFFFFF —
// identical to the inline loop this replaced. // everything except 0x1A, high dword zeroed so Society is opt-in),
var result = ChatTranscriptRenderer.BuildLines(detailed, maxW, measure, accept: null); // driven by the SAME ChatWindowState the floating windows read —
return StoreTranscriptLayout(result, revision, maxW, datFont, debugFont); // no more accept:null "no user filter" placeholder.
bool Accept(uint logTextType) => _windowFilters.ShouldDisplay(
ChatWindowState.MainWindowId, ChatWindowState.BroadcastTargetWindow, logTextType);
var result = ChatTranscriptRenderer.BuildLines(detailed, maxW, measure, Accept);
return StoreTranscriptLayout(result, revision, filter, maxW, datFont, debugFont);
} }
private IReadOnlyList<UiText.Line> StoreTranscriptLayout( private IReadOnlyList<UiText.Line> StoreTranscriptLayout(
IReadOnlyList<UiText.Line> lines, IReadOnlyList<UiText.Line> lines,
long revision, long revision,
ulong filter,
float wrapWidth, float wrapWidth,
UiDatFont? datFont, UiDatFont? datFont,
BitmapFont? debugFont) BitmapFont? debugFont)
{ {
_cachedTranscriptRevision = revision; _cachedTranscriptRevision = revision;
_cachedFilter = filter;
_cachedTranscriptWrapWidth = wrapWidth; _cachedTranscriptWrapWidth = wrapWidth;
_cachedTranscriptDatFont = datFont; _cachedTranscriptDatFont = datFont;
_cachedTranscriptDebugFont = debugFont; _cachedTranscriptDebugFont = debugFont;
@ -593,86 +631,6 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
return lines; return lines;
} }
/// <summary>
/// Greedy word-wrap: split <paramref name="text"/> into fragments that each fit in
/// <paramref name="maxW"/> pixels (per <paramref name="measure"/>), breaking at spaces.
/// A word that is itself wider than the line is broken at CHARACTER boundaries (no
/// hyphen), packed onto the current line first — so a long unbroken token (e.g. a URL
/// or "wwwww…") wraps instead of overflowing, and a "You say," prefix stays on the same
/// row as the start of the message. Mirrors retail GlyphList::Recalculate's per-GlyphLine
/// emission (which breaks mid-glyph-run when a run exceeds the wrap width).
/// </summary>
public static IEnumerable<string> WrapText(string text, float maxW, Func<string, float> measure)
{
if (string.IsNullOrEmpty(text))
{
yield return string.Empty;
yield break;
}
// Campaign CH user-gate round 1 (item F): server text (e.g. /help's
// reply) carries embedded '\n's. This function used to hand the
// WHOLE blob — newlines and all — to the single early-out below,
// rendering multi-line text as one UiText.Line with literal newline
// characters in it instead of one rendered line per segment. Split
// on '\n' FIRST (normalizing "\r\n"/bare "\r" the same way), then
// word-wrap each segment independently; the early-out is now scoped
// to one already-newline-free segment, so it only ever collapses a
// single-segment text to one line, never a multi-line one.
string normalized = text.Replace("\r\n", "\n").Replace('\r', '\n');
foreach (string segment in normalized.Split('\n'))
{
foreach (string frag in WrapSingleLine(segment, maxW, measure))
yield return frag;
}
}
/// <summary>
/// Greedy word-wrap for a single, already newline-free line. Split out of
/// <see cref="WrapText"/> (Campaign CH user-gate round 1, item F) so the
/// multi-segment split there can call this once per '\n'-delimited
/// segment without re-deriving the per-line wrap algorithm.
/// </summary>
private static IEnumerable<string> WrapSingleLine(string text, float maxW, Func<string, float> measure)
{
if (text.Length == 0 || maxW <= 0f || measure(text) <= maxW)
{
yield return text;
yield break;
}
var line = new System.Text.StringBuilder();
foreach (var word in text.Split(' '))
{
string sep = line.Length > 0 ? " " : string.Empty;
if (measure(line.ToString() + sep + word) <= maxW)
{
line.Append(sep).Append(word); // fits on the current line
continue;
}
if (line.Length > 0 && measure(word) <= maxW)
{
yield return line.ToString(); // word fits alone → push to a new line
line.Clear();
line.Append(word);
continue;
}
// Word too long for any single line: char-wrap it, packing onto the current
// line's remaining space first (keeps the prefix with the message start).
if (line.Length > 0) line.Append(' ');
foreach (char ch in word)
{
if (line.Length > 0 && measure(line.ToString() + ch) > maxW)
{
yield return line.ToString();
line.Clear();
}
line.Append(ch);
}
}
if (line.Length > 0) yield return line.ToString();
}
public void Dispose() public void Dispose()
{ {
if (_disposed) return; if (_disposed) return;

View file

@ -95,7 +95,7 @@ public static class DatWidgetFactory
// gmUIElement_*Indicator custom button classes // gmUIElement_*Indicator custom button classes
6 => new UiMenu(), // UIElement_Menu (reg :120163) 6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter 7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
9 => BuildResizeGrip(info), // UIElement_Resizebar (reg 0x0046B920) 9 => BuildResizeGrip(info, resolve), // UIElement_Resizebar (reg 0x0046B920)
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf 0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137) 11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text 12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
@ -266,14 +266,18 @@ public static class DatWidgetFactory
/// all-false Type-9 element is handled defensively the same way retail's own /// all-false Type-9 element is handled defensively the same way retail's own
/// BORDER_NONE fallback does) decodes to <see cref="UiResizeGrip.Border.None"/> /// BORDER_NONE fallback does) decodes to <see cref="UiResizeGrip.Border.None"/>
/// — <see cref="UiRoot"/> then treats it as contributing no resize edges. /// — <see cref="UiRoot"/> then treats it as contributing no resize edges.
/// <paramref name="resolve"/> is carried through (CH6a/b REJECT-review
/// BLOCKER 1) so the grip draws its own authored border/corner media
/// instead of nothing.
/// </summary> /// </summary>
private static UiResizeGrip BuildResizeGrip(ElementInfo info) private static UiResizeGrip BuildResizeGrip(
ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
{ {
bool bottom = info.TryGetEffectiveBool(0x2Au, out bool bottomValue) && bottomValue; bool bottom = info.TryGetEffectiveBool(0x2Au, out bool bottomValue) && bottomValue;
bool left = info.TryGetEffectiveBool(0x2Bu, out bool leftValue) && leftValue; bool left = info.TryGetEffectiveBool(0x2Bu, out bool leftValue) && leftValue;
bool right = info.TryGetEffectiveBool(0x2Cu, out bool rightValue) && rightValue; bool right = info.TryGetEffectiveBool(0x2Cu, out bool rightValue) && rightValue;
bool top = info.TryGetEffectiveBool(0x2Du, out bool topValue) && topValue; bool top = info.TryGetEffectiveBool(0x2Du, out bool topValue) && topValue;
return new UiResizeGrip return new UiResizeGrip(info, resolve)
{ {
BorderLocation = UiResizeGrip.DecodeBorderLocation(bottom, left, right, top), BorderLocation = UiResizeGrip.DecodeBorderLocation(bottom, left, right, top),
}; };

View file

@ -275,7 +275,8 @@ public sealed class FloatingChatWindowController : IRetainedPanelController
: debugFont is { } bf ? s => bf.MeasureWidth(s) : debugFont is { } bf ? s => bf.MeasureWidth(s)
: static s => s.Length * 7f; : static s => s.Length * 7f;
bool Accept(uint logTextType) => windowFilters.ShouldDisplay(WindowId, targetWindowId: 0u, logTextType); bool Accept(uint logTextType) => windowFilters.ShouldDisplay(
WindowId, ChatWindowState.BroadcastTargetWindow, logTextType);
var result = ChatTranscriptRenderer.BuildLines(detailed, maxW, measure, Accept); var result = ChatTranscriptRenderer.BuildLines(detailed, maxW, measure, Accept);
_cachedTranscriptRevision = revision; _cachedTranscriptRevision = revision;

View file

@ -32,7 +32,7 @@ internal static class IndicatorDetailText
continue; continue;
} }
foreach (string line in ChatWindowController.WrapText(paragraph, maxWidth, Measure)) foreach (string line in ChatTranscriptRenderer.WrapText(paragraph, maxWidth, Measure))
lines.Add(new UiText.Line(line, target.DefaultColor)); lines.Add(new UiText.Line(line, target.DefaultColor));
} }
return lines; return lines;

View file

@ -169,7 +169,7 @@ internal static class ItemAppraisalTextLayout
continue; continue;
} }
foreach (string wrapped in ChatWindowController.WrapText( foreach (string wrapped in ChatTranscriptRenderer.WrapText(
logicalLine, logicalLine,
maxWidth, maxWidth,
Measure)) Measure))

View file

@ -575,6 +575,17 @@ public sealed class RetailUiRuntime : IDisposable
/// Capture the four floating windows' current text-type filters into /// Capture the four floating windows' current text-type filters into
/// the local settings store (research doc §4.4/§6.1 — local-only until /// the local settings store (research doc §4.4/§6.1 — local-only until
/// CH6f's wire format). No-op when no store was wired. /// CH6f's wire format). No-op when no store was wired.
///
/// <para>
/// CH6a/b REJECT-review NIT 1: unlike window geometry/visibility (which
/// <see cref="_persistence"/> auto-saves on every change), a filter
/// change is only captured here — i.e. only when <see cref="SaveLayout"/>
/// runs, which today is exclusively the explicit <c>/saveautoui</c>
/// command. Harmless in practice (nothing currently mutates a window's
/// filter live — no options UI exists yet), but worth tightening to
/// auto-save-on-change once CH6e/CH6f gives filters a live settings
/// surface a user can actually edit mid-session.
/// </para>
/// </summary> /// </summary>
private void SaveChatWindowFilters() private void SaveChatWindowFilters()
{ {
@ -807,6 +818,7 @@ public sealed class RetailUiRuntime : IDisposable
layout, layout,
_bindings.Chat.ViewModel, _bindings.Chat.ViewModel,
_bindings.Chat.CommandBus, _bindings.Chat.CommandBus,
_bindings.Chat.Windows,
_bindings.Assets.DefaultFont, _bindings.Assets.DefaultFont,
_bindings.Assets.DebugFont, _bindings.Assets.DebugFont,
_bindings.Assets.ResolveSprite); _bindings.Assets.ResolveSprite);

View file

@ -111,6 +111,23 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
public float HotClickInitialDelay { get; } public float HotClickInitialDelay { get; }
public float HotClickRepeatInterval { get; } public float HotClickRepeatInterval { get; }
/// <summary>
/// Opt-out for a <see cref="ToggleBehavior"/> button whose <see cref="Selected"/>
/// state is a PURE MIRROR of external state (a producer other than the click
/// itself is the only legitimate writer — e.g. <see cref="ChatWindowController.SetIndicatorOpen"/>
/// mirroring a floating chat window's own visibility). CH6a/b REJECT-review
/// SHOULD-FIX 3: the chat-window 1-4 indicators (<c>0x10000522</c>-<c>0x10000525</c>)
/// carry DAT property <c>0x0B</c> (<see cref="ToggleBehavior"/>) = true, so
/// without this flag a click flips their Highlight/Normal art with no
/// underlying visibility change — the mirror lies until the next real
/// toggle. Retail confirms clicking these buttons does nothing
/// (<c>gmMainChatUI::ListenToElementMessage @0x004CDA80</c> has no case for
/// their element ids — see <see cref="ChatWindowController.SetIndicatorOpen"/>'s
/// doc). Default <see langword="false"/> — every OTHER toggle button (max/min,
/// checkboxes) keeps retail's normal click-toggles-itself behavior.
/// </summary>
public bool SuppressSelfToggle { get; set; }
public bool Selected public bool Selected
{ {
get => _selected; get => _selected;
@ -435,7 +452,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
_suppressNextClick = _hotClicking && _pointerOver; _suppressNextClick = _hotClicking && _pointerOver;
_hotClicking = false; _hotClicking = false;
_nextHotClickTime = double.NaN; _nextHotClickTime = double.NaN;
if (_pressed && _pointerOver && Enabled && ToggleBehavior) if (_pressed && _pointerOver && Enabled && ToggleBehavior && !SuppressSelfToggle)
_selected = !_selected; _selected = !_selected;
if (_pressed && Enabled) if (_pressed && Enabled)
OnReleased?.Invoke(); OnReleased?.Invoke();

View file

@ -1,3 +1,7 @@
using System;
using System.Numerics;
using AcDream.App.UI.Layout;
namespace AcDream.App.UI; namespace AcDream.App.UI;
/// <summary> /// <summary>
@ -23,10 +27,19 @@ namespace AcDream.App.UI;
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// A grip is a small (5px) leaf with no children and no authored background /// A grip is a small (5px) leaf with no children; it exists primarily as a
/// media beyond its own cursor-feedback sprite; it exists purely as a precise /// precise hit region, but it IS authored with real border/corner media (the
/// hit region. <see cref="AcDream.App.UI.UiRoot"/> gives a directly-hit grip's /// <c>0x06006129</c>-family sprites — CH6a/b REJECT-review BLOCKER 1,
/// own <see cref="Edges"/> priority over the generic proximity-based /// <c>docs/research/2026-08-10-ch6ab-review-findings.md</c>: every one of the
/// seven live grips on the main chat window carries a non-zero DirectState
/// sprite, and drawing nothing left the window with only its 400×5 top strip
/// visible). A factory-built grip (<see cref="AcDream.App.UI.Layout.DatWidgetFactory.Create"/>,
/// Type 9) therefore draws its own DirectState media exactly like
/// <see cref="AcDream.App.UI.Layout.UiDatElement"/> would; a synthetic grip
/// built with the parameterless constructor (unit tests exercising only the
/// resize-drag behavior) carries no media and draws nothing, matching its
/// prior behavior. <see cref="AcDream.App.UI.UiRoot"/> gives a directly-hit
/// grip's own <see cref="Edges"/> priority over the generic proximity-based
/// <see cref="AcDream.App.UI.UiRoot.HitEdges"/> heuristic, so e.g. the chat /// <see cref="AcDream.App.UI.UiRoot.HitEdges"/> heuristic, so e.g. the chat
/// window's top-left/top-right CORNER grips resize (including the Y/top axis) /// window's top-left/top-right CORNER grips resize (including the Y/top axis)
/// while the plain top EDGE strip in between remains a pure move handle. /// while the plain top EDGE strip in between remains a pure move handle.
@ -34,6 +47,57 @@ namespace AcDream.App.UI;
/// </summary> /// </summary>
public sealed class UiResizeGrip : UiElement public sealed class UiResizeGrip : UiElement
{ {
private readonly ElementInfo? _info;
private readonly Func<uint, (uint tex, int w, int h)>? _resolve;
/// <summary>Synthetic grip with no authored media — used by unit tests that
/// exercise only the resize-drag hit-testing/edge behavior.</summary>
public UiResizeGrip()
{
}
/// <summary>
/// Factory-built grip (CH6a/b REJECT-review BLOCKER 1): carries its resolved
/// <see cref="ElementInfo"/> and sprite resolver so it draws its own
/// authored DirectState media, the same way every other dat-imported leaf
/// does. <see cref="UiElement.ClickThrough"/> is left at the base
/// <see langword="false"/> default — unlike <see cref="UiDatElement"/>'s
/// decoration default of <see langword="true"/> — because a grip must stay
/// hit-testable to capture the resize drag.
/// </summary>
public UiResizeGrip(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
{
_info = info;
_resolve = resolve;
}
/// <summary>
/// This grip's authored DirectState sprite file id (0 for a synthetic grip,
/// or an authored grip with no media). Exposed for conformance tests —
/// BLOCKER 1's regression guard is that every one of the seven live Type-9
/// grips resolves a non-zero value here.
/// </summary>
public uint SpriteFile => _info is not null && _info.StateMedia.TryGetValue("", out var media)
? media.File
: 0u;
/// <summary>
/// Draws this grip's own DirectState media exactly like
/// <see cref="UiDatElement.OnDraw"/> — tiled at native size (Normal), same
/// blend for Overlay/Alphablend since the sprite shader already
/// alpha-blends. A synthetic (parameterless-constructed) grip has no
/// resolver and draws nothing, matching its pre-BLOCKER-1 behavior.
/// </summary>
protected override void OnDraw(UiRenderContext ctx)
{
if (_resolve is null) return;
uint file = SpriteFile;
if (file == 0u) return;
var (tex, tw, th) = _resolve(file);
if (tex == 0 || tw == 0 || th == 0) return;
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
}
/// <summary>Retail <c>BorderLocation</c> (<c>acclient.h:4327</c>): /// <summary>Retail <c>BorderLocation</c> (<c>acclient.h:4327</c>):
/// <c>BORDER_NONE=0, BORDER_UL=1, BORDER_TOP=2, BORDER_UR=3, BORDER_RIGHT=4, /// <c>BORDER_NONE=0, BORDER_UL=1, BORDER_TOP=2, BORDER_UR=3, BORDER_RIGHT=4,
/// BORDER_LR=5, BORDER_BOTTOM=6, BORDER_LL=7, BORDER_LEFT=8</c>.</summary> /// BORDER_LR=5, BORDER_BOTTOM=6, BORDER_LL=7, BORDER_LEFT=8</c>.</summary>

View file

@ -8,7 +8,13 @@ namespace AcDream.Core.Chat;
/// id <c>0</c> is the main chat window, ids <c>1</c>-<c>4</c> are the four /// 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, /// 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-window-shell.md</c> §1.2 and
/// <c>docs/research/2026-08-09-chat-retail-color-table.md</c> §4). /// <c>docs/research/2026-08-09-chat-retail-color-table.md</c> §4). These are
/// acdream's OWN compact indices, not retail's raw <c>m_eWindowID</c> values:
/// retail's actual main window is <c>m_eWindowID == 8</c>, its floaties are
/// <c>2</c>-<c>5</c>, and <c>m_eWindowID == 0</c> is retail's ctor-default
/// "unauthored" sentinel — none of that is <c>0</c>-<c>4</c> (CH6a/b
/// REJECT-review SHOULD-FIX 2,
/// <c>docs/research/2026-08-10-ch6ab-review-findings.md</c>).
/// ///
/// <para> /// <para>
/// Ports two retail mechanisms exactly: /// Ports two retail mechanisms exactly:
@ -16,38 +22,45 @@ namespace AcDream.Core.Chat;
/// <item><c>ChatInterface::PostInit @0x004F3DD0</c>'s <c>m_oldState</c> /// <item><c>ChatInterface::PostInit @0x004F3DD0</c>'s <c>m_oldState</c>
/// switch seeds each window's default 64-bit /// switch seeds each window's default 64-bit
/// <c>m_llTextTypeFilter</c> (color-table doc §4's table — the constants /// <c>m_llTextTypeFilter</c> (color-table doc §4's table — the constants
/// below are byte-identical to that table).</item> /// below are byte-identical to that table; retail's own main-window default
/// 0xFBFFFFFF is shared by <c>m_eWindowID</c> 1 AND 8, and the floaty
/// defaults come from retail <c>m_eWindowID</c> 2-5, one higher than
/// acdream's compact 1-4).</item>
/// <item><c>ChatInterface::RecvNotice_DisplayFinalStringInfo /// <item><c>ChatInterface::RecvNotice_DisplayFinalStringInfo
/// @0x004F4640</c>'s display predicate: a line shows in window /// @0x004F4640</c>'s display predicate: a line shows in window
/// <c>W</c> when the message's target window id equals <c>W</c> /// <c>W</c> when the message's target window id equals <c>W</c>
/// (explicit addressing) OR the message is broadcast (target id /// (explicit addressing) OR the message is broadcast
/// <c>0</c>) AND <c>W</c>'s filter accepts the line's /// (<see cref="BroadcastTargetWindow"/>) AND <c>W</c>'s filter accepts the
/// <see cref="RetailLogTextType"/> (<c>ChatInterface::TypeIsActive /// line's <see cref="RetailLogTextType"/> (<c>ChatInterface::TypeIsActive
/// @0x004F2F10</c>).</item> /// @0x004F2F10</c>).</item>
/// </list> /// </list>
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// <c>UpdateFromPlayerModule @0x004F3920</c> early-returns for window id /// <see cref="BroadcastTargetWindow"/> is a sentinel distinct from every real
/// <c>0</c> — the main window never has a user-settable filter. That /// window id (<c>0</c>-<c>4</c>) — the acdream-internal analogue of retail's
/// invariant needs no special case here: for <c>windowId == 0</c>, /// wire <c>arg5 == 0</c> broadcast marker, deliberately kept separate from
/// <see cref="ShouldDisplay"/>'s first branch (<c>targetWindowId == /// window id <c>0</c> (main). Earlier CH6b code conflated the two (both were
/// windowId</c>) is already true for every broadcast line (target id /// literal <c>0</c>), so <see cref="ShouldDisplay"/>'s explicit-addressing
/// <c>0</c>), so the main window's filter is never actually consulted — /// branch (<c>targetWindowId == windowId</c>) short-circuited true for EVERY
/// exactly matching retail's "no user filter" behavior without a guard. /// broadcast line evaluated against the main window, regardless of its own
/// <see cref="SetFilter"/> and <see cref="SetOpen"/> are still no-ops for /// filter — the CH6a/b REJECT-review BLOCKER. With the sentinel separated
/// window <c>0</c> (it is always open and its seeded filter is inert), for /// out, the main window's filter is genuinely consulted for broadcast lines,
/// the same reason retail's setters gate on <c>m_eWindowID != 0</c> /// <see cref="SetFilter"/> is a real (not inert) write for window <c>0</c>
/// (persistence doc §4.2). /// too, and a future explicit <c>targetWindowId == MainWindowId</c> (AP-180's
/// <c>m_idCurrentCommandSource</c> per-window echo) stays distinguishable
/// from an ordinary broadcast line. <see cref="SetOpen"/>/<see cref="Toggle"/>
/// keep their own, UNRELATED no-op for window <c>0</c> — retail's main window
/// is simply never closable, independent of the broadcast-sentinel fix.
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// No production <see cref="ChatEntry"/> carries an explicit target window /// No production <see cref="ChatEntry"/> carries an explicit target window
/// id yet (register row AP-180 — the <c>windowId</c> dual-destination echo /// id yet (register row AP-180 — the <c>windowId</c> dual-destination echo
/// is deferred); every current line is effectively broadcast /// is deferred); every current line is effectively broadcast
/// (<c>targetWindowId == 0</c>). <see cref="ShouldDisplay"/> still accepts /// (<c>targetWindowId == </c><see cref="BroadcastTargetWindow"/>).
/// the full retail shape so the routing predicate does not need to change /// <see cref="ShouldDisplay"/> still accepts the full retail shape so the
/// shape when AP-180 lands. /// routing predicate does not need to change shape when AP-180 lands.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class ChatWindowState public sealed class ChatWindowState
@ -56,6 +69,15 @@ public sealed class ChatWindowState
public const int MinFloatingWindowId = 1; public const int MinFloatingWindowId = 1;
public const int MaxFloatingWindowId = 4; public const int MaxFloatingWindowId = 4;
/// <summary>
/// Sentinel <c>targetWindowId</c> meaning "broadcast to every window,
/// subject to each window's own filter" — the acdream-internal analogue
/// of retail's wire <c>arg5 == 0</c>. Deliberately outside the
/// <c>0</c>-<c>4</c> real-window-id range (see the class doc) so it can
/// never collide with <see cref="MainWindowId"/>.
/// </summary>
public const uint BroadcastTargetWindow = uint.MaxValue;
private const int WindowCount = MaxFloatingWindowId + 1; private const int WindowCount = MaxFloatingWindowId + 1;
private readonly object _gate = new(); private readonly object _gate = new();
@ -83,8 +105,9 @@ public sealed class ChatWindowState
{ {
lock (_gate) lock (_gate)
{ {
// "everything 0x00-0x1F except 0x1A" (m_oldState 1/8) — inert for // "everything 0x00-0x1F except 0x1A" (m_oldState 1/8) — genuinely
// routing (see class doc) but seeded for fidelity/inspection. // consulted for broadcast lines now that BroadcastTargetWindow is
// distinct from MainWindowId (see class doc).
_filters[0] = 0xFBFFFFFFu; _filters[0] = 0xFBFFFFFFu;
// Speech, Tell, Speech_Direct_Send, Emote (m_oldState 2). // Speech, Tell, Speech_Direct_Send, Emote (m_oldState 2).
_filters[1] = 0x0000101Cu; _filters[1] = 0x0000101Cu;
@ -110,13 +133,15 @@ public sealed class ChatWindowState
} }
/// <summary> /// <summary>
/// Set window <paramref name="windowId"/>'s 64-bit type filter. No-op for /// Set window <paramref name="windowId"/>'s 64-bit type filter — including
/// the main window (id <c>0</c>) — see the class doc. /// the main window (id <c>0</c>): CH6a/b REJECT-review SHOULD-FIX 2 drops
/// the old no-op (see the class doc's <see cref="BroadcastTargetWindow"/>
/// paragraph). Retail's main window IS user-settable through the options
/// page; only the settings UI to drive this is future work.
/// </summary> /// </summary>
public void SetFilter(int windowId, ulong filter) public void SetFilter(int windowId, ulong filter)
{ {
ValidateWindowId(windowId); ValidateWindowId(windowId);
if (windowId == MainWindowId) return;
lock (_gate) lock (_gate)
{ {
if (_filters[windowId] == filter) return; if (_filters[windowId] == filter) return;
@ -182,12 +207,15 @@ public sealed class ChatWindowState
/// <summary> /// <summary>
/// <c>ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640</c>'s /// <c>ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640</c>'s
/// exact display predicate — see the class doc for the two branches. /// exact display predicate — see the class doc for the two branches.
/// <paramref name="targetWindowId"/> is either a real window id
/// (<c>0</c>-<c>4</c>, explicit addressing) or
/// <see cref="BroadcastTargetWindow"/> (broadcast, filtered per window).
/// </summary> /// </summary>
public bool ShouldDisplay(int windowId, uint targetWindowId, uint logTextType) public bool ShouldDisplay(int windowId, uint targetWindowId, uint logTextType)
{ {
ValidateWindowId(windowId); ValidateWindowId(windowId);
if (targetWindowId == (uint)windowId) return true; if (targetWindowId == (uint)windowId) return true;
return targetWindowId == 0u && TypeIsActive(windowId, logTextType); return targetWindowId == BroadcastTargetWindow && TypeIsActive(windowId, logTextType);
} }
private static void ValidateWindowId(int windowId) private static void ValidateWindowId(int windowId)

View file

@ -216,6 +216,13 @@ public sealed class RuntimeCommunicationState : IDisposable
Squelch.Clear(); Squelch.Clear();
Chat.ResetSessionIdentity(); Chat.ResetSessionIdentity();
SpewBox.Reset(); SpewBox.Reset();
// CH6a/b REJECT-review NIT 2: this only runs here, at full teardown
// (process exit / GameRuntime disposal) — NOT on an ordinary
// reconnect, which never calls Dispose. Deliberate: a user's chat-
// window filter customization and open/closed state are client-side
// presentation preferences, the same class as window geometry
// (RetailWindowLayoutPersistence, which also survives reconnect) —
// reconnecting should not silently discard them.
ChatWindows.ResetToDefaults(); ChatWindows.ResetToDefaults();
} }

View file

@ -77,7 +77,7 @@ public class ChatLayoutConformanceTests
var infos = FixtureLoader.LoadChatInfos(); var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null); var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind( var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, null, null, NoTex); infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(controller); Assert.NotNull(controller);
UiElement? indicator = layout.FindElement(indicatorId); UiElement? indicator = layout.FindElement(indicatorId);
@ -91,6 +91,18 @@ public class ChatLayoutConformanceTests
var button = Assert.IsType<UiButton>(indicator); var button = Assert.IsType<UiButton>(indicator);
Assert.Null(button.OnClick); Assert.Null(button.OnClick);
Assert.Null(button.OnClickAt); Assert.Null(button.OnClickAt);
// CH6a/b REJECT-review SHOULD-FIX 3: OnClick==null is the wrong level
// to prove "inert" — the fixture's own 0x0B (ToggleBehavior) = true
// means a plain press/release would still flip UiButton's internal
// Selected mirror even with no OnClick bound. SuppressSelfToggle
// (set by ChatWindowController.Bind) is the actual guard; a press
// and release must leave Selected unchanged.
Assert.True(button.SuppressSelfToggle);
bool before = button.Selected;
button.OnEvent(new UiEvent(0, button, UiEventType.MouseDown, Data1: 2, Data2: 2));
button.OnEvent(new UiEvent(0, button, UiEventType.MouseUp, Data1: 2, Data2: 2));
Assert.Equal(before, button.Selected);
} }
[Theory] [Theory]
@ -110,7 +122,7 @@ public class ChatLayoutConformanceTests
var infos = FixtureLoader.LoadChatInfos(); var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null); var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind( var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, null, null, NoTex); infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(controller); Assert.NotNull(controller);
UiElement? twin = layout.FindElement(lockedTwinId); UiElement? twin = layout.FindElement(lockedTwinId);
@ -191,6 +203,7 @@ public class ChatLayoutConformanceTests
layout, layout,
new ChatVM(new ChatLog()), new ChatVM(new ChatLog()),
() => NullCommandBus.Instance, () => NullCommandBus.Instance,
new ChatWindowState(),
null, null,
null, null,
NoTex); NoTex);
@ -226,6 +239,7 @@ public class ChatLayoutConformanceTests
layout, layout,
new ChatVM(new ChatLog()), new ChatVM(new ChatLog()),
() => NullCommandBus.Instance, () => NullCommandBus.Instance,
new ChatWindowState(),
null, null,
null, null,
NoTex); NoTex);
@ -250,6 +264,7 @@ public class ChatLayoutConformanceTests
layout, layout,
new ChatVM(new ChatLog()), new ChatVM(new ChatLog()),
() => NullCommandBus.Instance, () => NullCommandBus.Instance,
new ChatWindowState(),
null, null,
null, null,
NoTex)!; NoTex)!;
@ -324,6 +339,27 @@ public class ChatLayoutConformanceTests
Assert.True(topStrip!.WindowMoveHandle); Assert.True(topStrip!.WindowMoveHandle);
} }
[Theory]
[InlineData(0x1000069Bu)] // TL corner
[InlineData(0x1000069Du)] // TR corner
[InlineData(0x1000069Eu)] // left edge
[InlineData(0x1000069Fu)] // BL corner
[InlineData(0x100006A0u)] // bottom edge
[InlineData(0x100006A1u)] // BR corner
[InlineData(0x100006A2u)] // right edge
public void MountedChatWindow_LiveGrip_ResolvesNonZeroSprite(uint elementId)
{
// CH6a/b REJECT-review BLOCKER 1: pre-fix, UiResizeGrip drew nothing —
// it was constructed without its ElementInfo/resolve pair, so all seven
// live Type-9 grips rendered NOTHING even though the fixture proves
// every one of them carries real authored art (the 0x06006129 family).
// This is the regression guard: every live grip must resolve a non-zero
// DirectState sprite id, not just decode the right BorderLocation.
var layout = FixtureLoader.LoadChat();
var grip = Assert.IsType<UiResizeGrip>(layout.FindElement(elementId));
Assert.NotEqual(0u, grip.SpriteFile);
}
[Fact] [Fact]
public void MountedChatWindow_BottomRightGrip_GrowsBothAxes_NotOnlyShrinks() public void MountedChatWindow_BottomRightGrip_GrowsBothAxes_NotOnlyShrinks()
{ {
@ -340,6 +376,7 @@ public class ChatLayoutConformanceTests
layout, layout,
new ChatVM(new ChatLog()), new ChatVM(new ChatLog()),
() => NullCommandBus.Instance, () => NullCommandBus.Instance,
new ChatWindowState(),
null, null,
null, null,
NoTex)!; NoTex)!;
@ -396,6 +433,7 @@ public class ChatLayoutConformanceTests
layout, layout,
new ChatVM(new ChatLog()), new ChatVM(new ChatLog()),
() => NullCommandBus.Instance, () => NullCommandBus.Instance,
new ChatWindowState(),
null, null,
null, null,
NoTex)!; NoTex)!;

View file

@ -0,0 +1,89 @@
using System.Collections.Generic;
using System.Linq;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// <see cref="ChatTranscriptRenderer.WrapText"/> tests — moved here from
/// <c>ChatWindowControllerTests</c> (CH6a/b REJECT-review NIT 5: WrapText
/// itself moved off <c>ChatWindowController</c> onto
/// <see cref="ChatTranscriptRenderer"/> to close the circular dependency
/// where <see cref="ChatTranscriptRenderer.BuildLines"/> called back into
/// one of its own two consumers).
///
/// <para>
/// Campaign CH user-gate round 1, item F: <c>/help</c> (and "probably many
/// places") never split on embedded <c>'\n'</c> — the whole multi-line blob
/// rode the single early-out as ONE line. Split on <c>'\n'</c> first, then
/// word-wrap each segment; a single-segment text keeps the pre-existing
/// early-out behavior exactly.
/// </para>
/// </summary>
public class ChatTranscriptRendererTests
{
private static float MeasureByCharCount(string s) => s.Length;
[Fact]
public void WrapText_EmbeddedNewlines_ProduceOneRenderedLinePerSegment()
{
string text = "line one\nline two\nline three";
// maxW is generous — every segment fits without word-wrapping, so
// this isolates the newline-split behavior specifically.
var lines = new List<string>(ChatTranscriptRenderer.WrapText(text, 1000f, MeasureByCharCount));
Assert.Equal(new[] { "line one", "line two", "line three" }, lines);
}
[Fact]
public void WrapText_CarriageReturnNewline_NormalizesTheSameAsBareNewline()
{
string text = "line one\r\nline two";
var lines = new List<string>(ChatTranscriptRenderer.WrapText(text, 1000f, MeasureByCharCount));
Assert.Equal(new[] { "line one", "line two" }, lines);
}
[Fact]
public void WrapText_SegmentLongerThanMaxWidth_StillWordWraps()
{
// Each segment is independently word-wrapped by the SAME algorithm
// the single-line path always used — a multi-line server message
// whose second line overflows the window still wraps that line.
string text = "short\nthis segment is much too long to fit on one line";
var lines = new List<string>(ChatTranscriptRenderer.WrapText(text, 10f, MeasureByCharCount));
Assert.Equal("short", lines[0]);
Assert.True(lines.Count > 2, "the long second segment should have wrapped into multiple lines");
Assert.All(lines, line => Assert.True(MeasureByCharCount(line) <= 10f));
Assert.Equal(
"this segment is much too long to fit on one line",
string.Join(" ", lines.Skip(1)));
}
[Fact]
public void WrapText_SingleSegmentText_KeepsTheEarlyOutBehavior()
{
// No '\n' at all — the pre-existing single-line early-out path
// (whole text fits => returned verbatim as one fragment) is
// unchanged.
string text = "no newlines here";
var lines = new List<string>(ChatTranscriptRenderer.WrapText(text, 1000f, MeasureByCharCount));
Assert.Equal(new[] { text }, lines);
}
[Fact]
public void WrapText_ConsecutiveNewlines_ProduceABlankLine()
{
string text = "first\n\nthird";
var lines = new List<string>(ChatTranscriptRenderer.WrapText(text, 1000f, MeasureByCharCount));
Assert.Equal(new[] { "first", "", "third" }, lines);
}
}

View file

@ -152,7 +152,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl); Assert.NotNull(ctrl);
} }
@ -165,7 +165,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl); Assert.NotNull(ctrl);
var panel = layout.FindElement(0x10000010u); var panel = layout.FindElement(0x10000010u);
@ -182,7 +182,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl); Assert.NotNull(ctrl);
Assert.False(ctrl!.Transcript.Centered); Assert.False(ctrl!.Transcript.Centered);
@ -195,7 +195,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl); Assert.NotNull(ctrl);
var bar = layout.FindElement(0x10000013u); var bar = layout.FindElement(0x10000013u);
@ -209,7 +209,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind( var ctrl = ChatWindowController.Bind(
rootInfo, layout, vm, () => bus, null, null, NoTex)!; rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex)!;
vm.ShowSystemMessage("one wrapped transcript line"); vm.ShowSystemMessage("one wrapped transcript line");
IReadOnlyList<UiText.Line> first = ctrl.Transcript.LinesProvider(); IReadOnlyList<UiText.Line> first = ctrl.Transcript.LinesProvider();
@ -247,8 +247,14 @@ public class ChatWindowControllerTests
var log = new ChatLog(); var log = new ChatLog();
var (rootInfo, layout, vm) = BuildTestTree(log); var (rootInfo, layout, vm) = BuildTestTree(log);
var bus = new CaptureBus(); var bus = new CaptureBus();
// 0x22 is above the default main filter's low dword (retail's
// 0xFBFFFFFF only covers types 0-31) — accept everything so this
// test isolates the color-carry-forward behavior it's actually
// about from the (correctly) separate window-filter concern.
var filters = new ChatWindowState();
filters.SetFilter(ChatWindowState.MainWindowId, ulong.MaxValue);
var ctrl = ChatWindowController.Bind( var ctrl = ChatWindowController.Bind(
rootInfo, layout, vm, () => bus, null, null, NoTex)!; rootInfo, layout, vm, () => bus, filters, null, null, NoTex)!;
log.OnSystemMessage("first", chatType: 0x05u); // System, colorBrightPurple log.OnSystemMessage("first", chatType: 0x05u); // System, colorBrightPurple
log.OnSystemMessage("middle", chatType: 0x22u); // out of range — carries 0x05's color log.OnSystemMessage("middle", chatType: 0x22u); // out of range — carries 0x05's color
@ -269,7 +275,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl); Assert.NotNull(ctrl);
ctrl!.Input.OnSubmit!.Invoke("hello world"); ctrl!.Input.OnSubmit!.Invoke("hello world");
@ -286,7 +292,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl); Assert.NotNull(ctrl);
ctrl!.Input.OnSubmit!.Invoke("/ls"); ctrl!.Input.OnSubmit!.Invoke("/ls");
@ -303,7 +309,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl); Assert.NotNull(ctrl);
// Switch channel to General via the generic OnSelect (payload is ChatChannelKind). // Switch channel to General via the generic OnSelect (payload is ChatChannelKind).
@ -328,7 +334,7 @@ public class ChatWindowControllerTests
var vm = new ChatVM(new ChatLog()); var vm = new ChatVM(new ChatLog());
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(root, layout, vm, () => bus, null, null, NoTex); var ctrl = ChatWindowController.Bind(root, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.Null(ctrl); Assert.Null(ctrl);
} }
@ -351,7 +357,7 @@ public class ChatWindowControllerTests
// AnchorEdges fallback branch of the fix. // AnchorEdges fallback branch of the fix.
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl); Assert.NotNull(ctrl);
Assert.Null(ctrl!.Input.LayoutPolicy); Assert.Null(ctrl!.Input.LayoutPolicy);
Assert.Equal(AnchorEdges.Left | AnchorEdges.Right, ctrl.Input.Anchors & (AnchorEdges.Left | AnchorEdges.Right)); Assert.Equal(AnchorEdges.Left | AnchorEdges.Right, ctrl.Input.Anchors & (AnchorEdges.Left | AnchorEdges.Right));
@ -377,7 +383,7 @@ public class ChatWindowControllerTests
{ {
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl); Assert.NotNull(ctrl);
const float authoredParentWidth = 490f; const float authoredParentWidth = 490f;
@ -415,7 +421,7 @@ public class ChatWindowControllerTests
// inputs (BuildTestTree already built one without them). // inputs (BuildTestTree already built one without them).
layout = LayoutImporter.Build(rootInfo, NoTex, null); layout = LayoutImporter.Build(rootInfo, NoTex, null);
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl); Assert.NotNull(ctrl);
Assert.NotNull(ctrl!.Input.LayoutPolicy); Assert.NotNull(ctrl!.Input.LayoutPolicy);
@ -439,76 +445,8 @@ public class ChatWindowControllerTests
return null; return null;
} }
// ── WrapText: Campaign CH user-gate round 1, item F ────────────────────── // WrapText moved to ChatTranscriptRendererTests.cs (CH6a/b REJECT-review
// /help (and "probably many places") never split on embedded '\n' — the // NIT 5 — WrapText itself moved from this class to ChatTranscriptRenderer).
// whole multi-line blob rode the single early-out as ONE line. Split on
// '\n' first, then word-wrap each segment; a single-segment text keeps
// the pre-existing early-out behavior exactly.
private static float MeasureByCharCount(string s) => s.Length;
[Fact]
public void WrapText_EmbeddedNewlines_ProduceOneRenderedLinePerSegment()
{
string text = "line one\nline two\nline three";
// maxW is generous — every segment fits without word-wrapping, so
// this isolates the newline-split behavior specifically.
var lines = new List<string>(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount));
Assert.Equal(new[] { "line one", "line two", "line three" }, lines);
}
[Fact]
public void WrapText_CarriageReturnNewline_NormalizesTheSameAsBareNewline()
{
string text = "line one\r\nline two";
var lines = new List<string>(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount));
Assert.Equal(new[] { "line one", "line two" }, lines);
}
[Fact]
public void WrapText_SegmentLongerThanMaxWidth_StillWordWraps()
{
// Each segment is independently word-wrapped by the SAME algorithm
// the single-line path always used — a multi-line server message
// whose second line overflows the window still wraps that line.
string text = "short\nthis segment is much too long to fit on one line";
var lines = new List<string>(ChatWindowController.WrapText(text, 10f, MeasureByCharCount));
Assert.Equal("short", lines[0]);
Assert.True(lines.Count > 2, "the long second segment should have wrapped into multiple lines");
Assert.All(lines, line => Assert.True(MeasureByCharCount(line) <= 10f));
Assert.Equal(
"this segment is much too long to fit on one line",
string.Join(" ", lines.Skip(1)));
}
[Fact]
public void WrapText_SingleSegmentText_KeepsTheEarlyOutBehavior()
{
// No '\n' at all — the pre-existing single-line early-out path
// (whole text fits => returned verbatim as one fragment) is
// unchanged.
string text = "no newlines here";
var lines = new List<string>(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount));
Assert.Equal(new[] { text }, lines);
}
[Fact]
public void WrapText_ConsecutiveNewlines_ProduceABlankLine()
{
string text = "first\n\nthird";
var lines = new List<string>(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount));
Assert.Equal(new[] { "first", "", "third" }, lines);
}
// ── SetIndicatorOpen: Campaign CH slice CH6b — the button mirror ───────── // ── SetIndicatorOpen: Campaign CH slice CH6b — the button mirror ─────────
// gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80: State 6 // gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80: State 6
@ -524,7 +462,7 @@ public class ChatWindowControllerTests
{ {
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex)!; var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex)!;
var indicator = Assert.IsType<UiButton>(layout.FindElement(indicatorId)); var indicator = Assert.IsType<UiButton>(layout.FindElement(indicatorId));
ctrl.SetIndicatorOpen(windowId, open: true); ctrl.SetIndicatorOpen(windowId, open: true);
@ -537,7 +475,7 @@ public class ChatWindowControllerTests
{ {
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex)!; var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex)!;
var indicator = Assert.IsType<UiButton>(layout.FindElement(0x10000522u)); var indicator = Assert.IsType<UiButton>(layout.FindElement(0x10000522u));
ctrl.SetIndicatorOpen(1, open: true); ctrl.SetIndicatorOpen(1, open: true);
@ -551,7 +489,7 @@ public class ChatWindowControllerTests
{ {
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex)!; var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex)!;
var indicator2 = Assert.IsType<UiButton>(layout.FindElement(0x10000523u)); var indicator2 = Assert.IsType<UiButton>(layout.FindElement(0x10000523u));
ctrl.SetIndicatorOpen(1, open: true); ctrl.SetIndicatorOpen(1, open: true);
@ -566,7 +504,7 @@ public class ChatWindowControllerTests
{ {
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex)!; var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex)!;
Assert.Throws<ArgumentOutOfRangeException>(() => ctrl.SetIndicatorOpen(windowId, open: true)); Assert.Throws<ArgumentOutOfRangeException>(() => ctrl.SetIndicatorOpen(windowId, open: true));
} }

View file

@ -60,6 +60,25 @@ public static class FixtureLoader
public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos() public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos()
=> LoadInfos("chat_2100006f.json"); => LoadInfos("chat_2100006f.json");
/// <summary>
/// Deserializes the committed <c>chat_floaty_2100005b.json</c> fixture
/// (retail's floating chat windows 1-4, LayoutDesc <c>0x2100005B</c> — CH6a/b
/// REJECT-review SHOULD-FIX 4) into a raw <see cref="ElementInfo"/> tree and
/// builds the <see cref="ImportedLayout"/> using a null-returning sprite
/// resolver and no dat font — sufficient for conformance checks on resolved
/// widget types.
/// </summary>
public static ImportedLayout LoadFloatyChat()
=> LayoutImporter.Build(LoadFloatyChatInfos(), _ => (0u, 0, 0), null);
/// <summary>
/// Deserializes the committed <c>chat_floaty_2100005b.json</c> fixture into
/// a raw <see cref="ElementInfo"/> tree WITHOUT calling
/// <see cref="LayoutImporter.Build"/>.
/// </summary>
public static AcDream.App.UI.Layout.ElementInfo LoadFloatyChatInfos()
=> LoadInfos("chat_floaty_2100005b.json");
/// <summary>Builds the committed retail radar LayoutDesc 0x21000074 fixture.</summary> /// <summary>Builds the committed retail radar LayoutDesc 0x21000074 fixture.</summary>
public static ImportedLayout LoadRadar() public static ImportedLayout LoadRadar()
=> LayoutImporter.Build(LoadRadarInfos(), _ => (0u, 0, 0), null); => LayoutImporter.Build(LoadRadarInfos(), _ => (0u, 0, 0), null);

View file

@ -0,0 +1,120 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Dat-free conformance tests for the committed <c>chat_floaty_2100005b.json</c>
/// golden fixture — retail's ACTUAL floating chat window (LayoutDesc
/// <c>0x2100005B</c>, window root <c>0x100004F7</c>, authored 250x108; CH6a/b
/// REJECT-review SHOULD-FIX 4,
/// <c>docs/research/2026-08-10-ch6ab-review-findings.md</c>). Pins the resolved
/// widget types <see cref="FloatingChatWindowController"/> assumes at
/// <c>Bind</c> time — before this fixture existed those assumptions were
/// untested against real dat data.
/// </summary>
public class FloatingChatLayoutConformanceTests
{
private static ElementInfo? Find(ElementInfo n, uint id)
{
if (n.Id == id) return n;
foreach (var c in n.Children)
{
var f = Find(c, id);
if (f is not null) return f;
}
return null;
}
[Fact]
public void FloatyFixture_ResolvesKnownElements()
{
var root = FixtureLoader.LoadFloatyChatInfos();
Assert.NotNull(Find(root, 0x10000011u)); // transcript
Assert.NotNull(Find(root, 0x10000016u)); // input
Assert.NotNull(Find(root, 0x10000012u)); // scrollbar track
Assert.NotNull(Find(root, 0x10000019u)); // send button
Assert.NotNull(Find(root, 0x100004D9u)); // title bar
Assert.NotNull(Find(root, 0x1000052Au)); // close button
Assert.NotNull(Find(root, 0x10000529u)); // title-bar drag handle
// No talk-focus menu, no max/min button, no 1-4 indicators — a floaty
// window has none of these (research doc §2.2).
Assert.Null(Find(root, 0x10000014u));
Assert.Null(Find(root, 0x1000046Fu));
Assert.Null(Find(root, 0x10000522u));
}
[Fact]
public void FloatyFixture_ResolvedTypes_MatchRetailRegistry()
{
// CH6a/b REJECT-review SHOULD-FIX 4's three flagged untested
// assumptions: the chat entry field, the title bar, and the close
// button. All three are confirmed correct against the real fixture.
var root = FixtureLoader.LoadFloatyChatInfos();
Assert.Equal(12u, Find(root, 0x10000016u)!.Type); // Text/style-prototype (input) + Editable 0x16
Assert.True(Find(root, 0x10000016u)!.TryGetEffectiveBool(0x16u, out bool editable) && editable);
Assert.Equal(12u, Find(root, 0x100004D9u)!.Type); // Text (title bar) — no Editable
Assert.False(Find(root, 0x100004D9u)!.TryGetEffectiveBool(0x16u, out bool titleEditable) && titleEditable);
Assert.Equal(1u, Find(root, 0x10000019u)!.Type); // Button (Send)
Assert.Equal(1u, Find(root, 0x1000052Au)!.Type); // Button (Close)
Assert.Equal(11u, Find(root, 0x10000012u)!.Type); // Scrollbar
Assert.Equal(2u, Find(root, 0x10000529u)!.Type); // Dragbar (title-bar move handle)
}
[Fact]
public void MountedFloatyWindow_ResolvesToTheExpectedWidgetTypes()
{
// The resolved WIDGET types (post-DatWidgetFactory), not just the raw
// dat Type numbers above — this is what FloatingChatWindowController.Bind
// actually casts against.
var layout = FixtureLoader.LoadFloatyChat();
Assert.IsType<UiField>(layout.FindElement(0x10000016u)); // Editable 0x16 -> UiField
Assert.IsType<UiText>(layout.FindElement(0x100004D9u)); // no Editable -> UiText
Assert.IsType<UiButton>(layout.FindElement(0x10000019u)); // Send
Assert.IsType<UiButton>(layout.FindElement(0x1000052Au)); // Close
Assert.IsType<UiScrollbar>(layout.FindElement(0x10000012u));
}
// ── Border/corner grips — CH6a/b SHOULD-FIX 4's "also determines whether
// floaties resize from real grips" ────────────────────────────────────────
[Theory]
[InlineData(0x100004FCu)] // TL corner
[InlineData(0x1000000Fu)] // top edge
[InlineData(0x100004FEu)] // TR corner
[InlineData(0x100004D2u)] // left edge
[InlineData(0x10000501u)] // BL corner
[InlineData(0x100004D4u)] // bottom edge
[InlineData(0x10000503u)] // BR corner
[InlineData(0x100004D3u)] // right edge
public void FloatyFixture_EveryBorderElement_IsALiveResizeGrip(uint elementId)
{
// Unlike the main window (whose plain top EDGE strip is a Type-2
// Dragbar move handle, not a grip — the main window has no title bar
// so the top strip doubles as the move handle), the floaty window's
// move handle is its OWN title-bar group (0x10000529, Type 2) — so
// ALL EIGHT border/corner elements here are genuine Type-9 Resizebar
// grips. A floaty window resizes from every edge and every corner.
var root = FixtureLoader.LoadFloatyChatInfos();
Assert.Equal(9u, Find(root, elementId)!.Type);
}
[Theory]
[InlineData(0x100004FCu)]
[InlineData(0x1000000Fu)]
[InlineData(0x100004FEu)]
[InlineData(0x100004D2u)]
[InlineData(0x10000501u)]
[InlineData(0x100004D4u)]
[InlineData(0x10000503u)]
[InlineData(0x100004D3u)]
public void MountedFloatyWindow_EveryLiveGrip_ResolvesNonZeroSprite(uint elementId)
{
// BLOCKER 1's regression guard extended to the floaty layout: every
// one of the eight live grips must carry real authored border/corner
// media, not draw nothing.
var layout = FixtureLoader.LoadFloatyChat();
var grip = Assert.IsType<UiResizeGrip>(layout.FindElement(elementId));
Assert.NotEqual(0u, grip.SpriteFile);
}
}

View file

@ -17,6 +17,7 @@ public sealed class RetailLayoutFixtureGenerator
private static readonly (uint Id, string FileName)[] Layouts = private static readonly (uint Id, string FileName)[] Layouts =
{ {
(ChatWindowController.LayoutId, "chat_2100006f.json"), (ChatWindowController.LayoutId, "chat_2100006f.json"),
(FloatingChatWindowController.LayoutId, "chat_floaty_2100005b.json"),
(0x21000016u, "toolbar_21000016.json"), (0x21000016u, "toolbar_21000016.json"),
(0x2100001Du, "link_status_2100001D.json"), (0x2100001Du, "link_status_2100001D.json"),
(0x21000020u, "vitae_21000020.json"), (0x21000020u, "vitae_21000020.json"),

File diff suppressed because it is too large Load diff

View file

@ -90,6 +90,34 @@ public class UiButtonTests
Assert.Equal("Highlight", b.ActiveState); Assert.Equal("Highlight", b.ActiveState);
} }
[Fact]
public void SuppressSelfToggle_PressReleaseDoesNotFlipSelected()
{
// CH6a/b REJECT-review SHOULD-FIX 3: the chat-window 1-4 indicators
// (0x10000522-0x10000525) carry DAT property 0x0B (ToggleBehavior) =
// true — same shape as ToggleRelease_SelectsHighlightState above —
// but retail's own click dispatch has no case for their element ids
// (gmMainChatUI::ListenToElementMessage @0x004CDA80), so a click must
// NOT flip their Selected mirror. Only SetIndicatorOpen (an external
// writer) may change it.
var info = ButtonInfo("Normal", "Highlight");
AddBoolProperty(info, 0x0Bu, true);
var b = CreateButton(info);
b.SuppressSelfToggle = true;
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
Assert.False(b.Selected);
Assert.Equal("Normal", b.ActiveState);
// The external mirror path still works — SuppressSelfToggle only
// blocks the self-click, not a producer's own write.
b.Selected = true;
Assert.True(b.Selected);
Assert.Equal("Highlight", b.ActiveState);
}
[Fact] [Fact]
public void DisabledProperty_SelectsGhostedAndSuppressesClick() public void DisabledProperty_SelectsGhostedAndSuppressesClick()
{ {

View file

@ -116,7 +116,8 @@ public sealed class ChatWindowStateTests
{ {
var state = new ChatWindowState(); var state = new ChatWindowState();
// Addressed to window 2, evaluated from window 1's perspective: not a // Addressed to window 2, evaluated from window 1's perspective: not a
// broadcast (targetWindowId != 0) and not addressed to window 1. // broadcast (targetWindowId is a real window id, not
// BroadcastTargetWindow) and not addressed to window 1.
Assert.False(state.ShouldDisplay(windowId: 1, targetWindowId: 2u, logTextType: 0x02u)); Assert.False(state.ShouldDisplay(windowId: 1, targetWindowId: 2u, logTextType: 0x02u));
} }
@ -124,38 +125,65 @@ public sealed class ChatWindowStateTests
public void ShouldDisplay_Broadcast_FilterHit_Shows() public void ShouldDisplay_Broadcast_FilterHit_Shows()
{ {
var state = new ChatWindowState(); var state = new ChatWindowState();
Assert.True(state.ShouldDisplay(windowId: 1, targetWindowId: 0u, logTextType: 0x02u)); // Speech Assert.True(state.ShouldDisplay(
windowId: 1, targetWindowId: ChatWindowState.BroadcastTargetWindow, logTextType: 0x02u)); // Speech
} }
[Fact] [Fact]
public void ShouldDisplay_Broadcast_FilterMiss_DoesNotShow() public void ShouldDisplay_Broadcast_FilterMiss_DoesNotShow()
{ {
var state = new ChatWindowState(); var state = new ChatWindowState();
Assert.False(state.ShouldDisplay(windowId: 1, targetWindowId: 0u, logTextType: 0x0Au)); // Social Assert.False(state.ShouldDisplay(
windowId: 1, targetWindowId: ChatWindowState.BroadcastTargetWindow, logTextType: 0x0Au)); // Social
} }
[Fact] [Fact]
public void ShouldDisplay_MainWindow_ShowsEveryBroadcastRegardlessOfSeededFilter() public void ShouldDisplay_MainWindow_BroadcastRespectsItsOwnFilter()
{ {
// CH6a/b REJECT-review BLOCKER (SHOULD-FIX 2): before the fix, window
// 0's explicit-addressing branch (targetWindowId == windowId) used the
// SAME literal 0 as the broadcast sentinel, so it short-circuited true
// for EVERY broadcast line regardless of window 0's own filter. Now
// that BroadcastTargetWindow is a distinct sentinel, the main
// window's filter (0xFBFFFFFF — excludes 0x1A, Society opt-in) is
// genuinely consulted for a broadcast line, exactly like every other
// window.
var state = new ChatWindowState();
Assert.False(state.ShouldDisplay(
windowId: 0, targetWindowId: ChatWindowState.BroadcastTargetWindow, logTextType: 0x1Au)); // excluded
Assert.False(state.ShouldDisplay(
windowId: 0, targetWindowId: ChatWindowState.BroadcastTargetWindow, logTextType: 0x20u)); // Society, opt-in
Assert.True(state.ShouldDisplay(
windowId: 0, targetWindowId: ChatWindowState.BroadcastTargetWindow, logTextType: 0x02u)); // Speech
}
[Fact]
public void ShouldDisplay_MainWindow_ExplicitlyAddressed_AlwaysShows()
{
// Window id 0 (main) is still a valid EXPLICIT address, distinct from
// BroadcastTargetWindow — this is what keeps a future
// targetWindowId == MainWindowId (AP-180's per-window echo)
// expressible: explicit addressing to main always wins, even for a
// type main's own filter would otherwise reject.
var state = new ChatWindowState(); 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: 0x1Au));
Assert.True(state.ShouldDisplay(windowId: 0, targetWindowId: 0u, logTextType: 0x21u));
} }
// ── SetFilter / SetOpen / Toggle ───────────────────────────────────────── // ── SetFilter / SetOpen / Toggle ─────────────────────────────────────────
[Fact] [Fact]
public void SetFilter_MainWindow_IsANoOp() public void SetFilter_MainWindow_Persists()
{ {
// CH6a/b REJECT-review SHOULD-FIX 2: dropped the old no-op — the main
// window's filter is now genuinely settable, matching retail's own
// options-page-driven main-window filter.
var state = new ChatWindowState(); var state = new ChatWindowState();
ulong before = state.GetFilter(0);
state.SetFilter(0, 0u); state.SetFilter(0, 0x1u);
Assert.Equal(before, state.GetFilter(0)); Assert.Equal(0x1u, state.GetFilter(0));
Assert.True(state.TypeIsActive(0, 0x00u));
Assert.False(state.TypeIsActive(0, 0x02u));
} }
[Fact] [Fact]