feat(master): an edit mode flag on a private instance, down to its world server

CreatePrivateZone carries editMode as one more byte, written only when set, so
a plain private zone is sent as before. Master keeps it on the instance, starts
the world server with -editmode 1, and gives a private instance's replacement
(a reload or a live update) the same flag. Nothing sets it yet.

A capped string now skips the characters it cut, so what follows it is read
from the right place. A request to join a private instance that is still
starting is answered once its world server is ready, instead of at once with
an address nothing listens on yet.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-11-10 17:34:13 -06:00
parent 78b2c0f406
commit 4cf0909d52
12 changed files with 68 additions and 14 deletions

View File

@@ -387,7 +387,7 @@ const InstancePtr& InstanceManager::FindInstanceWithPrivate(LWOMAPID mapID, LWOI
return g_Empty;
}
const InstancePtr& InstanceManager::CreatePrivateInstance(LWOMAPID mapID, LWOCLONEID cloneID, const std::string& password) {
const InstancePtr& InstanceManager::CreatePrivateInstance(LWOMAPID mapID, LWOCLONEID cloneID, const std::string& password, const bool editMode) {
const auto& instance = FindPrivateInstance(password);
if (instance != nullptr) {
@@ -408,9 +408,10 @@ const InstancePtr& InstanceManager::CreatePrivateInstance(LWOMAPID mapID, LWOCLO
uint32_t port = GetFreePort();
auto newInstance = make_unique<Instance>(mExternalIP, port, mapID, ++m_LastInstanceID, cloneID, maxPlayers, maxPlayers, true, password);
newInstance->SetIsEditMode(editMode);
//Start the actual process:
StartWorldServer(mapID, port, m_LastInstanceID, maxPlayers, cloneID);
StartWorldServer(mapID, port, m_LastInstanceID, maxPlayers, cloneID, editMode);
m_Instances.push_back(std::move(newInstance));
if (m_OnInstancesChanged) m_OnInstancesChanged();

View File

@@ -58,6 +58,10 @@ public:
bool GetIsPrivate() const { return m_IsPrivate; }
const std::string& GetPassword() const { return m_Password; }
// A GM's edit mode instance (docs/EditMode.md): private, and its world server constructs none of the zone's
// objects. Its replacement (a reload, a live update) is in edit mode too
bool GetIsEditMode() const { return m_IsEditMode; }
void SetIsEditMode(bool value) { m_IsEditMode = value; }
bool GetIsReady() const { return m_Ready; }
void SetIsReady(bool value) { m_Ready = value; }
@@ -156,6 +160,7 @@ private:
bool m_IsPrivate{};
std::string m_Password{};
bool m_IsEditMode{};
bool m_Shutdown{};
@@ -194,7 +199,8 @@ public:
const InstancePtr& FindInstance(LWOMAPID mapID, LWOINSTANCEID instanceID);
const InstancePtr& FindInstanceWithPrivate(LWOMAPID mapID, LWOINSTANCEID instanceID);
const InstancePtr& CreatePrivateInstance(LWOMAPID mapID, LWOCLONEID cloneID, const std::string& password);
// editMode: a GM's edit mode instance. An instance that has the password already is returned as it is
const InstancePtr& CreatePrivateInstance(LWOMAPID mapID, LWOCLONEID cloneID, const std::string& password, bool editMode = false);
const InstancePtr& FindPrivateInstance(const std::string& password);
void SetIsShuttingDown(bool value) { this->m_IsShuttingDown = value; };
void PruneUnreadyInstances();

View File

@@ -893,12 +893,12 @@ namespace {
void OnCreatePrivateZone(const CreatePrivateZone& request, const SystemAddress& sysAddr) {
// Passwords were cut to 50 characters when read
const auto& newInst = Game::im->CreatePrivateInstance(request.zoneID, request.cloneID, request.password.c_str());
const auto& newInst = Game::im->CreatePrivateInstance(request.zoneID, request.cloneID, request.password.c_str(), request.editMode != 0);
if (!newInst) {
LOG("No private zone was created for map %i/%i", request.zoneID, request.cloneID);
return;
}
LOG("Creating private zone %i/%i/%i", newInst->GetMapID(), newInst->GetCloneID(), newInst->GetInstanceID());
LOG("Creating private zone %i/%i/%i%s", newInst->GetMapID(), newInst->GetCloneID(), newInst->GetInstanceID(), newInst->GetIsEditMode() ? " in edit mode" : "");
}
void OnRequestPrivateZone(const RequestPrivateZone& request, const SystemAddress& sysAddr) {
@@ -915,6 +915,12 @@ namespace {
return;
}
// Still starting (asked for right after it was created): answered when its world server is ready
if (!instance->GetIsReady()) {
instance->GetPendingRequests().push_back({ requestID, static_cast<bool>(mythranShift), sysAddr, {} });
return;
}
const auto& zone = instance->GetZoneID();
RequestZoneTransferResponse response;

View File

@@ -171,11 +171,11 @@ void MigrationCoordinator::SetObserver(std::function<void(const MigrationStatus&
namespace {
// The new instance (REPLACE): a fresh world server from the binary on disk now, so an update takes effect. A private
// instance's replacement gets the same password; the source is draining already, so the password finds the new one.
// instance's replacement gets the same password and edit mode; the source is draining already, so the password finds the new one.
Instance* StartTarget(Instance& source) {
if (source.GetIsPrivate()) {
source.SetIsDraining(true);
const auto& started = Game::im->CreatePrivateInstance(source.GetMapID(), source.GetCloneID(), source.GetPassword());
const auto& started = Game::im->CreatePrivateInstance(source.GetMapID(), source.GetCloneID(), source.GetPassword(), source.GetIsEditMode());
return started ? started.get() : nullptr;
}
const auto& started = Game::im->StartNewInstance(source.GetMapID(), source.GetCloneID());

View File

@@ -192,13 +192,13 @@ uint32_t StartUgcServer() {
return ugc_pid;
}
uint32_t StartWorldServer(LWOMAPID mapID, uint16_t port, LWOINSTANCEID lastInstanceID, int maxPlayers, LWOCLONEID cloneID) {
uint32_t StartWorldServer(LWOMAPID mapID, uint16_t port, LWOINSTANCEID lastInstanceID, int maxPlayers, LWOCLONEID cloneID, const bool editMode) {
auto world_path = BinaryPathFinder::GetBinaryDir() / "WorldServer";
#ifdef _WIN32
world_path.replace_extension(".exe");
auto cmd = world_path.wstring() + L" -zone " + std::to_wstring(mapID) + L" -port " + std::to_wstring(port) +
L" -instance " + std::to_wstring(lastInstanceID) + L" -maxclients " + std::to_wstring(maxPlayers) +
L" -clone " + std::to_wstring(cloneID);
L" -clone " + std::to_wstring(cloneID) + L" -editmode " + std::to_wstring(editMode ? 1 : 0);
auto world_startup = startup;
auto world_info = PROCESS_INFORMATION{};
@@ -226,7 +226,8 @@ uint32_t StartWorldServer(LWOMAPID mapID, uint16_t port, LWOINSTANCEID lastInsta
"-port", std::to_string(port).c_str(),
"-instance", std::to_string(lastInstanceID).c_str(),
"-maxclients", std::to_string(maxPlayers).c_str(),
"-clone", std::to_string(cloneID).c_str(), nullptr);
"-clone", std::to_string(cloneID).c_str(),
"-editmode", editMode ? "1" : "0", nullptr);
// exec failed (e.g. the binary is being rebuilt): the child must not carry on as a second master
perror("exec");
_exit(127);

View File

@@ -5,4 +5,4 @@ uint32_t StartAuthServer();
uint32_t StartChatServer();
uint32_t StartDashboardServer();
uint32_t StartUgcServer();
uint32_t StartWorldServer(LWOMAPID mapID, uint16_t port, LWOINSTANCEID lastInstanceID, int maxPlayers, LWOCLONEID cloneID);
uint32_t StartWorldServer(LWOMAPID mapID, uint16_t port, LWOINSTANCEID lastInstanceID, int maxPlayers, LWOCLONEID cloneID, bool editMode = false);

View File

@@ -16,7 +16,12 @@ namespace {
uint32_t length{};
VALIDATE_READ(bitStream.Read(length));
value.resize(std::min(length, maxLength));
return value.empty() || bitStream.ReadBits(reinterpret_cast<unsigned char*>(value.data()), BYTES_TO_BITS(value.size()), true);
if (!value.empty() && !bitStream.ReadBits(reinterpret_cast<unsigned char*>(value.data()), BYTES_TO_BITS(value.size()), true)) return false;
const uint32_t rest = length - static_cast<uint32_t>(value.size());
if (rest == 0) return true;
if (BITS_TO_BYTES(bitStream.GetNumberOfUnreadBits()) < rest) return false;
bitStream.IgnoreBits(BYTES_TO_BITS(rest));
return true;
}
}
@@ -147,12 +152,15 @@ namespace MasterPackets {
bitStream.Write(zoneID);
bitStream.Write(cloneID);
BitStreamUtils::WriteLengthPrefixed<uint32_t>(bitStream, password);
if (editMode != 0) bitStream.Write(editMode);
}
bool CreatePrivateZone::Deserialize(RakNet::BitStream& bitStream) {
VALIDATE_READ(bitStream.Read(zoneID));
VALIDATE_READ(bitStream.Read(cloneID));
VALIDATE_READ(ReadCappedString(bitStream, password, MAX_PASSWORD_LENGTH));
editMode = 0;
if (bitStream.GetNumberOfUnreadBits() >= 8) VALIDATE_READ(bitStream.Read(editMode));
return true;
}

View File

@@ -146,6 +146,9 @@ namespace MasterPackets {
uint32_t zoneID{};
uint32_t cloneID{};
std::string password; // u32 length, then 1 byte per character
// A GM's edit mode: the instance's world server constructs none of the zone's objects (docs/EditMode.md). Written
// only when set, so a plain private zone stays as it always was
uint8_t editMode{};
CreatePrivateZone() : LUBitStream(ServiceType::MASTER, MessageType::Master::CREATE_PRIVATE_ZONE) {}
void Serialize(RakNet::BitStream& bitStream) const override;

View File

@@ -1009,6 +1009,7 @@ json ToJson(const MasterPackets::CreatePrivateZone& m) {
json j = json::object();
j["zoneID"] = ToJson(m.zoneID);
j["cloneID"] = ToJson(m.cloneID);
j["editMode"] = ToJson(m.editMode);
return j;
}
json ToJson(const MasterPackets::RequestPrivateZone& m) {

View File

@@ -35,11 +35,12 @@ void ZoneInstanceManager::HandleRequestZoneTransferResponse(const MasterPackets:
}
}
void ZoneInstanceManager::CreatePrivateZone(dServer* server, uint32_t zoneID, uint32_t zoneClone, const std::string& password) {
void ZoneInstanceManager::CreatePrivateZone(dServer* server, uint32_t zoneID, uint32_t zoneClone, const std::string& password, const bool editMode) {
MasterPackets::CreatePrivateZone request;
request.zoneID = zoneID;
request.cloneID = zoneClone;
request.password = password;
request.editMode = editMode ? 1 : 0;
MasterPackets::SendToMaster(request, server);
}

View File

@@ -62,7 +62,8 @@ public:
*/
void HandleRequestZoneTransferResponse(const MasterPackets::RequestZoneTransferResponse& response);
void CreatePrivateZone(dServer* server, uint32_t zoneID, uint32_t zoneClone, const std::string& password);
// editMode: a GM's edit mode instance, whose world server constructs none of the zone's objects
void CreatePrivateZone(dServer* server, uint32_t zoneID, uint32_t zoneClone, const std::string& password, bool editMode = false);
void RequestPrivateZone(dServer* server, bool mythranShift, const std::string& password, TransferCallback callback);
};

View File

@@ -237,6 +237,32 @@ TEST(MasterPacketsTests, PrivateZonesMatchLegacy) {
}
}
// The edit mode flag of a private zone: one more byte, only when set, and read after a password of any length
TEST(MasterPacketsTests, CreatePrivateZoneEditMode) {
for (const auto& password : g_Strings) {
MasterPackets::CreatePrivateZone plain;
plain.zoneID = 1100;
plain.password = password;
MasterPackets::CreatePrivateZone edit = plain;
edit.editMode = 1;
RakNet::BitStream plainStream; LoadPayload(plainStream, plain);
RakNet::BitStream editStream; LoadPayload(editStream, edit);
EXPECT_EQ(editStream.GetNumberOfUnreadBits(), plainStream.GetNumberOfUnreadBits() + 8);
MasterPackets::CreatePrivateZone readPlain;
readPlain.editMode = 1;
ASSERT_TRUE(readPlain.Deserialize(plainStream));
EXPECT_EQ(readPlain.editMode, 0);
MasterPackets::CreatePrivateZone readEdit;
ASSERT_TRUE(readEdit.Deserialize(editStream));
EXPECT_EQ(readEdit.editMode, 1);
EXPECT_EQ(readEdit.zoneID, 1100u);
EXPECT_EQ(readEdit.password, password.substr(0, MasterPackets::CreatePrivateZone::MAX_PASSWORD_LENGTH));
}
}
TEST(MasterPacketsTests, WorldStateMatchesLegacy) {
for (const LWOMAPID zone : { static_cast<LWOMAPID>(0), static_cast<LWOMAPID>(1100), static_cast<LWOMAPID>(0xFFFF) }) {
for (const LWOINSTANCEID instance : { static_cast<LWOINSTANCEID>(0), static_cast<LWOINSTANCEID>(3), static_cast<LWOINSTANCEID>(0xFFFF) }) {