diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 0e4ee1c..f2e76d6 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -431,5 +431,45 @@ public sealed class WorldSession : IDisposable
StateChanged?.Invoke(next);
}
- public void Dispose() => _net.Dispose();
+ ///
+ /// 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
+ /// references/holtburger/crates/holtburger-core/src/client/commands.rs
+ /// lines 879-892: send CharacterLogOff game message (opcode
+ /// 0xF653, no payload) then send a bare DISCONNECT control
+ /// packet (header flag 0x8000, no payload).
+ ///
+ 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.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();
+ }
}