fix(net): honor retail graceful logout handshake
Send the active character id, drain until the authoritative server confirmation, then emit retail's zero-sequence connection disconnect with the negotiated receiver iteration. The connected gate now waits for ACE to remove the exact UDP session before reconnecting, eliminating fixed-delay races.
This commit is contained in:
parent
b03371c03d
commit
68578fa5fa
10 changed files with 372 additions and 41 deletions
38
src/AcDream.Core.Net/Messages/CharacterLogOff.cs
Normal file
38
src/AcDream.Core.Net/Messages/CharacterLogOff.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Packets;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Retail character-logoff request and server confirmation (opcode
|
||||
/// <c>0xF653</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail <c>Proto_UI::LogOffCharacter</c> at <c>0x00546A20</c> sends the
|
||||
/// opcode followed by the active character id. The server confirmation uses
|
||||
/// the same opcode with no trailing payload. <c>CPlayerSystem::RequestLogOff</c>
|
||||
/// at <c>0x00562DD0</c> waits for that confirmation; its inbound dispatch calls
|
||||
/// <c>CPlayerSystem::ExecuteLogOff</c> at <c>0x0055D780</c>, which only then
|
||||
/// disconnects the world connection.
|
||||
/// </remarks>
|
||||
public static class CharacterLogOff
|
||||
{
|
||||
public const uint Opcode = 0xF653u;
|
||||
|
||||
/// <summary>Builds retail's eight-byte client request.</summary>
|
||||
public static byte[] BuildRequestBody(uint characterId)
|
||||
{
|
||||
var writer = new PacketWriter(8);
|
||||
writer.WriteUInt32(Opcode);
|
||||
writer.WriteUInt32(characterId);
|
||||
return writer.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether a complete game-message body is the server's logoff
|
||||
/// confirmation. ACE emits the canonical four-byte opcode-only form.
|
||||
/// </summary>
|
||||
public static bool IsConfirmation(ReadOnlySpan<byte> body) =>
|
||||
body.Length >= sizeof(uint) &&
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body) == Opcode;
|
||||
}
|
||||
31
src/AcDream.Core.Net/Packets/TransportDisconnect.cs
Normal file
31
src/AcDream.Core.Net/Packets/TransportDisconnect.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
namespace AcDream.Core.Net.Packets;
|
||||
|
||||
/// <summary>
|
||||
/// Builds retail's connection-level disconnect packet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>ClientNet::LogOffServer</c> at <c>0x00543EF0</c> creates the
|
||||
/// <c>0x8000</c> optional header and sends it through
|
||||
/// <c>SharedNet::SendOptionalHeader</c> at <c>0x00543160</c>. That path
|
||||
/// zero-initializes the packet sequence and copies the receiver's network id
|
||||
/// and iteration into the fixed header. The disconnect is cleartext and has
|
||||
/// no body.
|
||||
/// </remarks>
|
||||
public static class TransportDisconnect
|
||||
{
|
||||
public static byte[] Build(ushort networkId, ushort iteration)
|
||||
{
|
||||
var header = new PacketHeader
|
||||
{
|
||||
Sequence = 0,
|
||||
Flags = PacketHeaderFlags.Disconnect,
|
||||
Id = networkId,
|
||||
Iteration = iteration,
|
||||
};
|
||||
|
||||
return PacketCodec.Encode(
|
||||
header,
|
||||
ReadOnlySpan<byte>.Empty,
|
||||
outboundIsaac: null);
|
||||
}
|
||||
}
|
||||
|
|
@ -28,10 +28,9 @@ namespace AcDream.Core.Net;
|
|||
/// </code>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Not yet provided</b> (deferred): ACK pump, retransmit handling,
|
||||
/// delete-object processing, position updates, chat, disconnect detection.
|
||||
/// The current client is one-shot — connect, enter the world, stream
|
||||
/// events for a few seconds, let the test harness tear it down.
|
||||
/// <b>Still deferred:</b> retransmit handling and unsolicited-disconnect
|
||||
/// recovery. ACKs, world updates, chat, and retail-ordered graceful logout
|
||||
/// are live.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class WorldSession : IDisposable
|
||||
|
|
@ -627,6 +626,7 @@ public sealed class WorldSession : IDisposable
|
|||
private IsaacRandom? _inboundIsaac;
|
||||
private IsaacRandom? _outboundIsaac;
|
||||
private ushort _sessionClientId;
|
||||
private ushort _sessionIteration;
|
||||
private uint _clientPacketSequence;
|
||||
private uint _fragmentSequence = 1;
|
||||
|
||||
|
|
@ -639,6 +639,9 @@ public sealed class WorldSession : IDisposable
|
|||
private ushort _serverControlSequence;
|
||||
private ushort _teleportSequence;
|
||||
private ushort _forcePositionSequence;
|
||||
private uint _activeCharacterId;
|
||||
private int _characterLogOffConfirmed;
|
||||
private int _disposeStarted;
|
||||
|
||||
// Phase A.3: background receive thread buffers raw UDP datagrams into
|
||||
// a channel so the render thread never blocks on socket I/O.
|
||||
|
|
@ -739,6 +742,10 @@ public sealed class WorldSession : IDisposable
|
|||
_inboundIsaac = new IsaacRandom(serverSeedBytes);
|
||||
_outboundIsaac = new IsaacRandom(clientSeedBytes);
|
||||
_sessionClientId = (ushort)opt.ConnectRequestClientId;
|
||||
// SharedNet::SendOptionalHeader @ 0x00543160 copies this ReceiverData
|
||||
// generation into connection-level control packets, including the
|
||||
// final disconnect. ACE currently emits iteration 1.
|
||||
_sessionIteration = cr.Header.Iteration;
|
||||
_clientPacketSequence = 2;
|
||||
|
||||
byte[] crBody = new byte[8];
|
||||
|
|
@ -772,6 +779,7 @@ public sealed class WorldSession : IDisposable
|
|||
|
||||
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
|
||||
var chosen = Characters.Characters[characterIndex];
|
||||
_activeCharacterId = chosen.Id;
|
||||
Transition(State.EnteringWorld);
|
||||
|
||||
SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody());
|
||||
|
|
@ -910,7 +918,10 @@ public sealed class WorldSession : IDisposable
|
|||
return true;
|
||||
}
|
||||
|
||||
private void ProcessDatagram(byte[] bytes, List<uint>? opcodesOut = null)
|
||||
private void ProcessDatagram(
|
||||
byte[] bytes,
|
||||
List<uint>? opcodesOut = null,
|
||||
bool dispatchWorldEvents = true)
|
||||
{
|
||||
var dec = PacketCodec.TryDecode(bytes, _inboundIsaac);
|
||||
if (!dec.IsOk) return;
|
||||
|
|
@ -954,6 +965,19 @@ public sealed class WorldSession : IDisposable
|
|||
uint op = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
||||
opcodesOut?.Add(op);
|
||||
|
||||
// Retail waits for the server's opcode-only CharacterLogOff echo
|
||||
// before tearing down the world connection. Record it even while
|
||||
// Dispose is draining the raw receive queue without dispatching
|
||||
// world callbacks to owners that are already shutting down.
|
||||
if (CharacterLogOff.IsConfirmation(body))
|
||||
{
|
||||
Interlocked.Exchange(ref _characterLogOffConfirmed, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!dispatchWorldEvents)
|
||||
continue;
|
||||
|
||||
if (op == CharacterList.Opcode && Characters is null)
|
||||
{
|
||||
try { Characters = CharacterList.Parse(body); }
|
||||
|
|
@ -1915,41 +1939,48 @@ public sealed class WorldSession : IDisposable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Graceful shutdown: tell the server we're leaving so it releases the
|
||||
/// character lock immediately instead of waiting 60s for the session to
|
||||
/// time out. Pattern from
|
||||
/// <c>references/holtburger/crates/holtburger-core/src/client/commands.rs</c>
|
||||
/// lines 879-892: send <c>CharacterLogOff</c> game message (opcode
|
||||
/// 0xF653, no payload) then send a bare <c>DISCONNECT</c> control
|
||||
/// packet (header flag 0x8000, no payload).
|
||||
/// Graceful shutdown: request character logoff, wait for the server's
|
||||
/// authoritative <c>0xF653</c> confirmation, and only then disconnect the
|
||||
/// transport. This is retail's <c>CPlayerSystem::RequestLogOff</c> to
|
||||
/// inbound confirmation to <c>ExecuteLogOff</c> ordering. It prevents a
|
||||
/// replacement session from racing the old character's asynchronous
|
||||
/// removal on ACE.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
|
||||
return;
|
||||
|
||||
if (CurrentState == State.InWorld)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Tell ACE "player is leaving the world" so it cleans up
|
||||
// the character immediately.
|
||||
var logoff = new Packets.PacketWriter(8);
|
||||
logoff.WriteUInt32(0xF653u); // CharacterLogOff opcode
|
||||
SendGameMessage(logoff.ToArray());
|
||||
// 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}");
|
||||
|
||||
// Tell the transport layer "close this session."
|
||||
var disconnectHeader = new PacketHeader
|
||||
{
|
||||
Sequence = _clientPacketSequence++,
|
||||
Flags = PacketHeaderFlags.Disconnect,
|
||||
Id = _sessionClientId,
|
||||
};
|
||||
byte[] disconnectPacket = PacketCodec.Encode(
|
||||
disconnectHeader, ReadOnlySpan<byte>.Empty, outboundIsaac: null);
|
||||
_net.Send(disconnectPacket);
|
||||
if (WaitForCharacterLogOffConfirmation(TimeSpan.FromSeconds(35)))
|
||||
Console.WriteLine("[session] graceful logout confirmed");
|
||||
else
|
||||
Console.Error.WriteLine(
|
||||
"[session] graceful logout confirmation timed out; disconnecting transport");
|
||||
}
|
||||
catch
|
||||
catch (Exception error)
|
||||
{
|
||||
// Best-effort — if the socket is already dead, eat the
|
||||
// exception and let Dispose finish cleaning up.
|
||||
Console.Error.WriteLine(
|
||||
$"[session] graceful logout failed: {error.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
SendTransportDisconnect();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[session] transport disconnect failed: {error.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1961,5 +1992,47 @@ public sealed class WorldSession : IDisposable
|
|||
_netCancel.Dispose();
|
||||
|
||||
_net.Dispose();
|
||||
Transition(State.Disconnected);
|
||||
}
|
||||
|
||||
private bool WaitForCharacterLogOffConfirmation(TimeSpan timeout)
|
||||
{
|
||||
using var timeoutSource = new CancellationTokenSource(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
while (Volatile.Read(ref _characterLogOffConfirmed) == 0)
|
||||
{
|
||||
while (_inboundQueue.Reader.TryRead(out byte[]? bytes))
|
||||
{
|
||||
ProcessDatagram(bytes, dispatchWorldEvents: false);
|
||||
if (Volatile.Read(ref _characterLogOffConfirmed) != 0)
|
||||
return true;
|
||||
}
|
||||
|
||||
_inboundQueue.Reader.WaitToReadAsync(timeoutSource.Token)
|
||||
.AsTask()
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (ChannelClosedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void SendTransportDisconnect()
|
||||
{
|
||||
byte[] disconnectPacket = TransportDisconnect.Build(
|
||||
_sessionClientId,
|
||||
_sessionIteration);
|
||||
_net.Send(disconnectPacket);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue