fix(ui,net): Campaign LA gate round 2 — char-select exit confirmation, authored row justify, world name

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>
This commit is contained in:
Erik 2026-08-15 11:50:45 +02:00
parent 308f40a3fb
commit ef96c55489
18 changed files with 724 additions and 13 deletions

File diff suppressed because one or more lines are too long

View file

@ -955,7 +955,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
late.GameRuntime.CharacterSelectionRequestDelete, late.GameRuntime.CharacterSelectionRequestDelete,
late.GameRuntime.CharacterSelectionConfirmDelete, late.GameRuntime.CharacterSelectionConfirmDelete,
late.GameRuntime.CharacterSelectionRestore, late.GameRuntime.CharacterSelectionRestore,
late.GameRuntime.CharacterSelectionCancel) late.GameRuntime.CharacterSelectionCancel,
// Campaign LA gate round 2 finding 1: the SAME
// window-close path GameplayInputCommandController's
// Escape fallback uses (IGameplayWindowCommands.Close
// /GameplayWindowCommands wrap this same d.Window.Close
// delegate) — no separate exit path.
d.Window.Close)
: null); : null);
RetailUiRuntime runtime = lease.Mount( RetailUiRuntime runtime = lease.Mount(
() => RetailUiRuntime.CreateUninitialized(bindings)); () => RetailUiRuntime.CreateUninitialized(bindings));

View file

@ -181,6 +181,7 @@ internal sealed class CurrentGameRuntimeAdapter
AccountName: string.Empty, AccountName: string.Empty,
SlotCount: 0, SlotCount: 0,
RosterCount: 0, RosterCount: 0,
WorldName: string.Empty,
HighlightedCharacterId: 0u, HighlightedCharacterId: 0u,
HighlightedDisplayIndex: -1, HighlightedDisplayIndex: -1,
PendingDeleteCharacterId: 0u, PendingDeleteCharacterId: 0u,

View file

