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:
Erik 2026-07-20 23:31:23 +02:00
parent b03371c03d
commit 68578fa5fa
10 changed files with 372 additions and 41 deletions

View 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;
}

View 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);
}
}

View file

@ -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);
}
}