using System.Numerics; using AcDream.App.Rendering; using AcDream.App.UI.Layout; 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. /// A single ClickThrough block at a high /// . Campaign CH user-gate round 2, item 2: /// this is now the ONLY on-screen interface-text presentation surface — /// the former PortalWaitNoticeController (a dedicated centered /// overlay with a single overwrite-only slot) is deleted; the portal-space /// wait notice routes here too, through the same /// RuntimeCommunicationState.AddText chokepoint every other /// ClientLocal refusal uses. 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. /// /// /// Campaign CH user-gate round 3 (2026-08-10), finding (a) — position /// and font. 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; SpewBoxLayoutDumpDiagnostic /// re-run this round still finds no FontDid/colour property on /// element 0x10000048 or its ListBox child): /// /// Position: is now 0 — 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. /// Font: now accepts a /// resolved (retail font id /// , 0x40000025) instead of silently /// falling back to the debug at its ad hoc 15px /// pixel height (the pre-round-3 behavior — no DatFont/Font /// was ever wired here at all). 0x40000025 is MaxCharHeight=11 /// px (confirmed via AcDream.Cli dump-font-atlas against the /// installed DAT, sweeping every populated font id /// 0x40000000-0x40000032) — the SMALLEST font id actually /// confirmed IN USE by any of acdream's currently-imported retail /// LayoutDesc fixtures (cross-referenced across every /// tests/AcDream.App.Tests/UI/Layout/fixtures/*.json dump), and /// it is ALSO the chat window's own smallest font — the same /// 0x2100006F floating-window 1/2/3/4 indicator badges /// (ChatWindowController.Indicator1-4Id) 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. /// ChatWindowController.Bind). /// /// Register row AP-178 updated to record both dispositions. /// /// /// Campaign CH user-gate round 4 (2026-08-10), /// docs/research/2026-08-10-retail-ui-text-style.md: the earlier /// "absent from both dats" finding for the SpewBox's own line template /// (element 0x1000004A, base style 0x10000377 in layout /// 0x2100003F) was WRONG — it was missed because the element is a /// ROOT of its layout (a children-only walk skips it) and its font/color /// live in a BaseElement in a DIFFERENT LayoutDesc plus a NAMED state, not /// its own DirectState. Extending the sweep to walk roots, BaseElement/ /// BaseLayoutId inheritance, and named-state properties found it, resolving /// three of AP-178's four remaining open sub-claims: /// /// Font: is now /// 0x40000001 (18px bold serif), not the round-3 smallest-font /// heuristic 0x40000025. Three independent cross-checks: (1) base /// style 0x10000377 authors FontDID [0x40000001] directly; /// (2) the line template's authored height is 18px, exactly /// 0x40000001's MaxCharHeight; (3) 4 items × 18px = 72px, /// the authored box height (). /// Outline: the line template's state 0x10000002 /// authors property 0x21 (Outline) = true, outline color /// NOT authored → ctor default black /// (). This is the /// "heavy black border" the user's screenshot showed and the earlier /// rounds never reproduced — is now set on /// construction. /// Position/extent: confirmed AUTHORED, not merely /// user-matched by luck. 0x10000048 (the gmSpewBoxUI root) is a /// ROOT element of its own layout, so its parent is the viewport: /// pos(0,0), edge codes L3/R3 ("centered" per /// ElementReader.ToAnchors's doc comment) and T1 /// (top-anchored) resolve to a 450×72 block horizontally centered, flush /// to the viewport top — exactly what =0 and the /// per-frame centered Left recompute already do. AP-178's position /// sub-claim retires. /// /// Only fill COLOR (see — the atlas is PFID_A8, /// alpha-only, so it cannot carry baked shading; the user-pinned yellow /// stands pending an exact retail measurement) and the AP-177 line-lifetime /// timeout remain open. /// internal sealed class SpewBoxController : IDisposable { /// /// Retail dat Font id this controller resolves for its text. Campaign CH /// user-gate round 4 (2026-08-10): AUTHORED, not a heuristic — base /// style 0x10000377 (layout 0x2100003F), which the /// SpewBox's own line template (0x1000004A in layout /// 0x21000011) inherits, authors FontDID [0x40000001] /// directly. 0x40000001 is the classic AC bold-serif UI face at /// 18px (MaxCharHeight=18, matching the line template's own /// authored 18px height and the 4×18=72px authored box height). See the /// class remarks for the three-way cross-check and /// docs/research/2026-08-10-retail-ui-text-style.md §4.2. /// internal const uint RetailFontId = 0x40000001u; /// /// Register row AP-178 (screen position): CONFIRMED authored, not a /// placeholder that happens to match. Campaign CH user-gate round 4 /// (2026-08-10): the SpewBox root (0x10000048) is a ROOT element /// of its own layout (0x21000011), so its parent is the /// viewport — pos(0,0) plus edge codes L3/R3 (centered) /// and T1 (top-anchored) resolve to exactly this: a fixed-width /// block horizontally centered and flush to the viewport top. The /// round-3 "user-directed approximation pending the true retail /// parent/offset" framing is retired — the parent (the viewport root) /// and the offset (0) are now both resolved. (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 = 0f; /// /// 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. has no "centered, /// fixed-width" flag combination to express retail's mode 3 directly, so /// recomputes a centered /// against the CURRENT every frame instead of /// anchoring. /// /// CH2 re-review nit 1 /// (docs/plans/2026-08-09-chat-parity-campaign.md): the original /// rework anchored with only, meaning /// captured the constructor's /// one-time centered Left as a fixed left MARGIN /// (ComputeAnchoredRect's Left/Right-both-false branch) and /// replayed that absolute pixel position forever — a window resize left /// the box off-center. is now /// (so ApplyAnchor is a no-op) and /// centering is owned entirely by the per-frame recompute below. /// /// private const float SpewBoxWidth = 450f; private const float SpewBoxHeight = 72f; /// /// 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 (0x81C4C8, RetailChatColorTable.Yellow = /// (1, 1, 0.247, 1)). The chat colour table's 0x1A entry /// (colorBrightRed) is still 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). POSITION and FONT were resolved as AUTHORED at /// Campaign CH user-gate round 4 (2026-08-10) — see the class remarks and /// the / comments. The /// AUTHORED value for THIS element's state 0x10000002 is actually /// pure red ARGB(255,255,0,0) — but the user's live retail /// screenshot shows gold/amber, not red, so the user's own eye remains /// the axiom here (per feedback_retail_oracle_no_whack_a_mole). /// Round 4 checked whether font 0x40000001's FILL-plane atlas /// could explain the gold as baked shading over the pinned yellow: it /// cannot — every dat font atlas (this one included) is PFID_A8, /// alpha-only coverage with no per-pixel colour channel, so there is no /// baked tint to compose with. The gold reading is therefore the OUTLINE /// itself (a black border around a bright yellow glyph reads warmer/ /// richer than the same colour drawn flat) rather than atlas shading. /// The user-pinned yellow stands; only an exact cdb capture of live /// retail's m_curFontColor would resolve the remaining gap. /// 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(); private bool _disposed; /// Retained-UI root this element mounts under. /// SpewBox view-model (bounded, newest-on-top queue). /// /// Resolved retail dat font () — Campaign CH /// user-gate round 3. Null falls back to , /// matching every other retained-UI controller's dat-font pattern (e.g. /// ChatWindowController.Bind). /// /// Fallback bitmap font, used only when /// is null. 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, // Campaign CH user-gate round 4: the line template's authored state // 0x10000002 sets property 0x21 (Outline) = true with no authored // outline colour, i.e. the ctor black default // (UiRenderContext.DefaultOutlineColor) — the heavy black border in // the user's screenshot. See the class remarks. Outline = true, 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) { // 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 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); } }