Root cause: the LA8 character-select screen's root background RenderSurface
(0x06007576, LayoutDesc 0x21000004 element 0x1000039A) is PFID_CUSTOM_RAW_JPEG
— a complete JFIF byte stream (confirmed live: 414,230 bytes, FFD8...FFD9,
Width=0/Height=0 on disk) that SurfaceDecoder.DecodeRenderSurface had no case
for, so it fell through the switch's `_ => DecodedTexture.Magenta` default arm
with nothing logged. Retail's RenderSurface::CreateFromSourceData
(named-retail decomp @0x004440a0) hands this exact byte stream to the Intel
JPEG Library (`_ijlInit`/`_ijlRead`/`_ijlFree`) at runtime and reads the real
pixel dimensions from the JPEG's own SOF header rather than this
RenderSurface's Width/Height fields, which are legitimately 0 for this
format — the same reason the decoder's generic non-positive-Width/Height
guard was also wrong to apply here.
A per-id media sweep of the installed DAT (new EveryDeclaredMediaId_
ResolvesToADecodableTexture test) showed this was the ONLY unresolved id
among the screen's 25 distinct media ids — the listbox (0x1000039D) and every
button face resolve fine. The listbox interior and the ENTER button's
circular fill are both transparent regions layered on top of the root, so
the one broken root background bled through everywhere nothing opaque
covered it, producing all three symptoms (full-screen background, listbox
interior, ENTER circle) from one cause.
Fix: SurfaceDecoder now special-cases PFID_CUSTOM_RAW_JPEG before the
Width/Height guard and decodes it with StbImageSharp (dual Unlicense/MIT,
pure managed, no native dependency — works on the Linux headless/graphical
targets Slice K/L commit to). JPEG is ITU T.81-standardized, so any
conforming decoder reproduces the pixels IJL would; round-tripped a
synthetic fixture through the real decode path to confirm. Verified against
the live DAT: 0x06007576 now decodes to 800x600, exactly the screen's
LayoutDesc-authored size.
Guard: per claude-memory/feedback_ui_resolve_zero_magenta.md, an unresolved
id reaching the draw path should be loud. That memory's existing guard
("guard on the id, not the handle") only covers a DIFFERENT trap — a
zero/absent id — and could not have caught this one, which has a real,
non-zero, DAT-resolved id. No guard existed for "id resolves but can't
decode" or "id doesn't exist in either dat" before this change, so both were
silent. SurfaceDecoder now logs once per surface id on every magenta-return
path (null data, JPEG decode failure, unsupported format, no-palette
paletted format, decode exception); TextureCache.GetOrUploadRenderSurface
logs once per id when a RenderSurface isn't found in Portal or HighRes at
all.
Tests: CharacterManagementLiveDatTests.EveryDeclaredMediaId_
ResolvesToADecodableTexture (installed-DAT gate, ACDREAM_PROBE_LIVE_MOUNT=1)
sweeps every StateMedia id in the char-select root + listbox row template
and asserts none decode to the magenta placeholder — this class of gap now
fails the gate instead of shipping silently. SurfaceDecoderTests adds
PFID_CUSTOM_RAW_JPEG coverage (real decode via a synthetic from-scratch
JPEG fixture — not retail art, generated with StbImageWriteSharp and
round-tripped before being pasted in as a literal; corrupt-data and
null-SourceData magenta paths) plus PFID_P8/PFID_INDEX16 no-palette cases
that now flow through the same logged path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
304 lines
13 KiB
C#
304 lines
13 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));
|
|
}
|
|
|
|
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}'.";
|
|
}
|
|
}
|