feat(net): graceful CharacterLogOff + DISCONNECT on session Dispose

When closing the acdream window, WorldSession.Dispose now sends
CharacterLogOff (game message 0xF653, no payload) followed by a bare
DISCONNECT control packet (header flag 0x8000) before closing the
UDP socket. This tells ACE to release the character lock immediately
instead of waiting for the ~60s network timeout — which was blocking
rapid iteration during testing since acdream now does a proper login
(Phase 4.8-4.10) and ACE holds the character in-world.

Pattern from references/holtburger/.../client/commands.rs lines
879-892 (Quit handler). Best-effort: if the socket is already dead,
the exception is eaten and Dispose finishes cleanup normally.

220 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 08:38:15 +02:00
parent 5666e05a85
commit fb3c8ebdaa

View file

@ -431,5 +431,45 @@ public sealed class WorldSession : IDisposable
StateChanged?.Invoke(next);
}
public void Dispose() => _net.Dispose();
/// <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).
/// </summary>
public void Dispose()
{
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());
// 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);
}
catch
{
// Best-effort — if the socket is already dead, eat the
// exception and let Dispose finish cleaning up.
}
}
_net.Dispose();
}
}