acdream/src/AcDream.App/UI/SpewBoxController.cs
Erik e0e7888308 fix(chat): CH2 rework — SpewBox tick-driven visibility + binary-derived error table
Reworks Campaign CH slice CH2 per the REJECT-review findings doc
(docs/research/2026-08-09-ch2-review-findings.md).

BLOCKER 1 — SpewBoxController never rendered a line and leaked its
pending queue. LinesProvider only ran through UiText.OnDraw, which
gates on Visible — and the box started invisible, so the provider (the
sole caller of SpewBoxState.Tick) never ran. Gave the controller an
explicit per-frame Tick(now) driven by UiRoot's global-message-3
broadcast (a zero-size GlobalTimeSink child, the same pattern
VendorUiController.DragOverGlobalTimeSink already uses), matching
retail's gmSpewBoxUI::Update. LinesProvider now only returns the
cache. Tests rewritten to drive root.Tick(...) instead of calling the
provider directly, plus new coverage for visibility-without-a-draw,
queue-drain-without-a-draw, and bounded-queue-across-many-ticks.

BLOCKER 2 — re-derived the HandleFailureEvent routing table from the
PDB-paired binary instead of the pseudo-C's ~33-char string previews.
tools/pdb-extract/sweep_weenie_strings.py sweeps every push imm32 in
VA 0x571990-0x575480, dereferences into .rdata/.data, and decodes the
full UTF-16LE literal. Added the 5 ids dispatched via else-if (missed
by case-label enumeration), resolved 0x4F8 (previously excluded),
fixed 18 wrong strings (16 the review flagged + 2 more — 0x4E9 and
0x518 — an automated diff between every swept literal and the landed
table found). Every changed row cross-checked against ACE's
WeenieError/WeenieErrorWithString enum doc comments; both oracles
agreed on every row, including a case where the review's own proposed
text for the new 0x4E8 row was itself wrong (it was 0x4E9's text) —
corrected via the else-if block's own instruction address plus the ACE
cross-check. Pinned table count: 344 (338 + 5 + 0x4F8).

SHOULD-FIX 1 — RuntimeCommunicationState.ResetSpewBox was dead code;
folded into the ChatIdentity generation-reset stage (same lifetime
boundary), with a reset assertion added to the existing populated-reset
test.

SHOULD-FIX 2 — AddText trimmed only the trailing end and invented an
empty-string early return; retail's AddTextToScroll trims both ends
(trim(&str, 1, 1, ws)) and has no empty guard. Both retired.

SHOULD-FIX 3 — ShowWeenieError bypassed the AddText chokepoint via
ChatLog.OnWeenieError (hardcoded LogTextType 0x00); routed through
Communication.AddText(Resolve(code, param)) instead, and
ChatLog.OnWeenieError is deleted — GameEventWiring's legacy no-router
fallback now resolves + calls OnSystemMessage directly.

SHOULD-FIX 4 — retail's HandleFailureEvent switch has no default case;
an unmapped id now resolves to a null Text (silence toward the
player) instead of the invented "WeenieError 0xNNNN" hex fallback,
with a diagnostics-only console log line for the id.

NITs — AP-TBD placeholders corrected to their real register rows
(AP-178, not the unrelated AP-177 lifetime row); filed AP-180 for the
windowId dual-destination gap and corrected three stale "lands with
CH2" comments; extended SpewBoxLayoutDumpDiagnostic from dats.Portal
to dats.Local and found the SpewBox element for real — LayoutDesc
0x21000011, element 0x10000048, size 450x72, MaxConcurrentItems
(ListBox property 0x10000028) = 4, not retail's code default of 1.
AP-178 narrowed accordingly; SpewBoxState.MaxConcurrentItems and
SpewBoxController's extent/anchor/OneLine are now authored rather than
placeholder (absolute screen position and colour remain open); fixed
the "19 ids... lists 18" miscount by retiring the stale paragraph in
the class doc rewrite; aligned the UseDone handler's silent-status
check with the other two WeenieError handlers.

Full Release suite: 11,914 passed / 4 skipped / 0 failed (build 0
errors).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 18:14:26 +02:00

213 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using AcDream.UI.Abstractions.Panels.SpewBox;
namespace AcDream.App.UI;
/// <summary>
/// Retained presentation of retail's <c>gmSpewBoxUI</c> (research doc
/// §1.1/§7.3/§7.4) — the transient top-of-viewport interface-text queue.
/// Modeled directly on <see cref="PortalWaitNoticeController"/>: a single
/// <c>ClickThrough</c> <see cref="UiText"/> block at a high
/// <see cref="UiElement.ZOrder"/>. Unlike that controller's single
/// overwrite-only slot, this reads <see cref="SpewBoxVM"/>'s bounded,
/// newest-on-top, per-entry-expiring queue every frame.
/// </summary>
/// <remarks>
/// <b>CH2 REJECT-review rework, BLOCKER 1
/// (<c>docs/research/2026-08-09-ch2-review-findings.md</c>):</b> the
/// original landing drove the queue drain from
/// <see cref="UiText.LinesProvider"/>, which <c>UiText.OnDraw</c> only
/// calls when the element is ALREADY <c>Visible</c> — and the element
/// starts invisible, so the provider was never invoked, no line ever drew,
/// and <see cref="SpewBoxState"/>'s pending queue never drained (an
/// unbounded per-session leak). Retail's own <c>gmSpewBoxUI::Update</c>
/// drains off the UI tick (global message 3,
/// <c>UIElementManager::UseTime @0x0045CFD0</c>), not off drawing —
/// <see cref="GlobalTimeSink"/> reproduces that: it is a zero-size child
/// mounted alongside <see cref="_text"/> purely so <see cref="UiRoot"/>'s
/// per-frame <c>BroadcastGlobalUiTime</c> walk reaches it (the same
/// pattern <c>VendorUiController.DragOverGlobalTimeSink</c> uses for
/// <c>gmVendorUI::ListenToGlobalMessage</c>). <see cref="Tick"/> pulls
/// <see cref="SpewBoxVM.Lines"/>, caches the resulting lines, and sets
/// <see cref="_text"/>'s <c>Visible</c> flag; <see cref="UiText.LinesProvider"/>
/// now only ever returns the cache — it is polled by drawing, but no
/// longer double-duties as the tick source, so lines become visible and
/// the queue drains even across a frame where nothing gets drawn (headless,
/// a hidden window, or simply before the first render pass).
/// </remarks>
/// <remarks>
/// <b>Position / font / colour are still PLACEHOLDERS; extent and
/// max-items are now AUTHORED.</b> CH2 REJECT-review rework, NIT 3
/// (<c>docs/research/2026-08-09-ch2-review-findings.md</c>): the task C.7
/// LayoutDesc dump (<c>SpewBoxLayoutDumpDiagnostic</c>) originally searched
/// only <c>dats.Portal</c> — EXHAUSTIVELY, against the entire installed
/// LayoutDesc id range (<c>0x21000000</c>-<c>0x21000075</c>, 101 of 118
/// possible ids populated, sanity-checked against 3 independently-known
/// ids) — and found ZERO elements of class <c>0x10000016</c> there.
/// Extending the identical sweep to <c>dats.Local</c>
/// (<c>client_local_English.dat</c>) found it: LayoutDesc
/// <c>0x21000011</c>, element <c>0x10000048</c> (<c>gmSpewBoxUI</c>),
/// position <c>(0,0)</c> RELATIVE TO ITS PARENT (edge codes
/// <c>leftEdge=3/rightEdge=3</c> — <c>ElementReader.ToAnchors</c>'s own doc
/// comment names 3 as "centered", a mode that projection cannot represent;
/// <c>topEdge=1</c> — top-anchored per that same helper), size
/// <c>450×72</c>, one child (ListBox <c>0x10000049</c>, matching
/// <c>gmSpewBoxUI::PostInit</c>'s <c>GetChildRecursive(0x10000049)</c>
/// verbatim) carrying <c>MaxConcurrentItems</c> (property
/// <c>0x10000028</c>) = <c>4</c>, not retail's code-default <c>1</c>. The
/// PARENT this element mounts under (and therefore the ABSOLUTE screen
/// position) is still unresolved — <c>(0,0)</c> is parent-relative, and the
/// parent is presumably assigned by the same C++ code the research doc's
/// §1.1 describes, not by another LayoutDesc this sweep can walk to. See
/// the divergence register rows this class cites for each remaining
/// placeholder.
/// </remarks>
internal sealed class SpewBoxController : IDisposable
{
/// <summary>
/// Register row AP-178 (screen position): retail's authored ABSOLUTE
/// screen position is still unknown — the LayoutDesc dump (see class
/// remarks) recovered the element's position as <c>(0,0)</c> relative
/// to a PARENT this sweep could not identify, so this centered-top
/// placement remains acdream's own choice, not a resolved retail value.
/// (The SIBLING row AP-177 — the invented line-lifetime timeout — lives
/// in <see cref="SpewBoxState.DefaultLifetime"/>'s own doc comment, not
/// here; this controller does not own that concern.)
/// </summary>
private const float TopOffset = 60f;
/// <summary>
/// Register row AP-178 (extent): AUTHORED, not a placeholder — the
/// LayoutDesc dump (see class remarks) found the SpewBox element sized
/// <c>450×72</c> in <c>dats.Local</c>. Retail's own edge codes
/// (<c>leftEdge=3</c>/<c>rightEdge=3</c>, "centered" per
/// <c>ElementReader.ToAnchors</c>'s doc comment) mean the box is a
/// FIXED-width block horizontally centered in its parent, not a
/// full-viewport stretch — the constructor below anchors it that way
/// (a one-time centered <see cref="UiText.Left"/> computed against
/// <see cref="UiRoot.Width"/>, <see cref="AnchorEdges.Top"/> only) since
/// <see cref="AnchorEdges"/> has no "centered, fixed-width" flag
/// combination to express retail's mode 3 directly.
/// </summary>
private const float SpewBoxWidth = 450f;
private const float SpewBoxHeight = 72f;
/// <summary>
/// Register row AP-178 (colour): retail's authored colour for THIS
/// element remains unresolved — the LayoutDesc dump (see class remarks)
/// found only two direct-state properties on the SpewBox element/ListBox
/// (a bool at <c>0x3B</c> and the <c>MaxConcurrentItems</c> integer at
/// <c>0x10000028</c>); no colour property surfaced in that direct-state
/// dump, and the per-<c>UIStateId</c> <c>States</c> dictionary (hover/
/// pressed/etc. variants, which could carry it) was not walked this
/// pass. The chat colour table's <c>0x1A</c> entry
/// (<c>colorBrightRed</c>) is explicitly NOT this — retail's own
/// <c>BuildChatColorLookupTable</c> writes to <c>ChatInterface::m_chatLog</c>,
/// a completely different element tree the SpewBox never touches
/// (research doc §3.2.3). This warm-yellow placeholder follows the
/// user's own recollection of the retail SpewBox's colour (unconfirmed
/// by any decompiled or DAT-authored source) rather than an arbitrary
/// choice.
/// </summary>
private static readonly Vector4 SpewBoxColor = new(1f, 1f, 0.4f, 1f);
private readonly UiRoot _root;
private readonly UiText _text;
private readonly SpewBoxVM _vm;
private readonly GlobalTimeSink _timeSink;
private UiText.Line[] _lines = Array.Empty<UiText.Line>();
private bool _disposed;
public SpewBoxController(UiRoot root, SpewBoxVM vm)
{
_root = root ?? throw new ArgumentNullException(nameof(root));
_vm = vm ?? throw new ArgumentNullException(nameof(vm));
_text = new UiText
{
Name = "SpewBox",
// Centered fixed-width block (retail's "mode 3" edge code on
// both left and right) — see the AP-178 extent comment above.
Left = (root.Width - SpewBoxWidth) / 2f,
Top = TopOffset,
Width = SpewBoxWidth,
Height = SpewBoxHeight,
Anchors = AnchorEdges.Top,
Centered = true,
// AUTHORED MaxConcurrentItems is 4, not retail's code-default 1
// (see SpewBoxState.MaxConcurrentItems) — OneLine=true would
// silently collapse the box back down to showing only the
// newest of up to 4 concurrent lines.
OneLine = false,
ClickThrough = true,
ZOrder = int.MaxValue,
DefaultColor = SpewBoxColor,
Visible = false,
};
_text.LinesProvider = () => _lines;
_root.AddChild(_text);
_timeSink = new GlobalTimeSink(Tick);
_root.AddChild(_timeSink);
}
/// <summary>
/// The SpewBox's per-frame tick, driven by <see cref="UiRoot"/>'s
/// global-message-3 broadcast via <see cref="GlobalTimeSink"/> — the
/// direct analogue of <c>gmSpewBoxUI::Update</c>. Drains
/// <see cref="SpewBoxState"/>'s pending queue and prunes expired
/// entries (see <see cref="SpewBoxVM.Lines"/>), caches the resulting
/// display lines, and sets <see cref="_text"/>'s visibility. Runs
/// whether or not a draw pass follows.
/// </summary>
/// <param name="nowSeconds">
/// <see cref="UiRoot"/>'s own per-frame clock — <b>not</b>
/// <c>Environment.TickCount64</c> — matching every other
/// <see cref="IUiGlobalTimeListener"/> consumer's time source.
/// </param>
private void Tick(double nowSeconds)
{
// SpewBoxVM.Lines returns newest-first, matching retail's
// InsertItem(item, 0) — with OneLine now false and the AUTHORED
// MaxConcurrentItems == 4 (see SpewBoxState.MaxConcurrentItems),
// up to 4 lines render, newest on top.
IReadOnlyList<SpewBoxLine> lines = _vm.Lines(nowSeconds);
_text.Visible = lines.Count > 0;
if (lines.Count == 0)
{
_lines = Array.Empty<UiText.Line>();
return;
}
var result = new UiText.Line[lines.Count];
for (int i = 0; i < lines.Count; i++)
result[i] = new UiText.Line(lines[i].Text, SpewBoxColor);
_lines = result;
}
public void Dispose()
{
if (_disposed)
return;
_root.RemoveChild(_text);
_root.RemoveChild(_timeSink);
_disposed = true;
}
/// <summary>
/// A runtime-only, zero-size, always-invisible-to-hit-testing helper
/// that opts this controller into retail's global UI message 3 — see
/// the class remarks and <c>VendorUiController.DragOverGlobalTimeSink</c>
/// for the identical pattern. <see cref="SpewBoxController"/> is not
/// itself a <see cref="UiElement"/> (it wraps one), so it cannot
/// directly implement <see cref="IUiGlobalTimeListener"/> the way
/// <see cref="UiButton"/> does — <see cref="UiRoot.Tick"/>'s broadcast
/// walks the ELEMENT tree, not arbitrary controllers.
/// </summary>
private sealed class GlobalTimeSink : UiElement, IUiGlobalTimeListener
{
private readonly Action<double> _onGlobalUiTime;
public GlobalTimeSink(Action<double> onGlobalUiTime) => _onGlobalUiTime = onGlobalUiTime;
public void OnGlobalUiTime(double nowSeconds) => _onGlobalUiTime(nowSeconds);
}
}