acdream/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs
Erik 0baebce262 fix(ui,runtime): Campaign LA gate-round-2 batch-review fixes F1,F3-F8; file #401
F1 (MUST-FIX): RetailWaitDialogView was the ONE dialog view the 0a7dc7d6
EffectiveCanvasSize sweep missed - the Entering World wait dialog (fires
on ENTER, the char screen primary action) still centered against the raw
window and landed off the visible canvas. Same three-line fix as its three
siblings; the enter-wait test now grows the window over the fixed canvas
and asserts canvas-space centering.

F3/F4: two stale assertions about the DELETED first AD-98 substitution
(the register section-2 header line and the live-DAT oracle test doc) now
describe the completed FixedCanvasSize mechanism - the C4-closeout failure
mode, caught before it cost anything.

F5: RetailDialogData.Confirmation sets ElementAttribute40 itself (retail
MakeConfirmExitDialog writes 0x8E=1, 0xAC=1, 0xC5); the manual set in
GameplayConfirmationController is gone.

F6: MapWindowToCanvas truncates instead of rounding - rounding mapped the
window far edge one past the canvas last valid coordinate, a 1px dead
hit-test band; test updated to truncation semantics + far-edge case.

F7: AD-98 records that the no-letterbox aspect claim has no decomp
citation and is confirmed by the user live gate pass 2026-08-15.

F8: the durable world-name read in StartCore is IsCurrent-gated like every
neighbouring step.

F2 filed as #401 (invert RetailUi to opt-out - product-default decision,
not a gate fix).

App 5100+6 skips, Runtime 1666, green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 12:28:06 +02:00

383 lines
17 KiB
C#

