acdream/tests/AcDream.App.Tests/UI/SpewBoxLayoutDumpDiagnostic.cs

330 lines
16 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>),
/// WORDING CORRECTED at the CH2 re-review nits pass
/// (<c>docs/plans/2026-08-09-chat-parity-campaign.md</c>, nit 6):</b> this
/// pass's id source, <c>allIds</c> below, is
/// <c>dats.GetAllIdsOfType&lt;LayoutDesc&gt;()</c> — the top-level AGGREGATE
/// accessor — NOT <c>dats.Portal.GetAllIdsOfType&lt;LayoutDesc&gt;()</c>,
/// which the very next line prints and which reports a count of
/// <b>ZERO</b>. The loop below then queries those aggregate ids against
/// <c>dats.Portal.TryGet</c>, a database whose own id enumeration for this
/// type is empty — so the original "the sweep is therefore EXHAUSTIVE...
/// finds ZERO elements... anywhere" conclusion never actually established
/// anything about <c>dats.Portal</c>: it iterated ids sourced from
/// elsewhere against a database that cannot be enumerated the same way.
/// The id-space/sanity-check numbers below (CLOSED range
/// <c>0x21000000</c>-<c>0x21000075</c>, 118 possible ids, 101 populated,
/// 3 known ids present) describe THAT aggregate list, not Portal's own.
/// What IS established — from the separate <c>dats.Local</c> sweep, see
/// RESULT 2 below — is that <c>client_local_English.dat</c> hosts the
/// SpewBox layout at LayoutDesc <c>0x21000011</c>. Whether
/// <c>dats.Portal</c> ALSO carries a <c>0x10000016</c> element remains
/// UNESTABLISHED; this sweep did not meaningfully search it. 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>
[Trait("Purpose", "Diagnostic")]
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() ?? "?",
};
}