using System.Numerics; using AcDream.UI.Abstractions.Panels.SpewBox; namespace AcDream.App.UI; /// /// Retained presentation of retail's gmSpewBoxUI (research doc /// §1.1/§7.3/§7.4) — the transient top-of-viewport interface-text queue. /// Modeled directly on : a single /// ClickThrough block at a high /// . Unlike that controller's single /// overwrite-only slot, this reads 's bounded, /// newest-on-top, per-entry-expiring queue every frame. /// /// /// CH2 REJECT-review rework, BLOCKER 1 /// (docs/research/2026-08-09-ch2-review-findings.md): the /// original landing drove the queue drain from /// , which UiText.OnDraw only /// calls when the element is ALREADY Visible — and the element /// starts invisible, so the provider was never invoked, no line ever drew, /// and 's pending queue never drained (an /// unbounded per-session leak). Retail's own gmSpewBoxUI::Update /// drains off the UI tick (global message 3, /// UIElementManager::UseTime @0x0045CFD0), not off drawing — /// reproduces that: it is a zero-size child /// mounted alongside purely so 's /// per-frame BroadcastGlobalUiTime walk reaches it (the same /// pattern VendorUiController.DragOverGlobalTimeSink uses for /// gmVendorUI::ListenToGlobalMessage). pulls /// , caches the resulting lines, and sets /// 's Visible flag; /// 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). /// /// /// Position / font / colour are still PLACEHOLDERS; extent and /// max-items are now AUTHORED. CH2 REJECT-review rework, NIT 3 /// (docs/research/2026-08-09-ch2-review-findings.md): the task C.7 /// LayoutDesc dump (SpewBoxLayoutDumpDiagnostic) originally searched /// only dats.Portal — EXHAUSTIVELY, against the entire installed /// LayoutDesc id range (0x21000000-0x21000075, 101 of 118 /// possible ids populated, sanity-checked against 3 independently-known /// ids) — and found ZERO elements of class 0x10000016 there. /// Extending the identical sweep to dats.Local /// (client_local_English.dat) found it: LayoutDesc /// 0x21000011, element 0x10000048 (gmSpewBoxUI), /// position (0,0) RELATIVE TO ITS PARENT (edge codes /// leftEdge=3/rightEdge=3ElementReader.ToAnchors's own doc /// comment names 3 as "centered", a mode that projection cannot represent; /// topEdge=1 — top-anchored per that same helper), size /// 450×72, one child (ListBox 0x10000049, matching /// gmSpewBoxUI::PostInit's GetChildRecursive(0x10000049) /// verbatim) carrying MaxConcurrentItems (property /// 0x10000028) = 4, not retail's code-default 1. The /// PARENT this element mounts under (and therefore the ABSOLUTE screen /// position) is still unresolved — (0,0) 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. /// internal sealed class SpewBoxController : IDisposable { /// /// 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 (0,0) 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 's own doc comment, not /// here; this controller does not own that concern.) /// private const float TopOffset = 60f; /// /// Register row AP-178 (extent): AUTHORED, not a placeholder — the /// LayoutDesc dump (see class remarks) found the SpewBox element sized /// 450×72 in dats.Local. Retail's own edge codes /// (leftEdge=3/rightEdge=3, "centered" per /// ElementReader.ToAnchors'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 computed against /// , only) since /// has no "centered, fixed-width" flag /// combination to express retail's mode 3 directly. /// private const float SpewBoxWidth = 450f; private const float SpewBoxHeight = 72f; /// /// 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 0x3B and the MaxConcurrentItems integer at /// 0x10000028); no colour property surfaced in that direct-state /// dump, and the per-UIStateId States dictionary (hover/ /// pressed/etc. variants, which could carry it) was not walked this /// pass. The chat colour table's 0x1A entry /// (colorBrightRed) is explicitly NOT this — retail's own /// BuildChatColorLookupTable writes to ChatInterface::m_chatLog, /// 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. /// 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(); 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); } /// /// The SpewBox's per-frame tick, driven by 's /// global-message-3 broadcast via — the /// direct analogue of gmSpewBoxUI::Update. Drains /// 's pending queue and prunes expired /// entries (see ), caches the resulting /// display lines, and sets 's visibility. Runs /// whether or not a draw pass follows. /// /// /// 's own per-frame clock — not /// Environment.TickCount64 — matching every other /// consumer's time source. /// 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 lines = _vm.Lines(nowSeconds); _text.Visible = lines.Count > 0; if (lines.Count == 0) { _lines = Array.Empty(); 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; } /// /// 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 VendorUiController.DragOverGlobalTimeSink /// for the identical pattern. is not /// itself a (it wraps one), so it cannot /// directly implement the way /// does — 's broadcast /// walks the ELEMENT tree, not arbitrary controllers. /// private sealed class GlobalTimeSink : UiElement, IUiGlobalTimeListener { private readonly Action _onGlobalUiTime; public GlobalTimeSink(Action onGlobalUiTime) => _onGlobalUiTime = onGlobalUiTime; public void OnGlobalUiTime(double nowSeconds) => _onGlobalUiTime(nowSeconds); } }