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) |
| 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) |
| 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 |
| 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 |
### 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
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.
## 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
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.
UNVERIFIED: the exact modifier bit → key mapping (4 = Alt vs 4 = Ctrl). Cheapest
resolution: read `keymap-default.txt`'s modifier legend, or one cdb breakpoint on
`UIElementManager::KeyPressEvent`. The *mechanism* below is independent of this.
RESOLVED at Campaign CH slice CH6b: `retail-default.keymap.txt`'s own
`MetaKeys` legend (not a guess — the keymap file's literal index table) reads
```
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:
@ -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
whole pseudo-C returns only `gmMainChatUI::RecvNotice_SetPanelVisibility`.
**RESOLVED 2026-08-10 at Campaign CH slice CH6b.** The prior UNVERIFIED
paragraph's hedge ("safe to wire both") is superseded by a direct read of
`gmMainChatUI::ListenToElementMessage @0x004CDA80` — the ONLY function in the
whole 2013 binary that branches on `idMessage == 1` ("clicked"). It handles
**RESOLVED 2026-08-10 at Campaign CH slice CH6b; wording corrected at the
CH6a/b REJECT-review (NIT 4) — the original "ONLY function in the whole 2013
binary that branches on `idMessage == 1`" superlative was false**
(`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
`HandleMaximizeButton`) and the talk-focus menu's selection message
(`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
/// already had before this extraction.
/// </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>
internal static class ChatTranscriptRenderer
{
@ -28,12 +35,15 @@ internal static class ChatTranscriptRenderer
/// <param name="accept">
/// Optional per-line filter — retail's <c>ChatInterface::TypeIsActive</c>
/// (or the full <c>ShouldDisplay</c> predicate) for THIS window. Null
/// accepts every line (the main window has no user filter — color-table
/// research doc §4). A line that fails the filter is dropped from this
/// window's view WITHOUT advancing the carried-forward color, matching
/// retail's <c>m_curFontColor</c> only advancing for lines actually
/// appended to THIS window's own scroll (<c>AppendStringInfoWithFont</c>
/// only runs for displayed lines).
/// accepts every line. CH6a/b REJECT-review SHOULD-FIX 2: the main
/// window now ALWAYS passes a real predicate too (its own retail default
/// 0xFBFFFFFF filter via <c>ChatWindowState</c>) — null remains supported
/// for callers with no filter concept at all (there are none in
/// production today, but the shape stays general). A line that fails the
/// filter is dropped from this window's view WITHOUT advancing the
/// carried-forward color, matching retail's <c>m_curFontColor</c> only
/// advancing for lines actually appended to THIS window's own scroll
/// (<c>AppendStringInfoWithFont</c> only runs for displayed lines).
/// </param>
public static List<UiText.Line> BuildLines(
IReadOnlyList<FormattedLine> detailed,
@ -57,9 +67,89 @@ internal static class ChatTranscriptRenderer
continue;
if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 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));
}
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;
// 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
// formatted + wrapped transcript until either its source revision or the
// metrics that determine wrapping change. This makes an idle chat window
// allocation-free instead of snapshotting/formatting/wrapping every frame.
private IReadOnlyList<UiText.Line> _cachedTranscriptLines = Array.Empty<UiText.Line>();
private long _cachedTranscriptRevision = -1;
private ulong _cachedFilter;
private float _cachedTranscriptWrapWidth = float.NaN;
private UiDatFont? _cachedTranscriptDatFont;
private BitmapFont? _cachedTranscriptDebugFont;
@ -202,6 +208,11 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
/// 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
/// (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="debugFont">Fallback debug bitmap font (used when
/// <paramref name="datFont"/> is null).</param>
@ -212,10 +223,13 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
ImportedLayout layout,
ChatVM vm,
Func<ICommandBus> busProvider,
ChatWindowState windowFilters,
UiDatFont? datFont,
BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> resolve)
{
ArgumentNullException.ThrowIfNull(windowFilters);
// Their parent panels must exist as real widgets in the layout tree.
var transcriptPanel = layout.FindElement(TranscriptPanelId);
var inputBar = layout.FindElement(InputBarId);
@ -240,6 +254,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
{
Root = window,
DatWindowInfo = FindInfo(rootInfo, RootId) ?? rootInfo,
_windowFilters = windowFilters,
};
// 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
// RetailUiRuntime via SetIndicatorOpen as each floating window's own
// 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 };
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 ───────────────────────────────────────────────────
// The factory now builds the Type-12 transcript element (0x10000011) as a UiText.
@ -483,11 +510,14 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
///
/// <para>
/// One-directional by design — a decomp read of
/// <c>gmMainChatUI::ListenToElementMessage @0x004CDA80</c> (the ONLY
/// function in the whole 2013 binary that branches on
/// <c>idMessage == 1</c>, i.e. "clicked") shows it handles exactly two
/// element ids: <c>0x1000046f</c> (max/min) and the talk-focus menu's
/// selection message. There is no case for
/// <c>gmMainChatUI::ListenToElementMessage @0x004CDA80</c> (one of
/// several functions in the 2013 binary that branch on
/// <c>idMessage == 1</c>, i.e. "clicked" — CH6a/b REJECT-review NIT 4
/// corrected the earlier "the ONLY function" superlative, e.g.
/// <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
/// indicator button does NOTHING in retail. acdream ports this exactly:
/// 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;
BitmapFont? debugFont = Transcript.Font;
long revision = vm.Revision;
ulong filter = _windowFilters.GetFilter(ChatWindowState.MainWindowId);
if (_cachedTranscriptRevision == revision
&& _cachedFilter == filter
&& _cachedTranscriptWrapWidth.Equals(maxW)
&& ReferenceEquals(_cachedTranscriptDatFont, datFont)
&& ReferenceEquals(_cachedTranscriptDebugFont, debugFont))
@ -556,7 +588,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
if (detailed.Count == 0)
{
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
@ -570,21 +602,27 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
// Campaign CH slice CH6b: the wrap + retail color-carry-forward
// algorithm is now shared with FloatingChatWindowController via
// ChatTranscriptRenderer (approximation note on the color carry
// moved there). The main window passes accept:null — it has no
// user filter (color-table research doc §4); this is behaviorally
// identical to the inline loop this replaced.
var result = ChatTranscriptRenderer.BuildLines(detailed, maxW, measure, accept: null);
return StoreTranscriptLayout(result, revision, maxW, datFont, debugFont);
// moved there). CH6a/b REJECT-review SHOULD-FIX 2: the main window
// now has a REAL accept predicate (retail's default 0xFBFFFFFF —
// everything except 0x1A, high dword zeroed so Society is opt-in),
// driven by the SAME ChatWindowState the floating windows read —
// 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(
IReadOnlyList<UiText.Line> lines,
long revision,
ulong filter,
float wrapWidth,
UiDatFont? datFont,
BitmapFont? debugFont)
{
_cachedTranscriptRevision = revision;
_cachedFilter = filter;
_cachedTranscriptWrapWidth = wrapWidth;
_cachedTranscriptDatFont = datFont;
_cachedTranscriptDebugFont = debugFont;
@ -593,86 +631,6 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
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()
{
if (_disposed) return;

View file

@ -95,7 +95,7 @@ public static class DatWidgetFactory
// gmUIElement_*Indicator custom button classes
6 => new UiMenu(), // UIElement_Menu (reg :120163)
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
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
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
/// BORDER_NONE fallback does) decodes to <see cref="UiResizeGrip.Border.None"/>
/// — <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>
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 left = info.TryGetEffectiveBool(0x2Bu, out bool leftValue) && leftValue;
bool right = info.TryGetEffectiveBool(0x2Cu, out bool rightValue) && rightValue;
bool top = info.TryGetEffectiveBool(0x2Du, out bool topValue) && topValue;
return new UiResizeGrip
return new UiResizeGrip(info, resolve)
{
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)
: 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);
_cachedTranscriptRevision = revision;

View file

@ -32,7 +32,7 @@ internal static class IndicatorDetailText
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));
}
return lines;

View file

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

View file

@ -575,6 +575,17 @@ public sealed class RetailUiRuntime : IDisposable
/// Capture the four floating windows' current text-type filters into
/// the local settings store (research doc §4.4/§6.1 — local-only until
/// CH6f's wire format). No-op when no store was wired.
///
/// <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>
private void SaveChatWindowFilters()
{
@ -807,6 +818,7 @@ public sealed class RetailUiRuntime : IDisposable
layout,
_bindings.Chat.ViewModel,
_bindings.Chat.CommandBus,
_bindings.Chat.Windows,
_bindings.Assets.DefaultFont,
_bindings.Assets.DebugFont,
_bindings.Assets.ResolveSprite);

View file

@ -111,6 +111,23 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
public float HotClickInitialDelay { 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
{
get => _selected;
@ -435,7 +452,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
_suppressNextClick = _hotClicking && _pointerOver;
_hotClicking = false;
_nextHotClickTime = double.NaN;
if (_pressed && _pointerOver && Enabled && ToggleBehavior)
if (_pressed && _pointerOver && Enabled && ToggleBehavior && !SuppressSelfToggle)
_selected = !_selected;
if (_pressed && Enabled)
OnReleased?.Invoke();

View file

@ -1,3 +1,7 @@
using System;
using System.Numerics;
using AcDream.App.UI.Layout;
namespace AcDream.App.UI;
/// <summary>
@ -23,10 +27,19 @@ namespace AcDream.App.UI;
/// </para>
///
/// <para>
/// A grip is a small (5px) leaf with no children and no authored background
/// media beyond its own cursor-feedback sprite; it exists purely as a precise
/// hit region. <see cref="AcDream.App.UI.UiRoot"/> gives a directly-hit grip's
/// own <see cref="Edges"/> priority over the generic proximity-based
/// A grip is a small (5px) leaf with no children; it exists primarily as a
/// precise hit region, but it IS authored with real border/corner media (the
/// <c>0x06006129</c>-family sprites — CH6a/b REJECT-review BLOCKER 1,
/// <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
/// 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.
@ -34,6 +47,57 @@ namespace AcDream.App.UI;
/// </summary>
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>):
/// <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>

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
/// floating chat windows (Campaign CH slice CH6b,
/// <c>docs/research/2026-08-09-chat-retail-window-shell.md</c> §1.2 and
/// <c>docs/research/2026-08-09-chat-retail-color-table.md</c> §4).
/// <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>
/// Ports two retail mechanisms exactly:
@ -16,38 +22,45 @@ namespace AcDream.Core.Chat;
/// <item><c>ChatInterface::PostInit @0x004F3DD0</c>'s <c>m_oldState</c>
/// switch seeds each window's default 64-bit
/// <c>m_llTextTypeFilter</c> (color-table doc §4's table — the constants
/// below are byte-identical to that table).</item>
/// 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
/// @0x004F4640</c>'s display predicate: a line shows in window
/// <c>W</c> when the message's target window id equals <c>W</c>
/// (explicit addressing) OR the message is broadcast (target id
/// <c>0</c>) AND <c>W</c>'s filter accepts the line's
/// <see cref="RetailLogTextType"/> (<c>ChatInterface::TypeIsActive
/// (explicit addressing) OR the message is broadcast
/// (<see cref="BroadcastTargetWindow"/>) AND <c>W</c>'s filter accepts the
/// line's <see cref="RetailLogTextType"/> (<c>ChatInterface::TypeIsActive
/// @0x004F2F10</c>).</item>
/// </list>
/// </para>
///
/// <para>
/// <c>UpdateFromPlayerModule @0x004F3920</c> early-returns for window id
/// <c>0</c> — the main window never has a user-settable filter. That
/// invariant needs no special case here: for <c>windowId == 0</c>,
/// <see cref="ShouldDisplay"/>'s first branch (<c>targetWindowId ==
/// windowId</c>) is already true for every broadcast line (target id
/// <c>0</c>), so the main window's filter is never actually consulted —
/// exactly matching retail's "no user filter" behavior without a guard.
/// <see cref="SetFilter"/> and <see cref="SetOpen"/> are still no-ops for
/// window <c>0</c> (it is always open and its seeded filter is inert), for
/// the same reason retail's setters gate on <c>m_eWindowID != 0</c>
/// (persistence doc §4.2).
/// <see cref="BroadcastTargetWindow"/> is a sentinel distinct from every real
/// window id (<c>0</c>-<c>4</c>) — the acdream-internal analogue of retail's
/// wire <c>arg5 == 0</c> broadcast marker, deliberately kept separate from
/// window id <c>0</c> (main). Earlier CH6b code conflated the two (both were
/// literal <c>0</c>), so <see cref="ShouldDisplay"/>'s explicit-addressing
/// branch (<c>targetWindowId == windowId</c>) short-circuited true for EVERY
/// broadcast line evaluated against the main window, regardless of its own
/// filter — the CH6a/b REJECT-review BLOCKER. With the sentinel separated
/// out, the main window's filter is genuinely consulted for broadcast lines,
/// <see cref="SetFilter"/> is a real (not inert) write for window <c>0</c>
/// 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>
/// No production <see cref="ChatEntry"/> carries an explicit target window
/// id yet (register row AP-180 — the <c>windowId</c> dual-destination echo
/// is deferred); every current line is effectively broadcast
/// (<c>targetWindowId == 0</c>). <see cref="ShouldDisplay"/> still accepts
/// the full retail shape so the routing predicate does not need to change
/// shape when AP-180 lands.
/// (<c>targetWindowId == </c><see cref="BroadcastTargetWindow"/>).
/// <see cref="ShouldDisplay"/> still accepts the full retail shape so the
/// routing predicate does not need to change shape when AP-180 lands.
/// </para>
/// </summary>
public sealed class ChatWindowState
@ -56,6 +69,15 @@ public sealed class ChatWindowState
public const int MinFloatingWindowId = 1;
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 readonly object _gate = new();
@ -83,8 +105,9 @@ public sealed class ChatWindowState
{
lock (_gate)
{
// "everything 0x00-0x1F except 0x1A" (m_oldState 1/8) — inert for
// routing (see class doc) but seeded for fidelity/inspection.
// "everything 0x00-0x1F except 0x1A" (m_oldState 1/8) — genuinely
// consulted for broadcast lines now that BroadcastTargetWindow is
// distinct from MainWindowId (see class doc).
_filters[0] = 0xFBFFFFFFu;
// Speech, Tell, Speech_Direct_Send, Emote (m_oldState 2).
_filters[1] = 0x0000101Cu;
@ -110,13 +133,15 @@ public sealed class ChatWindowState
}
/// <summary>
/// Set window <paramref name="windowId"/>'s 64-bit type filter. No-op for
/// the main window (id <c>0</c>) — see the class doc.
/// Set window <paramref name="windowId"/>'s 64-bit type filter — including
/// 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>
public void SetFilter(int windowId, ulong filter)
{
ValidateWindowId(windowId);
if (windowId == MainWindowId) return;
lock (_gate)
{
if (_filters[windowId] == filter) return;
@ -182,12 +207,15 @@ public sealed class ChatWindowState
/// <summary>
/// <c>ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640</c>'s
/// 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>
public bool ShouldDisplay(int windowId, uint targetWindowId, uint logTextType)
{
ValidateWindowId(windowId);
if (targetWindowId == (uint)windowId) return true;
return targetWindowId == 0u && TypeIsActive(windowId, logTextType);
return targetWindowId == BroadcastTargetWindow && TypeIsActive(windowId, logTextType);
}
private static void ValidateWindowId(int windowId)

View file

@ -216,6 +216,13 @@ public sealed class RuntimeCommunicationState : IDisposable
Squelch.Clear();
Chat.ResetSessionIdentity();
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();
}

View file

@ -77,7 +77,7 @@ public class ChatLayoutConformanceTests
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
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);
UiElement? indicator = layout.FindElement(indicatorId);
@ -91,6 +91,18 @@ public class ChatLayoutConformanceTests
var button = Assert.IsType<UiButton>(indicator);
Assert.Null(button.OnClick);
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]
@ -110,7 +122,7 @@ public class ChatLayoutConformanceTests
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
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);
UiElement? twin = layout.FindElement(lockedTwinId);
@ -191,6 +203,7 @@ public class ChatLayoutConformanceTests
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex);
@ -226,6 +239,7 @@ public class ChatLayoutConformanceTests
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex);
@ -250,6 +264,7 @@ public class ChatLayoutConformanceTests
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex)!;
@ -324,6 +339,27 @@ public class ChatLayoutConformanceTests
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]
public void MountedChatWindow_BottomRightGrip_GrowsBothAxes_NotOnlyShrinks()
{
@ -340,6 +376,7 @@ public class ChatLayoutConformanceTests
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex)!;
@ -396,6 +433,7 @@ public class ChatLayoutConformanceTests
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
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 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);
}
@ -165,7 +165,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree();
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);
var panel = layout.FindElement(0x10000010u);
@ -182,7 +182,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree();
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.False(ctrl!.Transcript.Centered);
@ -195,7 +195,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree();
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);
var bar = layout.FindElement(0x10000013u);
@ -209,7 +209,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
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");
IReadOnlyList<UiText.Line> first = ctrl.Transcript.LinesProvider();
@ -247,8 +247,14 @@ public class ChatWindowControllerTests
var log = new ChatLog();
var (rootInfo, layout, vm) = BuildTestTree(log);
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(
rootInfo, layout, vm, () => bus, null, null, NoTex)!;
rootInfo, layout, vm, () => bus, filters, null, null, NoTex)!;
log.OnSystemMessage("first", chatType: 0x05u); // System, colorBrightPurple
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 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);
ctrl!.Input.OnSubmit!.Invoke("hello world");
@ -286,7 +292,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree();
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);
ctrl!.Input.OnSubmit!.Invoke("/ls");
@ -303,7 +309,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree();
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);
// 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 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);
}
@ -351,7 +357,7 @@ public class ChatWindowControllerTests
// AnchorEdges fallback branch of the fix.
var (rootInfo, layout, vm) = BuildTestTree();
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.Null(ctrl!.Input.LayoutPolicy);
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 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);
const float authoredParentWidth = 490f;
@ -415,7 +421,7 @@ public class ChatWindowControllerTests
// inputs (BuildTestTree already built one without them).
layout = LayoutImporter.Build(rootInfo, NoTex, null);
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!.Input.LayoutPolicy);
@ -439,76 +445,8 @@ public class ChatWindowControllerTests
return null;
}
// ── WrapText: Campaign CH user-gate round 1, item F ──────────────────────
// /help (and "probably many places") never split on embedded '\n' — the
// 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);
}
// WrapText moved to ChatTranscriptRendererTests.cs (CH6a/b REJECT-review
// NIT 5 — WrapText itself moved from this class to ChatTranscriptRenderer).
// ── SetIndicatorOpen: Campaign CH slice CH6b — the button mirror ─────────
// gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80: State 6
@ -524,7 +462,7 @@ public class ChatWindowControllerTests
{
var (rootInfo, layout, vm) = BuildTestTree();
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));
ctrl.SetIndicatorOpen(windowId, open: true);
@ -537,7 +475,7 @@ public class ChatWindowControllerTests
{
var (rootInfo, layout, vm) = BuildTestTree();
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));
ctrl.SetIndicatorOpen(1, open: true);
@ -551,7 +489,7 @@ public class ChatWindowControllerTests
{
var (rootInfo, layout, vm) = BuildTestTree();
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));
ctrl.SetIndicatorOpen(1, open: true);
@ -566,7 +504,7 @@ public class ChatWindowControllerTests
{
var (rootInfo, layout, vm) = BuildTestTree();
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));
}

View file

@ -60,6 +60,25 @@ public static class FixtureLoader
public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos()
=> 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>
public static ImportedLayout LoadRadar()
=> 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 =
{
(ChatWindowController.LayoutId, "chat_2100006f.json"),
(FloatingChatWindowController.LayoutId, "chat_floaty_2100005b.json"),
(0x21000016u, "toolbar_21000016.json"),
(0x2100001Du, "link_status_2100001D.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);
}
[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]
public void DisabledProperty_SelectsGhostedAndSuppressesClick()
{

View file

@ -116,7 +116,8 @@ public sealed class ChatWindowStateTests
{
var state = new ChatWindowState();
// Addressed to window 2, evaluated from window 1's perspective: not a
// broadcast (targetWindowId != 0) and not addressed to window 1.
// 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));
}
@ -124,38 +125,65 @@ public sealed class ChatWindowStateTests
public void ShouldDisplay_Broadcast_FilterHit_Shows()
{
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]
public void ShouldDisplay_Broadcast_FilterMiss_DoesNotShow()
{
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]
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();
// Window 0's own filter is 0xFBFFFFFF (excludes 0x1A) but that filter
// is never actually consulted for a broadcast message: targetWindowId
// (0) == windowId (0) is already true via the first branch.
Assert.True(state.ShouldDisplay(windowId: 0, targetWindowId: 0u, logTextType: 0x1Au));
Assert.True(state.ShouldDisplay(windowId: 0, targetWindowId: 0u, logTextType: 0x21u));
}
// ── SetFilter / SetOpen / Toggle ─────────────────────────────────────────
[Fact]
public void SetFilter_MainWindow_IsANoOp()
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();
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]