From a25eb34d66418d6499c20d50b12f89d3139ca07c Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 03:25:56 -0500 Subject: [PATCH] feat(load): PlayerReady also goes to the zone control object Live answered PlayerLoaded with PlayerReady to the player and then a second PlayerReady to the zone control object (0x3FFFFFFFFFFE), both to the loading client: 235 of 237 captured loads carry exactly that pair. DLU sent only the first. The zone control object's client-side zone scripts are what can react to it (inferred: the client's component handlers only act on the player's own PlayerReady). Check: load into Avant Gardens, Nimbus Station, a property and an instance (a survival or dragon battle): the zone plays its intro, music and UI as before and nothing is stuck waiting. Co-Authored-By: Claude Opus 5.5 --- dGame/dGameMessages/ZoneMessages.cpp | 14 +++++-- dGame/dGameMessages/ZoneMessages.h | 4 ++ .../dGameMessagesTests/CMakeLists.txt | 3 +- .../dGameMessagesTests/LoadSequenceTests.cpp | 37 +++++++++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 tests/dGameTests/dGameMessagesTests/LoadSequenceTests.cpp diff --git a/dGame/dGameMessages/ZoneMessages.cpp b/dGame/dGameMessages/ZoneMessages.cpp index 36c0f8884..7230db842 100644 --- a/dGame/dGameMessages/ZoneMessages.cpp +++ b/dGame/dGameMessages/ZoneMessages.cpp @@ -35,11 +35,19 @@ namespace GameMessages { return bitStream.Read(playerID); } + void SendPlayerReady(const LWOOBJID player, const LWOOBJID zoneControl, const SystemAddress& sysAddr) { + PlayerReady playerReady; + playerReady.target = player; + playerReady.SendToClient(sysAddr); + if (zoneControl == LWOOBJID_EMPTY) return; + playerReady.target = zoneControl; + playerReady.SendToClient(sysAddr); + } + void PlayerLoaded::Handle(Entity& entityRef, const SystemAddress& sysAddr) { auto* entity = &entityRef; - PlayerReady playerReady; - playerReady.target = entity->GetObjectID(); - playerReady.SendToClient(sysAddr); + const auto* const zoneControlEntity = Game::entityManager->GetZoneControlEntity(); + SendPlayerReady(entity->GetObjectID(), zoneControlEntity ? zoneControlEntity->GetObjectID() : LWOOBJID_EMPTY, sysAddr); entity->SetPlayerReadyForUpdates(); auto* ghostComponent = entity->GetComponent(); diff --git a/dGame/dGameMessages/ZoneMessages.h b/dGame/dGameMessages/ZoneMessages.h index ccf1c8306..d5f82d855 100644 --- a/dGame/dGameMessages/ZoneMessages.h +++ b/dGame/dGameMessages/ZoneMessages.h @@ -33,6 +33,10 @@ namespace GameMessages { PlayerReady() : NetGameMsg(MessageType::Game::PLAYER_READY) {} }; + // Answers PlayerLoaded the way live did (235 of 237 loads): PlayerReady to the player, then to the zone control + // object, whose client scripts wait for it. zoneControl is LWOOBJID_EMPTY when the zone has none. + void SendPlayerReady(LWOOBJID player, LWOOBJID zoneControl, const SystemAddress& sysAddr); + // Server -> client, to one client. No payload. struct RestoreToPostLoadStats : public NetGameMsg { RestoreToPostLoadStats() : NetGameMsg(MessageType::Game::RESTORE_TO_POST_LOAD_STATS) {} diff --git a/tests/dGameTests/dGameMessagesTests/CMakeLists.txt b/tests/dGameTests/dGameMessagesTests/CMakeLists.txt index 70e10d40c..81c2710ff 100644 --- a/tests/dGameTests/dGameMessagesTests/CMakeLists.txt +++ b/tests/dGameTests/dGameMessagesTests/CMakeLists.txt @@ -17,7 +17,8 @@ SET(DGAMEMESSAGES_TESTS "LegacyGameMessageTests.cpp" "GameMessageDecoderTests.cpp" "HotPropertyMessageTests.cpp" - "ClientMessagesTests.cpp") + "ClientMessagesTests.cpp" + "LoadSequenceTests.cpp") # Get the folder name and prepend it to the files above get_filename_component(thisFolderName ${CMAKE_CURRENT_SOURCE_DIR} NAME) diff --git a/tests/dGameTests/dGameMessagesTests/LoadSequenceTests.cpp b/tests/dGameTests/dGameMessagesTests/LoadSequenceTests.cpp new file mode 100644 index 000000000..5fc5ff456 --- /dev/null +++ b/tests/dGameTests/dGameMessagesTests/LoadSequenceTests.cpp @@ -0,0 +1,37 @@ +#include "GameDependencies.h" +#include "GameMessageTestUtils.h" + +#include "ZoneMessages.h" + +#include + +using namespace GameMessageTestUtils; + +// What the world sends while a player loads into a zone, compared with the live captures. +class LoadSequenceTests : public GameDependenciesTest { +protected: + void SetUp() override { SetUpDependencies(); } + void TearDown() override { TearDownDependencies(); } + + static constexpr LWOOBJID PLAYER = 0x1000000000000001LL; + static constexpr LWOOBJID ZONE_CONTROL = 0x3FFF'FFFFFFFELL; +}; + +// Live: PlayerReady to the player, then to the zone control object, both to the loading client only. +// Bytes: the live pair with the player ID replaced. +TEST_F(LoadSequenceTests, PlayerReadyGoesToThePlayerThenTheZoneControl) { + const auto sent = Capture([&] { GameMessages::SendPlayerReady(PLAYER, ZONE_CONTROL, ClientAddress()); }); + ASSERT_EQ(sent.size(), 2u); + EXPECT_PACKET_EQ(FromHex("53 05 00 0c 00 00 00 00 01 00 00 00 00 00 00 10 fd 01"), FromCapture(sent[0])); + EXPECT_PACKET_EQ(FromHex("53 05 00 0c 00 00 00 00 fe ff ff ff ff 3f 00 00 fd 01"), FromCapture(sent[1])); + for (const auto& packet : sent) { + EXPECT_EQ(packet.sysAddr, ClientAddress()); + EXPECT_FALSE(packet.broadcast); + } +} + +TEST_F(LoadSequenceTests, PlayerReadyWithoutZoneControlGoesToThePlayerOnly) { + const auto sent = Capture([&] { GameMessages::SendPlayerReady(PLAYER, LWOOBJID_EMPTY, ClientAddress()); }); + ASSERT_EQ(sent.size(), 1u); + EXPECT_PACKET_EQ(FromHex("53 05 00 0c 00 00 00 00 01 00 00 00 00 00 00 10 fd 01"), FromCapture(sent[0])); +}