diff --git a/CLAUDE.md b/CLAUDE.md index 024870f..2ac14f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,9 +164,23 @@ these, ideally all four: on field order, packed-dword conventions, type-prefix handling. The generated Types/*.cs files have accurate field comments (e.g. "If it is 0, it defaults to 256*8") that ACE's server-side code doesn't. -- **`references/holtburger/`** — Rust AC client crate. Cross-references - handshake quirks, race delays, and per-message encoding decisions - that ACE doesn't document because it's server-side. +- **`references/holtburger/`** — **Almost-complete Rust TUI AC client.** + Not just a crate or a handshake reference: it's a full client that + logs in, plays the game, sends/receives chat, handles combat, and + renders state in a terminal. **This is acdream's most authoritative + reference for client-side behavior** — anything about how a client + is *supposed* to talk to the server lives here. Specifically: + - Handshake / login flow including all the post-EnterWorld + messages retail clients send (LoginComplete, ack pump, + DDDInterrogation responses, etc). + - The proper ACK_SEQUENCE pattern (every received packet with + sequence > 0 gets an ack queued back; not periodic). + - Outbound game-action message construction with sequence + numbering. + - Message routing and session lifecycle. + Look here FIRST when implementing anything in `WorldSession` or + the message-builder layer. ACE shows what the server expects; + holtburger shows what a real client actually sends. Pattern: when you encounter an unknown behavior, grep all four for the relevant term, read each hit, and compose a multi-source understanding diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 9fb08d7..e29fde9 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -254,6 +254,20 @@ public sealed class WorldSession : IDisposable var dec = PacketCodec.TryDecode(bytes, _inboundIsaac); if (!dec.IsOk) return; + // Phase 4.9: send an ACK_SEQUENCE control packet for every received + // server packet with sequence > 0 and no ACK flag of its own. This + // is the proper holtburger pattern (every received packet gets an + // ack queued back; not periodic). Without it, ACE drops the session + // with "Network Timeout" because it sees no acks coming back — + // which surfaces in other clients' views as the player rendering + // as a stationary purple haze (loading state). + var serverHeader = dec.Packet!.Header; + if (serverHeader.Sequence > 0 + && (serverHeader.Flags & PacketHeaderFlags.AckSequence) == 0) + { + SendAck(serverHeader.Sequence); + } + foreach (var frag in dec.Packet!.Fragments) { var body = _assembler.Ingest(frag, out _); @@ -335,6 +349,53 @@ public sealed class WorldSession : IDisposable _net.Send(datagram); } + /// + /// Phase 4.9: send a bare ACK_SEQUENCE control packet acknowledging + /// . This is a cleartext control + /// packet (no EncryptedChecksum) — the body is just the 4-byte server + /// sequence number being acknowledged. The header re-uses the most + /// recently sent client sequence (no increment) because acks aren't + /// themselves part of the reliable stream the server tracks. + /// + /// + /// Without sending these, ACE drops the session with + /// Network Timeout after ~60s — and during that 60s the + /// character appears to other clients as a stationary purple haze + /// (loading state) because the server hasn't seen the client confirm + /// any post-EnterWorld traffic. + /// + /// + /// + /// Pattern ported from + /// references/holtburger/crates/holtburger-session/src/session/send.rs::send_ack + /// and the receive-side trigger at + /// .../session/receive.rs::finalize_ordered_server_packet. + /// + /// + private void SendAck(uint serverPacketSequence) + { + // 4-byte body: little-endian u32 of the server sequence we're acking. + Span body = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(body, serverPacketSequence); + + // Holtburger uses current_client_sequence (= packet_sequence - 1) for + // ack headers. We mirror that — acks borrow the most recently issued + // client sequence rather than consuming a new one. + uint ackHeaderSequence = _clientPacketSequence > 0 + ? _clientPacketSequence - 1 + : 0u; + + var header = new PacketHeader + { + Sequence = ackHeaderSequence, + Flags = PacketHeaderFlags.AckSequence, + Id = _sessionClientId, + }; + + byte[] datagram = PacketCodec.Encode(header, body, outboundIsaac: null); + _net.Send(datagram); + } + private void Transition(State next) { if (CurrentState == next) return;