fix(chat): CH2 rework — SpewBox tick-driven visibility + binary-derived error table
Reworks Campaign CH slice CH2 per the REJECT-review findings doc (docs/research/2026-08-09-ch2-review-findings.md). BLOCKER 1 — SpewBoxController never rendered a line and leaked its pending queue. LinesProvider only ran through UiText.OnDraw, which gates on Visible — and the box started invisible, so the provider (the sole caller of SpewBoxState.Tick) never ran. Gave the controller an explicit per-frame Tick(now) driven by UiRoot's global-message-3 broadcast (a zero-size GlobalTimeSink child, the same pattern VendorUiController.DragOverGlobalTimeSink already uses), matching retail's gmSpewBoxUI::Update. LinesProvider now only returns the cache. Tests rewritten to drive root.Tick(...) instead of calling the provider directly, plus new coverage for visibility-without-a-draw, queue-drain-without-a-draw, and bounded-queue-across-many-ticks. BLOCKER 2 — re-derived the HandleFailureEvent routing table from the PDB-paired binary instead of the pseudo-C's ~33-char string previews. tools/pdb-extract/sweep_weenie_strings.py sweeps every push imm32 in VA 0x571990-0x575480, dereferences into .rdata/.data, and decodes the full UTF-16LE literal. Added the 5 ids dispatched via else-if (missed by case-label enumeration), resolved 0x4F8 (previously excluded), fixed 18 wrong strings (16 the review flagged + 2 more — 0x4E9 and 0x518 — an automated diff between every swept literal and the landed table found). Every changed row cross-checked against ACE's WeenieError/WeenieErrorWithString enum doc comments; both oracles agreed on every row, including a case where the review's own proposed text for the new 0x4E8 row was itself wrong (it was 0x4E9's text) — corrected via the else-if block's own instruction address plus the ACE cross-check. Pinned table count: 344 (338 + 5 + 0x4F8). SHOULD-FIX 1 — RuntimeCommunicationState.ResetSpewBox was dead code; folded into the ChatIdentity generation-reset stage (same lifetime boundary), with a reset assertion added to the existing populated-reset test. SHOULD-FIX 2 — AddText trimmed only the trailing end and invented an empty-string early return; retail's AddTextToScroll trims both ends (trim(&str, 1, 1, ws)) and has no empty guard. Both retired. SHOULD-FIX 3 — ShowWeenieError bypassed the AddText chokepoint via ChatLog.OnWeenieError (hardcoded LogTextType 0x00); routed through Communication.AddText(Resolve(code, param)) instead, and ChatLog.OnWeenieError is deleted — GameEventWiring's legacy no-router fallback now resolves + calls OnSystemMessage directly. SHOULD-FIX 4 — retail's HandleFailureEvent switch has no default case; an unmapped id now resolves to a null Text (silence toward the player) instead of the invented "WeenieError 0xNNNN" hex fallback, with a diagnostics-only console log line for the id. NITs — AP-TBD placeholders corrected to their real register rows (AP-178, not the unrelated AP-177 lifetime row); filed AP-180 for the windowId dual-destination gap and corrected three stale "lands with CH2" comments; extended SpewBoxLayoutDumpDiagnostic from dats.Portal to dats.Local and found the SpewBox element for real — LayoutDesc 0x21000011, element 0x10000048, size 450x72, MaxConcurrentItems (ListBox property 0x10000028) = 4, not retail's code default of 1. AP-178 narrowed accordingly; SpewBoxState.MaxConcurrentItems and SpewBoxController's extent/anchor/OneLine are now authored rather than placeholder (absolute screen position and colour remain open); fixed the "19 ids... lists 18" miscount by retiring the stale paragraph in the class doc rewrite; aligned the UseDone handler's silent-status check with the other two WeenieError handlers. Full Release suite: 11,914 passed / 4 skipped / 0 failed (build 0 errors). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
b3ba4c6663
commit
e0e7888308
22 changed files with 1164 additions and 336 deletions
|
|
@ -10,37 +10,98 @@ namespace AcDream.App.UI;
|
|||
/// <c>ClickThrough</c> <see cref="UiText"/> block at a high
|
||||
/// <see cref="UiElement.ZOrder"/>. Unlike that controller's single
|
||||
/// overwrite-only slot, this reads <see cref="SpewBoxVM"/>'s bounded,
|
||||
/// newest-on-top, per-entry-expiring queue every frame — the retained UI
|
||||
/// tree has no separate per-frame "Update(dt)" hook, so
|
||||
/// <see cref="UiText.LinesProvider"/> (already polled once per render pass)
|
||||
/// doubles as this controller's tick source.
|
||||
/// newest-on-top, per-entry-expiring queue every frame.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Position / font / colour / max-items are PLACEHOLDERS.</b> The task
|
||||
/// C.7 LayoutDesc dump (<c>SpewBoxLayoutDumpDiagnostic</c>) was attempted
|
||||
/// and completed EXHAUSTIVELY against the installed DAT's entire 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> anywhere — <c>gmSpewBoxUI</c> is
|
||||
/// mounted directly from C++ code, not resolved from any authored
|
||||
/// LayoutDesc tree, so its screen position/extent/font/colour and the
|
||||
/// authored <c>MaxConcurrentItems</c> ListBox property are simply not
|
||||
/// recoverable this way. See the divergence register rows this class cites
|
||||
/// for each specific placeholder.
|
||||
/// <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>
|
||||
internal sealed class SpewBoxController : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Register row AP-TBD (position/extent): retail's authored screen
|
||||
/// position for the SpewBox host is unknown (see class remarks); this
|
||||
/// centered-top placement is acdream's own choice, not a retail value.
|
||||
/// 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 <c>(0,0)</c> relative
|
||||
/// to a PARENT this sweep could not identify, so this centered-top
|
||||
/// placement remains acdream's own choice, not a resolved retail value.
|
||||
/// (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 = 60f;
|
||||
private const float BoxHeight = 40f;
|
||||
|
||||
/// <summary>
|
||||
/// Register row AP-TBD (colour): the chat colour table's <c>0x1A</c>
|
||||
/// entry (<c>colorBrightRed</c>) is explicitly NOT this — retail's own
|
||||
/// 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 — the constructor below anchors it that way
|
||||
/// (a one-time centered <see cref="UiText.Left"/> computed against
|
||||
/// <see cref="UiRoot.Width"/>, <see cref="AnchorEdges.Top"/> only) since
|
||||
/// <see cref="AnchorEdges"/> has no "centered, fixed-width" flag
|
||||
/// combination to express retail's mode 3 directly.
|
||||
/// </summary>
|
||||
private const float SpewBoxWidth = 450f;
|
||||
private const float SpewBoxHeight = 72f;
|
||||
|
||||
/// <summary>
|
||||
/// Register row AP-178 (colour): retail's authored colour for THIS
|
||||
/// element remains unresolved — the LayoutDesc dump (see class remarks)
|
||||
/// found only two direct-state properties on the SpewBox element/ListBox
|
||||
/// (a bool at <c>0x3B</c> and the <c>MaxConcurrentItems</c> integer at
|
||||
/// <c>0x10000028</c>); no colour property surfaced in that direct-state
|
||||
/// dump, and the per-<c>UIStateId</c> <c>States</c> dictionary (hover/
|
||||
/// pressed/etc. variants, which could carry it) was not walked this
|
||||
/// pass. The chat colour table's <c>0x1A</c> entry
|
||||
/// (<c>colorBrightRed</c>) is 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). This warm-yellow placeholder follows the
|
||||
|
|
@ -53,6 +114,8 @@ internal sealed class SpewBoxController : IDisposable
|
|||
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;
|
||||
|
||||
public SpewBoxController(UiRoot root, SpewBoxVM vm)
|
||||
|
|
@ -62,43 +125,63 @@ internal sealed class SpewBoxController : IDisposable
|
|||
_text = new UiText
|
||||
{
|
||||
Name = "SpewBox",
|
||||
Left = 0f,
|
||||
// Centered fixed-width block (retail's "mode 3" edge code on
|
||||
// both left and right) — see the AP-178 extent comment above.
|
||||
Left = (root.Width - SpewBoxWidth) / 2f,
|
||||
Top = TopOffset,
|
||||
Width = root.Width,
|
||||
Height = BoxHeight,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
|
||||
Width = SpewBoxWidth,
|
||||
Height = SpewBoxHeight,
|
||||
Anchors = AnchorEdges.Top,
|
||||
Centered = true,
|
||||
OneLine = true,
|
||||
// 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,
|
||||
ClickThrough = true,
|
||||
ZOrder = int.MaxValue,
|
||||
DefaultColor = SpewBoxColor,
|
||||
Visible = false,
|
||||
};
|
||||
_text.LinesProvider = ComputeLines;
|
||||
_text.LinesProvider = () => _lines;
|
||||
_root.AddChild(_text);
|
||||
|
||||
_timeSink = new GlobalTimeSink(Tick);
|
||||
_root.AddChild(_timeSink);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Polled once per render pass by <see cref="UiText"/> — this IS the
|
||||
/// SpewBox's per-frame tick (drains <c>SpewBoxState</c>'s pending queue
|
||||
/// and prunes expired entries; see <see cref="SpewBoxVM.Lines"/>).
|
||||
/// <see cref="SpewBoxVM.Lines"/> returns newest-first, matching retail's
|
||||
/// <c>InsertItem(item, 0)</c>; <c>OneLine</c> mode only ever draws
|
||||
/// index 0, so with retail's code-default
|
||||
/// <c>MaxConcurrentItems == 1</c> this always shows the current line.
|
||||
/// 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>
|
||||
private IReadOnlyList<UiText.Line> ComputeLines()
|
||||
/// <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)
|
||||
{
|
||||
double nowSeconds = Environment.TickCount64 / 1000.0;
|
||||
// SpewBoxVM.Lines returns newest-first, matching retail's
|
||||
// InsertItem(item, 0) — with OneLine now false and the AUTHORED
|
||||
// MaxConcurrentItems == 4 (see SpewBoxState.MaxConcurrentItems),
|
||||
// up to 4 lines render, newest on top.
|
||||
IReadOnlyList<SpewBoxLine> lines = _vm.Lines(nowSeconds);
|
||||
_text.Visible = lines.Count > 0;
|
||||
if (lines.Count == 0)
|
||||
return Array.Empty<UiText.Line>();
|
||||
{
|
||||
_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);
|
||||
return result;
|
||||
_lines = result;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
|
@ -107,6 +190,24 @@ internal sealed class SpewBoxController : IDisposable
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue