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:
parent
56b84deeab
commit
1aa7709988
23 changed files with 6284 additions and 248 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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 1–4 | (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 1–4 | `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).
|
||||
|
|
|
|||
|
|
@ -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 1–4.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ internal static class ItemAppraisalTextLayout
|
|||
continue;
|
||||
}
|
||||
|
||||
foreach (string wrapped in ChatWindowController.WrapText(
|
||||
foreach (string wrapped in ChatTranscriptRenderer.WrapText(
|
||||
logicalLine,
|
||||
maxWidth,
|
||||
Measure))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)!;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
5474
tests/AcDream.App.Tests/UI/Layout/fixtures/chat_floaty_2100005b.json
Normal file
5474
tests/AcDream.App.Tests/UI/Layout/fixtures/chat_floaty_2100005b.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue