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;