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

@ -168,7 +168,7 @@ In-scope: 51. Implemented in acdream: 21. Phase M target delta: 30.
| 0xEA60 | inbound | AdminEnvirons | `CPlayerSystem::Handle_Admin__Environs` | | B | P+W | P+W | Fog presets / sound cues |
| 0xF625 | inbound | ObjDescEvent | `SmartBox::HandleObjDescEvent` | PB | B | P+W | P+W | Per-entity appearance update |
| 0xF643 | inbound | CharacterCreateResponse | | PB | B | | defer:char-creation | Char-creation flow not yet built |
| 0xF653 | outbound | CharacterLogOff | | PB | P | B | PB+W | Sent on Dispose; ACE accepts |
| 0xF653 | both | CharacterLogOff | | PB | P | B | PB+W | Request carries active character GUID; opcode-only server confirmation gates transport disconnect |
| 0xF655 | both | CharacterDelete | | PB | P | | defer:char-mgmt | Char-management UI deferred |
| 0xF656 | outbound | CharacterCreate | | PB | P | | defer:char-creation | Char-creation flow not yet built |
| 0xF657 | outbound | CharacterEnterWorld | `CM_Login::SendNotice_BeginEnterWorld` [^m-2] | PB | P | B | PB+W | Built; sent during handshake |

View file

@ -0,0 +1,70 @@
# 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
```text
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.

View file

@ -181,7 +181,7 @@ is the opcode itself, followed immediately by the payload.
| 0xF619 | PositionAndMovement | GM | S→C | unhandled | P4 | Ghost opcode — declared but never fired by ACE. |
| 0xF625 | ObjDescEvent | GM | S→C | unhandled | P1 | `u32 guid, ObjectDescription` — full re-send of visual description (body parts, textures, palettes). Critical for seeing other players' gear changes. |
| 0xF643 | CharacterCreateResponse / CharacterRestoreResponse | GM | S→C | unhandled | P0+ | `u32 responseCode` — login-phase, only relevant after char-create. Same opcode, two semantics (disambiguated by session state). |
| 0xF653 | CharacterLogOff | GM | bi | partial | P0 | No payload. Client sends before Disconnect to release the character lock immediately. acdream sends it from `Dispose`. |
| 0xF653 | CharacterLogOff | GM | bi | parsed | P0 | Client request is opcode + active character GUID; server confirmation is opcode-only. acdream waits for confirmation before transport Disconnect. |
| 0xF655 | CharacterDelete | GM | bi | unhandled | P4 | `u32 slot` — char-select-screen deletion. |
| 0xF656 | CharacterCreate | GM | C→S | unhandled | P4 | Full character-creation blob — heritage, gender, starting town, appearance. |
| 0xF657 | CharacterEnterWorld | GM | C→S | done | P0 | `u32 characterGuid, string16L account`. Built by `Messages/CharacterEnterWorld.cs`. |
@ -859,8 +859,9 @@ All the P3/P4 tails. Ship when we need them, not before.
- **0x00A3/0x00A4 FellowshipQuit/Dismiss** — same pairing; C→S in
GameAction, S→C in GameEvent.
- **0xF653 CharacterLogOff** — bi-directional, same opcode, no
envelope. Client sends to request logout; server sends the echo
before Disconnect.
envelope. Retail's client request carries the active character GUID;
the server confirmation is opcode-only. The client waits for that
confirmation before disconnecting the world transport.
- **0x01A8 MagicRemoveSpell** — C→S is GameAction, S→C is GameEvent.
- **0xF7DE TurbineChat** — bi-directional GameMessage. Blob layout
includes a `ChatNetworkBlobType` discriminant (EVENT_BINARY=1,