diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index b51c6997..a4ce63c6 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -102,7 +102,7 @@ accepted-divergence entries (#96, #49, #50).
| AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses ONLY `handle_all_collisions` + `cached_velocity` on a no-move frame; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.App/Input/PlayerMovementController.cs` (`candidateMoved` guard) | The load-bearing effect is not re-zeroing the gravity velocity that rebuilds after a stuck-fall bleed; the zero-distance resolve is a near-no-op (numSteps 0 → the zero-step early return, no ValidateTransition, contact plane persists via the writeback), so running it is harmless while keeping acdream's per-frame cell/membership refresh | If the zero-distance resolve ever gains a side effect on a no-move frame (a contact-plane clear, an fsf change), it would diverge from retail's skip — a no-move frame must stay a near-no-op | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 pc:283657 (candidate-moved gate) |
| AD-42 | Enter-world placement is split across two Core calls: legacy `Resolve` performs retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` runs the verbatim object-aware `find_placement_pos` ring search. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.App/Rendering/GameWindow.cs` (`EnterPlayerModeNow`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolvePlacement`) | The first call has already committed the same validated cell/floor point that feeds the ring search; the second call uses the same sphere dimensions, collision registry, and cell id. Keeping the split preserves the proven indoor-login snap while adding the missing occupied-position behavior | A spawn that requires retail's final placement step-down after a ring candidate (rather than the existing floor snap before it) could settle at a slightly different Z on a ledge/water boundary; the overlap is still cleared | `CPhysicsObj::enter_world` 0x00516170; `CTransition::find_placement_position` 0x0050C170; `CTransition::find_placement_pos` 0x0050BA50 |
| AD-43 | A malformed/custom PhysicsScript `CallPES` cycle whose script timeline never advances is rejected with a diagnostic; retail's linked scheduler would continue draining that zero-time tail indefinitely | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` (timeline-progress ancestry guard) | Prevents corrupt DAT content from hanging the single update/render thread. Installed-DAT audit plus conformance tests prove the real rolling-weather cycles advance 2.8 seconds per edge and continue unchanged; only a no-progress strongly connected cycle is rejected | A custom DAT that deliberately relies on an infinite zero-time loop observes a rejected play instead of freezing the client | `ScriptManager::AddScriptInternal` 0x0051B310; `ScriptManager::UpdateScripts` 0x0051B480; `CPhysicsObj::CallPES` 0x00511AF0 |
-| AD-44 | Native-window close performs retail's complete character-logoff handshake and transport disconnect, then exits the process instead of retaining the authenticated logon connection and returning to character selection. acdream has one active `ReceiverData` equivalent, so `ClientNet::LogOffServer`'s per-receiver disconnect loop sends one header. | `src/AcDream.Core.Net/WorldSession.cs` (`Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | The application currently auto-selects one character and has no character-selection presentation/state owner to receive the returned authenticated session. Close must still release ACE immediately and in retail order. | Closing is correct, but an eventual in-client "log off character" action cannot reuse this process-exit path; it needs an App/session transition that retains the socket after server `0xF653` rather than calling `Dispose`. | `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` |
+| AD-44 | acdream has no retained character-management screen: startup deterministically selects the first active, non-greyed CharacterList identity, and native-window close performs retail's complete character-logoff handshake plus transport disconnect before exiting instead of returning to character selection. One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.App/Rendering/GameWindow.cs` (live-session bootstrap, moving to `LiveSessionController` in Slice 3); `src/AcDream.Core.Net/WorldSession.cs` (`SelectCharacterForEnterWorld`, `Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | This preserves unattended startup and immediate ACE endpoint release while validating that the chosen identity is active/non-greyed and using the server's canonical account. A future retained character-management owner is separate UI/session work. | An account with multiple playable characters enters the first wire-order identity without retail's explicit choice. An eventual in-client "log off character" action cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` |
---
diff --git a/docs/research/2026-07-21-retail-live-session-lifecycle-pseudocode.md b/docs/research/2026-07-21-retail-live-session-lifecycle-pseudocode.md
new file mode 100644
index 00000000..126555cc
--- /dev/null
+++ b/docs/research/2026-07-21-retail-live-session-lifecycle-pseudocode.md
@@ -0,0 +1,157 @@
+# Retail live-session lifecycle pseudocode
+
+**Scope:** GameWindow Slice 3 character-list, character-entry, and transport-
+disconnect conformance corrections.
+
+## Named-retail oracle
+
+- `CharacterIdentity::UnPack @ 0x004FE880`
+- `CharacterSet::UnPack @ 0x004FE340`
+- `CharacterSet::GetGreyedOutFor @ 0x004FDFA0`
+- `gmCharacterManagementUI::EnterGame @ 0x004ED440`
+- `gmCharGenMainUI::Update @ 0x004E8460`
+- `CPlayerSystem::Handle_Login__CharacterSet @ 0x0055F6D0`
+- `Proto_UI::SendEnterWorldRequest @ 0x00546A00`
+- `CPlayerSystem::Handle_Character__EnterGame_ServerReady @ 0x0055E4F0`
+- `Proto_UI::SendEnterWorld @ 0x00546BC0`
+- `CPlayerSystem::LogOnCharacter @ 0x0055F890`
+- `CPlayerSystem::UseTime @ 0x00563110`
+- `Proto_UI::LogOffCharacter @ 0x00546A20`
+- `CPlayerSystem::RequestLogOff @ 0x00562DD0`
+- inbound F653 dispatch `@ 0x0055C963`
+- `CPlayerSystem::ExecuteLogOff @ 0x0055D780`
+- `ClientNet::ExitWorldDisconnect @ 0x00541E00`
+- `ClientNet::LogOffServer @ 0x00543EF0`
+- `SharedNet::SendOptionalHeader @ 0x00543160`
+
+The verbatim layouts are `CharacterIdentity` and `CharacterSet` in
+`docs/research/named-retail/acclient.h` (types 3206 and 3209).
+
+## CharacterSet wire decode
+
+```text
+CharacterIdentity.UnPack(cursor):
+ identity.guid = read_u32(cursor)
+ identity.name = read_PString(cursor)
+ identity.secondsGreyedOut = read_u32(cursor)
+ align cursor to 4 bytes
+
+CharacterSet.UnPack(cursor):
+ result.status = read_u32(cursor)
+
+ activeCount = read_u32(cursor)
+ result.active = empty
+ repeat activeCount times:
+ result.active.append(CharacterIdentity.UnPack(cursor))
+
+ deletedCount = read_u32(cursor)
+ result.deleted = empty
+ repeat deletedCount times:
+ result.deleted.append(CharacterIdentity.UnPack(cursor))
+
+ result.numAllowedCharacters = read_u32(cursor)
+ result.account = read_PString(cursor)
+ result.useTurbineChat = read_u32(cursor)
+ result.hasThroneOfDestiny = read_u32(cursor)
+```
+
+The two values previously described as "leading/trailing padding" are the
+retail status and deleted-character count. ACE currently writes zero for both,
+which hid the schema error in acdream and in Holtburger's current reader.
+
+Cross-checks:
+
+- ACE `GameMessageCharacterList` writes the same active identity records,
+ allowed-slot count, canonical `Session.Account`, and booleans, but currently
+ emits status/deleted-count as zero and no deleted records.
+- Holtburger `CharacterListData` agrees on active records and the tail, but
+ likewise labels both zero values as padding. The named retail `CharacterSet`
+ layout and `UnPack` control flow win where the references differ.
+
+## Retail selection and acdream unattended entry
+
+```text
+gmCharacterManagementUI.EnterGame():
+ playerSystem = GetPlayerSystem()
+ if playerSystem exists
+ and selectedGuid != 0
+ and CharacterSet.GetGreyedOutFor(
+ CharacterSet.GetSlot(selectedGuid)) <= 0:
+ show entering-world dialog
+ playerSystem.LogOnCharacter(selectedGuid)
+```
+
+Retail ordinarily requires an explicit selected active character. The only
+automatic retail path found is `gmCharGenMainUI::Update`: after character
+creation it walks the active list, skips entries greyed for a positive number
+of seconds, matches the newly-created name case-insensitively, and logs that
+GUID on.
+
+acdream does not yet have the retained character-management screen. Its
+unattended auto-entry is therefore an explicit adaptation with a narrow rule:
+
+```text
+for each identity in active wire order:
+ if identity.guid != 0 and identity.secondsGreyedOut == 0:
+ select this active-list index
+ stop
+if none matched:
+ do not enter a character
+```
+
+Deleted entries are never candidates. This preserves the established "first
+available character" behavior without treating a pending-deletion character
+as available.
+
+## Canonical account used by EnterWorld
+
+```text
+CPlayerSystem.LogOnCharacter(selectedGuid), when ready:
+ selectedGuid = persistentData.selectedCharacter
+ account = playerSystem.account // populated from CharacterSet.account
+ Proto_UI.SendEnterWorld(account, selectedGuid)
+ ClientNet.EnterWorld()
+```
+
+The account string in the final F657 message comes from the server-returned
+`CharacterSet.account`, not from the spelling typed into the startup login
+form. acdream must therefore make `WorldSession.EnterWorld` consume
+`Characters.AccountName` internally.
+
+## Logout and negotiated transport disconnect
+
+```text
+ClientNet.LogOffServer():
+ packet = make optional-header packet(flags = Disconnect)
+ for each negotiated ReceiverData:
+ SharedNet.SendOptionalHeader(packet, receiver.address, receiver)
+ logOffSent = true
+ logonReceiverId = 0
+ ExitWorldDisconnect()
+```
+
+The in-world character path remains:
+
+```text
+send F653 + active character GUID
+wait for server's opcode-only F653
+send negotiated transport Disconnect
+release transport
+```
+
+ACE's `GameMessageCharacterLogOff` confirms the server form is exactly four
+bytes. Its `NetworkSession` terminates a session when it receives a packet with
+the transport Disconnect flag.
+
+The transport Disconnect is connection-scoped, not character-state-scoped.
+Once acdream has accepted ConnectRequest receiver id/iteration, disposal sends
+it whether the session is still selecting a character, entering the world, in
+world, or failed later. F653 remains restricted to an active in-world
+character. A pre-negotiation failure has no receiver identity and only closes
+the local socket.
+
+## Deliberately unchanged registered gaps
+
+- UN-6: the fixed 200 ms ConnectResponse delay remains registered.
+- TS-28: LoginComplete readiness remains a later protocol slice.
+- AD-44: closing exits rather than returning to a retained character screen.
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 835d3dc8..1d31c22a 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -2867,6 +2867,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents);
if (_liveSession is null)
{
+ _commandBus = null;
_combatChatTranslator?.Dispose();
_combatChatTranslator = null;
_liveSessionController = null;
@@ -2883,9 +2884,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_liveSession.Connect(user, pass);
Chat.OnSystemMessage("connected — character list received", chatType: 1);
- if (_liveSession.Characters is null || _liveSession.Characters.Characters.Count == 0)
+ if (_liveSession.Characters is null
+ || !AcDream.Core.Net.Messages.CharacterList.TrySelectFirstAvailable(
+ _liveSession.Characters,
+ out AcDream.Core.Net.Messages.CharacterList.Selection selection))
{
- Console.WriteLine("live: no characters on account; disconnecting");
+ Console.WriteLine("live: no available characters on account; disconnecting");
+ _commandBus = null;
+ _combatChatTranslator?.Dispose();
+ _combatChatTranslator = null;
_liveSessionController.Dispose();
_liveSessionController = null;
_liveSession = null;
@@ -2893,7 +2900,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
return;
}
- var chosen = _liveSession.Characters.Characters[0];
+ var chosen = selection.Character;
_playerServerGuid = chosen.Id;
_vitalsVm?.SetLocalPlayerGuid(chosen.Id);
Chat.SetLocalPlayerGuid(chosen.Id);
@@ -2901,7 +2908,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = chosen.Id; // TEMP #138-B probe
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
Combat.Clear();
- _liveSession.EnterWorld(user, characterIndex: 0);
+ _liveSession.EnterWorld(characterIndex: selection.ActiveIndex);
_activeToonKey = chosen.Name;
_retailUiRuntime?.RestoreLayout();
@@ -2919,6 +2926,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
catch (Exception ex)
{
Console.WriteLine($"live: session failed: {ex.Message}");
+ _commandBus = null;
_combatChatTranslator?.Dispose();
_combatChatTranslator = null;
_liveSessionController?.Dispose();
diff --git a/src/AcDream.Core.Net/Messages/CharacterList.cs b/src/AcDream.Core.Net/Messages/CharacterList.cs
index 0b7eac9a..fa436442 100644
--- a/src/AcDream.Core.Net/Messages/CharacterList.cs
+++ b/src/AcDream.Core.Net/Messages/CharacterList.cs
@@ -7,22 +7,25 @@ namespace AcDream.Core.Net.Messages;
/// Inbound CharacterList GameMessage (opcode 0xF658).
/// The server sends one of these right after ConnectResponse completes
/// the handshake — it's the client's "pick which character to log in"
-/// data. Contains one entry per character on the account plus account-
-/// level settings (slot count, Turbine chat toggle, ToD flag).
+/// data. Contains the retail status, separate active and deleted character
+/// collections, plus account-level settings.
///
///
-/// Wire layout (ported from ACE's GameMessageCharacterList.cs
-/// writer — see NOTICE.md for attribution):
+/// Wire layout ported from retail CharacterSet::UnPack at
+/// 0x004FE340 and CharacterIdentity::UnPack at
+/// 0x004FE880. ACE currently emits zero for both status and deleted
+/// count, which is why older readers mislabeled them as padding:
///
///
///
-/// u32 0 (leading padding, always 0)
-/// u32 characterCount
-/// for each character:
+/// u32 status
+/// u32 activeCount
+/// for each active character:
/// u32 characterId (GUID)
/// String16L name (prefixed with '+' for admin chars)
-/// u32 deleteTimeDelta (0 if not scheduled for deletion)
-/// u32 0 (trailing padding)
+/// u32 secondsGreyedOut
+/// u32 deletedCount
+/// for each deleted character: same CharacterIdentity record
/// u32 slotCount (max characters per account)
/// String16L accountName
/// u32 useTurbineChat (bool)
@@ -33,11 +36,15 @@ public static class CharacterList
{
public const uint Opcode = 0xF658u;
- public readonly record struct Character(uint Id, string Name, uint DeleteTimeDelta);
+ public readonly record struct Character(uint Id, string Name, uint SecondsGreyedOut);
+
+ public readonly record struct Selection(int ActiveIndex, Character Character);
public sealed record Parsed(
+ uint Status,
IReadOnlyList Characters,
- uint SlotCount,
+ IReadOnlyList DeletedCharacters,
+ int SlotCount,
string AccountName,
bool UseTurbineChat,
bool HasThroneOfDestiny);
@@ -55,27 +62,78 @@ public static class CharacterList
if (opcode != Opcode)
throw new FormatException($"expected CharacterList opcode 0x{Opcode:X4}, got 0x{opcode:X8}");
- _ = ReadU32(body, ref pos); // leading 0
- uint count = ReadU32(body, ref pos);
- if (count > 255)
- throw new FormatException($"character count {count} exceeds sanity limit");
-
- var characters = new Character[count];
- for (int i = 0; i < count; i++)
- {
- uint id = ReadU32(body, ref pos);
- string name = ReadString16L(body, ref pos);
- uint deleteDelta = ReadU32(body, ref pos);
- characters[i] = new Character(id, name, deleteDelta);
- }
-
- _ = ReadU32(body, ref pos); // trailing 0
- uint slotCount = ReadU32(body, ref pos);
+ uint status = ReadU32(body, ref pos);
+ Character[] characters = ReadCharacters(body, ref pos, "active");
+ Character[] deletedCharacters = ReadCharacters(body, ref pos, "deleted");
+ int slotCount = unchecked((int)ReadU32(body, ref pos));
string accountName = ReadString16L(body, ref pos);
bool useTurbineChat = ReadU32(body, ref pos) != 0;
bool hasThroneOfDestiny = ReadU32(body, ref pos) != 0;
- return new Parsed(characters, slotCount, accountName, useTurbineChat, hasThroneOfDestiny);
+ return new Parsed(
+ status,
+ characters,
+ deletedCharacters,
+ slotCount,
+ accountName,
+ useTurbineChat,
+ hasThroneOfDestiny);
+ }
+
+ ///
+ /// acdream's unattended character-management adaptation: choose the first
+ /// active, non-greyed identity in retail wire order. Deleted identities are
+ /// intentionally outside the candidate collection.
+ ///
+ public static bool TrySelectFirstAvailable(Parsed parsed, out Selection selection)
+ {
+ ArgumentNullException.ThrowIfNull(parsed);
+
+ for (int i = 0; i < parsed.Characters.Count; i++)
+ {
+ Character character = parsed.Characters[i];
+ if (!IsAvailableActiveIdentity(character))
+ continue;
+
+ selection = new Selection(i, character);
+ return true;
+ }
+
+ selection = default;
+ return false;
+ }
+
+ ///
+ /// Returns whether an identity from the active collection can be selected.
+ /// The collection context is deliberate: deleted identities share the same
+ /// wire record but are never selectable.
+ ///
+ public static bool IsAvailableActiveIdentity(Character character) =>
+ character.Id != 0 && character.SecondsGreyedOut == 0;
+
+ private static Character[] ReadCharacters(
+ ReadOnlySpan body,
+ ref int pos,
+ string collectionName)
+ {
+ uint count = ReadU32(body, ref pos);
+ // CharacterIdentity's minimum aligned wire size is twelve bytes:
+ // guid + empty String16L record + secondsGreyedOut. Derive the bound
+ // from the packet rather than inventing a retail-external count cap.
+ if (count > (uint)((body.Length - pos) / 12))
+ throw new FormatException(
+ $"{collectionName} character count {count} exceeds remaining payload");
+
+ var characters = new Character[checked((int)count)];
+ for (int i = 0; i < characters.Length; i++)
+ {
+ uint id = ReadU32(body, ref pos);
+ string name = ReadString16L(body, ref pos);
+ uint secondsGreyedOut = ReadU32(body, ref pos);
+ characters[i] = new Character(id, name, secondsGreyedOut);
+ }
+
+ return characters;
}
private static uint ReadU32(ReadOnlySpan source, ref int pos)
@@ -97,6 +155,8 @@ public static class CharacterList
pos += length;
int recordSize = 2 + length;
int padding = (4 - (recordSize & 3)) & 3;
+ if (source.Length - pos < padding)
+ throw new FormatException("truncated String16L padding");
pos += padding;
return result;
}
diff --git a/src/AcDream.Core.Net/Messages/CharacterLogOff.cs b/src/AcDream.Core.Net/Messages/CharacterLogOff.cs
index d92a50e0..0ca97def 100644
--- a/src/AcDream.Core.Net/Messages/CharacterLogOff.cs
+++ b/src/AcDream.Core.Net/Messages/CharacterLogOff.cs
@@ -33,6 +33,6 @@ public static class CharacterLogOff
/// confirmation. ACE emits the canonical four-byte opcode-only form.
///
public static bool IsConfirmation(ReadOnlySpan body) =>
- body.Length >= sizeof(uint) &&
+ body.Length == sizeof(uint) &&
BinaryPrimitives.ReadUInt32LittleEndian(body) == Opcode;
}
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index fa9a3366..1f27bc25 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -627,6 +627,7 @@ public sealed class WorldSession : IDisposable
private IsaacRandom? _outboundIsaac;
private ushort _sessionClientId;
private ushort _sessionIteration;
+ private bool _transportNegotiated;
private uint _clientPacketSequence;
private uint _fragmentSequence = 1;
@@ -732,9 +733,6 @@ public sealed class WorldSession : IDisposable
// in the ConnectRequest optional section. Publish it to
// subscribers so WorldTimeService.SyncFromServer can seed the
// client clock.
- LastServerTimeTicks = opt.ConnectRequestServerTime;
- ServerTimeUpdated?.Invoke(opt.ConnectRequestServerTime);
-
byte[] serverSeedBytes = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, opt.ConnectRequestServerSeed);
byte[] clientSeedBytes = new byte[4];
@@ -746,8 +744,15 @@ public sealed class WorldSession : IDisposable
// generation into connection-level control packets, including the
// final disconnect. ACE currently emits iteration 1.
_sessionIteration = cr.Header.Iteration;
+ _transportNegotiated = true;
_clientPacketSequence = 2;
+ // Publish only after the receiver identity and crypto state are fully
+ // committed. A synchronous App callback may throw or request teardown;
+ // disposal must still send the negotiated Disconnect.
+ LastServerTimeTicks = opt.ConnectRequestServerTime;
+ ServerTimeUpdated?.Invoke(opt.ConnectRequestServerTime);
+
byte[] crBody = new byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(crBody, opt.ConnectRequestCookie);
var crHeader = new PacketHeader { Sequence = 1, Flags = PacketHeaderFlags.ConnectResponse, Id = 0 };
@@ -770,15 +775,15 @@ public sealed class WorldSession : IDisposable
/// Returns once the server starts sending CreateObjects (at which point
/// callers should poll to stream events).
///
- public void EnterWorld(string account, int characterIndex = 0, TimeSpan? timeout = null)
+ public void EnterWorld(int characterIndex = 0, TimeSpan? timeout = null)
{
if (Characters is null || Characters.Characters.Count == 0)
throw new InvalidOperationException("Connect() must complete with a non-empty CharacterList");
- if (characterIndex < 0 || characterIndex >= Characters.Characters.Count)
- throw new ArgumentOutOfRangeException(nameof(characterIndex));
-
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
- var chosen = Characters.Characters[characterIndex];
+ EnterWorldSelection selection = SelectCharacterForEnterWorld(
+ Characters,
+ characterIndex);
+ CharacterList.Character chosen = selection.Character;
_activeCharacterId = chosen.Id;
Transition(State.EnteringWorld);
@@ -795,7 +800,10 @@ public sealed class WorldSession : IDisposable
}
if (!serverReady) { Transition(State.Failed); throw new TimeoutException("ServerReady not received"); }
- SendGameMessage(CharacterEnterWorld.BuildEnterWorldBody(chosen.Id, account));
+ // CPlayerSystem::LogOnCharacter @ 0x0055F890 passes the account
+ // populated by CharacterSet::UnPack, not the spelling supplied to the
+ // login form. ACE validates this canonical account value.
+ SendGameMessage(selection.EnterWorldBody);
// NOTE: LoginComplete used to be sent here unconditionally. That was
// wrong — per holtburger's flow (see references/holtburger/.../client/
@@ -819,6 +827,35 @@ public sealed class WorldSession : IDisposable
_netThread.Start();
}
+ internal readonly record struct EnterWorldSelection(
+ CharacterList.Character Character,
+ byte[] EnterWorldBody);
+
+ ///
+ /// Applies retail's selected-active-character gate and binds the F657
+ /// account field to the canonical CharacterSet account. Kept pure so the
+ /// public session invariant is conformance-tested without opening a socket.
+ ///
+ internal static EnterWorldSelection SelectCharacterForEnterWorld(
+ CharacterList.Parsed characters,
+ int characterIndex)
+ {
+ ArgumentNullException.ThrowIfNull(characters);
+ if (characterIndex < 0 || characterIndex >= characters.Characters.Count)
+ throw new ArgumentOutOfRangeException(nameof(characterIndex));
+
+ CharacterList.Character chosen = characters.Characters[characterIndex];
+ if (!CharacterList.IsAvailableActiveIdentity(chosen))
+ throw new InvalidOperationException(
+ "selected character must be an active, non-greyed identity");
+
+ return new EnterWorldSelection(
+ chosen,
+ CharacterEnterWorld.BuildEnterWorldBody(
+ chosen.Id,
+ characters.AccountName));
+ }
+
// Per-frame inbound time budget (#2 flood timeslice). On a teleport arrival ACE floods
// a town's CreateObjects; draining them ALL in one Tick (each hydrates mesh + textures
// under _datLock on the render thread) monopolized the update loop for ~a minute and
@@ -1951,38 +1988,38 @@ public sealed class WorldSession : IDisposable
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
return;
- if (CurrentState == State.InWorld)
+ SessionShutdownPlan shutdown = BuildShutdownPlan(
+ CurrentState,
+ _transportNegotiated,
+ _activeCharacterId);
+
+ Interlocked.Exchange(ref _characterLogOffConfirmed, 0);
+ ShutdownExecutionResult result = ExecuteShutdownWire(
+ shutdown,
+ _activeCharacterId,
+ _sessionClientId,
+ _sessionIteration,
+ SendGameMessage,
+ WaitForCharacterLogOffConfirmation,
+ packet => _net.Send(packet),
+ TimeSpan.FromSeconds(35));
+
+ if (result.CharacterLogOffSent)
{
- try
- {
- // Proto_UI::LogOffCharacter @ 0x00546A20 sends the active id.
- Interlocked.Exchange(ref _characterLogOffConfirmed, 0);
- SendGameMessage(CharacterLogOff.BuildRequestBody(_activeCharacterId));
- Console.WriteLine(
- $"[session] graceful logout requested character=0x{_activeCharacterId:X8}");
-
- if (WaitForCharacterLogOffConfirmation(TimeSpan.FromSeconds(35)))
- Console.WriteLine("[session] graceful logout confirmed");
- else
- Console.Error.WriteLine(
- "[session] graceful logout confirmation timed out; disconnecting transport");
- }
- catch (Exception error)
- {
+ Console.WriteLine(
+ $"[session] graceful logout requested character=0x{_activeCharacterId:X8}");
+ if (result.ConfirmationReceived)
+ Console.WriteLine("[session] graceful logout confirmed");
+ else if (result.CharacterLogOffError is null)
Console.Error.WriteLine(
- $"[session] graceful logout failed: {error.Message}");
- }
-
- try
- {
- SendTransportDisconnect();
- }
- catch (Exception error)
- {
- Console.Error.WriteLine(
- $"[session] transport disconnect failed: {error.Message}");
- }
+ "[session] graceful logout confirmation timed out; disconnecting transport");
}
+ if (result.CharacterLogOffError is not null)
+ Console.Error.WriteLine(
+ $"[session] graceful logout failed: {result.CharacterLogOffError.Message}");
+ if (result.TransportDisconnectError is not null)
+ Console.Error.WriteLine(
+ $"[session] transport disconnect failed: {result.TransportDisconnectError.Message}");
// Phase A.3: shut down the background receive thread. Cancel the
// token → the 250ms receive timeout fires → loop exits → join.
@@ -1995,25 +2032,132 @@ public sealed class WorldSession : IDisposable
Transition(State.Disconnected);
}
- private bool WaitForCharacterLogOffConfirmation(TimeSpan timeout)
+ internal readonly record struct SessionShutdownPlan(
+ bool RequestCharacterLogOff,
+ bool SendTransportDisconnect);
+
+ internal readonly record struct ShutdownExecutionResult(
+ bool CharacterLogOffSent,
+ bool ConfirmationReceived,
+ bool TransportDisconnectSent,
+ Exception? CharacterLogOffError,
+ Exception? TransportDisconnectError);
+
+ internal static SessionShutdownPlan BuildShutdownPlan(
+ State state,
+ bool transportNegotiated,
+ uint activeCharacterId) =>
+ new(
+ RequestCharacterLogOff:
+ transportNegotiated
+ && state == State.InWorld
+ && activeCharacterId != 0,
+ SendTransportDisconnect: transportNegotiated);
+
+ ///
+ /// Executes the retail wire order while keeping request and transport
+ /// failures independent. The delegates are the production send/wait path;
+ /// their narrow shape also makes exact packet ordering deterministic in
+ /// tests without duplicating shutdown logic.
+ ///
+ internal static ShutdownExecutionResult ExecuteShutdownWire(
+ SessionShutdownPlan plan,
+ uint activeCharacterId,
+ ushort sessionClientId,
+ ushort sessionIteration,
+ Action sendGameMessage,
+ Func waitForConfirmation,
+ Action sendTransportDatagram,
+ TimeSpan confirmationTimeout)
{
+ ArgumentNullException.ThrowIfNull(sendGameMessage);
+ ArgumentNullException.ThrowIfNull(waitForConfirmation);
+ ArgumentNullException.ThrowIfNull(sendTransportDatagram);
+
+ bool characterLogOffSent = false;
+ bool confirmationReceived = false;
+ bool transportDisconnectSent = false;
+ Exception? characterError = null;
+ Exception? transportError = null;
+
+ if (plan.RequestCharacterLogOff)
+ {
+ try
+ {
+ // Proto_UI::LogOffCharacter @ 0x00546A20 sends the active id.
+ sendGameMessage(CharacterLogOff.BuildRequestBody(activeCharacterId));
+ characterLogOffSent = true;
+ confirmationReceived = waitForConfirmation(confirmationTimeout);
+ }
+ catch (Exception error)
+ {
+ characterError = error;
+ }
+ }
+
+ if (plan.SendTransportDisconnect)
+ {
+ try
+ {
+ // ClientNet::LogOffServer @ 0x00543EF0 sends Disconnect for
+ // every negotiated ReceiverData, independent of character
+ // entry state.
+ sendTransportDatagram(TransportDisconnect.Build(
+ sessionClientId,
+ sessionIteration));
+ transportDisconnectSent = true;
+ }
+ catch (Exception error)
+ {
+ transportError = error;
+ }
+ }
+
+ return new ShutdownExecutionResult(
+ characterLogOffSent,
+ confirmationReceived,
+ transportDisconnectSent,
+ characterError,
+ transportError);
+ }
+
+ private bool WaitForCharacterLogOffConfirmation(TimeSpan timeout) =>
+ WaitForCharacterLogOffConfirmation(
+ _inboundQueue.Reader,
+ timeout,
+ bytes =>
+ {
+ ProcessDatagram(bytes, dispatchWorldEvents: false);
+ return Volatile.Read(ref _characterLogOffConfirmed) != 0;
+ });
+
+ internal static bool WaitForCharacterLogOffConfirmation(
+ ChannelReader reader,
+ TimeSpan timeout,
+ Func processAndCheckConfirmation)
+ {
+ ArgumentNullException.ThrowIfNull(reader);
+ ArgumentNullException.ThrowIfNull(processAndCheckConfirmation);
using var timeoutSource = new CancellationTokenSource(timeout);
try
{
- while (Volatile.Read(ref _characterLogOffConfirmed) == 0)
+ while (!timeoutSource.IsCancellationRequested)
{
- while (_inboundQueue.Reader.TryRead(out byte[]? bytes))
+ while (reader.TryRead(out byte[]? bytes))
{
- ProcessDatagram(bytes, dispatchWorldEvents: false);
- if (Volatile.Read(ref _characterLogOffConfirmed) != 0)
+ if (timeoutSource.IsCancellationRequested)
+ return false;
+ if (processAndCheckConfirmation(bytes))
return true;
}
- _inboundQueue.Reader.WaitToReadAsync(timeoutSource.Token)
+ bool canRead = reader.WaitToReadAsync(timeoutSource.Token)
.AsTask()
.GetAwaiter()
.GetResult();
+ if (!canRead)
+ return false;
}
}
catch (OperationCanceledException)
@@ -2025,14 +2169,6 @@ public sealed class WorldSession : IDisposable
return false;
}
- return true;
- }
-
- private void SendTransportDisconnect()
- {
- byte[] disconnectPacket = TransportDisconnect.Build(
- _sessionClientId,
- _sessionIteration);
- _net.Send(disconnectPacket);
+ return false;
}
}
diff --git a/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs b/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs
index a096262e..52ac51d6 100644
--- a/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs
+++ b/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs
@@ -347,7 +347,10 @@ public class LiveHandshakeTests
Assert.NotNull(charList);
Assert.NotEmpty(charList!.Characters);
- var chosen = charList.Characters[0];
+ Assert.True(
+ CharacterList.TrySelectFirstAvailable(charList, out var selection),
+ "CharacterList contains no active, non-greyed character");
+ var chosen = selection.Character;
Console.WriteLine($"[live] choosing character: 0x{chosen.Id:X8} {chosen.Name}");
// ---- Step 5: send CharacterEnterWorldRequest (UIQueue, encrypted checksum) ----
@@ -403,7 +406,7 @@ public class LiveHandshakeTests
// ---- Step 7: send CharacterEnterWorld with the chosen GUID. ----
SendGameMessage(
- CharacterEnterWorld.BuildEnterWorldBody(chosen.Id, user),
+ CharacterEnterWorld.BuildEnterWorldBody(chosen.Id, charList.AccountName),
$"CharacterEnterWorld(guid=0x{chosen.Id:X8})");
// ---- Step 8: receive the CreateObject flood + parse bodies. ----
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterEnterWorldTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterEnterWorldTests.cs
index 0fa1d264..0b607f0a 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/CharacterEnterWorldTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterEnterWorldTests.cs
@@ -1,4 +1,5 @@
using System.Buffers.Binary;
+using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
@@ -41,6 +42,67 @@ public class CharacterEnterWorldTests
Assert.Equal(0, body[pos++]);
Assert.Equal(4 + 4 + 16, body.Length); // opcode + guid + padded string
}
+
+ [Fact]
+ public void SessionSelection_UsesCanonicalCharacterListAccountInF657()
+ {
+ CharacterList.Parsed characters = MakeCharacters(
+ [new CharacterList.Character(0x50000001u, "Ready", 0)],
+ accountName: "CanonicalCase");
+
+ WorldSession.EnterWorldSelection selection =
+ WorldSession.SelectCharacterForEnterWorld(characters, 0);
+
+ ReadOnlySpan body = selection.EnterWorldBody;
+ Assert.Equal(CharacterEnterWorld.EnterWorldOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body));
+ Assert.Equal(0x50000001u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body[4..]));
+ ushort length = BinaryPrimitives.ReadUInt16LittleEndian(body[8..]);
+ Assert.Equal("CanonicalCase",
+ System.Text.Encoding.ASCII.GetString(body.Slice(10, length)));
+ }
+
+ [Theory]
+ [InlineData(0u, 0u)]
+ [InlineData(0x50000001u, 1u)]
+ [InlineData(0x50000001u, 30u)]
+ public void SessionSelection_RejectsUnavailableActiveIdentity(
+ uint guid,
+ uint secondsGreyedOut)
+ {
+ CharacterList.Parsed characters = MakeCharacters(
+ [new CharacterList.Character(guid, "Unavailable", secondsGreyedOut)],
+ accountName: "Account");
+
+ Assert.Throws(() =>
+ WorldSession.SelectCharacterForEnterWorld(characters, 0));
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(1)]
+ public void SessionSelection_RejectsIndexOutsideActiveCollection(int index)
+ {
+ CharacterList.Parsed characters = MakeCharacters(
+ [new CharacterList.Character(0x50000001u, "Ready", 0)],
+ accountName: "Account");
+
+ Assert.Throws(() =>
+ WorldSession.SelectCharacterForEnterWorld(characters, index));
+ }
+
+ private static CharacterList.Parsed MakeCharacters(
+ IReadOnlyList active,
+ string accountName) =>
+ new(
+ Status: 0,
+ Characters: active,
+ DeletedCharacters: [],
+ SlotCount: 11,
+ AccountName: accountName,
+ UseTurbineChat: true,
+ HasThroneOfDestiny: true);
}
public class GameMessageFragmentTests
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterListTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterListTests.cs
index 84c58e68..c38a854e 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/CharacterListTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterListTests.cs
@@ -9,10 +9,10 @@ public class CharacterListTests
[Fact]
public void Parse_SingleCharacter_ExtractsGuidAndName()
{
- // Hand-assemble a CharacterList body matching ACE's writer format:
- // u32 opcode, u32 0, u32 count=1,
- // u32 guid, String16L name, u32 deleteDelta,
- // u32 0, u32 slotCount, String16L accountName,
+ // Hand-assemble retail CharacterSet wire layout as emitted by ACE:
+ // u32 opcode, u32 status=0, u32 activeCount=1,
+ // u32 guid, String16L name, u32 secondsGreyedOut,
+ // u32 deletedCount=0, i32 numAllowed, String16L accountName,
// u32 useTurbineChat, u32 hasToD
var w = new PacketWriter(128);
w.WriteUInt32(CharacterList.Opcode);
@@ -29,11 +29,13 @@ public class CharacterListTests
var parsed = CharacterList.Parse(w.ToArray());
+ Assert.Equal(0u, parsed.Status);
Assert.Single(parsed.Characters);
+ Assert.Empty(parsed.DeletedCharacters);
Assert.Equal(0x50000001u, parsed.Characters[0].Id);
Assert.Equal("+Acdream", parsed.Characters[0].Name);
- Assert.Equal(0u, parsed.Characters[0].DeleteTimeDelta);
- Assert.Equal(11u, parsed.SlotCount);
+ Assert.Equal(0u, parsed.Characters[0].SecondsGreyedOut);
+ Assert.Equal(11, parsed.SlotCount);
Assert.Equal("testaccount", parsed.AccountName);
Assert.True(parsed.UseTurbineChat);
Assert.True(parsed.HasThroneOfDestiny);
@@ -69,6 +71,152 @@ public class CharacterListTests
Assert.Equal("Carol", parsed.Characters[2].Name);
}
+ [Fact]
+ public void Parse_NonzeroStatusAndDeletedCollection_PreservesRetailLayout()
+ {
+ var w = new PacketWriter(160);
+ w.WriteUInt32(CharacterList.Opcode);
+ w.WriteUInt32(7); // status
+ w.WriteUInt32(1); // active count
+ WriteIdentity(w, 0x50000001u, "Active", 0);
+ w.WriteUInt32(2); // deleted count, not padding
+ WriteIdentity(w, 0x50000002u, "Deleted One", 90);
+ WriteIdentity(w, 0x50000003u, "Deleted Two", 180);
+ WriteTail(w, 13, "CanonicalAccount", useTurbineChat: true, hasTod: false);
+
+ CharacterList.Parsed parsed = CharacterList.Parse(w.ToArray());
+
+ Assert.Equal(7u, parsed.Status);
+ Assert.Single(parsed.Characters);
+ Assert.Equal("Active", parsed.Characters[0].Name);
+ Assert.Collection(
+ parsed.DeletedCharacters,
+ character =>
+ {
+ Assert.Equal(0x50000002u, character.Id);
+ Assert.Equal("Deleted One", character.Name);
+ Assert.Equal(90u, character.SecondsGreyedOut);
+ },
+ character =>
+ {
+ Assert.Equal(0x50000003u, character.Id);
+ Assert.Equal("Deleted Two", character.Name);
+ Assert.Equal(180u, character.SecondsGreyedOut);
+ });
+ Assert.Equal(13, parsed.SlotCount);
+ Assert.Equal("CanonicalAccount", parsed.AccountName);
+ Assert.True(parsed.UseTurbineChat);
+ Assert.False(parsed.HasThroneOfDestiny);
+ }
+
+ [Fact]
+ public void TrySelectFirstAvailable_SkipsZeroGuidAndGreyedActiveCharacters()
+ {
+ CharacterList.Parsed parsed = BuildParsed(
+ [
+ new CharacterList.Character(0, "Empty", 0),
+ new CharacterList.Character(0x50000001u, "Deleting", 30),
+ new CharacterList.Character(0x50000002u, "Ready", 0),
+ ],
+ [new CharacterList.Character(0x50000003u, "Deleted", 0)]);
+
+ bool found = CharacterList.TrySelectFirstAvailable(parsed, out var selection);
+
+ Assert.True(found);
+ Assert.Equal(2, selection.ActiveIndex);
+ Assert.Equal(0x50000002u, selection.Character.Id);
+ }
+
+ [Fact]
+ public void TrySelectFirstAvailable_RejectsAllGreyedAndNeverReadsDeletedCollection()
+ {
+ CharacterList.Parsed parsed = BuildParsed(
+ [new CharacterList.Character(0x50000001u, "Deleting", 1)],
+ [new CharacterList.Character(0x50000002u, "Deleted Ready", 0)]);
+
+ Assert.False(CharacterList.TrySelectFirstAvailable(parsed, out _));
+ }
+
+ [Fact]
+ public void Parse_EmptyCollectionsAndNegativeAllowedSentinel_ArePreserved()
+ {
+ var w = new PacketWriter(64);
+ w.WriteUInt32(CharacterList.Opcode);
+ w.WriteUInt32(0);
+ w.WriteUInt32(0); // active count
+ w.WriteUInt32(0); // deleted count
+ w.WriteUInt32(uint.MaxValue); // retail signed -1 sentinel
+ w.WriteString16L("Account");
+ w.WriteUInt32(2); // every nonzero retail int is true
+ w.WriteUInt32(uint.MaxValue);
+
+ CharacterList.Parsed parsed = CharacterList.Parse(w.ToArray());
+
+ Assert.Empty(parsed.Characters);
+ Assert.Empty(parsed.DeletedCharacters);
+ Assert.Equal(-1, parsed.SlotCount);
+ Assert.True(parsed.UseTurbineChat);
+ Assert.True(parsed.HasThroneOfDestiny);
+ Assert.False(CharacterList.TrySelectFirstAvailable(parsed, out _));
+ }
+
+ [Fact]
+ public void Parse_CountThatCannotFitRemainingPayload_IsRejected()
+ {
+ var active = new PacketWriter(16);
+ active.WriteUInt32(CharacterList.Opcode);
+ active.WriteUInt32(0);
+ active.WriteUInt32(256);
+ Assert.Throws(() => CharacterList.Parse(active.ToArray()));
+
+ var deleted = new PacketWriter(20);
+ deleted.WriteUInt32(CharacterList.Opcode);
+ deleted.WriteUInt32(0);
+ deleted.WriteUInt32(0);
+ deleted.WriteUInt32(256);
+ Assert.Throws(() => CharacterList.Parse(deleted.ToArray()));
+ }
+
+ [Fact]
+ public void Parse_255MinimumSizeIdentities_UsesPayloadBoundNotArbitraryCap()
+ {
+ var w = new PacketWriter(4096);
+ w.WriteUInt32(CharacterList.Opcode);
+ w.WriteUInt32(0);
+ w.WriteUInt32(255);
+ for (uint i = 1; i <= 255; i++)
+ WriteIdentity(w, i, string.Empty, 0);
+ w.WriteUInt32(0);
+ WriteTail(w, 255, "Account", useTurbineChat: true, hasTod: true);
+
+ CharacterList.Parsed parsed = CharacterList.Parse(w.ToArray());
+
+ Assert.Equal(255, parsed.Characters.Count);
+ Assert.Equal(255, parsed.SlotCount);
+ }
+
+ [Fact]
+ public void Parse_EveryTruncatedPrefixOfRetailPayload_Throws()
+ {
+ var w = new PacketWriter(160);
+ w.WriteUInt32(CharacterList.Opcode);
+ w.WriteUInt32(3);
+ w.WriteUInt32(1);
+ WriteIdentity(w, 0x50000001u, "A", 0);
+ w.WriteUInt32(1);
+ WriteIdentity(w, 0x50000002u, "Deleted", 22);
+ WriteTail(w, 11, "Account", useTurbineChat: true, hasTod: true);
+ byte[] valid = w.ToArray();
+
+ for (int length = 0; length < valid.Length; length++)
+ {
+ byte[] prefix = valid.AsSpan(0, length).ToArray();
+ Assert.Throws(() => CharacterList.Parse(prefix));
+ }
+
+ _ = CharacterList.Parse(valid);
+ }
+
[Fact]
public void Parse_WrongOpcode_Throws()
{
@@ -84,4 +232,40 @@ public class CharacterListTests
BinaryPrimitives.WriteUInt32LittleEndian(bytes, CharacterList.Opcode);
Assert.Throws(() => CharacterList.Parse(bytes));
}
+
+ private static void WriteIdentity(
+ PacketWriter writer,
+ uint id,
+ string name,
+ uint secondsGreyedOut)
+ {
+ writer.WriteUInt32(id);
+ writer.WriteString16L(name);
+ writer.WriteUInt32(secondsGreyedOut);
+ }
+
+ private static void WriteTail(
+ PacketWriter writer,
+ int slotCount,
+ string accountName,
+ bool useTurbineChat,
+ bool hasTod)
+ {
+ writer.WriteUInt32(unchecked((uint)slotCount));
+ writer.WriteString16L(accountName);
+ writer.WriteUInt32(useTurbineChat ? 1u : 0u);
+ writer.WriteUInt32(hasTod ? 1u : 0u);
+ }
+
+ private static CharacterList.Parsed BuildParsed(
+ IReadOnlyList active,
+ IReadOnlyList deleted) =>
+ new(
+ Status: 0,
+ Characters: active,
+ DeletedCharacters: deleted,
+ SlotCount: 11,
+ AccountName: "Account",
+ UseTurbineChat: true,
+ HasThroneOfDestiny: true);
}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterLogOffTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterLogOffTests.cs
index 41af606c..bfeb627b 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/CharacterLogOffTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterLogOffTests.cs
@@ -33,4 +33,12 @@ public sealed class CharacterLogOffTests
Assert.False(CharacterLogOff.IsConfirmation([0x53, 0xF6, 0x00]));
Assert.False(CharacterLogOff.IsConfirmation(BitConverter.GetBytes(0xF654u)));
}
+
+ [Fact]
+ public void IsConfirmation_RejectsRequestShapedBodyWithCharacterId()
+ {
+ byte[] request = CharacterLogOff.BuildRequestBody(0x5000000Au);
+
+ Assert.False(CharacterLogOff.IsConfirmation(request));
+ }
}
diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionNegotiationShutdownTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionNegotiationShutdownTests.cs
new file mode 100644
index 00000000..90d70e57
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/WorldSessionNegotiationShutdownTests.cs
@@ -0,0 +1,77 @@
+using System.Buffers.Binary;
+using System.Net;
+using System.Net.Sockets;
+using AcDream.Core.Net.Cryptography;
+using AcDream.Core.Net.Packets;
+
+namespace AcDream.Core.Net.Tests;
+
+public sealed class WorldSessionNegotiationShutdownTests
+{
+ [Fact]
+ public async Task ThrowingServerTimeSubscriber_StillDisposesWithNegotiatedDisconnect()
+ {
+ using var server = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0));
+ var serverEndpoint = (IPEndPoint)server.Client.LocalEndPoint!;
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+
+ Task serverFlow = Task.Run(async () =>
+ {
+ UdpReceiveResult login = await server.ReceiveAsync(timeout.Token);
+ byte[] connectRequest = BuildConnectRequest(
+ clientId: 0x1234,
+ iteration: 7,
+ serverTime: 12345.5);
+ await server.SendAsync(connectRequest, login.RemoteEndPoint, timeout.Token);
+
+ UdpReceiveResult disconnect = await server.ReceiveAsync(timeout.Token);
+ return disconnect.Buffer;
+ }, timeout.Token);
+
+ using var session = new WorldSession(serverEndpoint);
+ session.ServerTimeUpdated += _ =>
+ throw new InvalidOperationException("subscriber failed");
+
+ Assert.Throws(() =>
+ session.Connect("typed-account", "password", TimeSpan.FromSeconds(3)));
+
+ session.Dispose();
+ byte[] datagram = await serverFlow;
+ PacketCodec.PacketDecodeResult decoded =
+ PacketCodec.TryDecode(datagram, inboundIsaac: null);
+
+ Assert.True(decoded.IsOk, decoded.Error.ToString());
+ Assert.True(decoded.Packet!.Header.HasFlag(PacketHeaderFlags.Disconnect));
+ Assert.Equal((ushort)0x1234, decoded.Packet.Header.Id);
+ Assert.Equal((ushort)7, decoded.Packet.Header.Iteration);
+ Assert.Equal(WorldSession.State.Disconnected, session.CurrentState);
+ }
+
+ private static byte[] BuildConnectRequest(
+ ushort clientId,
+ ushort iteration,
+ double serverTime)
+ {
+ byte[] body = new byte[32];
+ BinaryPrimitives.WriteInt64LittleEndian(
+ body,
+ BitConverter.DoubleToInt64Bits(serverTime));
+ BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(8), 0xFEEDFACECAFEBABEUL);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), clientId);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), 0xAABBCCDDu);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), 0x01020304u);
+
+ var header = new PacketHeader
+ {
+ Flags = PacketHeaderFlags.ConnectRequest,
+ DataSize = (ushort)body.Length,
+ Iteration = iteration,
+ };
+ header.Checksum = header.CalculateHeaderHash32() + Hash32.Calculate(body);
+
+ byte[] datagram = new byte[PacketHeader.Size + body.Length];
+ header.Pack(datagram);
+ body.CopyTo(datagram.AsSpan(PacketHeader.Size));
+ return datagram;
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionShutdownTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionShutdownTests.cs
new file mode 100644
index 00000000..4bff3d30
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/WorldSessionShutdownTests.cs
@@ -0,0 +1,226 @@
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Net.Packets;
+using System.Diagnostics;
+using System.Threading.Channels;
+
+namespace AcDream.Core.Net.Tests;
+
+public sealed class WorldSessionShutdownTests
+{
+ [Theory]
+ [InlineData(WorldSession.State.Disconnected)]
+ [InlineData(WorldSession.State.Handshaking)]
+ public void PreNegotiationShutdown_OnlyReleasesLocalTransport(
+ WorldSession.State state)
+ {
+ WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan(
+ state,
+ transportNegotiated: false,
+ activeCharacterId: 0);
+
+ Assert.False(plan.RequestCharacterLogOff);
+ Assert.False(plan.SendTransportDisconnect);
+ }
+
+ [Theory]
+ [InlineData(WorldSession.State.Handshaking)]
+ [InlineData(WorldSession.State.InCharacterSelect)]
+ [InlineData(WorldSession.State.EnteringWorld)]
+ [InlineData(WorldSession.State.Failed)]
+ [InlineData(WorldSession.State.Disconnected)]
+ public void EveryNegotiatedNonWorldState_SendsTransportDisconnectOnly(
+ WorldSession.State state)
+ {
+ WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan(
+ state,
+ transportNegotiated: true,
+ activeCharacterId: 0x50000001u);
+
+ Assert.False(plan.RequestCharacterLogOff);
+ Assert.True(plan.SendTransportDisconnect);
+ }
+
+ [Fact]
+ public void NegotiatedInWorldState_RequestsCharacterLogoffThenDisconnect()
+ {
+ WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan(
+ WorldSession.State.InWorld,
+ transportNegotiated: true,
+ activeCharacterId: 0x50000001u);
+
+ Assert.True(plan.RequestCharacterLogOff);
+ Assert.True(plan.SendTransportDisconnect);
+ }
+
+ [Fact]
+ public void InWorldWithoutActiveCharacter_DoesNotSendMalformedF653Request()
+ {
+ WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan(
+ WorldSession.State.InWorld,
+ transportNegotiated: true,
+ activeCharacterId: 0);
+
+ Assert.False(plan.RequestCharacterLogOff);
+ Assert.True(plan.SendTransportDisconnect);
+ }
+
+ [Fact]
+ public void ImpossibleUnnegotiatedInWorldState_DoesNotAttemptWireMessages()
+ {
+ WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan(
+ WorldSession.State.InWorld,
+ transportNegotiated: false,
+ activeCharacterId: 0x50000001u);
+
+ Assert.False(plan.RequestCharacterLogOff);
+ Assert.False(plan.SendTransportDisconnect);
+ }
+
+ [Fact]
+ public void ExecuteShutdownWire_SendsExactRequestWaitThenNegotiatedDisconnect()
+ {
+ var order = new List();
+ byte[]? request = null;
+ byte[]? disconnect = null;
+ WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan(
+ WorldSession.State.InWorld,
+ transportNegotiated: true,
+ activeCharacterId: 0x5000000Au);
+
+ WorldSession.ShutdownExecutionResult result = WorldSession.ExecuteShutdownWire(
+ plan,
+ activeCharacterId: 0x5000000Au,
+ sessionClientId: 0x1234,
+ sessionIteration: 7,
+ sendGameMessage: body =>
+ {
+ order.Add("F653");
+ request = body;
+ },
+ waitForConfirmation: timeout =>
+ {
+ order.Add("wait");
+ Assert.Equal(TimeSpan.FromSeconds(35), timeout);
+ return true;
+ },
+ sendTransportDatagram: datagram =>
+ {
+ order.Add("disconnect");
+ disconnect = datagram;
+ },
+ confirmationTimeout: TimeSpan.FromSeconds(35));
+
+ Assert.Equal(["F653", "wait", "disconnect"], order);
+ Assert.Equal(CharacterLogOff.BuildRequestBody(0x5000000Au), request);
+ PacketCodec.PacketDecodeResult decoded =
+ PacketCodec.TryDecode(disconnect!, inboundIsaac: null);
+ Assert.True(decoded.IsOk, decoded.Error.ToString());
+ Assert.True(decoded.Packet!.Header.HasFlag(PacketHeaderFlags.Disconnect));
+ Assert.Equal((ushort)0x1234, decoded.Packet.Header.Id);
+ Assert.Equal((ushort)7, decoded.Packet.Header.Iteration);
+ Assert.True(result.CharacterLogOffSent);
+ Assert.True(result.ConfirmationReceived);
+ Assert.True(result.TransportDisconnectSent);
+ Assert.Null(result.CharacterLogOffError);
+ Assert.Null(result.TransportDisconnectError);
+ }
+
+ [Fact]
+ public void ExecuteShutdownWire_RequestFailureStillSendsDisconnect()
+ {
+ var order = new List();
+ WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan(
+ WorldSession.State.InWorld,
+ transportNegotiated: true,
+ activeCharacterId: 0x50000001u);
+
+ WorldSession.ShutdownExecutionResult result = WorldSession.ExecuteShutdownWire(
+ plan,
+ activeCharacterId: 0x50000001u,
+ sessionClientId: 1,
+ sessionIteration: 2,
+ sendGameMessage: _ =>
+ {
+ order.Add("F653");
+ throw new IOException("send failed");
+ },
+ waitForConfirmation: _ =>
+ {
+ order.Add("wait");
+ return true;
+ },
+ sendTransportDatagram: _ => order.Add("disconnect"),
+ confirmationTimeout: TimeSpan.FromSeconds(1));
+
+ Assert.Equal(["F653", "disconnect"], order);
+ Assert.False(result.CharacterLogOffSent);
+ Assert.False(result.ConfirmationReceived);
+ Assert.True(result.TransportDisconnectSent);
+ Assert.IsType(result.CharacterLogOffError);
+ }
+
+ [Fact]
+ public void WaitForConfirmation_CompletedEmptyChannelReturnsWithoutSpinning()
+ {
+ Channel channel = Channel.CreateUnbounded();
+ channel.Writer.TryComplete();
+ var stopwatch = Stopwatch.StartNew();
+
+ bool confirmed = WorldSession.WaitForCharacterLogOffConfirmation(
+ channel.Reader,
+ TimeSpan.FromSeconds(2),
+ _ => false);
+
+ Assert.False(confirmed);
+ Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(1));
+ }
+
+ [Fact]
+ public void WaitForConfirmation_IgnoresMalformedBodiesUntilExactOpcodeOnlyBody()
+ {
+ Channel channel = Channel.CreateUnbounded();
+ channel.Writer.TryWrite([0x53, 0xF6, 0x00]);
+ channel.Writer.TryWrite(BitConverter.GetBytes(0xF654u));
+ channel.Writer.TryWrite(CharacterLogOff.BuildRequestBody(0x50000001u));
+ channel.Writer.TryWrite(BitConverter.GetBytes(CharacterLogOff.Opcode));
+ channel.Writer.TryComplete();
+ int processed = 0;
+
+ bool confirmed = WorldSession.WaitForCharacterLogOffConfirmation(
+ channel.Reader,
+ TimeSpan.FromSeconds(1),
+ body =>
+ {
+ processed++;
+ return CharacterLogOff.IsConfirmation(body);
+ });
+
+ Assert.True(confirmed);
+ Assert.Equal(4, processed);
+ }
+
+ [Fact]
+ public void WaitForConfirmation_TimeoutWinsDuringContinuousUnrelatedDrain()
+ {
+ Channel channel = Channel.CreateUnbounded();
+ for (int i = 0; i < 100; i++)
+ channel.Writer.TryWrite(BitConverter.GetBytes(0xF654u));
+ int processed = 0;
+ var stopwatch = Stopwatch.StartNew();
+
+ bool confirmed = WorldSession.WaitForCharacterLogOffConfirmation(
+ channel.Reader,
+ TimeSpan.FromMilliseconds(25),
+ _ =>
+ {
+ processed++;
+ Thread.Sleep(2);
+ return false;
+ });
+
+ Assert.False(confirmed);
+ Assert.True(processed < 100);
+ Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(1));
+ }
+}