acdream/docs/research/2026-07-20-retail-graceful-logout-pseudocode.md
Erik 68578fa5fa 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.
2026-07-20 23:31:23 +02:00

2.6 KiB

Retail graceful character logout

Oracle

  • Proto_UI::LogOffCharacter 0x00546A20
  • CPlayerSystem::RequestLogOff 0x00562DD0
  • inbound login-message dispatch at 0x0055C963
  • CPlayerSystem::ExecuteLogOff 0x0055D780
  • ClientNet::ExitWorldDisconnect 0x00541E00
  • ClientNet::LogOffServer 0x00543EF0
  • SharedNet::SendOptionalHeader 0x00543160
  • CPlayerSystem::LogOffCharacter 0x00563520

Cross-checks:

  • ACE CharacterHandler.CharacterLogOff begins asynchronous player removal.
  • ACE Session.SendFinalLogOffMessages sends the opcode-only CharacterLogOff confirmation only after the player no longer owns a landblock, then returns the session to AuthConnected.
  • Holtburger currently sends opcode-only and disconnects immediately. Its own comment records that AC normally waits for the server response. That shortcut is not the retail oracle and was the source of acdream's reconnect race.

Pseudocode

LogOffCharacter(force):
    save player module
    if force:
        ExecuteLogOff()
        return
    if no interaction is pending:
        RequestLogOff()
    else:
        print "Logging off..."
        remember that logout must begin after the interaction

RequestLogOff():
    print "Logging off..."
    send_to_logon([0xF653, active_player_id])
    logOffRequested = true
    logOffRequestTime = now + 3 seconds
    if player is PK:
        logOffRequestTime += 20 seconds

on inbound login message 0xF653:
    ExecuteLogOff()

ExecuteLogOff():
    clear player-login state
    for every live ReceiverData connection:
        send a cleartext, empty DISCONNECT optional header
        use sequence 0 plus that receiver's network id and iteration
    disconnect the world connection
    clear the active player id

acdream close-only adaptation

acdream does not yet return to character selection when its native window is closed. It retains retail's important ordering: send the eight-byte request, continue receiving until the server's opcode-only confirmation arrives, then send the transport DISCONNECT and release the socket. World callbacks are not dispatched while shutdown drains the raw receive queue because their App owners may already be tearing down. A bounded 35-second wait covers retail's additional 20-second PK logout delay and is only a transport safety limit; the normal path advances on the authoritative confirmation, not elapsed time. The connected gate separately observes ACE accepting the transport header before opening a replacement process; ACE removes the authenticated session on its world-manager tick, after the UDP sender has already completed.