From 04c5c4f310cc2af761c15c7b9410b61d3734b013 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 03:39:06 -0500 Subject: [PATCH] feat(transfer): rocket launches send TransferToZone before TRANSFER_TO_WORLD Live sent, right before TRANSFER_TO_WORLD on every rocket launch (131 captured): NotifyClientFlagChange(32, true), TransferToZone (check_transfer_allowed, the target map, the landing spawn point, the clone only for properties, no position or rotation), TransferToZoneCheckedIM (no queue) and NotifyClientFlagChange(32, false). DLU went straight to TRANSFER_TO_WORLD. In the client TransferToZone runs the civilian INVALIDMAPTRANSFERLIST check, and TransferToZoneCheckedIM pauses the player's controls, starts the target zone's loading screen and leaves the gameplay state (LWOCharacterComponent, cases TransferToZone and TransferToZoneCheckedIM). They are sent from the transfer callback, so a failed zone request never leaves the client paused. Instance exits through a message box used TransferToLastNonInstance in live instead and are not changed here. Check: launch rockets to Nimbus Station, Gnarled Forest and your property: controls lock and the destination's loading screen shows as the rocket leaves, and you land at the usual spawn point. Co-Authored-By: Claude Opus 5.5 --- dGame/dGameMessages/ObjectMessages.cpp | 3 + dGame/dGameMessages/ZoneMessages.cpp | 79 +++++++++++++++++++ dGame/dGameMessages/ZoneMessages.h | 52 ++++++++++++ .../dGameMessagesTests/LoadSequenceTests.cpp | 37 +++++++++ 4 files changed, 171 insertions(+) diff --git a/dGame/dGameMessages/ObjectMessages.cpp b/dGame/dGameMessages/ObjectMessages.cpp index 62ac10ccb..824a5d52d 100644 --- a/dGame/dGameMessages/ObjectMessages.cpp +++ b/dGame/dGameMessages/ObjectMessages.cpp @@ -13,6 +13,7 @@ #include "MissionOfferComponent.h" #include "PlayerManager.h" #include "RocketLaunchpadControlComponent.h" +#include "ZoneMessages.h" #include "ZoneInstanceManager.h" #include "dServer.h" #include "dZoneManager.h" @@ -115,6 +116,8 @@ namespace GameMessages { character->SetZoneClone(zoneClone); } + GameMessages::SendZoneTransferNotice(player->GetObjectID(), static_cast(zoneID), zoneClone, character ? GeneralUtils::ASCIIToUTF16(character->GetTargetScene()) : u"", sysAddr); + ClientPackets::TransferToWorld transfer; transfer.serverIP = LUString(serverIP); transfer.serverPort = serverPort; diff --git a/dGame/dGameMessages/ZoneMessages.cpp b/dGame/dGameMessages/ZoneMessages.cpp index 68727cf76..08f007d43 100644 --- a/dGame/dGameMessages/ZoneMessages.cpp +++ b/dGame/dGameMessages/ZoneMessages.cpp @@ -1,5 +1,6 @@ #include "ZoneMessages.h" #include "MovementMessages.h" +#include "MissionMessages.h" #include "BitStreamUtils.h" #include "CDClientManager.h" @@ -175,6 +176,84 @@ namespace GameMessages { return bitStream.Read(objectID); } + namespace { + template + void WriteTransfer(RakNet::BitStream& bitStream, const Msg& msg, const bool first) { + bitStream.Write(first); + BitStreamUtils::WriteOptional(bitStream, msg.cloneID, 0); + BitStreamUtils::WriteOptional(bitStream, msg.posX, std::numeric_limits::max()); + BitStreamUtils::WriteOptional(bitStream, msg.posY, std::numeric_limits::max()); + BitStreamUtils::WriteOptional(bitStream, msg.posZ, std::numeric_limits::max()); + BitStreamUtils::WriteOptional(bitStream, msg.rotW, 1.0f); + BitStreamUtils::WriteOptional(bitStream, msg.rotX, 0.0f); + BitStreamUtils::WriteOptional(bitStream, msg.rotY, 0.0f); + BitStreamUtils::WriteOptional(bitStream, msg.rotZ, 0.0f); + BitStreamUtils::WriteLengthPrefixed(bitStream, msg.spawnPoint); + bitStream.Write(msg.ucInstanceType); + BitStreamUtils::WriteOptional(bitStream, msg.zoneID, 0); + } + + template + bool ReadTransfer(RakNet::BitStream& bitStream, Msg& msg, bool& first) { + VALIDATE_READ(bitStream.Read(first)); + VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, msg.cloneID, 0)); + VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, msg.posX, std::numeric_limits::max())); + VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, msg.posY, std::numeric_limits::max())); + VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, msg.posZ, std::numeric_limits::max())); + VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, msg.rotW, 1.0f)); + VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, msg.rotX, 0.0f)); + VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, msg.rotY, 0.0f)); + VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, msg.rotZ, 0.0f)); + VALIDATE_READ(BitStreamUtils::ReadLengthPrefixed(bitStream, msg.spawnPoint)); + VALIDATE_READ(bitStream.Read(msg.ucInstanceType)); + VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, msg.zoneID, 0)); + return true; + } + } + + void TransferToZone::Serialize(RakNet::BitStream& bitStream) const { + WriteTransfer(bitStream, *this, bCheckTransferAllowed); + } + + bool TransferToZone::Deserialize(RakNet::BitStream& bitStream) { + return ReadTransfer(bitStream, *this, bCheckTransferAllowed); + } + + void TransferToZoneCheckedIM::Serialize(RakNet::BitStream& bitStream) const { + WriteTransfer(bitStream, *this, bIsThereaQueue); + } + + bool TransferToZoneCheckedIM::Deserialize(RakNet::BitStream& bitStream) { + return ReadTransfer(bitStream, *this, bIsThereaQueue); + } + + void SendZoneTransferNotice(const LWOOBJID player, const LWOMAPID zoneID, const LWOCLONEID cloneID, const std::u16string& spawnPoint, const SystemAddress& sysAddr) { + NotifyClientFlagChange flag; + flag.target = player; + flag.iFlagID = static_cast(ePlayerFlag::BEGINNING_ZONE_SUMMARY_DISPLAYED); + flag.bFlag = true; + flag.SendToClient(sysAddr); + + TransferToZone transfer; + transfer.target = player; + transfer.bCheckTransferAllowed = true; + transfer.cloneID = cloneID; + transfer.spawnPoint = spawnPoint; + transfer.zoneID = zoneID; + transfer.SendToClient(sysAddr); + + TransferToZoneCheckedIM checked; + checked.target = player; + checked.bIsThereaQueue = false; + checked.cloneID = cloneID; + checked.spawnPoint = spawnPoint; + checked.zoneID = zoneID; + checked.SendToClient(sysAddr); + + flag.bFlag = false; + flag.SendToClient(sysAddr); + } + void InvalidZoneTransferList::Serialize(RakNet::BitStream& bitStream) const { BitStreamUtils::WriteLengthPrefixed(bitStream, customerFeedbackURL); BitStreamUtils::WriteLengthPrefixed(bitStream, invalidMapTransferList); diff --git a/dGame/dGameMessages/ZoneMessages.h b/dGame/dGameMessages/ZoneMessages.h index 7743a8d71..993844cc1 100644 --- a/dGame/dGameMessages/ZoneMessages.h +++ b/dGame/dGameMessages/ZoneMessages.h @@ -4,11 +4,63 @@ #include "GameMessages.h" #include "eObjectWorldState.h" +#include #include // Game messages for a player's zone lifecycle: loading in, zone summaries, level ups, announcements. // Fields are listed in wire order; names follow the client (legouniverse.exe 1.10.64) where known. namespace GameMessages { + // Server -> client, to one client. The player is about to change zones. The client checks the map against its + // INVALIDMAPTRANSFERLIST (civilians only, when bCheckTransferAllowed). The optional fields are written only when + // they differ from the defaults below (the client's own invalid clone and map are 0). + struct TransferToZone : public NetGameMsg { + TransferToZone() : NetGameMsg(MessageType::Game::TRANSFER_TO_ZONE) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + + bool bCheckTransferAllowed{ false }; + LWOCLONEID cloneID{ 0 }; // optional + float posX{ std::numeric_limits::max() }; // optional + float posY{ std::numeric_limits::max() }; // optional + float posZ{ std::numeric_limits::max() }; // optional + float rotW{ 1.0f }; // optional + float rotX{ 0.0f }; // optional + float rotY{ 0.0f }; // optional + float rotZ{ 0.0f }; // optional + std::u16string spawnPoint{}; // u32 length prefixed + uint8_t ucInstanceType{}; + LWOMAPID zoneID{ 0 }; // optional + }; + + // Server -> client, to one client. The transfer was checked: with no queue the client pauses the player's + // controls, shows the target zone's loading screen and leaves gameplay; with a queue it cancels the launch + // (LWOCharacterComponent, case TransferToZoneCheckedIM). Same layout as TransferToZone. + struct TransferToZoneCheckedIM : public NetGameMsg { + TransferToZoneCheckedIM() : NetGameMsg(MessageType::Game::TRANSFER_TO_ZONE_CHECKED_IM) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + + bool bIsThereaQueue{ false }; + LWOCLONEID cloneID{ 0 }; // optional + float posX{ std::numeric_limits::max() }; // optional + float posY{ std::numeric_limits::max() }; // optional + float posZ{ std::numeric_limits::max() }; // optional + float rotW{ 1.0f }; // optional + float rotX{ 0.0f }; // optional + float rotY{ 0.0f }; // optional + float rotZ{ 0.0f }; // optional + std::u16string spawnPoint{}; // u32 length prefixed + uint8_t ucInstanceType{}; + LWOMAPID zoneID{ 0 }; // optional + }; + + /** + * What live sent right before TRANSFER_TO_WORLD when a player launched to another zone (131 captured launches): + * player flag 32 set, TransferToZone (transfer check on), TransferToZoneCheckedIM (no queue), flag 32 cleared. + * No position or rotation; the clone only for properties. + */ + void SendZoneTransferNotice(LWOOBJID player, LWOMAPID zoneID, LWOCLONEID cloneID, const std::u16string& spawnPoint, const SystemAddress& sysAddr); + // Client -> server. The client finished loading the zone and its player. struct PlayerLoaded : public NetGameMsg { PlayerLoaded() : NetGameMsg(MessageType::Game::PLAYER_LOADED) {} diff --git a/tests/dGameTests/dGameMessagesTests/LoadSequenceTests.cpp b/tests/dGameTests/dGameMessagesTests/LoadSequenceTests.cpp index ac96042c1..a4e6d0cd1 100644 --- a/tests/dGameTests/dGameMessagesTests/LoadSequenceTests.cpp +++ b/tests/dGameTests/dGameMessagesTests/LoadSequenceTests.cpp @@ -78,3 +78,40 @@ TEST_F(LoadSequenceTests, UnreadMailIsAnnouncedOnLoad) { "53 05 00 31 00 00 00 00 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 " "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00"), FromCapture(sent[0])); } + +// Live, right before TRANSFER_TO_WORLD on a rocket launch to Nimbus Station (spawn point MedPropLand): flag 32 on, +// TransferToZone, TransferToZoneCheckedIM, flag 32 off, all to the launching client. Bytes: live with the player ID +// replaced (the live UpdatePlayerStatistic between the first two is left out). +TEST_F(LoadSequenceTests, ZoneTransferNoticeMatchesLive) { + const auto sent = Capture([&] { GameMessages::SendZoneTransferNotice(PLAYER, 1200, 0, u"MedPropLand", ClientAddress()); }); + ASSERT_EQ(sent.size(), 4u); + EXPECT_PACKET_EQ(FromHex("53 05 00 0c 00 00 00 00 01 00 00 00 00 00 00 10 d8 01 90 00 00 00 00", 177), FromCapture(sent[0])); + EXPECT_PACKET_EQ(FromHex( + "53 05 00 0c 00 00 00 00 01 00 00 00 00 00 00 10 04 02 80 05 80 00 00 26 80 32 80 32 00 28 00 39 00 37 80 38 00 " + "26 00 30 80 37 00 32 00 00 6c 01 00", 386), FromCapture(sent[1])); + EXPECT_PACKET_EQ(FromHex( + "53 05 00 0c 00 00 00 00 01 00 00 00 00 00 00 10 05 02 00 05 80 00 00 26 80 32 80 32 00 28 00 39 00 37 80 38 00 " + "26 00 30 80 37 00 32 00 00 6c 01 00", 386), FromCapture(sent[2])); + EXPECT_PACKET_EQ(FromHex("53 05 00 0c 00 00 00 00 01 00 00 00 00 00 00 10 d8 01 10 00 00 00 00", 177), FromCapture(sent[3])); + for (const auto& packet : sent) { + EXPECT_EQ(packet.sysAddr, ClientAddress()); + EXPECT_FALSE(packet.broadcast); + } +} + +// A property launch carries the clone; the messages read back to what was sent. +TEST_F(LoadSequenceTests, ZoneTransferCarriesThePropertyClone) { + const auto sent = Capture([&] { GameMessages::SendZoneTransferNotice(PLAYER, 1250, 545173, u"", ClientAddress()); }); + const auto transfers = SentGameMessages(sent); + const auto checked = SentGameMessages(sent); + ASSERT_EQ(transfers.size(), 1u); + ASSERT_EQ(checked.size(), 1u); + EXPECT_TRUE(transfers[0].bCheckTransferAllowed); + EXPECT_EQ(transfers[0].cloneID, 545173u); + EXPECT_EQ(transfers[0].zoneID, 1250); + EXPECT_FALSE(checked[0].bIsThereaQueue); + EXPECT_EQ(checked[0].cloneID, 545173u); + RoundTrip(transfers[0]); + RoundTrip(checked[0]); + ExpectTruncatedFails(transfers[0]); +}