From 2907bd62f1c0dc2538a9835ccda3714b8e42e8b9 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 20 Aug 2026 15:40:10 +0100 Subject: [PATCH 01/14] Fix character screen addon packet order --- src/game/WorldHandlers/CharacterHandler.cpp | 9 +++- src/game/WorldHandlers/World.cpp | 5 ++- src/tests/CheckWardenBoundary.cmake | 46 +++++++++++++++++---- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/game/WorldHandlers/CharacterHandler.cpp b/src/game/WorldHandlers/CharacterHandler.cpp index f65701976..64de4723b 100644 --- a/src/game/WorldHandlers/CharacterHandler.cpp +++ b/src/game/WorldHandlers/CharacterHandler.cpp @@ -396,6 +396,10 @@ void WorldSession::HandleCharEnum(QueryResult* result) */ void WorldSession::HandleCharEnumOpcode(WorldPacket & /*recv_data*/) { + // Retail answers the client's enum request with the queued addon metadata + // before the asynchronous character-list response, not alongside AUTH_OK. + SendPendingAddonInfo(); + /// get all the data necessary for loading all characters (along with their pets) on the account uint32 accountId = GetAccountId(); CharacterDatabase.AsyncPQuery([accountId](QueryResult* result) @@ -713,8 +717,9 @@ void WorldSession::HandlePlayerLoginOpcode(WorldPacket& recv_data) return; } - // Do not gate login on Warden. This idempotent safety net covers clients - // that skip character enumeration while leaving normal retail order intact. + // Do not gate login on addon info or Warden. These idempotent safety nets + // cover clients that skip character enumeration. + SendPendingAddonInfo(); StartWardenBootstrap(); m_playerLoading = true; diff --git a/src/game/WorldHandlers/World.cpp b/src/game/WorldHandlers/World.cpp index 424d91f0c..8cf6a4639 100644 --- a/src/game/WorldHandlers/World.cpp +++ b/src/game/WorldHandlers/World.cpp @@ -348,7 +348,6 @@ World::AddSession_(WorldSession* s) if (pLimit > 0 && Sessions >= pLimit && s->GetSecurity() == SEC_PLAYER) { AddQueuedSession(s); - s->SendPendingAddonInfo(); UpdateMaxSessionCounters(); DETAIL_LOG("PlayerQueue: Account id %u is in Queue Position (%u).", s->GetAccountId(), ++QueueSize); return; @@ -361,7 +360,9 @@ World::AddSession_(WorldSession* s) packet << uint8(0); // BillingPlanFlags packet << uint32(0); // BillingTimeRested s->SendPacket(&packet); - s->SendPendingAddonInfo(); + + // Addon metadata stays queued until CMSG_CHAR_ENUM. Emitting it at + // authentication reverses the ordering observed on the Classic client. s->OnAuthenticatedAdmission(); UpdateMaxSessionCounters(); diff --git a/src/tests/CheckWardenBoundary.cmake b/src/tests/CheckWardenBoundary.cmake index abb1f7299..9dd604b3f 100644 --- a/src/tests/CheckWardenBoundary.cmake +++ b/src/tests/CheckWardenBoundary.cmake @@ -107,13 +107,14 @@ if(WARDEN_CLOCK_AT EQUAL -1 OR PACKET_UPDATE_AT EQUAL -1 OR endif() require_count("${WORLD_CPP}" "OnAuthenticatedAdmission[ \\t]*\\(" 1 "immediate AUTH_OK path must admit exactly once") +require_count("${WORLD_CPP}" "SendPendingAddonInfo[ \\t]*\\(" 0 + "authentication paths must defer addon response until character enumeration") string(FIND "${WORLD_CPP}" "packet << uint8(AUTH_OK)" AUTH_OK_AT) -string(FIND "${WORLD_CPP}" "s->SendPendingAddonInfo()" ADDON_AT REVERSE) string(FIND "${WORLD_CPP}" "s->OnAuthenticatedAdmission()" ADMISSION_AT) -if(AUTH_OK_AT EQUAL -1 OR ADDON_AT EQUAL -1 OR ADMISSION_AT EQUAL -1 OR - ADDON_AT LESS_EQUAL AUTH_OK_AT OR ADMISSION_AT LESS_EQUAL ADDON_AT) +if(AUTH_OK_AT EQUAL -1 OR ADMISSION_AT EQUAL -1 OR + ADMISSION_AT LESS_EQUAL AUTH_OK_AT) message(FATAL_ERROR - "Warden boundary: immediate admission must follow AUTH_OK and addon response") + "Warden boundary: immediate admission must follow AUTH_OK") endif() require_count("${SESSION_CPP}" "m_warden->Start[ \\t]*\\(" 1 @@ -347,6 +348,31 @@ if(CHAR_LIST_SEND_AT EQUAL -1 OR CHAR_ENUM_START_AT EQUAL -1 OR "Warden boundary: character-list send must precede bootstrap emission") endif() +string(FIND "${CHARACTER_HANDLER}" + "void WorldSession::HandleCharEnumOpcode" CHAR_ENUM_OPCODE_BEGIN) +string(FIND "${CHARACTER_HANDLER}" + "void WorldSession::HandleCharCreateOpcode" CHAR_ENUM_OPCODE_END) +if(CHAR_ENUM_OPCODE_BEGIN EQUAL -1 OR CHAR_ENUM_OPCODE_END EQUAL -1 OR + CHAR_ENUM_OPCODE_END LESS_EQUAL CHAR_ENUM_OPCODE_BEGIN) + message(FATAL_ERROR + "Warden boundary: cannot locate character-enum opcode body") +endif() +math(EXPR CHAR_ENUM_OPCODE_LENGTH + "${CHAR_ENUM_OPCODE_END} - ${CHAR_ENUM_OPCODE_BEGIN}") +string(SUBSTRING "${CHARACTER_HANDLER}" ${CHAR_ENUM_OPCODE_BEGIN} + ${CHAR_ENUM_OPCODE_LENGTH} CHAR_ENUM_OPCODE_BODY) +require_count("${CHAR_ENUM_OPCODE_BODY}" "SendPendingAddonInfo[ \\t]*\\(" 1 + "character-enum request must send deferred addon response exactly once") +string(FIND "${CHAR_ENUM_OPCODE_BODY}" "SendPendingAddonInfo()" + CHAR_ENUM_ADDON_AT) +string(FIND "${CHAR_ENUM_OPCODE_BODY}" "CharacterDatabase.AsyncPQuery" + CHAR_ENUM_QUERY_AT) +if(CHAR_ENUM_ADDON_AT EQUAL -1 OR CHAR_ENUM_QUERY_AT EQUAL -1 OR + CHAR_ENUM_QUERY_AT LESS_EQUAL CHAR_ENUM_ADDON_AT) + message(FATAL_ERROR + "Warden boundary: addon response must precede asynchronous character enumeration") +endif() + string(FIND "${CHARACTER_HANDLER}" "void WorldSession::HandlePlayerLoginOpcode" PLAYER_LOGIN_BEGIN) string(FIND "${CHARACTER_HANDLER}" @@ -361,17 +387,23 @@ string(SUBSTRING "${CHARACTER_HANDLER}" ${PLAYER_LOGIN_BEGIN} ${PLAYER_LOGIN_LENGTH} PLAYER_LOGIN_BODY) require_count("${PLAYER_LOGIN_BODY}" "StartWardenBootstrap[ \\t]*\\(" 1 "player-login path must retain one non-gating bootstrap safety net") +require_count("${PLAYER_LOGIN_BODY}" "SendPendingAddonInfo[ \\t]*\\(" 1 + "player-login path must retain one addon-response safety net") string(FIND "${PLAYER_LOGIN_BODY}" "PlayerLoading()" PLAYER_LOGIN_GUARD_AT) +string(FIND "${PLAYER_LOGIN_BODY}" "SendPendingAddonInfo()" + PLAYER_LOGIN_ADDON_AT) string(FIND "${PLAYER_LOGIN_BODY}" "StartWardenBootstrap()" PLAYER_LOGIN_START_AT) string(FIND "${PLAYER_LOGIN_BODY}" "m_playerLoading = true" PLAYER_LOADING_SET_AT) -if(PLAYER_LOGIN_GUARD_AT EQUAL -1 OR PLAYER_LOGIN_START_AT EQUAL -1 OR - PLAYER_LOADING_SET_AT EQUAL -1 OR +if(PLAYER_LOGIN_GUARD_AT EQUAL -1 OR PLAYER_LOGIN_ADDON_AT EQUAL -1 OR + PLAYER_LOGIN_START_AT EQUAL -1 OR PLAYER_LOADING_SET_AT EQUAL -1 OR + PLAYER_LOGIN_ADDON_AT LESS_EQUAL PLAYER_LOGIN_GUARD_AT OR + PLAYER_LOGIN_ADDON_AT GREATER_EQUAL PLAYER_LOGIN_START_AT OR PLAYER_LOGIN_START_AT LESS_EQUAL PLAYER_LOGIN_GUARD_AT OR PLAYER_LOGIN_START_AT GREATER_EQUAL PLAYER_LOADING_SET_AT) message(FATAL_ERROR - "Warden boundary: login safety net must follow the duplicate guard and never gate loading") + "Warden boundary: login safety nets must follow the duplicate guard and never gate loading") endif() string(FIND "${SESSION_MGR}" "void World::AddQueuedSession" ADD_QUEUE_BEGIN) From fb24cc60413f662ad813cc06054a0ac4ebe6c7cd Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 20 Aug 2026 16:50:35 +0100 Subject: [PATCH 02/14] Batch initial login object updates --- src/game/Object/Camera.cpp | 18 ++- src/game/Object/Camera.h | 15 ++- src/game/WorldHandlers/GridNotifiers.cpp | 26 +++- src/game/WorldHandlers/GridNotifiers.h | 11 +- src/game/WorldHandlers/GridNotifiersImpl.h | 2 +- src/game/WorldHandlers/Map.cpp | 70 +++++++++-- src/game/WorldHandlers/Map.h | 5 +- src/game/WorldHandlers/TransportMap.cpp | 80 +++++++++--- src/game/WorldHandlers/TransportMap.h | 4 +- src/game/WorldHandlers/UpdateData.h | 40 ++++++ src/tests/CMakeLists.txt | 5 + src/tests/CheckLoginObjectBatching.cmake | 135 +++++++++++++++++++++ 12 files changed, 371 insertions(+), 40 deletions(-) create mode 100644 src/tests/CheckLoginObjectBatching.cmake diff --git a/src/game/Object/Camera.cpp b/src/game/Object/Camera.cpp index df0bf13f3..5a8ef52e4 100644 --- a/src/game/Object/Camera.cpp +++ b/src/game/Object/Camera.cpp @@ -162,14 +162,17 @@ void Camera::ResetView(bool update_far_sight_field /*= true*/) /** * @brief Handles the camera being added to the world. + * + * @param batch Shared initial-login data for the owner camera, or null for an + * ordinary visibility rebuild. */ -void Camera::Event_AddedToWorld() +void Camera::Event_AddedToWorld(InitialWorldUpdateBatch* batch) { GridType* grid = m_source->GetViewPoint().m_grid; MANGOS_ASSERT(grid); grid->AddWorldObject(this); - UpdateVisibilityForOwner(); + UpdateVisibilityForOwnerInBatch(batch); } /** @@ -221,6 +224,15 @@ void Camera::UpdateVisibilityOf(WorldObject* target, UpdateData& data, std::set< * @brief Rebuilds visibility for the camera owner around the current source. */ void Camera::UpdateVisibilityForOwner() +{ + UpdateVisibilityForOwnerInBatch(NULL); +} + +/** + * Rebuilds owner visibility while optionally appending create blocks to the + * initial self/transport batch instead of sending a second update packet. + */ +void Camera::UpdateVisibilityForOwnerInBatch(InitialWorldUpdateBatch* batch) { // Honor a per-viewpoint visibility distance override (e.g. the cinematic // flyover body widens the populate radius); otherwise use the map default. @@ -230,7 +242,7 @@ void Camera::UpdateVisibilityForOwner() visibilityDistance = m_source->GetMap()->GetVisibilityDistance(); } - MaNGOS::VisibleNotifier notifier(*this); + MaNGOS::VisibleNotifier notifier(*this, batch); Cell::VisitAllObjects(m_source, notifier, visibilityDistance, false); // The other side of a vessel's boundary. A deck and the shore it sails past are two diff --git a/src/game/Object/Camera.h b/src/game/Object/Camera.h index a2e025c47..48d6caf8b 100644 --- a/src/game/Object/Camera.h +++ b/src/game/Object/Camera.h @@ -35,6 +35,7 @@ class WorldObject; class UpdateData; class WorldPacket; class Player; +class InitialWorldUpdateBatch; /// Camera - object-receiver. Receives broadcast packets from nearby worldobjects, object visibility changes and sends them to client class Camera @@ -74,7 +75,7 @@ class Camera private: // called when viewpoint changes visibility state - void Event_AddedToWorld(); + void Event_AddedToWorld(InitialWorldUpdateBatch* batch); void Event_RemovedFromWorld(); void Event_Moved(); void Event_ViewPointVisibilityChanged(); @@ -83,6 +84,7 @@ class Camera WorldObject* m_source; void UpdateForCurrentViewPoint(); + void UpdateVisibilityForOwnerInBatch(InitialWorldUpdateBatch* batch); public: GridReference& GetGridRef() @@ -128,10 +130,17 @@ class ViewPoint bool hasViewers() const { return !m_cameras.empty(); } // these events are called when viewpoint changes visibility state - void Event_AddedToWorld(GridType* grid) + void Event_AddedToWorld(GridType* grid, Player* batchOwner = NULL, + InitialWorldUpdateBatch* batch = NULL) { m_grid = grid; - CameraCall(&Camera::Event_AddedToWorld); + // A viewpoint may have several cameras. Only the logging-in + // player's own camera may consume the shared initial batch. + for (CameraList::iterator itr = m_cameras.begin(); itr != m_cameras.end();) + { + Camera* c = *(itr++); + c->Event_AddedToWorld(c->GetOwner() == batchOwner ? batch : NULL); + } } void Event_RemovedFromWorld() diff --git a/src/game/WorldHandlers/GridNotifiers.cpp b/src/game/WorldHandlers/GridNotifiers.cpp index 0e53cce4e..479f4715e 100644 --- a/src/game/WorldHandlers/GridNotifiers.cpp +++ b/src/game/WorldHandlers/GridNotifiers.cpp @@ -77,6 +77,9 @@ void VisibleChangesNotifier::Visit(CameraMapType& m) void VisibleNotifier::Notify() { Player& player = *i_camera.GetOwner(); + // Initial login aliases the earlier self/transport accumulator; every + // other visibility pass continues to use this notifier's local data. + UpdateData& data = Data(); // at this moment i_clientGUIDs have guids that not iterate at grid level checks // but exist one case when this possible and object not out of range: transports if (player.GetMap()->AsTransport()) @@ -89,7 +92,7 @@ void VisibleNotifier::Notify() { // ignore far sight case mate->UpdateVisibilityOf(mate, &player); - player.UpdateVisibilityOf(&player, mate, i_data, i_visibleNow); + player.UpdateVisibilityOf(&player, mate, data, i_visibleNow); i_clientGUIDs.erase(mate->GetObjectGuid()); } } @@ -101,7 +104,7 @@ void VisibleNotifier::Notify() // correct without anybody keeping a list. // generate outOfRange for not iterate objects - i_data.AddOutOfRangeGUID(i_clientGUIDs); + data.AddOutOfRangeGUID(i_clientGUIDs); for (GuidSet::iterator itr = i_clientGUIDs.begin(); itr != i_clientGUIDs.end(); ++itr) { player.m_clientGUIDs.erase(*itr); @@ -110,15 +113,21 @@ void VisibleNotifier::Notify() itr->GetString().c_str(), player.GetGuidStr().c_str()); } - if (i_data.HasData()) + if (data.HasData()) { // send create/outofrange packet to player (except player create updates that already sent using SendUpdateToPlayer) WorldPacket packet; - i_data.BuildPacket(&packet); + bool const built = BuildPacket(&packet); + if (i_initialBatch && !built) + { + sLog.outError("Failed to build initial object update batch for player %u", player.GetGUIDLow()); + player.GetSession()->KickPlayer(); + return; + } player.GetSession()->SendPacket(&packet); // send out of range to other players if need - GuidSet const& oor = i_data.GetOutOfRangeGUIDs(); + GuidSet const& oor = data.GetOutOfRangeGUIDs(); for (GuidSet::const_iterator iter = oor.begin(); iter != oor.end(); ++iter) { if (!iter->IsPlayer()) @@ -131,6 +140,13 @@ void VisibleNotifier::Notify() plr->UpdateVisibilityOf(plr->GetCamera().GetBody(), &player); } } + + if (i_initialBatch) + { + // Consume the shared batch only after its one packet was built and + // sent; Map::Add fails closed if this acknowledgement is absent. + i_initialBatch->MarkSent(); + } } // Now do operations that required done at object visibility change to visible diff --git a/src/game/WorldHandlers/GridNotifiers.h b/src/game/WorldHandlers/GridNotifiers.h index 648658a5a..734e4a867 100644 --- a/src/game/WorldHandlers/GridNotifiers.h +++ b/src/game/WorldHandlers/GridNotifiers.h @@ -43,11 +43,20 @@ namespace MaNGOS struct VisibleNotifier { Camera& i_camera; + // Non-null only for the login owner's camera; its visibility sweep + // appends to the packet already holding self and transport blocks. + InitialWorldUpdateBatch* i_initialBatch; UpdateData i_data; GuidSet i_clientGUIDs; std::set i_visibleNow; - explicit VisibleNotifier(Camera& c) : i_camera(c), i_clientGUIDs(c.GetOwner()->m_clientGUIDs) {} + explicit VisibleNotifier(Camera& c, InitialWorldUpdateBatch* batch = NULL) + : i_camera(c), i_initialBatch(batch), i_clientGUIDs(c.GetOwner()->m_clientGUIDs) {} + UpdateData& Data() { return i_initialBatch ? i_initialBatch->Data() : i_data; } + bool BuildPacket(WorldPacket* packet) + { + return i_initialBatch ? i_initialBatch->BuildPacket(packet) : i_data.BuildPacket(packet); + } template void Visit(GridRefManager& m); void Visit(CameraMapType& /*m*/) {} void Notify(void); diff --git a/src/game/WorldHandlers/GridNotifiersImpl.h b/src/game/WorldHandlers/GridNotifiersImpl.h index dcde8a176..b3e999e10 100644 --- a/src/game/WorldHandlers/GridNotifiersImpl.h +++ b/src/game/WorldHandlers/GridNotifiersImpl.h @@ -40,7 +40,7 @@ template { for (typename GridRefManager::iterator iter = m.begin(); iter != m.end(); ++iter) { - i_camera.UpdateVisibilityOf(iter->getSource(), i_data, i_visibleNow); + i_camera.UpdateVisibilityOf(iter->getSource(), Data(), i_visibleNow); i_clientGUIDs.erase(iter->getSource()->GetObjectGuid()); } } diff --git a/src/game/WorldHandlers/Map.cpp b/src/game/WorldHandlers/Map.cpp index e4e43b356..fa0f4ecf4 100644 --- a/src/game/WorldHandlers/Map.cpp +++ b/src/game/WorldHandlers/Map.cpp @@ -718,12 +718,36 @@ bool Map::Add(Player* player) PromoteEnvelopeNeighboursToFull(cell.GridX(), cell.GridY()); player->AddToWorld(); - SendInitSelf(player); - SendInitTransports(player); + // Coalesce only a genuine login using the player's own camera. Redirected + // cameras and non-login map entry retain the established packet path. + std::optional initialUpdates; + if (player->GetSession()->PlayerLoading() && player->GetCamera().GetBody() == player) + { + initialUpdates.emplace(); + } + auto* batch = initialUpdates ? &*initialUpdates : nullptr; + + SendInitSelf(player, batch); + SendInitTransports(player, batch); NGridType* grid = getNGrid(cell.GridX(), cell.GridY()); - player->GetViewPoint().Event_AddedToWorld(&(*grid)(cell.CellX(), cell.CellY())); - UpdateObjectVisibility(player, cell, p); + player->GetViewPoint().Event_AddedToWorld( + &(*grid)(cell.CellX(), cell.CellY()), player, batch); + + // The owner camera is the sole flush point. Continuing after a missed + // flush would admit a client with an incomplete initial world. + if (batch && !batch->WasSent()) + { + if (!batch->FlushAttempted()) + { + sLog.outError("Initial object update batch for player %u was not flushed by the owner camera", player->GetGUIDLow()); + } + player->GetSession()->KickPlayer(); + } + else + { + UpdateObjectVisibility(player, cell, p); + } #ifdef ENABLE_ELUNA if (Eluna* e = GetEluna()) @@ -1877,11 +1901,12 @@ void Map::UpdateObjectVisibility(WorldObject* obj, Cell cell, CellPair cellpair) * * @param player The player receiving the initialization packets. */ -void Map::SendInitSelf(Player* player) +void Map::SendInitSelf(Player* player, InitialWorldUpdateBatch* batch) { DETAIL_LOG("Creating player data for himself %u", player->GetGUIDLow()); - UpdateData data; + UpdateData localData; + UpdateData& data = batch ? batch->Data() : localData; bool hasTransport = false; @@ -1889,6 +1914,10 @@ void Map::SendInitSelf(Player* player) if (Transport* transport = player->GetTransport()) { hasTransport = true; + if (batch) + { + batch->MarkTransport(); + } transport->BuildCreateUpdateBlockForPlayer(&data, player); // The vessel is the only thing that can announce her crew -- including to the man @@ -1902,6 +1931,13 @@ void Map::SendInitSelf(Player* player) // build data for self presence in world at own client (one time for map) player->BuildCreateUpdateBlockForPlayer(&data, player); + if (batch) + { + // The owner camera sweep appends nearby objects and performs the one + // flush after every self, inventory, and vessel block is present. + return; + } + WorldPacket packet; data.BuildPacket(&packet, hasTransport); player->GetSession()->SendPacket(&packet); @@ -1912,7 +1948,7 @@ void Map::SendInitSelf(Player* player) * * @param player The player receiving transport initialization data. */ -void Map::SendInitTransports(Player* player) +void Map::SendInitTransports(Player* player, InitialWorldUpdateBatch* batch) { // A player joining a map takes possession of every vessel on it -- one of the four // events that carry transport visibility. No distance, no grid: you share her map, you @@ -1922,7 +1958,15 @@ void Map::SendInitTransports(Player* player) // logging in aboard was handed no hull at all, which is a man standing in mid-air. if (TransportMap* hull = AsTransport()) { - TransportMap::AnnounceVessel(hull->Vessel(), player); + if (batch) + { + TransportMap::AppendVesselCreateBlocks(hull->Vessel(), player, batch->Data()); + batch->MarkTransport(); + } + else + { + TransportMap::AnnounceVessel(hull->Vessel(), player); + } return; } @@ -1944,7 +1988,15 @@ void Map::SendInitTransports(Player* player) continue; } - TransportMap::AnnounceVessel(vessel, player); + if (batch) + { + TransportMap::AppendVesselCreateBlocks(vessel, player, batch->Data()); + batch->MarkTransport(); + } + else + { + TransportMap::AnnounceVessel(vessel, player); + } } } diff --git a/src/game/WorldHandlers/Map.h b/src/game/WorldHandlers/Map.h index c4d34a9b9..c68523173 100644 --- a/src/game/WorldHandlers/Map.h +++ b/src/game/WorldHandlers/Map.h @@ -86,6 +86,7 @@ class Creature; class Eluna; #endif /* ENABLE_ELUNA */ class TransportMap; +class InitialWorldUpdateBatch; class Unit; class WorldPacket; class InstanceData; @@ -466,9 +467,9 @@ class Map : public GridRefManager void SetTimer(uint32 t) { i_gridExpiry = t < MIN_GRID_DELAY ? MIN_GRID_DELAY : t; } - void SendInitSelf(Player* player); + void SendInitSelf(Player* player, InitialWorldUpdateBatch* batch); - void SendInitTransports(Player* player); + void SendInitTransports(Player* player, InitialWorldUpdateBatch* batch); void SendRemoveTransports(Player* player); bool CreatureCellRelocation(Creature* creature, const Cell &new_cell); diff --git a/src/game/WorldHandlers/TransportMap.cpp b/src/game/WorldHandlers/TransportMap.cpp index 9bfabdd34..b98f3c335 100644 --- a/src/game/WorldHandlers/TransportMap.cpp +++ b/src/game/WorldHandlers/TransportMap.cpp @@ -417,22 +417,39 @@ bool TransportMap::Add(Player* passenger) PromoteEnvelopeNeighboursToFull(cell.GridX(), cell.GridY()); passenger->AddToWorld(); + // Match ordinary map login: only the passenger's own initial camera may + // coalesce this data. Seam crossings keep their established send path. + std::optional initialUpdates; + if (passenger->GetSession()->PlayerLoading() && passenger->GetCamera().GetBody() == passenger) + { + initialUpdates.emplace(); + } + auto* batch = initialUpdates ? &*initialUpdates : nullptr; + // The ship, then the man standing on her. Nothing else: no world to introduce, no map // id he could be told. Her block is not stamped into his client set -- possession of a // vessel is map membership, and the elimination sweep must never learn she exists. - UpdateData data; + UpdateData localData; + UpdateData& data = batch ? batch->Data() : localData; m_vessel->BuildCreateUpdateBlockForPlayer(&data, passenger); passenger->BuildCreateUpdateBlockForPlayer(&data, passenger); - WorldPacket packet; - // hasTransport, and it must be true: this packet carries the vessel and a - // passenger whose own block names her. The byte is written on CLASSIC and TBC - // only -- mangos_two omits the argument because there the field does not exist, - // and copying that form here tells the client there is no transport in a packet - // that is nothing but transport. It then has nothing to compose him against and - // never leaves the loading screen. - data.BuildPacket(&packet, true); - passenger->GetSession()->SendPacket(&packet); + if (batch) + { + batch->MarkTransport(); + } + else + { + WorldPacket packet; + // hasTransport, and it must be true: this packet carries the vessel and a + // passenger whose own block names her. The byte is written on CLASSIC and TBC + // only -- mangos_two omits the argument because there the field does not exist, + // and copying that form here tells the client there is no transport in a packet + // that is nothing but transport. It then has nothing to compose him against and + // never leaves the loading screen. + data.BuildPacket(&packet, true); + passenger->GetSession()->SendPacket(&packet); + } // And the OTHER ships on the water she is crossing. His client is drawing that map, so // they are his to see, and no sweep of his will ever reach them: he is not on it. @@ -449,15 +466,38 @@ bool TransportMap::Add(Player* passenger) { if (other != m_vessel && other->GetMap() == sailed) { - AnnounceVessel(other, passenger); + if (batch) + { + AppendVesselCreateBlocks(other, passenger, batch->Data()); + batch->MarkTransport(); + } + else + { + AnnounceVessel(other, passenger); + } } } } } NGridType* grid = getNGrid(cell.GridX(), cell.GridY()); - passenger->GetViewPoint().Event_AddedToWorld(&(*grid)(cell.CellX(), cell.CellY())); - UpdateObjectVisibility(passenger, cell, p); + passenger->GetViewPoint().Event_AddedToWorld( + &(*grid)(cell.CellX(), cell.CellY()), passenger, batch); + + // The owner camera must flush exactly once before normal visibility can + // proceed; otherwise the client would enter with a partial deck world. + if (batch && !batch->WasSent()) + { + if (!batch->FlushAttempted()) + { + sLog.outError("Initial object update batch for passenger %u was not flushed by the owner camera", passenger->GetGUIDLow()); + } + passenger->GetSession()->KickPlayer(); + } + else + { + UpdateObjectVisibility(passenger, cell, p); + } return true; } @@ -771,7 +811,7 @@ void TransportMap::SendCrewMemberCreate(Creature* crew) } } -void TransportMap::AnnounceVessel(Transport* vessel, Player* observer) +void TransportMap::AppendVesselCreateBlocks(Transport* vessel, Player* observer, UpdateData& data) { if (!vessel || !observer) { @@ -781,13 +821,23 @@ void TransportMap::AnnounceVessel(Transport* vessel, Player* observer) // The hull AND everyone on her. The observer's own visibility sweep would find the crew // too, but only when HE moves -- and a man standing on a pier watching a ship come in // does not move. Leaving it to the sweep gave him an empty deck until he stepped aboard. - UpdateData data; vessel->BuildCreateUpdateBlockForPlayer(&data, observer); if (TransportMap* hull = vessel->AsMap()) { hull->AppendCrewCreateBlocks(data, observer); } +} + +void TransportMap::AnnounceVessel(Transport* vessel, Player* observer) +{ + if (!vessel || !observer) + { + return; + } + + UpdateData data; + AppendVesselCreateBlocks(vessel, observer, data); WorldPacket packet; data.BuildPacket(&packet, true); diff --git a/src/game/WorldHandlers/TransportMap.h b/src/game/WorldHandlers/TransportMap.h index 5bddb7dc5..748afbf7b 100644 --- a/src/game/WorldHandlers/TransportMap.h +++ b/src/game/WorldHandlers/TransportMap.h @@ -221,7 +221,9 @@ class TransportMap : public Map // everyone aboard her, for as long as you share the map she sails. /// The vessel AND everyone on her. Static because a vessel the baker gave nothing to - /// has no map to ask, and is then exactly what the base class says it is. + /// has no map to ask, and is then exactly what the base class says it is. The + /// append-only seam lets login reuse the blocks without forcing an early packet. + static void AppendVesselCreateBlocks(Transport* vessel, Player* observer, UpdateData& data); static void AnnounceVessel(Transport* vessel, Player* observer); static void RetractVessel(Transport* vessel, Player* observer); diff --git a/src/game/WorldHandlers/UpdateData.h b/src/game/WorldHandlers/UpdateData.h index 363b8ac2e..6106472ee 100644 --- a/src/game/WorldHandlers/UpdateData.h +++ b/src/game/WorldHandlers/UpdateData.h @@ -87,4 +87,44 @@ class UpdateData void Compress(void* dst, uint32* dst_size, void* src, int src_size); }; + +/** + * Accumulates every create block for initial login into one update packet. + * Transport presence is retained for the Classic packet header, and the + * single-use flush state prevents a partial second initial world. + */ +class InitialWorldUpdateBatch +{ + public: + InitialWorldUpdateBatch() : m_hasTransport(false), m_flushAttempted(false), m_sent(false) {} + + UpdateData& Data() { return m_data; } + void MarkTransport() { m_hasTransport = true; } + + bool BuildPacket(WorldPacket* packet) + { + if (m_flushAttempted) + { + return false; + } + + m_flushAttempted = true; + return m_data.BuildPacket(packet, m_hasTransport); + } + + void MarkSent() + { + m_data.Clear(); + m_sent = true; + } + + bool FlushAttempted() const { return m_flushAttempted; } + bool WasSent() const { return m_sent; } + + private: + UpdateData m_data; + bool m_hasTransport; + bool m_flushAttempted; + bool m_sent; +}; #endif diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index bd727cbec..9bf8f34fa 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -138,6 +138,11 @@ add_test(NAME warden_boundary -DSOURCE_ROOT=${CMAKE_SOURCE_DIR} -P ${CMAKE_CURRENT_SOURCE_DIR}/CheckWardenBoundary.cmake) +add_test(NAME login_object_batching + COMMAND ${CMAKE_COMMAND} + -DSOURCE_ROOT=${CMAKE_SOURCE_DIR} + -P ${CMAKE_CURRENT_SOURCE_DIR}/CheckLoginObjectBatching.cmake) + add_test(NAME spatial_boundary COMMAND ${CMAKE_COMMAND} -DSOURCE_ROOT=${CMAKE_SOURCE_DIR} diff --git a/src/tests/CheckLoginObjectBatching.cmake b/src/tests/CheckLoginObjectBatching.cmake new file mode 100644 index 000000000..f8903a038 --- /dev/null +++ b/src/tests/CheckLoginObjectBatching.cmake @@ -0,0 +1,135 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# MaNGOS is a full featured server for World of Warcraft, supporting +# the following clients: 1.12.x, 2.4.3, 3.3.5a, 4.3.4a and 5.4.8 +# +# Copyright (C) 2005-2026 MaNGOS +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +function(require_count TEXT_VAR PATTERN EXPECTED DESCRIPTION) + string(REGEX MATCHALL "${PATTERN}" MATCHES "${${TEXT_VAR}}") + list(LENGTH MATCHES COUNT) + if(NOT COUNT EQUAL EXPECTED) + message(FATAL_ERROR + "Login object batching: ${DESCRIPTION}; expected ${EXPECTED}, found ${COUNT}") + endif() +endfunction() + +function(require_before TEXT_VAR FIRST SECOND DESCRIPTION) + string(FIND "${${TEXT_VAR}}" "${FIRST}" FIRST_AT) + string(FIND "${${TEXT_VAR}}" "${SECOND}" SECOND_AT) + if(FIRST_AT EQUAL -1 OR SECOND_AT EQUAL -1 OR + SECOND_AT LESS_EQUAL FIRST_AT) + message(FATAL_ERROR "Login object batching: ${DESCRIPTION}") + endif() +endfunction() + +set(GAME_ROOT "${SOURCE_ROOT}/src/game") +file(READ "${GAME_ROOT}/WorldHandlers/UpdateData.h" UPDATE_DATA_H) +file(READ "${GAME_ROOT}/WorldHandlers/Map.cpp" MAP_CPP) +file(READ "${GAME_ROOT}/WorldHandlers/TransportMap.cpp" TRANSPORT_MAP_CPP) +file(READ "${GAME_ROOT}/Object/Camera.h" CAMERA_H) +file(READ "${GAME_ROOT}/WorldHandlers/GridNotifiers.cpp" GRID_NOTIFIERS_CPP) + +require_count(UPDATE_DATA_H "class[ \\t]+InitialWorldUpdateBatch" 1 + "one login-scoped batch owner must carry update data and transport state") + +string(FIND "${MAP_CPP}" "bool Map::Add(Player* player)" MAP_ADD_BEGIN) +if(MAP_ADD_BEGIN EQUAL -1) + message(FATAL_ERROR "Login object batching: cannot locate Map::Add(Player*)") +endif() +string(SUBSTRING "${MAP_CPP}" ${MAP_ADD_BEGIN} -1 MAP_ADD_TAIL) +string(FIND "${MAP_ADD_TAIL}" "template" MAP_ADD_LENGTH) +if(MAP_ADD_LENGTH LESS_EQUAL 0) + message(FATAL_ERROR "Login object batching: cannot bound Map::Add(Player*)") +endif() +string(SUBSTRING "${MAP_ADD_TAIL}" 0 ${MAP_ADD_LENGTH} MAP_ADD_BODY) +require_count(MAP_ADD_BODY "PlayerLoading[ \\t]*\\(" 1 + "ordinary map entry must select batching only from the login lifecycle") +require_count(MAP_ADD_BODY + "GetCamera[ \\t]*\\([ \\t]*\\)[ \\t]*\\.[ \\t]*GetBody[ \\t]*\\([ \\t]*\\)[ \\t]*==[ \\t]*player" 1 + "a redirected login camera must retain the legacy multi-packet path") +require_count(MAP_ADD_BODY "InitialWorldUpdateBatch" 1 + "ordinary-map login must own exactly one initial batch") +require_before(MAP_ADD_BODY "SendInitSelf(player" "SendInitTransports(player" + "self and inventory must precede map-wide vessels") +require_before(MAP_ADD_BODY "SendInitTransports(player" "Event_AddedToWorld(" + "vessels must be accumulated before the owner visibility sweep flushes") +require_before(MAP_ADD_BODY "Event_AddedToWorld(" "UpdateObjectVisibility(player" + "the login batch must flush before other clients are notified") +require_count(MAP_ADD_BODY "return[ \\t]+true" 1 + "batch failure must not skip balanced map and instance enter hooks") + +string(FIND "${TRANSPORT_MAP_CPP}" "bool TransportMap::Add(Player* passenger)" + TRANSPORT_ADD_BEGIN) +string(FIND "${TRANSPORT_MAP_CPP}" "void TransportMap::Embark(Player* passenger)" + TRANSPORT_ADD_END) +if(TRANSPORT_ADD_BEGIN EQUAL -1 OR TRANSPORT_ADD_END EQUAL -1 OR + TRANSPORT_ADD_END LESS_EQUAL TRANSPORT_ADD_BEGIN) + message(FATAL_ERROR "Login object batching: cannot locate TransportMap::Add(Player*)") +endif() +math(EXPR TRANSPORT_ADD_LENGTH + "${TRANSPORT_ADD_END} - ${TRANSPORT_ADD_BEGIN}") +string(SUBSTRING "${TRANSPORT_MAP_CPP}" ${TRANSPORT_ADD_BEGIN} + ${TRANSPORT_ADD_LENGTH} TRANSPORT_ADD_BODY) +require_count(TRANSPORT_ADD_BODY "PlayerLoading[ \\t]*\\(" 1 + "transport-map entry must batch only during login") +require_count(TRANSPORT_ADD_BODY + "GetCamera[ \\t]*\\([ \\t]*\\)[ \\t]*\\.[ \\t]*GetBody[ \\t]*\\([ \\t]*\\)[ \\t]*==[ \\t]*passenger" 1 + "a redirected transport login camera must retain the legacy multi-packet path") +require_count(TRANSPORT_ADD_BODY "InitialWorldUpdateBatch" 1 + "transport-map login must own exactly one initial batch") +require_before(TRANSPORT_ADD_BODY "BuildCreateUpdateBlockForPlayer" + "Event_AddedToWorld(" + "vessel and passenger blocks must precede the owner visibility sweep") +require_before(TRANSPORT_ADD_BODY "Event_AddedToWorld(" + "UpdateObjectVisibility(passenger" + "transport login must flush before other clients are notified") +require_count(TRANSPORT_ADD_BODY "return[ \\t]+true" 1 + "transport batch failure must leave through the normal add epilogue") + +require_count(TRANSPORT_MAP_CPP + "TransportMap::AppendVesselCreateBlocks[ \\t]*\\(" 1 + "one append-only vessel seam must serve login and ordinary announcements") +require_count(TRANSPORT_MAP_CPP + "AppendVesselCreateBlocks[ \\t]*\\(" 3 + "the append seam must serve transport login and ordinary announcements") +require_count(MAP_CPP + "TransportMap::AppendVesselCreateBlocks[ \\t]*\\(" 2 + "both normal-map transport sources must append into the shared login batch") + +require_count(CAMERA_H + "GetOwner[ \\t]*\\([ \\t]*\\)[ \\t]*==[ \\t]*batchOwner" 1 + "only the camera owned by the logging-in player may consume the batch") + +string(FIND "${GRID_NOTIFIERS_CPP}" "void VisibleNotifier::Notify()" NOTIFY_BEGIN) +string(FIND "${GRID_NOTIFIERS_CPP}" "void MessageDeliverer::Visit" NOTIFY_END) +if(NOTIFY_BEGIN EQUAL -1 OR NOTIFY_END EQUAL -1 OR + NOTIFY_END LESS_EQUAL NOTIFY_BEGIN) + message(FATAL_ERROR "Login object batching: cannot locate VisibleNotifier::Notify") +endif() +math(EXPR NOTIFY_LENGTH "${NOTIFY_END} - ${NOTIFY_BEGIN}") +string(SUBSTRING "${GRID_NOTIFIERS_CPP}" ${NOTIFY_BEGIN} + ${NOTIFY_LENGTH} NOTIFY_BODY) +require_count(NOTIFY_BODY "BuildPacket[ \\t]*\\(" 1 + "the visibility notifier must own the single update-packet build") +require_count(NOTIFY_BODY "MarkSent[ \\t]*\\(" 1 + "a successful shared flush must consume the batch exactly once") +require_before(NOTIFY_BODY "BuildPacket(" "MarkSent(" + "the batch cannot be consumed before its packet is built") +require_before(NOTIFY_BODY "MarkSent(" "SendAuraDurationsForTarget(" + "object creation must be sent before aura packets reference visible units") + +message(STATUS "Login object batching boundary intact") From 46c9348cfc463ae4fa541acc7c1c417a4f7e0c20 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 21 Aug 2026 00:25:10 +0100 Subject: [PATCH 03/14] Add retail login effect packet builders --- src/game/WorldHandlers/LoginEffectPackets.cpp | 64 +++++++++++++++++++ src/game/WorldHandlers/LoginEffectPackets.h | 47 ++++++++++++++ src/tests/CMakeLists.txt | 4 ++ src/tests/LoginSequenceTest.cpp | 53 +++++++++++++++ 4 files changed, 168 insertions(+) create mode 100644 src/game/WorldHandlers/LoginEffectPackets.cpp create mode 100644 src/game/WorldHandlers/LoginEffectPackets.h create mode 100644 src/tests/LoginSequenceTest.cpp diff --git a/src/game/WorldHandlers/LoginEffectPackets.cpp b/src/game/WorldHandlers/LoginEffectPackets.cpp new file mode 100644 index 000000000..7b2517c4e --- /dev/null +++ b/src/game/WorldHandlers/LoginEffectPackets.cpp @@ -0,0 +1,64 @@ +/** + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MaNGOS is a full featured server for World of Warcraft, supporting + * the following clients: 1.12.x, 2.4.3, 3.3.5a, 4.3.4a and 5.4.8 + * + * Copyright (C) 2005-2026 MaNGOS + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * World of Warcraft, and all World of Warcraft or Warcraft art, images, + * and lore are copyrighted by Blizzard Entertainment, Inc. + */ + +#include "LoginEffectPackets.h" + +#include "Opcodes.h" +#include "WorldPacket.h" + +WorldPacket LoginEffectPackets::BuildCastResult() +{ + WorldPacket packet(SMSG_CAST_FAILED, 5); + packet << uint32(SpellId); + packet << uint8(0); // successful cast result + return packet; +} + +WorldPacket LoginEffectPackets::BuildStart(uint64 casterGuid) +{ + WorldPacket packet(SMSG_SPELL_START, 22); + packet.appendPackGUID(casterGuid); + packet.appendPackGUID(casterGuid); + packet << uint32(SpellId); + packet << uint16(2); // CAST_FLAG_UNKNOWN2 + packet << uint32(0); // zero cast time + packet << uint16(0); // TARGET_FLAG_SELF + return packet; +} + +WorldPacket LoginEffectPackets::BuildGo(uint64 casterGuid) +{ + WorldPacket packet(SMSG_SPELL_GO, 29); + packet.appendPackGUID(casterGuid); + packet.appendPackGUID(casterGuid); + packet << uint32(SpellId); + packet << uint16(256); // CAST_FLAG_UNKNOWN9 + packet << uint8(1); // one successful hit + packet << uint64(casterGuid); // hit target + packet << uint8(0); // no miss records + packet << uint16(2); // TARGET_FLAG_UNIT + packet << uint8(0); // empty packed target GUID + return packet; +} diff --git a/src/game/WorldHandlers/LoginEffectPackets.h b/src/game/WorldHandlers/LoginEffectPackets.h new file mode 100644 index 000000000..107f2b629 --- /dev/null +++ b/src/game/WorldHandlers/LoginEffectPackets.h @@ -0,0 +1,47 @@ +/** + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MaNGOS is a full featured server for World of Warcraft, supporting + * the following clients: 1.12.x, 2.4.3, 3.3.5a, 4.3.4a and 5.4.8 + * + * Copyright (C) 2005-2026 MaNGOS + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * World of Warcraft, and all World of Warcraft or Warcraft art, images, + * and lore are copyrighted by Blizzard Entertainment, Inc. + */ + +#ifndef MANGOS_LOGIN_EFFECT_PACKETS_H +#define MANGOS_LOGIN_EFFECT_PACKETS_H + +#include "Platform/Define.h" + +class WorldPacket; + +/** + * Exact Classic spell-836 wire builders. Login needs CAST_FAILED before the + * initial object batch and START/GO after it, which a single generic CastSpell + * call cannot express. + */ +namespace LoginEffectPackets +{ + constexpr uint32 SpellId = 836; + + WorldPacket BuildCastResult(); + WorldPacket BuildStart(uint64 casterGuid); + WorldPacket BuildGo(uint64 casterGuid); +} + +#endif diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 9bf8f34fa..48725c549 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -71,6 +71,10 @@ set(SRC_GRP_TESTS PlayerbotEventQueueTest.cpp PlayerbotPacketPolicyTest.cpp PlayerbotPerformanceMonitorTest.cpp + # These pure packet builders are linked directly so the wire-layout tests + # do not pull the complete game library and its mangosd-only globals. + LoginSequenceTest.cpp + ${CMAKE_SOURCE_DIR}/src/game/WorldHandlers/LoginEffectPackets.cpp # Compiled in, not linked from `game`: game.lib pulls the whole server, down to the # database globals that only mangosd defines. These three know nothing of it. ${CMAKE_SOURCE_DIR}/src/game/WorldHandlers/DynamicCollision.cpp diff --git a/src/tests/LoginSequenceTest.cpp b/src/tests/LoginSequenceTest.cpp new file mode 100644 index 000000000..1317a201d --- /dev/null +++ b/src/tests/LoginSequenceTest.cpp @@ -0,0 +1,53 @@ +/** + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MaNGOS is a full featured server for World of Warcraft, supporting + * the following clients: 1.12.x, 2.4.3, 3.3.5a, 4.3.4a and 5.4.8 + * + * Copyright (C) 2005-2026 MaNGOS + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * World of Warcraft, and all World of Warcraft or Warcraft art, images, + * and lore are copyrighted by Blizzard Entertainment, Inc. + */ + +#include "TestHarness.h" + +#include "LoginEffectPackets.h" +#include "Opcodes.h" +#include "WorldPacket.h" + +TEST(LoginEffectPackets_builds_success_result) +{ + WorldPacket packet = LoginEffectPackets::BuildCastResult(); + CHECK_EQ(int(packet.GetOpcode()), int(SMSG_CAST_FAILED)); + CHECK_HEX(packet.contents(), packet.size(), "4403000000"); +} + +TEST(LoginEffectPackets_builds_retail_start) +{ + WorldPacket packet = LoginEffectPackets::BuildStart(0x0000000001020304ULL); + CHECK_EQ(int(packet.GetOpcode()), int(SMSG_SPELL_START)); + CHECK_HEX(packet.contents(), packet.size(), + "0f040302010f04030201440300000200000000000000"); +} + +TEST(LoginEffectPackets_builds_retail_go) +{ + WorldPacket packet = LoginEffectPackets::BuildGo(0x0000000001020304ULL); + CHECK_EQ(int(packet.GetOpcode()), int(SMSG_SPELL_GO)); + CHECK_HEX(packet.contents(), packet.size(), + "0f040302010f0403020144030000000101040302010000000000020000"); +} From 6c854b34592ce6cc5fab5d6303c81f44d602d14c Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 21 Aug 2026 00:25:53 +0100 Subject: [PATCH 04/14] Track character enum map evidence --- src/game/Server/CharacterEnumMapSnapshot.h | 59 ++++++++++++++++++++++ src/tests/LoginSequenceTest.cpp | 22 ++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/game/Server/CharacterEnumMapSnapshot.h diff --git a/src/game/Server/CharacterEnumMapSnapshot.h b/src/game/Server/CharacterEnumMapSnapshot.h new file mode 100644 index 000000000..7808530e2 --- /dev/null +++ b/src/game/Server/CharacterEnumMapSnapshot.h @@ -0,0 +1,59 @@ +/** + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MaNGOS is a full featured server for World of Warcraft, supporting + * the following clients: 1.12.x, 2.4.3, 3.3.5a, 4.3.4a and 5.4.8 + * + * Copyright (C) 2005-2026 MaNGOS + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * World of Warcraft, and all World of Warcraft or Warcraft art, images, + * and lore are copyrighted by Blizzard Entertainment, Inc. + */ + +#ifndef MANGOS_CHARACTER_ENUM_MAP_SNAPSHOT_H +#define MANGOS_CHARACTER_ENUM_MAP_SNAPSHOT_H + +#include "Platform/Define.h" + +#include +#include + +/** + * Records the map id serialized for each character by the most recent + * SMSG_CHAR_ENUM. Login compares against this wire-facing snapshot, rather + * than a fresh database read, to detect movement since the screen was shown. + */ +class CharacterEnumMapSnapshot +{ + public: + using MapByGuid = std::map; + + void Replace(MapByGuid snapshot) + { + m_maps = std::move(snapshot); + } + + bool Matches(uint64 guid, uint32 mapId) const + { + MapByGuid::const_iterator found = m_maps.find(guid); + return found != m_maps.end() && found->second == mapId; + } + + private: + MapByGuid m_maps; +}; + +#endif diff --git a/src/tests/LoginSequenceTest.cpp b/src/tests/LoginSequenceTest.cpp index 1317a201d..ca246d50e 100644 --- a/src/tests/LoginSequenceTest.cpp +++ b/src/tests/LoginSequenceTest.cpp @@ -25,6 +25,7 @@ #include "TestHarness.h" +#include "CharacterEnumMapSnapshot.h" #include "LoginEffectPackets.h" #include "Opcodes.h" #include "WorldPacket.h" @@ -51,3 +52,24 @@ TEST(LoginEffectPackets_builds_retail_go) CHECK_HEX(packet.contents(), packet.size(), "0f040302010f0403020144030000000101040302010000000000020000"); } + +TEST(CharacterEnumMapSnapshot_requires_matching_guid_and_map) +{ + CharacterEnumMapSnapshot snapshot; + CHECK(!snapshot.Matches(0x11, 0)); + + snapshot.Replace({{0x11, 0}, {0x22, 1}}); + CHECK(snapshot.Matches(0x11, 0)); + CHECK(!snapshot.Matches(0x11, 1)); + CHECK(!snapshot.Matches(0x33, 0)); +} + +TEST(CharacterEnumMapSnapshot_replaces_the_previous_response) +{ + CharacterEnumMapSnapshot snapshot; + snapshot.Replace({{0x11, 0}}); + snapshot.Replace({{0x22, 1}}); + + CHECK(!snapshot.Matches(0x11, 0)); + CHECK(snapshot.Matches(0x22, 1)); +} From 76ac8670fd47d42da5a21950a22b79dff4724e5c Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 21 Aug 2026 00:26:39 +0100 Subject: [PATCH 05/14] Define initial world entry lifecycle --- src/game/WorldHandlers/InitialWorldEntry.h | 170 +++++++++++++++++++++ src/tests/LoginSequenceTest.cpp | 59 +++++++ 2 files changed, 229 insertions(+) create mode 100644 src/game/WorldHandlers/InitialWorldEntry.h diff --git a/src/game/WorldHandlers/InitialWorldEntry.h b/src/game/WorldHandlers/InitialWorldEntry.h new file mode 100644 index 000000000..4e381292b --- /dev/null +++ b/src/game/WorldHandlers/InitialWorldEntry.h @@ -0,0 +1,170 @@ +/** + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MaNGOS is a full featured server for World of Warcraft, supporting + * the following clients: 1.12.x, 2.4.3, 3.3.5a, 4.3.4a and 5.4.8 + * + * Copyright (C) 2005-2026 MaNGOS + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * World of Warcraft, and all World of Warcraft or Warcraft art, images, + * and lore are copyrighted by Blizzard Entertainment, Inc. + */ + +#ifndef MANGOS_INITIAL_WORLD_ENTRY_H +#define MANGOS_INITIAL_WORLD_ENTRY_H + +#include "Platform/Define.h" + +#include +#include +#include + +class Player; + +/** Packets emitted after map admission and before the first object update. */ +enum class InitialWorldEntryPacket +{ + InitWorldStates, + TriggerCinematic, + ExplorationExperience, + LoginEffectResult, + LoginTimeSpeed +}; + +inline std::vector +InitialWorldEntryPacketOrder(bool cinematic) +{ + // Keep the observed Classic wire order explicit. First-login cinematic + // packets occupy the same pre-object-batch window but are otherwise absent. + std::vector order = { + InitialWorldEntryPacket::InitWorldStates + }; + if (cinematic) + { + order.push_back(InitialWorldEntryPacket::TriggerCinematic); + order.push_back(InitialWorldEntryPacket::ExplorationExperience); + } + order.push_back(InitialWorldEntryPacket::LoginEffectResult); + order.push_back(InitialWorldEntryPacket::LoginTimeSpeed); + return order; +} + +/** Facts established by the one-shot entry hook and consumed after map add. */ +struct InitialWorldEntryContext +{ + uint32 anchorMapId = 0; + uint32 zoneId = 0; + uint32 areaId = 0; + bool initialWorldStatesSent = false; + bool cinematicStarted = false; +}; + +enum class LoginEffectPhase +{ + Start, + Go, + Complete +}; + +class LoginEffectSequenceState +{ + // START and GO are separate wire packets; this state prevents either phase + // from being emitted twice and makes world loss terminal for the sequence. + public: + std::optional TakeNext(bool inWorld) + { + if (!inWorld || m_phase == LoginEffectPhase::Complete) + { + m_phase = LoginEffectPhase::Complete; + return std::nullopt; + } + + LoginEffectPhase current = m_phase; + m_phase = current == LoginEffectPhase::Start ? + LoginEffectPhase::Go : LoginEffectPhase::Complete; + return current; + } + + bool IsComplete() const + { + return m_phase == LoginEffectPhase::Complete; + } + + private: + LoginEffectPhase m_phase = LoginEffectPhase::Start; +}; + +/** + * Owns only the temporary root applied for a first-login cinematic. The + * one-shot release prevents completion and timeout from unrooting twice or + * clearing a root owned by another mechanic. + */ +class LoginCinematicRootOwnership +{ + public: + bool Claim() + { + bool expected = false; + return m_owned.compare_exchange_strong(expected, true); + } + + bool ReleaseOnce() + { + return m_owned.exchange(false); + } + + void Clear() + { + m_owned.store(false); + } + + bool IsOwned() const + { + return m_owned.load(); + } + + private: + std::atomic_bool m_owned{false}; +}; + +/** + * Runs once after committed map membership and before initial object batching, + * then exposes the emitted preamble state to the post-add login path. + */ +class InitialWorldEntryHook +{ + public: + explicit InitialWorldEntryHook(uint32 cinematicSequenceId) + : m_cinematicSequenceId(cinematicSequenceId) + { + } + + void AfterAddToWorld(Player& player); + + InitialWorldEntryContext const* GetContext() const + { + return m_context ? &*m_context : nullptr; + } + + private: + uint32 m_cinematicSequenceId; + std::optional m_context; +}; + +// Failsafe for clients that never report cinematic completion. +constexpr uint32 LOGIN_CINEMATIC_ROOT_TIMEOUT_MS = 120000; + +#endif diff --git a/src/tests/LoginSequenceTest.cpp b/src/tests/LoginSequenceTest.cpp index ca246d50e..e6b9602b6 100644 --- a/src/tests/LoginSequenceTest.cpp +++ b/src/tests/LoginSequenceTest.cpp @@ -26,6 +26,7 @@ #include "TestHarness.h" #include "CharacterEnumMapSnapshot.h" +#include "InitialWorldEntry.h" #include "LoginEffectPackets.h" #include "Opcodes.h" #include "WorldPacket.h" @@ -73,3 +74,61 @@ TEST(CharacterEnumMapSnapshot_replaces_the_previous_response) CHECK(!snapshot.Matches(0x11, 0)); CHECK(snapshot.Matches(0x22, 1)); } + +TEST(InitialWorldEntry_orders_established_packets) +{ + std::vector order = + InitialWorldEntryPacketOrder(false); + REQUIRE(order.size() == 3); + CHECK_EQ(int(order[0]), int(InitialWorldEntryPacket::InitWorldStates)); + CHECK_EQ(int(order[1]), int(InitialWorldEntryPacket::LoginEffectResult)); + CHECK_EQ(int(order[2]), int(InitialWorldEntryPacket::LoginTimeSpeed)); +} + +TEST(InitialWorldEntry_inserts_cinematic_packets_before_result) +{ + std::vector order = + InitialWorldEntryPacketOrder(true); + REQUIRE(order.size() == 5); + CHECK_EQ(int(order[0]), int(InitialWorldEntryPacket::InitWorldStates)); + CHECK_EQ(int(order[1]), int(InitialWorldEntryPacket::TriggerCinematic)); + CHECK_EQ(int(order[2]), int(InitialWorldEntryPacket::ExplorationExperience)); + CHECK_EQ(int(order[3]), int(InitialWorldEntryPacket::LoginEffectResult)); + CHECK_EQ(int(order[4]), int(InitialWorldEntryPacket::LoginTimeSpeed)); +} + +TEST(LoginEffectSequence_has_two_ordered_phases_and_cancels_out_of_world) +{ + LoginEffectSequenceState sequence; + std::optional first = sequence.TakeNext(true); + std::optional second = sequence.TakeNext(true); + std::optional done = sequence.TakeNext(true); + REQUIRE(first.has_value()); + REQUIRE(second.has_value()); + CHECK_EQ(int(*first), int(LoginEffectPhase::Start)); + CHECK_EQ(int(*second), int(LoginEffectPhase::Go)); + CHECK(!done.has_value()); + + LoginEffectSequenceState cancelled; + CHECK(!cancelled.TakeNext(false).has_value()); + CHECK(cancelled.IsComplete()); + + LoginEffectSequenceState interrupted; + REQUIRE(interrupted.TakeNext(true).has_value()); + CHECK(!interrupted.TakeNext(false).has_value()); + CHECK(interrupted.IsComplete()); +} + +TEST(LoginCinematicRootOwnership_releases_exactly_once) +{ + LoginCinematicRootOwnership ownership; + CHECK(ownership.Claim()); + CHECK(!ownership.Claim()); + CHECK(ownership.ReleaseOnce()); + CHECK(!ownership.ReleaseOnce()); + CHECK(!ownership.IsOwned()); + + CHECK(ownership.Claim()); + ownership.Clear(); + CHECK(!ownership.ReleaseOnce()); +} From eade182192e99781792c18cbc21202b6a55ee8de Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 21 Aug 2026 00:29:29 +0100 Subject: [PATCH 06/14] Match retail login verify-world behavior --- src/game/Server/WorldSession.h | 9 +++ src/game/WorldHandlers/CharacterHandler.cpp | 64 ++++++++++++++------- 2 files changed, 51 insertions(+), 22 deletions(-) diff --git a/src/game/Server/WorldSession.h b/src/game/Server/WorldSession.h index cd8a84785..2305791ad 100644 --- a/src/game/Server/WorldSession.h +++ b/src/game/Server/WorldSession.h @@ -37,6 +37,7 @@ #include #include #include +#include "CharacterEnumMapSnapshot.h" #include "SessionProtocolPolicy.h" #include "WardenConfiguration.h" #include "Auth/BigNumber.h" @@ -345,6 +346,13 @@ class WorldSession _player = plr; } + // Compare login against the map this session actually advertised on + // the character screen, not a newer database value. + bool HasMatchingCharacterEnumMap(ObjectGuid const& guid, uint32 mapId) const + { + return m_characterEnumMaps.Matches(guid.GetRawValue(), mapId); + } + /// Session in auth.queue currently void SetInQueue(bool state) { @@ -941,6 +949,7 @@ class WorldSession void LogUnprocessedTail(WorldPacket* packet); Player* _player; + CharacterEnumMapSnapshot m_characterEnumMaps; std::shared_ptr m_link; std::shared_ptr m_mailbox; std::unique_ptr m_pendingAddonInfo; diff --git a/src/game/WorldHandlers/CharacterHandler.cpp b/src/game/WorldHandlers/CharacterHandler.cpp index 64de4723b..46bd94462 100644 --- a/src/game/WorldHandlers/CharacterHandler.cpp +++ b/src/game/WorldHandlers/CharacterHandler.cpp @@ -359,6 +359,9 @@ bool PlayerbotHolder::AddPlayerBot(uint64 playerGuid, uint32 masterAccountId) void WorldSession::HandleCharEnum(QueryResult* result) { WorldPacket data(SMSG_CHAR_ENUM, 100); // we guess size + // Preserve only rows that are successfully serialized. Login must compare + // against what reached this character screen, not current database state. + CharacterEnumMapSnapshot::MapByGuid advertisedMaps; uint8 num = 0; @@ -369,10 +372,13 @@ void WorldSession::HandleCharEnum(QueryResult* result) do { uint32 guidlow = (*result)[0].GetUInt32(); + uint32 advertisedMap = (*result)[9].GetUInt32(); DETAIL_LOG("Build enum data for char guid %u from account %u.", guidlow, GetAccountId()); if (Player::BuildEnumData(result, &data)) { ++num; + advertisedMaps.emplace( + ObjectGuid(HIGHGUID_PLAYER, guidlow).GetRawValue(), advertisedMap); } } while (result->NextRow()); @@ -383,6 +389,8 @@ void WorldSession::HandleCharEnum(QueryResult* result) data.put(0, num); SendPacket(&data); + // Each enum response replaces the previous screen snapshot in full. + m_characterEnumMaps.Replace(std::move(advertisedMaps)); // Retail 1.12.1.5875 emits its first Warden packet after the completed // character list. Start is idempotent for repeated enumeration requests. @@ -775,30 +783,42 @@ void WorldSession::HandlePlayerLogin(LoginQueryHolder* holder) /* Validation check completely, assign player to WorldSession::_player for later use */ SetPlayer(pCurrChar); - WorldPacket data(SMSG_LOGIN_VERIFY_WORLD, 20); - data << pCurrChar->GetMapId(); - - // ABOARD, THE OFFSET -- exactly what SMSG_NEW_WORLD carries on the teleport path, and - // for the same reason. The create block that follows says the player is at (0, 0, 0) on - // a transport, with the deck spot in the offset; sending the ship's WORLD pose here - // instead means the two packets describe the position with two different meanings, and - // the second one has an orientation the vessel never updates (Transport::Create leaves - // it at 1.0). The teleport path is the one that demonstrably works; this makes login - // say the same thing. - if (pCurrChar->GetTransport()) - { - Position const* aboard = pCurrChar->m_movementInfo.GetTransportPos(); - data << aboard->x << aboard->y << aboard->z << aboard->o; - } - else + // Retail omits LOGIN_VERIFY_WORLD when the committed world anchor still + // matches CHAR_ENUM; a server-side move between screen and login needs it. + WorldPacket data; + uint32 anchorMapId = 0; + float anchorX = 0.0f; + float anchorY = 0.0f; + float anchorZ = 0.0f; + pCurrChar->GetWorldAnchor(anchorMapId, anchorX, anchorY, anchorZ); + + if (!HasMatchingCharacterEnumMap(playerGuid, anchorMapId)) { - data << pCurrChar->Where().X(); - data << pCurrChar->Where().Y(); - data << pCurrChar->Where().Z(); - data << pCurrChar->Where().Facing(); - } + data.Initialize(SMSG_LOGIN_VERIFY_WORLD, 20); + data << pCurrChar->GetMapId(); + + // ABOARD, THE OFFSET -- exactly what SMSG_NEW_WORLD carries on the teleport path, and + // for the same reason. The create block that follows says the player is at (0, 0, 0) on + // a transport, with the deck spot in the offset; sending the ship's WORLD pose here + // instead means the two packets describe the position with two different meanings, and + // the second one has an orientation the vessel never updates (Transport::Create leaves + // it at 1.0). The teleport path is the one that demonstrably works; this makes login + // say the same thing. + if (pCurrChar->GetTransport()) + { + Position const* aboard = pCurrChar->m_movementInfo.GetTransportPos(); + data << aboard->x << aboard->y << aboard->z << aboard->o; + } + else + { + data << pCurrChar->Where().X(); + data << pCurrChar->Where().Y(); + data << pCurrChar->Where().Z(); + data << pCurrChar->Where().Facing(); + } - SendPacket(&data); + SendPacket(&data); + } data.Initialize(SMSG_ACCOUNT_DATA_TIMES, 128); for (int i = 0; i < 32; ++i) From 72294b2f2d399afbb3d7853c63ee1b8f6ec9beaf Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 21 Aug 2026 00:29:52 +0100 Subject: [PATCH 07/14] Move social lists into retail login preamble --- src/game/WorldHandlers/CharacterHandler.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/game/WorldHandlers/CharacterHandler.cpp b/src/game/WorldHandlers/CharacterHandler.cpp index 46bd94462..324790a57 100644 --- a/src/game/WorldHandlers/CharacterHandler.cpp +++ b/src/game/WorldHandlers/CharacterHandler.cpp @@ -820,6 +820,8 @@ void WorldSession::HandlePlayerLogin(LoginQueryHolder* holder) SendPacket(&data); } + // The captured pre-world preamble sends account data followed by friend + // and ignore lists, before MOTD and map admission. data.Initialize(SMSG_ACCOUNT_DATA_TIMES, 128); for (int i = 0; i < 32; ++i) { @@ -827,6 +829,9 @@ void WorldSession::HandlePlayerLogin(LoginQueryHolder* holder) } SendPacket(&data); + pCurrChar->GetSocial()->SendFriendList(); + pCurrChar->GetSocial()->SendIgnoreList(); + /* 1.12.1 does not have SMSG_MOTD, so we send a server message */ /* Used for counting number of newlines in MOTD */ @@ -971,10 +976,6 @@ void WorldSession::HandlePlayerLogin(LoginQueryHolder* holder) sPlayerRegistry.Add(pCurrChar); DEBUG_LOG("Player %s added to map %i", pCurrChar->GetName(), pCurrChar->GetMapId()); - /* send the player's social lists */ - pCurrChar->GetSocial()->SendFriendList(); - pCurrChar->GetSocial()->SendIgnoreList(); - /* Send packets that must be sent only after player is added to the map */ pCurrChar->SendInitialPacketsAfterAddToMap(); From 6057abd807f0fef544a5a06343e8de2ce2871fa1 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 21 Aug 2026 00:38:00 +0100 Subject: [PATCH 08/14] Add retail initial world entry hook --- src/game/Object/Player.cpp | 71 ++++++++++----- src/game/Object/Player.h | 11 ++- src/game/Object/PlayerZone.cpp | 10 ++- src/game/WorldHandlers/CharacterHandler.cpp | 62 ++++++++------ src/game/WorldHandlers/InitialWorldEntry.cpp | 90 ++++++++++++++++++++ src/game/WorldHandlers/Map.cpp | 19 +++-- src/game/WorldHandlers/Map.h | 7 +- src/game/WorldHandlers/TransportMap.cpp | 12 ++- src/game/WorldHandlers/TransportMap.h | 2 +- src/tests/CheckLoginObjectBatching.cmake | 22 ++++- 10 files changed, 244 insertions(+), 62 deletions(-) create mode 100644 src/game/WorldHandlers/InitialWorldEntry.cpp diff --git a/src/game/Object/Player.cpp b/src/game/Object/Player.cpp index 72e452265..eb4012e03 100644 --- a/src/game/Object/Player.cpp +++ b/src/game/Object/Player.cpp @@ -3869,10 +3869,19 @@ void Player::SendUpdateWorldState(uint32 Field, uint32 Value) * @param zoneid The zone identifier used to select world states. */ void Player::SendInitWorldStates(uint32 zoneid) +{ + SendInitWorldStates(GetMapId(), zoneid); +} + +/** + * Sends initial world states for an explicit client-visible map anchor. + * Transport passengers live on a deck map internally while the client still + * renders the world map sailed by the vessel. + */ +void Player::SendInitWorldStates(uint32 mapid, uint32 zoneid) { // data depends on zoneid/mapid... BattleGround* bg = GetBattleGround(); - uint32 mapid = GetMapId(); DEBUG_LOG("Sending SMSG_INIT_WORLD_STATES to Map:%u, Zone: %u", mapid, zoneid); @@ -5317,8 +5326,13 @@ void Player::SetComboPoints() -/* Called by WorldSession::HandlePlayerLogin */ -void Player::SendInitialPacketsBeforeAddToMap() +/** + * Sends the map-independent login preamble. + * + * @param deferLoginTimeSpeed Keep time/speed for the post-admission retail + * ordering instead of sending it from this legacy position. + */ +void Player::SendInitialPacketsBeforeAddToMap(bool deferLoginTimeSpeed) { /** This packet seems useless... * TODO: Work out if we need SMSG_SET_REST_START */ @@ -5344,12 +5358,10 @@ void Player::SendInitialPacketsBeforeAddToMap() /* Update player's honour information (does not send anything) */ UpdateHonor(); - const float game_time = 0.01666667f; // Game speed - - data.Initialize(SMSG_LOGIN_SETTIMESPEED, 4 + 4); - data << uint32(secsToTimeBitFields(sWorld.GetGameTime())); - data << game_time; // Float is 4 bytes here - GetSession()->SendPacket(&data); + if (!deferLoginTimeSpeed) + { + SendLoginTimeSpeed(); + } // Set fly flag if player is on a taxi to avoid falling to the ground if (IsTaxiFlying()) @@ -5361,9 +5373,15 @@ void Player::SendInitialPacketsBeforeAddToMap() SetMover(this); } -/** - * @brief Sends map-dependent initialization packets after the player is added to the world. - */ +/** Isolates SMSG_LOGIN_SETTIMESPEED so entry ordering can defer it unchanged. */ +void Player::SendLoginTimeSpeed() +{ + WorldPacket data(SMSG_LOGIN_SETTIMESPEED, 8); + data << uint32(secsToTimeBitFields(sWorld.GetGameTime())); + data << float(0.01666667f); + GetSession()->SendPacket(&data); +} + /** * @brief Where this player is, for the questions the WORLD answers: a graveyard, an area * trigger, anything looked up against terrain the client shipped. @@ -5511,15 +5529,29 @@ void Player::UpdateLiftMinions() CONTROLLED_PET | CONTROLLED_MINIPET | CONTROLLED_GUARDIANS); } -void Player::SendInitialPacketsAfterAddToMap() +/** + * Sends map-dependent initialization after committed world entry. + * + * A non-null context means the entry hook already sent world states and + * time/speed before the object batch. Null preserves the legacy teleport path. + */ +void Player::SendInitialPacketsAfterAddToMap(InitialWorldEntryContext const* initialEntry) { - /* Update players zone */ - uint32 newzone, newarea; - GetTerrain()->GetZoneAndAreaId(newzone, newarea, Where().X(), Where().Y(), Where().Z()); - UpdateZone(newzone, newarea); // This calls SendInitWorldStates + if (initialEntry) + { + UpdateZone(initialEntry->zoneId, initialEntry->areaId, + !initialEntry->initialWorldStatesSent); + } + else + { + /* Update players zone */ + uint32 newzone, newarea; + GetTerrain()->GetZoneAndAreaId(newzone, newarea, Where().X(), Where().Y(), Where().Z()); + UpdateZone(newzone, newarea); // This calls SendInitWorldStates - /* Login effect spell */ - CastSpell(this, 836, true); // LOGINEFFECT + /* Login effect spell */ + CastSpell(this, 836, true); // LOGINEFFECT + } /** Sets aura effects that need to be sent after the player is added to the map * We use SendMessageToSet so that it's sent to everyone, including the player @@ -6937,4 +6969,3 @@ void Player::KnockBackFrom(Unit* target, float horizontalSpeed, float verticalSp GetSession()->SendKnockBack(angle, horizontalSpeed, verticalSpeed); } - diff --git a/src/game/Object/Player.h b/src/game/Object/Player.h index 840a540d6..a4b630b02 100644 --- a/src/game/Object/Player.h +++ b/src/game/Object/Player.h @@ -87,6 +87,7 @@ #include "PetMgr.h" // held by value on Player; owns stable-slot count + temp-unsummon pet number #include "BattleGround.h" #include "DBCStores.h" +#include "InitialWorldEntry.h" #include "SharedDefines.h" #include "Chat.h" #include "GMTicketMgr.h" @@ -1189,8 +1190,11 @@ class Player : public Unit return Where().Z() < m_lastFallZ; } - void SendInitialPacketsBeforeAddToMap(); // Send initial packets before adding the player to the map - void SendInitialPacketsAfterAddToMap(); // Send initial packets after adding the player to the map + // A context is supplied only for the initial login lifecycle; null keeps + // ordinary teleport callers on their established packet sequence. + void SendInitialPacketsBeforeAddToMap(bool deferLoginTimeSpeed = false); + void SendInitialPacketsAfterAddToMap(InitialWorldEntryContext const* initialEntry = nullptr); + void SendLoginTimeSpeed(); void SendInstanceResetWarning(uint32 mapid, uint32 time); // Send instance reset warning // Get the NPC if the player can interact with it @@ -2498,7 +2502,7 @@ class Player : public Unit void SetFFAPvP(bool state); // Update the player's zone - void UpdateZone(uint32 newZone, uint32 newArea); + void UpdateZone(uint32 newZone, uint32 newArea, bool sendInitialWorldStates = true); // Update the player's area void UpdateArea(uint32 newArea); @@ -3178,6 +3182,7 @@ class Player : public Unit void CastItemUseSpell(Item* item, SpellCastTargets const& targets); void SendInitWorldStates(uint32 zone); + void SendInitWorldStates(uint32 mapId, uint32 zone); void SendUpdateWorldState(uint32 Field, uint32 Value); // Send a direct message to the client diff --git a/src/game/Object/PlayerZone.cpp b/src/game/Object/PlayerZone.cpp index 4c636e9e7..60243a2e0 100644 --- a/src/game/Object/PlayerZone.cpp +++ b/src/game/Object/PlayerZone.cpp @@ -254,8 +254,11 @@ void Player::UpdateArea(uint32 newArea) * * @param newZone The new zone identifier. * @param newArea The new area identifier. + * @param sendInitialWorldStates False when the entry hook already emitted the + * packet before the initial object batch; all other zone side effects + * still run. */ -void Player::UpdateZone(uint32 newZone, uint32 newArea) +void Player::UpdateZone(uint32 newZone, uint32 newArea, bool sendInitialWorldStates) { /* If we're trying to update into a zone that doesn't exist, just return */ AreaTableEntry const* zone = GetAreaEntryByAreaID(newZone); @@ -275,7 +278,10 @@ void Player::UpdateZone(uint32 newZone, uint32 newArea) sRandomPlayerbotMgr.OnPlayerZoneChange(this, newZone); #endif - SendInitWorldStates(newZone); // only if really enters to new zone, not just area change, works strange... + if (sendInitialWorldStates) + { + SendInitWorldStates(newZone); // only if really enters to new zone, not just area change, works strange... + } if (sWorld.getConfig(CONFIG_BOOL_WEATHER)) { diff --git a/src/game/WorldHandlers/CharacterHandler.cpp b/src/game/WorldHandlers/CharacterHandler.cpp index 324790a57..aa8e314a3 100644 --- a/src/game/WorldHandlers/CharacterHandler.cpp +++ b/src/game/WorldHandlers/CharacterHandler.cpp @@ -56,6 +56,8 @@ #include "World.h" #include "ObjectMgr.h" #include "Player.h" +#include "DBCStores.h" +#include "InitialWorldEntry.h" #include "CinematicFlyover.h" #include "Guild.h" #include "GuildMgr.h" @@ -923,22 +925,29 @@ void WorldSession::HandlePlayerLogin(LoginQueryHolder* holder) pCurrChar->SendCorpseReclaimDelay(true); } - /** Sends information required before the player can be added to the map - * TODO: See if we can send information about game objects here (prevent alt+f4 through object) */ - pCurrChar->SendInitialPacketsBeforeAddToMap(); - - /* If it's the player's first login, send a cinematic */ - bool isFirstLogin = !pCurrChar->getCinematic(); - if (isFirstLogin) + uint32 cinematicSequenceId = 0; + // Validate the sequence now, but do not mark or emit the cinematic until + // map admission succeeds and the entry hook owns the wire position. + if (!pCurrChar->getCinematic()) { - pCurrChar->setCinematic(1); - - /* Set the start location to the player's racial starting point */ - if (ChrRacesEntry const* rEntry = sChrRacesStore.LookupEntry(pCurrChar->getRace())) + if (ChrRacesEntry const* race = + sChrRacesStore.LookupEntry(pCurrChar->getRace())) { - pCurrChar->SendCinematicStart(rEntry->CinematicSequence); + if (race->CinematicSequence && + sCinematicSequencesStore.LookupEntry(race->CinematicSequence)) + { + cinematicSequenceId = race->CinematicSequence; + } } } + // The hook owns packets placed after committed map membership but before + // the first consolidated object update. + InitialWorldEntryHook initialEntry(cinematicSequenceId); + + /** Sends information required before the player can be added to the map + * TODO: See if we can send information about game objects here (prevent alt+f4 through object) */ + // Time/speed is deferred into the hook's pre-object-batch position. + pCurrChar->SendInitialPacketsBeforeAddToMap(true); uint32 miscRequirement = 0; AreaLockStatus lockStatus = AREA_LOCKSTATUS_OK; @@ -957,7 +966,8 @@ void WorldSession::HandlePlayerLogin(LoginQueryHolder* holder) } /* This code is run if we can not add the player to the map for some reason */ - if (lockStatus != AREA_LOCKSTATUS_OK || !pCurrChar->BoardingMap()->Add(pCurrChar)) + if (lockStatus != AREA_LOCKSTATUS_OK || + !pCurrChar->BoardingMap()->Add(pCurrChar, &initialEntry)) { /* Attempt to find an areatrigger to teleport the player for us */ AreaTrigger const* at = sObjectMgr.GetGoBackTrigger(pCurrChar->GetMapId()); @@ -973,23 +983,27 @@ void WorldSession::HandlePlayerLogin(LoginQueryHolder* holder) } } + InitialWorldEntryContext const* entryContext = initialEntry.GetContext(); + // Admission can fail without starting a far teleport. Preserve the legacy + // time packet when no hook had a chance to emit it. + if (!entryContext && !pCurrChar->IsBeingTeleportedFar()) + { + pCurrChar->SendLoginTimeSpeed(); + } + sPlayerRegistry.Add(pCurrChar); DEBUG_LOG("Player %s added to map %i", pCurrChar->GetName(), pCurrChar->GetMapId()); /* Send packets that must be sent only after player is added to the map */ - pCurrChar->SendInitialPacketsAfterAddToMap(); + pCurrChar->SendInitialPacketsAfterAddToMap(entryContext); - /* If it's the player's first login, create cinematic flyover if enabled */ - /* Note: isFirstLogin was captured before mutating getCinematic() (line 807) */ - /* We create the flyover after the player is fully in-world (per final review) */ - if (isFirstLogin && sConfig.GetBoolDefault("Cinematic.Flyover.Enable", false)) + /* Create a flyover only for the cinematic actually emitted by the entry hook. */ + if (entryContext && entryContext->cinematicStarted && + sConfig.GetBoolDefault("Cinematic.Flyover.Enable", false)) { - if (sChrRacesStore.LookupEntry(pCurrChar->getRace())) - { - pCurrChar->SetCinematicFlyover( - std::make_unique(pCurrChar, - pCurrChar->getRace())); - } + pCurrChar->SetCinematicFlyover( + std::make_unique(pCurrChar, + pCurrChar->getRace())); } /* Mark player as online in the database */ diff --git a/src/game/WorldHandlers/InitialWorldEntry.cpp b/src/game/WorldHandlers/InitialWorldEntry.cpp new file mode 100644 index 000000000..dbd2d7be7 --- /dev/null +++ b/src/game/WorldHandlers/InitialWorldEntry.cpp @@ -0,0 +1,90 @@ +/** + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MaNGOS is a full featured server for World of Warcraft, supporting + * the following clients: 1.12.x, 2.4.3, 3.3.5a, 4.3.4a and 5.4.8 + * + * Copyright (C) 2005-2026 MaNGOS + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * World of Warcraft, and all World of Warcraft or Warcraft art, images, + * and lore are copyrighted by Blizzard Entertainment, Inc. + */ + +#include "InitialWorldEntry.h" + +#include "DBCStores.h" +#include "LoginEffectPackets.h" +#include "Player.h" +#include "WorldPacket.h" +#include "WorldSession.h" + +void InitialWorldEntryHook::AfterAddToWorld(Player& player) +{ + // Map admission supplies the authoritative world/transport anchor. Running + // sooner would derive zone state from the map the player is leaving. + if (!player.IsInWorld() || m_context) + { + return; + } + + InitialWorldEntryContext context; + float anchorX = 0.0f; + float anchorY = 0.0f; + float anchorZ = 0.0f; + player.GetWorldAnchor(context.anchorMapId, anchorX, anchorY, anchorZ); + player.GetZoneAndAreaAboardOrHere(context.zoneId, context.areaId); + + // These packets must precede the single initial object update; later login + // work consumes m_context instead of sending world states a second time. + for (InitialWorldEntryPacket action : + InitialWorldEntryPacketOrder(m_cinematicSequenceId != 0)) + { + switch (action) + { + case InitialWorldEntryPacket::InitWorldStates: + if (sMapStore.LookupEntry(context.anchorMapId) && + GetAreaEntryByAreaID(context.zoneId)) + { + player.SendInitWorldStates( + context.anchorMapId, context.zoneId); + context.initialWorldStatesSent = true; + } + break; + case InitialWorldEntryPacket::TriggerCinematic: + player.SendCinematicStart(m_cinematicSequenceId); + player.setCinematic(1); + context.cinematicStarted = true; + break; + case InitialWorldEntryPacket::ExplorationExperience: + if (context.areaId && GetAreaEntryByAreaID(context.areaId)) + { + player.SendExplorationExperience(context.areaId, 0); + } + break; + case InitialWorldEntryPacket::LoginEffectResult: + { + WorldPacket packet = LoginEffectPackets::BuildCastResult(); + player.GetSession()->SendPacket(&packet); + break; + } + case InitialWorldEntryPacket::LoginTimeSpeed: + player.SendLoginTimeSpeed(); + break; + } + } + + m_context = context; +} diff --git a/src/game/WorldHandlers/Map.cpp b/src/game/WorldHandlers/Map.cpp index fa0f4ecf4..7df02228c 100644 --- a/src/game/WorldHandlers/Map.cpp +++ b/src/game/WorldHandlers/Map.cpp @@ -49,6 +49,7 @@ #include #include "Utilities/MathDefines.h" #include "Map.h" +#include "InitialWorldEntry.h" #include "GameObjectModel.h" #include "MapManager.h" #include "Player.h" @@ -704,9 +705,10 @@ void Map::ForceLoadGrid(float x, float y) * @brief Adds a player to the map and initializes its visible world state. * * @param player The player entering the map. + * @param initialEntry Optional initial-login hook; ordinary map entry passes null. * @return Always true after the player has been added. */ -bool Map::Add(Player* player) +bool Map::Add(Player* player, InitialWorldEntryHook* initialEntry) { player->GetMapRef().link(this, player); player->SetMap(this); @@ -718,6 +720,13 @@ bool Map::Add(Player* player) PromoteEnvelopeNeighboursToFull(cell.GridX(), cell.GridY()); player->AddToWorld(); + // The hook needs committed membership to derive the correct world anchor, + // but must finish its preamble before any object block enters the batch. + if (initialEntry) + { + initialEntry->AfterAddToWorld(*player); + } + // Coalesce only a genuine login using the player's own camera. Redirected // cameras and non-login map entry retain the established packet path. std::optional initialUpdates; @@ -2489,7 +2498,7 @@ void DungeonMap::InitVisibilityDistance() /** * Do map specific checks and add the player to the map if successful. */ -bool DungeonMap::Add(Player* player) +bool DungeonMap::Add(Player* player, InitialWorldEntryHook* initialEntry) { // TODO: Not sure about checking player level: already done in HandleAreaTriggerOpcode // GMs still can teleport player in instance. @@ -2612,7 +2621,7 @@ bool DungeonMap::Add(Player* player) m_unloadWhenEmpty = false; // this will acquire the same mutex so it can not be in the previous block - Map::Add(player); + Map::Add(player, initialEntry); return true; } @@ -2875,7 +2884,7 @@ bool BattleGroundMap::CanEnter(Player* player) * @param player The player entering the battleground. * @return true if the player was added; otherwise false. */ -bool BattleGroundMap::Add(Player* player) +bool BattleGroundMap::Add(Player* player, InitialWorldEntryHook* initialEntry) { if (!CanEnter(player)) { @@ -2885,7 +2894,7 @@ bool BattleGroundMap::Add(Player* player) // reset instance validity, battleground maps do not homebind player->m_InstanceValid = true; - return Map::Add(player); + return Map::Add(player, initialEntry); } /** diff --git a/src/game/WorldHandlers/Map.h b/src/game/WorldHandlers/Map.h index c68523173..a35c63d68 100644 --- a/src/game/WorldHandlers/Map.h +++ b/src/game/WorldHandlers/Map.h @@ -87,6 +87,7 @@ class Eluna; #endif /* ENABLE_ELUNA */ class TransportMap; class InitialWorldUpdateBatch; +class InitialWorldEntryHook; class Unit; class WorldPacket; class InstanceData; @@ -170,7 +171,7 @@ class Map : public GridRefManager return false; } - virtual bool Add(Player*); + virtual bool Add(Player*, InitialWorldEntryHook* initialEntry = nullptr); virtual void Remove(Player*, bool); template void Add(T*); template void Remove(T*, bool); @@ -604,7 +605,7 @@ class DungeonMap : public Map public: DungeonMap(uint32 id, time_t, uint32 InstanceId); ~DungeonMap(); - bool Add(Player*) override; + bool Add(Player*, InitialWorldEntryHook* initialEntry = nullptr) override; void Remove(Player*, bool) override; void Update(const uint32&) override; bool Reset(InstanceResetMethod method); @@ -634,7 +635,7 @@ class BattleGroundMap : public Map ~BattleGroundMap(); void Update(const uint32&) override; - bool Add(Player*) override; + bool Add(Player*, InitialWorldEntryHook* initialEntry = nullptr) override; void Remove(Player*, bool) override; bool CanEnter(Player* player) override; void SetUnload(); diff --git a/src/game/WorldHandlers/TransportMap.cpp b/src/game/WorldHandlers/TransportMap.cpp index b98f3c335..774a0f1d4 100644 --- a/src/game/WorldHandlers/TransportMap.cpp +++ b/src/game/WorldHandlers/TransportMap.cpp @@ -25,6 +25,7 @@ #include "Utilities/MathDefines.h" #include "TransportMap.h" +#include "InitialWorldEntry.h" #include #include @@ -401,7 +402,7 @@ std::optional TransportMap::FreeSpotNear(WorldObject const& master, fl /* ******************************** Who is aboard ************************************** */ -bool TransportMap::Add(Player* passenger) +bool TransportMap::Add(Player* passenger, InitialWorldEntryHook* initialEntry) { // WHERE HE REALLY STANDS. The wire calls it an offset; the moment it is ours it is a // position on this map, composed with nothing. @@ -417,6 +418,13 @@ bool TransportMap::Add(Player* passenger) PromoteEnvelopeNeighboursToFull(cell.GridX(), cell.GridY()); passenger->AddToWorld(); + // As on an ordinary map, derive the client-visible world anchor only after + // membership commits and before vessel/passenger blocks are accumulated. + if (initialEntry) + { + initialEntry->AfterAddToWorld(*passenger); + } + // Match ordinary map login: only the passenger's own initial camera may // coalesce this data. Seam crossings keep their established send path. std::optional initialUpdates; @@ -516,7 +524,7 @@ void TransportMap::Embark(Player* passenger) DescribeSpatially(passenger).c_str()); passenger->GetMap()->Remove(passenger, false); - Add(passenger); + Add(passenger, nullptr); // His minions come with him, NOW. UpdateMinions reconciles this once per tick and is // the safety net for the half-dozen other ways one arrives -- but a pet that waits a diff --git a/src/game/WorldHandlers/TransportMap.h b/src/game/WorldHandlers/TransportMap.h index 748afbf7b..458915f47 100644 --- a/src/game/WorldHandlers/TransportMap.h +++ b/src/game/WorldHandlers/TransportMap.h @@ -101,7 +101,7 @@ class TransportMap : public Map * ordinary visibility pass at the end, because on this map they are ordinary objects * in ordinary cells. */ - bool Add(Player* passenger) override; + bool Add(Player* passenger, InitialWorldEntryHook* initialEntry = nullptr) override; TransportMap* AsTransport() override { return this; } TransportMap const* AsTransport() const override { return this; } diff --git a/src/tests/CheckLoginObjectBatching.cmake b/src/tests/CheckLoginObjectBatching.cmake index f8903a038..ffc247483 100644 --- a/src/tests/CheckLoginObjectBatching.cmake +++ b/src/tests/CheckLoginObjectBatching.cmake @@ -40,13 +40,14 @@ set(GAME_ROOT "${SOURCE_ROOT}/src/game") file(READ "${GAME_ROOT}/WorldHandlers/UpdateData.h" UPDATE_DATA_H) file(READ "${GAME_ROOT}/WorldHandlers/Map.cpp" MAP_CPP) file(READ "${GAME_ROOT}/WorldHandlers/TransportMap.cpp" TRANSPORT_MAP_CPP) +file(READ "${GAME_ROOT}/WorldHandlers/MovementHandler.cpp" MOVEMENT_HANDLER_CPP) file(READ "${GAME_ROOT}/Object/Camera.h" CAMERA_H) file(READ "${GAME_ROOT}/WorldHandlers/GridNotifiers.cpp" GRID_NOTIFIERS_CPP) require_count(UPDATE_DATA_H "class[ \\t]+InitialWorldUpdateBatch" 1 "one login-scoped batch owner must carry update data and transport state") -string(FIND "${MAP_CPP}" "bool Map::Add(Player* player)" MAP_ADD_BEGIN) +string(FIND "${MAP_CPP}" "bool Map::Add(Player* player" MAP_ADD_BEGIN) if(MAP_ADD_BEGIN EQUAL -1) message(FATAL_ERROR "Login object batching: cannot locate Map::Add(Player*)") endif() @@ -63,6 +64,13 @@ require_count(MAP_ADD_BODY "a redirected login camera must retain the legacy multi-packet path") require_count(MAP_ADD_BODY "InitialWorldUpdateBatch" 1 "ordinary-map login must own exactly one initial batch") +require_count(MAP_ADD_BODY "AfterAddToWorld" 1 + "ordinary-map entry must invoke the initial-world hook exactly once") +require_before(MAP_ADD_BODY "player->AddToWorld()" "initialEntry->AfterAddToWorld" + "ordinary-map hook must run after committed world membership") +require_before(MAP_ADD_BODY "initialEntry->AfterAddToWorld" + "std::optional" + "ordinary-map hook must run before initial batch construction") require_before(MAP_ADD_BODY "SendInitSelf(player" "SendInitTransports(player" "self and inventory must precede map-wide vessels") require_before(MAP_ADD_BODY "SendInitTransports(player" "Event_AddedToWorld(" @@ -72,7 +80,7 @@ require_before(MAP_ADD_BODY "Event_AddedToWorld(" "UpdateObjectVisibility(player require_count(MAP_ADD_BODY "return[ \\t]+true" 1 "batch failure must not skip balanced map and instance enter hooks") -string(FIND "${TRANSPORT_MAP_CPP}" "bool TransportMap::Add(Player* passenger)" +string(FIND "${TRANSPORT_MAP_CPP}" "bool TransportMap::Add(Player* passenger" TRANSPORT_ADD_BEGIN) string(FIND "${TRANSPORT_MAP_CPP}" "void TransportMap::Embark(Player* passenger)" TRANSPORT_ADD_END) @@ -91,6 +99,14 @@ require_count(TRANSPORT_ADD_BODY "a redirected transport login camera must retain the legacy multi-packet path") require_count(TRANSPORT_ADD_BODY "InitialWorldUpdateBatch" 1 "transport-map login must own exactly one initial batch") +require_count(TRANSPORT_ADD_BODY "AfterAddToWorld" 1 + "transport-map entry must invoke the initial-world hook exactly once") +require_before(TRANSPORT_ADD_BODY "passenger->AddToWorld()" + "initialEntry->AfterAddToWorld" + "transport-map hook must run after committed world membership") +require_before(TRANSPORT_ADD_BODY "initialEntry->AfterAddToWorld" + "std::optional" + "transport-map hook must run before initial batch construction") require_before(TRANSPORT_ADD_BODY "BuildCreateUpdateBlockForPlayer" "Event_AddedToWorld(" "vessel and passenger blocks must precede the owner visibility sweep") @@ -109,6 +125,8 @@ require_count(TRANSPORT_MAP_CPP require_count(MAP_CPP "TransportMap::AppendVesselCreateBlocks[ \\t]*\\(" 2 "both normal-map transport sources must append into the shared login batch") +require_count(MOVEMENT_HANDLER_CPP "InitialWorldEntry" 0 + "far worldport handling must remain on the legacy no-hook path") require_count(CAMERA_H "GetOwner[ \\t]*\\([ \\t]*\\)[ \\t]*==[ \\t]*batchOwner" 1 From b4d6265fe262210b355e7938d247fd25180fb48d Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 21 Aug 2026 00:41:53 +0100 Subject: [PATCH 09/14] Match retail post-batch login effect flow --- src/game/Object/Player.cpp | 106 ++++++++++++++++++++++++- src/game/Object/Player.h | 7 ++ src/game/WorldHandlers/MiscHandler.cpp | 2 + 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/game/Object/Player.cpp b/src/game/Object/Player.cpp index eb4012e03..62de401f7 100644 --- a/src/game/Object/Player.cpp +++ b/src/game/Object/Player.cpp @@ -31,6 +31,7 @@ #include "Utilities/MathDefines.h" #include "Utilities/PackedValues.h" #include "Player.h" +#include "LoginEffectPackets.h" #include "Language.h" #include "Database/DatabaseEnv.h" #include "Log.h" @@ -88,6 +89,65 @@ #include +namespace +{ + // Spell 836 is deliberately split around the initial object batch. The + // event emits START after presentation and GO on the next eligible tick. + class LoginEffectEvent final : public BasicEvent + { + public: + explicit LoginEffectEvent(Player& player) : m_player(player) + { + } + + bool Execute(uint64 eTime, uint32) override + { + std::optional phase = + m_state.TakeNext(m_player.IsInWorld()); + if (!phase) + { + return true; + } + + WorldPacket packet = *phase == LoginEffectPhase::Start ? + LoginEffectPackets::BuildStart( + m_player.GetObjectGuid().GetRawValue()) : + LoginEffectPackets::BuildGo( + m_player.GetObjectGuid().GetRawValue()); + m_player.SendMessageToSet(&packet, true); + + if (*phase == LoginEffectPhase::Start) + { + m_player.m_Events.AddEvent(this, eTime + 1, false); + return false; + } + return true; + } + + private: + Player& m_player; + LoginEffectSequenceState m_state; + }; + + class LoginCinematicRootTimeoutEvent final : public BasicEvent + { + public: + explicit LoginCinematicRootTimeoutEvent(Player& player) + : m_player(player) + { + } + + bool Execute(uint64, uint32) override + { + m_player.ReleaseLoginCinematicRoot(); + return true; + } + + private: + Player& m_player; + }; +} + #define ZONE_UPDATE_INTERVAL (1*IN_MILLISECONDS) #define PLAYER_SKILL_INDEX(x) (PLAYER_SKILL_INFO_1_1 + ((x)*3)) @@ -698,6 +758,10 @@ Player::~Player() */ void Player::CleanupsBeforeDelete() { + // Event teardown destroys the timers; clear their independent root token + // before any later cleanup can attempt to release it. + m_loginCinematicRootOwnership.Clear(); + // Stop cinematic flyover if active (must happen before camera dtor) if (m_cinematicFlyover && m_cinematicFlyover->IsActive()) { @@ -5382,6 +5446,40 @@ void Player::SendLoginTimeSpeed() GetSession()->SendPacket(&data); } +/** Queues the visible START/GO half after the initial object batch is sent. */ +void Player::ScheduleLoginEffect() +{ + m_Events.AddEvent(new LoginEffectEvent(*this), m_Events.CalculateTime(1)); +} + +void Player::BeginLoginCinematicRoot() +{ + if (!m_loginCinematicRootOwnership.Claim()) + { + return; + } + + // Normal cinematic completion releases first; this timer is the bounded + // failsafe for clients that never send completion. + m_Events.AddEvent(new LoginCinematicRootTimeoutEvent(*this), + m_Events.CalculateTime(LOGIN_CINEMATIC_ROOT_TIMEOUT_MS)); + SetRoot(true); +} + +void Player::ReleaseLoginCinematicRoot() +{ + if (!m_loginCinematicRootOwnership.ReleaseOnce()) + { + return; + } + + if (IsInWorld() && !HasAuraType(SPELL_AURA_MOD_STUN) && + !HasAuraType(SPELL_AURA_MOD_ROOT)) + { + SetRoot(false); + } +} + /** * @brief Where this player is, for the questions the WORLD answers: a graveyard, an area * trigger, anything looked up against terrain the client shipped. @@ -5541,6 +5639,13 @@ void Player::SendInitialPacketsAfterAddToMap(InitialWorldEntryContext const* ini { UpdateZone(initialEntry->zoneId, initialEntry->areaId, !initialEntry->initialWorldStatesSent); + if (initialEntry->cinematicStarted) + { + BeginLoginCinematicRoot(); + } + // CAST_FAILED was part of the pre-batch hook; START/GO are intentionally + // deferred until the client has received its initial object world. + ScheduleLoginEffect(); } else { @@ -6968,4 +7073,3 @@ void Player::KnockBackFrom(Unit* target, float horizontalSpeed, float verticalSp float angle = this == target ? Where().Facing() + M_PI_F : target->Where().BearingTo(this->Where()); GetSession()->SendKnockBack(angle, horizontalSpeed, verticalSpeed); } - diff --git a/src/game/Object/Player.h b/src/game/Object/Player.h index a4b630b02..011eb69fe 100644 --- a/src/game/Object/Player.h +++ b/src/game/Object/Player.h @@ -3556,6 +3556,11 @@ class Player : public Unit // Set the cinematic flyover manager void SetCinematicFlyover(std::unique_ptr flyover) { m_cinematicFlyover = std::move(flyover); } + // Initial-login presentation state; unrelated spell roots are not owned here. + void ScheduleLoginEffect(); + void BeginLoginCinematicRoot(); + void ReleaseLoginCinematicRoot(); + // Forced speed changes uint8 m_forced_speed_changes[MAX_MOVE_TYPE]; @@ -4065,6 +4070,8 @@ class Player : public Unit // Cinematic flyover manager (optional, for first-login intro visibility) std::unique_ptr m_cinematicFlyover; + LoginCinematicRootOwnership m_loginCinematicRootOwnership; + // Countdown (ms) for the periodic observer-side visibility sweep uint32 m_visibilityObserverSweepTimer; diff --git a/src/game/WorldHandlers/MiscHandler.cpp b/src/game/WorldHandlers/MiscHandler.cpp index 92fd44e58..c35096cdc 100644 --- a/src/game/WorldHandlers/MiscHandler.cpp +++ b/src/game/WorldHandlers/MiscHandler.cpp @@ -967,6 +967,8 @@ void WorldSession::HandleCompleteCinematic(WorldPacket& /*recv_data*/) flyover->Stop(); } } + // Releases only the temporary root claimed by the initial-login cinematic. + player->ReleaseLoginCinematicRoot(); } } From 06a9f97f879b83ba52f57732f1f89c7755284455 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 21 Aug 2026 01:18:34 +0100 Subject: [PATCH 10/14] Fix pre-world login social list delivery --- src/game/Object/SocialMgr.cpp | 21 ++++++++-------- src/game/Object/SocialMgr.h | 6 ++--- src/game/WorldHandlers/CharacterHandler.cpp | 4 +-- src/game/WorldHandlers/MiscHandler.cpp | 2 +- src/tests/LoginSequenceTest.cpp | 28 +++++++++++++++++++++ 5 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/game/Object/SocialMgr.cpp b/src/game/Object/SocialMgr.cpp index 37b5ab9c2..09a477bc8 100644 --- a/src/game/Object/SocialMgr.cpp +++ b/src/game/Object/SocialMgr.cpp @@ -29,7 +29,6 @@ #include "Opcodes.h" #include "WorldPacket.h" #include "Player.h" -#include "ObjectMgr.h" #include "World.h" #include "Util.h" #include "PlayerRegistry.h" @@ -170,11 +169,15 @@ struct friend_ /** * @brief Sends the friend list packet to the owning player. + * + * @param plr Explicit owner. Initial login calls this before PlayerRegistry + * insertion, so a global lookup cannot be used here. */ -void PlayerSocial::SendFriendList() +void PlayerSocial::SendFriendList(Player* plr) { - Player* plr = sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER, m_playerLowGuid)); - if (!plr) + // The explicit pointer crosses the pre-registry window; retain the GUID + // check so another player can never receive this private list. + if (!plr || plr->GetGUIDLow() != m_playerLowGuid) { return; } @@ -207,14 +210,12 @@ void PlayerSocial::SendFriendList() /** * @brief Sends the ignore list packet to the owning player. + * + * @param plr Explicit owner; see SendFriendList for the pre-registry reason. */ -void PlayerSocial::SendIgnoreList() +void PlayerSocial::SendIgnoreList(Player* plr) { - /* Make sure the player ID is actually valid */ - Player* plr = sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER, m_playerLowGuid)); - - /* The ID is NOT valid, so just return */ - if (!plr) + if (!plr || plr->GetGUIDLow() != m_playerLowGuid) { return; } diff --git a/src/game/Object/SocialMgr.h b/src/game/Object/SocialMgr.h index f986c6f96..aa37a8aee 100644 --- a/src/game/Object/SocialMgr.h +++ b/src/game/Object/SocialMgr.h @@ -126,9 +126,9 @@ class PlayerSocial bool AddToSocialList(ObjectGuid friend_guid, bool ignore); void RemoveFromSocialList(ObjectGuid friend_guid, bool ignore); void SetFriendNote(ObjectGuid friend_guid, std::string note); - // Packet send's - void SendFriendList(); - void SendIgnoreList(); + // The owner is explicit because initial login sends before registry insertion. + void SendFriendList(Player* owner); + void SendIgnoreList(Player* owner); // Misc bool HasFriend(ObjectGuid friend_guid); bool HasIgnore(ObjectGuid ignore_guid); diff --git a/src/game/WorldHandlers/CharacterHandler.cpp b/src/game/WorldHandlers/CharacterHandler.cpp index aa8e314a3..4a9c12625 100644 --- a/src/game/WorldHandlers/CharacterHandler.cpp +++ b/src/game/WorldHandlers/CharacterHandler.cpp @@ -831,8 +831,8 @@ void WorldSession::HandlePlayerLogin(LoginQueryHolder* holder) } SendPacket(&data); - pCurrChar->GetSocial()->SendFriendList(); - pCurrChar->GetSocial()->SendIgnoreList(); + pCurrChar->GetSocial()->SendFriendList(pCurrChar); + pCurrChar->GetSocial()->SendIgnoreList(pCurrChar); /* 1.12.1 does not have SMSG_MOTD, so we send a server message */ /* Used for counting number of newlines in MOTD */ diff --git a/src/game/WorldHandlers/MiscHandler.cpp b/src/game/WorldHandlers/MiscHandler.cpp index c35096cdc..1bd220149 100644 --- a/src/game/WorldHandlers/MiscHandler.cpp +++ b/src/game/WorldHandlers/MiscHandler.cpp @@ -603,7 +603,7 @@ void WorldSession::HandleStandStateChangeOpcode(WorldPacket& recv_data) void WorldSession::HandleFriendListOpcode(WorldPacket& /*recv_data*/) { DEBUG_LOG("WORLD: Received opcode CMSG_FRIEND_LIST"); - _player->GetSocial()->SendFriendList(); + _player->GetSocial()->SendFriendList(_player); } diff --git a/src/tests/LoginSequenceTest.cpp b/src/tests/LoginSequenceTest.cpp index e6b9602b6..616f28f2c 100644 --- a/src/tests/LoginSequenceTest.cpp +++ b/src/tests/LoginSequenceTest.cpp @@ -29,8 +29,31 @@ #include "InitialWorldEntry.h" #include "LoginEffectPackets.h" #include "Opcodes.h" +#include "../game/Object/SocialMgr.h" #include "WorldPacket.h" +#include +#include + +namespace +{ + // This compile-time seam protects pre-registry login delivery without + // constructing Player or linking the complete game library into the test. + template + struct HasExplicitLoginSocialRecipient : std::false_type + { + }; + + template + struct HasExplicitLoginSocialRecipient().SendFriendList( + static_cast(nullptr))), + decltype(std::declval().SendIgnoreList( + static_cast(nullptr)))>> : std::true_type + { + }; +} + TEST(LoginEffectPackets_builds_success_result) { WorldPacket packet = LoginEffectPackets::BuildCastResult(); @@ -75,6 +98,11 @@ TEST(CharacterEnumMapSnapshot_replaces_the_previous_response) CHECK(snapshot.Matches(0x22, 1)); } +TEST(LoginSocialLists_accept_the_loaded_player_before_registry_insertion) +{ + CHECK(HasExplicitLoginSocialRecipient::value); +} + TEST(InitialWorldEntry_orders_established_packets) { std::vector order = From 6a29327f9c8cf0965e5d09e903491bbbe0bf2bd6 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 21 Aug 2026 01:21:31 +0100 Subject: [PATCH 11/14] Preserve cinematic root failsafe across world loss --- src/game/Object/Player.cpp | 7 +++++-- src/game/WorldHandlers/InitialWorldEntry.h | 9 ++++++++- src/tests/LoginSequenceTest.cpp | 16 +++++++++++++--- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/game/Object/Player.cpp b/src/game/Object/Player.cpp index 62de401f7..dfc74c459 100644 --- a/src/game/Object/Player.cpp +++ b/src/game/Object/Player.cpp @@ -5468,14 +5468,17 @@ void Player::BeginLoginCinematicRoot() void Player::ReleaseLoginCinematicRoot() { - if (!m_loginCinematicRootOwnership.ReleaseOnce()) + // Retain the token while out of world; consuming it there would lose the + // only later opportunity to send the matching unroot. + if (!m_loginCinematicRootOwnership.ReleaseOnce(IsInWorld())) { return; } - if (IsInWorld() && !HasAuraType(SPELL_AURA_MOD_STUN) && + if (!HasAuraType(SPELL_AURA_MOD_STUN) && !HasAuraType(SPELL_AURA_MOD_ROOT)) { + // This path owns only the cinematic root; active aura roots win. SetRoot(false); } } diff --git a/src/game/WorldHandlers/InitialWorldEntry.h b/src/game/WorldHandlers/InitialWorldEntry.h index 4e381292b..5afa813c0 100644 --- a/src/game/WorldHandlers/InitialWorldEntry.h +++ b/src/game/WorldHandlers/InitialWorldEntry.h @@ -121,8 +121,15 @@ class LoginCinematicRootOwnership return m_owned.compare_exchange_strong(expected, true); } - bool ReleaseOnce() + bool ReleaseOnce(bool canRelease) { + // Do not consume ownership while Player cannot emit the matching + // unroot. Unit::Update stops before m_Events.Update out of world, + // so this timeout cannot fire until the player has re-entered. + if (!canRelease) + { + return false; + } return m_owned.exchange(false); } diff --git a/src/tests/LoginSequenceTest.cpp b/src/tests/LoginSequenceTest.cpp index 616f28f2c..102622ba7 100644 --- a/src/tests/LoginSequenceTest.cpp +++ b/src/tests/LoginSequenceTest.cpp @@ -152,11 +152,21 @@ TEST(LoginCinematicRootOwnership_releases_exactly_once) LoginCinematicRootOwnership ownership; CHECK(ownership.Claim()); CHECK(!ownership.Claim()); - CHECK(ownership.ReleaseOnce()); - CHECK(!ownership.ReleaseOnce()); + CHECK(ownership.ReleaseOnce(true)); + CHECK(!ownership.ReleaseOnce(true)); CHECK(!ownership.IsOwned()); CHECK(ownership.Claim()); ownership.Clear(); - CHECK(!ownership.ReleaseOnce()); + CHECK(!ownership.ReleaseOnce(true)); +} + +TEST(LoginCinematicRootOwnership_retains_ownership_until_release_is_actionable) +{ + LoginCinematicRootOwnership ownership; + CHECK(ownership.Claim()); + CHECK(!ownership.ReleaseOnce(false)); + CHECK(ownership.IsOwned()); + CHECK(ownership.ReleaseOnce(true)); + CHECK(!ownership.IsOwned()); } From 28c9f53c0e64f39676e7ea2b94d2699bfeaffed7 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 21 Aug 2026 01:24:29 +0100 Subject: [PATCH 12/14] Restore login verify on admission fallback --- src/game/Server/CharacterEnumMapSnapshot.h | 33 +++++++++++++++++++++ src/game/WorldHandlers/CharacterHandler.cpp | 18 ++++++++++- src/tests/LoginSequenceTest.cpp | 15 ++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/game/Server/CharacterEnumMapSnapshot.h b/src/game/Server/CharacterEnumMapSnapshot.h index 7808530e2..9f526629d 100644 --- a/src/game/Server/CharacterEnumMapSnapshot.h +++ b/src/game/Server/CharacterEnumMapSnapshot.h @@ -56,4 +56,37 @@ class CharacterEnumMapSnapshot MapByGuid m_maps; }; +/** + * One-shot gate for SMSG_LOGIN_VERIFY_WORLD. An unchanged character-screen + * destination omits the initial packet, while an admission failure can still + * claim the fallback send without risking a duplicate. + */ +class LoginVerifyDeliveryState +{ + public: + bool TakeInitial(bool matchingEnumMap) + { + return matchingEnumMap ? false : Take(); + } + + bool TakeAdmissionFallback() + { + return Take(); + } + + private: + bool Take() + { + if (m_sent) + { + return false; + } + + m_sent = true; + return true; + } + + bool m_sent = false; +}; + #endif diff --git a/src/game/WorldHandlers/CharacterHandler.cpp b/src/game/WorldHandlers/CharacterHandler.cpp index 4a9c12625..9eb78b735 100644 --- a/src/game/WorldHandlers/CharacterHandler.cpp +++ b/src/game/WorldHandlers/CharacterHandler.cpp @@ -794,7 +794,10 @@ void WorldSession::HandlePlayerLogin(LoginQueryHolder* holder) float anchorZ = 0.0f; pCurrChar->GetWorldAnchor(anchorMapId, anchorX, anchorY, anchorZ); - if (!HasMatchingCharacterEnumMap(playerGuid, anchorMapId)) + // A matching enum map leaves the one allowed send available for an + // admission failure; a normal initial send consumes it immediately. + LoginVerifyDeliveryState loginVerifyDelivery; + auto sendLoginVerifyWorld = [&]() { data.Initialize(SMSG_LOGIN_VERIFY_WORLD, 20); data << pCurrChar->GetMapId(); @@ -820,6 +823,12 @@ void WorldSession::HandlePlayerLogin(LoginQueryHolder* holder) } SendPacket(&data); + }; + + if (loginVerifyDelivery.TakeInitial( + HasMatchingCharacterEnumMap(playerGuid, anchorMapId))) + { + sendLoginVerifyWorld(); } // The captured pre-world preamble sends account data followed by friend @@ -969,6 +978,13 @@ void WorldSession::HandlePlayerLogin(LoginQueryHolder* holder) if (lockStatus != AREA_LOCKSTATUS_OK || !pCurrChar->BoardingMap()->Add(pCurrChar, &initialEntry)) { + // Admission can fail after normal verify suppression. Send once before + // corrective teleport, without duplicating an initial verify. + if (loginVerifyDelivery.TakeAdmissionFallback()) + { + sendLoginVerifyWorld(); + } + /* Attempt to find an areatrigger to teleport the player for us */ AreaTrigger const* at = sObjectMgr.GetGoBackTrigger(pCurrChar->GetMapId()); if (at) diff --git a/src/tests/LoginSequenceTest.cpp b/src/tests/LoginSequenceTest.cpp index 102622ba7..b9dc4ce58 100644 --- a/src/tests/LoginSequenceTest.cpp +++ b/src/tests/LoginSequenceTest.cpp @@ -98,6 +98,21 @@ TEST(CharacterEnumMapSnapshot_replaces_the_previous_response) CHECK(snapshot.Matches(0x22, 1)); } +TEST(LoginVerifyDelivery_restores_suppressed_verify_on_admission_fallback) +{ + LoginVerifyDeliveryState delivery; + CHECK(!delivery.TakeInitial(true)); + CHECK(delivery.TakeAdmissionFallback()); + CHECK(!delivery.TakeAdmissionFallback()); +} + +TEST(LoginVerifyDelivery_does_not_duplicate_an_initial_verify_on_fallback) +{ + LoginVerifyDeliveryState delivery; + CHECK(delivery.TakeInitial(false)); + CHECK(!delivery.TakeAdmissionFallback()); +} + TEST(LoginSocialLists_accept_the_loaded_player_before_registry_insertion) { CHECK(HasExplicitLoginSocialRecipient::value); From e7f581bd66fb14df47e36adbfb779c498d19dee3 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 21 Aug 2026 10:05:04 +0100 Subject: [PATCH 13/14] Delay retail login effect until world presentation --- src/game/Object/Player.cpp | 8 ++++++-- src/game/WorldHandlers/InitialWorldEntry.h | 7 +++++++ src/tests/LoginSequenceTest.cpp | 10 ++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/game/Object/Player.cpp b/src/game/Object/Player.cpp index dfc74c459..a7fd7c8ff 100644 --- a/src/game/Object/Player.cpp +++ b/src/game/Object/Player.cpp @@ -118,7 +118,9 @@ namespace if (*phase == LoginEffectPhase::Start) { - m_player.m_Events.AddEvent(this, eTime + 1, false); + m_player.m_Events.AddEvent(this, + eTime + LoginEffectDelayBefore(LoginEffectPhase::Go), + false); return false; } return true; @@ -5449,7 +5451,9 @@ void Player::SendLoginTimeSpeed() /** Queues the visible START/GO half after the initial object batch is sent. */ void Player::ScheduleLoginEffect() { - m_Events.AddEvent(new LoginEffectEvent(*this), m_Events.CalculateTime(1)); + m_Events.AddEvent(new LoginEffectEvent(*this), + m_Events.CalculateTime( + LoginEffectDelayBefore(LoginEffectPhase::Start))); } void Player::BeginLoginCinematicRoot() diff --git a/src/game/WorldHandlers/InitialWorldEntry.h b/src/game/WorldHandlers/InitialWorldEntry.h index 5afa813c0..d37681966 100644 --- a/src/game/WorldHandlers/InitialWorldEntry.h +++ b/src/game/WorldHandlers/InitialWorldEntry.h @@ -72,6 +72,8 @@ struct InitialWorldEntryContext bool cinematicStarted = false; }; +// Separate scheduling lets START wait for world presentation while the +1 ms +// GO delay guarantees a following eligible EventProcessor tick. enum class LoginEffectPhase { Start, @@ -79,6 +81,11 @@ enum class LoginEffectPhase Complete }; +constexpr uint32 LoginEffectDelayBefore(LoginEffectPhase phase) +{ + return phase == LoginEffectPhase::Start ? 500 : 1; +} + class LoginEffectSequenceState { // START and GO are separate wire packets; this state prevents either phase diff --git a/src/tests/LoginSequenceTest.cpp b/src/tests/LoginSequenceTest.cpp index b9dc4ce58..7390d943a 100644 --- a/src/tests/LoginSequenceTest.cpp +++ b/src/tests/LoginSequenceTest.cpp @@ -162,6 +162,16 @@ TEST(LoginEffectSequence_has_two_ordered_phases_and_cancels_out_of_world) CHECK(interrupted.IsComplete()); } +TEST(LoginEffectTiming_delays_start_past_the_initial_loading_transition) +{ + CHECK_EQ(LoginEffectDelayBefore(LoginEffectPhase::Start), uint32(500)); +} + +TEST(LoginEffectTiming_keeps_go_on_the_following_event_tick) +{ + CHECK_EQ(LoginEffectDelayBefore(LoginEffectPhase::Go), uint32(1)); +} + TEST(LoginCinematicRootOwnership_releases_exactly_once) { LoginCinematicRootOwnership ownership; From 2c000b3ab5a45fba146767d9ceb508da349cc946 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 21 Aug 2026 10:54:48 +0100 Subject: [PATCH 14/14] Retune login effect delay to one second --- src/game/WorldHandlers/InitialWorldEntry.h | 4 +++- src/tests/LoginSequenceTest.cpp | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/game/WorldHandlers/InitialWorldEntry.h b/src/game/WorldHandlers/InitialWorldEntry.h index d37681966..9619b3668 100644 --- a/src/game/WorldHandlers/InitialWorldEntry.h +++ b/src/game/WorldHandlers/InitialWorldEntry.h @@ -83,7 +83,9 @@ enum class LoginEffectPhase constexpr uint32 LoginEffectDelayBefore(LoginEffectPhase phase) { - return phase == LoginEffectPhase::Start ? 500 : 1; + // The one-second START grace is presentation time, not spell cast time: it + // keeps the short visual from being consumed behind the loading transition. + return phase == LoginEffectPhase::Start ? 1000 : 1; } class LoginEffectSequenceState diff --git a/src/tests/LoginSequenceTest.cpp b/src/tests/LoginSequenceTest.cpp index 7390d943a..01ca20121 100644 --- a/src/tests/LoginSequenceTest.cpp +++ b/src/tests/LoginSequenceTest.cpp @@ -164,7 +164,7 @@ TEST(LoginEffectSequence_has_two_ordered_phases_and_cancels_out_of_world) TEST(LoginEffectTiming_delays_start_past_the_initial_loading_transition) { - CHECK_EQ(LoginEffectDelayBefore(LoginEffectPhase::Start), uint32(500)); + CHECK_EQ(LoginEffectDelayBefore(LoginEffectPhase::Start), uint32(1000)); } TEST(LoginEffectTiming_keeps_go_on_the_following_event_tick)