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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-11-10 17:34:13 -06:00
parent 4cf0909d52
commit f6914940c1
14 changed files with 219 additions and 14 deletions

View File

@@ -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);
}

View File

@@ -118,7 +118,8 @@ namespace GameMessages {
std::vector<Entity*> racingControllers = Game::entityManager->GetEntitiesByComponent(eReplicaComponentType::RACING_CONTROL);
for (Entity* racingController : racingControllers) {
auto* racingComponent = racingController->GetComponent<RacingControlComponent>();
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);
}
}

View File

@@ -5,6 +5,7 @@ set(DGAME_DUTILITIES_SOURCES "BrickDatabase.cpp"
"CheatDetection.cpp"
"DashboardActions.cpp"
"EconomyLedger.cpp"
"EditMode.cpp"
"DashboardNotify.cpp"
"GUID.cpp"
"LiveEvents.cpp"

View File

@@ -0,0 +1,13 @@
#include "EditMode.h"
#include <algorithm>
#include "CharacterComponent.h"
#include "Game.h"
#include "GeneralUtils.h"
#include "dConfig.h"
eGameMasterLevel EditMode::RequiredLevel() {
const auto level = GeneralUtils::TryParse<uint32_t>(Game::config->GetValue("editor_gm_level")).value_or(CharacterComponent::DEFAULT_EDITOR_GM_LEVEL);
return static_cast<eGameMasterLevel>(std::clamp<uint32_t>(level, 1, 9));
}

View File

@@ -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();
}

View File

@@ -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:

View File

@@ -47,7 +47,7 @@ Level::Level(Zone* parentZone, const std::string& filepath, int32_t sceneID) {
static_cast<LevelFile::ChunkHeader&>(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<SceneObject>& objects) {
void Level::LoadSceneObjects(const std::vector<SceneObject>& 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<CDFeatureGatingTable>();
CDFeatureGating gating;
@@ -181,7 +192,7 @@ void Level::LoadSceneObjects(const std::vector<SceneObject>& 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<SceneObject>& objects) {
info.rot = obj.rotation;
info.settings = obj.settings;
info.scale = obj.scale;
info.scene = m_SceneID;
info.scene = sceneID;
Game::entityManager->CreateEntity(info);
}
}

View File

@@ -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<SceneObject>& objects, int32_t sceneID = -1);
std::map<uint32_t, Header> m_ChunkHeaders;
private:
Zone* m_ParentZone;
int32_t m_SceneID = -1;
//private functions:
void LoadSceneObjects(const std::vector<SceneObject>& objects);
};

View File

@@ -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);
}

View File

@@ -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() {

View File

@@ -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<LWOOBJID, Spawner*> m_Spawners;
std::optional<WorldConfig> m_WorldConfig = std::nullopt;

54
docs/EditMode.md Normal file
View File

@@ -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.

View File

@@ -7,6 +7,7 @@ set(DGAMETEST_SOURCES
"MailPacketTests.cpp"
"MatchmakingTests.cpp"
"EconomyLedgerTests.cpp"
"EditModeTests.cpp"
"PlayerReportsLimitTests.cpp"
"SlashCommandPermissionTests.cpp"
"StaleSaveGuardTests.cpp"

View File

@@ -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 <gtest/gtest.h>
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<SceneObject> 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));
}