Applies the seven NITs from the CH2 re-review (verdict APPROVE-WITH-FIXES, following the REJECT->rework ate0e78883): 1. SpewBoxController's centered Left was captured once via AnchorEdges.Top and replayed forever on resize (UiElement.ApplyAnchor's Left/Right-both-false branch pins a fixed margin). Anchors is now AnchorEdges.None and Tick recomputes Left every frame against the current root width. 2. OneLine=false was defaulting to UiText's bottom-pinned transcript flow (VerticalJustify honored only via ConfigureDatState, which this synthesized element never calls). Added UiText.HonorVerticalJustification so a non-DAT controller can opt the scrollable path into VerticalJustify without a full LayoutDesc binding; SpewBoxController sets VerticalJustify=Top so lines flow from the top of the 450x72 box, matching newest-at-top insert semantics. Noted as invented-pending- measurement in AP-178's row (no new row). 3. Documented the deliberate inversion of UiText.LinesProvider's oldest-first contract in SpewBoxController.Tick (SpewBoxVM.Lines feeds newest-first, which is correct specifically because the box is now top-aligned) and added a test pinning the rendered order (newer message is the topmost line), driving root.Tick. 4. Fixed the stale "retail's code default, 1" comment in SpewBoxControllerTests — MaxConcurrentItems is the shipped LayoutDesc's AUTHORED value, 4. 5. Added the matching unmapped-id diagnostics line to LiveSessionRuntimeFactory's ShowWeenieError sink, matching the pattern GameEventWiring's WeenieError/WeenieErrorWithString handlers already use. 6. Corrected the "EXHAUSTIVE Portal sweep found ZERO" overclaim in SpewBoxLayoutDumpDiagnostic: the loop's id source was DatCollection's top-level aggregate GetAllIdsOfType<LayoutDesc>(), not dats.Portal's own (which reports a count of ZERO for this type), so querying those ids against dats.Portal.TryGet established nothing about Portal either way. Corrected the same overclaim echoed in SpewBoxState's MaxConcurrentItems doc comment and in AP-178's register text (both the table row and the section-header history line). What's actually established: dats.Local hosts the SpewBox layout at 0x21000011; whether Portal also carries a copy remains unestablished. 7. Added a test exercising the full ShowWeenieError -> AddText -> SpewBox path for id 0x0561 (the 50-friends-cap refusal) in LiveSessionCommandRouterTests, mirroring LiveSessionRuntimeFactory's ShowWeenieError closure exactly since every other LiveSessionRuntimeFactory test in this tree is a source-text conformance grep, not an instantiation. Ledger: CH2 ledger row's review column now reads REJECT -> reworkede0e78883-> re-review APPROVE-WITH-FIXES -> nits (this commit); Status header flips CH2 to code-complete/closed pending the user gate, CH3 next. Build green; touched-project tests green (19/19 new/changed, 4351/3354 App.Tests unaffected pass); full Release suite 11,916 passed / 4 skipped / 0 failed (baseline 11,914/4/0 plus the two new tests this commit adds). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
329 lines
16 KiB
C#
329 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>
|
||
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() ?? "?",
|
||
};
|
||
}
|