Finding 1 (Exit button dead): retail's gmCharacterManagementUI Exit
button (element 0x100003A4, offset 7 from the listbox base in
ListenToElementMessage@0x004ed5a0) opens MakeConfirmExitDialog
(0x004ed250), whose exact ID_CharacterManagement_ConfirmExit text
(table 0x23000002) and m_confirmExitDialogContext re-entry guard are
now ported. On confirm (matching RecvNotice_CloseDialog@0x004ed760
case 1's ConfirmationResult check) the client exits through the
EXISTING graceful window-close seam (CharacterSelectionRuntimeBindings
.RequestExit -> d.Window.Close, the same delegate
GameplayInputCommandController's Escape fallback already uses) so
disconnected/exited status events still fire via GameWindow.OnClosing
-> CompleteShutdown. Retail's real post-confirm destination is
QueueUIMode(0x10000009) -> gmEpilogueUI, an epilogue screen this round
does not port — recorded as AD-99. Credits (element 0x100003A3,
QueueUIMode(0x10000005) -> gmCreditsUI) stays visibly ghosted like
Create, same treatment, out of scope this round.
Finding 2 (row names center-aligned, retail is left): the character
row template (LayoutDesc 0x21000004, element 0x100003A5, live-DAT
confirmed HJustify=Left with three stateful Type-3 highlight-art
children and no Type-12 caption child) authors its OWN justify
directly, with no separate text child to lift a label from.
DatWidgetFactory.BuildButton's Left-justify branch required
!ReferenceEquals(labelInfo, info) — true only when a label was LIFTED
from a distinct child — so a button's own direct HJustify=Left was
silently dropped to UiButton's Center default. Widened the branch to
also honor the direct case, preserving the existing lifted-child
LabelOffsetX behavior and leaving genuinely-centered buttons
(CREATE/ENTER/DELETE/RESTORE) untouched.
Finding 3 (World box empty): parsed ACE's GameMessageServerName
(opcode 0xF7E1, ACE.Server/Network/GameMessages/Messages/
GameMessageServerName.cs; retail CM_Login::DispatchUI_WorldInfo
@0x006ad860 -> ClientUISystem::Handle_Login__WorldInfo@0x005641a0 ->
ECM_Login::SendNotice_WorldName@0x00692b10, notice 0x186a2, consumed
by gmCharacterManagementUI::UpdateWorldName@0x004ec120 /
RecvNotice_WorldName@0x004ec360 onto element 0x1000039B) as
src/AcDream.Core.Net/Messages/ServerName.cs, cross-checked against
holtburger's ServerNameData. WorldSession.ServerNameReceived fires
alongside CharacterListReceived (ACE sends both in one
SendConnectResponse batch); RuntimeCharacterSelectionState.
ApplyWorldName is the new J-owner field (ungated by lifecycle, since
either message can arrive first); CharacterManagementUiController
binds it onto the WorldTextElementId UiText. Per the LA1 status
vocabulary, the characterList STATUS event's worldName field is
intentionally NOT added this round (kept bounded to the client-side
fix) — a follow-up if the launcher UI wants it.
Also corrects AD-44, discovered stale while filing AD-99: its opening
claim ("acdream has no retained character-management screen") was
false as of this session — LA7/LA8 shipped the screen in earlier
commits without updating this row.
Tests: exit-confirm open/cancel/confirm/re-entry-guard flow;
DatWidgetFactory own-HJustify-Left/Center regression tests plus the
live-DAT pinned row-justify assertion; ServerName parse round-trip
(byte-exact vs ACE's AceWireWriter fixture, truncation/wrong-opcode
cases); WorldSession dispatch test (roster+world in one wire batch);
RuntimeCharacterSelectionState.ApplyWorldName tests (order-independent
of ApplyRoster, unchanged-value no-op, Reset clears); controller test
binding the World text element to the live snapshot. Extended the
shared RetailDialogFactoryTests.BuildDialogLayout test fixture with a
Confirmation-type branch (Accept/Reject buttons) since this is its
first RetailDialogType.Confirmation consumer.
Suites: full solution Release build green; AcDream.App.Tests 5100/6
skips, AcDream.Core.Net.Tests 965/0, AcDream.Runtime.Tests 1665/0, all
Release, 0 failures; live-DAT probes (ACDREAM_PROBE_LIVE_MOUNT=1)
green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
382 lines
17 KiB
C#
382 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 <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}'.";
|
|
}
|
|
}
|