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