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>
315 lines
15 KiB
C#
315 lines
15 KiB
C#
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<LayoutDesc></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() ?? "?",
|
||
};
|
||
}
|