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=3 — ElementReader.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.
///
internal sealed class SpewBoxController : IDisposable
{
///
/// 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.
///
internal const uint RetailFontId = 0x40000025u;
///
/// 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. 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 0 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
/// '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); 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
/// / comments; both
/// remain acdream-directed approximations, not resolved retail values.
///
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,
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);
}
}