Applies the seven NITs from the CH2 re-review (verdict APPROVE-WITH-FIXES, following the REJECT->rework ate0e78883): 1. SpewBoxController's centered Left was captured once via AnchorEdges.Top and replayed forever on resize (UiElement.ApplyAnchor's Left/Right-both-false branch pins a fixed margin). Anchors is now AnchorEdges.None and Tick recomputes Left every frame against the current root width. 2. OneLine=false was defaulting to UiText's bottom-pinned transcript flow (VerticalJustify honored only via ConfigureDatState, which this synthesized element never calls). Added UiText.HonorVerticalJustification so a non-DAT controller can opt the scrollable path into VerticalJustify without a full LayoutDesc binding; SpewBoxController sets VerticalJustify=Top so lines flow from the top of the 450x72 box, matching newest-at-top insert semantics. Noted as invented-pending- measurement in AP-178's row (no new row). 3. Documented the deliberate inversion of UiText.LinesProvider's oldest-first contract in SpewBoxController.Tick (SpewBoxVM.Lines feeds newest-first, which is correct specifically because the box is now top-aligned) and added a test pinning the rendered order (newer message is the topmost line), driving root.Tick. 4. Fixed the stale "retail's code default, 1" comment in SpewBoxControllerTests — MaxConcurrentItems is the shipped LayoutDesc's AUTHORED value, 4. 5. Added the matching unmapped-id diagnostics line to LiveSessionRuntimeFactory's ShowWeenieError sink, matching the pattern GameEventWiring's WeenieError/WeenieErrorWithString handlers already use. 6. Corrected the "EXHAUSTIVE Portal sweep found ZERO" overclaim in SpewBoxLayoutDumpDiagnostic: the loop's 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. Corrected the same overclaim echoed in SpewBoxState's MaxConcurrentItems doc comment and in AP-178's register text (both the table row and the section-header history line). What's actually established: dats.Local hosts the SpewBox layout at 0x21000011; whether Portal also carries a copy remains unestablished. 7. Added a test exercising the full ShowWeenieError -> AddText -> SpewBox path for id 0x0561 (the 50-friends-cap refusal) in LiveSessionCommandRouterTests, mirroring LiveSessionRuntimeFactory's ShowWeenieError closure exactly since every other LiveSessionRuntimeFactory test in this tree is a source-text conformance grep, not an instantiation. Ledger: CH2 ledger row's review column now reads REJECT -> reworkede0e78883-> re-review APPROVE-WITH-FIXES -> nits (this commit); Status header flips CH2 to code-complete/closed pending the user gate, CH3 next. Build green; touched-project tests green (19/19 new/changed, 4351/3354 App.Tests unaffected pass); full Release suite 11,916 passed / 4 skipped / 0 failed (baseline 11,914/4/0 plus the two new tests this commit adds). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 lines
7.7 KiB
C#
176 lines
7.7 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
|
|
namespace AcDream.Core.Chat;
|
|
|
|
/// <summary>
|
|
/// One visible SpewBox line: display text plus the caller-clock timestamp
|
|
/// (seconds) at which it should be pruned.
|
|
/// </summary>
|
|
public readonly record struct SpewBoxEntry(string Text, double ExpiresAtSeconds);
|
|
|
|
/// <summary>
|
|
/// Retail's transient on-screen "interface text" — a direct port of
|
|
/// <c>gmSpewBoxUI</c>'s pending/visible split
|
|
/// (<c>docs/research/2026-08-09-chat-retail-interface-text.md</c> §1.1/§3.1).
|
|
/// Pure state, no presentation.
|
|
///
|
|
/// <para>
|
|
/// <b>Placement note (deviation from the CH2 brief):</b> the brief names
|
|
/// <c>AcDream.Runtime</c> as this type's home. It lives in
|
|
/// <c>AcDream.Core.Chat</c> instead, directly beside <see cref="ChatLog"/>,
|
|
/// because <c>AcDream.UI.Abstractions</c> (Code Structure Rule 3 — panels/
|
|
/// ViewModels target UI.Abstractions only) references <c>AcDream.Core</c>
|
|
/// but NOT <c>AcDream.Runtime</c>, and the UI.Abstractions
|
|
/// <c>SpewBoxVM</c> needs to wrap this type directly — exactly the same
|
|
/// constraint <see cref="ChatVM"/> already satisfies by wrapping
|
|
/// <see cref="ChatLog"/> (also Core, not Runtime). <c>RuntimeCommunicationState</c>
|
|
/// still owns the canonical instance and is still the sole writer via its
|
|
/// <c>AddText</c> router, matching every other J4-era Runtime-owns/App-or-
|
|
/// UI.Abstractions-borrows pattern in this codebase.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Retail decouples enqueue (<c>RecvNotice_DisplayFinalStringInfo
|
|
/// @0x004D60A0</c>, type-filtered to <c>0x1A</c> only) from display
|
|
/// (<c>Update @0x004D5DF0</c>, driven once per UI tick by global message
|
|
/// <c>3</c>) — 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. <see cref="Tick"/>
|
|
/// reproduces the SAME-CALL shape: it drains whatever is pending into the
|
|
/// visible list (applying retail's dedupe-against-index-0 and
|
|
/// <c>MaxConcurrentItems</c> overflow rules) and prunes expired entries in
|
|
/// one call, so a caller invoking <see cref="Enqueue"/> then immediately
|
|
/// <see cref="Tick"/> and <see cref="Snapshot"/> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class SpewBoxState
|
|
{
|
|
/// <summary>
|
|
/// The shipped LayoutDesc's AUTHORED value — no longer a placeholder.
|
|
/// CH2 REJECT-review rework, NIT 3
|
|
/// (<c>docs/research/2026-08-09-ch2-review-findings.md</c>), wording
|
|
/// corrected at the CH2 re-review nits pass
|
|
/// (<c>docs/plans/2026-08-09-chat-parity-campaign.md</c>, nit 6): the
|
|
/// original C.7 sweep's <c>dats.Portal</c> pass was not a meaningful
|
|
/// search — its id source was <c>DatCollection</c>'s top-level
|
|
/// AGGREGATE <c>GetAllIdsOfType<LayoutDesc>()</c>, not
|
|
/// <c>dats.Portal</c>'s own (which reports a count of ZERO for this
|
|
/// type), so querying those ids against <c>dats.Portal.TryGet</c>
|
|
/// established nothing about Portal either way. Extending the sweep
|
|
/// to <c>dats.Local</c> (<c>client_local_English.dat</c>) — this time
|
|
/// with a correctly-paired id source — found it: LayoutDesc
|
|
/// <c>0x21000011</c>, element <c>0x10000048</c>, whose sole child
|
|
/// (ListBox <c>0x10000049</c>, matching <c>gmSpewBoxUI::PostInit</c>'s
|
|
/// <c>GetChildRecursive(0x10000049)</c> call verbatim) carries ListBox
|
|
/// property <c>0x10000028</c> = the integer <c>4</c>. Retail's own
|
|
/// code default (<c>gmSpewBoxUI::PostInit @0x004D5AB0</c>), used only
|
|
/// when this property is absent or unreadable, was <c>1</c> — the
|
|
/// shipped layout overrides it with <c>4</c>. See
|
|
/// <c>SpewBoxLayoutDumpDiagnostic</c>'s own corrected RESULT comment
|
|
/// for the full id-source analysis.
|
|
/// </summary>
|
|
public const int MaxConcurrentItems = 4;
|
|
|
|
/// <summary>
|
|
/// Retail's own client never raises the expiry element message
|
|
/// (<c>0x10000003</c>) anywhere in the Sept 2013 EoR build — the real
|
|
/// per-line timeout is owned by keystone.dll's authored behaviour for
|
|
/// layout <c>0x10000012</c> element <c>0x1000004A</c> 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.
|
|
/// </summary>
|
|
public static readonly TimeSpan DefaultLifetime = TimeSpan.FromSeconds(5);
|
|
|
|
private readonly object _gate = new();
|
|
private readonly Queue<string> _pending = new();
|
|
private readonly List<SpewBoxEntry> _visible = new();
|
|
private long _revision;
|
|
|
|
/// <summary>Monotonic content revision — advances on any Tick that changes the visible set, and on Reset.</summary>
|
|
public long Revision => Interlocked.Read(ref _revision);
|
|
|
|
/// <summary>Number of currently visible lines (0..<see cref="MaxConcurrentItems"/>).</summary>
|
|
public int Count
|
|
{
|
|
get { lock (_gate) return _visible.Count; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enqueue text for display — retail's
|
|
/// <c>RecvNotice_DisplayFinalStringInfo</c> type-<c>0x1A</c> branch.
|
|
/// Does not itself become visible until the next <see cref="Tick"/>.
|
|
/// </summary>
|
|
public void Enqueue(string text)
|
|
{
|
|
lock (_gate)
|
|
_pending.Enqueue(text);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drain any pending text into the visible list and prune expired
|
|
/// entries. Call once per UI frame/tick (retail's global message
|
|
/// <c>3</c>, <c>gmSpewBoxUI::Update</c>).
|
|
/// </summary>
|
|
/// <param name="nowSeconds">
|
|
/// 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.
|
|
/// </param>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Snapshot of currently visible lines, newest first.</summary>
|
|
public SpewBoxEntry[] Snapshot()
|
|
{
|
|
lock (_gate)
|
|
return _visible.ToArray();
|
|
}
|
|
|
|
/// <summary>Clear all pending and visible state — session/generation reset.</summary>
|
|
public void Reset()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
_pending.Clear();
|
|
_visible.Clear();
|
|
}
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
}
|