using System.IO;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Content;
using AcDream.Core.Textures;
using DatReaderWriter;
using DatReaderWriter.Options;
using Palette = DatReaderWriter.DBObjs.Palette;
using RenderSurface = DatReaderWriter.DBObjs.RenderSurface;
using StringTable = DatReaderWriter.DBObjs.StringTable;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Installed-retail-DAT acceptance gate for LA8. Opt in with
/// <c>ACDREAM_PROBE_LIVE_MOUNT=1</c>; <c>ACDREAM_DAT_DIR</c> can override the
/// ordinary Documents/Asheron's Call location. Reads the DATs read-only.
/// </summary>
public sealed class CharacterManagementLiveDatTests
{
[InstalledDatFact]
public void EnumTable5_ResolvesAndImportsTheExactRetailScreenAndDialogs()
{
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
using var dats = new DatCollection(datDirectory, DatAccessType.Read);
const uint expectedLayoutDid = 0x21000004u;
uint layoutDid = RetailDataIdResolver.Resolve(
dats,
CharacterManagementUiController.RootEnum,
5u);
Assert.Equal(expectedLayoutDid, layoutDid);
Console.WriteLine(
"[LA8-DAT] category=5 enum=0x10000005 -> DID=0x21000004; "
+ "selected-root=0x1000039A");
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats,
layoutDid,
CharacterManagementUiController.RootElementId));
Assert.Equal(800f, rootInfo.Width);
Assert.Equal(600f, rootInfo.Height);
Assert.Equal(8, rootInfo.Children.Count);
Assert.Equal(0x06007576u, rootInfo.StateMedia[""].File);
ImportedLayout screen = LayoutImporter.Build(
rootInfo,
_ => (0u, 0, 0),
null,
null,
new DatStringResolver(dats).Resolve);
var list = Assert.IsType<UiTemplateListBox>(screen.FindElement(
CharacterManagementUiController.ListElementId));
UiTemplateListEntry template = Assert.Single(list.Templates);
Assert.Equal(expectedLayoutDid, template.TemplateLayoutId);
Assert.Equal(0x100003A5u, template.TemplateElementId);
AssertButton(screen, CharacterManagementUiController.CreateElementId,
"Create Character");
AssertButton(screen, CharacterManagementUiController.EnterElementId,
"ENTER");
AssertButton(screen, CharacterManagementUiController.DeleteElementId,
"DELETE");
AssertButton(screen, CharacterManagementUiController.RestoreElementId,
"RESTORE");
// Finding 1: retail's bottom-row Credits/Exit buttons (offsets 6/7
// from the listbox base in
// gmCharacterManagementUI::ListenToElementMessage@0x004ed5a0).
AssertButton(screen, CharacterManagementUiController.CreditsElementId,
"CREDITS");
AssertButton(screen, CharacterManagementUiController.ExitElementId,
"EXIT");
// Finding 3: the World box (retail element 0x1000039B, resolved via
// UpdateWorldName@0x004ec120) imports as a plain UiText the
// controller binds Runtime's ServerName-sourced snapshot field to.
Assert.IsType<UiText>(screen.FindElement(
CharacterManagementUiController.WorldTextElementId));
Assert.DoesNotContain(
Descendants(screen.Root),
static element => element is UiViewport);
ElementInfo rowInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(dats, layoutDid, template.TemplateElementId));
Assert.Equal(1u, rowInfo.Type);
Assert.Equal(160f, rowInfo.Width);
Assert.Equal(16f, rowInfo.Height);
Assert.Equal(0x40000009u, rowInfo.FontDid);
Assert.Equal(
[
UiButtonStateMachine.Normal,
UiButtonStateMachine.NormalRollover,
UiButtonStateMachine.NormalPressed,
UiButtonStateMachine.Highlight,
UiButtonStateMachine.HighlightRollover,
uint.MaxValue,
],
rowInfo.States.Keys.Order().ToArray());
// Campaign LA gate round 2 finding 2: the row template's OWN authored
// justify is Left (character names render left-aligned in retail, not
// centered) — it carries three stateful Type-3 highlight-art children
// (0x10000481-0x10000483, the Normal_rollover/Normal_pressed/Highlight/
// Highlight_rollover face art) and NO Type-12 caption child, so the row's
// Left justify can only come from ElementInfo.HJustify directly, never a
// lifted text child. DatWidgetFactory.BuildButton must honor it.
Assert.Equal(HJustify.Left, rowInfo.HJustify);
Assert.DoesNotContain(rowInfo.Children, static child => child.Type == 12u);
ImportedLayout? builtRowLayout = LayoutImporter.Import(
dats, template.TemplateLayoutId, template.TemplateElementId,
_ => (0u, 0, 0), null, null);
var builtRow = Assert.IsType<UiButton>(builtRowLayout!.Root);
Assert.Equal(UiButton.LabelAlignment.Left, builtRow.LabelAlign);
uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u);
Assert.Equal(0x2100003Cu, dialogDid);
ImportedLayout message = BuildSelected(dats, dialogDid, 0x24u);
Assert.IsType<UiDialogRoot>(message.Root);
Assert.IsType<UiText>(message.FindElement(0x3Eu));
Assert.IsType<UiButton>(message.FindElement(0x26u));
ImportedLayout delete = BuildSelected(dats, dialogDid, 0x2Cu);
Assert.IsType<UiDialogRoot>(delete.Root);
Assert.IsType<UiField>(delete.FindElement(0x2Cu));
Assert.IsType<UiButton>(delete.FindElement(0x2Eu));
Assert.IsType<UiButton>(delete.FindElement(0x2Fu));
var strings = new DatStringResolver(dats);
const uint table = 0x23000002u;
Assert.Equal("DELETE", Resolve(strings, table,
"ID_CharacterManagement_DeleteCharacterResponse"));
Assert.Equal("Please Wait", Resolve(strings, table,
"ID_CharacterManagement_PleaseWait"));
Assert.Equal("Entering World", Resolve(strings, table,
"ID_Character_EnteringWorld"));
// Finding 1: MakeConfirmExitDialog@0x004ed250's text
// (compute_str_hash("ID_CharacterManagement_ConfirmExit"), table
// enum 0x10000002 -> 0x23000002). The raw DAT string carries a
// literal two-character "\n" escape (this test's Resolve() helper
// does not normalize it — RetailUiRuntime does, via
// NormalizeRetailNewlines, before handing it to the controller).
Assert.Equal("Are you sure you want to leave?\\n", Resolve(strings, table,
"ID_CharacterManagement_ConfirmExit"));
string confirmation = Assert.IsType<string>(strings.ResolveTemplate(
table,
"ID_CharacterManagement_DeleteCharacterConfirmation",
new Dictionary<uint, string>
{
[DatStringResolver.PlayerVariable] = "Test Character",
}));
Assert.Contains("Test Character", confirmation);
Assert.Contains("'DELETE'", confirmation);
StringTable stringTable = Assert.IsType<StringTable>(dats.Get<StringTable>(table));
var deleteEntry = stringTable.Strings[
DatStringResolver.ComputeHash(
"ID_CharacterManagement_DeleteCharacterConfirmation")];
Assert.Equal([DatStringResolver.PlayerVariable], deleteEntry.Variables);
}
/// <summary>
/// Campaign LA gate round 2 regression gate: EVERY non-zero StateMedia image id
/// declared anywhere in the char-select screen's imported element tree (root +
/// descendants + the listbox row template) must resolve to a RenderSurface in
/// Portal/HighRes AND decode to something other than the 1x1 magenta placeholder.
/// This is the class of gap that shipped LA8's full-screen background, listbox
/// interior, and ENTER circular-fill magenta defect: root background 0x06007576
/// is PFID_CUSTOM_RAW_JPEG (a verbatim JFIF stream — see
/// <see cref="AcDream.Core.Textures.SurfaceDecoder"/>'s PFID_CUSTOM_RAW_JPEG
/// branch for the retail mechanism), which the decoder previously had no case for
/// and silently fell through to magenta. The listbox (0x1000039D) itself carries
/// no own background media — it is a transparent container, so the fix for the
/// ONE root id also cleared the listbox-interior and ENTER-circle symptoms (both
/// were the broken root bleeding through transparent regions on top of it), which
/// this test's per-id sweep proves by finding no OTHER magenta id.
/// </summary>
[InstalledDatFact]
public void EveryDeclaredMediaId_ResolvesToADecodableTexture()
{
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
using var dats = new DatCollection(datDirectory, DatAccessType.Read);
uint layoutDid = RetailDataIdResolver.Resolve(
dats,
CharacterManagementUiController.RootEnum,
5u);
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats,
layoutDid,
CharacterManagementUiController.RootElementId));
var ids = new SortedDictionary<uint, string>();
CollectMediaIds(rootInfo, "root", ids);
// Also walk the listbox's row template — it is imported separately by
// AddItemFromTemplateList/TemplateResolver, not as a root descendant.
ElementInfo? listInfo = FindById(rootInfo, CharacterManagementUiController.ListElementId);
Assert.NotNull(listInfo);
Assert.NotEmpty(listInfo!.TemplateList);
foreach (var entry in listInfo.TemplateList)
{
ElementInfo? rowInfo = LayoutImporter.ImportInfos(
dats, entry.TemplateLayoutId, entry.TemplateElementId);
Assert.NotNull(rowInfo);
CollectMediaIds(rowInfo!, "row-template", ids);
}
Console.WriteLine($"[LA8-DIAG] layout=0x{layoutDid:X8} distinct media ids={ids.Count}");
Assert.NotEmpty(ids);
// The known root-background id must actually be present in this sweep —
// otherwise the assertions below would vacuously pass without ever having
// exercised the surface that caused the defect.
Assert.Contains(0x06007576u, ids.Keys);
var unresolved = new List<string>();
foreach (var (id, where) in ids)
{
bool found = dats.Portal.TryGet<RenderSurface>(id, out RenderSurface? rs)
|| dats.HighRes.TryGet<RenderSurface>(id, out rs);
if (!found)
{
Console.WriteLine($"[LA8-DIAG] 0x{id:X8} ({where}): NOT FOUND in Portal or HighRes");
unresolved.Add($"0x{id:X8} ({where}): missing RenderSurface");
continue;
}
Palette? palette = rs!.DefaultPaletteId != 0
? dats.Get<Palette>(rs.DefaultPaletteId)
: null;
DecodedTexture decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette);
bool magenta = decoded.Width == 1 && decoded.Height == 1
&& decoded.Rgba8 is [0xFF, 0x00, 0xFF, 0xFF];
Console.WriteLine(
$"[LA8-DIAG] 0x{id:X8} ({where}): format={rs.Format} "
+ $"{rs.Width}x{rs.Height} defaultPalette=0x{rs.DefaultPaletteId:X8} "
+ $"paletteLoaded={(palette is not null)} decoded={decoded.Width}x{decoded.Height} "
+ $"magenta={magenta}");
if (magenta)
unresolved.Add(
$"0x{id:X8} ({where}): format={rs.Format} defaultPalette=0x{rs.DefaultPaletteId:X8} "
+ $"paletteLoaded={(palette is not null)}");
}
Assert.True(
unresolved.Count == 0,
"Media ids that resolved to the 1x1 magenta placeholder:\n"
+ string.Join('\n', unresolved));
}
/// <summary>
/// Campaign LA gate round 2 (background-tiling investigation): the raw dat
/// authors NO edge anchors at all on the char-select root (0x1000039A) — every
/// one of LeftEdge/TopEdge/RightEdge/BottomEdge is 0 ("no anchor" per
/// <c>UIElement::UpdateForParentSizeChange</c>, acclient 0x00462640). Retail's
/// own edge-anchor resize mechanism therefore NEVER touches this element's size;
/// it stays a fixed 800x600 rect in retail's own widget tree. This is the pivot
/// fact behind the completed AD-98 substitution
/// (<see cref="AcDream.App.UI.UiRoot.FixedCanvasSize"/>): since the dat itself
/// asks for no resize, whatever makes the char-select scene fill an arbitrary
/// window resolution in retail (background AND buttons AND listbox together)
/// cannot be a per-element anchor/draw-mode difference — it has to be an
/// out-of-band presentation-time scale of the whole fixed-size frame. acdream
/// therefore keeps the mounted root at its authored 800x600 extent and
/// stretches the ENTIRE canvas — widgets, glyphs, art — as one unit at the
/// renderer's quad chokepoint, with inverse mouse mapping.
/// </summary>
[InstalledDatFact]
public void RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf()
{
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
using var dats = new DatCollection(datDirectory, DatAccessType.Read);
uint layoutDid = RetailDataIdResolver.Resolve(
dats,
CharacterManagementUiController.RootEnum,
5u);
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats,
layoutDid,
CharacterManagementUiController.RootElementId));
Assert.Equal(0u, rootInfo.Left);
Assert.Equal(0u, rootInfo.Top);
Assert.Equal(0u, rootInfo.Right);
Assert.Equal(0u, rootInfo.Bottom);
Assert.Equal(3u, rootInfo.Type); // UIElement_Field — generic container, not a custom gm*UI class id
}
private static ElementInfo? FindById(ElementInfo info, uint id)
{
if (info.Id == id) return info;
foreach (ElementInfo child in info.Children)
{
ElementInfo? found = FindById(child, id);
if (found is not null) return found;
}
return null;
}
private static void CollectMediaIds(ElementInfo info, string where, SortedDictionary<uint, string> ids)
{
foreach (var (stateName, media) in info.StateMedia)
{
if (media.File == 0) continue;
string label = $"{where} elem=0x{info.Id:X8} type={info.Type} state='{stateName}'";
if (!ids.ContainsKey(media.File))
ids[media.File] = label;
else
ids[media.File] += " | " + label;
}
foreach (ElementInfo child in info.Children)
CollectMediaIds(child, where, ids);
}
private static ImportedLayout BuildSelected(
IDatReaderWriter dats,
uint layoutDid,
uint rootId)
{
ElementInfo info = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(dats, layoutDid, rootId));
return LayoutImporter.Build(
info,
_ => (0u, 0, 0),
null,
null,
new DatStringResolver(dats).Resolve);
}
private static string Resolve(
DatStringResolver strings,
uint table,
string key) => Assert.IsType<string>(strings.Resolve(
table,
DatStringResolver.ComputeHash(key)));
private static void AssertButton(
ImportedLayout layout,
uint elementId,
string label) => Assert.Equal(
label,
Assert.IsType<UiButton>(layout.FindElement(elementId)).Label);
private static IEnumerable<UiElement> Descendants(UiElement root)
{
yield return root;
foreach (UiElement child in root.Children)
foreach (UiElement descendant in Descendants(child))
yield return descendant;
}
}
internal sealed class InstalledDatFactAttribute : FactAttribute
{
public InstalledDatFactAttribute()
{
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1")
{
Skip = "Set ACDREAM_PROBE_LIVE_MOUNT=1 to run the installed-DAT LA8 gate.";
return;
}
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
if (!File.Exists(Path.Combine(datDirectory, "client_portal.dat")))
Skip = $"Installed client_portal.dat is required at '{datDirectory}'.";
}
}