acdream/src/AcDream.App/UI/SpewBoxController.cs
Erik 98de4f5ab3 fix(chat): Campaign CH round 3 — SpewBox flush-top/font, /help exact print sequence
User-gate round 3 findings (a)-(c):

(a) SpewBox: TopOffset moves from the round-1 60px placeholder to 0 (flush
to the viewport top). SpewBoxController never wired DatFont/Font at all
before this round, so it silently rendered through the 15px debug
BitmapFont fallback; it now resolves retail dat Font 0x40000025
(MaxCharHeight=11px) through a new RetailUiRuntime.Assets accessor —
the smallest font id confirmed in use by any currently-imported retail
LayoutDesc fixture, cross-referenced against every
tests/AcDream.App.Tests/UI/Layout/fixtures/*.json dump and confirmed
against the installed DAT via AcDream.Cli dump-font-atlas. It is also the
chat window's own smallest font (the 0x2100006F floating-window 1/2/3/4
indicator badges), so both selection criteria the brief offered agree.
Both remain best-available approximations, not resolved retail values —
register row AP-178 updated accordingly.

(b)/(c) /help and /help death: round 2 extracted the individual retail
strings byte-exact but never traced ClientCommunicationSystem::DoHelp's
complete print sequence. Byte-swept DoHelp's own range plus the five
Summary-branch functions it calls into (HelpEmote/HelpSquelch/
HelpStatusGroup/HelpTextGroup/HelpAllGroup) against the PDB-paired
acclient.exe. Retail's real shape: bare /help prints exactly TWO scroll
entries (HelpPrefixNote, then the 13-item AvailableHelpListing built from
DoHelp's own literals and each group's Summary_HelpType branch, in exact
source order) — not the acdream-invented cheat sheet BuildHelpText()
built before. Any resolved /help <verb> gets the SAME two-entry shape:
HelpPrefixNote, then ForMoreInformationPrefix concatenated directly onto
the verb's own Detail text (retail's own unsubstituted "<command>"
literal, ported verbatim). ChatCommandRouter.EmitVerbHelp applies this
uniformly to every resolved verb, not just death. An unresolved verb now
shows retail's real "Unknown command" fallback text; that fallback types
0x1A (ClientLocal), which retail routes to the SpewBox exclusively — a
gap ChatVM's UI.Abstractions layer can't yet reach, filed as ISSUES #367
/ register AP-186 rather than left silently unregistered.

Jump-in-air (round 2's open item 1) was root-caused and fixed separately
at a5a7eb4f between rounds — recorded in the campaign ledger.

Debug suite (all projects): 12,329 passed / 4 skipped / 1 failed — the
one failure is issue #351, a pre-existing Debug-only streaming flake
confirmed reproducing identically on the pristine pre-round-3 commit via
git stash, not a regression. Release verification covers every project
reachable without rebuilding AcDream.App: a live client process (PID
15064) held its own Release binaries locked for the session and was not
killed per project policy — AcDream.UI.Abstractions.Tests (867/867, the
layer both /help fixes live in) plus every other non-App-dependent
project, all 0 failed. AcDream.App/AcDream.App.Tests/AcDream.Core.Tests
(the SpewBox fix's layer) are green in Debug only this session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 10:40:19 +02:00

336 lines
18 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.App.Rendering;
using AcDream.App.UI.Layout;
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.
/// A single <c>ClickThrough</c> <see cref="UiText"/> block at a high
/// <see cref="UiElement.ZOrder"/>. Campaign CH user-gate round 2, item 2:
/// this is now the ONLY on-screen interface-text presentation surface —
/// the former <c>PortalWaitNoticeController</c> (a dedicated centered
/// overlay with a single overwrite-only slot) is deleted; the portal-space
/// wait notice routes here too, through the same
/// <c>RuntimeCommunicationState.AddText</c> chokepoint every other
/// <c>ClientLocal</c> refusal uses. 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>
/// <remarks>
/// <b>Campaign CH user-gate round 3 (2026-08-10), finding (a) — position
/// and font.</b> The user reported live: "still not aligned all the way to
/// the top" and "not the correct font and size (retail's is SMALLER than
/// ours)". Two changes, both still user-DIRECTED approximations (not
/// resolved retail values — the true absolute position/parent and the true
/// retail font remain unmeasurable statically; <c>SpewBoxLayoutDumpDiagnostic</c>
/// re-run this round still finds no <c>FontDid</c>/colour property on
/// element <c>0x10000048</c> or its ListBox child):
/// <list type="bullet">
/// <item><b>Position:</b> <see cref="TopOffset"/> is now <c>0</c> — flush
/// to the viewport top, per the user's explicit direction ("mount at
/// viewport top-center, exactly"). The centered-X, recompute-every-frame
/// behavior from CH2 nit 1 is unchanged.</item>
/// <item><b>Font:</b> <see cref="SpewBoxController"/> now accepts a
/// resolved <see cref="UiDatFont"/> (retail font id
/// <see cref="RetailFontId"/>, <c>0x40000025</c>) instead of silently
/// falling back to the debug <see cref="BitmapFont"/> at its ad hoc 15px
/// pixel height (the pre-round-3 behavior — no <c>DatFont</c>/<c>Font</c>
/// was ever wired here at all). <c>0x40000025</c> is <c>MaxCharHeight=11</c>
/// px (confirmed via <c>AcDream.Cli dump-font-atlas</c> against the
/// installed DAT, sweeping every populated font id
/// <c>0x40000000</c>-<c>0x40000032</c>) — the SMALLEST font id actually
/// confirmed IN USE by any of acdream's currently-imported retail
/// LayoutDesc fixtures (cross-referenced across every
/// <c>tests/AcDream.App.Tests/UI/Layout/fixtures/*.json</c> dump), and
/// it is ALSO the chat window's own smallest font — the same
/// <c>0x2100006F</c> floating-window 1/2/3/4 indicator badges
/// (<c>ChatWindowController.Indicator1-4Id</c>) use it. Both selection
/// criteria from the round-3 brief ("smallest DAT font used by retail UI
/// imports" vs "the chat window's own font, whichever is smaller") land
/// on the SAME id, so there was no tension to resolve. This is visibly
/// smaller than the previous 15px debug font, matching the user's
/// report. Falls back to the debug font only if the dat resolve fails
/// (matching every other retained-UI controller's pattern, e.g.
/// <c>ChatWindowController.Bind</c>).</item>
/// </list>
/// Register row AP-178 updated to record both dispositions.
/// </remarks>
internal sealed class SpewBoxController : IDisposable
{
/// <summary>
/// Retail dat Font id this controller resolves for its text
/// (Campaign CH user-gate round 3 — see the class remarks). Not
/// retail's own measured SpewBox font (unmeasurable — no FontDid
/// property was found on the authored element); the smallest DAT font
/// confirmed in use by any currently-imported retail LayoutDesc,
/// chosen so the rendered text is visibly smaller than the prior debug
/// fallback, per the user's report.
/// </summary>
internal const uint RetailFontId = 0x40000025u;
/// <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. Campaign CH user-gate
/// round 3 (2026-08-10): the user reported the box was not flush to
/// the very top of the screen; mounted at <c>0</c> now, per explicit
/// user direction — still acdream's own placement choice pending the
/// true retail parent/offset, but now matching the user's live report
/// instead of an arbitrary 60px placeholder. (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 = 0f;
/// <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. <see cref="AnchorEdges"/> has no "centered,
/// fixed-width" flag combination to express retail's mode 3 directly, so
/// <see cref="Tick"/> recomputes a centered <see cref="UiText.Left"/>
/// against the CURRENT <see cref="UiRoot.Width"/> every frame instead of
/// anchoring.
/// <para>
/// CH2 re-review nit 1
/// (<c>docs/plans/2026-08-09-chat-parity-campaign.md</c>): the original
/// rework anchored with <see cref="AnchorEdges.Top"/> only, meaning
/// <see cref="UiElement.ApplyAnchor"/> captured the constructor's
/// one-time centered <c>Left</c> as a fixed left MARGIN
/// (<c>ComputeAnchoredRect</c>'s Left/Right-both-false branch) and
/// replayed that absolute pixel position forever — a window resize left
/// the box off-center. <see cref="Anchors"/> is now
/// <see cref="AnchorEdges.None"/> (so <c>ApplyAnchor</c> is a no-op) and
/// centering is owned entirely by the per-frame recompute below.
/// </para>
/// </summary>
private const float SpewBoxWidth = 450f;
private const float SpewBoxHeight = 72f;
/// <summary>
/// Register row AP-178 (colour): CH user-gate round 1 (2026-08-09)
/// PINNED this — the user confirmed live, side-by-side against retail,
/// that the on-screen SpewBox text is the same bright yellow as an
/// incoming Tell (<c>0x81C4C8</c>, <c>RetailChatColorTable.Yellow</c> =
/// <c>(1, 1, 0.247, 1)</c>). The chat colour table's <c>0x1A</c> entry
/// (<c>colorBrightRed</c>) is still 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); the LayoutDesc dump (see class remarks) also
/// never surfaced a colour property for this element. The exact retail
/// value simply happens to coincide with the Tell colour, per the user's
/// live observation. POSITION and FONT were re-addressed at Campaign CH
/// user-gate round 3 (2026-08-10) — see the class remarks and the
/// <see cref="TopOffset"/>/<see cref="RetailFontId"/> comments; both
/// remain acdream-directed approximations, not resolved retail values.
/// </summary>
private static readonly Vector4 SpewBoxColor = new(1f, 1f, 0.247f, 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;
/// <param name="root">Retained-UI root this element mounts under.</param>
/// <param name="vm">SpewBox view-model (bounded, newest-on-top queue).</param>
/// <param name="font">
/// Resolved retail dat font (<see cref="RetailFontId"/>) — Campaign CH
/// user-gate round 3. Null falls back to <paramref name="debugFont"/>,
/// matching every other retained-UI controller's dat-font pattern (e.g.
/// <c>ChatWindowController.Bind</c>).
/// </param>
/// <param name="debugFont">Fallback bitmap font, used only when
/// <paramref name="font"/> is null.</param>
public SpewBoxController(
UiRoot root, SpewBoxVM vm, UiDatFont? font = null, BitmapFont? debugFont = null)
{
_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.
// This is only the FIRST frame's value; Tick recomputes it
// every frame against the current root width (CH2 re-review
// nit 1 — see the extent comment's nit-1 paragraph).
Left = (root.Width - SpewBoxWidth) / 2f,
Top = TopOffset,
Width = SpewBoxWidth,
Height = SpewBoxHeight,
Anchors = AnchorEdges.None,
Centered = true,
// Campaign CH user-gate round 3: retail dat font (RetailFontId)
// when resolved, falling back to the debug bitmap font exactly
// like every other retained-UI controller (ChatWindowController
// et al.) — see the class remarks.
DatFont = font,
Font = debugFont,
// 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,
// CH2 re-review nit 2 (docs/plans/2026-08-09-chat-parity-campaign.md):
// retail's own authored vertical justification for THIS element
// is unknown (register row AP-178 covers presentation
// unknowns) — top-aligned flow is acdream's own choice, made
// because it is the only placement consistent with "lines flow
// from the top, newest on top" (see Tick's ordering comment).
// HonorVerticalJustification opts the scrollable multi-line
// path into VerticalJustify without a full ConfigureDatState
// LayoutDesc binding, which this synthesized element does not
// have.
VerticalJustify = VJustify.Top,
HonorVerticalJustification = true,
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)
{
// CH2 re-review nit 1 (docs/plans/2026-08-09-chat-parity-campaign.md):
// Anchors is AnchorEdges.None (see the AP-178 extent comment on
// SpewBoxWidth), so nothing else recenters this element on a
// window resize — recompute every frame against the CURRENT root
// width rather than the width captured once at construction.
_text.Left = (_root.Width - SpewBoxWidth) / 2f;
// CH2 re-review nit 3 (docs/plans/2026-08-09-chat-parity-campaign.md):
// deliberate inversion of UiText.LinesProvider's documented
// contract ("Provider of the lines to show, oldest-first" —
// UiText.cs). SpewBoxVM.Lines returns newest-first, matching
// retail's InsertItem(item, 0), and this controller feeds that
// order straight through WITHOUT reversing it. That is correct
// here specifically because the box is top-aligned
// (VerticalJustify.Top + HonorVerticalJustification above): index 0
// of the lines array draws at the TOP of the box, so feeding
// newest-first into a slot documented as oldest-first is exactly
// what makes "newest line on top" true. Reversing the feed to
// satisfy the doc comment literally would flip the visible order to
// oldest-on-top, which is wrong for this element — see
// SpewBoxControllerTests' rendered-order test. With OneLine now
// false and the AUTHORED MaxConcurrentItems == 4 (see
// SpewBoxState.MaxConcurrentItems), up to 4 lines render.
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);
}
}