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>
383 lines
17 KiB
C#
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}'.";
|
|
}
|
|
}
|