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
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue