The LA8 char-select root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=
BottomEdge=0 ("no anchor") in the installed DAT — confirmed via the new
CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf
gate — so retail's own UIElement::UpdateForParentSizeChange (0x00462640) never
resizes this element; it stays a fixed 800x600 rect in retail's own tree.
Retail's generic UI sprite blit, Graphic::Draw (0x00693b20) dispatching to
Graphic::PutImage (0x00693a30) for an exact/undersized destination or a
modulo-wrapped tile loop otherwise, has no third "stretch" mode — confirmed
against BlitMode (acclient.h ~3135) and MD_Data_Image::m_drawMode/DrawModeType,
both COLOR-blend selectors, not tile-vs-stretch geometry modes. The prior
"Normal -> tile, matching ImgTex::TileCSI" citation in UiDatElement was a
mis-attribution: ImgTex::TileCSI (0x0053e740) is called exclusively from
TexMerge::CopyAndTile/ImgTex::CopyCSI for LAND-SURFACE terrain texture
compositing, never from the UI element system.
Given the dat authors zero resize anchors and the blitter can only copy or
tile, the only way retail's whole pre-world scene (background + buttons +
listbox together) fills an arbitrary window resolution is that these
fixed-canvas "flow" screens render at 800x600 and the WHOLE FRAME is
stretched once at presentation — outside the UI sprite system entirely.
acdream has no offscreen fixed-resolution UI render target / present-time
scale pass; CharacterManagementUiController's constructor instead resizes
the MOUNTED ROOT element itself to the live viewport, which is why its own
background tiled (Width/tw > 1 at any resolution above 800x600, wrapped by
GL_REPEAT).
Fix: UiDatElement gains StretchOwnBackgroundToFill (default false, every
ordinary chrome/container element keeps tiling) — when set, the element's
own DirectState background draws as one UV-0..1 quad instead of the native
tile formula. CharacterManagementUiController sets it on Root right where
Root is resized to the host viewport, reaching the same visual result as
retail's present-time stretch (no tiling, no aspect-preserving letterbox)
through a different mechanism. Divergence register row AD-98 records the
substitution.
Tests: three new UiDatElementTests pin the UV-span mechanism generically
(tile past 1.0 when unset and rect exceeds native size; clamped to 1.0 when
set; byte-identical to the old tile formula when rect equals native size,
so every unaffected panel is untouched). CharacterManagementUiControllerTests
pins Root.StretchOwnBackgroundToFill == true post-construction. The live-DAT
gate confirms the root's zero edge-anchors and Type=3 against the installed
DAT. AcDream.App.Tests: 5084 passed / 3 skipped with ACDREAM_PROBE_LIVE_MOUNT=1
(5081/6 skipped without it — the 3 live-DAT-gated tests skip).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
347 lines
15 KiB
C#
347 lines
15 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");
|
|
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());
|
|
|
|
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"));
|
|
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 <see cref="AcDream.App.UI.Layout.UiDatElement.StretchOwnBackgroundToFill"/>:
|
|
/// 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 instead resizes the MOUNTED root itself (CharacterManagementUiController's
|
|
/// constructor) to reach the same visual fill, which is why the background needs
|
|
/// its own explicit stretch flag rather than an authored draw-mode bit.
|
|
/// </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}'.";
|
|
}
|
|
}
|