using System; using System.Collections.Generic; using System.Threading; namespace AcDream.Core.Chat; /// /// One visible SpewBox line: display text plus the caller-clock timestamp /// (seconds) at which it should be pruned. /// public readonly record struct SpewBoxEntry(string Text, double ExpiresAtSeconds); /// /// Retail's transient on-screen "interface text" — a direct port of /// gmSpewBoxUI's pending/visible split /// (docs/research/2026-08-09-chat-retail-interface-text.md §1.1/§3.1). /// Pure state, no presentation. /// /// /// Placement note (deviation from the CH2 brief): the brief names /// AcDream.Runtime as this type's home. It lives in /// AcDream.Core.Chat instead, directly beside , /// because AcDream.UI.Abstractions (Code Structure Rule 3 — panels/ /// ViewModels target UI.Abstractions only) references AcDream.Core /// but NOT AcDream.Runtime, and the UI.Abstractions /// SpewBoxVM needs to wrap this type directly — exactly the same /// constraint already satisfies by wrapping /// (also Core, not Runtime). RuntimeCommunicationState /// still owns the canonical instance and is still the sole writer via its /// AddText router, matching every other J4-era Runtime-owns/App-or- /// UI.Abstractions-borrows pattern in this codebase. /// /// /// /// Retail decouples enqueue (RecvNotice_DisplayFinalStringInfo /// @0x004D60A0, type-filtered to 0x1A only) from display /// (Update @0x004D5DF0, driven once per UI tick by global message /// 3) — NOT by exactly one frame (fixed 2026-08-09, CH2 REJECT-review /// rework NIT 5: the earlier wording overstated this). A message that /// arrives just before the tick fires waits ~0 frames; one that arrives /// just after waits nearly a full frame — retail's own gap is 0-1 frames, /// bounded by tick cadence, not a fixed one-frame delay. /// reproduces the SAME-CALL shape: it drains whatever is pending into the /// visible list (applying retail's dedupe-against-index-0 and /// MaxConcurrentItems overflow rules) and prunes expired entries in /// one call, so a caller invoking then immediately /// and in the same frame sees the /// line SAME-frame — the decoupling only shows up when the caller's own /// tick cadence spans multiple frames, exactly like retail's. /// /// public sealed class SpewBoxState { /// /// The shipped LayoutDesc's AUTHORED value — no longer a placeholder. /// CH2 REJECT-review rework, NIT 3 /// (docs/research/2026-08-09-ch2-review-findings.md), wording /// corrected at the CH2 re-review nits pass /// (docs/plans/2026-08-09-chat-parity-campaign.md, nit 6): the /// original C.7 sweep's dats.Portal pass was not a meaningful /// search — its id source was DatCollection's top-level /// AGGREGATE GetAllIdsOfType<LayoutDesc>(), not /// dats.Portal's own (which reports a count of ZERO for this /// type), so querying those ids against dats.Portal.TryGet /// established nothing about Portal either way. Extending the sweep /// to dats.Local (client_local_English.dat) — this time /// with a correctly-paired id source — found it: LayoutDesc /// 0x21000011, element 0x10000048, whose sole child /// (ListBox 0x10000049, matching gmSpewBoxUI::PostInit's /// GetChildRecursive(0x10000049) call verbatim) carries ListBox /// property 0x10000028 = the integer 4. Retail's own /// code default (gmSpewBoxUI::PostInit @0x004D5AB0), used only /// when this property is absent or unreadable, was 1 — the /// shipped layout overrides it with 4. See /// SpewBoxLayoutDumpDiagnostic's own corrected RESULT comment /// for the full id-source analysis. /// public const int MaxConcurrentItems = 4; /// /// Retail's own client never raises the expiry element message /// (0x10000003) anywhere in the Sept 2013 EoR build — the real /// per-line timeout is owned by keystone.dll's authored behaviour for /// layout 0x10000012 element 0x1000004A and was not /// measured in this slice (§3.2.1 of the research doc). This is an /// INVENTED placeholder, not a retail-measured value — see the /// divergence register. /// public static readonly TimeSpan DefaultLifetime = TimeSpan.FromSeconds(5); private readonly object _gate = new(); private readonly Queue _pending = new(); private readonly List _visible = new(); private long _revision; /// Monotonic content revision — advances on any Tick that changes the visible set, and on Reset. public long Revision => Interlocked.Read(ref _revision); /// Number of currently visible lines (0..). public int Count { get { lock (_gate) return _visible.Count; } } /// /// Enqueue text for display — retail's /// RecvNotice_DisplayFinalStringInfo type-0x1A branch. /// Does not itself become visible until the next . /// public void Enqueue(string text) { lock (_gate) _pending.Enqueue(text); } /// /// Drain any pending text into the visible list and prune expired /// entries. Call once per UI frame/tick (retail's global message /// 3, gmSpewBoxUI::Update). /// /// /// Caller's own monotonic clock, in seconds. Only used to stamp new /// entries' expiry and to prune old ones — never compared across /// different clock sources. /// public void Tick(double nowSeconds) { bool changed; lock (_gate) { changed = _pending.Count > 0; while (_pending.Count > 0) { string text = _pending.Dequeue(); // Dedupe against index 0 ONLY (retail: 0x004D5EF6-0x004D5F91) // — an identical repeat refreshes the newest line in place // instead of stacking a duplicate. if (_visible.Count > 0 && _visible[0].Text == text) _visible.RemoveAt(0); // Newest at the top — retail's InsertItem(item, 0). _visible.Insert(0, new SpewBoxEntry(text, nowSeconds + DefaultLifetime.TotalSeconds)); // Overflow drops the OLDEST (highest index) entry — retail's // DeleteItem(count - 1) when count > m_maxConcurrentItems. while (_visible.Count > MaxConcurrentItems) _visible.RemoveAt(_visible.Count - 1); } int removed = _visible.RemoveAll(e => e.ExpiresAtSeconds <= nowSeconds); changed |= removed > 0; } if (changed) Interlocked.Increment(ref _revision); } /// Snapshot of currently visible lines, newest first. public SpewBoxEntry[] Snapshot() { lock (_gate) return _visible.ToArray(); } /// Clear all pending and visible state — session/generation reset. public void Reset() { lock (_gate) { _pending.Clear(); _visible.Clear(); } Interlocked.Increment(ref _revision); } }