feat(net): Phase 4.10 — DddInterrogationResponse + correct LoginComplete trigger

User reported that even with the Phase 4.9 ack pump, acdream's
character still rendered to other clients as the purple loading haze.
Spent another round in holtburger's references and found two more
gaps in the post-EnterWorld handshake:

1. Server sends DddInterrogation (game opcode 0xF7E5) and waits for
   the client to acknowledge dat-list versions. We never replied.
   Build the canonical empty response (12 bytes: opcode + language=1
   + count=0 lists) and ship it as soon as DddInterrogation arrives.

2. LoginComplete was being sent immediately after CharacterEnterWorld
   in Phase 4.8, which is too early — the server hasn't finished
   creating the player object yet so it ignores LoginComplete and
   the player stays in transition state. The correct trigger is the
   server's PlayerCreate (0xF746) game message for our character;
   that's when holtburger fires send_login_complete (see references/
   holtburger/.../client/messages.rs::PlayerCreate handler).

Wired both into ProcessDatagram. Removed the unconditional
LoginComplete from the EnterWorld flow. Added a _loginCompleteSent
latch so re-PlayerCreate (e.g., across portal teleports) doesn't
re-fire LoginComplete during the same session.

Reference repo cited per the new CLAUDE.md guidance — holtburger is
the authoritative client-behavior reference. Should have looked there
sooner; this would have saved the Phase 4.8 false fix.

220 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 23:48:37 +02:00
parent af381ac6fb
commit 8c14e0207c
2 changed files with 77 additions and 8 deletions

View file

@ -0,0 +1,41 @@
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Outbound reply to the server's <c>DddInterrogation</c> game message
/// (0xF7E5). Sent during the post-EnterWorld handshake; without it the
/// server keeps the client in a transitional state and other clients
/// see the character as a stationary purple loading haze.
///
/// <para>
/// Wire layout (see
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/misc/types.rs::DddInterrogationResponseData</c>):
/// </para>
/// <list type="bullet">
/// <item>u32 game-message opcode = 0xF7E6 (<c>DddInterrogationResponse</c>)</item>
/// <item>u32 language = 1 (English)</item>
/// <item>u32 count = 0 (no tagged iteration lists; we acknowledge the
/// interrogation without claiming any dat-list versions). The
/// loop body is then empty.</item>
/// </list>
///
/// <para>
/// Total payload: 12 bytes. Should be sent automatically by
/// <c>WorldSession</c> in response to receiving 0xF7E5.
/// </para>
/// </summary>
public static class DddInterrogationResponse
{
public const uint Opcode = 0xF7E6u;
public const uint EnglishLanguage = 1u;
public static byte[] Build()
{
var w = new PacketWriter(16);
w.WriteUInt32(Opcode);
w.WriteUInt32(EnglishLanguage);
w.WriteUInt32(0u); // empty TaggedIterationList vec
return w.ToArray();
}
}

View file

@ -108,6 +108,14 @@ public sealed class WorldSession : IDisposable
private uint _clientPacketSequence;
private uint _fragmentSequence = 1;
/// <summary>
/// Phase 4.10 latch — true after we've sent the LoginComplete game
/// action in response to PlayerCreate. Prevents re-sending if the
/// server emits multiple PlayerCreate messages (rare but possible
/// across recall / portal teleports).
/// </summary>
private bool _loginCompleteSent;
public WorldSession(IPEndPoint serverLogin)
{
_loginEndpoint = serverLogin;
@ -202,14 +210,13 @@ public sealed class WorldSession : IDisposable
SendGameMessage(CharacterEnterWorld.BuildEnterWorldBody(chosen.Id, account));
// Tell the server "I'm done loading, you can show me to other players
// and push my initial property updates." Without this, the character
// stays in a transitional state and renders as the purple loading
// haze to other clients in the same area. ACE's GameActionLoginComplete
// handler is what flips Player.FirstEnterWorldDone and triggers
// SendPropertyUpdatesAndOverrides.
SendGameMessage(GameActionLoginComplete.Build());
// NOTE: LoginComplete used to be sent here unconditionally. That was
// wrong — per holtburger's flow (see references/holtburger/.../client/
// messages.rs lines 391-422), LoginComplete is sent in response to the
// server's PlayerCreate (0xF746) game message, NOT immediately after
// EnterWorld. Sending it too early means the player object isn't
// ready and the server ignores it. The actual trigger lives in
// ProcessDatagram.
Transition(State.InWorld);
}
@ -280,6 +287,27 @@ public sealed class WorldSession : IDisposable
try { Characters = CharacterList.Parse(body); }
catch { /* malformed — ignore and keep draining */ }
}
else if (op == 0xF7E5u) // DddInterrogation — server asks "what dat list versions do you have?"
{
// Phase 4.10: reply with an empty DddInterrogationResponse
// (language=1 English, count=0 lists). The server is happy
// with an empty acknowledgement; without ANY reply it keeps
// the client in a transitional state and renders us as the
// purple loading haze to other clients. Pattern from
// references/holtburger/.../client/messages.rs::DddInterrogation
SendGameMessage(DddInterrogationResponse.Build());
}
else if (op == 0xF746u && !_loginCompleteSent) // PlayerCreate — server creates our player object
{
// Phase 4.10: PlayerCreate for our character is the cue to
// send LoginComplete. Sending it earlier (right after the
// outbound CharacterEnterWorld) was wrong because the server
// hadn't finished spawning the player yet. Holtburger's
// client/messages.rs (PlayerCreate handler) confirms this is
// the correct trigger. Send once per session.
_loginCompleteSent = true;
SendGameMessage(GameActionLoginComplete.Build());
}
else if (op == CreateObject.Opcode)
{
var parsed = CreateObject.TryParse(body);