fix(net): conform character entry and session shutdown
This commit is contained in:
parent
bacc7e45a9
commit
aea957f845
12 changed files with 1016 additions and 95 deletions
|
|
@ -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-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-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-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` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -2867,6 +2867,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
_liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents);
|
_liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents);
|
||||||
if (_liveSession is null)
|
if (_liveSession is null)
|
||||||
{
|
{
|
||||||
|
_commandBus = null;
|
||||||
_combatChatTranslator?.Dispose();
|
_combatChatTranslator?.Dispose();
|
||||||
_combatChatTranslator = null;
|
_combatChatTranslator = null;
|
||||||
_liveSessionController = null;
|
_liveSessionController = null;
|
||||||
|
|
@ -2883,9 +2884,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
_liveSession.Connect(user, pass);
|
_liveSession.Connect(user, pass);
|
||||||
Chat.OnSystemMessage("connected — character list received", chatType: 1);
|
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.Dispose();
|
||||||
_liveSessionController = null;
|
_liveSessionController = null;
|
||||||
_liveSession = null;
|
_liveSession = null;
|
||||||
|
|
@ -2893,7 +2900,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var chosen = _liveSession.Characters.Characters[0];
|
var chosen = selection.Character;
|
||||||
_playerServerGuid = chosen.Id;
|
_playerServerGuid = chosen.Id;
|
||||||
_vitalsVm?.SetLocalPlayerGuid(chosen.Id);
|
_vitalsVm?.SetLocalPlayerGuid(chosen.Id);
|
||||||
Chat.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
|
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = chosen.Id; // TEMP #138-B probe
|
||||||
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
|
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
|
||||||
Combat.Clear();
|
Combat.Clear();
|
||||||
_liveSession.EnterWorld(user, characterIndex: 0);
|
_liveSession.EnterWorld(characterIndex: selection.ActiveIndex);
|
||||||
|
|
||||||
_activeToonKey = chosen.Name;
|
_activeToonKey = chosen.Name;
|
||||||
_retailUiRuntime?.RestoreLayout();
|
_retailUiRuntime?.RestoreLayout();
|
||||||
|
|
@ -2919,6 +2926,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"live: session failed: {ex.Message}");
|
Console.WriteLine($"live: session failed: {ex.Message}");
|
||||||
|
_commandBus = null;
|
||||||
_combatChatTranslator?.Dispose();
|
_combatChatTranslator?.Dispose();
|
||||||
_combatChatTranslator = null;
|
_combatChatTranslator = null;
|
||||||
_liveSessionController?.Dispose();
|
_liveSessionController?.Dispose();
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,25 @@ namespace AcDream.Core.Net.Messages;
|
||||||
/// Inbound <c>CharacterList</c> GameMessage (opcode <c>0xF658</c>).
|
/// Inbound <c>CharacterList</c> GameMessage (opcode <c>0xF658</c>).
|
||||||
/// The server sends one of these right after ConnectResponse completes
|
/// The server sends one of these right after ConnectResponse completes
|
||||||
/// the handshake — it's the client's "pick which character to log in"
|
/// the handshake — it's the client's "pick which character to log in"
|
||||||
/// data. Contains one entry per character on the account plus account-
|
/// data. Contains the retail status, separate active and deleted character
|
||||||
/// level settings (slot count, Turbine chat toggle, ToD flag).
|
/// collections, plus account-level settings.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Wire layout (ported from ACE's <c>GameMessageCharacterList.cs</c>
|
/// Wire layout ported from retail <c>CharacterSet::UnPack</c> at
|
||||||
/// writer — see NOTICE.md for attribution):
|
/// <c>0x004FE340</c> and <c>CharacterIdentity::UnPack</c> at
|
||||||
|
/// <c>0x004FE880</c>. ACE currently emits zero for both status and deleted
|
||||||
|
/// count, which is why older readers mislabeled them as padding:
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <code>
|
/// <code>
|
||||||
/// u32 0 (leading padding, always 0)
|
/// u32 status
|
||||||
/// u32 characterCount
|
/// u32 activeCount
|
||||||
/// for each character:
|
/// for each active character:
|
||||||
/// u32 characterId (GUID)
|
/// u32 characterId (GUID)
|
||||||
/// String16L name (prefixed with '+' for admin chars)
|
/// String16L name (prefixed with '+' for admin chars)
|
||||||
/// u32 deleteTimeDelta (0 if not scheduled for deletion)
|
/// u32 secondsGreyedOut
|
||||||
/// u32 0 (trailing padding)
|
/// u32 deletedCount
|
||||||
|
/// for each deleted character: same CharacterIdentity record
|
||||||
/// u32 slotCount (max characters per account)
|
/// u32 slotCount (max characters per account)
|
||||||
/// String16L accountName
|
/// String16L accountName
|
||||||
/// u32 useTurbineChat (bool)
|
/// u32 useTurbineChat (bool)
|
||||||
|
|
@ -33,11 +36,15 @@ public static class CharacterList
|
||||||
{
|
{
|
||||||
public const uint Opcode = 0xF658u;
|
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(
|
public sealed record Parsed(
|
||||||
|
uint Status,
|
||||||
IReadOnlyList<Character> Characters,
|
IReadOnlyList<Character> Characters,
|
||||||
uint SlotCount,
|
IReadOnlyList<Character> DeletedCharacters,
|
||||||
|
int SlotCount,
|
||||||
string AccountName,
|
string AccountName,
|
||||||
bool UseTurbineChat,
|
bool UseTurbineChat,
|
||||||
bool HasThroneOfDestiny);
|
bool HasThroneOfDestiny);
|
||||||
|
|
@ -55,27 +62,78 @@ public static class CharacterList
|
||||||
if (opcode != Opcode)
|
if (opcode != Opcode)
|
||||||
throw new FormatException($"expected CharacterList opcode 0x{Opcode:X4}, got 0x{opcode:X8}");
|
throw new FormatException($"expected CharacterList opcode 0x{Opcode:X4}, got 0x{opcode:X8}");
|
||||||
|
|
||||||
_ = ReadU32(body, ref pos); // leading 0
|
uint status = ReadU32(body, ref pos);
|
||||||
uint count = ReadU32(body, ref pos);
|
Character[] characters = ReadCharacters(body, ref pos, "active");
|
||||||
if (count > 255)
|
Character[] deletedCharacters = ReadCharacters(body, ref pos, "deleted");
|
||||||
throw new FormatException($"character count {count} exceeds sanity limit");
|
int slotCount = unchecked((int)ReadU32(body, ref pos));
|
||||||
|
|
||||||
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);
|
|
||||||
string accountName = ReadString16L(body, ref pos);
|
string accountName = ReadString16L(body, ref pos);
|
||||||
bool useTurbineChat = ReadU32(body, ref pos) != 0;
|
bool useTurbineChat = ReadU32(body, ref pos) != 0;
|
||||||
bool hasThroneOfDestiny = 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsAvailableActiveIdentity(Character character) =>
|
||||||
|
character.Id != 0 && character.SecondsGreyedOut == 0;
|
||||||
|
|
||||||
|
private static Character[] ReadCharacters(
|
||||||
|
ReadOnlySpan<byte> 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<byte> source, ref int pos)
|
private static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
|
||||||
|
|
@ -97,6 +155,8 @@ public static class CharacterList
|
||||||
pos += length;
|
pos += length;
|
||||||
int recordSize = 2 + length;
|
int recordSize = 2 + length;
|
||||||
int padding = (4 - (recordSize & 3)) & 3;
|
int padding = (4 - (recordSize & 3)) & 3;
|
||||||
|
if (source.Length - pos < padding)
|
||||||
|
throw new FormatException("truncated String16L padding");
|
||||||
pos += padding;
|
pos += padding;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,6 @@ public static class CharacterLogOff
|
||||||
/// confirmation. ACE emits the canonical four-byte opcode-only form.
|
/// confirmation. ACE emits the canonical four-byte opcode-only form.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool IsConfirmation(ReadOnlySpan<byte> body) =>
|
public static bool IsConfirmation(ReadOnlySpan<byte> body) =>
|
||||||
body.Length >= sizeof(uint) &&
|
body.Length == sizeof(uint) &&
|
||||||
BinaryPrimitives.ReadUInt32LittleEndian(body) == Opcode;
|
BinaryPrimitives.ReadUInt32LittleEndian(body) == Opcode;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -627,6 +627,7 @@ public sealed class WorldSession : IDisposable
|
||||||
private IsaacRandom? _outboundIsaac;
|
private IsaacRandom? _outboundIsaac;
|
||||||
private ushort _sessionClientId;
|
private ushort _sessionClientId;
|
||||||
private ushort _sessionIteration;
|
private ushort _sessionIteration;
|
||||||
|
private bool _transportNegotiated;
|
||||||
private uint _clientPacketSequence;
|
private uint _clientPacketSequence;
|
||||||
private uint _fragmentSequence = 1;
|
private uint _fragmentSequence = 1;
|
||||||
|
|
||||||
|
|
@ -732,9 +733,6 @@ public sealed class WorldSession : IDisposable
|
||||||
// in the ConnectRequest optional section. Publish it to
|
// in the ConnectRequest optional section. Publish it to
|
||||||
// subscribers so WorldTimeService.SyncFromServer can seed the
|
// subscribers so WorldTimeService.SyncFromServer can seed the
|
||||||
// client clock.
|
// client clock.
|
||||||
LastServerTimeTicks = opt.ConnectRequestServerTime;
|
|
||||||
ServerTimeUpdated?.Invoke(opt.ConnectRequestServerTime);
|
|
||||||
|
|
||||||
byte[] serverSeedBytes = new byte[4];
|
byte[] serverSeedBytes = new byte[4];
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, opt.ConnectRequestServerSeed);
|
BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, opt.ConnectRequestServerSeed);
|
||||||
byte[] clientSeedBytes = new byte[4];
|
byte[] clientSeedBytes = new byte[4];
|
||||||
|
|
@ -746,8 +744,15 @@ public sealed class WorldSession : IDisposable
|
||||||
// generation into connection-level control packets, including the
|
// generation into connection-level control packets, including the
|
||||||
// final disconnect. ACE currently emits iteration 1.
|
// final disconnect. ACE currently emits iteration 1.
|
||||||
_sessionIteration = cr.Header.Iteration;
|
_sessionIteration = cr.Header.Iteration;
|
||||||
|
_transportNegotiated = true;
|
||||||
_clientPacketSequence = 2;
|
_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];
|
byte[] crBody = new byte[8];
|
||||||
BinaryPrimitives.WriteUInt64LittleEndian(crBody, opt.ConnectRequestCookie);
|
BinaryPrimitives.WriteUInt64LittleEndian(crBody, opt.ConnectRequestCookie);
|
||||||
var crHeader = new PacketHeader { Sequence = 1, Flags = PacketHeaderFlags.ConnectResponse, Id = 0 };
|
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
|
/// Returns once the server starts sending CreateObjects (at which point
|
||||||
/// callers should poll <see cref="Tick"/> to stream events).
|
/// callers should poll <see cref="Tick"/> to stream events).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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)
|
if (Characters is null || Characters.Characters.Count == 0)
|
||||||
throw new InvalidOperationException("Connect() must complete with a non-empty CharacterList");
|
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 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;
|
_activeCharacterId = chosen.Id;
|
||||||
Transition(State.EnteringWorld);
|
Transition(State.EnteringWorld);
|
||||||
|
|
||||||
|
|
@ -795,7 +800,10 @@ public sealed class WorldSession : IDisposable
|
||||||
}
|
}
|
||||||
if (!serverReady) { Transition(State.Failed); throw new TimeoutException("ServerReady not received"); }
|
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
|
// NOTE: LoginComplete used to be sent here unconditionally. That was
|
||||||
// wrong — per holtburger's flow (see references/holtburger/.../client/
|
// wrong — per holtburger's flow (see references/holtburger/.../client/
|
||||||
|
|
@ -819,6 +827,35 @@ public sealed class WorldSession : IDisposable
|
||||||
_netThread.Start();
|
_netThread.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal readonly record struct EnterWorldSelection(
|
||||||
|
CharacterList.Character Character,
|
||||||
|
byte[] EnterWorldBody);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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
|
// 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
|
// 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
|
// 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)
|
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
|
||||||
return;
|
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
|
Console.WriteLine(
|
||||||
{
|
$"[session] graceful logout requested character=0x{_activeCharacterId:X8}");
|
||||||
// Proto_UI::LogOffCharacter @ 0x00546A20 sends the active id.
|
if (result.ConfirmationReceived)
|
||||||
Interlocked.Exchange(ref _characterLogOffConfirmed, 0);
|
Console.WriteLine("[session] graceful logout confirmed");
|
||||||
SendGameMessage(CharacterLogOff.BuildRequestBody(_activeCharacterId));
|
else if (result.CharacterLogOffError is null)
|
||||||
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.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
$"[session] graceful logout failed: {error.Message}");
|
"[session] graceful logout confirmation timed out; disconnecting transport");
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
SendTransportDisconnect();
|
|
||||||
}
|
|
||||||
catch (Exception error)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine(
|
|
||||||
$"[session] transport disconnect failed: {error.Message}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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
|
// Phase A.3: shut down the background receive thread. Cancel the
|
||||||
// token → the 250ms receive timeout fires → loop exits → join.
|
// token → the 250ms receive timeout fires → loop exits → join.
|
||||||
|
|
@ -1995,25 +2032,132 @@ public sealed class WorldSession : IDisposable
|
||||||
Transition(State.Disconnected);
|
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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static ShutdownExecutionResult ExecuteShutdownWire(
|
||||||
|
SessionShutdownPlan plan,
|
||||||
|
uint activeCharacterId,
|
||||||
|
ushort sessionClientId,
|
||||||
|
ushort sessionIteration,
|
||||||
|
Action<byte[]> sendGameMessage,
|
||||||
|
Func<TimeSpan, bool> waitForConfirmation,
|
||||||
|
Action<byte[]> 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<byte[]> reader,
|
||||||
|
TimeSpan timeout,
|
||||||
|
Func<byte[], bool> processAndCheckConfirmation)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(reader);
|
||||||
|
ArgumentNullException.ThrowIfNull(processAndCheckConfirmation);
|
||||||
using var timeoutSource = new CancellationTokenSource(timeout);
|
using var timeoutSource = new CancellationTokenSource(timeout);
|
||||||
|
|
||||||
try
|
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 (timeoutSource.IsCancellationRequested)
|
||||||
if (Volatile.Read(ref _characterLogOffConfirmed) != 0)
|
return false;
|
||||||
|
if (processAndCheckConfirmation(bytes))
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
_inboundQueue.Reader.WaitToReadAsync(timeoutSource.Token)
|
bool canRead = reader.WaitToReadAsync(timeoutSource.Token)
|
||||||
.AsTask()
|
.AsTask()
|
||||||
.GetAwaiter()
|
.GetAwaiter()
|
||||||
.GetResult();
|
.GetResult();
|
||||||
|
if (!canRead)
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
|
|
@ -2025,14 +2169,6 @@ public sealed class WorldSession : IDisposable
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
private void SendTransportDisconnect()
|
|
||||||
{
|
|
||||||
byte[] disconnectPacket = TransportDisconnect.Build(
|
|
||||||
_sessionClientId,
|
|
||||||
_sessionIteration);
|
|
||||||
_net.Send(disconnectPacket);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -347,7 +347,10 @@ public class LiveHandshakeTests
|
||||||
|
|
||||||
Assert.NotNull(charList);
|
Assert.NotNull(charList);
|
||||||
Assert.NotEmpty(charList!.Characters);
|
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}");
|
Console.WriteLine($"[live] choosing character: 0x{chosen.Id:X8} {chosen.Name}");
|
||||||
|
|
||||||
// ---- Step 5: send CharacterEnterWorldRequest (UIQueue, encrypted checksum) ----
|
// ---- Step 5: send CharacterEnterWorldRequest (UIQueue, encrypted checksum) ----
|
||||||
|
|
@ -403,7 +406,7 @@ public class LiveHandshakeTests
|
||||||
|
|
||||||
// ---- Step 7: send CharacterEnterWorld with the chosen GUID. ----
|
// ---- Step 7: send CharacterEnterWorld with the chosen GUID. ----
|
||||||
SendGameMessage(
|
SendGameMessage(
|
||||||
CharacterEnterWorld.BuildEnterWorldBody(chosen.Id, user),
|
CharacterEnterWorld.BuildEnterWorldBody(chosen.Id, charList.AccountName),
|
||||||
$"CharacterEnterWorld(guid=0x{chosen.Id:X8})");
|
$"CharacterEnterWorld(guid=0x{chosen.Id:X8})");
|
||||||
|
|
||||||
// ---- Step 8: receive the CreateObject flood + parse bodies. ----
|
// ---- Step 8: receive the CreateObject flood + parse bodies. ----
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
|
using AcDream.Core.Net;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Core.Net.Packets;
|
using AcDream.Core.Net.Packets;
|
||||||
|
|
||||||
|
|
@ -41,6 +42,67 @@ public class CharacterEnterWorldTests
|
||||||
Assert.Equal(0, body[pos++]);
|
Assert.Equal(0, body[pos++]);
|
||||||
Assert.Equal(4 + 4 + 16, body.Length); // opcode + guid + padded string
|
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<byte> 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<InvalidOperationException>(() =>
|
||||||
|
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<ArgumentOutOfRangeException>(() =>
|
||||||
|
WorldSession.SelectCharacterForEnterWorld(characters, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CharacterList.Parsed MakeCharacters(
|
||||||
|
IReadOnlyList<CharacterList.Character> active,
|
||||||
|
string accountName) =>
|
||||||
|
new(
|
||||||
|
Status: 0,
|
||||||
|
Characters: active,
|
||||||
|
DeletedCharacters: [],
|
||||||
|
SlotCount: 11,
|
||||||
|
AccountName: accountName,
|
||||||
|
UseTurbineChat: true,
|
||||||
|
HasThroneOfDestiny: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class GameMessageFragmentTests
|
public class GameMessageFragmentTests
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ public class CharacterListTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Parse_SingleCharacter_ExtractsGuidAndName()
|
public void Parse_SingleCharacter_ExtractsGuidAndName()
|
||||||
{
|
{
|
||||||
// Hand-assemble a CharacterList body matching ACE's writer format:
|
// Hand-assemble retail CharacterSet wire layout as emitted by ACE:
|
||||||
// u32 opcode, u32 0, u32 count=1,
|
// u32 opcode, u32 status=0, u32 activeCount=1,
|
||||||
// u32 guid, String16L name, u32 deleteDelta,
|
// u32 guid, String16L name, u32 secondsGreyedOut,
|
||||||
// u32 0, u32 slotCount, String16L accountName,
|
// u32 deletedCount=0, i32 numAllowed, String16L accountName,
|
||||||
// u32 useTurbineChat, u32 hasToD
|
// u32 useTurbineChat, u32 hasToD
|
||||||
var w = new PacketWriter(128);
|
var w = new PacketWriter(128);
|
||||||
w.WriteUInt32(CharacterList.Opcode);
|
w.WriteUInt32(CharacterList.Opcode);
|
||||||
|
|
@ -29,11 +29,13 @@ public class CharacterListTests
|
||||||
|
|
||||||
var parsed = CharacterList.Parse(w.ToArray());
|
var parsed = CharacterList.Parse(w.ToArray());
|
||||||
|
|
||||||
|
Assert.Equal(0u, parsed.Status);
|
||||||
Assert.Single(parsed.Characters);
|
Assert.Single(parsed.Characters);
|
||||||
|
Assert.Empty(parsed.DeletedCharacters);
|
||||||
Assert.Equal(0x50000001u, parsed.Characters[0].Id);
|
Assert.Equal(0x50000001u, parsed.Characters[0].Id);
|
||||||
Assert.Equal("+Acdream", parsed.Characters[0].Name);
|
Assert.Equal("+Acdream", parsed.Characters[0].Name);
|
||||||
Assert.Equal(0u, parsed.Characters[0].DeleteTimeDelta);
|
Assert.Equal(0u, parsed.Characters[0].SecondsGreyedOut);
|
||||||
Assert.Equal(11u, parsed.SlotCount);
|
Assert.Equal(11, parsed.SlotCount);
|
||||||
Assert.Equal("testaccount", parsed.AccountName);
|
Assert.Equal("testaccount", parsed.AccountName);
|
||||||
Assert.True(parsed.UseTurbineChat);
|
Assert.True(parsed.UseTurbineChat);
|
||||||
Assert.True(parsed.HasThroneOfDestiny);
|
Assert.True(parsed.HasThroneOfDestiny);
|
||||||
|
|
@ -69,6 +71,152 @@ public class CharacterListTests
|
||||||
Assert.Equal("Carol", parsed.Characters[2].Name);
|
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<FormatException>(() => CharacterList.Parse(active.ToArray()));
|
||||||
|
|
||||||
|
var deleted = new PacketWriter(20);
|
||||||
|
deleted.WriteUInt32(CharacterList.Opcode);
|
||||||
|
deleted.WriteUInt32(0);
|
||||||
|
deleted.WriteUInt32(0);
|
||||||
|
deleted.WriteUInt32(256);
|
||||||
|
Assert.Throws<FormatException>(() => 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<FormatException>(() => CharacterList.Parse(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = CharacterList.Parse(valid);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Parse_WrongOpcode_Throws()
|
public void Parse_WrongOpcode_Throws()
|
||||||
{
|
{
|
||||||
|
|
@ -84,4 +232,40 @@ public class CharacterListTests
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes, CharacterList.Opcode);
|
BinaryPrimitives.WriteUInt32LittleEndian(bytes, CharacterList.Opcode);
|
||||||
Assert.Throws<FormatException>(() => CharacterList.Parse(bytes));
|
Assert.Throws<FormatException>(() => 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<CharacterList.Character> active,
|
||||||
|
IReadOnlyList<CharacterList.Character> deleted) =>
|
||||||
|
new(
|
||||||
|
Status: 0,
|
||||||
|
Characters: active,
|
||||||
|
DeletedCharacters: deleted,
|
||||||
|
SlotCount: 11,
|
||||||
|
AccountName: "Account",
|
||||||
|
UseTurbineChat: true,
|
||||||
|
HasThroneOfDestiny: true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,4 +33,12 @@ public sealed class CharacterLogOffTests
|
||||||
Assert.False(CharacterLogOff.IsConfirmation([0x53, 0xF6, 0x00]));
|
Assert.False(CharacterLogOff.IsConfirmation([0x53, 0xF6, 0x00]));
|
||||||
Assert.False(CharacterLogOff.IsConfirmation(BitConverter.GetBytes(0xF654u)));
|
Assert.False(CharacterLogOff.IsConfirmation(BitConverter.GetBytes(0xF654u)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsConfirmation_RejectsRequestShapedBodyWithCharacterId()
|
||||||
|
{
|
||||||
|
byte[] request = CharacterLogOff.BuildRequestBody(0x5000000Au);
|
||||||
|
|
||||||
|
Assert.False(CharacterLogOff.IsConfirmation(request));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<byte[]> 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<InvalidOperationException>(() =>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
226
tests/AcDream.Core.Net.Tests/WorldSessionShutdownTests.cs
Normal file
226
tests/AcDream.Core.Net.Tests/WorldSessionShutdownTests.cs
Normal file
|
|
@ -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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<IOException>(result.CharacterLogOffError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WaitForConfirmation_CompletedEmptyChannelReturnsWithoutSpinning()
|
||||||
|
{
|
||||||
|
Channel<byte[]> channel = Channel.CreateUnbounded<byte[]>();
|
||||||
|
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<byte[]> channel = Channel.CreateUnbounded<byte[]>();
|
||||||
|
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<byte[]> channel = Channel.CreateUnbounded<byte[]>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue