feat(net): WORLD_FILES, WORLD_RELOAD and WORLD_FILES_STATUS master messages

Appended at the end of MessageType::Master (47-49); the pin test is
updated. A world sends its zone file list to master once it is ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-30 02:44:16 -05:00
parent e709855b7d
commit ab395a1af9
5 changed files with 319 additions and 1 deletions

View File

@@ -94,5 +94,13 @@ namespace MessageType {
// Master -> every server: the client's cdclient.fdb changed; switch to the named copy and CDServer.sqlite.
// World or dashboard -> master (no names): check the client's fdb now
CDCLIENT_RELOAD,
// World hot reload (see WorldFiles.h, docs/WorldHotReload.md)
// World -> master, once ready: the zone data files it loaded, with their sizes and hashes
WORLD_FILES,
// World (a GM's /reloadworld) or dashboard -> master: replace every instance of a zone with one on the files on disk
WORLD_RELOAD,
// Master -> dashboard: each running zone's files, whether they changed on disk, and which instances are stale
WORLD_FILES_STATUS,
};
}

211
dNet/master/WorldFiles.h Normal file
View File

@@ -0,0 +1,211 @@
#ifndef __WORLDFILES__H__
#define __WORLDFILES__H__
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
#include "BitStream.h"
#include "BitStreamUtils.h"
#include "InstanceMigration.h"
#include "MessageType/Master.h"
#include "ZoneFileLog.h"
#include "dCommonVars.h"
/**
* World hot reload (docs/WorldHotReload.md).
*/
namespace WorldFiles {
constexpr uint16_t MAX_PATH = 1024;
constexpr uint16_t MAX_FILES = 2048;
constexpr uint16_t MAX_BY = 64;
constexpr uint16_t MAX_MESSAGE = 256;
inline void WriteFile(RakNet::BitStream& stream, const ZoneFileLog::Entry& file) {
stream.Write(static_cast<uint8_t>(file.kind));
stream.Write<uint8_t>(file.packed ? 1 : 0);
stream.Write(file.size);
stream.Write(file.hash);
InstanceMigration::WriteText(stream, file.path, MAX_PATH);
}
inline bool ReadFile(RakNet::BitStream& stream, ZoneFileLog::Entry& file) {
uint8_t kind{}, packed{};
if (!stream.Read(kind) || !stream.Read(packed) || !stream.Read(file.size) || !stream.Read(file.hash)) return false;
if (kind > static_cast<uint8_t>(ZoneFileLog::eKind::OTHER)) return false;
file.kind = static_cast<ZoneFileLog::eKind>(kind);
file.packed = packed != 0;
return InstanceMigration::ReadText(stream, file.path, MAX_PATH) && !file.path.empty();
}
}
/**
* WORLD_FILES (world -> master, once the world is ready): the zone data files it loaded (ZoneFileLog). Master takes
* the zone, instance and clone from the instance it knows at that address; the IDs here are for the log.
*/
struct WorldFilesReport : public LUBitStream {
WorldFilesReport() : LUBitStream(ServiceType::MASTER, MessageType::Master::WORLD_FILES) {}
uint32_t zoneId{};
uint32_t instanceId{};
uint32_t cloneId{};
std::vector<ZoneFileLog::Entry> files;
void Serialize(RakNet::BitStream& stream) const override {
stream.Write(zoneId);
stream.Write(instanceId);
stream.Write(cloneId);
const auto count = static_cast<uint16_t>(std::min<size_t>(files.size(), WorldFiles::MAX_FILES));
stream.Write(count);
for (uint16_t i = 0; i < count; i++) WorldFiles::WriteFile(stream, files[i]);
}
bool Deserialize(RakNet::BitStream& stream) override {
uint16_t count{};
if (!stream.Read(zoneId) || !stream.Read(instanceId) || !stream.Read(cloneId) || !stream.Read(count)) return false;
if (count > WorldFiles::MAX_FILES) return false;
files.assign(count, {});
for (auto& file : files) {
if (!WorldFiles::ReadFile(stream, file)) return false;
}
return true;
}
};
/**
* WORLD_RELOAD (world for a GM's /reloadworld, or the dashboard -> master): replace every instance of zoneId with a
* new one that loads the files on disk now. warnSeconds: how long players are warned first. requesterId is the
* character who asked (told how each move goes), 0 for the dashboard.
*/
struct WorldReloadRequest : public LUBitStream {
WorldReloadRequest() : LUBitStream(ServiceType::MASTER, MessageType::Master::WORLD_RELOAD) {}
static constexpr uint16_t DEFAULT_WARN_SECONDS = 10;
uint32_t zoneId{};
uint16_t warnSeconds{ DEFAULT_WARN_SECONDS };
LWOOBJID requesterId{};
std::string requestedBy;
void Serialize(RakNet::BitStream& stream) const override {
stream.Write(zoneId);
stream.Write(std::min(warnSeconds, InstanceMigrationRequest::MAX_WARN_SECONDS));
stream.Write(requesterId);
InstanceMigration::WriteText(stream, requestedBy, WorldFiles::MAX_BY);
}
bool Deserialize(RakNet::BitStream& stream) override {
if (!stream.Read(zoneId) || !stream.Read(warnSeconds) || !stream.Read(requesterId)) return false;
if (warnSeconds > InstanceMigrationRequest::MAX_WARN_SECONDS) return false;
return InstanceMigration::ReadText(stream, requestedBy, WorldFiles::MAX_BY);
}
};
/**
* WORLD_FILES_STATUS (master -> dashboard): every zone that runs, the files its instances loaded as they are on disk
* now, and which instances loaded an older version. Sent when something changes and when the dashboard connects.
*/
struct WorldFilesStatus : public LUBitStream {
WorldFilesStatus() : LUBitStream(ServiceType::MASTER, MessageType::Master::WORLD_FILES_STATUS) {}
static constexpr uint16_t MAX_ZONES = 1024;
static constexpr uint16_t MAX_INSTANCES = 1024;
struct File {
ZoneFileLog::Entry disk; // as master last read it (packed: as the world reported it)
bool hashed{}; // master has read it (not yet: disk is what a world reported)
bool missing{}; // gone from disk
bool changed{}; // an instance of the zone loaded another version
bool operator==(const File& other) const = default;
};
struct Instance {
uint32_t instanceId{};
uint32_t cloneId{};
int32_t players{};
bool stale{}; // loaded a version of a file that isn't on disk any more
bool reloading{}; // being replaced, or shutting down
bool operator==(const Instance& other) const = default;
};
struct Zone {
uint32_t zoneId{};
std::vector<File> files;
std::vector<Instance> instances;
std::string message; // the last reload of the zone
bool operator==(const Zone& other) const = default;
};
bool watching{};
uint16_t watchSeconds{};
bool seamless{};
std::vector<Zone> zones;
void Serialize(RakNet::BitStream& stream) const override {
stream.Write<uint8_t>(watching ? 1 : 0);
stream.Write(watchSeconds);
stream.Write<uint8_t>(seamless ? 1 : 0);
const auto zoneCount = static_cast<uint16_t>(std::min<size_t>(zones.size(), MAX_ZONES));
stream.Write(zoneCount);
for (uint16_t z = 0; z < zoneCount; z++) {
const auto& zone = zones[z];
stream.Write(zone.zoneId);
const auto fileCount = static_cast<uint16_t>(std::min<size_t>(zone.files.size(), WorldFiles::MAX_FILES));
stream.Write(fileCount);
for (uint16_t f = 0; f < fileCount; f++) {
const auto& file = zone.files[f];
WorldFiles::WriteFile(stream, file.disk);
stream.Write<uint8_t>((file.hashed ? 1 : 0) | (file.missing ? 2 : 0) | (file.changed ? 4 : 0));
}
const auto instanceCount = static_cast<uint16_t>(std::min<size_t>(zone.instances.size(), MAX_INSTANCES));
stream.Write(instanceCount);
for (uint16_t i = 0; i < instanceCount; i++) {
const auto& instance = zone.instances[i];
stream.Write(instance.instanceId);
stream.Write(instance.cloneId);
stream.Write(instance.players);
stream.Write<uint8_t>((instance.stale ? 1 : 0) | (instance.reloading ? 2 : 0));
}
InstanceMigration::WriteText(stream, zone.message, WorldFiles::MAX_MESSAGE);
}
}
bool Deserialize(RakNet::BitStream& stream) override {
uint8_t watchingValue{}, seamlessValue{};
uint16_t zoneCount{};
if (!stream.Read(watchingValue) || !stream.Read(watchSeconds) || !stream.Read(seamlessValue) || !stream.Read(zoneCount)) return false;
if (zoneCount > MAX_ZONES) return false;
watching = watchingValue != 0;
seamless = seamlessValue != 0;
zones.assign(zoneCount, {});
for (auto& zone : zones) {
uint16_t fileCount{};
if (!stream.Read(zone.zoneId) || !stream.Read(fileCount) || fileCount > WorldFiles::MAX_FILES) return false;
zone.files.assign(fileCount, {});
for (auto& file : zone.files) {
uint8_t flags{};
if (!WorldFiles::ReadFile(stream, file.disk) || !stream.Read(flags)) return false;
file.hashed = flags & 1;
file.missing = flags & 2;
file.changed = flags & 4;
}
uint16_t instanceCount{};
if (!stream.Read(instanceCount) || instanceCount > MAX_INSTANCES) return false;
zone.instances.assign(instanceCount, {});
for (auto& instance : zone.instances) {
uint8_t flags{};
if (!stream.Read(instance.instanceId) || !stream.Read(instance.cloneId) || !stream.Read(instance.players) || !stream.Read(flags)) return false;
instance.stale = flags & 1;
instance.reloading = flags & 2;
}
if (!InstanceMigration::ReadText(stream, zone.message, WorldFiles::MAX_MESSAGE)) return false;
}
return true;
}
};
#endif //!__WORLDFILES__H__

