331 lines
16 KiB
C#
331 lines
16 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>),
|
||
/// 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<LayoutDesc>()</c> — the top-level AGGREGATE
|
||
/// accessor — NOT <c>dats.Portal.GetAllIdsOfType<LayoutDesc>()</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<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>
|
||
[Trait("Purpose", "Diagnostic")]
|
||
[Trait("Lane", "InstalledDat")]
|
||
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() ?? "?",
|
||
};
|
||
}
|