acdream/tests/AcDream.App.Tests/UI/SpewBoxLayoutDumpDiagnostic.cs
Erik e0e7888308 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>
2026-08-09 18:14:26 +02:00

315 lines
15 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;
using System.Collections.Generic;
using System.Linq;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Campaign CH slice CH2, task C.7: a one-shot diagnostic sweep of every
/// installed <see cref="LayoutDesc"/> for an element of retail's SpewBox
/// class (<c>0x10000016</c> — <c>gmSpewBoxUI::GetUIElementType @0x004D5AA0</c>).
/// Not a gate/conformance test — just a discovery tool, per the research
/// doc's §8.1/§8.2 open question. Skips (does not fail) when the installed
/// DAT directory is unavailable, matching every other
/// <c>ContentConformanceDats.ResolveDatDir()</c>-gated diagnostic in this
/// tree.
///
/// <para>
/// <b>RESULT (2026-08-09, run against the installed <c>client_portal.dat</c>):</b>
/// the installed DAT's entire LayoutDesc id space is the CLOSED range
/// <c>0x21000000</c>-<c>0x21000075</c> (118 possible ids, 101 populated —
/// sanity-checked by confirming 3 independently-known ids, 0x2100002E
/// character window / 0x21000023 inventory / 0x21000016 toolbar prototype,
/// are all present). The sweep is therefore EXHAUSTIVE, and it finds
/// <b>ZERO</b> elements of class <c>0x10000016</c> anywhere in any of the
/// 101 layouts, and LayoutDesc <c>0x10000012</c> — the id the decompiled
/// <c>CreateChildElementByEnum(null, 0x10000012, 0x1000004A)</c> call
/// appears to reference — exists but has zero top-level elements (it is
/// not the per-line template catalog; that id must resolve through a
/// different mechanism than a direct <c>dats.Get&lt;LayoutDesc&gt;</c> hit,
/// which this slice did not crack).
/// </para>
///
/// <para>
/// <b>RESULT 2, CH2 REJECT-review rework NIT 3
/// (2026-08-09, docs/research/2026-08-09-ch2-review-findings.md):</b> the
/// FIRST result above only ever swept <c>dats.Portal</c>. Extending the
/// IDENTICAL sweep to <c>dats.Local</c> (<c>client_local_English.dat</c>)
/// FINDS it: LayoutDesc <c>0x21000011</c>, element <c>0x10000048</c>
/// (class <c>0x10000016</c>), position <c>(0,0)</c> relative to its parent,
/// size <c>450×72</c>, one child — ListBox <c>0x10000049</c> (matching
/// <c>gmSpewBoxUI::PostInit</c>'s <c>GetChildRecursive(0x10000049)</c> call
/// verbatim, though its own widget-class <c>Type</c> is <c>0x00000005</c>,
/// NOT the id <c>0x10000049</c> — <c>GetChildRecursive</c> searches by
/// <c>ElementId</c>, not <c>Type</c>) — carrying <c>MaxConcurrentItems</c>
/// (property <c>0x10000028</c>) = <c>4</c>. <c>gmSpewBoxUI</c> IS
/// dat-authored after all; the earlier "mounted directly from C++ code...
/// not resolved from any authored LayoutDesc tree" conclusion was an
/// artifact of only having checked one of the two locale-bearing dats.
/// Extent and <c>MaxConcurrentItems</c> are now AUTHORED, not invented —
/// see <see cref="AcDream.Core.Chat.SpewBoxState.MaxConcurrentItems"/> and
/// <c>SpewBoxController</c>'s own doc comments, and register row AP-178.
/// Absolute screen position (the element's parent, hence its true screen
/// offset, is still unidentified) and colour (no colour property surfaced
/// in this element's direct-state dump; the per-<c>UIStateId</c>
/// <c>States</c> dictionary was not walked) remain open.
/// </para>
/// </summary>
public sealed class SpewBoxLayoutDumpDiagnostic
{
private readonly ITestOutputHelper _out;
public SpewBoxLayoutDumpDiagnostic(ITestOutputHelper output) => _out = output;
private const uint SpewBoxElementClass = 0x10000016u;
private const uint ListBoxElementClass = 0x10000049u;
private const uint LineTemplateLayoutEnum = 0x10000012u;
private const uint LineTemplateElementId = 0x1000004Au;
private const uint ListBoxMaxItemsProperty = 0x10000028u;
private static string? ResolveDatDir()
{
var fromEnv = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && System.IO.Directory.Exists(fromEnv))
return fromEnv;
var def = System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return System.IO.Directory.Exists(def) ? def : null;
}
[Fact]
public void SweepInstalledLayoutDescs_ForSpewBoxElementClass()
{
string? datDir = ResolveDatDir();
if (datDir is null)
{
_out.WriteLine("SKIP: installed retail DAT directory is unavailable.");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
int scanned = 0;
var hits = new List<(uint LayoutId, ElementDesc Element)>();
var allIds = dats.GetAllIdsOfType<LayoutDesc>().ToList();
_out.WriteLine($"Portal.GetAllIdsOfType<LayoutDesc> count: {dats.Portal.GetAllIdsOfType<LayoutDesc>().Count()}");
_out.WriteLine($"HighRes.GetAllIdsOfType<LayoutDesc> count: {dats.HighRes.GetAllIdsOfType<LayoutDesc>().Count()}");
_out.WriteLine($"Contains known char-window 0x2100002E: {allIds.Contains(0x2100002Eu)}");
_out.WriteLine($"Contains known inventory 0x21000023: {allIds.Contains(0x21000023u)}");
_out.WriteLine($"Contains known toolbar 0x21000016: {allIds.Contains(0x21000016u)}");
_out.WriteLine($"Min id: 0x{allIds.Min():X8} Max id: 0x{allIds.Max():X8}");
foreach (uint layoutId in allIds)
{
scanned++;
if (!dats.Portal.TryGet<LayoutDesc>(layoutId, out LayoutDesc? ld) || ld is null)
continue;
foreach (var kv in ld.Elements)
{
var found = FindByType(kv.Value, SpewBoxElementClass);
if (found is not null)
hits.Add((layoutId, found));
}
}
_out.WriteLine($"Scanned {scanned} installed LayoutDescs.");
_out.WriteLine($"Elements of class 0x{SpewBoxElementClass:X8} (gmSpewBoxUI): {hits.Count}");
foreach (var (layoutId, element) in hits)
{
_out.WriteLine(
$" LayoutDesc 0x{layoutId:X8} -> element 0x{element.ElementId:X8} "
+ $"pos=({element.X},{element.Y}) size=({element.Width}x{element.Height}) "
+ $"zLevel={element.ZLevel} readOrder={element.ReadOrder} "
+ $"children={element.Children.Count}");
var listBox = FindByType(element, ListBoxElementClass);
if (listBox is not null)
{
_out.WriteLine(
$" ListBox child 0x{listBox.ElementId:X8} "
+ $"pos=({listBox.X},{listBox.Y}) size=({listBox.Width}x{listBox.Height})");
DumpProperties(listBox, " ", _out.WriteLine);
}
else
{
_out.WriteLine(" (no ListBox child of class 0x10000049 found)");
}
}
// Also probe the presumed shared line-template catalog directly —
// gmSpewBoxUI::Update's CreateChildElementByEnum(null, 0x10000012,
// 0x1000004A) call implies LayoutDesc 0x10000012 hosts a template
// catalog with a top-level entry 0x1000004A.
if (dats.Portal.TryGet<LayoutDesc>(LineTemplateLayoutEnum, out LayoutDesc? templateLd)
&& templateLd is not null)
{
_out.WriteLine($"LayoutDesc 0x{LineTemplateLayoutEnum:X8} exists ({templateLd.Elements.Count} top-level elements).");
if (templateLd.Elements.TryGetValue(LineTemplateElementId, out ElementDesc? lineTemplate))
{
_out.WriteLine(
$" Line template 0x{LineTemplateElementId:X8}: type=0x{lineTemplate.Type:X8} "
+ $"pos=({lineTemplate.X},{lineTemplate.Y}) size=({lineTemplate.Width}x{lineTemplate.Height})");
DumpProperties(lineTemplate, " ", _out.WriteLine);
}
else
{
_out.WriteLine($" No top-level element 0x{LineTemplateElementId:X8} in that LayoutDesc.");
}
}
else
{
_out.WriteLine($"LayoutDesc 0x{LineTemplateLayoutEnum:X8} does not exist in the installed DAT.");
}
// CH2 REJECT-review rework, NIT 3
// (docs/research/2026-08-09-ch2-review-findings.md): the original
// sweep above never consulted dats.Local (client_local_English.dat
// — research doc §8.1 names this file, not client_portal.dat, as
// the one that might carry locale-specific UI text/layout
// resources). Repeat the exact same class-0x10000016 sweep against
// Local's own LayoutDesc id space before trusting AP-178's
// "not dat-authored, no LayoutDesc dump can recover this" wording.
var localHits = new List<(uint LayoutId, ElementDesc Element)>();
int localScanned = 0;
List<uint> localIds = dats.Local.GetAllIdsOfType<LayoutDesc>().ToList();
_out.WriteLine($"Local.GetAllIdsOfType<LayoutDesc> count: {localIds.Count}");
if (localIds.Count > 0)
_out.WriteLine($"Local min id: 0x{localIds.Min():X8} Local max id: 0x{localIds.Max():X8}");
foreach (uint layoutId in localIds)
{
localScanned++;
if (!dats.Local.TryGet<LayoutDesc>(layoutId, out LayoutDesc? ld) || ld is null)
continue;
foreach (var kv in ld.Elements)
{
var found = FindByType(kv.Value, SpewBoxElementClass);
if (found is not null)
localHits.Add((layoutId, found));
}
}
_out.WriteLine($"Scanned {localScanned} dats.Local LayoutDescs.");
_out.WriteLine(
$"dats.Local elements of class 0x{SpewBoxElementClass:X8} (gmSpewBoxUI): {localHits.Count}");
foreach (var (layoutId, element) in localHits)
{
_out.WriteLine(
$" [Local] LayoutDesc 0x{layoutId:X8} -> element 0x{element.ElementId:X8} "
+ $"pos=({element.X},{element.Y}) size=({element.Width}x{element.Height}) "
+ $"zLevel={element.ZLevel} readOrder={element.ReadOrder} "
+ $"leftEdge={element.LeftEdge} topEdge={element.TopEdge} "
+ $"rightEdge={element.RightEdge} bottomEdge={element.BottomEdge} "
+ $"baseElement=0x{element.BaseElement:X8} baseLayoutId=0x{element.BaseLayoutId:X8} "
+ $"children={element.Children.Count}");
DumpProperties(element, " ", _out.WriteLine);
foreach (var (childId, child) in element.Children)
{
_out.WriteLine(
$" child 0x{childId:X8}: type=0x{child.Type:X8} "
+ $"pos=({child.X},{child.Y}) size=({child.Width}x{child.Height})");
DumpProperties(child, " ", _out.WriteLine);
}
// NIT 3 finding: gmSpewBoxUI::PostInit's GetChildRecursive(0x10000049)
// searches by ELEMENT ID, not by the widget CLASS (Type) —
// ListBoxElementClass below was the wrong axis to search on
// (it collided with the decomp's 0x10000049 constant, which is
// actually this instance's authored ElementId; the ListBox
// widget's own Type turned out to be 0x00000005). Look the
// child up directly by the ElementId the decomp names.
if (element.Children.TryGetValue(ListBoxElementClass, out ElementDesc? listBoxById))
{
_out.WriteLine(
$" [Local] ListBox-by-ElementId 0x{listBoxById.ElementId:X8} "
+ $"(Type=0x{listBoxById.Type:X8}) "
+ $"pos=({listBoxById.X},{listBoxById.Y}) size=({listBoxById.Width}x{listBoxById.Height})");
}
var listBox = FindByType(element, ListBoxElementClass);
if (listBox is not null)
{
_out.WriteLine(
$" [Local] ListBox-by-Type child 0x{listBox.ElementId:X8} "
+ $"pos=({listBox.X},{listBox.Y}) size=({listBox.Width}x{listBox.Height})");
DumpProperties(listBox, " ", _out.WriteLine);
}
}
if (dats.Local.TryGet<LayoutDesc>(LineTemplateLayoutEnum, out LayoutDesc? localTemplateLd)
&& localTemplateLd is not null)
{
_out.WriteLine(
$"[Local] LayoutDesc 0x{LineTemplateLayoutEnum:X8} exists "
+ $"({localTemplateLd.Elements.Count} top-level elements).");
}
else
{
_out.WriteLine($"[Local] LayoutDesc 0x{LineTemplateLayoutEnum:X8} does not exist.");
}
// Informational only — this is a discovery sweep, not a pass/fail gate.
// The findings are transcribed into WeenieErrorMessages/SpewBoxController
// doc comments and the divergence register by hand after reading this
// output; there is nothing here worth asserting on.
}
private static ElementDesc? FindByType(ElementDesc d, uint type)
{
if (d.Type == type)
return d;
foreach (var kv in d.Children)
{
var found = FindByType(kv.Value, type);
if (found is not null)
return found;
}
return null;
}
private static void DumpProperties(ElementDesc d, string indent, Action<string> write)
{
// NIT 3 fix (docs/research/2026-08-09-ch2-review-findings.md): this
// used to write to Console.WriteLine unconditionally, which xUnit's
// "Standard Output Messages" capture does NOT show — a latent bug
// that only mattered once a hit with an actual property to dump
// existed (the original Portal-only sweep found none). Routed
// through the caller's ITestOutputHelper.WriteLine so a future run
// actually surfaces this.
if (d.StateDesc?.Properties is null)
{
write($"{indent}(no direct-state properties)");
return;
}
foreach (var (propertyId, property) in d.StateDesc.Properties)
{
write($"{indent}property 0x{propertyId:X8} = {Describe(property)}"
+ (propertyId == ListBoxMaxItemsProperty ? " <-- MaxConcurrentItems" : ""));
}
}
private static string Describe(object property) => property switch
{
DatReaderWriter.Types.EnumBaseProperty e => $"Enum({e.Value})",
DatReaderWriter.Types.DataIdBaseProperty did => $"DataId(0x{did.Value:X8})",
DatReaderWriter.Types.ArrayBaseProperty arr => $"Array[{arr.Value.Count}]({string.Join(", ", arr.Value.Select(Describe))})",
// NIT 3 fix: the original switch had no case for these two —
// Integer is exactly the type MaxConcurrentItems (property
// 0x10000028) uses, so without this case the diagnostic could
// find the property but never print its actual authored value.
DatReaderWriter.Types.IntegerBaseProperty i => $"Integer({i.Value})",
DatReaderWriter.Types.BoolBaseProperty b => $"Bool({b.Value})",
_ => property.ToString() ?? "?",
};
}