From ebcfd5491556fc7eb692ef2a7e780eee1a600117 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 16:06:31 -0600 Subject: [PATCH] fix: tell the player when the zone they go to gets no instance The master server dropped a transfer request it started no instance for, so the world server never heard back and the player waited. It now answers the request with an empty address and the reason in place of the port: no room for another instance (max_instances), a zone that is not in the ZoneTable, or another reason. A request for a private zone that does not exist is answered the same way. The world server passes this on as a TRANSFER_TO_WORLD without an address, which the client shows as a failed map change, and leaves the character where it is. The auth server fails the login when there is no character select to send the player to. Co-Authored-By: Claude Fable 5.2 --- dCommon/dEnums/eTransferFailure.h | 17 +++++++++++++ dGame/UserManager.cpp | 2 ++ dGame/dBehaviors/PropertyTeleportBehavior.cpp | 3 +++ .../Character/CharacterComponent.cpp | 3 +++ dGame/dGameMessages/ObjectMessages.cpp | 3 +++ .../SlashCommands/DEVGMCommands.cpp | 3 +++ .../SlashCommands/GMZeroCommands.cpp | 5 ++++ dMasterServer/InstanceLimits.h | 13 ++++++++++ dMasterServer/InstanceManager.cpp | 25 +++++++++++++++++-- dMasterServer/InstanceManager.h | 11 ++++++++ dMasterServer/MasterServer.cpp | 5 ++++ dNet/AuthPackets.cpp | 5 ++++ dNet/WorldPackets.cpp | 13 ++++++++++ dNet/WorldPackets.h | 7 ++++++ resources/masterconfig.ini | 3 ++- .../InstanceLimitsTests.cpp | 17 +++++++++++++ 16 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 dCommon/dEnums/eTransferFailure.h diff --git a/dCommon/dEnums/eTransferFailure.h b/dCommon/dEnums/eTransferFailure.h new file mode 100644 index 000000000..184e5ff88 --- /dev/null +++ b/dCommon/dEnums/eTransferFailure.h @@ -0,0 +1,17 @@ +#ifndef __ETRANSFERFAILURE__H__ +#define __ETRANSFERFAILURE__H__ + +#include + +/** + * Why a transfer to a world got no world to go to. A TRANSFER_TO_WORLD message with an empty address + * carries it in place of the port, and the client tells the player. + */ +enum class eTransferFailure : uint16_t { + NoServer = 0, // nothing is free to start the zone on + MapLoad, // the zone can not be loaded + InstanceFull, // the instance asked for is full + Other // every other value is a failure without a reason +}; + +#endif //!__ETRANSFERFAILURE__H__ diff --git a/dGame/UserManager.cpp b/dGame/UserManager.cpp index 0a339e7d0..7590caace 100644 --- a/dGame/UserManager.cpp +++ b/dGame/UserManager.cpp @@ -588,6 +588,8 @@ void UserManager::LoginCharacter(const SystemAddress& sysAddr, LWOOBJID playerID if (zoneID == LWOZONEID_INVALID) zoneID = 1000; //Send char to VE ZoneInstanceManager::Instance()->RequestZoneTransfer(Game::server, zoneID, character->GetZoneClone(), false, [=](bool mythranShift, uint32_t zoneID, uint32_t zoneInstance, uint32_t zoneClone, std::string serverIP, uint16_t serverPort) { + if (WorldPackets::SendIfTransferFailed(sysAddr, serverIP, serverPort)) return; + LOG("Transferring %s to Zone %i (Instance %i | Clone %i | Mythran Shift: %s) with IP %s and Port %i", character->GetName().c_str(), zoneID, zoneInstance, zoneClone, mythranShift == true ? "true" : "false", serverIP.c_str(), serverPort); if (character) { auto* entity = Game::entityManager->GetEntity(character->GetObjectID()); diff --git a/dGame/dBehaviors/PropertyTeleportBehavior.cpp b/dGame/dBehaviors/PropertyTeleportBehavior.cpp index f51fe3bdd..321f84eaf 100644 --- a/dGame/dBehaviors/PropertyTeleportBehavior.cpp +++ b/dGame/dBehaviors/PropertyTeleportBehavior.cpp @@ -1,4 +1,5 @@ #include "PropertyTeleportBehavior.h" +#include "WorldPackets.h" #include "BehaviorBranchContext.h" #include "BehaviorContext.h" @@ -37,6 +38,8 @@ void PropertyTeleportBehavior::Handle(BehaviorContext* context, RakNet::BitStrea const auto sysAddr = entity->GetSystemAddress(); + if (WorldPackets::SendIfTransferFailed(sysAddr, serverIP, serverPort)) return; + if (zoneClone != 0) ChatPackets::SendSystemMessage(sysAddr, u"Transfering to your property!"); else ChatPackets::SendSystemMessage(sysAddr, u"Transfering back to previous world!"); diff --git a/dGame/dComponents/Character/CharacterComponent.cpp b/dGame/dComponents/Character/CharacterComponent.cpp index f363841e9..e7bf138a9 100644 --- a/dGame/dComponents/Character/CharacterComponent.cpp +++ b/dGame/dComponents/Character/CharacterComponent.cpp @@ -1,4 +1,5 @@ #include "CharacterComponent.h" +#include "WorldPackets.h" #include "BitStream.h" #include "tinyxml2.h" #include "Game.h" @@ -1011,6 +1012,8 @@ void CharacterComponent::SendToZone(LWOMAPID zoneId, LWOCLONEID cloneId) const { const auto sysAddr = entity->GetSystemAddress(); + if (WorldPackets::SendIfTransferFailed(sysAddr, serverIP, serverPort)) return; + auto* character = entity->GetCharacter(); auto* characterComponent = entity->GetComponent(); diff --git a/dGame/dGameMessages/ObjectMessages.cpp b/dGame/dGameMessages/ObjectMessages.cpp index 1a1d6b956..e4afc7def 100644 --- a/dGame/dGameMessages/ObjectMessages.cpp +++ b/dGame/dGameMessages/ObjectMessages.cpp @@ -1,4 +1,5 @@ #include "ObjectMessages.h" +#include "WorldPackets.h" #include "BitStreamUtils.h" #include "Character.h" @@ -100,6 +101,8 @@ namespace GameMessages { if (launcher) launcher->UpdatePlayerStatistic(RocketsUsed); ZoneInstanceManager::Instance()->RequestZoneTransfer(Game::server, mapId, cloneId, false, [=](bool mythranShift, uint32_t zoneID, uint32_t zoneInstance, uint32_t zoneClone, std::string serverIP, uint16_t serverPort) { + if (WorldPackets::SendIfTransferFailed(sysAddr, serverIP, serverPort)) return; + LOG("Transferring %s to Zone %i (Instance %i | Clone %i | Mythran Shift: %s) with IP %s and Port %i", character->GetName().c_str(), zoneID, zoneInstance, zoneClone, mythranShift == true ? "true" : "false", serverIP.c_str(), serverPort); if (character) { diff --git a/dGame/dUtilities/SlashCommands/DEVGMCommands.cpp b/dGame/dUtilities/SlashCommands/DEVGMCommands.cpp index e4d4a1b62..48ee47670 100644 --- a/dGame/dUtilities/SlashCommands/DEVGMCommands.cpp +++ b/dGame/dUtilities/SlashCommands/DEVGMCommands.cpp @@ -1,4 +1,5 @@ #include "DEVGMCommands.h" +#include "WorldPackets.h" #include "UgcKeys.h" #include "UgcManifest.h" @@ -1170,6 +1171,8 @@ namespace DEVGMCommands { const auto sysAddr = entity->GetSystemAddress(); + if (WorldPackets::SendIfTransferFailed(sysAddr, serverIP, serverPort)) return; + ChatPackets::SendSystemMessage(sysAddr, u"Transfering map..."); LOG("Transferring %s to Zone %i (Instance %i | Clone %i | Mythran Shift: %s) with IP %s and Port %i", sysAddr.ToString(), zoneID, zoneInstance, zoneClone, mythranShift == true ? "true" : "false", serverIP.c_str(), serverPort); diff --git a/dGame/dUtilities/SlashCommands/GMZeroCommands.cpp b/dGame/dUtilities/SlashCommands/GMZeroCommands.cpp index ff1e3d140..731aecddc 100644 --- a/dGame/dUtilities/SlashCommands/GMZeroCommands.cpp +++ b/dGame/dUtilities/SlashCommands/GMZeroCommands.cpp @@ -1,4 +1,5 @@ #include "GMZeroCommands.h" +#include "WorldPackets.h" // Classes #include "Amf3.h" @@ -182,6 +183,8 @@ namespace GMZeroCommands { const auto sysAddr = entity->GetSystemAddress(); + if (WorldPackets::SendIfTransferFailed(sysAddr, serverIP, serverPort)) return; + LOG("Transferring %s to Zone %i (Instance %i | Clone %i | Mythran Shift: %s) with IP %s and Port %i", entity->GetCharacter()->GetName().c_str(), zoneID, zoneInstance, zoneClone, mythranShift == true ? "true" : "false", serverIP.c_str(), serverPort); if (entity->GetCharacter()) { @@ -217,6 +220,8 @@ namespace GMZeroCommands { ChatPackets::SendSystemMessage(sysAddr, u"Requesting private map..."); ZoneInstanceManager::Instance()->RequestPrivateZone(Game::server, false, password, [=](bool mythranShift, uint32_t zoneID, uint32_t zoneInstance, uint32_t zoneClone, std::string serverIP, uint16_t serverPort) { + if (WorldPackets::SendIfTransferFailed(sysAddr, serverIP, serverPort)) return; + LOG("Transferring %s to Zone %i (Instance %i | Clone %i | Mythran Shift: %s) with IP %s and Port %i", sysAddr.ToString(), zoneID, zoneInstance, zoneClone, mythranShift == true ? "true" : "false", serverIP.c_str(), serverPort); if (entity->GetCharacter()) { diff --git a/dMasterServer/InstanceLimits.h b/dMasterServer/InstanceLimits.h index 9f25af4c0..71011a187 100644 --- a/dMasterServer/InstanceLimits.h +++ b/dMasterServer/InstanceLimits.h @@ -3,6 +3,8 @@ #include +#include "eTransferFailure.h" + namespace InstanceLimits { // The most world instances that run at once when the config does not say constexpr uint32_t DefaultMaxInstances = 256; @@ -27,6 +29,17 @@ namespace InstanceLimits { if (maxInstances != 0 && runningInstances >= maxInstances) return eNewInstance::TooManyInstances; return eNewInstance::Allowed; } + + /** + * What the player who asked for the zone is told when no instance was started for it. + */ + constexpr eTransferFailure TransferFailure(const eNewInstance newInstance) { + switch (newInstance) { + case eNewInstance::UnknownZone: return eTransferFailure::MapLoad; + case eNewInstance::TooManyInstances: return eTransferFailure::NoServer; + default: return eTransferFailure::Other; + } + } } #endif //!__INSTANCELIMITS__H__ diff --git a/dMasterServer/InstanceManager.cpp b/dMasterServer/InstanceManager.cpp index 03bd41692..d68f6e53f 100644 --- a/dMasterServer/InstanceManager.cpp +++ b/dMasterServer/InstanceManager.cpp @@ -43,6 +43,17 @@ namespace { } } +void InstanceManager::SendTransferFailure(const PendingInstanceRequest& request, const LWOMAPID mapID, const LWOCLONEID cloneID, const eTransferFailure failure) { + MasterPackets::RequestZoneTransferResponse response; + response.requestID = request.id; + response.mythranShift = request.mythranShift; + response.zoneID = mapID; + response.zoneClone = cloneID; + response.serverPort = static_cast(failure); + response.stamps = request.stamps; + MasterPackets::SendTo(request.sysAddr, response); +} + InstanceManager::InstanceManager(const std::string& externalIP) : mExternalIP{ externalIP } { m_LastPort = GeneralUtils::TryParse(Game::config->GetValue("world_port_start")).value_or(m_LastPort); @@ -51,10 +62,13 @@ InstanceManager::InstanceManager(const std::string& externalIP) : mExternalIP{ e GeneralUtils::TryParse(Game::config->GetValue("max_instances")).value_or(InstanceLimits::DefaultMaxInstances); } -bool InstanceManager::CanCreateInstance(const LWOMAPID mapID, const LWOCLONEID cloneID) const { +InstanceLimits::eNewInstance InstanceManager::CheckNewInstance(const LWOMAPID mapID) const { const bool isInZoneTable = mapID != 0 && CDZoneTableTable::Query(mapID) != nullptr; + return InstanceLimits::CheckNewInstance(mapID, isInZoneTable, m_Instances.size(), m_MaxInstances); +} - switch (InstanceLimits::CheckNewInstance(mapID, isInZoneTable, m_Instances.size(), m_MaxInstances)) { +bool InstanceManager::CanCreateInstance(const LWOMAPID mapID, const LWOCLONEID cloneID) const { + switch (CheckNewInstance(mapID)) { case InstanceLimits::eNewInstance::UnknownZone: LOG("Tried to create an instance of map %i/%i, which is not in the ZoneTable.", mapID, cloneID); return false; @@ -315,6 +329,13 @@ void InstanceManager::RedirectPendingRequests(const InstancePtr& instance) { for (const auto& request : instance->GetPendingAffirmations()) { const auto& in = Game::im->GetInstance(zoneId.GetMapID(), false, zoneId.GetCloneID()); + if (!in) { + // No instance takes the place of the one that is gone, so the transfer fails + const auto failure = InstanceLimits::TransferFailure(CheckNewInstance(zoneId.GetMapID())); + SendTransferFailure(request, zoneId.GetMapID(), zoneId.GetCloneID(), failure); + continue; + } + if (in && !in->GetIsReady()) // Instance not ready, make a pending request { in->GetPendingRequests().push_back(request); diff --git a/dMasterServer/InstanceManager.h b/dMasterServer/InstanceManager.h index 72391c80d..f25a4129c 100644 --- a/dMasterServer/InstanceManager.h +++ b/dMasterServer/InstanceManager.h @@ -15,6 +15,7 @@ #include "dZMCommon.h" #include "Logger.h" #include "master/InstanceMigration.h" +#include "InstanceLimits.h" struct Player { LWOOBJID id; @@ -220,6 +221,16 @@ public: // Mark the running instances which() picks outdated (old binary or old zone files); returns how many were new uint32_t MarkOutdated(const std::function& which); + /** + * Whether a new instance may be started for a zone, and why not when it may not + */ + InstanceLimits::eNewInstance CheckNewInstance(LWOMAPID mapID) const; + + /** + * Answers a transfer request no instance was started for: an empty address, and the reason in place of the port + */ + static void SendTransferFailure(const PendingInstanceRequest& request, LWOMAPID mapID, LWOCLONEID cloneID, eTransferFailure failure); + private: std::string mExternalIP; std::vector> m_Instances; diff --git a/dMasterServer/MasterServer.cpp b/dMasterServer/MasterServer.cpp index 110ad41e9..e7d60ad0c 100644 --- a/dMasterServer/MasterServer.cpp +++ b/dMasterServer/MasterServer.cpp @@ -746,7 +746,10 @@ namespace { } const auto& in = Game::im->GetInstance(zoneID, false, zoneClone); if (!in) { + // An empty address tells the world server there is no instance, the port is the reason + const auto failure = InstanceLimits::TransferFailure(Game::im->CheckNewInstance(zoneID)); LOG("No instance for transfer request %llu to zone %i %i", requestID, zoneID, zoneClone); + InstanceManager::SendTransferFailure({ requestID, static_cast(mythranShift), sysAddr, stamps }, zoneID, zoneClone, failure); return; } @@ -907,6 +910,8 @@ namespace { LOG("Join private zone: %llu %d %p", requestID, mythranShift, instance.get()); if (instance == nullptr) { + // No private zone has this password, the player is told the transfer failed + InstanceManager::SendTransferFailure({ requestID, static_cast(mythranShift), sysAddr, {} }, 0, 0, eTransferFailure::Other); return; } diff --git a/dNet/AuthPackets.cpp b/dNet/AuthPackets.cpp index 536f9a78c..2739c7cf9 100644 --- a/dNet/AuthPackets.cpp +++ b/dNet/AuthPackets.cpp @@ -276,6 +276,11 @@ void AuthPackets::LoginRequest::Handle() { stamps.Add(eStamps::PASSPORT_AUTH_WORLD_COMMUNICATION_START); // The stamps go along: master adds its steps and sends them back with the world server ZoneInstanceManager::Instance()->RequestZoneTransfer(server, 0, 0, false, stamps, [system, server, username](bool mythranShift, uint32_t zoneID, uint32_t zoneInstance, uint32_t zoneClone, std::string zoneIP, uint16_t zonePort, Stamps stamps) mutable { + // An empty address is the master server's answer when no character select runs + if (zoneIP.empty()) { + AuthPackets::SendLoginResponse(server, system, eLoginResponse::GENERAL_FAILED, "", "", 0, username, stamps); + return; + } AuthPackets::SendLoginResponse(server, system, eLoginResponse::SUCCESS, "", zoneIP, zonePort, username, stamps); }); } diff --git a/dNet/WorldPackets.cpp b/dNet/WorldPackets.cpp index b72dfb807..ec62b85ea 100644 --- a/dNet/WorldPackets.cpp +++ b/dNet/WorldPackets.cpp @@ -1,6 +1,8 @@ #include "WorldPackets.h" +#include "ClientPackets.h" #include "dCommonVars.h" +#include "Logger.h" #include @@ -18,6 +20,17 @@ namespace { } namespace WorldPackets { + bool SendIfTransferFailed(const SystemAddress& sysAddr, const std::string& serverIP, const uint32_t serverPort) { + if (!serverIP.empty()) return false; + + LOG("No instance to transfer %s to (reason %u)", sysAddr.ToString(), serverPort); + // The client reads the port of a transfer without an address as the reason and tells the player + ClientPackets::TransferToWorld transfer; + transfer.serverPort = static_cast(serverPort); + transfer.Send(sysAddr); + return true; + } + void Validation::Serialize(RakNet::BitStream& bitStream) const { bitStream.Write(username); bitStream.Write(sessionKey); diff --git a/dNet/WorldPackets.h b/dNet/WorldPackets.h index 1c3921be7..0b70f02fc 100644 --- a/dNet/WorldPackets.h +++ b/dNet/WorldPackets.h @@ -19,6 +19,13 @@ // Packets of ServiceType::WORLD: what a client sends to a world server. The server's answers are ClientPackets. // The structs only read and write bytes; the world server attaches the handlers (see WorldServer.cpp). namespace WorldPackets { + /** + * Tells a player that the zone they were to go to has no instance. The master server answers a transfer + * request with an empty address then, and with the eTransferFailure in place of the port. + * @return whether the transfer failed, the caller has nothing left to do for it then + */ + bool SendIfTransferFailed(const SystemAddress& sysAddr, const std::string& serverIP, uint32_t serverPort); + // Base of every world packet: the dispatcher sets sysAddr before Deserialize and Handle. struct WorldLUBitStream : public LUBitStream { SystemAddress sysAddr = UNASSIGNED_SYSTEM_ADDRESS; diff --git a/resources/masterconfig.ini b/resources/masterconfig.ini index 398e0c079..a98440c5a 100644 --- a/resources/masterconfig.ini +++ b/resources/masterconfig.ini @@ -8,7 +8,8 @@ master_server_port=2000 world_port_start=3000 # The most world instances that run at once. The character select is always started. -# A zone or property that needs a new instance past this is not started. 0 for no limit +# A zone or property that needs a new instance past this is not started and the player +# who asked for it is told that the map change failed. 0 for no limit max_instances=256 # 0 or 1, should autostart auth, chat, and char servers diff --git a/tests/dGameTests/dGameMessagesTests/InstanceLimitsTests.cpp b/tests/dGameTests/dGameMessagesTests/InstanceLimitsTests.cpp index 543b1def7..8076f4fa3 100644 --- a/tests/dGameTests/dGameMessagesTests/InstanceLimitsTests.cpp +++ b/tests/dGameTests/dGameMessagesTests/InstanceLimitsTests.cpp @@ -4,6 +4,7 @@ using InstanceLimits::CheckNewInstance; using InstanceLimits::eNewInstance; +using InstanceLimits::TransferFailure; TEST(InstanceLimitsTest, AZoneOfTheZoneTableIsStarted) { EXPECT_EQ(CheckNewInstance(1000, true, 0, 256), eNewInstance::Allowed); @@ -32,3 +33,19 @@ TEST(InstanceLimitsTest, TheCharacterSelectIsAlwaysStarted) { EXPECT_EQ(CheckNewInstance(0, false, 0, 256), eNewInstance::Allowed); EXPECT_EQ(CheckNewInstance(0, false, 256, 256), eNewInstance::Allowed); } + +TEST(InstanceLimitsTest, ARefusedInstanceHasAReasonForThePlayer) { + // The limit: nothing is free to start the zone on + EXPECT_EQ(TransferFailure(CheckNewInstance(1000, true, 256, 256)), eTransferFailure::NoServer); + // No row in the ZoneTable: the zone can not be loaded + EXPECT_EQ(TransferFailure(CheckNewInstance(31337, false, 0, 256)), eTransferFailure::MapLoad); + // Refused for another reason, such as a master server that shuts down + EXPECT_EQ(TransferFailure(eNewInstance::Allowed), eTransferFailure::Other); +} + +TEST(InstanceLimitsTest, TheReasonsAreTheValuesTheClientKnows) { + EXPECT_EQ(static_cast(eTransferFailure::NoServer), 0); + EXPECT_EQ(static_cast(eTransferFailure::MapLoad), 1); + EXPECT_EQ(static_cast(eTransferFailure::InstanceFull), 2); + EXPECT_EQ(static_cast(eTransferFailure::Other), 3); +}