@ -13,25 +13,46 @@ internal sealed class CharacterManagementUiController : IDisposable
{ {
internal const uint RootEnum = 0x10000005u; internal const uint RootEnum = 0x10000005u;
internal const uint RootElementId = 0x1000039Au; internal const uint RootElementId = 0x1000039Au;
internal const uint WorldTextElementId = 0x1000039Bu;
internal const uint ListElementId = 0x1000039Du; internal const uint ListElementId = 0x1000039Du;
internal const uint CreateElementId = 0x100003A0u; internal const uint CreateElementId = 0x100003A0u;
internal const uint EnterElementId = 0x100003A2u; internal const uint EnterElementId = 0x100003A2u;
internal const uint DeleteElementId = 0x1000039Fu; internal const uint DeleteElementId = 0x1000039Fu;
internal const uint RestoreElementId = 0x1000039Eu; internal const uint RestoreElementId = 0x1000039Eu;
/// <summary>
/// gmCharacterManagementUI::ListenToElementMessage@0x004ed5a0's element-id
/// switch is keyed off <c>idElement - 0x1000039d</c> (the listbox base);
/// offset 6 -> QueueUIMode(0x10000005), the mode gmCreditsUI registers
/// (Register@0x0047a69e) — out of scope this round (finding 1 note).
/// </summary>
internal const uint CreditsElementId = 0x100003A3u;
/// <summary>Offset 7 from the listbox base -> MakeConfirmExitDialog@0x004ed250.</summary>
internal const uint ExitElementId = 0x100003A4u;
internal sealed record DialogStrings( internal sealed record DialogStrings(
Func<string, string> DeleteConfirmation, Func<string, string> DeleteConfirmation,
string DeleteResponse, string DeleteResponse,
string PleaseWait, string PleaseWait,
string EnteringWorld); string EnteringWorld,
/// <summary>
/// Retail <c>ID_CharacterManagement_ConfirmExit</c> (table
/// <c>0x23000002</c>) — "Are you sure you want to leave?", the text
/// <c>MakeConfirmExitDialog@0x004ed250</c> resolves via
/// <c>StringInfo::SetStringIDandTableEnum(compute_str_hash(
/// "ID_CharacterManagement_ConfirmExit"), 0x10000002)</c>.
/// </summary>
string ConfirmExit);
private readonly UiRoot _host; private readonly UiRoot _host;
private readonly ImportedLayout _layout; private readonly ImportedLayout _layout;
private readonly UiText _worldText;
private readonly UiTemplateListBox _list; private readonly UiTemplateListBox _list;
private readonly UiButton _create; private readonly UiButton _create;
private readonly UiButton _enter; private readonly UiButton _enter;
private readonly UiButton _delete; private readonly UiButton _delete;
private readonly UiButton _restore; private readonly UiButton _restore;
private readonly UiButton _credits;
private readonly UiButton _exit;
private readonly RetailDialogFactory _dialogs; private readonly RetailDialogFactory _dialogs;
private readonly CharacterSelectionRuntimeBindings _bindings; private readonly CharacterSelectionRuntimeBindings _bindings;
private readonly DialogStrings _strings; private readonly DialogStrings _strings;
@ -41,10 +62,12 @@ internal sealed class CharacterManagementUiController : IDisposable
private Vector2 _authoredCanvas; private Vector2 _authoredCanvas;
private RuntimeGenerationToken _lastGeneration; private RuntimeGenerationToken _lastGeneration;
private long _lastRevision = long.MinValue; private long _lastRevision = long.MinValue;
private string _lastWorldName = string.Empty;
private uint _deleteDialogContext; private uint _deleteDialogContext;
private uint _operationWaitContext; private uint _operationWaitContext;
private uint _enterWaitContext; private uint _enterWaitContext;
private uint _errorDialogContext; private uint _errorDialogContext;
private uint _confirmExitDialogContext;
private bool _active; private bool _active;
private bool _restoreCommandInFlight; private bool _restoreCommandInFlight;
private bool _suppressDialogCallbacks; private bool _suppressDialogCallbacks;
@ -53,22 +76,28 @@ internal sealed class CharacterManagementUiController : IDisposable
private CharacterManagementUiController( private CharacterManagementUiController(
UiRoot host, UiRoot host,
ImportedLayout layout, ImportedLayout layout,
UiText worldText,
UiTemplateListBox list, UiTemplateListBox list,
UiButton create, UiButton create,
UiButton enter, UiButton enter,
UiButton delete, UiButton delete,
UiButton restore, UiButton restore,
UiButton credits,
UiButton exit,
RetailDialogFactory dialogs, RetailDialogFactory dialogs,
CharacterSelectionRuntimeBindings bindings, CharacterSelectionRuntimeBindings bindings,
DialogStrings strings) DialogStrings strings)
{ {
_host = host; _host = host;
_layout = layout; _layout = layout;
_worldText = worldText;
_list = list; _list = list;
_create = create; _create = create;
_enter = enter; _enter = enter;
_delete = delete; _delete = delete;
_restore = restore; _restore = restore;
_credits = credits;
_exit = exit;
_dialogs = dialogs; _dialogs = dialogs;
_bindings = bindings; _bindings = bindings;
_strings = strings; _strings = strings;
@ -101,6 +130,22 @@ internal sealed class CharacterManagementUiController : IDisposable
_enter.OnClick = EnterSelected; _enter.OnClick = EnterSelected;
_delete.OnClick = RequestDelete; _delete.OnClick = RequestDelete;
_restore.OnClick = RestoreSelected; _restore.OnClick = RestoreSelected;
// Credits (retail QueueUIMode(0x10000005) -> gmCreditsUI) is out of
// scope this round (finding 1 note) — same "future campaign, visibly
// ghosted, no invented action" treatment as Create above. Filed as
// issue #397.
_credits.Visible = true;
_credits.Enabled = false;
_credits.OnClick = null;
_exit.OnClick = RequestExit;
// World name (retail UpdateWorldName@0x004ec120 /
// RecvNotice_WorldName@0x004ec360 both just push
// Client::GetWorldName() onto this element). LinesProvider reads the
// live field Tick() updates each time Runtime's snapshot changes.
_worldText.LinesProvider =
() => [new UiText.Line(_lastWorldName, _worldText.DefaultColor)];
} }
internal UiElement Root => _layout.Root; internal UiElement Root => _layout.Root;
@ -109,6 +154,7 @@ internal sealed class CharacterManagementUiController : IDisposable
internal uint OperationWaitContext => _operationWaitContext; internal uint OperationWaitContext => _operationWaitContext;
internal uint EnterWaitContext => _enterWaitContext; internal uint EnterWaitContext => _enterWaitContext;
internal uint ErrorDialogContext => _errorDialogContext; internal uint ErrorDialogContext => _errorDialogContext;
internal uint ConfirmExitDialogContext => _confirmExitDialogContext;
internal void ResetSession() internal void ResetSession()
{ {
@ -171,11 +217,14 @@ internal sealed class CharacterManagementUiController : IDisposable
} }
if (layout.Root.DatElementId != RootElementId if (layout.Root.DatElementId != RootElementId
|| layout.FindElement(WorldTextElementId) is not UiText worldText
|| layout.FindElement(ListElementId) is not UiTemplateListBox list || layout.FindElement(ListElementId) is not UiTemplateListBox list
|| layout.FindElement(CreateElementId) is not UiButton create || layout.FindElement(CreateElementId) is not UiButton create
|| layout.FindElement(EnterElementId) is not UiButton enter || layout.FindElement(EnterElementId) is not UiButton enter
|| layout.FindElement(DeleteElementId) is not UiButton delete || layout.FindElement(DeleteElementId) is not UiButton delete
|| layout.FindElement(RestoreElementId) is not UiButton restore) || layout.FindElement(RestoreElementId) is not UiButton restore
|| layout.FindElement(CreditsElementId) is not UiButton credits
|| layout.FindElement(ExitElementId) is not UiButton exit)
{ {
Console.WriteLine( Console.WriteLine(
"[UI] character management: the authored root/list/button contract is incomplete."); "[UI] character management: the authored root/list/button contract is incomplete.");
@ -188,11 +237,14 @@ internal sealed class CharacterManagementUiController : IDisposable
return new CharacterManagementUiController( return new CharacterManagementUiController(
host, host,
layout, layout,
worldText,
list, list,
create, create,
enter, enter,
delete, delete,
restore, restore,
credits,
exit,
dialogs, dialogs,
bindings, bindings,
strings); strings);
@ -204,6 +256,7 @@ internal sealed class CharacterManagementUiController : IDisposable
enter.OnClick = null; enter.OnClick = null;
delete.OnClick = null; delete.OnClick = null;
restore.OnClick = null; restore.OnClick = null;
exit.OnClick = null;
throw; throw;
} }
} }
@ -249,6 +302,11 @@ internal sealed class CharacterManagementUiController : IDisposable
_host.BringToFront(Root); _host.BringToFront(Root);
} }
// World name rides independently of the roster revision gate below —
// ServerName can arrive slightly before or after CharacterList (see
// RuntimeCharacterSelectionState.ApplyWorldName).
_lastWorldName = snapshot.WorldName;
if (_lastGeneration != snapshot.Generation if (_lastGeneration != snapshot.Generation
|| _lastRevision != snapshot.Revision) || _lastRevision != snapshot.Revision)
{ {
@ -314,6 +372,7 @@ internal sealed class CharacterManagementUiController : IDisposable
_enter.OnClick = null; _enter.OnClick = null;
_delete.OnClick = null; _delete.OnClick = null;
_restore.OnClick = null; _restore.OnClick = null;
_exit.OnClick = null;
foreach (UiButton row in _rows) foreach (UiButton row in _rows)
{ {
row.OnClick = null; row.OnClick = null;
@ -550,6 +609,33 @@ internal sealed class CharacterManagementUiController : IDisposable
InvalidateAndTick(); InvalidateAndTick();
} }
private void RequestExit()
{
if (_disposed)
return;
// MakeConfirmExitDialog @ 0x004ed250's own guard: a second Exit
// click while the confirmation is already open is a no-op.
if (_confirmExitDialogContext != 0u)
return;
_confirmExitDialogContext = _dialogs.MakeConfirmation(
_strings.ConfirmExit,
data =>
{
_confirmExitDialogContext = 0u;
if (_disposed || _suppressDialogCallbacks)
return;
// RecvNotice_CloseDialog @ 0x004ed760 case 1: only a
// confirmed (OK) close proceeds through the SAME graceful
// shutdown path window-close uses; Cancel leaves the screen
// exactly as it was.
if (data.GetBoolean(RetailDialogProperty.ConfirmationResult))
_bindings.RequestExit();
});
}
private void ReconcileDialogs( private void ReconcileDialogs(
IRuntimeCharacterSelectionView view, IRuntimeCharacterSelectionView view,
RuntimeCharacterSelectionSnapshot snapshot) RuntimeCharacterSelectionSnapshot snapshot)
@ -689,6 +775,7 @@ internal sealed class CharacterManagementUiController : IDisposable
CloseContext(ref _operationWaitContext, suppressCallback: false); CloseContext(ref _operationWaitContext, suppressCallback: false);
CloseContext(ref _enterWaitContext, suppressCallback: false); CloseContext(ref _enterWaitContext, suppressCallback: false);
CloseContext(ref _errorDialogContext, suppressCallback: false); CloseContext(ref _errorDialogContext, suppressCallback: false);
CloseContext(ref _confirmExitDialogContext, suppressCallback: false);
} }
finally finally
{ {

View file

@ -881,10 +881,24 @@ public static class DatWidgetFactory
button.LabelAlign = UiButton.LabelAlignment.Left; button.LabelAlign = UiButton.LabelAlignment.Left;
button.LabelOffsetX = face.X + face.Width + 4f; button.LabelOffsetX = face.X + face.Width + 4f;
} }
else if (!ReferenceEquals(labelInfo, info) && labelInfo.HJustify == HJustify.Left) else if (labelInfo.HJustify == HJustify.Left)
{ {
// Campaign LA gate round 2 finding 2: the guard used to require
// labelInfo to be a LIFTED Type-12 text child (!ReferenceEquals),
// so a button authoring its OWN HJustify=Left with no separate
// label child — e.g. gmCharacterManagementUI's character-list row
// template (0x21000004/0x100003A5: HJustify=Left, three stateful
// Type-3 highlight-art children, no Type-12 caption child) — fell
// through with LabelAlign left at UiButton's Center default.
// Live-DAT probe confirmed: rowInfo.HJustify=Left,
// authoredFaces.Length=3 (faceSegments, not a single face), no
// Type-12 child, and the built row's LabelAlign came out Center.
// labelInfo.X is only a valid inner-offset when a distinct child
// was actually lifted; for the direct (labelInfo == info) case,
// leave UiButton's own default 3px LabelOffsetX in place.
button.LabelAlign = UiButton.LabelAlignment.Left; button.LabelAlign = UiButton.LabelAlignment.Left;
button.LabelOffsetX = labelInfo.X; if (!ReferenceEquals(labelInfo, info))
button.LabelOffsetX = labelInfo.X;
} }
return button; return button;

