acdream/src/AcDream.App/UI/SpewBoxController.cs
Erik bcc34ee301 feat(chat): retail text style — two-plane glyph outlines, authored SpewBox/chat styles
Campaign CH round 4, user-gate items 1+2. Root cause: retail ships a
second (background) glyph atlas per font, dilated 2px on every side,
plus two border-pixel scalars (Font.NumHorizontalBorderPixels/
NumVerticalBorderPixels) that acdream's font reader never read — so
even the pre-existing outline parameter drew almost nothing once
enabled. Landed together (either half alone is a no-op or a
regression):

- UiDatFont carries BorderX/BorderY from the DAT font resource.
- UiRenderContext.DrawStringDat inflates the background blit's source
  and destination rect by that margin and restructures into retail's
  exact two-pass whole-string outline-then-fill model
  (UIElement_Text::DrawSelf), plus the 8-neighbour +-1px fallback for
  fonts with no background atlas. Corrects the stale "property 0xd"
  comment to the real ids, 0x21 (Outline) / 0x22 (OutlineColor).
- LayoutDesc property 0x21/0x22 import (ElementInfo.Outline/
  OutlineColor, LayoutImporter.ReadState, ElementReader.Merge/
  ApplyCanonicalLegacyProjection, DatWidgetFactory.BuildText) so every
  authored-outline element across the DAT set is correct at once.
- SpewBox: RetailFontId corrected from a round-3 heuristic
  (0x40000025) to the actually-authored 0x40000001 (18px bold serif),
  Outline=true set on the controller's UiText. Fill colour stays the
  user-gate-round-1-pinned yellow — font atlases are alpha-only
  (PFID_A8), so there is no baked shading that could explain the
  screenshot's gold as anything other than the outline itself.
- Chat transcript: default fill now seeds from its authored
  ARGB(255,204,204,204) instead of an unrelated color-table slot
  (ChatTranscriptRenderer.BuildLines takes the transcript's own
  DefaultColor as a parameter); the 34-entry LogTextType table is
  untouched, and every existing CH1 conformance test stays green
  unmodified.

Regenerated the committed chat_2100006f.json fixture from the real
installed DAT, confirming end to end (not by missing-field default)
that the transcript carries no outline.

Tests: font-reader border fields + inflation math pinned against the
real DAT font, two-pass draw ordering/tint/inflation via a new
TextRenderer.DebugSpriteSegmentVerts test seam, property 0x21/0x22
import at both the ElementReader.Merge and StateDesc-property layers,
SpewBox font/outline, and the chat default-shade seed with the color
table proven untouched.

Full Release suite: 12,610 passed / 4 skipped / 0 failed
(AcDream.slnx, complete solution).

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

396 lines
22 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>
/// <remarks>
/// <b>Campaign CH user-gate round 4 (2026-08-10),
/// <c>docs/research/2026-08-10-retail-ui-text-style.md</c>:</b> the earlier
/// "absent from both dats" finding for the SpewBox's own line template
/// (element <c>0x1000004A</c>, base style <c>0x10000377</c> in layout
/// <c>0x2100003F</c>) 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:
/// <list type="bullet">
/// <item><b>Font:</b> <see cref="RetailFontId"/> is now
/// <b>0x40000001</b> (18px bold serif), not the round-3 smallest-font
/// heuristic <c>0x40000025</c>. Three independent cross-checks: (1) base
/// style <c>0x10000377</c> authors FontDID <c>[0x40000001]</c> directly;
/// (2) the line template's authored height is 18px, exactly
/// <c>0x40000001</c>'s <c>MaxCharHeight</c>; (3) 4 items × 18px = 72px,
/// the authored box height (<see cref="SpewBoxHeight"/>).</item>
/// <item><b>Outline:</b> the line template's state <c>0x10000002</c>
/// authors property <c>0x21</c> (Outline) = <c>true</c>, outline color
/// NOT authored → ctor default black
/// (<see cref="UiRenderContext.DefaultOutlineColor"/>). This is the
/// "heavy black border" the user's screenshot showed and the earlier
/// rounds never reproduced — <see cref="UiText.Outline"/> is now set on
/// construction.</item>
/// <item><b>Position/extent:</b> confirmed AUTHORED, not merely
/// user-matched by luck. <c>0x10000048</c> (the gmSpewBoxUI root) is a
/// ROOT element of its own layout, so its parent is the viewport:
/// <c>pos(0,0)</c>, edge codes <c>L3/R3</c> ("centered" per
/// <c>ElementReader.ToAnchors</c>'s doc comment) and <c>T1</c>
/// (top-anchored) resolve to a 450×72 block horizontally centered, flush
/// to the viewport top — exactly what <see cref="TopOffset"/>=0 and the
/// per-frame centered <c>Left</c> recompute already do. AP-178's position
/// sub-claim retires.</item>
/// </list>
/// Only fill COLOR (see <see cref="SpewBoxColor"/> — 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.
/// </remarks>
internal sealed class SpewBoxController : IDisposable
{
/// <summary>
/// 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 <c>0x10000377</c> (layout <c>0x2100003F</c>), which the
/// SpewBox's own line template (<c>0x1000004A</c> in layout
/// <c>0x21000011</c>) inherits, authors FontDID <c>[0x40000001]</c>
/// directly. <c>0x40000001</c> is the classic AC bold-serif UI face at
/// 18px (<c>MaxCharHeight=18</c>, 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
/// <c>docs/research/2026-08-10-retail-ui-text-style.md</c> §4.2.
/// </summary>
internal const uint RetailFontId = 0x40000001u;
/// <summary>
/// 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 (<c>0x10000048</c>) is a ROOT element
/// of its own layout (<c>0x21000011</c>), so its parent is the
/// viewport — <c>pos(0,0)</c> plus edge codes <c>L3/R3</c> (centered)
/// and <c>T1</c> (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
/// <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). POSITION and FONT were resolved as AUTHORED at
/// Campaign CH user-gate round 4 (2026-08-10) — see the class remarks and
/// the <see cref="TopOffset"/>/<see cref="RetailFontId"/> comments. The
/// AUTHORED value for THIS element's state <c>0x10000002</c> is actually
/// pure red <c>ARGB(255,255,0,0)</c> — but the user's live retail
/// screenshot shows gold/amber, not red, so the user's own eye remains
/// the axiom here (per <c>feedback_retail_oracle_no_whack_a_mole</c>).
/// Round 4 checked whether font <c>0x40000001</c>'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 <c>PFID_A8</c>,
/// 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 <c>m_curFontColor</c> would resolve the remaining gap.
/// </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,
// 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);
}
/// <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);
}
}