From f6914940c1e1b72f87681e46ac05842604077d08 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 17:34:13 -0600 Subject: [PATCH] feat(world): an edit mode world constructs none of the zone's objects With -editmode 1 the world loads the zone file, terrain scene map, navmesh and level revisions, but makes no level objects, carver blockers, spawners, spawner networks, triggers, vanity objects or live event objects. The zone control object stays with the zone's own template, as the client makes the same one itself (LevelManager::LoadZoneControlObject, 0x01082660); its server script does nothing and a race track's does not take the player into a race. Such a world lets in only accounts at editor_gm_level. Plumbing only: no command starts an edit mode instance yet (docs/EditMode.md). Co-Authored-By: Claude Fable 5.2 --- dGame/dComponents/ScriptComponent.cpp | 5 ++ dGame/dGameMessages/ZoneMessages.cpp | 3 +- dGame/dUtilities/CMakeLists.txt | 1 + dGame/dUtilities/EditMode.cpp | 13 +++++ dGame/dUtilities/EditMode.h | 18 +++++++ dWorldServer/WorldServer.cpp | 19 +++++++- dZoneManager/Level.cpp | 19 ++++++-- dZoneManager/Level.h | 7 +-- dZoneManager/Zone.cpp | 6 ++- dZoneManager/dZoneManager.cpp | 7 +-- dZoneManager/dZoneManager.h | 11 +++++ docs/EditMode.md | 54 +++++++++++++++++++++ tests/dGameTests/CMakeLists.txt | 1 + tests/dGameTests/EditModeTests.cpp | 69 +++++++++++++++++++++++++++ 14 files changed, 219 insertions(+), 14 deletions(-) create mode 100644 dGame/dUtilities/EditMode.cpp create mode 100644 dGame/dUtilities/EditMode.h create mode 100644 docs/EditMode.md create mode 100644 tests/dGameTests/EditModeTests.cpp diff --git a/dGame/dComponents/ScriptComponent.cpp b/dGame/dComponents/ScriptComponent.cpp index dd0b1a604..a5acaed1d 100644 --- a/dGame/dComponents/ScriptComponent.cpp +++ b/dGame/dComponents/ScriptComponent.cpp @@ -98,6 +98,11 @@ CppScripts::Script* const ScriptComponent::GetScript() { void ScriptComponent::SetScript(const std::string& scriptName) { // Scripts are managed by the CppScripts class and are effecitvely singletons // and they may also be used by other script components so DON'T delete them. + // Edit mode (docs/EditMode.md): the zone control object stays, its zone script does not run + if (Game::zoneManager && Game::zoneManager->GetEditMode() && m_Parent->GetObjectID() == dZoneManager::ZONE_CONTROL_OBJECT_ID) { + m_Script = CppScripts::GetInvalidScript(); + return; + } m_Script = CppScripts::GetScript(m_Parent, scriptName); } diff --git a/dGame/dGameMessages/ZoneMessages.cpp b/dGame/dGameMessages/ZoneMessages.cpp index 310e727aa..25f531bd1 100644 --- a/dGame/dGameMessages/ZoneMessages.cpp +++ b/dGame/dGameMessages/ZoneMessages.cpp @@ -118,7 +118,8 @@ namespace GameMessages { std::vector racingControllers = Game::entityManager->GetEntitiesByComponent(eReplicaComponentType::RACING_CONTROL); for (Entity* racingController : racingControllers) { auto* racingComponent = racingController->GetComponent(); - if (racingComponent != nullptr) { + // Edit mode: a race track's zone control doesn't take the GM into a race (or send them out for having no car) + if (racingComponent != nullptr && !Game::zoneManager->GetEditMode()) { racingComponent->OnPlayerLoaded(entity); } } diff --git a/dGame/dUtilities/CMakeLists.txt b/dGame/dUtilities/CMakeLists.txt index ef6ed86e6..72ba808cb 100644 --- a/dGame/dUtilities/CMakeLists.txt +++ b/dGame/dUtilities/CMakeLists.txt @@ -5,6 +5,7 @@ set(DGAME_DUTILITIES_SOURCES "BrickDatabase.cpp" "CheatDetection.cpp" "DashboardActions.cpp" "EconomyLedger.cpp" + "EditMode.cpp" "DashboardNotify.cpp" "GUID.cpp" "LiveEvents.cpp" diff --git a/dGame/dUtilities/EditMode.cpp b/dGame/dUtilities/EditMode.cpp new file mode 100644 index 000000000..b1f986c3e --- /dev/null +++ b/dGame/dUtilities/EditMode.cpp @@ -0,0 +1,13 @@ +#include "EditMode.h" + +#include + +#include "CharacterComponent.h" +#include "Game.h" +#include "GeneralUtils.h" +#include "dConfig.h" + +eGameMasterLevel EditMode::RequiredLevel() { + const auto level = GeneralUtils::TryParse(Game::config->GetValue("editor_gm_level")).value_or(CharacterComponent::DEFAULT_EDITOR_GM_LEVEL); + return static_cast(std::clamp(level, 1, 9)); +} diff --git a/dGame/dUtilities/EditMode.h b/dGame/dUtilities/EditMode.h new file mode 100644 index 000000000..a10886ada --- /dev/null +++ b/dGame/dUtilities/EditMode.h @@ -0,0 +1,18 @@ +#pragma once + +#include "eGameMasterLevel.h" + +/** + * Edit mode (docs/EditMode.md): a private instance of a zone whose world server constructs none of the zone's level + * objects, spawners or triggers, for editing the zone in the game client. Only the plumbing is here; nothing starts + * such an instance yet. + */ +namespace EditMode { + // Whether an account may be in an edit mode instance + inline bool MayEnter(const eGameMasterLevel accountLevel, const eGameMasterLevel requiredLevel) { + return accountLevel != eGameMasterLevel::CIVILIAN && accountLevel >= requiredLevel; + } + + // The account GM level an edit mode instance lets in: editor_gm_level (9 unless set) + eGameMasterLevel RequiredLevel(); +} diff --git a/dWorldServer/WorldServer.cpp b/dWorldServer/WorldServer.cpp index 89dd0c32e..7efe90371 100644 --- a/dWorldServer/WorldServer.cpp +++ b/dWorldServer/WorldServer.cpp @@ -86,6 +86,7 @@ #include "DestroyableComponent.h" #include "Game.h" #include "PropertyManagementComponent.h" +#include "EditMode.h" #include "AssetManager.h" #include "LevelProgressionComponent.h" #include "eBlueprintSaveResponseType.h" @@ -243,6 +244,7 @@ int main(int argc, char** argv) { uint32_t cloneID = 0; uint32_t maxClients = 8; uint32_t ourPort = 2007; + bool editMode = false; //Check our arguments: for (int32_t i = 0; (i + 1) < argc; i++) { @@ -257,6 +259,7 @@ int main(int argc, char** argv) { else if (argument == "-clone") cloneID = valOptional.value_or(0); else if (argument == "-maxclients") maxClients = valOptional.value_or(8); else if (argument == "-port") ourPort = valOptional.value_or(2007); + else if (argument == "-editmode") editMode = valOptional.value_or(0) != 0; } Game::config = new dConfig("worldconfig.ini"); @@ -423,6 +426,8 @@ int main(int argc, char** argv) { Profiler::Scope navmesh("Navmesh and physics load", Profiler::Phase::PHYSICS); dpWorld::Initialize(zoneID); } + // A GM's edit mode instance (docs/EditMode.md): master says so when it starts the world + Game::zoneManager->SetEditMode(editMode); Game::zoneManager->Initialize(LWOZONEID(zoneID, g_InstanceID, cloneID)); PacketCapture::SetClone(cloneID); g_CloneID = cloneID; @@ -578,7 +583,8 @@ int main(int argc, char** argv) { DashboardNotify::Flush(); DashboardNotify::SendPlayerPositions(g_InstanceID); MessageInspector::Update(); - LiveEvents::Update(); + // (an edit mode instance has no server objects, so no live events either) + if (!Game::zoneManager->GetEditMode()) LiveEvents::Update(); UgcManifest::Update(); Metrics::StartMeasurement(MetricVariable::UpdateSpawners); @@ -889,6 +895,17 @@ namespace { } else { LOG("User %s authenticated with correct key.", username.GetAsString().c_str()); + // An edit mode instance takes only accounts that may use /editmode, however they found its address + if (Game::zoneManager->GetEditMode()) { + const auto account = Database::Get()->GetAccountInfo(username.GetAsString()); + if (!EditMode::MayEnter(account ? account->maxGmLevel : eGameMasterLevel::CIVILIAN, EditMode::RequiredLevel())) { + LOG("User %s may not enter an edit mode instance; disconnecting them", username.GetAsString().c_str()); + Game::server->Disconnect(it->second.sysAddr, eServerDisconnectIdentifiers::KICK); + g_PendingUsers.erase(it); + return; + } + } + UserManager::Instance()->DeleteUser(masterAddr); //Create our user and send them in: diff --git a/dZoneManager/Level.cpp b/dZoneManager/Level.cpp index 786b8e6a8..16140fa73 100644 --- a/dZoneManager/Level.cpp +++ b/dZoneManager/Level.cpp @@ -47,7 +47,7 @@ Level::Level(Zone* parentZone, const std::string& filepath, int32_t sceneID) { static_cast(header) = chunkHeader; m_ChunkHeaders.insert(std::make_pair(id, header)); } - LoadSceneObjects(levelFile.objects); + LoadSceneObjects(levelFile.objects, m_SceneID); } void Level::MakeSpawner(const SceneObject& obj, int32_t sceneID) { @@ -120,7 +120,18 @@ void Level::MakeSpawner(const SceneObject& obj, int32_t sceneID) { Game::zoneManager->MakeSpawner(spawnInfo); } -void Level::LoadSceneObjects(const std::vector& objects) { +void Level::LoadSceneObjects(const std::vector& objects, const int32_t sceneID) { + // Edit mode: the GM's client has the level's objects from its own files, so none is made here. The oldest files + // keep the spawn point in an object, which is still read. + if (Game::zoneManager->GetEditMode()) { + for (const auto& obj : objects) { + if (obj.lot != LOT_MARKER_PLAYER_START) continue; + Game::zoneManager->GetZoneMut()->SetSpawnPos(obj.position); + Game::zoneManager->GetZoneMut()->SetSpawnRot(obj.rotation); + } + return; + } + CDFeatureGatingTable* featureGatingTable = CDClientManager::GetTable(); CDFeatureGating gating; @@ -181,7 +192,7 @@ void Level::LoadSceneObjects(const std::vector& objects) { } if (obj.lot == 176) { //Spawner - MakeSpawner(obj, m_SceneID); + MakeSpawner(obj, sceneID); } else { //Regular object EntityInfo info; info.spawnerID = 0; @@ -191,7 +202,7 @@ void Level::LoadSceneObjects(const std::vector& objects) { info.rot = obj.rotation; info.settings = obj.settings; info.scale = obj.scale; - info.scene = m_SceneID; + info.scene = sceneID; Game::entityManager->CreateEntity(info); } } diff --git a/dZoneManager/Level.h b/dZoneManager/Level.h index 1814aed47..82173288a 100644 --- a/dZoneManager/Level.h +++ b/dZoneManager/Level.h @@ -20,11 +20,12 @@ public: static void MakeSpawner(const SceneObject& obj, int32_t sceneID = -1); + // Makes the objects and spawners of a level file. In edit mode (dZoneManager::GetEditMode) none: only the player + // start marker's place is taken + static void LoadSceneObjects(const std::vector& objects, int32_t sceneID = -1); + std::map m_ChunkHeaders; private: Zone* m_ParentZone; int32_t m_SceneID = -1; - - //private functions: - void LoadSceneObjects(const std::vector& objects); }; diff --git a/dZoneManager/Zone.cpp b/dZoneManager/Zone.cpp index 34933503b..6420f02da 100644 --- a/dZoneManager/Zone.cpp +++ b/dZoneManager/Zone.cpp @@ -138,7 +138,8 @@ void Zone::LoadZoneIntoMemory() { } } - if (m_FileFormatVersion >= Zone::FileFormatVersion::EarlyAlpha) { + // Edit mode: no spawner networks (the paths themselves are kept, they are only data) + if (m_FileFormatVersion >= Zone::FileFormatVersion::EarlyAlpha && !Game::zoneManager->GetEditMode()) { for (const Path& path : m_Paths) { if (path.pathType != PathType::Spawner) continue; SpawnerInfo info{}; @@ -286,7 +287,8 @@ void Zone::LoadScene(ZoneScene&& zoneScene) { LWOSCENEID lwoSceneID(scene.id, scene.sceneType); std::string luTriggersPath = scene.filename.substr(0, scene.filename.size() - 4) + ".lutriggers"; - if (Game::assetManager->HasFile((m_ZonePath + luTriggersPath).c_str())) LoadLUTriggers(luTriggersPath, scene); + // Edit mode: no triggers + if (!Game::zoneManager->GetEditMode() && Game::assetManager->HasFile((m_ZonePath + luTriggersPath).c_str())) LoadLUTriggers(luTriggersPath, scene); m_Scenes[lwoSceneID] = std::move(scene); } diff --git a/dZoneManager/dZoneManager.cpp b/dZoneManager/dZoneManager.cpp index 472737b3b..1765f3a09 100644 --- a/dZoneManager/dZoneManager.cpp +++ b/dZoneManager/dZoneManager.cpp @@ -57,8 +57,7 @@ void dZoneManager::Initialize(const LWOZONEID& zoneID) { EntityInfo info; info.lot = zoneControlTemplate; - /* Yep its hardcoded like this in the client too, this exact value. */ - info.id = 0x3FFF'FFFFFFFELL; + info.id = ZONE_CONTROL_OBJECT_ID; Entity* zoneControl = Game::entityManager->CreateEntity(info, nullptr, nullptr, true); m_ZoneControlObject = zoneControl; @@ -73,7 +72,9 @@ void dZoneManager::Initialize(const LWOZONEID& zoneID) { LOG("Zone prepared in: %llu ms", (endTime - startTime)); - VanityUtilities::SpawnVanity(); + // The vanity objects are server objects the client's editor would not know + if (!m_EditMode) VanityUtilities::SpawnVanity(); + else LOG("Zone %i is in edit mode: no level objects, spawners, triggers or vanity objects were loaded", zoneID.GetMapID()); } dZoneManager::~dZoneManager() { diff --git a/dZoneManager/dZoneManager.h b/dZoneManager/dZoneManager.h index 1a22bca8b..f9b29a5b6 100644 --- a/dZoneManager/dZoneManager.h +++ b/dZoneManager/dZoneManager.h @@ -23,6 +23,9 @@ private: void LoadWorldConfig(); public: + // The zone control object's ID, the same fixed value in the client (LevelManager::LoadZoneControlObject, 0x01082660) + static constexpr LWOOBJID ZONE_CONTROL_OBJECT_ID = 0x3FFF'FFFFFFFELL; + void Initialize(const LWOZONEID& zoneID); ~dZoneManager(); @@ -30,6 +33,13 @@ public: Zone* GetZoneMut() const; const Zone* GetZone() const { return GetZoneMut(); }; bool HasZone() const { return m_pZone != nullptr; }; + + /** + * A GM's edit mode (docs/EditMode.md): the zone's level objects, spawners, spawner networks and triggers are not + * loaded, so the GM's client is the only one that has them. Set before Initialize. + */ + bool GetEditMode() const { return m_EditMode; } + void SetEditMode(bool value) { m_EditMode = value; } void LoadZone(const LWOZONEID& zoneID); //Discard the current zone (if any) and loads a new zone. /* Adds a spawner to the zone with the specified ID. */ @@ -103,6 +113,7 @@ private: bool m_PlayerLoseCoinsOnDeath = false; bool m_DisableSaveLocation = false; bool m_MountsAllowed = true; + bool m_EditMode = false; bool m_PetsAllowed = true; std::map m_Spawners; std::optional m_WorldConfig = std::nullopt; diff --git a/docs/EditMode.md b/docs/EditMode.md new file mode 100644 index 000000000..538588bdd --- /dev/null +++ b/docs/EditMode.md @@ -0,0 +1,54 @@ +# Edit mode (plumbing) + +A private instance of a zone for editing the zone in the game client. The client loads the zone's objects from its own +files; the server never reads the client's files, so in a normal instance its own copies of the same objects are there +too. In an edit mode instance the server constructs none of them. + +**Not exposed yet:** no command or packet from a client starts an edit mode instance. This is what exists for one to use. + +## The flag + +| Where | What | +|---|---| +| World -> master | `MasterPackets::CreatePrivateZone::editMode`, one more byte written only when set (`ZoneInstanceManager::CreatePrivateZone(..., editMode)`) | +| Master | `Instance::GetIsEditMode`; `InstanceManager::CreatePrivateInstance(..., editMode)` | +| Master -> world | The launch argument `-editmode 1` (`StartWorldServer`) | +| World | `dZoneManager::GetEditMode`, set before the zone loads | + +A reload (`/reloadworld`, a zone file change) or a live update replaces a private instance with one that has the same +password and edit mode. A request to join a private instance that is still starting is answered once it is ready. + +## What an edit mode instance loads + +| Loaded | Not loaded | +|---|---| +| The zone file: spawn point, scenes, scene transitions, boundaries, paths (as data) | Level objects (`.lvl`), carver-only blockers | +| The terrain's scene map, the navmesh and the physics world | Spawners (LOT 176) and spawner networks (spawner paths) | +| The level files' revisions (the map checksum sent to the client) | Triggers (`.lutriggers`) | +| The zone control object, with the zone's own template | The zone control object's server script | +| The player, their items, pets and mounts; GM commands | Vanity objects, live event objects | + +The zone control object stays: the client makes its own from the `ZoneTable` template under the same fixed ID +(`LevelManager::LoadZoneControlObject`, 0x01082660), the server's construction must match it, and server code expects +the object. Its script is replaced by one that does nothing. Its other components (an activity, a race) stay but are +idle: a race track's zone control does not take the player into a race. + +The client waits on nothing a level object sends: it reports the level loaded when its own resource queue is empty and +finishes the login on its character and the server's "done loading" (`LWONetworkInterface::Run`, 0x00b32360). + +## The map checksum + +The client compares the checksum the server sends in `LOAD_STATIC_ZONE` with the one of its own files +(`LWONetworkInterface::iSendMessage`, 0x00b35870), and logs out with "invalid map version" when they differ. The +checksum is made of the zone and scene files' revisions. Edit mode does not change that: the server's copy of the zone +must have the revisions the client's has. + +## Who gets in + +The world server of an edit mode instance disconnects accounts below `editor_gm_level` (worldconfig.ini, default 9). +Master never sends anyone to a private instance from a normal transfer or matchmaking. + +## The save's `edit` attribute + +A character's save has `edit="1"` when its account's GM level is at least `editor_gm_level`, `0` for everyone else. +The client takes anything but 0 as the editor being enabled. diff --git a/tests/dGameTests/CMakeLists.txt b/tests/dGameTests/CMakeLists.txt index a2e925f81..08107c4c4 100644 --- a/tests/dGameTests/CMakeLists.txt +++ b/tests/dGameTests/CMakeLists.txt @@ -7,6 +7,7 @@ set(DGAMETEST_SOURCES "MailPacketTests.cpp" "MatchmakingTests.cpp" "EconomyLedgerTests.cpp" + "EditModeTests.cpp" "PlayerReportsLimitTests.cpp" "SlashCommandPermissionTests.cpp" "StaleSaveGuardTests.cpp" diff --git a/tests/dGameTests/EditModeTests.cpp b/tests/dGameTests/EditModeTests.cpp new file mode 100644 index 000000000..bad510cf6 --- /dev/null +++ b/tests/dGameTests/EditModeTests.cpp @@ -0,0 +1,69 @@ +// Edit mode (docs/EditMode.md): who may enter, and a level file's objects not being made. +#include "GameDependencies.h" + +#include "EditMode.h" +#include "EntityManager.h" +#include "Level.h" +#include "LevelFile.h" +#include "Zone.h" +#include "GeneralUtils.h" +#include "dConfig.h" +#include "eObjectBits.h" +#include "dZoneManager.h" + +#include + +class EditModeTests : public GameDependenciesTest { +protected: + void SetUp() override { SetUpDependencies(); } + void TearDown() override { TearDownDependencies(); } +}; + +TEST_F(EditModeTests, OnlyEditorAccountsMayEnter) { + EXPECT_TRUE(EditMode::MayEnter(eGameMasterLevel::OPERATOR, eGameMasterLevel::OPERATOR)); + EXPECT_FALSE(EditMode::MayEnter(eGameMasterLevel::DEVELOPER, eGameMasterLevel::OPERATOR)); + EXPECT_FALSE(EditMode::MayEnter(eGameMasterLevel::CIVILIAN, eGameMasterLevel::OPERATOR)); + EXPECT_FALSE(EditMode::MayEnter(eGameMasterLevel::CIVILIAN, eGameMasterLevel::CIVILIAN)); + EXPECT_TRUE(EditMode::MayEnter(eGameMasterLevel::DEVELOPER, eGameMasterLevel::JUNIOR_DEVELOPER)); + + // editor_gm_level, 9 unless set + EXPECT_EQ(EditMode::RequiredLevel(), eGameMasterLevel::OPERATOR); + Game::config->SetDatabaseValues({ { "editor_gm_level", "8" } }, {}); + EXPECT_EQ(EditMode::RequiredLevel(), eGameMasterLevel::DEVELOPER); + Game::config->SetDatabaseValues({ { "editor_gm_level", "0" } }, {}); + EXPECT_EQ(EditMode::RequiredLevel(), eGameMasterLevel::FORUM_MODERATOR); + Game::config->SetDatabaseValues({}, {}); +} + +// In edit mode a level file's objects and spawners are not made; the player start marker still gives the spawn point +TEST_F(EditModeTests, LevelObjectsAreNotMade) { + Game::zoneManager->SetEditMode(true); + ASSERT_TRUE(Game::zoneManager->GetEditMode()); + + std::vector objects; + SceneObject object; + object.id = 1001; + object.lot = 2365; + objects.push_back(object); + SceneObject spawner; + spawner.id = 1002; + spawner.lot = 176; + objects.push_back(spawner); + SceneObject start; + start.id = 1003; + start.lot = LOT_MARKER_PLAYER_START; + start.position = NiPoint3(10.0f, 20.0f, 30.0f); + objects.push_back(start); + + Level::LoadSceneObjects(objects, 4); + + // (a level object's ID gets the client bit when it is made) + for (const LWOOBJID id : { 1001, 1002, 1003 }) { + EXPECT_EQ(Game::entityManager->GetEntity(id), nullptr); + LWOOBJID made = id; + GeneralUtils::SetBit(made, eObjectBits::CLIENT); + EXPECT_EQ(Game::entityManager->GetEntity(made), nullptr); + } + EXPECT_TRUE(Game::zoneManager->GetSpawners().empty()); + EXPECT_EQ(Game::zoneManager->GetZone()->GetSpawnPos(), NiPoint3(10.0f, 20.0f, 30.0f)); +}