View file

@ -376,6 +376,20 @@ public sealed record KeyboardRuntimeBindings(
/// mirror; an absent view means the current adapter has not bound (or has /// mirror; an absent view means the current adapter has not bound (or has
/// already been released). /// already been released).
/// </summary> /// </summary>
/// <param name="RequestExit">
/// Campaign LA gate round 2 finding 1: retail's Exit button
/// (<c>gmCharacterManagementUI::ListenToElementMessage@0x004ed5a0</c>,
/// element offset 7 from the listbox base — id <c>0x100003A4</c>) opens
/// <c>MakeConfirmExitDialog@0x004ed250</c>; on confirm
/// (<c>RecvNotice_CloseDialog@0x004ed760</c> case 1) retail queues UI mode
/// <c>0x10000009</c> (<c>gmEpilogueUI</c>) rather than exiting immediately —
/// out of scope here. This is a plain host action, not a generation-gated
/// Runtime command: it is the SAME window-close path
/// <c>GameplayWindowCommands</c>/<c>IGameplayWindowCommands.Close</c> already
/// use for the in-world Escape fallback (<c>d.Window.Close</c> at
/// composition), so status events <c>disconnected</c>/<c>exited</c> still
/// fire through <c>GameWindow.OnClosing</c> → <c>CompleteShutdown</c>.
/// </param>
public sealed record CharacterSelectionRuntimeBindings( public sealed record CharacterSelectionRuntimeBindings(
Func<IRuntimeCharacterSelectionView?> View, Func<IRuntimeCharacterSelectionView?> View,
Func<uint, RuntimeCommandResult> Highlight, Func<uint, RuntimeCommandResult> Highlight,
@ -383,7 +397,8 @@ public sealed record CharacterSelectionRuntimeBindings(
Func<RuntimeCommandResult> RequestDelete, Func<RuntimeCommandResult> RequestDelete,
Func<RuntimeCommandResult> ConfirmDelete, Func<RuntimeCommandResult> ConfirmDelete,
Func<RuntimeCommandResult> Restore, Func<RuntimeCommandResult> Restore,
Func<RuntimeCommandResult> Cancel); Func<RuntimeCommandResult> Cancel,
Action RequestExit);
public sealed record RetailUiRuntimeBindings( public sealed record RetailUiRuntimeBindings(
UiHost Host, UiHost Host,
@ -3766,6 +3781,7 @@ public sealed class RetailUiRuntime : IDisposable
string? deleteConfirmationProbe; string? deleteConfirmationProbe;
string? pleaseWait; string? pleaseWait;
string? enteringWorld; string? enteringWorld;
string? confirmExit;
lock (_bindings.Assets.DatLock) lock (_bindings.Assets.DatLock)
{ {
deleteConfirmationProbe = strings.ResolveTemplate( deleteConfirmationProbe = strings.ResolveTemplate(
@ -3787,12 +3803,21 @@ public sealed class RetailUiRuntime : IDisposable
strings, strings,
stringTableId, stringTableId,
"ID_Character_EnteringWorld"); "ID_Character_EnteringWorld");
// Finding 1: MakeConfirmExitDialog@0x004ed250 resolves this via
// compute_str_hash("ID_CharacterManagement_ConfirmExit") against
// the same table-enum-0x10000002 -> 0x23000002 the other
// character-management dialogs already use.
confirmExit = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharacterManagement_ConfirmExit");
} }
if (deleteConfirmationProbe is null if (deleteConfirmationProbe is null
|| deleteResponse is null || deleteResponse is null
|| pleaseWait is null || pleaseWait is null
|| enteringWorld is null) || enteringWorld is null
|| confirmExit is null)
{ {
Console.WriteLine( Console.WriteLine(
"[UI] character management: required retail strings are unavailable."); "[UI] character management: required retail strings are unavailable.");
@ -3835,7 +3860,8 @@ public sealed class RetailUiRuntime : IDisposable
ComposeDeleteConfirmation, ComposeDeleteConfirmation,
deleteResponse, deleteResponse,
pleaseWait, pleaseWait,
enteringWorld)); enteringWorld,
confirmExit));
} }
private static string? ResolveCharacterManagementString( private static string? ResolveCharacterManagementString(

View file

@ -0,0 +1,90 @@
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>ServerName</c> GameMessage (opcode <c>0xF7E1</c>). ACE sends
/// this in the SAME batch as <see cref="CharacterList"/>, right after
/// <c>AuthConnectResponse</c> completes — it is the world (server) name the
/// retail character-select screen's "World" box shows.
///
/// <para>
/// Retail wire path: <c>CM_Login::DispatchUI_WorldInfo@0x006ad860</c> checks
/// the leading opcode against <c>0xf7e1</c>, unpacks the trailing
/// <c>PStringBase&lt;char&gt;</c>, and calls
/// <c>ClientUISystem::Handle_Login__WorldInfo@0x005641a0(currentConnections,
/// maxConnections, worldName)</c>, which forwards only the name to
/// <c>ECM_Login::SendNotice_WorldName@0x00692b10</c> (notice id
/// <c>0x186a2</c>). <c>gmCharacterManagementUI</c> registers for that notice
/// in its ctor (<c>0x004ec8f0</c>) and both
/// <c>RecvNotice_WorldName@0x004ec360</c> and its own
/// <c>UpdateWorldName@0x004ec120</c> resolve child element <c>0x1000039B</c>
/// (<c>UIElement::GetChildRecursive(m_rootField, 0x1000039b)</c>, dynamic-cast
/// to <c>UIElement_Text</c>) and call
/// <c>UIElement_Text::SetText(Client::GetInstance()->GetWorldName())</c> —
/// <c>Client::GetWorldName@0x00401ca0</c>/<c>SetWorldName@0x00402090</c> just
/// hold the string the notice delivered. The two leading dwords
/// (currentConnections/maxConnections) are read off the wire by the
/// dispatcher but never consumed by the character-management screen itself.
/// </para>
///
/// <para>
/// ACE: <c>GameMessageOpcode.ServerName = 0xF7E1</c>
/// (<c>ACE.Server/Network/GameMessages/GameMessageOpcode.cs</c>);
/// <c>GameMessageServerName</c>
/// (<c>ACE.Server/Network/GameMessages/Messages/GameMessageServerName.cs</c>)
/// writes <c>i32 currentConnections, i32 maxConnections, String16L
/// serverName</c>; sent from
/// <c>AuthenticationHandler.SendConnectResponse</c>
/// (<c>ACE.Server/Network/Handlers/AuthenticationHandler.cs:258</c>)
/// alongside <c>GameMessageCharacterList</c> and
/// <c>GameMessageDDDInterrogation</c>. holtburger's
/// <c>ServerNameData</c>
/// (<c>holtburger-protocol/src/messages/character/types.rs</c>) parses the
/// same three fields and cross-checks the field order/types.
/// </para>
///
/// <code>
/// u32 opcode (0xF7E1)
/// i32 currentConnections
/// i32 maxConnections
/// String16L worldName
/// </code>
/// </summary>
public static class ServerName
{
public const uint Opcode = 0xF7E1u;
public readonly record struct Parsed(
int CurrentConnections,
int MaxConnections,
string WorldName);
/// <summary>
/// Parse a ServerName body. <paramref name="body"/> must start with the
/// 4-byte opcode (0xF7E1) — i.e. pass the full reassembled GameMessage
/// output from <see cref="AcDream.Core.Net.Packets.FragmentAssembler"/>.
/// </summary>
public static Parsed Parse(ReadOnlySpan<byte> body)
{
int pos = 0;
uint opcode = ReadU32(body, ref pos);
if (opcode != Opcode)
throw new FormatException($"expected ServerName opcode 0x{Opcode:X4}, got 0x{opcode:X8}");
int currentConnections = unchecked((int)ReadU32(body, ref pos));
int maxConnections = unchecked((int)ReadU32(body, ref pos));
string worldName = StringReader.ReadString16L(body, ref pos);
return new Parsed(currentConnections, maxConnections, worldName);
}
private static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 4) throw new FormatException("truncated u32");
uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos));
pos += 4;
return value;
}
}

