From 8744bd61791e2f017b850fde4cc7dd8c435d768d Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 23:36:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(net):=20Phase=204.8=20=E2=80=94=20send=20G?= =?UTF-8?q?ameAction.LoginComplete=20after=20EnterWorld?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported that when they observed acdream's character through a second AC client running on a different account, the character rendered as a stationary purple haze (AC's "loading screen / portal space" indicator) instead of a normal avatar. The character was "in-world enough" to receive the CreateObject stream but never "in-world enough" for the server to flip its first-enter-world flag, push initial property updates / equipment overrides, or show the character to other clients in the area. Root cause: WorldSession.EnterWorld stopped after sending CharacterEnterWorld (0xF657). The handshake is supposed to continue with one more message — a GameAction(LoginComplete) — that ACE's GameActionLoginComplete handler interprets as "client has exited portal space, mark FirstEnterWorldDone, push property updates, make the character visible to others." Wire layout (confirmed via references/ACE/Source/ACE.Server/Network/GameAction/GameActionPacket.cs and .../Actions/GameActionLoginComplete.cs): u32 game-message opcode = 0xF7B1 (GameAction) u32 sequence = 0 (ACE ignores; TODO comment in source) u32 GameActionType opc = 0x000000A1 (LoginComplete) Send happens immediately after CharacterEnterWorld and just before flipping the WorldSession state to InWorld. acdream has no portal- space transition animation, so we can claim "loading complete" the moment we've sent the EnterWorld message — the dat-side world is already loaded by then. 1 new test (97 Core.Net total). 220 tests green overall. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Messages/GameActionLoginComplete.cs | 53 +++++++++++++++++++ src/AcDream.Core.Net/WorldSession.cs | 9 ++++ .../Messages/GameActionLoginCompleteTests.cs | 25 +++++++++ 3 files changed, 87 insertions(+) create mode 100644 src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/GameActionLoginCompleteTests.cs diff --git a/src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs b/src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs new file mode 100644 index 0000000..9d9c7fe --- /dev/null +++ b/src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs @@ -0,0 +1,53 @@ +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Messages; + +/// +/// Outbound GameAction message announcing that the client has +/// finished loading into the world. Without this, the server keeps the +/// character in a transitional "exiting portal space" state forever — +/// other players see the character rendered as a stationary purple haze +/// (AC's loading-screen indicator) instead of a real avatar, and the +/// server doesn't push initial property updates / equipment overrides +/// to the client either. +/// +/// +/// Wire layout (see +/// references/ACE/Source/ACE.Server/Network/GameAction/GameActionPacket.cs +/// and references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionLoginComplete.cs): +/// +/// +/// u32 game-message opcode = 0xF7B1 (GameAction) +/// u32 sequence — ACE's GameActionPacket.HandleGameAction +/// reads this and currently ignores its value (// TODO: verify +/// sequence in the source). 0 is fine. +/// u32 GameActionType opcode = 0x000000A1 (LoginComplete) +/// (no payload) +/// +/// +/// +/// Should be sent immediately after CharacterEnterWorld (0xF657) +/// completes the in-world transition. Retail clients send it once the +/// portal-space transition animation finishes; we send it as soon as we +/// flag the session InWorld because acdream doesn't have a portal- +/// space animation yet. +/// +/// +public static class GameActionLoginComplete +{ + public const uint GameActionOpcode = 0xF7B1u; + public const uint LoginCompleteActionType = 0x000000A1u; + + /// + /// Build the body bytes for an outbound GameAction(LoginComplete). + /// Layout: opcode(4) + sequence(4) + actionType(4) = 12 bytes total. + /// + public static byte[] Build() + { + var w = new PacketWriter(16); + w.WriteUInt32(GameActionOpcode); + w.WriteUInt32(0u); // sequence — server ignores per ACE source + w.WriteUInt32(LoginCompleteActionType); + return w.ToArray(); + } +} diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 5b8482f..9fb08d7 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -201,6 +201,15 @@ public sealed class WorldSession : IDisposable if (!serverReady) { Transition(State.Failed); throw new TimeoutException("ServerReady not received"); } 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()); + Transition(State.InWorld); } diff --git a/tests/AcDream.Core.Net.Tests/Messages/GameActionLoginCompleteTests.cs b/tests/AcDream.Core.Net.Tests/Messages/GameActionLoginCompleteTests.cs new file mode 100644 index 0000000..c3e2478 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/GameActionLoginCompleteTests.cs @@ -0,0 +1,25 @@ +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public class GameActionLoginCompleteTests +{ + [Fact] + public void Build_ProducesExactly12Bytes_WithCorrectOpcodes() + { + var body = GameActionLoginComplete.Build(); + + // 4 bytes GameAction opcode + 4 bytes sequence + 4 bytes action type. + Assert.Equal(12, body.Length); + + // Little-endian decode. + uint gameActionOpcode = (uint)(body[0] | (body[1] << 8) | (body[2] << 16) | (body[3] << 24)); + uint sequence = (uint)(body[4] | (body[5] << 8) | (body[6] << 16) | (body[7] << 24)); + uint actionType = (uint)(body[8] | (body[9] << 8) | (body[10] << 16) | (body[11] << 24)); + + Assert.Equal(0xF7B1u, gameActionOpcode); + Assert.Equal(0u, sequence); + Assert.Equal(0x000000A1u, actionType); + } +}