From aea957f8450eac54e5825f66c2bf6c455d3ab5d5 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 10:33:03 +0200 Subject: [PATCH] fix(net): conform character entry and session shutdown --- .../retail-divergence-register.md | 2 +- ...etail-live-session-lifecycle-pseudocode.md | 157 ++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 16 +- .../Messages/CharacterList.cs | 116 +++++++-- .../Messages/CharacterLogOff.cs | 2 +- src/AcDream.Core.Net/WorldSession.cs | 242 ++++++++++++++---- .../LiveHandshakeTests.cs | 7 +- .../Messages/CharacterEnterWorldTests.cs | 62 +++++ .../Messages/CharacterListTests.cs | 196 +++++++++++++- .../Messages/CharacterLogOffTests.cs | 8 + .../WorldSessionNegotiationShutdownTests.cs | 77 ++++++ .../WorldSessionShutdownTests.cs | 226 ++++++++++++++++ 12 files changed, 1016 insertions(+), 95 deletions(-) create mode 100644 docs/research/2026-07-21-retail-live-session-lifecycle-pseudocode.md create mode 100644 tests/AcDream.Core.Net.Tests/WorldSessionNegotiationShutdownTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/WorldSessionShutdownTests.cs 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)); + } +}