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
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -7,22 +7,25 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// Inbound <c>CharacterList</c> GameMessage (opcode <c>0xF658</c>).
|
||||
/// 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.
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout (ported from ACE's <c>GameMessageCharacterList.cs</c>
|
||||
/// writer — see NOTICE.md for attribution):
|
||||
/// Wire layout ported from retail <c>CharacterSet::UnPack</c> at
|
||||
/// <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>
|
||||
///
|
||||
/// <code>
|
||||
/// 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<Character> Characters,
|
||||
uint SlotCount,
|
||||
IReadOnlyList<Character> 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);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,6 @@ public static class CharacterLogOff
|
|||
/// confirmation. ACE emits the canonical four-byte opcode-only form.
|
||||
/// </summary>
|
||||
public static bool IsConfirmation(ReadOnlySpan<byte> body) =>
|
||||
body.Length >= sizeof(uint) &&
|
||||
body.Length == sizeof(uint) &&
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body) == Opcode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <see cref="Tick"/> to stream events).
|
||||
/// </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)
|
||||
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);
|
||||
|
||||
/// <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
|
||||
// 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);
|
||||
|
||||
/// <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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue