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);
+ }
+}