diff --git a/dCommon/dEnums/MessageType/Master.h b/dCommon/dEnums/MessageType/Master.h index d11bd6e80..4aca357de 100644 --- a/dCommon/dEnums/MessageType/Master.h +++ b/dCommon/dEnums/MessageType/Master.h @@ -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, }; } diff --git a/dNet/master/WorldFiles.h b/dNet/master/WorldFiles.h new file mode 100644 index 000000000..721196b06 --- /dev/null +++ b/dNet/master/WorldFiles.h @@ -0,0 +1,211 @@ +#ifndef __WORLDFILES__H__ +#define __WORLDFILES__H__ + +#include +#include +#include +#include + +#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(file.kind)); + stream.Write(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(ZoneFileLog::eKind::OTHER)) return false; + file.kind = static_cast(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 files; + + void Serialize(RakNet::BitStream& stream) const override { + stream.Write(zoneId); + stream.Write(instanceId); + stream.Write(cloneId); + const auto count = static_cast(std::min(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 files; + std::vector 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 zones; + + void Serialize(RakNet::BitStream& stream) const override { + stream.Write(watching ? 1 : 0); + stream.Write(watchSeconds); + stream.Write(seamless ? 1 : 0); + const auto zoneCount = static_cast(std::min(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(std::min(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((file.hashed ? 1 : 0) | (file.missing ? 2 : 0) | (file.changed ? 4 : 0)); + } + const auto instanceCount = static_cast(std::min(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((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__ diff --git a/dWorldServer/WorldServer.cpp b/dWorldServer/WorldServer.cpp index ef7ac8faa..67e163866 100644 --- a/dWorldServer/WorldServer.cpp +++ b/dWorldServer/WorldServer.cpp @@ -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(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; } diff --git a/tests/dCommonTests/MessageIdPinTests.cpp b/tests/dCommonTests/MessageIdPinTests.cpp index 8206ba8fa..4dfd52497 100644 --- a/tests/dCommonTests/MessageIdPinTests.cpp +++ b/tests/dCommonTests/MessageIdPinTests.cpp @@ -1763,7 +1763,7 @@ static_assert(static_cast(MessageType::Game::CAN_ITEMS_BE_REFORGED) == static_assert(static_cast(MessageType::Game::NOTIFY_CLIENT_RAIL_START_FAILED) == 1771); static_assert(static_cast(MessageType::Game::GET_IS_ON_RAIL) == 1772); -// MessageType::Master: 44 enumerators +// MessageType::Master: 47 enumerators static_assert(static_cast(MessageType::Master::REQUEST_ZONE_TRANSFER) == 1); static_assert(static_cast(MessageType::Master::REQUEST_ZONE_TRANSFER_RESPONSE) == 2); static_assert(static_cast(MessageType::Master::SERVER_INFO) == 3); @@ -1810,6 +1810,9 @@ static_assert(static_cast(MessageType::Master::CHAT_SERVER_READY) == 43 static_assert(static_cast(MessageType::Master::PROFILE_REQUEST) == 44); static_assert(static_cast(MessageType::Master::PROFILE_RESULT) == 45); static_assert(static_cast(MessageType::Master::CDCLIENT_RELOAD) == 46); +static_assert(static_cast(MessageType::Master::WORLD_FILES) == 47); +static_assert(static_cast(MessageType::Master::WORLD_RELOAD) == 48); +static_assert(static_cast(MessageType::Master::WORLD_FILES_STATUS) == 49); // MessageType::Server: 3 enumerators static_assert(static_cast(MessageType::Server::VERSION_CONFIRM) == 0); diff --git a/tests/dGameTests/dNetTests/MasterPacketsTests.cpp b/tests/dGameTests/dNetTests/MasterPacketsTests.cpp index a78e6cbe6..f268acded 100644 --- a/tests/dGameTests/dNetTests/MasterPacketsTests.cpp +++ b/tests/dGameTests/dNetTests/MasterPacketsTests.cpp @@ -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(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); +}