View file

@ -599,6 +599,12 @@ public sealed class WorldSession : IDisposable
public event Action? CharacterDeleteAcknowledged; public event Action? CharacterDeleteAcknowledged;
public event Action<CharacterRestore.Parsed>? CharacterRestoreReceived; public event Action<CharacterRestore.Parsed>? CharacterRestoreReceived;
public event Action<CharacterError.Parsed>? CharacterErrorReceived; public event Action<CharacterError.Parsed>? CharacterErrorReceived;
/// <summary>
/// Campaign LA gate round 2 finding 3: ACE sends this in the same batch
/// as <see cref="CharacterListReceived"/> (right after
/// AuthConnectResponse) — see <see cref="ServerName"/>.
/// </summary>
public event Action<ServerName.Parsed>? ServerNameReceived;
/// <summary> /// <summary>
/// Phase F.1: inbound 0xF7B0 GameEvent dispatcher. Each sub-opcode /// Phase F.1: inbound 0xF7B0 GameEvent dispatcher. Each sub-opcode
@ -691,6 +697,13 @@ public sealed class WorldSession : IDisposable
} }
public CharacterList.Parsed? Characters { get; private set; } public CharacterList.Parsed? Characters { get; private set; }
/// <summary>
/// Campaign LA gate round 2 finding 3: last <see cref="ServerName"/>
/// (opcode 0xF7E1) received, mirroring <see cref="Characters"/>' shape —
/// ACE sends it in the same batch, right after AuthConnectResponse.
/// </summary>
public ServerName.Parsed? ServerInfo { get; private set; }
private CharacterError.Parsed? _lastCharacterSelectionError; private CharacterError.Parsed? _lastCharacterSelectionError;
private readonly IWorldSessionTransport _net; private readonly IWorldSessionTransport _net;
@ -1789,6 +1802,22 @@ public sealed class WorldSession : IDisposable
Characters = parsed; Characters = parsed;
CharacterListReceived?.Invoke(parsed); CharacterListReceived?.Invoke(parsed);
} }
else if (op == ServerName.Opcode)
{
ServerName.Parsed parsed;
try
{
parsed = ServerName.Parse(body);
}
catch
{
// Malformed management messages do not poison the
// remaining ordered UIQueue fragments.
continue;
}
ServerInfo = parsed;
ServerNameReceived?.Invoke(parsed);
}
else if (op == CharacterDelete.Opcode else if (op == CharacterDelete.Opcode
&& CharacterDelete.IsAcknowledgement(body)) && CharacterDelete.IsAcknowledgement(body))
{ {

View file

@ -246,23 +246,27 @@ public sealed class LiveSessionController
private readonly Action _delete; private readonly Action _delete;
private readonly Action<CharacterRestore.Parsed> _restore; private readonly Action<CharacterRestore.Parsed> _restore;
private readonly Action<CharacterError.Parsed> _error; private readonly Action<CharacterError.Parsed> _error;
private readonly Action<ServerName.Parsed> _worldName;
public CharacterSelectionWireBinding( public CharacterSelectionWireBinding(
WorldSession session, WorldSession session,
Action<CharacterList.Parsed> roster, Action<CharacterList.Parsed> roster,
Action delete, Action delete,
Action<CharacterRestore.Parsed> restore, Action<CharacterRestore.Parsed> restore,
Action<CharacterError.Parsed> error) Action<CharacterError.Parsed> error,
Action<ServerName.Parsed> worldName)
{ {
_session = session; _session = session;
_roster = roster; _roster = roster;
_delete = delete; _delete = delete;
_restore = restore; _restore = restore;
_error = error; _error = error;
_worldName = worldName;
session.CharacterListReceived += roster; session.CharacterListReceived += roster;
session.CharacterDeleteAcknowledged += delete; session.CharacterDeleteAcknowledged += delete;
session.CharacterRestoreReceived += restore; session.CharacterRestoreReceived += restore;
session.CharacterErrorReceived += error; session.CharacterErrorReceived += error;
session.ServerNameReceived += worldName;
} }
public bool IsDisposed => _session is null; public bool IsDisposed => _session is null;
@ -276,6 +280,7 @@ public sealed class LiveSessionController
session.CharacterDeleteAcknowledged -= _delete; session.CharacterDeleteAcknowledged -= _delete;
session.CharacterRestoreReceived -= _restore; session.CharacterRestoreReceived -= _restore;
session.CharacterErrorReceived -= _error; session.CharacterErrorReceived -= _error;
session.ServerNameReceived -= _worldName;
} }
} }
@ -886,6 +891,14 @@ public sealed class LiveSessionController
if (IsCurrent(scope, generation)) if (IsCurrent(scope, generation))
CharacterSelectionState.ApplyError(error); CharacterSelectionState.ApplyError(error);
} }
},
worldName =>
{
lock (_gate)
{
if (IsCurrent(scope, generation))
CharacterSelectionState.ApplyWorldName(worldName.WorldName);
}
}); });
public RuntimeCommandResult Highlight( public RuntimeCommandResult Highlight(

View file

@ -36,6 +36,11 @@ public enum RuntimeCharacterSelectionDeltaKind
ErrorChanged, ErrorChanged,
EnteringWorld, EnteringWorld,
EnteredWorld, EnteredWorld,
/// <summary>
/// Campaign LA gate round 2 finding 3: <see cref="ServerName"/> arrived —
/// see <see cref="RuntimeCharacterSelectionState.ApplyWorldName"/>.
/// </summary>
WorldNameChanged,
} }
public readonly record struct RuntimeCharacterSelectionEntry( public readonly record struct RuntimeCharacterSelectionEntry(
@ -77,6 +82,7 @@ public readonly record struct RuntimeCharacterSelectionSnapshot(
string AccountName, string AccountName,
int SlotCount, int SlotCount,
int RosterCount, int RosterCount,
string WorldName,
uint HighlightedCharacterId, uint HighlightedCharacterId,
int HighlightedDisplayIndex, int HighlightedDisplayIndex,
uint PendingDeleteCharacterId, uint PendingDeleteCharacterId,
@ -202,6 +208,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
private long _revision; private long _revision;
private string _accountName = string.Empty; private string _accountName = string.Empty;
private int _slotCount; private int _slotCount;
private string _worldName = string.Empty;
private uint _highlightedCharacterId; private uint _highlightedCharacterId;
private uint _pendingDeleteCharacterId; private uint _pendingDeleteCharacterId;
private uint _lastRestoreRequestedCharacterId; private uint _lastRestoreRequestedCharacterId;
@ -236,6 +243,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
_accountName, _accountName,
_slotCount, _slotCount,
_entries.Length, _entries.Length,
_worldName,
_highlightedCharacterId, _highlightedCharacterId,
selectedIndex, selectedIndex,
_pendingDeleteCharacterId, _pendingDeleteCharacterId,
@ -326,6 +334,28 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
selected); selected);
} }
/// <summary>
/// Campaign LA gate round 2 finding 3: retail's <c>UpdateWorldName</c>
/// (<c>0x004ec120</c>) / <c>RecvNotice_WorldName</c> (<c>0x004ec360</c>)
/// both just push <c>Client::GetWorldName()</c> onto element
/// <c>0x1000039B</c> — no lifecycle gate. ACE sends <c>ServerName</c> in
/// the same batch as <c>CharacterList</c>, so this may land slightly
/// before or after <see cref="ApplyRoster"/>; it is intentionally
/// ungated (beyond disposal) so neither arrival order loses the name.
/// </summary>
internal void ApplyWorldName(string worldName)
{
ArgumentNullException.ThrowIfNull(worldName);
lock (_gate)
{
if (_disposed || _worldName == worldName)
return;
_worldName = worldName;
_revision++;
}
Publish(RuntimeCharacterSelectionDeltaKind.WorldNameChanged);
}
internal bool TryHighlight(uint characterId) internal bool TryHighlight(uint characterId)
{ {
lock (_gate) lock (_gate)
@ -900,6 +930,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
_entries = []; _entries = [];
_accountName = string.Empty; _accountName = string.Empty;
_slotCount = 0; _slotCount = 0;
_worldName = string.Empty;
_highlightedCharacterId = 0u; _highlightedCharacterId = 0u;
_pendingDeleteCharacterId = 0u; _pendingDeleteCharacterId = 0u;
_lastRestoreRequestedCharacterId = 0u; _lastRestoreRequestedCharacterId = 0u;

View file

@ -308,6 +308,7 @@ public sealed class InteractionUiRuntimeSourcesTests
AccountName: "account", AccountName: "account",
SlotCount: 0, SlotCount: 0,
RosterCount: 0, RosterCount: 0,
WorldName: string.Empty,
HighlightedCharacterId: 0u, HighlightedCharacterId: 0u,
HighlightedDisplayIndex: -1, HighlightedDisplayIndex: -1,
PendingDeleteCharacterId: 0u, PendingDeleteCharacterId: 0u,

View file

@ -67,6 +67,18 @@ public sealed class CharacterManagementLiveDatTests
"DELETE"); "DELETE");
AssertButton(screen, CharacterManagementUiController.RestoreElementId, AssertButton(screen, CharacterManagementUiController.RestoreElementId,
"RESTORE"); "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( Assert.DoesNotContain(
Descendants(screen.Root), Descendants(screen.Root),
static element => element is UiViewport); static element => element is UiViewport);
@ -88,6 +100,21 @@ public sealed class CharacterManagementLiveDatTests
], ],
rowInfo.States.Keys.Order().ToArray()); 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); uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u);
Assert.Equal(0x2100003Cu, dialogDid); Assert.Equal(0x2100003Cu, dialogDid);
ImportedLayout message = BuildSelected(dats, dialogDid, 0x24u); ImportedLayout message = BuildSelected(dats, dialogDid, 0x24u);
@ -108,6 +135,14 @@ public sealed class CharacterManagementLiveDatTests
"ID_CharacterManagement_PleaseWait")); "ID_CharacterManagement_PleaseWait"));
Assert.Equal("Entering World", Resolve(strings, table, Assert.Equal("Entering World", Resolve(strings, table,
"ID_Character_EnteringWorld")); "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( string confirmation = Assert.IsType<string>(strings.ResolveTemplate(
table, table,
"ID_CharacterManagement_DeleteCharacterConfirmation", "ID_CharacterManagement_DeleteCharacterConfirmation",

View file

@ -99,6 +99,32 @@ public sealed class CharacterManagementUiControllerTests
Assert.True(restore.Enabled); Assert.True(restore.Enabled);
} }
/// <summary>
/// Campaign LA gate round 2 finding 3: retail's UpdateWorldName@0x004ec120
/// / RecvNotice_WorldName@0x004ec360 both push Client::GetWorldName()
/// onto element 0x1000039B. The controller binds Runtime's borrowed
/// snapshot field to that same element.
/// </summary>
[Fact]
public void WorldName_TicksFromSnapshot_IntoTheWorldTextElement()
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
var worldText = Assert.IsType<UiText>(environment.Screen.FindElement(
CharacterManagementUiController.WorldTextElementId));
Assert.Equal(
"sawato",
string.Join(" ", worldText.LinesProvider().Select(static line => line.Text)));
environment.Runtime.SetWorldName("Frostfell");
controller.Tick();
Assert.Equal(
"Frostfell",
string.Join(" ", worldText.LinesProvider().Select(static line => line.Text)));
}
[Fact] [Fact]
public void RowHeight_UsesAllowedSlotsAndClampsAtOneTenthForLargeRosters() public void RowHeight_UsesAllowedSlotsAndClampsAtOneTenthForLargeRosters()
{ {
@ -346,6 +372,81 @@ public sealed class CharacterManagementUiControllerTests
controller.Rows.Select(static row => row.Label!).ToArray()); controller.Rows.Select(static row => row.Label!).ToArray());
} }
/// <summary>
/// Campaign LA gate round 2 finding 1: Exit -> MakeConfirmExitDialog
/// (0x004ed250, retail's confirm-only dialog type 1) -> Cancel/Reject
/// leaves the screen exactly as it was — no exit request reaches
/// Runtime's window-close binding.
/// </summary>
[Fact]
public void ExitButton_OpenThenCancel_KeepsScreenActive_NoExitRequested()
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
UiButton exit = environment.Button(
CharacterManagementUiController.ExitElementId);
exit.OnClick!();
Assert.NotEqual(0u, controller.ConfirmExitDialogContext);
ImportedLayout dialog = environment.LastDialog(RetailDialogType.Confirmation);
Assert.Equal(
"Are you sure you want to leave?",
Message(dialog));
DialogButton(dialog, RetailConfirmationDialogView.RejectButtonId).OnClick!();
Assert.Equal(0, environment.Runtime.RequestExitCalls);
Assert.Equal(0u, controller.ConfirmExitDialogContext);
Assert.False(environment.Dialogs.IsOpen);
Assert.True(controller.Root.Visible);
}
/// <summary>
/// Confirm reaches the SAME graceful-shutdown seam window-close uses —
/// asserted here via the bindings fake, since the controller/Runtime
/// boundary is a plain host <c>Action</c>
/// (<see cref="CharacterSelectionRuntimeBindings.RequestExit"/>), not a
/// generation-gated Runtime command.
/// </summary>
[Fact]
public void ExitButton_OpenThenConfirm_ReachesGracefulShutdownSeam()
{
using var environment = new EnvironmentHarness();
CharacterManagementUiController controller = environment.Controller;
UiButton exit = environment.Button(
CharacterManagementUiController.ExitElementId);
exit.OnClick!();
ImportedLayout dialog = environment.LastDialog(RetailDialogType.Confirmation);
DialogButton(dialog, RetailConfirmationDialogView.AcceptButtonId).OnClick!();
Assert.Equal(1, environment.Runtime.RequestExitCalls);
Assert.Equal(0u, controller.ConfirmExitDialogContext);
Assert.False(environment.Dialogs.IsOpen);
}
/// <summary>
/// MakeConfirmExitDialog's own guard (<c>m_confirmExitDialogContext != 0
/// -&gt; return</c>): a second Exit click while the confirmation is
/// already open does not open a second dialog.
/// </summary>
[Fact]
public void ExitButton_SecondClickWhileOpen_IsNoOp()
{
using var environment = new EnvironmentHarness();
UiButton exit = environment.Button(
CharacterManagementUiController.ExitElementId);
exit.OnClick!();
Assert.Equal(1, environment.DialogLayouts.Count(
entry => entry.Type == RetailDialogType.Confirmation));
exit.OnClick!();
Assert.Equal(1, environment.DialogLayouts.Count(
entry => entry.Type == RetailDialogType.Confirmation));
}
[Fact] [Fact]
public void AuthoredRowDoubleActivation_EntersTheHighlightedCharacter() public void AuthoredRowDoubleActivation_EntersTheHighlightedCharacter()
{ {
@ -598,7 +699,8 @@ public sealed class CharacterManagementUiControllerTests
name => $"WARNING! {name}\nType DELETE in the box below.", name => $"WARNING! {name}\nType DELETE in the box below.",
"DELETE", "DELETE",
"Please Wait", "Please Wait",
"Entering World"); "Entering World",
"Are you sure you want to leave?");
private static void AssertDetachedAndUnbound(ImportedLayout screen) private static void AssertDetachedAndUnbound(ImportedLayout screen)
{ {
@ -633,6 +735,15 @@ public sealed class CharacterManagementUiControllerTests
0x21000004u, 0x21000004u,
0x100003A5u)); 0x100003A5u));
root.Children.Add(list); root.Children.Add(list);
root.Children.Add(new ElementInfo
{
Id = CharacterManagementUiController.WorldTextElementId,
Type = 12u,
X = 21f,
Y = 44f,
Width = 193f,
Height = 110f,
});
root.Children.Add(ButtonInfo( root.Children.Add(ButtonInfo(
CharacterManagementUiController.CreateElementId)); CharacterManagementUiController.CreateElementId));
root.Children.Add(ButtonInfo( root.Children.Add(ButtonInfo(
@ -641,6 +752,10 @@ public sealed class CharacterManagementUiControllerTests
CharacterManagementUiController.DeleteElementId)); CharacterManagementUiController.DeleteElementId));
root.Children.Add(ButtonInfo( root.Children.Add(ButtonInfo(
CharacterManagementUiController.RestoreElementId)); CharacterManagementUiController.RestoreElementId));
root.Children.Add(ButtonInfo(
CharacterManagementUiController.CreditsElementId));
root.Children.Add(ButtonInfo(
CharacterManagementUiController.ExitElementId));
if (includePreview) if (includePreview)
{ {
root.Children.Add(new ElementInfo root.Children.Add(new ElementInfo
@ -762,7 +877,8 @@ public sealed class CharacterManagementUiControllerTests
RequestDelete, RequestDelete,
ConfirmDelete, ConfirmDelete,
Restore, Restore,
Cancel); Cancel,
RequestExit);
} }
public FakeView View { get; } = new(); public FakeView View { get; } = new();
@ -773,6 +889,7 @@ public sealed class CharacterManagementUiControllerTests
public int ConfirmDeleteCalls { get; private set; } public int ConfirmDeleteCalls { get; private set; }
public int CancelCalls { get; private set; } public int CancelCalls { get; private set; }
public int RestoreCalls { get; private set; } public int RestoreCalls { get; private set; }
public int RequestExitCalls { get; private set; }
public RuntimeCommandStatus RestoreStatus { get; set; } = public RuntimeCommandStatus RestoreStatus { get; set; } =
RuntimeCommandStatus.Accepted; RuntimeCommandStatus.Accepted;
public bool ThrowOnRestore { get; set; } public bool ThrowOnRestore { get; set; }
@ -814,6 +931,9 @@ public sealed class CharacterManagementUiControllerTests
public void SetLifecycle(RuntimeCharacterSelectionLifecycle lifecycle) => public void SetLifecycle(RuntimeCharacterSelectionLifecycle lifecycle) =>
Update(snapshot => snapshot with { Lifecycle = lifecycle }); Update(snapshot => snapshot with { Lifecycle = lifecycle });
public void SetWorldName(string worldName) =>
Update(snapshot => snapshot with { WorldName = worldName });
public void SetError(string message) => Update(snapshot => snapshot with public void SetError(string message) => Update(snapshot => snapshot with
{ {
Lifecycle = RuntimeCharacterSelectionLifecycle.AwaitingSelection, Lifecycle = RuntimeCharacterSelectionLifecycle.AwaitingSelection,
@ -915,6 +1035,8 @@ public sealed class CharacterManagementUiControllerTests
return Result(RuntimeCommandStatus.Accepted); return Result(RuntimeCommandStatus.Accepted);
} }
private void RequestExit() => RequestExitCalls++;
private RuntimeCharacterSelectionButtons ButtonsFor(uint characterId) private RuntimeCharacterSelectionButtons ButtonsFor(uint characterId)
{ {
RuntimeCharacterSelectionEntry? selected = View.Entries RuntimeCharacterSelectionEntry? selected = View.Entries
@ -959,6 +1081,7 @@ public sealed class CharacterManagementUiControllerTests
"account", "account",
SlotCount: 5, SlotCount: 5,
RosterCount: View.Entries.Length, RosterCount: View.Entries.Length,
WorldName: "sawato",
highlightedCharacterId, highlightedCharacterId,
HighlightedDisplayIndex: Array.FindIndex( HighlightedDisplayIndex: Array.FindIndex(
View.Entries, View.Entries,

View file

@ -305,6 +305,67 @@ public class DatWidgetFactoryTests
Assert.IsType<UiButton>(e); Assert.IsType<UiButton>(e);
} }
/// <summary>
/// Campaign LA gate round 2 finding 2: the retail character-select row
/// template (LayoutDesc 0x21000004, element 0x100003A5, live-DAT
/// confirmed) authors HJustify=Left DIRECTLY on the row's own
/// UIElement_Button — no separate Type-12 caption child (its label comes
/// from the runtime-bound character name, not an authored string), just
/// three stateful Type-3 highlight-art children. The old guard
/// (<c>!ReferenceEquals(labelInfo, info)</c>) only honored HJustify when
/// the label was LIFTED from a distinct Type-12 child, so a button
/// authoring its own justify with no such child fell through to
/// UiButton's Center default. This reproduces that exact shape.
/// </summary>
[Fact]
public void BuildButton_OwnHJustifyLeft_NoTextChild_MultipleStatefulFaces_LabelAlignsLeft()
{
var info = new ElementInfo
{
Type = 1,
Width = 160,
Height = 16,
HJustify = HJustify.Left,
};
info.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal" };
info.States[2u] = new UiStateInfo { Id = 2u, Name = "Normal_rollover" };
info.States[3u] = new UiStateInfo { Id = 3u, Name = "Highlight" };
for (int i = 0; i < 3; i++)
{
var face = new ElementInfo { Type = 3, ReadOrder = (uint)i };
face.StateMedia["Normal_rollover"] = (0x06000000u + (uint)i, 1);
info.Children.Add(face);
}
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(UiButton.LabelAlignment.Left, button.LabelAlign);
// Direct (non-lifted) case: LabelOffsetX stays at UiButton's own
// default small left padding, not a bogus inner offset.
Assert.Equal(3f, button.LabelOffsetX);
}
/// <summary>
/// A button whose own authored HJustify really is Center (the normal
/// case — CREATE/ENTER/DELETE/RESTORE captions) must stay centered; the
/// fix only widens the Left branch, it must not force every button left.
/// </summary>
[Fact]
public void BuildButton_OwnHJustifyCenter_NoTextChild_StaysCentered()
{
var info = new ElementInfo
{
Type = 1,
Width = 160,
Height = 16,
HJustify = HJustify.Center,
};
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(UiButton.LabelAlignment.Center, button.LabelAlign);
}
// ── Test 5b: Type 11 → UiScrollbar ────────────────────────────────────── // ── Test 5b: Type 11 → UiScrollbar ──────────────────────────────────────
[Fact] [Fact]

View file

@ -729,6 +729,32 @@ public sealed class RetailDialogFactoryTests
Height = 32f, Height = 32f,
}); });
} }
else if (type == RetailDialogType.Confirmation)
{
// Campaign LA gate round 2 finding 1: CharacterManagementUiController's
// exit-confirm dialog is the first BuildDialogLayout consumer that
// exercises RetailDialogType.Confirmation through this synthetic
// builder (other Confirmation coverage in THIS file uses the real
// FixtureLoader.LoadConfirmationDialog() fixture instead).
popup.Children.Add(new ElementInfo
{
Id = RetailConfirmationDialogView.AcceptButtonId,
Type = 1u,
X = 80f,
Y = 48f,
Width = 80f,
Height = 32f,
});
popup.Children.Add(new ElementInfo
{
Id = RetailConfirmationDialogView.RejectButtonId,
Type = 1u,
X = 240f,
Y = 48f,
Width = 80f,
Height = 32f,
});
}
else if (type == RetailDialogType.ConfirmationTextInput) else if (type == RetailDialogType.ConfirmationTextInput)
{ {
var field = new ElementInfo var field = new ElementInfo

View file

@ -0,0 +1,81 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class ServerNameTests
{
[Fact]
public void Parse_MirrorsAceSerializer_ExactFields()
{
// Mirrors ACE's GameMessageServerName: opcode, i32 currentConnections,
// i32 maxConnections, String16L serverName.
var w = AceWireWriter.GameMessage(ServerName.Opcode)
.Write(123)
.Write(1000)
.WriteString16L("sawato");
ServerName.Parsed parsed = ServerName.Parse(w.ToArray());
Assert.Equal(123, parsed.CurrentConnections);
Assert.Equal(1000, parsed.MaxConnections);
Assert.Equal("sawato", parsed.WorldName);
}
[Fact]
public void Parse_NegativeMaxConnections_PreservesSign()
{
// ACE's default is maxConnections = -1 (unlimited); the field must
// stay signed rather than being read as a huge unsigned value.
var w = AceWireWriter.GameMessage(ServerName.Opcode)
.Write(0)
.Write(-1)
.WriteString16L("Frostfell");
ServerName.Parsed parsed = ServerName.Parse(w.ToArray());
Assert.Equal(0, parsed.CurrentConnections);
Assert.Equal(-1, parsed.MaxConnections);
Assert.Equal("Frostfell", parsed.WorldName);
}
[Fact]
public void Parse_EmptyWorldName_RoundTrips()
{
var w = AceWireWriter.GameMessage(ServerName.Opcode)
.Write(0)
.Write(0)
.WriteString16L(string.Empty);
ServerName.Parsed parsed = ServerName.Parse(w.ToArray());
Assert.Equal(string.Empty, parsed.WorldName);
}
[Fact]
public void Parse_WrongOpcode_Throws()
{
byte[] bytes = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0xDEADBEEFu);
Assert.Throws<FormatException>(() => ServerName.Parse(bytes));
}
[Fact]
public void Parse_TruncatedAfterCurrentConnections_Throws()
{
var w = AceWireWriter.GameMessage(ServerName.Opcode).Write(0);
Assert.Throws<FormatException>(() => ServerName.Parse(w.ToArray()));
}
[Fact]
public void Parse_TruncatedBeforeWorldName_Throws()
{
var w = AceWireWriter.GameMessage(ServerName.Opcode)
.Write(0)
.Write(0);
Assert.Throws<FormatException>(() => ServerName.Parse(w.ToArray()));
}
}

View file

@ -92,6 +92,30 @@ public sealed class WorldSessionCharacterSelectionTests
Assert.Equal(1u, current.SecondsGreyedOut); Assert.Equal(1u, current.SecondsGreyedOut);
} }
[Fact]
public void ServerName_Dispatches_AndPopulatesServerInfo()
{
// Campaign LA gate round 2 finding 3: ACE's SendConnectResponse
// enqueues CharacterList then ServerName in the same batch
// (AuthenticationHandler.cs:257-261) — assert both arrive, in wire
// order, through the same UIQueue dispatch path.
using var session = CreateSession();
var events = new List<string>();
session.CharacterListReceived += _ => events.Add("roster");
session.ServerNameReceived += info => events.Add($"world:{info.WorldName}");
byte[] packet = BuildPacket(
BuildRoster(secondsGreyedOut: 0u),
BuildServerName("sawato", currentConnections: 3, maxConnections: 100));
InvokeProcessDatagram(session, packet);
Assert.Equal(["roster", "world:sawato"], events);
Assert.NotNull(session.ServerInfo);
Assert.Equal("sawato", session.ServerInfo!.Value.WorldName);
Assert.Equal(3, session.ServerInfo!.Value.CurrentConnections);
Assert.Equal(100, session.ServerInfo!.Value.MaxConnections);
}
[Fact] [Fact]
public void ImmediateEnterWorld_IgnoresNumErrorsSentinelBeforeServerReady() public void ImmediateEnterWorld_IgnoresNumErrorsSentinelBeforeServerReady()
{ {
@ -183,6 +207,19 @@ public sealed class WorldSessionCharacterSelectionTests
return writer.ToArray(); return writer.ToArray();
} }
private static byte[] BuildServerName(
string worldName,
int currentConnections,
int maxConnections)
{
var writer = new PacketWriter(64);
writer.WriteUInt32(ServerName.Opcode);
writer.WriteUInt32(unchecked((uint)currentConnections));
writer.WriteUInt32(unchecked((uint)maxConnections));
writer.WriteString16L(worldName);
return writer.ToArray();
}
private static byte[] BuildRestoreResponse() private static byte[] BuildRestoreResponse()
{ {
var writer = new PacketWriter(64); var writer = new PacketWriter(64);

View file

@ -291,6 +291,55 @@ public sealed class RuntimeCharacterSelectionStateTests
delta => Assert.Equal(new RuntimeGenerationToken(9), delta.Generation)); delta => Assert.Equal(new RuntimeGenerationToken(9), delta.Generation));
} }
[Fact]
public void ApplyWorldName_PopulatesSnapshot_IndependentOfRoster()
{
// Campaign LA gate round 2 finding 3: ACE sends ServerName in the
// same batch as CharacterList; ApplyWorldName must not require
// ApplyRoster to have run first (arrival order is not guaranteed).
using var state = new RuntimeCharacterSelectionState();
state.Begin(new RuntimeGenerationToken(5));
Assert.Equal(string.Empty, state.Snapshot.WorldName);
state.ApplyWorldName("sawato");
Assert.Equal("sawato", state.Snapshot.WorldName);
state.ApplyRoster(Roster(
new LiveSessionRosterEntry(0x50000001u, "One", 0u)));
Assert.Equal("sawato", state.Snapshot.WorldName);
Assert.Equal(0x50000001u, state.Snapshot.HighlightedCharacterId);
}
[Fact]
public void ApplyWorldName_UnchangedValue_DoesNotBumpRevisionOrPublish()
{
using var state = new RuntimeCharacterSelectionState();
state.Begin(new RuntimeGenerationToken(6));
state.ApplyWorldName("sawato");
var deltas = new List<RuntimeCharacterSelectionDelta>();
using IDisposable subscription = state.View.Subscribe(
new Observer(deltas.Add));
long revision = state.Snapshot.Revision;
state.ApplyWorldName("sawato");
Assert.Equal(revision, state.Snapshot.Revision);
Assert.Empty(deltas);
}
[Fact]
public void Reset_ClearsWorldName()
{
using var state = new RuntimeCharacterSelectionState();
state.Begin(new RuntimeGenerationToken(8));
state.ApplyWorldName("sawato");
state.Reset(new RuntimeGenerationToken(9));
Assert.Equal(string.Empty, state.Snapshot.WorldName);
}
private static LiveSessionRosterReport Roster( private static LiveSessionRosterReport Roster(
params LiveSessionRosterEntry[] entries) => params LiveSessionRosterEntry[] entries) =>
new("Canonical", 11, entries); new("Canonical", 11, entries);