View File

@@ -38,6 +38,7 @@
#include "BinaryPathFinder.h"
#include "FdbSnapshot.h"
#include "master/CDClientReload.h"
#include "master/WorldFiles.h"
#include "dPlatforms.h"
//RakNet includes:
@@ -717,6 +718,14 @@ int main(int argc, char** argv) {
worldReady.instanceID = static_cast<LWOINSTANCEID>(Game::server->GetInstanceID());
MasterPackets::SendToMaster(worldReady);
// The zone data files this world loaded; master watches them (docs/WorldHotReload.md)
WorldFilesReport files;
files.zoneId = Game::server->GetZoneID();
files.instanceId = Game::server->GetInstanceID();
files.cloneId = g_CloneID;
files.files = ZoneFileLog::Entries();
MasterPackets::SendToMaster(files);
ready = true;
}

View File

@@ -1763,7 +1763,7 @@ static_assert(static_cast<int64_t>(MessageType::Game::CAN_ITEMS_BE_REFORGED) ==
static_assert(static_cast<int64_t>(MessageType::Game::NOTIFY_CLIENT_RAIL_START_FAILED) == 1771);
static_assert(static_cast<int64_t>(MessageType::Game::GET_IS_ON_RAIL) == 1772);
// MessageType::Master: 44 enumerators
// MessageType::Master: 47 enumerators
static_assert(static_cast<int64_t>(MessageType::Master::REQUEST_ZONE_TRANSFER) == 1);
static_assert(static_cast<int64_t>(MessageType::Master::REQUEST_ZONE_TRANSFER_RESPONSE) == 2);
static_assert(static_cast<int64_t>(MessageType::Master::SERVER_INFO) == 3);
@@ -1810,6 +1810,9 @@ static_assert(static_cast<int64_t>(MessageType::Master::CHAT_SERVER_READY) == 43
static_assert(static_cast<int64_t>(MessageType::Master::PROFILE_REQUEST) == 44);
static_assert(static_cast<int64_t>(MessageType::Master::PROFILE_RESULT) == 45);
static_assert(static_cast<int64_t>(MessageType::Master::CDCLIENT_RELOAD) == 46);
static_assert(static_cast<int64_t>(MessageType::Master::WORLD_FILES) == 47);
static_assert(static_cast<int64_t>(MessageType::Master::WORLD_RELOAD) == 48);
static_assert(static_cast<int64_t>(MessageType::Master::WORLD_FILES_STATUS) == 49);
// MessageType::Server: 3 enumerators
static_assert(static_cast<int64_t>(MessageType::Server::VERSION_CONFIRM) == 0);

View File

@@ -3,6 +3,7 @@
#include "master/DashboardMessages.h"
#include "master/DataChanged.h"
#include "master/InstanceMigration.h"
#include "master/WorldFiles.h"
#include "master/MessageCapture.h"
#include "master/PlayerAction.h"
#include "PacketDispatcher.h"
@@ -572,3 +573,89 @@ TEST(MasterPacketsTests, CDClientReload) {
EXPECT_FALSE(read.Deserialize(stream)) << fdb;
}
}
TEST(MasterPacketsTests, WorldFilesReport) {
WorldFilesReport report;
report.zoneId = 1200;
report.instanceId = 3;
report.cloneId = 0;
report.files.push_back({ ZoneFileLog::eKind::ZONE, false, 1234, 0x0123456789abcdefULL, "/srv/res/maps/nimbusstation/nd_nimbus_station.luz" });
report.files.push_back({ ZoneFileLog::eKind::SCENE, true, 99, 7, "maps/nimbusstation/nd_scene.lvl" });
report.files.push_back({ ZoneFileLog::eKind::NAVMESH, false, 1ULL << 33, 0xffffffffffffffffULL, "C:/server/navmeshes/1200.bin" });
ExpectHeaderThenSerialize(report, MessageType::Master::WORLD_FILES);
RakNet::BitStream stream;
report.Serialize(stream);
WorldFilesReport read;
ASSERT_TRUE(read.Deserialize(stream));
EXPECT_EQ(read.zoneId, 1200u);
EXPECT_EQ(read.instanceId, 3u);
EXPECT_EQ(read.files, report.files);
// An empty path or an unknown kind is refused
for (const auto& bad : { ZoneFileLog::Entry{ ZoneFileLog::eKind::ZONE, false, 1, 1, "" },
ZoneFileLog::Entry{ static_cast<ZoneFileLog::eKind>(200), false, 1, 1, "x.luz" } }) {
WorldFilesReport badReport;
badReport.files.push_back(bad);
RakNet::BitStream badStream;
badReport.Serialize(badStream);
WorldFilesReport badRead;
EXPECT_FALSE(badRead.Deserialize(badStream));
}
RakNet::BitStream truncated;
report.Serialize(truncated);
RakNet::BitStream cut(truncated.GetData(), truncated.GetNumberOfBytesUsed() - 3, false);
WorldFilesReport cutRead;
EXPECT_FALSE(cutRead.Deserialize(cut));
}
TEST(MasterPacketsTests, WorldReloadRequest) {
WorldReloadRequest request;
request.zoneId = 1100;
request.warnSeconds = 0;
request.requesterId = 42;
request.requestedBy = "a GM";
ExpectHeaderThenSerialize(request, MessageType::Master::WORLD_RELOAD);
RakNet::BitStream stream;
request.Serialize(stream);
WorldReloadRequest read;
ASSERT_TRUE(read.Deserialize(stream));
EXPECT_EQ(read.zoneId, 1100u);
EXPECT_EQ(read.warnSeconds, 0);
EXPECT_EQ(read.requesterId, 42);
EXPECT_EQ(read.requestedBy, "a GM");
}
TEST(MasterPacketsTests, WorldFilesStatus) {
WorldFilesStatus status;
status.watching = true;
status.watchSeconds = 5;
status.seamless = false;
WorldFilesStatus::Zone zone;
zone.zoneId = 1100;
WorldFilesStatus::File file;
file.disk = { ZoneFileLog::eKind::TERRAIN, false, 5, 6, "/srv/res/maps/x.raw" };
file.hashed = true;
file.changed = true;
zone.files.push_back(file);
file.disk.path = "/srv/res/maps/y.raw";
file.missing = true;
file.changed = false;
zone.files.push_back(file);
zone.instances.push_back({ 1, 0, 12, true, false });
zone.instances.push_back({ 2, 0, 0, false, true });
zone.message = "Reloading 1 instance(s)";
status.zones.push_back(zone);
status.zones.push_back({});
ExpectHeaderThenSerialize(status, MessageType::Master::WORLD_FILES_STATUS);
RakNet::BitStream stream;
status.Serialize(stream);
WorldFilesStatus read;
ASSERT_TRUE(read.Deserialize(stream));
EXPECT_TRUE(read.watching);
EXPECT_EQ(read.watchSeconds, 5);
EXPECT_EQ(read.zones, status.zones);
}