From fe82cd91f37c5810ca0fb03b087a2f94c46a770f Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 12 Apr 2026 18:46:04 +0200 Subject: [PATCH 01/19] wip modules --- code/addons/CMakeLists.txt | 3 +- .../navigationfeatureunit.cc | 9 + .../navigationfeaturemodule/CMakeLists.txt | 6 + .../navigationfeaturemodule.cc | 51 ++++ code/application/CMakeLists.txt | 3 + code/application/appgame/gameapplication.cc | 151 ++++++++++ code/application/appgame/gameapplication.h | 6 + code/application/game/moduleinterface.h | 42 +++ code/application/game/modulemanager.cc | 275 ++++++++++++++++++ code/application/game/modulemanager.h | 62 ++++ code/foundation/CMakeLists.txt | 2 + code/foundation/system/base/librarybase.h | 2 + code/foundation/system/library.h | 6 + code/foundation/system/posix/posixlibrary.cc | 93 ++++++ code/foundation/system/posix/posixlibrary.h | 39 +++ code/render/coregraphics/vk/vkloader.h | 5 + fips-files/include.cmake | 39 ++- .../flatbuffer/options/projectsettings.fbs | 10 + tests/CMakeLists.txt | 4 + tests/mathtest/mat4test.cc | 2 +- tests/testgame/CMakeLists.txt | 2 + tests/testgame/main.cc | 3 + tests/testgame/moduletest.cc | 178 ++++++++++++ tests/testgame/moduletest.h | 21 ++ tests/testruntimeloader/CMakeLists.txt | 12 + tests/testruntimeloader/main.cc | 54 ++++ tests/testruntimeloader/runtimemoduletest.cc | 153 ++++++++++ tests/testruntimeloader/runtimemoduletest.h | 21 ++ tests/testruntimemodule/CMakeLists.txt | 6 + .../testruntimemodule/runtimemodulefeature.cc | 77 +++++ tests/testruntimemodulebadabi/CMakeLists.txt | 6 + tests/testruntimemodulebadabi/badabimodule.cc | 37 +++ .../CMakeLists.txt | 6 + .../badexportsmodule.cc | 25 ++ toolkit/editor/CMakeLists.txt | 2 +- toolkit/editor/editor/editor.cc | 5 + .../editor/editor/ui/windows/navigation.cc | 71 ++++- toolkit/editor/editor/ui/windows/navigation.h | 4 + toolkit/levelviewer/levelviewerapplication.cc | 37 ++- toolkit/levelviewer/levelviewerapplication.h | 4 +- 40 files changed, 1521 insertions(+), 13 deletions(-) create mode 100644 code/addons/navigationfeaturemodule/CMakeLists.txt create mode 100644 code/addons/navigationfeaturemodule/navigationfeaturemodule.cc create mode 100644 code/application/game/moduleinterface.h create mode 100644 code/application/game/modulemanager.cc create mode 100644 code/application/game/modulemanager.h create mode 100644 code/foundation/system/posix/posixlibrary.cc create mode 100644 code/foundation/system/posix/posixlibrary.h create mode 100644 tests/testgame/moduletest.cc create mode 100644 tests/testgame/moduletest.h create mode 100644 tests/testruntimeloader/CMakeLists.txt create mode 100644 tests/testruntimeloader/main.cc create mode 100644 tests/testruntimeloader/runtimemoduletest.cc create mode 100644 tests/testruntimeloader/runtimemoduletest.h create mode 100644 tests/testruntimemodule/CMakeLists.txt create mode 100644 tests/testruntimemodule/runtimemodulefeature.cc create mode 100644 tests/testruntimemodulebadabi/CMakeLists.txt create mode 100644 tests/testruntimemodulebadabi/badabimodule.cc create mode 100644 tests/testruntimemodulebadexports/CMakeLists.txt create mode 100644 tests/testruntimemodulebadexports/badexportsmodule.cc diff --git a/code/addons/CMakeLists.txt b/code/addons/CMakeLists.txt index 587501fbc5..e13c9cd687 100644 --- a/code/addons/CMakeLists.txt +++ b/code/addons/CMakeLists.txt @@ -14,4 +14,5 @@ add_subdirectory(tinyxml) add_subdirectory(nsharp) add_addon(tbui) add_subdirectory(multiplayer) -add_subdirectory(navigationfeature) \ No newline at end of file +add_subdirectory(navigationfeature) +add_subdirectory(navigationfeaturemodule) \ No newline at end of file diff --git a/code/addons/navigationfeature/navigationfeatureunit.cc b/code/addons/navigationfeature/navigationfeatureunit.cc index 28e28055eb..7c9b37d974 100644 --- a/code/addons/navigationfeature/navigationfeatureunit.cc +++ b/code/addons/navigationfeature/navigationfeatureunit.cc @@ -58,6 +58,7 @@ NavigationFeatureUnit::OnActivate() void NavigationFeatureUnit::OnDeactivate() { + Navigation::navMeshCache = nullptr; FeatureUnit::OnDeactivate(); } @@ -75,6 +76,9 @@ NavigationFeatureUnit::OnBeginFrame() void NavigationFeatureUnit::OnRenderDebug() { + if (Navigation::navMeshCache == nullptr) + return; + Util::Array meshes = Navigation::navMeshCache->GetLoadedMeshes(); for (Navigation::NavMeshId id : meshes) { @@ -90,6 +94,11 @@ NavigationFeatureUnit::OnRenderDebug() */ void RenderUI(Graphics::GraphicsEntityId camera) { + (void)camera; + + if (!NavigationFeatureUnit::HasInstance() || Navigation::navMeshCache == nullptr) + return; + ImGui::Separator(); static bool showNavmesh = false; if (ImGui::Checkbox("Render Navmeshes", &showNavmesh)) diff --git a/code/addons/navigationfeaturemodule/CMakeLists.txt b/code/addons/navigationfeaturemodule/CMakeLists.txt new file mode 100644 index 0000000000..5364504372 --- /dev/null +++ b/code/addons/navigationfeaturemodule/CMakeLists.txt @@ -0,0 +1,6 @@ +nebula_begin_shared_module(navigationfeaturemodule) +fips_deps(navigationfeature application) +fips_files( + navigationfeaturemodule.cc +) +nebula_end_shared_module() diff --git a/code/addons/navigationfeaturemodule/navigationfeaturemodule.cc b/code/addons/navigationfeaturemodule/navigationfeaturemodule.cc new file mode 100644 index 0000000000..a4c4bb7816 --- /dev/null +++ b/code/addons/navigationfeaturemodule/navigationfeaturemodule.cc @@ -0,0 +1,51 @@ +//------------------------------------------------------------------------------ +// navigationfeaturemodule.cc +// (C) 2026 Individual contributors, see AUTHORS file +//------------------------------------------------------------------------------ +#include "application/stdneb.h" +#include "core/factory.h" +#include "game/moduleinterface.h" +#include "navigationfeature/navigationfeatureunit.h" + +#if __WIN32__ +#define NEBULA_MODULE_EXPORT extern "C" __declspec(dllexport) +#else +#define NEBULA_MODULE_EXPORT extern "C" __attribute__((visibility("default"))) +#endif + +namespace +{ +using NavigationFeatureRenderUiFn = void (*)(Graphics::GraphicsEntityId camera); +} + +NEBULA_MODULE_EXPORT int +NebulaModuleGetDescriptor(NebulaModuleDescriptor* outDescriptor) +{ + if (outDescriptor == nullptr) + return 0; + + outDescriptor->abiVersion = NEBULA_MODULE_ABI_VERSION; + outDescriptor->name = "navigationfeaturemodule"; + outDescriptor->version = "0.1.0"; + outDescriptor->flags = 0; + return 1; +} + +NEBULA_MODULE_EXPORT void* +NebulaModuleCreateFeature() +{ + return Core::Factory::Instance()->Create(NavigationFeature::NavigationFeatureUnit::RTTI.GetName()); +} + +NEBULA_MODULE_EXPORT void +NebulaModuleDestroyFeature(void* feature) +{ + // Feature instances are managed by Nebula refcounting via Ptr. + (void)feature; +} + +NEBULA_MODULE_EXPORT void +NebulaNavigationFeatureRenderUI(Graphics::GraphicsEntityId camera) +{ + NavigationFeature::RenderUI(camera); +} diff --git a/code/application/CMakeLists.txt b/code/application/CMakeLists.txt index e6e8870fff..381396f5bc 100644 --- a/code/application/CMakeLists.txt +++ b/code/application/CMakeLists.txt @@ -41,6 +41,9 @@ nebula_begin_module(application) gameserver.cc manager.h manager.cc + moduleinterface.h + modulemanager.h + modulemanager.cc componentserialization.h componentserialization.cc componentinspection.h diff --git a/code/application/appgame/gameapplication.cc b/code/application/appgame/gameapplication.cc index 6a97448ab8..e064fb40c0 100644 --- a/code/application/appgame/gameapplication.cc +++ b/code/application/appgame/gameapplication.cc @@ -42,6 +42,7 @@ GameApplication::GameApplication() : #if __NEBULA_HTTP__ defaultTcpPort(2100), #endif + runtimeModuleStrictMode(false), exitHandler(this) { __ConstructSingleton; @@ -110,6 +111,8 @@ GameApplication::Open() Options::InitOptions(); + this->SetupRuntimeModulesFromCmdLineArgs(); + Jobs2::JobSystemInitInfo jobSystemInfo; jobSystemInfo.numThreads = System::NumCpuCores; jobSystemInfo.name = "JobSystem"; @@ -159,6 +162,20 @@ GameApplication::Open() // create and add new game features this->SetupGameFeatures(); + + if (this->runtimeModuleConfigs.Size() > 0) + { + this->moduleManager = Game::ModuleManager::Create(); + const bool loaded = this->moduleManager->LoadModules(this->runtimeModuleConfigs, this->gameServer, this->runtimeModuleStrictMode); + if (!loaded && this->runtimeModuleStrictMode) + { + n_warning("GameApplication::Open(): runtime module loading failed in strict mode\n"); + this->moduleManager->UnloadModules(this->gameServer); + this->moduleManager = nullptr; + return false; + } + } + // open the game server this->gameServer->Open(); // start the game @@ -186,9 +203,16 @@ GameApplication::Close() this->gameServer->Stop(); this->gameServer->CleanupWorld(Game::GetWorld(WORLD_DEFAULT)); + if (this->moduleManager.isvalid()) + { + this->moduleManager->UnloadModules(this->gameServer); + this->moduleManager = nullptr; + } + this->gameServer->Close(); this->CleanupGameFeatures(); + this->gameServer->RemoveGameFeature(this->baseGameFeature); this->baseGameFeature = nullptr; @@ -322,4 +346,131 @@ GameApplication::SetupAppFromCmdLineArgs() editorEnabled = args.GetBoolFlag("-editor"); } +//------------------------------------------------------------------------------ +/** +*/ +void +GameApplication::SetupRuntimeModulesFromCmdLineArgs() +{ + this->runtimeModuleConfigs.Clear(); + this->runtimeModuleStrictMode = false; + const Util::CommandLineArgs& args = this->GetCmdLineArgs(); + + auto findModuleConfig = [this](const Util::String& moduleName) -> IndexT + { + Util::String check = moduleName; + check.ToLower(); + for (IndexT i = 0; i < this->runtimeModuleConfigs.Size(); i++) + { + Util::String candidate = this->runtimeModuleConfigs[i].name; + candidate.ToLower(); + if (candidate == check) + return i; + } + return InvalidIndex; + }; + + // Initialize runtime modules from project settings. CLI flags can then + // override these values on a per-module basis. + for (IndexT i = 0; i < (IndexT)Options::ProjectSettings.runtime_modules.size(); i++) + { + const std::unique_ptr& moduleSettings = Options::ProjectSettings.runtime_modules[i]; + if (!moduleSettings) + continue; + + if (moduleSettings->name.empty()) + continue; + + Game::RuntimeModuleConfig config; + config.name = moduleSettings->name.c_str(); + config.path = moduleSettings->path.c_str(); + config.enabled = moduleSettings->enabled; + config.required = moduleSettings->required; + this->runtimeModuleConfigs.Append(config); + } + + this->runtimeModuleStrictMode = Options::ProjectSettings.runtime_modules_strict; + + if (args.HasArg("-module")) + { + Util::Array modules = args.GetStrings("-module"); + for (IndexT i = 0; i < modules.Size(); i++) + { + if (!modules[i].IsValid()) + continue; + + IndexT index = findModuleConfig(modules[i]); + if (index == InvalidIndex) + { + Game::RuntimeModuleConfig config; + config.name = modules[i]; + config.enabled = true; + this->runtimeModuleConfigs.Append(config); + } + else + { + this->runtimeModuleConfigs[index].enabled = true; + } + } + } + + if (args.HasArg("-modulepath")) + { + Util::Array mappings = args.GetStrings("-modulepath"); + for (IndexT i = 0; i < mappings.Size(); i++) + { + Util::Array pair; + mappings[i].Tokenize("=", pair); + if (pair.Size() != 2) + { + n_warning("GameApplication: ignoring invalid -modulepath value '%s' (expected name=path)\n", mappings[i].AsCharPtr()); + continue; + } + + IndexT index = findModuleConfig(pair[0]); + if (index == InvalidIndex) + { + Game::RuntimeModuleConfig config; + config.name = pair[0]; + config.path = pair[1]; + config.enabled = true; + this->runtimeModuleConfigs.Append(config); + } + else + { + this->runtimeModuleConfigs[index].path = pair[1]; + } + } + } + + if (args.HasArg("-nomodule")) + { + Util::Array disabledModules = args.GetStrings("-nomodule"); + for (IndexT i = 0; i < disabledModules.Size(); i++) + { + if (!disabledModules[i].IsValid()) + continue; + + IndexT index = findModuleConfig(disabledModules[i]); + if (index == InvalidIndex) + { + Game::RuntimeModuleConfig config; + config.name = disabledModules[i]; + config.enabled = false; + this->runtimeModuleConfigs.Append(config); + } + else + { + this->runtimeModuleConfigs[index].enabled = false; + } + } + } + + if (args.GetBoolFlag("-modulestrict")) + { + this->runtimeModuleStrictMode = true; + } + +} + } // namespace App diff --git a/code/application/appgame/gameapplication.h b/code/application/appgame/gameapplication.h index 50c34f555a..c3c176ce43 100644 --- a/code/application/appgame/gameapplication.h +++ b/code/application/appgame/gameapplication.h @@ -23,6 +23,7 @@ #include "http/httpinterface.h" #include "http/httpserverproxy.h" #include "basegamefeature/basegamefeatureunit.h" +#include "game/modulemanager.h" //------------------------------------------------------------------------------ namespace App @@ -57,6 +58,8 @@ class GameApplication : public Application virtual void CleanupGameFeatures(); /// setup app from cmd lines virtual void SetupAppFromCmdLineArgs(); + /// parse runtime module startup options from command line + virtual void SetupRuntimeModulesFromCmdLineArgs(); Ptr coreServer; Ptr gameContentServer; @@ -64,6 +67,9 @@ class GameApplication : public Application Ptr ioServer; Ptr ioInterface; Ptr baseGameFeature; + Ptr moduleManager; + Util::Array runtimeModuleConfigs; + bool runtimeModuleStrictMode; static bool editorEnabled; diff --git a/code/application/game/moduleinterface.h b/code/application/game/moduleinterface.h new file mode 100644 index 0000000000..c8f9c5043e --- /dev/null +++ b/code/application/game/moduleinterface.h @@ -0,0 +1,42 @@ +#pragma once +//------------------------------------------------------------------------------ +/** + Runtime shared module ABI definitions. + + These symbols are exported from shared game modules and consumed by the + runtime module manager. Keep this interface C-compatible and minimal to + avoid accidental ABI breakage. + + @copyright + (C) 2026 Individual contributors, see AUTHORS file +*/ +#include + +#define NEBULA_MODULE_ABI_VERSION 1u +#define NEBULA_MODULE_GET_DESCRIPTOR_EXPORT "NebulaModuleGetDescriptor" +#define NEBULA_MODULE_CREATE_FEATURE_EXPORT "NebulaModuleCreateFeature" +#define NEBULA_MODULE_DESTROY_FEATURE_EXPORT "NebulaModuleDestroyFeature" + +#ifdef __cplusplus +extern "C" +{ +#endif + +struct NebulaModuleDescriptor +{ + uint32_t abiVersion; + const char* name; + const char* version; + uint32_t flags; +}; + +typedef int (*NebulaModuleGetDescriptorFn)(NebulaModuleDescriptor* outDescriptor); +// Created feature must be a Game::FeatureUnit-compatible Core::RefCounted object +// owned by Nebula's refcounting lifecycle after attachment. +typedef void* (*NebulaModuleCreateFeatureFn)(); +typedef void (*NebulaModuleDestroyFeatureFn)(void* feature); + +#ifdef __cplusplus +} +#endif +//------------------------------------------------------------------------------ diff --git a/code/application/game/modulemanager.cc b/code/application/game/modulemanager.cc new file mode 100644 index 0000000000..5e11c9d812 --- /dev/null +++ b/code/application/game/modulemanager.cc @@ -0,0 +1,275 @@ +//------------------------------------------------------------------------------ +// modulemanager.cc +// (C) 2026 Individual contributors, see AUTHORS file +//------------------------------------------------------------------------------ +#include "application/stdneb.h" +#include "game/modulemanager.h" + +#include "game/featureunit.h" +#include "game/gameserver.h" +#include "io/fswrapper.h" +#include "io/ioserver.h" +#include "system/library.h" +#include + +namespace Game +{ +__ImplementClass(Game::ModuleManager, 'GMDM', Core::RefCounted); + +struct ModuleManager::LoadedModule +{ + RuntimeModuleConfig config; + Base::Library* library; + Ptr feature; +}; + +//------------------------------------------------------------------------------ +/** +*/ +ModuleManager::ModuleManager() +{ + // empty +} + +//------------------------------------------------------------------------------ +/** +*/ +ModuleManager::~ModuleManager() +{ + n_assert(this->loadedModules.IsEmpty()); +} + +//------------------------------------------------------------------------------ +/** +*/ +bool +ModuleManager::LoadModules(const Util::Array& moduleConfigs, GameServer* gameServer, bool strictMode) +{ + n_assert(gameServer != nullptr); + bool success = true; + for (IndexT i = 0; i < moduleConfigs.Size(); i++) + { + const RuntimeModuleConfig& moduleConfig = moduleConfigs[i]; + if (!moduleConfig.enabled) + continue; + + if (!this->LoadModule(moduleConfig, gameServer, strictMode)) + { + success = false; + if (strictMode || moduleConfig.required) + break; + } + } + return success; +} + +//------------------------------------------------------------------------------ +/** +*/ +void +ModuleManager::UnloadModules(GameServer* gameServer) +{ + if (gameServer == nullptr) + return; + + for (IndexT i = this->loadedModules.Size() - 1; i >= 0; i--) + { + this->UnloadModule(this->loadedModules[i], gameServer); + } + this->loadedModules.Clear(); +} + +//------------------------------------------------------------------------------ +/** +*/ +bool +ModuleManager::IsModuleLoaded(const Util::String& moduleName) const +{ + Util::String checkName = moduleName; + checkName.ToLower(); + + for (IndexT i = 0; i < this->loadedModules.Size(); i++) + { + Util::String loadedName = this->loadedModules[i].config.name; + loadedName.ToLower(); + if (loadedName == checkName) + return true; + } + return false; +} + +//------------------------------------------------------------------------------ +/** +*/ +SizeT +ModuleManager::GetNumLoadedModules() const +{ + return this->loadedModules.Size(); +} + +//------------------------------------------------------------------------------ +/** +*/ +bool +ModuleManager::LoadModule(const RuntimeModuleConfig& moduleConfig, GameServer* gameServer, bool strictMode) +{ + if (moduleConfig.name.IsValid() && this->IsModuleLoaded(moduleConfig.name)) + { + std::fprintf(stdout, "ModuleManager: module '%s' is already loaded, skipping duplicate request\n", moduleConfig.name.AsCharPtr()); + return true; + } + + Util::String libraryPath = this->ResolveLibraryPath(moduleConfig); + if (!libraryPath.IsValid()) + { + std::fprintf(stderr, "ModuleManager: module '%s' has no valid path\n", moduleConfig.name.AsCharPtr()); + return !(strictMode || moduleConfig.required); + } + + Base::Library* library = new System::Library(); + library->SetPath(IO::URI(libraryPath)); + if (!library->Load()) + { + delete library; + return !(strictMode || moduleConfig.required); + } + + NebulaModuleGetDescriptorFn getDescriptor = reinterpret_cast(library->GetExport(NEBULA_MODULE_GET_DESCRIPTOR_EXPORT)); + NebulaModuleCreateFeatureFn createFeature = reinterpret_cast(library->GetExport(NEBULA_MODULE_CREATE_FEATURE_EXPORT)); + NebulaModuleDestroyFeatureFn destroyFeature = reinterpret_cast(library->GetExport(NEBULA_MODULE_DESTROY_FEATURE_EXPORT)); + + if (getDescriptor == nullptr || createFeature == nullptr) + { + std::fprintf(stderr, "ModuleManager: module '%s' is missing required exports\n", moduleConfig.name.AsCharPtr()); + library->Close(); + delete library; + return !(strictMode || moduleConfig.required); + } + + NebulaModuleDescriptor desc = {}; + if (getDescriptor(&desc) == 0) + { + std::fprintf(stderr, "ModuleManager: module '%s' descriptor callback failed\n", moduleConfig.name.AsCharPtr()); + library->Close(); + delete library; + return !(strictMode || moduleConfig.required); + } + + if (desc.abiVersion != NEBULA_MODULE_ABI_VERSION) + { + std::fprintf(stderr, "ModuleManager: module '%s' has ABI %u, expected %u\n", moduleConfig.name.AsCharPtr(), desc.abiVersion, NEBULA_MODULE_ABI_VERSION); + library->Close(); + delete library; + return !(strictMode || moduleConfig.required); + } + + FeatureUnit* featureRaw = reinterpret_cast(createFeature()); + if (featureRaw == nullptr) + { + std::fprintf(stderr, "ModuleManager: module '%s' did not return a feature instance\n", moduleConfig.name.AsCharPtr()); + library->Close(); + delete library; + return !(strictMode || moduleConfig.required); + } + + Ptr feature = featureRaw; + gameServer->AttachGameFeature(feature); + + LoadedModule loaded; + loaded.config = moduleConfig; + loaded.library = library; + loaded.feature = feature; + this->loadedModules.Append(loaded); + + const char* descName = desc.name != nullptr ? desc.name : ""; + const char* descVersion = desc.version != nullptr ? desc.version : ""; + if (destroyFeature != nullptr) + { + std::fprintf(stderr, "ModuleManager: module '%s' exports '%s', but runtime expects FeatureUnit instances to be managed via Nebula refcounting\n", descName, NEBULA_MODULE_DESTROY_FEATURE_EXPORT); + } + std::fprintf(stdout, "ModuleManager: loaded module '%s' v%s from '%s'\n", descName, descVersion, libraryPath.AsCharPtr()); + return true; +} + +//------------------------------------------------------------------------------ +/** +*/ +void +ModuleManager::UnloadModule(LoadedModule& loaded, GameServer* gameServer) +{ + if (loaded.feature.isvalid()) + { + gameServer->RemoveGameFeature(loaded.feature); + + // Keep the module resident if external references still hold the feature. + // Unloading the library in this state could leave dangling vtables. + if (loaded.feature->GetRefCount() > 1) + { + std::fprintf(stderr, "ModuleManager: module '%s' still has external references on unload (%d), keeping shared library loaded\n", loaded.config.name.AsCharPtr(), loaded.feature->GetRefCount()); + return; + } + + loaded.feature = nullptr; + } + + if (loaded.library != nullptr) + { + if (loaded.library->IsLoaded()) + { + loaded.library->Close(); + } + delete loaded.library; + loaded.library = nullptr; + } +} + +//------------------------------------------------------------------------------ +/** +*/ +Util::String +ModuleManager::ResolveLibraryPath(const RuntimeModuleConfig& moduleConfig) const +{ + if (moduleConfig.path.IsValid()) + return moduleConfig.path; + + if (!moduleConfig.name.IsValid()) + return ""; + + Util::String path = moduleConfig.name; + + if (path.ContainsCharFromSet("/\\")) + return path; + +#if __WIN32__ + if (!path.EndsWithString(".dll")) + { + path.Append(".dll"); + } +#else + if (!path.BeginsWithString("lib")) + { + path = Util::String::Sprintf("lib%s", path.AsCharPtr()); + } + if (!path.EndsWithString(".so")) + { + path.Append(".so"); + } +#endif + + if (IO::FSWrapper::FileExists(path)) + { + return IO::IoServer::NativePath(path); + } + +#if defined(NEBULA_BINARY_FOLDER) + Util::String deployCandidate = Util::String::Sprintf("%s/%s", NEBULA_BINARY_FOLDER, path.AsCharPtr()); + if (IO::FSWrapper::FileExists(deployCandidate)) + { + return deployCandidate; + } +#endif + + return path; +} + +} // namespace Game diff --git a/code/application/game/modulemanager.h b/code/application/game/modulemanager.h new file mode 100644 index 0000000000..91d3162cf1 --- /dev/null +++ b/code/application/game/modulemanager.h @@ -0,0 +1,62 @@ +#pragma once +//------------------------------------------------------------------------------ +/** + @class Game::ModuleManager + + Loads FeatureUnit modules from shared libraries at startup. + + @copyright + (C) 2026 Individual contributors, see AUTHORS file +*/ +#include "core/refcounted.h" +#include "util/array.h" +#include "util/string.h" +#include "game/moduleinterface.h" + +namespace Base +{ +class Library; +} + +namespace Game +{ +class FeatureUnit; +class GameServer; + +struct RuntimeModuleConfig +{ + Util::String name; + Util::String path; + bool enabled = true; + bool required = false; +}; + +class ModuleManager : public Core::RefCounted +{ + __DeclareClass(ModuleManager) +public: + ModuleManager(); + virtual ~ModuleManager(); + + /// load all enabled modules and attach feature units to the game server + bool LoadModules(const Util::Array& moduleConfigs, GameServer* gameServer, bool strictMode = false); + /// unload all modules and detach feature units from game server + void UnloadModules(GameServer* gameServer); + + /// return true if a module with this name has been loaded + bool IsModuleLoaded(const Util::String& moduleName) const; + /// get number of loaded modules + SizeT GetNumLoadedModules() const; + +private: + struct LoadedModule; + + bool LoadModule(const RuntimeModuleConfig& moduleConfig, GameServer* gameServer, bool strictMode); + void UnloadModule(LoadedModule& loaded, GameServer* gameServer); + Util::String ResolveLibraryPath(const RuntimeModuleConfig& moduleConfig) const; + + Util::Array loadedModules; +}; + +} // namespace Game +//------------------------------------------------------------------------------ diff --git a/code/foundation/CMakeLists.txt b/code/foundation/CMakeLists.txt index 8f0dfc59ff..1723106163 100644 --- a/code/foundation/CMakeLists.txt +++ b/code/foundation/CMakeLists.txt @@ -658,6 +658,8 @@ nebula_begin_module(foundation) system/posix/posixsysteminfo.cc system/posix/posixenvironment.cc system/posix/posixenvironment.h + system/posix/posixlibrary.cc + system/posix/posixlibrary.h system/posix/posixsettings.cc system/posix/posixsettings.h ) diff --git a/code/foundation/system/base/librarybase.h b/code/foundation/system/base/librarybase.h index cb792e32fa..7b41c13c7b 100644 --- a/code/foundation/system/base/librarybase.h +++ b/code/foundation/system/base/librarybase.h @@ -20,6 +20,8 @@ class Library public: /// constructor Library(); + /// destructor + virtual ~Library() = default; /// set the executable path void SetPath(const IO::URI& uri); diff --git a/code/foundation/system/library.h b/code/foundation/system/library.h index a4a651b48c..b1d93add1d 100644 --- a/code/foundation/system/library.h +++ b/code/foundation/system/library.h @@ -14,6 +14,12 @@ namespace System { typedef Win32::Win32Library Library; } +#elif (__linux__ || __OSX__ || __APPLE__) +#include "posix/posixlibrary.h" +namespace System +{ +typedef Posix::PosixLibrary Library; +} #else #error "System::Library not implemented on this platform!" #endif diff --git a/code/foundation/system/posix/posixlibrary.cc b/code/foundation/system/posix/posixlibrary.cc new file mode 100644 index 0000000000..e4ec26b57f --- /dev/null +++ b/code/foundation/system/posix/posixlibrary.cc @@ -0,0 +1,93 @@ +//------------------------------------------------------------------------------ +// posixlibrary.cc +// (C) 2026 Individual contributors, see AUTHORS file +//------------------------------------------------------------------------------ +#include "foundation/stdneb.h" +#include "posixlibrary.h" +#include +#include + +namespace Posix +{ +using namespace Util; + +//------------------------------------------------------------------------------ +/** +*/ +PosixLibrary::PosixLibrary() : + handle(nullptr) +{ + // empty +} + +//------------------------------------------------------------------------------ +/** +*/ +bool +PosixLibrary::Load() +{ + n_assert(this->path.IsValid()); + Util::String p = this->path.GetHostAndLocalPath(); + + // Clear previous dlerror state before trying to load. + dlerror(); + this->handle = dlopen(p.AsCharPtr(), RTLD_NOW | RTLD_LOCAL); + this->isLoaded = this->handle != nullptr; + if (!this->isLoaded) + { + const char* err = dlerror(); + std::fprintf(stderr, "PosixLibrary::Load(): failed to load '%s' (%s)\n", p.AsCharPtr(), err != nullptr ? err : "unknown error"); + } + return this->isLoaded; +} + +//------------------------------------------------------------------------------ +/** +*/ +void +PosixLibrary::Close() +{ + if (!this->isLoaded || this->handle == nullptr) + return; + + int result = dlclose(this->handle); + if (result != 0) + { + const char* err = dlerror(); + std::fprintf(stderr, "PosixLibrary::Close(): failed to close '%s' (%s)\n", this->path.GetHostAndLocalPath().AsCharPtr(), err != nullptr ? err : "unknown error"); + } + + this->handle = nullptr; + this->isLoaded = false; +} + +//------------------------------------------------------------------------------ +/** +*/ +void* +PosixLibrary::GetExport(Util::String const& name) const +{ + if (this->handle == nullptr) + return nullptr; + + dlerror(); + void* sym = dlsym(this->handle, name.AsCharPtr()); + const char* err = dlerror(); + if (err != nullptr) + { + std::fprintf(stderr, "PosixLibrary::GetExport(): symbol '%s' not found in '%s' (%s)\n", name.AsCharPtr(), this->path.GetHostAndLocalPath().AsCharPtr(), err); + return nullptr; + } + return sym; +} + +//------------------------------------------------------------------------------ +/** +*/ +bool +PosixLibrary::IsLoaded() +{ + return this->isLoaded; +} + +} // namespace Posix diff --git a/code/foundation/system/posix/posixlibrary.h b/code/foundation/system/posix/posixlibrary.h new file mode 100644 index 0000000000..861ec8eb5c --- /dev/null +++ b/code/foundation/system/posix/posixlibrary.h @@ -0,0 +1,39 @@ +#pragma once +//------------------------------------------------------------------------------ +/** + @class Posix::PosixLibrary + + Load and handle a shared object using the POSIX dynamic loader API. + + @copyright + (C) 2026 Individual contributors, see AUTHORS file +*/ +#include "util/string.h" +#include "io/uri.h" +#include "io/stream.h" +#include "system/base/librarybase.h" + +//------------------------------------------------------------------------------ +namespace Posix +{ +class PosixLibrary : public Base::Library +{ +public: + /// constructor + PosixLibrary(); + + /// load shared library + bool Load() override; + /// close shared library + void Close() override; + /// get exported function address (dlsym) + void* GetExport(Util::String const& name) const override; + /// gets the state of the library + bool IsLoaded() override; + +private: + void* handle; +}; + +} // namespace Posix +//------------------------------------------------------------------------------ diff --git a/code/render/coregraphics/vk/vkloader.h b/code/render/coregraphics/vk/vkloader.h index 28a57b05ca..03173cf2db 100644 --- a/code/render/coregraphics/vk/vkloader.h +++ b/code/render/coregraphics/vk/vkloader.h @@ -32,8 +32,13 @@ extern PFN_vkSetDebugUtilsObjectNameEXT VkDebugObjectName; } // namespace Vulkan #define _IMP_VK(name) name = (PFN_##name)vkGetInstanceProcAddr(instance, #name);n_assert_fmt(name != nullptr, "Unable to get function proc: %s\n",#name); +#if __WIN32__ #define _DEC_VK(name) extern PFN_##name name; #define _DEF_VK(name) PFN_##name name = nullptr; +#else +#define _DEC_VK(name) extern PFN_##name name __attribute__((visibility("hidden"))); +#define _DEF_VK(name) PFN_##name name __attribute__((visibility("hidden"))) = nullptr; +#endif #define _IMP_VK_DYN(name, instance) name = (PFN_##name)vkGetInstanceProcAddr(instance, #name);n_assert_fmt(name != nullptr, "Unable to get function proc: %s\n",#name); diff --git a/fips-files/include.cmake b/fips-files/include.cmake index 55cedf7550..82f9803271 100644 --- a/fips-files/include.cmake +++ b/fips-files/include.cmake @@ -751,7 +751,7 @@ macro(nebula_end_app) if (target_has_frame_script) nebula_framescript_compile() endif() - set_target_properties(${curtarget} PROPERTIES ENABLE_EXPORTS false) + set_target_properties(${curtarget} PROPERTIES ENABLE_EXPORTS true) if (TARGET system_resources-res) target_link_libraries(${curtarget} $) endif() @@ -803,6 +803,43 @@ macro(nebula_end_module) endif() endmacro() +macro(nebula_begin_shared_module name) + fips_begin_sharedlib(${name}) + set(target_has_nidl 0) + set(target_has_shaders 0) + set(target_has_flatc 0) + set(target_has_materials 0) + set(target_has_frame_script 0) + if(N_EDITOR) + add_compile_definitions(WITH_NEBULA_EDITOR) + endif() + set_target_properties(${name} PROPERTIES COMPILE_WARNING_AS_ERROR TRUE) +endmacro() + +macro(nebula_end_shared_module) + set(curtarget ${CurTargetName}) + fips_end_sharedlib() + if(target_has_nidl) + target_include_directories(${curtarget} PUBLIC "${CMAKE_BINARY_DIR}/nidl/${CurTargetName}") + target_include_directories(${curtarget} PUBLIC "${CMAKE_BINARY_DIR}/nidl/") + endif() + if (target_has_shaders) + target_include_directories(${curtarget} PUBLIC "${CMAKE_BINARY_DIR}/shaders") + endif() + if (target_has_flatc) + target_include_directories(${curtarget} PUBLIC "${CMAKE_BINARY_DIR}/generated") + endif() + if (target_has_materials) + nebula_material_template_gpulang_compile() + endif() + if (target_has_frame_script) + nebula_framescript_compile() + endif() + if(N_DEBUG_SYMBOLS) + target_compile_options(${curtarget} PRIVATE $,/Zi,/Z7>) + endif() +endmacro() + macro(nebula_begin_lib name) fips_begin_lib(${name}) set(target_has_nidl 0) diff --git a/syswork/data/flatbuffer/options/projectsettings.fbs b/syswork/data/flatbuffer/options/projectsettings.fbs index 412c95a712..98ffe0b484 100644 --- a/syswork/data/flatbuffer/options/projectsettings.fbs +++ b/syswork/data/flatbuffer/options/projectsettings.fbs @@ -12,9 +12,19 @@ table GISettings update_frequency: float = 0.0; } +table RuntimeModuleSettings +{ + name: string; + path: string; + enabled: bool = true; + required: bool = false; +} + table ProjectSettings { gi_settings: GISettings; + runtime_modules: [RuntimeModuleSettings]; + runtime_modules_strict: bool = false; } root_type ProjectSettings; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index df107c675b..71df2a7b07 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -19,3 +19,7 @@ add_subdirectory(testscript) add_subdirectory(testaddon) add_subdirectory(testnsharp) add_subdirectory(testtbui) +add_subdirectory(testruntimemodule) +add_subdirectory(testruntimemodulebadexports) +add_subdirectory(testruntimemodulebadabi) +add_subdirectory(testruntimeloader) diff --git a/tests/mathtest/mat4test.cc b/tests/mathtest/mat4test.cc index 865098750d..6bf7686524 100644 --- a/tests/mathtest/mat4test.cc +++ b/tests/mathtest/mat4test.cc @@ -365,7 +365,7 @@ Mat4Test::Run() // trs() is documented shorthand for affine(scale, rotation, position) const mat4 mTrs = trs(trsPos, trsRot, trsScale); - const mat4 mAffine = affine(trsScale, trsRot, trsPos); + const mat4 mAffine = Math::affine(trsScale, trsRot, trsPos); VERIFY(matnearequal(mTrs, mAffine)); // isidentity should only be true for identity matrix diff --git a/tests/testgame/CMakeLists.txt b/tests/testgame/CMakeLists.txt index 75250aeecb..0ce0a02ac2 100644 --- a/tests/testgame/CMakeLists.txt +++ b/tests/testgame/CMakeLists.txt @@ -8,6 +8,8 @@ fips_files(databasetest.cc idtest.cc idtest.h main.cc + moduletest.cc + moduletest.h scriptingtest.cc scriptingtest.h blueprints_test.json diff --git a/tests/testgame/main.cc b/tests/testgame/main.cc index ef2d8e784b..705cf8c5e9 100644 --- a/tests/testgame/main.cc +++ b/tests/testgame/main.cc @@ -13,6 +13,7 @@ #include "idtest.h" #include "databasetest.h" #include "entitysystemtest.h" +#include "moduletest.h" #include "scriptingtest.h" #include "testcomponents.h" @@ -80,6 +81,7 @@ NebulaMain(const Util::CommandLineArgs& args) GameAppTest gameApp; gameApp.SetCompanyName("Test Company"); gameApp.SetAppTitle("NEBULA GAME-TESTS"); + gameApp.SetCmdLineArgs(args); Game::BlueprintManager::SetBlueprintsFilename("blueprints_test.json", "bin:"); @@ -97,6 +99,7 @@ NebulaMain(const Util::CommandLineArgs& args) testRunner->AttachTestCase(IdTest::Create()); testRunner->AttachTestCase(DatabaseTest::Create()); testRunner->AttachTestCase(EntitySystemTest::Create()); + testRunner->AttachTestCase(ModuleTest::Create()); //testRunner->AttachTestCase(ScriptingTest::Create()); bool result = testRunner->Run(); diff --git a/tests/testgame/moduletest.cc b/tests/testgame/moduletest.cc new file mode 100644 index 0000000000..caec4efa52 --- /dev/null +++ b/tests/testgame/moduletest.cc @@ -0,0 +1,178 @@ +//------------------------------------------------------------------------------ +// moduletest.cc +// (C) 2026 Individual contributors, see AUTHORS file +//------------------------------------------------------------------------------ +#include "stdneb.h" +#include "moduletest.h" +#include "game/featureunit.h" +#include "game/modulemanager.h" +#include "game/gameserver.h" + +namespace Test +{ +__ImplementClass(Test::ModuleTest, 'GMDT', Test::TestCase); + +void +ModuleTest::Run() +{ + VERIFY(Game::GameServer::HasInstance()); + Game::GameServer* server = Game::GameServer::Instance(); + const SizeT baseFeatureCount = server->GetGameFeatures().Size(); + + Ptr manager = Game::ModuleManager::Create(); + + { + Util::Array configs; + Game::RuntimeModuleConfig cfg; + cfg.name = "testruntimemodule"; + cfg.enabled = true; + cfg.required = true; + configs.Append(cfg); + + bool ok = manager->LoadModules(configs, server, true); + VERIFY(ok); + VERIFY(manager->GetNumLoadedModules() == 1); + VERIFY(manager->IsModuleLoaded("testruntimemodule")); + VERIFY(server->GetGameFeatures().Size() == baseFeatureCount + 1); + + manager->UnloadModules(server); + VERIFY(manager->GetNumLoadedModules() == 0); + VERIFY(server->GetGameFeatures().Size() == baseFeatureCount); + } + + { + Util::Array configs; + + Game::RuntimeModuleConfig first; + first.name = "testruntimemodule"; + first.enabled = true; + first.required = true; + configs.Append(first); + + Game::RuntimeModuleConfig duplicate = first; + configs.Append(duplicate); + + bool ok = manager->LoadModules(configs, server, true); + VERIFY(ok); + VERIFY(manager->GetNumLoadedModules() == 1); + VERIFY(server->GetGameFeatures().Size() == baseFeatureCount + 1); + + manager->UnloadModules(server); + VERIFY(manager->GetNumLoadedModules() == 0); + VERIFY(server->GetGameFeatures().Size() == baseFeatureCount); + } + + { + Util::Array configs; + Game::RuntimeModuleConfig cfg; + cfg.name = "testruntimemodule"; + cfg.enabled = true; + cfg.required = true; + configs.Append(cfg); + + bool ok = manager->LoadModules(configs, server, true); + VERIFY(ok); + VERIFY(manager->GetNumLoadedModules() == 1); + VERIFY(server->GetGameFeatures().Size() == baseFeatureCount + 1); + + Ptr extraRef = server->GetGameFeatures().Back(); + VERIFY(extraRef.isvalid()); + + manager->UnloadModules(server); + VERIFY(manager->GetNumLoadedModules() == 0); + VERIFY(server->GetGameFeatures().Size() == baseFeatureCount); + VERIFY(extraRef.isvalid()); + + extraRef = nullptr; + + ok = manager->LoadModules(configs, server, true); + VERIFY(ok); + VERIFY(manager->GetNumLoadedModules() == 1); + VERIFY(server->GetGameFeatures().Size() == baseFeatureCount + 1); + + manager->UnloadModules(server); + VERIFY(manager->GetNumLoadedModules() == 0); + VERIFY(server->GetGameFeatures().Size() == baseFeatureCount); + } + + { + Util::Array configs; + Game::RuntimeModuleConfig cfg; + cfg.name = "does_not_exist"; + cfg.enabled = true; + cfg.required = false; + configs.Append(cfg); + + bool ok = manager->LoadModules(configs, server, false); + VERIFY(ok); + VERIFY(manager->GetNumLoadedModules() == 0); + } + + { + Util::Array configs; + Game::RuntimeModuleConfig cfg; + cfg.name = "does_not_exist"; + cfg.enabled = true; + cfg.required = true; + configs.Append(cfg); + + bool ok = manager->LoadModules(configs, server, false); + VERIFY(!ok); + VERIFY(manager->GetNumLoadedModules() == 0); + } + + { + Util::Array configs; + Game::RuntimeModuleConfig cfg; + cfg.name = "testruntimemodulebadexports"; + cfg.enabled = true; + cfg.required = false; + configs.Append(cfg); + + bool ok = manager->LoadModules(configs, server, false); + VERIFY(ok); + VERIFY(manager->GetNumLoadedModules() == 0); + } + + { + Util::Array configs; + Game::RuntimeModuleConfig cfg; + cfg.name = "testruntimemodulebadabi"; + cfg.enabled = true; + cfg.required = false; + configs.Append(cfg); + + bool ok = manager->LoadModules(configs, server, false); + VERIFY(ok); + VERIFY(manager->GetNumLoadedModules() == 0); + } + + { + Util::Array configs; + + Game::RuntimeModuleConfig missing; + missing.name = "does_not_exist"; + missing.enabled = true; + missing.required = false; + configs.Append(missing); + + Game::RuntimeModuleConfig valid; + valid.name = "testruntimemodule"; + valid.enabled = true; + valid.required = true; + configs.Append(valid); + + bool ok = manager->LoadModules(configs, server, false); + VERIFY(ok); + VERIFY(manager->GetNumLoadedModules() == 1); + VERIFY(manager->IsModuleLoaded("testruntimemodule")); + + manager->UnloadModules(server); + VERIFY(manager->GetNumLoadedModules() == 0); + VERIFY(server->GetGameFeatures().Size() == baseFeatureCount); + } + + manager = nullptr; +} + +} // namespace Test diff --git a/tests/testgame/moduletest.h b/tests/testgame/moduletest.h new file mode 100644 index 0000000000..0041c938d7 --- /dev/null +++ b/tests/testgame/moduletest.h @@ -0,0 +1,21 @@ +#pragma once +//------------------------------------------------------------------------------ +/** + @class Test::ModuleTest + + Tests runtime module loading behavior. + + (C) 2026 Individual contributors, see AUTHORS file +*/ +#include "testbase/testcase.h" + +namespace Test +{ +class ModuleTest : public TestCase +{ + __DeclareClass(ModuleTest); +public: + virtual void Run(); +}; +} +//------------------------------------------------------------------------------ diff --git a/tests/testruntimeloader/CMakeLists.txt b/tests/testruntimeloader/CMakeLists.txt new file mode 100644 index 0000000000..ab11ae7fdc --- /dev/null +++ b/tests/testruntimeloader/CMakeLists.txt @@ -0,0 +1,12 @@ +nebula_begin_app(testruntimeloader cmdline) + +fips_files( + main.cc + runtimemoduletest.cc + runtimemoduletest.h +) + +fips_deps(foundation application testbase) +target_precompile_headers(testruntimeloader PRIVATE [["foundation/stdneb.h"]] [["application/stdneb.h"]]) + +nebula_end_app() diff --git a/tests/testruntimeloader/main.cc b/tests/testruntimeloader/main.cc new file mode 100644 index 0000000000..6d6aa1bd57 --- /dev/null +++ b/tests/testruntimeloader/main.cc @@ -0,0 +1,54 @@ +//------------------------------------------------------------------------------ +// main.cc +// (C) 2026 Individual contributors, see AUTHORS file +//------------------------------------------------------------------------------ +#include "stdneb.h" +#include "system/appentry.h" +#include "testbase/testrunner.h" +#include "appgame/gameapplication.h" +#include "runtimemoduletest.h" + +ImplementNebulaApplication(); + +class RuntimeModuleTestApp : public App::GameApplication +{ +private: + void SetupGameFeatures() override + { + // No additional features required for module loader tests. + } + + void CleanupGameFeatures() override + { + // No-op + } +}; + +void +NebulaMain(const Util::CommandLineArgs& args) +{ + RuntimeModuleTestApp app; + app.SetCompanyName("Test Company"); + app.SetAppTitle("NEBULA RUNTIME MODULE LOADER TESTS"); + app.SetCmdLineArgs(args); + + if (!app.Open()) + { + n_printf("Aborting runtime module loader tests due to startup failure...\n"); + Core::SysFunc::Exit(-1); + return; + } + + n_printf("NEBULA RUNTIME MODULE LOADER TESTS\n"); + n_printf("========================\n"); + + Ptr testRunner = Test::TestRunner::Create(); + testRunner->AttachTestCase(Test::RuntimeModuleLoaderTest::Create()); + + bool result = testRunner->Run(); + + testRunner = nullptr; + app.Close(); + + Core::SysFunc::Exit(result ? 0 : -1); +} diff --git a/tests/testruntimeloader/runtimemoduletest.cc b/tests/testruntimeloader/runtimemoduletest.cc new file mode 100644 index 0000000000..98df7e0b32 --- /dev/null +++ b/tests/testruntimeloader/runtimemoduletest.cc @@ -0,0 +1,153 @@ +//------------------------------------------------------------------------------ +// runtimemoduletest.cc +// (C) 2026 Individual contributors, see AUTHORS file +//------------------------------------------------------------------------------ +#include "stdneb.h" +#include "runtimemoduletest.h" +#include "game/featureunit.h" +#include "game/modulemanager.h" +#include "game/gameserver.h" + +namespace Test +{ +__ImplementClass(Test::RuntimeModuleLoaderTest, 'RMLT', Test::TestCase); + +void +RuntimeModuleLoaderTest::Run() +{ + VERIFY(Game::GameServer::HasInstance()); + Game::GameServer* server = Game::GameServer::Instance(); + const SizeT baseFeatureCount = server->GetGameFeatures().Size(); + + Ptr manager = Game::ModuleManager::Create(); + + { + Util::Array configs; + Game::RuntimeModuleConfig cfg; + cfg.name = "testruntimemodule"; + cfg.enabled = true; + cfg.required = true; + configs.Append(cfg); + + bool ok = manager->LoadModules(configs, server, true); + VERIFY(ok); + VERIFY(manager->GetNumLoadedModules() == 1); + VERIFY(manager->IsModuleLoaded("testruntimemodule")); + VERIFY(server->GetGameFeatures().Size() == baseFeatureCount + 1); + + manager->UnloadModules(server); + VERIFY(manager->GetNumLoadedModules() == 0); + VERIFY(server->GetGameFeatures().Size() == baseFeatureCount); + } + + { + Util::Array configs; + + Game::RuntimeModuleConfig first; + first.name = "testruntimemodule"; + first.enabled = true; + first.required = true; + configs.Append(first); + + Game::RuntimeModuleConfig duplicate = first; + configs.Append(duplicate); + + bool ok = manager->LoadModules(configs, server, true); + VERIFY(ok); + VERIFY(manager->GetNumLoadedModules() == 1); + VERIFY(server->GetGameFeatures().Size() == baseFeatureCount + 1); + + manager->UnloadModules(server); + VERIFY(manager->GetNumLoadedModules() == 0); + VERIFY(server->GetGameFeatures().Size() == baseFeatureCount); + } + + { + Util::Array configs; + Game::RuntimeModuleConfig cfg; + cfg.name = "testruntimemodule"; + cfg.enabled = true; + cfg.required = true; + configs.Append(cfg); + + bool ok = manager->LoadModules(configs, server, true); + VERIFY(ok); + VERIFY(manager->GetNumLoadedModules() == 1); + VERIFY(server->GetGameFeatures().Size() == baseFeatureCount + 1); + + Ptr extraRef = server->GetGameFeatures().Back(); + VERIFY(extraRef.isvalid()); + + manager->UnloadModules(server); + VERIFY(manager->GetNumLoadedModules() == 0); + VERIFY(server->GetGameFeatures().Size() == baseFeatureCount); + VERIFY(extraRef.isvalid()); + + extraRef = nullptr; + + ok = manager->LoadModules(configs, server, true); + VERIFY(ok); + VERIFY(manager->GetNumLoadedModules() == 1); + VERIFY(server->GetGameFeatures().Size() == baseFeatureCount + 1); + + manager->UnloadModules(server); + VERIFY(manager->GetNumLoadedModules() == 0); + VERIFY(server->GetGameFeatures().Size() == baseFeatureCount); + } + + { + Util::Array configs; + Game::RuntimeModuleConfig missing; + missing.name = "does_not_exist"; + missing.enabled = true; + missing.required = false; + configs.Append(missing); + + bool ok = manager->LoadModules(configs, server, false); + VERIFY(ok); + VERIFY(manager->GetNumLoadedModules() == 0); + } + + { + Util::Array configs; + Game::RuntimeModuleConfig missing; + missing.name = "does_not_exist"; + missing.enabled = true; + missing.required = true; + configs.Append(missing); + + bool ok = manager->LoadModules(configs, server, false); + VERIFY(!ok); + VERIFY(manager->GetNumLoadedModules() == 0); + } + + { + Util::Array configs; + Game::RuntimeModuleConfig badExport; + badExport.name = "testruntimemodulebadexports"; + badExport.enabled = true; + badExport.required = false; + configs.Append(badExport); + + bool ok = manager->LoadModules(configs, server, false); + VERIFY(ok); + VERIFY(manager->GetNumLoadedModules() == 0); + } + + { + Util::Array configs; + Game::RuntimeModuleConfig badAbi; + badAbi.name = "testruntimemodulebadabi"; + badAbi.enabled = true; + badAbi.required = false; + configs.Append(badAbi); + + bool ok = manager->LoadModules(configs, server, false); + VERIFY(ok); + VERIFY(manager->GetNumLoadedModules() == 0); + } + + manager = nullptr; +} + +} // namespace Test diff --git a/tests/testruntimeloader/runtimemoduletest.h b/tests/testruntimeloader/runtimemoduletest.h new file mode 100644 index 0000000000..06b2aae510 --- /dev/null +++ b/tests/testruntimeloader/runtimemoduletest.h @@ -0,0 +1,21 @@ +#pragma once +//------------------------------------------------------------------------------ +/** + @class Test::RuntimeModuleLoaderTest + + Dedicated tests for runtime module loading behavior. + + (C) 2026 Individual contributors, see AUTHORS file +*/ +#include "testbase/testcase.h" + +namespace Test +{ +class RuntimeModuleLoaderTest : public TestCase +{ + __DeclareClass(RuntimeModuleLoaderTest); +public: + virtual void Run(); +}; +} +//------------------------------------------------------------------------------ diff --git a/tests/testruntimemodule/CMakeLists.txt b/tests/testruntimemodule/CMakeLists.txt new file mode 100644 index 0000000000..3a7816dcd9 --- /dev/null +++ b/tests/testruntimemodule/CMakeLists.txt @@ -0,0 +1,6 @@ +nebula_begin_shared_module(testruntimemodule) + fips_files( + runtimemodulefeature.cc + ) + fips_deps(foundation application) +nebula_end_shared_module() diff --git a/tests/testruntimemodule/runtimemodulefeature.cc b/tests/testruntimemodule/runtimemodulefeature.cc new file mode 100644 index 0000000000..0ad00566f3 --- /dev/null +++ b/tests/testruntimemodule/runtimemodulefeature.cc @@ -0,0 +1,77 @@ +//------------------------------------------------------------------------------ +// runtimemodulefeature.cc +// (C) 2026 Individual contributors, see AUTHORS file +//------------------------------------------------------------------------------ +#include "stdneb.h" +#include "core/factory.h" +#include "game/featureunit.h" +#include "game/moduleinterface.h" +#include + +namespace TestRuntimeModule +{ + +class RuntimeModuleFeature : public Game::FeatureUnit +{ + __DeclareClass(RuntimeModuleFeature) +public: + virtual void OnAttach() override + { + Game::FeatureUnit::OnAttach(); + std::fprintf(stdout, "TestRuntimeModule: RuntimeModuleFeature attached\n"); + } + + virtual void OnActivate() override + { + Game::FeatureUnit::OnActivate(); + std::fprintf(stdout, "TestRuntimeModule: RuntimeModuleFeature activated\n"); + } + + virtual void OnDeactivate() override + { + std::fprintf(stdout, "TestRuntimeModule: RuntimeModuleFeature deactivated\n"); + Game::FeatureUnit::OnDeactivate(); + } + + virtual void OnRemove() override + { + std::fprintf(stdout, "TestRuntimeModule: RuntimeModuleFeature removed\n"); + Game::FeatureUnit::OnRemove(); + } +}; + +__ImplementClass(TestRuntimeModule::RuntimeModuleFeature, 'TRMF', Game::FeatureUnit); + +} // namespace TestRuntimeModule + +#if __WIN32__ +#define NEBULA_MODULE_EXPORT extern "C" __declspec(dllexport) +#else +#define NEBULA_MODULE_EXPORT extern "C" __attribute__((visibility("default"))) +#endif + +NEBULA_MODULE_EXPORT int +NebulaModuleGetDescriptor(NebulaModuleDescriptor* outDescriptor) +{ + if (outDescriptor == nullptr) + return 0; + + outDescriptor->abiVersion = NEBULA_MODULE_ABI_VERSION; + outDescriptor->name = "testruntimemodule"; + outDescriptor->version = "0.1.0"; + outDescriptor->flags = 0; + return 1; +} + +NEBULA_MODULE_EXPORT void* +NebulaModuleCreateFeature() +{ + return Core::Factory::Instance()->Create(TestRuntimeModule::RuntimeModuleFeature::RTTI.GetName()); +} + +NEBULA_MODULE_EXPORT void +NebulaModuleDestroyFeature(void* feature) +{ + // Feature instances are currently managed by Nebula refcounting via Ptr. + (void)feature; +} diff --git a/tests/testruntimemodulebadabi/CMakeLists.txt b/tests/testruntimemodulebadabi/CMakeLists.txt new file mode 100644 index 0000000000..90708ce468 --- /dev/null +++ b/tests/testruntimemodulebadabi/CMakeLists.txt @@ -0,0 +1,6 @@ +nebula_begin_shared_module(testruntimemodulebadabi) + fips_files( + badabimodule.cc + ) + fips_deps(foundation application) +nebula_end_shared_module() diff --git a/tests/testruntimemodulebadabi/badabimodule.cc b/tests/testruntimemodulebadabi/badabimodule.cc new file mode 100644 index 0000000000..9b28d244a4 --- /dev/null +++ b/tests/testruntimemodulebadabi/badabimodule.cc @@ -0,0 +1,37 @@ +//------------------------------------------------------------------------------ +// badabimodule.cc +// (C) 2026 Individual contributors, see AUTHORS file +//------------------------------------------------------------------------------ +#include "stdneb.h" +#include "game/moduleinterface.h" + +#if __WIN32__ +#define NEBULA_MODULE_EXPORT extern "C" __declspec(dllexport) +#else +#define NEBULA_MODULE_EXPORT extern "C" __attribute__((visibility("default"))) +#endif + +NEBULA_MODULE_EXPORT int +NebulaModuleGetDescriptor(NebulaModuleDescriptor* outDescriptor) +{ + if (outDescriptor == nullptr) + return 0; + + outDescriptor->abiVersion = NEBULA_MODULE_ABI_VERSION + 42; + outDescriptor->name = "testruntimemodulebadabi"; + outDescriptor->version = "0.1.0"; + outDescriptor->flags = 0; + return 1; +} + +NEBULA_MODULE_EXPORT void* +NebulaModuleCreateFeature() +{ + return nullptr; +} + +NEBULA_MODULE_EXPORT void +NebulaModuleDestroyFeature(void* feature) +{ + (void)feature; +} diff --git a/tests/testruntimemodulebadexports/CMakeLists.txt b/tests/testruntimemodulebadexports/CMakeLists.txt new file mode 100644 index 0000000000..fdb6e64146 --- /dev/null +++ b/tests/testruntimemodulebadexports/CMakeLists.txt @@ -0,0 +1,6 @@ +nebula_begin_shared_module(testruntimemodulebadexports) + fips_files( + badexportsmodule.cc + ) + fips_deps(foundation application) +nebula_end_shared_module() diff --git a/tests/testruntimemodulebadexports/badexportsmodule.cc b/tests/testruntimemodulebadexports/badexportsmodule.cc new file mode 100644 index 0000000000..88842582dc --- /dev/null +++ b/tests/testruntimemodulebadexports/badexportsmodule.cc @@ -0,0 +1,25 @@ +//------------------------------------------------------------------------------ +// badexportsmodule.cc +// (C) 2026 Individual contributors, see AUTHORS file +//------------------------------------------------------------------------------ +#include "stdneb.h" +#include "game/moduleinterface.h" + +#if __WIN32__ +#define NEBULA_MODULE_EXPORT extern "C" __declspec(dllexport) +#else +#define NEBULA_MODULE_EXPORT extern "C" __attribute__((visibility("default"))) +#endif + +NEBULA_MODULE_EXPORT int +NebulaModuleGetDescriptor(NebulaModuleDescriptor* outDescriptor) +{ + if (outDescriptor == nullptr) + return 0; + + outDescriptor->abiVersion = NEBULA_MODULE_ABI_VERSION; + outDescriptor->name = "testruntimemodulebadexports"; + outDescriptor->version = "0.1.0"; + outDescriptor->flags = 0; + return 1; +} diff --git a/toolkit/editor/CMakeLists.txt b/toolkit/editor/CMakeLists.txt index d373c9344f..391fffb447 100644 --- a/toolkit/editor/CMakeLists.txt +++ b/toolkit/editor/CMakeLists.txt @@ -163,5 +163,5 @@ fips_dir(editor) pathconverter.cc pathconverter.h ) -fips_deps(foundation application graphicsfeature physicsfeature navigationfeature audio toolkit-common toolkitutil) +fips_deps(foundation application graphicsfeature physicsfeature audio toolkit-common toolkitutil) nebula_end_module() diff --git a/toolkit/editor/editor/editor.cc b/toolkit/editor/editor/editor.cc index 4388675485..db3564adb2 100644 --- a/toolkit/editor/editor/editor.cc +++ b/toolkit/editor/editor/editor.cc @@ -20,6 +20,7 @@ #include "tools/pathconverter.h" #include "io/assignregistry.h" #include "tools/livebatcher.h" +#include "editor/ui/windows/navigation.h" #include "game/editorstate.h" @@ -54,6 +55,10 @@ Create() IO::AssignRegistry::Instance()->SetAssign(IO::Assign("int", projectInfo.GetAttr("IntermediateDir"))); IO::IoServer::Instance()->CreateDirectory("int:"); + // Load optional runtime hooks up-front so tool windows do not trigger + // first-use loading in the middle of interaction. + Presentation::EnsureNavigationUiHookLoaded(); + LiveBatcher::Setup(); Game::TimeSource* gameTimeSource = Game::Time::GetTimeSource(TIMESOURCE_GAMEPLAY); diff --git a/toolkit/editor/editor/ui/windows/navigation.cc b/toolkit/editor/editor/ui/windows/navigation.cc index 27bc3f42d1..6c7b497afa 100644 --- a/toolkit/editor/editor/ui/windows/navigation.cc +++ b/toolkit/editor/editor/ui/windows/navigation.cc @@ -12,7 +12,47 @@ #include "physics/debugui.h" #include "editor/ui/windowserver.h" #include "editor/ui/windows/scene.h" -#include "navigationfeature/navigationfeatureunit.h" +#include "system/library.h" + +namespace +{ +using NavigationFeatureRenderUiFn = void (*)(Graphics::GraphicsEntityId camera); + +NavigationFeatureRenderUiFn +LoadNavigationFeatureRenderUi() +{ + static NavigationFeatureRenderUiFn renderUi = nullptr; + static Base::Library* library = nullptr; + static bool attemptedLoad = false; + if (attemptedLoad) + return renderUi; + + attemptedLoad = true; + + Util::String libraryFile; +#if __WIN32__ + libraryFile = "navigationfeaturemodule.dll"; +#else + libraryFile = "libnavigationfeaturemodule.so"; +#endif + + Util::String libraryPath = Util::String::Sprintf("%s/%s", NEBULA_BINARY_FOLDER, libraryFile.AsCharPtr()); + library = new System::Library(); + library->SetPath(IO::URI(libraryPath)); + if (!library->Load()) + return nullptr; + + renderUi = reinterpret_cast(library->GetExport("NebulaNavigationFeatureRenderUI")); + if (renderUi == nullptr) + { + library->Close(); + delete library; + library = nullptr; + } + + return renderUi; +} +} using namespace Editor; @@ -20,6 +60,15 @@ namespace Presentation { __ImplementClass(Presentation::Navigation, 'PtNa', Presentation::BaseWindow); +//------------------------------------------------------------------------------ +/** +*/ +bool +EnsureNavigationUiHookLoaded() +{ + return LoadNavigationFeatureRenderUi() != nullptr; +} + //------------------------------------------------------------------------------ /** */ @@ -54,7 +103,25 @@ Navigation::Run(SaveMode save) { return; } - NavigationFeature::RenderUI(this->defaultCamera); + + NavigationFeatureRenderUiFn renderUi = LoadNavigationFeatureRenderUi(); + if (renderUi != nullptr) + { + renderUi(this->defaultCamera); + this->missingHookWarningShown = false; + } + else + { + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Navigation runtime module is unavailable."); + ImGui::TextUnformatted("Expected module: libnavigationfeaturemodule.so in deploy folder."); + + if (!this->missingHookWarningShown) + { + n_warning("Editor Navigation window: failed to load runtime hook from navigationfeaturemodule."); + this->missingHookWarningShown = true; + } + } } } // namespace Presentation diff --git a/toolkit/editor/editor/ui/windows/navigation.h b/toolkit/editor/editor/ui/windows/navigation.h index 27e5aa3610..3940bd073b 100644 --- a/toolkit/editor/editor/ui/windows/navigation.h +++ b/toolkit/editor/editor/ui/windows/navigation.h @@ -14,6 +14,9 @@ namespace Presentation { +/// Ensure navigation runtime UI hook is loaded and ready. +bool EnsureNavigationUiHookLoaded(); + class Navigation: public BaseWindow { __DeclareClass(Navigation) @@ -25,6 +28,7 @@ class Navigation: public BaseWindow private: Graphics::GraphicsEntityId defaultCamera; + bool missingHookWarningShown = false; }; __RegisterClass(Navigation) diff --git a/toolkit/levelviewer/levelviewerapplication.cc b/toolkit/levelviewer/levelviewerapplication.cc index 7e1d559780..57d61293eb 100644 --- a/toolkit/levelviewer/levelviewerapplication.cc +++ b/toolkit/levelviewer/levelviewerapplication.cc @@ -111,6 +111,38 @@ LevelViewerGameStateApplication::SetupStateHandlers() } +//------------------------------------------------------------------------------ +/** +*/ +void +LevelViewerGameStateApplication::SetupRuntimeModulesFromCmdLineArgs() +{ + GameApplication::SetupRuntimeModulesFromCmdLineArgs(); + + Util::String moduleName = "navigationfeaturemodule"; + bool found = false; + for (IndexT i = 0; i < this->runtimeModuleConfigs.Size(); i++) + { + Util::String candidate = this->runtimeModuleConfigs[i].name; + candidate.ToLower(); + if (candidate == moduleName) + { + this->runtimeModuleConfigs[i].enabled = true; + found = true; + break; + } + } + + if (!found) + { + Game::RuntimeModuleConfig config; + config.name = moduleName; + config.enabled = true; + config.required = false; + this->runtimeModuleConfigs.Append(config); + } +} + //------------------------------------------------------------------------------ /** */ @@ -158,15 +190,12 @@ LevelViewerGameStateApplication::SetupGameFeatures() // create post effect this->postEffectFeature = PostEffect::PostEffectFeatureUnit::Create(); - this->navigationFeature = Navigation::NavigationFeatureUnit::Create(); - // attach features this->gameServer->AttachGameFeature(this->baseGameFeature.upcast()); this->gameServer->AttachGameFeature(this->effectFeature.cast()); this->gameServer->AttachGameFeature(this->graphicsFeature.cast()); this->gameServer->AttachGameFeature(this->scriptingFeature.upcast()); this->gameServer->AttachGameFeature(this->physicsFeature.upcast()); - this->gameServer->AttachGameFeature(this->navigationFeature.cast()); // setup intermediate gui this->imgui = Dynui::ImguiAddon::Create(); @@ -238,8 +267,6 @@ LevelViewerGameStateApplication::CleanupGameFeatures() this->imgui = 0; this->remoteClient = 0; - this->gameServer->RemoveGameFeature(this->navigationFeature.upcast()); - this->navigationFeature = 0; this->gameServer->RemoveGameFeature(this->postEffectFeature.upcast()); this->postEffectFeature = 0; this->gameServer->RemoveGameFeature(this->uiFeature.upcast()); diff --git a/toolkit/levelviewer/levelviewerapplication.h b/toolkit/levelviewer/levelviewerapplication.h index 1bd33493c8..750f01030a 100644 --- a/toolkit/levelviewer/levelviewerapplication.h +++ b/toolkit/levelviewer/levelviewerapplication.h @@ -19,7 +19,6 @@ #include "dynui/console/imguiconsolehandler.h" #include "levelviewerfactorymanager.h" #include "gamestates/viewergamestate.h" -#include "navigationfeatureunit.h" #include "inputfeature/inputfeatureunit.h" //------------------------------------------------------------------------------ @@ -51,6 +50,8 @@ class LevelViewerGameStateApplication : public App::GameApplication /// setup application state handlers virtual void SetupStateHandlers(); + /// parse startup args and ensure LevelViewer runtime defaults are set + virtual void SetupRuntimeModulesFromCmdLineArgs() override; /// setup game features virtual void SetupGameFeatures(); /// cleanup game features @@ -66,7 +67,6 @@ class LevelViewerGameStateApplication : public App::GameApplication Ptr effectFeature; Ptr uiFeature; Ptr postEffectFeature; - Ptr navigationFeature; Ptr inputFeature; Ptr viewerState; From 707f7f8aa30b22aa73f682475938eff7fef72997 Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 12 Apr 2026 19:42:20 +0200 Subject: [PATCH 02/19] - turned application and render into modules - added featuremodule for editor - made dummy streamtexturesaver --- code/addons/nflatbuffer/CMakeLists.txt | 2 +- code/addons/tinyxml/CMakeLists.txt | 1 - code/application/CMakeLists.txt | 12 ++++- code/foundation/CMakeLists.txt | 12 ++++- code/render/CMakeLists.txt | 13 +++++- .../render/coregraphics/streamtexturesaver.cc | 44 +++++++++++++++++++ toolkit/editor/CMakeLists.txt | 8 ++++ toolkit/editor/editorfeaturemodule.cc | 39 ++++++++++++++++ 8 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 code/render/coregraphics/streamtexturesaver.cc create mode 100644 toolkit/editor/editorfeaturemodule.cc diff --git a/code/addons/nflatbuffer/CMakeLists.txt b/code/addons/nflatbuffer/CMakeLists.txt index 466438a9d4..674393ef44 100644 --- a/code/addons/nflatbuffer/CMakeLists.txt +++ b/code/addons/nflatbuffer/CMakeLists.txt @@ -14,5 +14,5 @@ fips_dir(.) flatbufferinterface.h ) nebula_flatc(SYSTEM foundation/math.fbs) -fips_deps(flatbuffers foundation) +fips_deps(flatbuffers) nebula_end_lib() diff --git a/code/addons/tinyxml/CMakeLists.txt b/code/addons/tinyxml/CMakeLists.txt index fbf25e41de..1983a000c5 100644 --- a/code/addons/tinyxml/CMakeLists.txt +++ b/code/addons/tinyxml/CMakeLists.txt @@ -2,5 +2,4 @@ fips_begin_lib(tinyxml) fips_ide_group(addons) target_include_directories(tinyxml PRIVATE ${CODE_ROOT}/foundation) fips_files(tinystr.cc tinystr.h tinyxml.cc tinyxml.h tinyxmlerror.cc tinyxmlparser.cc) -fips_deps(foundation) fips_end_lib() \ No newline at end of file diff --git a/code/application/CMakeLists.txt b/code/application/CMakeLists.txt index 381396f5bc..c972fc75be 100644 --- a/code/application/CMakeLists.txt +++ b/code/application/CMakeLists.txt @@ -2,7 +2,11 @@ # Game #------------------------------------------------------------------------------- -nebula_begin_module(application) +if(FIPS_WINDOWS) + nebula_begin_module(application) +else() + nebula_begin_shared_module(application) +endif() nebula_add_blueprints() target_include_directories(application PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) fips_deps(foundation resource memdb imgui input options) @@ -94,7 +98,11 @@ nebula_begin_module(application) fips_dir(.) nebula_flatc(SYSTEM game/level.fbs) -nebula_end_module() +if(FIPS_WINDOWS) + nebula_end_module() +else() + nebula_end_shared_module() +endif() if(FIPS_WINDOWS) target_link_options(application PUBLIC "/WHOLEARCHIVE:application") diff --git a/code/foundation/CMakeLists.txt b/code/foundation/CMakeLists.txt index 1723106163..ea847ae017 100644 --- a/code/foundation/CMakeLists.txt +++ b/code/foundation/CMakeLists.txt @@ -12,7 +12,11 @@ if (EXISTS "${NROOT}/syswork/export.zip") set_target_properties(system_resources-res PROPERTIES FOLDER Resources) endif() -nebula_begin_module(foundation) +if(FIPS_WINDOWS) + nebula_begin_module(foundation) +else() + nebula_begin_shared_module(foundation) +endif() target_precompile_headers(foundation PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/foundation/stdneb.h ${CMAKE_CURRENT_SOURCE_DIR}/core/ptr.h ${CMAKE_CURRENT_SOURCE_DIR}/core/rtti.h ${CMAKE_CURRENT_SOURCE_DIR}/core/refcounted.h ${CMAKE_CURRENT_SOURCE_DIR}/math/mat4.h ${CMAKE_CURRENT_SOURCE_DIR}/io/binaryreader.h) if(FIPS_LINUX) @@ -703,6 +707,10 @@ nebula_begin_module(foundation) fips_dir(threading/gcc GROUP "threading/gcc") fips_files(gccinterlocked.cc) endif() -nebula_end_module() +if(FIPS_WINDOWS) + nebula_end_module() +else() + nebula_end_shared_module() +endif() #FIXME cant add python include dir globally as it clashes with antlr4 target_include_directories(foundation PUBLIC ${PYTHON_INCLUDE_DIRS}) diff --git a/code/render/CMakeLists.txt b/code/render/CMakeLists.txt index 1b8cd691f0..4c2e539cec 100644 --- a/code/render/CMakeLists.txt +++ b/code/render/CMakeLists.txt @@ -8,7 +8,11 @@ FIND_PROGRAM(GPULANGC NO_DEFAULT_PATH ) -nebula_begin_module(render) +if(FIPS_WINDOWS) + nebula_begin_module(render) +else() + nebula_begin_shared_module(render) +endif() add_nebula_shaders() set(target_has_shaders 1) target_include_directories(render PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} ${CODE_ROOT}/addons ${CODE_ROOT}/render) @@ -186,6 +190,7 @@ ENDIF() shaperenderer.cc shaperenderer.h sparsebuffer.h + streamtexturesaver.cc streamtexturesaver.h swapchain.h textelement.cc @@ -664,6 +669,10 @@ ENDIF() ) -nebula_end_module() +if(FIPS_WINDOWS) + nebula_end_module() +else() + nebula_end_shared_module() +endif() diff --git a/code/render/coregraphics/streamtexturesaver.cc b/code/render/coregraphics/streamtexturesaver.cc new file mode 100644 index 0000000000..edaf3feabc --- /dev/null +++ b/code/render/coregraphics/streamtexturesaver.cc @@ -0,0 +1,44 @@ +//------------------------------------------------------------------------------ +// streamtexturesaver.cc +// (C) 2026 Individual contributors, see AUTHORS file +//------------------------------------------------------------------------------ +#include "render/stdneb.h" +#include "coregraphics/streamtexturesaver.h" +#include "io/ioserver.h" + +namespace CoreGraphics +{ + +//------------------------------------------------------------------------------ +/** +*/ +bool +SaveTexture(const Resources::ResourceId& id, const IO::URI& path, IndexT mip, CoreGraphics::ImageFileFormat::Code code) +{ + Ptr stream = IO::IoServer::Instance()->CreateStream(path); + stream->SetAccessMode(IO::Stream::WriteAccess); + if (!stream->Open()) + { + return false; + } + + const bool result = SaveTexture(id, stream, mip, code); + stream->Close(); + return result; +} + +//------------------------------------------------------------------------------ +/** + Texture export is currently not implemented for Vulkan in this branch. +*/ +bool +SaveTexture(const Resources::ResourceId& id, const Ptr& stream, IndexT mip, CoreGraphics::ImageFileFormat::Code code) +{ + (void)id; + (void)stream; + (void)mip; + (void)code; + return false; +} + +} // namespace CoreGraphics diff --git a/toolkit/editor/CMakeLists.txt b/toolkit/editor/CMakeLists.txt index 391fffb447..e529a1aa79 100644 --- a/toolkit/editor/CMakeLists.txt +++ b/toolkit/editor/CMakeLists.txt @@ -165,3 +165,11 @@ fips_dir(editor) ) fips_deps(foundation application graphicsfeature physicsfeature audio toolkit-common toolkitutil) nebula_end_module() + +nebula_begin_shared_module(editorfeaturemodule) +target_include_directories(editorfeaturemodule PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +fips_files( + editorfeaturemodule.cc +) +fips_deps(editor application) +nebula_end_shared_module() diff --git a/toolkit/editor/editorfeaturemodule.cc b/toolkit/editor/editorfeaturemodule.cc new file mode 100644 index 0000000000..4f342eeffc --- /dev/null +++ b/toolkit/editor/editorfeaturemodule.cc @@ -0,0 +1,39 @@ +//------------------------------------------------------------------------------ +// editorfeaturemodule.cc +// (C) 2026 Individual contributors, see AUTHORS file +//------------------------------------------------------------------------------ +#include "application/stdneb.h" +#include "core/factory.h" +#include "game/moduleinterface.h" +#include "editorfeature/editorfeatureunit.h" + +#if __WIN32__ +#define NEBULA_MODULE_EXPORT extern "C" __declspec(dllexport) +#else +#define NEBULA_MODULE_EXPORT extern "C" __attribute__((visibility("default"))) +#endif + +NEBULA_MODULE_EXPORT int +NebulaModuleGetDescriptor(NebulaModuleDescriptor* outDescriptor) +{ + if (outDescriptor == nullptr) + return 0; + + outDescriptor->abiVersion = NEBULA_MODULE_ABI_VERSION; + outDescriptor->name = "editorfeaturemodule"; + outDescriptor->version = "0.1.0"; + outDescriptor->flags = 0; + return 1; +} + +NEBULA_MODULE_EXPORT void* +NebulaModuleCreateFeature() +{ + return Core::Factory::Instance()->Create(EditorFeature::EditorFeatureUnit::RTTI.GetName()); +} + +NEBULA_MODULE_EXPORT void +NebulaModuleDestroyFeature(void* feature) +{ + (void)feature; +} From ee16ec4fb76bfb8a4567ef23be163e9ba4680252 Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 19 Apr 2026 01:54:19 +0200 Subject: [PATCH 03/19] - rudimentary hotreloading that recreates all state --- code/addons/dynui/imguicontext.cc | 33 +- code/addons/memdb/attributeregistry.cc | 15 +- code/addons/memdb/attributeregistry.h | 90 ++++- .../physicsfeature/physicsfeatureunit.cc | 16 +- code/addons/scripting/python/pythonserver.cc | 38 +- code/addons/scripting/python/pythonserver.h | 3 + code/addons/scripting/scriptserver.h | 5 + code/application/appgame/gameapplication.cc | 17 + code/application/appgame/gameapplication.h | 3 + .../basegamefeature/levelparser.cc | 78 ++-- .../basegamefeature/managers/timemanager.cc | 45 +++ .../basegamefeature/managers/timemanager.h | 6 + code/application/game/component.h | 9 + code/application/game/componentinspection.cc | 15 + code/application/game/componentinspection.h | 1 + .../game/componentserialization.cc | 16 + .../application/game/componentserialization.h | 1 + code/application/game/editorstate.h | 7 + code/application/game/featureunit.cc | 9 +- code/application/game/featureunit.h | 3 + code/application/game/frameevent.cc | 37 +- code/application/game/frameevent.h | 4 + code/application/game/modulemanager.cc | 93 ++++- code/application/game/modulemanager.h | 10 +- code/application/game/world.cc | 104 +++++- code/foundation/core/factory.cc | 54 +++ code/foundation/core/factory.h | 6 + code/foundation/core/rtti.cc | 21 ++ code/foundation/core/rtti.h | 2 + code/foundation/io/jsonreader.h | 2 +- code/physics/physics/physxstate.cc | 18 +- code/physics/physicsinterface.cc | 13 + code/physics/physicsinterface.h | 3 + code/render/frame/framesubpassbatch.cc | 7 +- code/render/graphics/cameracontext.cc | 39 +- code/render/graphics/graphicsserver.cc | 63 +++- code/render/graphics/graphicsserver.h | 4 + code/render/models/modelcontext.cc | 344 +++++++++++------- code/render/models/modelcontext.h | 45 ++- toolkit/editor/CMakeLists.txt | 6 +- .../editor/editor/bindings/editorbindings.cc | 56 ++- toolkit/editor/editor/editor.cc | 166 ++++++++- toolkit/editor/editor/editor.h | 13 + toolkit/editor/editor/entityloader.cc | 8 + toolkit/editor/editor/tools/camera.cc | 16 + toolkit/editor/editor/tools/camera.h | 2 +- toolkit/editor/editor/ui/modules/viewport.cc | 12 + toolkit/editor/editor/ui/modules/viewport.h | 8 +- toolkit/editor/editor/ui/uimanager.cc | 19 + toolkit/editor/editor/ui/windows/toolbar.cc | 13 + toolkit/editor/editor/ui/windowserver.h | 3 + .../editor/editorfeature/editorfeatureunit.cc | 57 ++- .../editor/editorfeature/editorfeatureunit.h | 4 + toolkit/editor/editorfeaturemodule.cc | 26 ++ 54 files changed, 1430 insertions(+), 258 deletions(-) diff --git a/code/addons/dynui/imguicontext.cc b/code/addons/dynui/imguicontext.cc index a691278836..0d10fcdaa1 100644 --- a/code/addons/dynui/imguicontext.cc +++ b/code/addons/dynui/imguicontext.cc @@ -100,9 +100,20 @@ ImguiDrawFunction(const CoreGraphics::CmdBufferId cmdBuf, const Math::rectangle< //const Ptr& vboLock = renderer->GetVertexBufferLock(); //const Ptr& iboLock = renderer->GetIndexBufferLock(); IndexT currentBuffer = CoreGraphics::GetBufferedFrameIndex(); + if (currentBuffer < 0 || currentBuffer >= state.vbos.Size() || currentBuffer >= state.ibos.Size()) + { + n_warning("ImguiDrawFunction(): buffer index %d out of bounds (vbos=%d, ibos=%d), skipping UI draw", currentBuffer, state.vbos.Size(), state.ibos.Size()); + return; + } BufferId vbo = state.vbos[currentBuffer]; BufferId ibo = state.ibos[currentBuffer]; + if (vbo == CoreGraphics::InvalidBufferId || ibo == CoreGraphics::InvalidBufferId) + { + n_warning("ImguiDrawFunction(): invalid UI buffers for buffered frame %d, skipping UI draw", currentBuffer); + return; + } + N_CMD_SCOPE(cmdBuf, NEBULA_MARKER_GRAPHICS, "ImGUI"); // apply shader @@ -497,8 +508,13 @@ ImguiContext::Create() ImguiDrawFunction(cmdBuf, viewport, ImGui::GetDrawData()); } IndexT currentBuffer = CoreGraphics::GetBufferedFrameIndex(); - CoreGraphics::BufferFlush(state.vbos[currentBuffer]); - CoreGraphics::BufferFlush(state.ibos[currentBuffer]); + if (currentBuffer >= 0 && currentBuffer < state.vbos.Size() && currentBuffer < state.ibos.Size()) + { + if (state.vbos[currentBuffer] != CoreGraphics::InvalidBufferId) + CoreGraphics::BufferFlush(state.vbos[currentBuffer]); + if (state.ibos[currentBuffer] != CoreGraphics::InvalidBufferId) + CoreGraphics::BufferFlush(state.ibos[currentBuffer]); + } }); } else @@ -550,8 +566,17 @@ ImguiContext::Create() ImguiDrawFunction(cmdBuf, viewport, ImGui::GetDrawData()); } IndexT currentBuffer = CoreGraphics::GetBufferedFrameIndex(); - CoreGraphics::BufferFlush(state.vbos[currentBuffer]); - CoreGraphics::BufferFlush(state.ibos[currentBuffer]); + if (currentBuffer >= 0 && currentBuffer < state.vbos.Size() && currentBuffer < state.ibos.Size()) + { + if (state.vbos[currentBuffer] != CoreGraphics::InvalidBufferId) + { + CoreGraphics::BufferFlush(state.vbos[currentBuffer]); + } + if (state.ibos[currentBuffer] != CoreGraphics::InvalidBufferId) + { + CoreGraphics::BufferFlush(state.ibos[currentBuffer]); + } + } }); } diff --git a/code/addons/memdb/attributeregistry.cc b/code/addons/memdb/attributeregistry.cc index 7610fdb187..db6c041851 100644 --- a/code/addons/memdb/attributeregistry.cc +++ b/code/addons/memdb/attributeregistry.cc @@ -11,8 +11,7 @@ AttributeRegistry* AttributeRegistry::Singleton = 0; //------------------------------------------------------------------------------ /** - The registry's constructor is called by the Instance() method, and - nobody else. + This creates a singleton if needed, unlike the macro */ AttributeRegistry* AttributeRegistry::Instance() @@ -27,9 +26,15 @@ AttributeRegistry::Instance() //------------------------------------------------------------------------------ /** - This static method is used to destroy the registry object and should be - called right before the main function exits. It will make sure that - no accidential memory leaks are reported by the debug heap. +*/ +bool +AttributeRegistry::HasInstance() +{ + return Singleton != nullptr; +} + +//------------------------------------------------------------------------------ +/** */ void AttributeRegistry::Destroy() diff --git a/code/addons/memdb/attributeregistry.h b/code/addons/memdb/attributeregistry.h index 16333abea5..0898ff4f64 100644 --- a/code/addons/memdb/attributeregistry.h +++ b/code/addons/memdb/attributeregistry.h @@ -16,6 +16,9 @@ namespace MemDb class AttributeRegistry { public: + /// return true if registry singleton has been created + static bool HasInstance(); + /// register a type (templated) template static AttributeId Register(Util::StringAtom name, TYPE defaultValue, uint32_t flags = 0); @@ -26,6 +29,8 @@ class AttributeRegistry /// Check if a type is registered template static bool IsRegistered(); + /// Check if an attribute id is currently registered + static bool IsRegistered(AttributeId descriptor); /// register a POD, mem-copyable type static AttributeId Register(Util::StringAtom name, SizeT typeSize, void const* defaultValue, uint32_t flags = 0); @@ -42,6 +47,8 @@ class AttributeRegistry static void const* const DefaultValue(AttributeId descriptor); /// get an array of all attributes static Util::FixedArray const& GetAllAttributes(); + /// unregister an attribute by id (safe no-op if missing) + static void Unregister(AttributeId descriptor); private: static AttributeRegistry* Instance(); @@ -88,6 +95,26 @@ AttributeRegistry::IsRegistered() return false; } +//------------------------------------------------------------------------------ +/** +*/ +inline bool +AttributeRegistry::IsRegistered(AttributeId descriptor) +{ + if (!AttributeRegistry::HasInstance()) + { + return false; + } + + auto* reg = Instance(); + if (descriptor.id < 0 || descriptor.id >= reg->componentDescriptions.Size()) + { + return false; + } + + return reg->componentDescriptions[descriptor.id] != nullptr; +} + //------------------------------------------------------------------------------ /** TYPE must be trivially copyable and destructible, and also standard layout. @@ -228,16 +255,13 @@ AttributeRegistry::GetAttributeId(Util::StringAtom name) inline Attribute* AttributeRegistry::GetAttribute(AttributeId descriptor) { - auto* reg = Instance(); - if (descriptor.id >= 0 && descriptor.id < reg->componentDescriptions.Size()) + if (!AttributeRegistry::IsRegistered(descriptor)) { - n_assert2( - reg->componentDescriptions[descriptor.id] != nullptr, "Trying to get description of attribute that is not registered!" - ); - return reg->componentDescriptions[descriptor.id]; + return nullptr; } - - return nullptr; + + auto* reg = Instance(); + return reg->componentDescriptions[descriptor.id]; } //------------------------------------------------------------------------------ @@ -246,8 +270,12 @@ AttributeRegistry::GetAttribute(AttributeId descriptor) inline SizeT AttributeRegistry::TypeSize(AttributeId descriptor) { + if (!AttributeRegistry::IsRegistered(descriptor)) + { + return 0; + } + auto* reg = Instance(); - n_assert(descriptor.id >= 0 && descriptor.id < reg->componentDescriptions.Size()); return reg->componentDescriptions[descriptor.id]->typeSize; } @@ -257,8 +285,12 @@ AttributeRegistry::TypeSize(AttributeId descriptor) inline uint32_t AttributeRegistry::Flags(AttributeId descriptor) { + if (!AttributeRegistry::IsRegistered(descriptor)) + { + return 0; + } + auto* reg = Instance(); - n_assert(descriptor.id >= 0 && descriptor.id < reg->componentDescriptions.Size()); return reg->componentDescriptions[descriptor.id]->externalFlags; } @@ -268,8 +300,12 @@ AttributeRegistry::Flags(AttributeId descriptor) inline void const* const AttributeRegistry::DefaultValue(AttributeId descriptor) { + if (!AttributeRegistry::IsRegistered(descriptor)) + { + return nullptr; + } + auto* reg = Instance(); - n_assert(descriptor.id >= 0 && descriptor.id < reg->componentDescriptions.Size()); return reg->componentDescriptions[descriptor.id]->defVal; } @@ -283,4 +319,36 @@ AttributeRegistry::GetAllAttributes() return reg->componentDescriptions; } +//------------------------------------------------------------------------------ +/** +*/ +inline void +AttributeRegistry::Unregister(AttributeId descriptor) +{ + if (!AttributeRegistry::HasInstance()) + { + return; + } + + auto* reg = Instance(); + if (descriptor.id < 0 || descriptor.id >= reg->componentDescriptions.Size()) + { + return; + } + + Attribute* desc = reg->componentDescriptions[descriptor.id]; + if (desc == nullptr) + { + return; + } + + if (reg->registry.Contains(desc->name)) + { + reg->registry.Erase(desc->name); + } + + delete desc; + reg->componentDescriptions[descriptor.id] = nullptr; +} + } // namespace MemDb diff --git a/code/addons/physicsfeature/physicsfeatureunit.cc b/code/addons/physicsfeature/physicsfeatureunit.cc index 3a5f1e96cc..8d5a553a19 100644 --- a/code/addons/physicsfeature/physicsfeatureunit.cc +++ b/code/addons/physicsfeature/physicsfeatureunit.cc @@ -171,7 +171,11 @@ PhysicsFeatureUnit::OnBeginFrame() for (auto const& scene : this->physicsWorlds) { - Physics::EndSimulating(scene.Value()); + IndexT sceneId = scene.Value(); + if (Physics::IsSceneActive(sceneId)) + { + Physics::EndSimulating(sceneId); + } } simulating = false; #endif @@ -186,11 +190,17 @@ PhysicsFeatureUnit::OnDecay() FeatureUnit::OnDecay(); #if USE_SYNC_UPDATE == 0 Game::TimeSource* const time = Game::Time::GetTimeSource(TIMESOURCE_PHYSICS); + bool startedSimulation = false; for (auto const& scene : this->physicsWorlds) { - Physics::BeginSimulating(time->frameTime, scene.Value()); + IndexT sceneId = scene.Value(); + if (Physics::IsSceneActive(sceneId)) + { + Physics::BeginSimulating(time->frameTime, sceneId); + startedSimulation = true; + } } - simulating = true; + simulating = startedSimulation; #endif } diff --git a/code/addons/scripting/python/pythonserver.cc b/code/addons/scripting/python/pythonserver.cc index 8c309c0c60..b391bd8138 100644 --- a/code/addons/scripting/python/pythonserver.cc +++ b/code/addons/scripting/python/pythonserver.cc @@ -47,6 +47,8 @@ PythonServer::~PythonServer() bool PythonServer::Open() { + Threading::CriticalScope lock(&this->pythonLock); + //FIXME fugly as f... static Util::String linebuffer; static Util::String errorbuffer; @@ -59,7 +61,11 @@ PythonServer::Open() { init(); } - Py_Initialize(); + if (!this->pythonInitialized) + { + Py_Initialize(); + this->pythonInitialized = true; + } nanobind::detail::init(nullptr); tyti::pylog::redirect_stdout([](const char* msg) @@ -99,13 +105,19 @@ PythonServer::Open() void PythonServer::Close() { + Threading::CriticalScope lock(&this->pythonLock); + n_assert(this->IsOpen()); // this will unregister all commands ScriptServer::Close(); - // close python - Py_Finalize(); + // NOTE: Do NOT call Py_Finalize() here. + // Python cannot be safely re-initialized after finalization in the same process. + // During runtime module reloads, Close() can be called multiple times, and + // Py_Finalize() would corrupt interpreter state for subsequent Py_Initialize() calls. + // Only finalize at true process shutdown. + // Py_Finalize(); } @@ -118,7 +130,7 @@ PythonServer::AddModulePath(const IO::URI & folder) { n_assert(this->IsOpen()); Util::String exec; - exec.Format("sys.path.insert(0,\"%s\")\n", folder.LocalPath().AsCharPtr()); + exec.Format("import sys\nsys.path.insert(0,\"%s\")\n", folder.LocalPath().AsCharPtr()); this->Eval(exec); } @@ -129,13 +141,29 @@ PythonServer::AddModulePath(const IO::URI & folder) bool PythonServer::Eval(const String& str) { + Threading::CriticalScope lock(&this->pythonLock); + n_assert(this->IsOpen()); if (!str.IsValid()) { return false; } - return 0 != PyRun_SimpleString(str.AsCharPtr()); + if (!Py_IsInitialized()) + { + n_warning("PythonServer::Eval(): Python runtime is not initialized"); + return false; + } + + PyGILState_STATE gilState = PyGILState_Ensure(); + int runResult = PyRun_SimpleStringFlags(str.AsCharPtr(), nullptr); + if (runResult != 0 && PyErr_Occurred()) + { + PyErr_Print(); + } + PyGILState_Release(gilState); + + return runResult == 0; } diff --git a/code/addons/scripting/python/pythonserver.h b/code/addons/scripting/python/pythonserver.h index c0fb9ec17b..8a9adeb3f9 100644 --- a/code/addons/scripting/python/pythonserver.h +++ b/code/addons/scripting/python/pythonserver.h @@ -9,6 +9,7 @@ (C) 2018-2020 Individual contributors, see AUTHORS file */ #include "scripting/scriptserver.h" +#include "threading/criticalsection.h" #include "util/string.h" //------------------------------------------------------------------------------ @@ -34,6 +35,8 @@ class PythonServer : public ScriptServer /// evaluate script in file bool EvalFile(const IO::URI& file); private: + Threading::CriticalSection pythonLock; + bool pythonInitialized = false; }; diff --git a/code/addons/scripting/scriptserver.h b/code/addons/scripting/scriptserver.h index 00c8f09c89..9d645112ac 100644 --- a/code/addons/scripting/scriptserver.h +++ b/code/addons/scripting/scriptserver.h @@ -83,6 +83,11 @@ void ScriptServer::RegisterModuleInit(const ScriptModuleInit& init) { initFuncs.Append(init); + + if (ScriptServer::HasInstance() && ScriptServer::Instance()->IsOpen()) + { + init(); + } } } // namespace Scripting diff --git a/code/application/appgame/gameapplication.cc b/code/application/appgame/gameapplication.cc index e064fb40c0..947a4741e3 100644 --- a/code/application/appgame/gameapplication.cc +++ b/code/application/appgame/gameapplication.cc @@ -302,6 +302,14 @@ GameApplication::StepFrame() // trigger end of frame for feature units this->gameServer->OnEndFrame(); + // Process pending module reloads after all OnEndFrame callbacks have returned. + // Must be called from application-layer code so the frame containing the reload + // request has fully completed before the module is unloaded/reloaded. + if (this->moduleManager.isvalid()) + { + this->moduleManager->ProcessPendingReloads(this->gameServer); + } + GameApplication::FrameIndex++; _stop_timer(GameApplicationFrameTimeAll); @@ -331,6 +339,15 @@ GameApplication::CleanupGameFeatures() // cleanup your features in derived class } +//------------------------------------------------------------------------------ +/** +*/ +Ptr +GameApplication::GetModuleManager() const +{ + return this->moduleManager; +} + //------------------------------------------------------------------------------ /** */ diff --git a/code/application/appgame/gameapplication.h b/code/application/appgame/gameapplication.h index c3c176ce43..372cdaf008 100644 --- a/code/application/appgame/gameapplication.h +++ b/code/application/appgame/gameapplication.h @@ -50,6 +50,9 @@ class GameApplication : public Application /// static bool IsEditorEnabled(); + /// return the module manager (may be nullptr if no runtime modules are configured) + Ptr GetModuleManager() const; + protected: /// setup game features diff --git a/code/application/basegamefeature/levelparser.cc b/code/application/basegamefeature/levelparser.cc index 8350028095..9ff80341d1 100644 --- a/code/application/basegamefeature/levelparser.cc +++ b/code/application/basegamefeature/levelparser.cc @@ -46,6 +46,14 @@ Util::Array LevelParser::LoadJsonLevel(const Ptr & reader) { auto& g2e = this->guidToEntity; + struct ScopedEntityOverrideCleanup + { + ~ScopedEntityOverrideCleanup() + { + Game::ComponentSerialization::OverrideType(Game::ComponentSerialization::ENTITY, nullptr, nullptr); + } + } cleanup; + Game::ComponentSerialization::OverrideType( // TODO: this should be a temporary object that is destroyed at end of scope, removing the override Game::ComponentSerialization::ENTITY, [&g2e](Ptr const& reader, const char* name, void* data) @@ -81,47 +89,53 @@ LevelParser::LoadJsonLevel(const Ptr & reader) this->invalidAttrs.Clear(); // load entities, setup guid->entity map - reader->SetToFirstChild(); + bool hasEntities = reader->SetToFirstChild(); this->guidToEntity.BeginBulkAdd(); - do + if (hasEntities) { - if (reader->HasAttr("sub_scene")) + do { - // 1. Load subscene consisting of multiple entities. - // 2. Group them in editor - // Maybe this can be made with a sort of hierarchical "Transform" - // component (changes to this entitys transform is propagated to - // it's children, and the children needs to have a "parent" - // component, consisting of local pos, rot, scale and a parent ID) - // 3. Tie them to the scene resource, so that if the resource is - // updated, the entities are as well - } - else // regular entity, just load normally - { - Game::Entity entity = this->LoadEntity(reader); - entities.Append(entity); - } - - } while (reader->SetToNextChild()); + if (reader->HasAttr("sub_scene")) + { + // 1. Load subscene consisting of multiple entities. + // 2. Group them in editor + // Maybe this can be made with a sort of hierarchical "Transform" + // component (changes to this entitys transform is propagated to + // it's children, and the children needs to have a "parent" + // component, consisting of local pos, rot, scale and a parent ID) + // 3. Tie them to the scene resource, so that if the resource is + // updated, the entities are as well + } + else // regular entity, just load normally + { + Game::Entity entity = this->LoadEntity(reader); + entities.Append(entity); + } + + } while (reader->SetToNextChild()); + } this->guidToEntity.EndBulkAdd(); // Load components. We do this separately since the entities and their guid // needs to be established to be able to patch from GUID to entity in component fields - reader->SetToFirstChild(); - IndexT entityIndex = 0; - do + if (hasEntities) { - if (reader->HasAttr("sub_scene")) - { - // Make sure to load all sub_scene entities data - } - else // regular entity, just load normally + reader->SetToFirstChild(); + IndexT entityIndex = 0; + do { - Game::Entity entity = entities[entityIndex++]; - this->LoadComponents(reader, entity); - } - - } while (reader->SetToNextChild()); + if (reader->HasAttr("sub_scene")) + { + // Make sure to load all sub_scene entities data + } + else // regular entity, just load normally + { + Game::Entity entity = entities[entityIndex++]; + this->LoadComponents(reader, entity); + } + + } while (reader->SetToNextChild()); + } if (!this->invalidAttrs.IsEmpty()) { diff --git a/code/application/basegamefeature/managers/timemanager.cc b/code/application/basegamefeature/managers/timemanager.cc index e42d0edf64..cb969591da 100644 --- a/code/application/basegamefeature/managers/timemanager.cc +++ b/code/application/basegamefeature/managers/timemanager.cc @@ -76,6 +76,51 @@ Time::CreateTimeSource(TimeSourceCreateInfo const& info) return reinterpret_cast(×ource); } +//------------------------------------------------------------------------------ +/** +*/ +bool +Time::HasTimeSource(uint32_t TIMESOURCE_HASH) +{ + return state->timeSourceTable.Contains(TIMESOURCE_HASH); +} + +//------------------------------------------------------------------------------ +/** +*/ +void +Time::DestroyTimeSource(uint32_t TIMESOURCE_HASH) +{ + if (!state->timeSourceTable.Contains(TIMESOURCE_HASH)) + return; + + const uint32_t removeIndex = state->timeSourceTable[TIMESOURCE_HASH]; + const uint32_t lastIndex = state->numTimeSources - 1; + + // Remove hash entry first. + state->timeSourceTable.Erase(TIMESOURCE_HASH); + + // Keep array dense by moving the last entry into the removed slot. + if (removeIndex != lastIndex) + { + state->timeSources[removeIndex] = state->timeSources[lastIndex]; + + // Find hash currently mapped to lastIndex and update it to removeIndex. + auto content = state->timeSourceTable.Content(); + for (IndexT i = 0; i < content.Size(); i++) + { + if (content[i].Value() == lastIndex) + { + state->timeSourceTable[content[i].Key()] = removeIndex; + break; + } + } + } + + n_assert(state->numTimeSources > 0); + state->numTimeSources--; +} + //------------------------------------------------------------------------------ /** */ diff --git a/code/application/basegamefeature/managers/timemanager.h b/code/application/basegamefeature/managers/timemanager.h index c8801f5cee..13e93e6c23 100644 --- a/code/application/basegamefeature/managers/timemanager.h +++ b/code/application/basegamefeature/managers/timemanager.h @@ -76,6 +76,12 @@ namespace Time /// create a timesource. The global time manager handles the timesources. TimeSource* const CreateTimeSource(TimeSourceCreateInfo const& info); + /// return true if a timesource with this hash exists + bool HasTimeSource(uint32_t TIMESOURCE_HASH); + + /// destroy an existing timesource by hash (safe no-op if missing) + void DestroyTimeSource(uint32_t TIMESOURCE_HASH); + /// get a time source by hash TimeSource* const GetTimeSource(uint32_t TIMESOURCE_HASH); diff --git a/code/application/game/component.h b/code/application/game/component.h index 411bbd3a2b..2542b2def5 100644 --- a/code/application/game/component.h +++ b/code/application/game/component.h @@ -119,6 +119,15 @@ template inline ComponentId GetComponentId() { + // During runtime module reload, template-local static IDs can be re-created + // and diverge from existing world table columns. Prefer name-based lookup + // so we keep the stable registry ID already used by live world data. + ComponentId byName = MemDb::AttributeRegistry::GetAttributeId(COMPONENT::Traits::name); + if (byName != ComponentId::Invalid()) + { + return byName; + } + #if !PUBLIC_BUILD if (!MemDb::AttributeRegistry::IsRegistered()) { diff --git a/code/application/game/componentinspection.cc b/code/application/game/componentinspection.cc index 58c840d3da..c2c4a44bbb 100644 --- a/code/application/game/componentinspection.cc +++ b/code/application/game/componentinspection.cc @@ -75,6 +75,21 @@ ComponentInspection::Register(ComponentId component, DrawFunc func) reg->inspectors[component.id] = func; } +//------------------------------------------------------------------------------ +/** +*/ +void +ComponentInspection::Unregister(ComponentId component) +{ + if (Singleton == nullptr) + return; + + if (component.id >= Singleton->inspectors.Size()) + return; + + Singleton->inspectors[component.id] = nullptr; +} + //------------------------------------------------------------------------------ /** */ diff --git a/code/application/game/componentinspection.h b/code/application/game/componentinspection.h index 0247cb63cb..177d0e527c 100644 --- a/code/application/game/componentinspection.h +++ b/code/application/game/componentinspection.h @@ -35,6 +35,7 @@ class ComponentInspection static void Destroy(); static void Register(ComponentId component, DrawFunc); + static void Unregister(ComponentId component); static void DrawInspector(Game::Entity owner, ComponentId component, void* data, bool* commit); diff --git a/code/application/game/componentserialization.cc b/code/application/game/componentserialization.cc index 1627652715..c4005a8301 100644 --- a/code/application/game/componentserialization.cc +++ b/code/application/game/componentserialization.cc @@ -80,6 +80,22 @@ ComponentSerialization::Override(ComponentId component, DeserializeJsonFunc dese Singleton->serializers[component.id].deserializeJson = deserialize; } +//------------------------------------------------------------------------------ +/** +*/ +void +ComponentSerialization::Unregister(ComponentId component) +{ + if (Singleton == nullptr) + return; + + if (component.id >= Singleton->serializers.Size()) + return; + + Singleton->serializers[component.id].deserializeJson = nullptr; + Singleton->serializers[component.id].serializeJson = nullptr; +} + void ComponentSerialization::OverrideType(OverridableType type, DeserializeJsonFunc deserialize, SerializeJsonFunc serialize) { diff --git a/code/application/game/componentserialization.h b/code/application/game/componentserialization.h index b3248b211a..7c92d10edf 100644 --- a/code/application/game/componentserialization.h +++ b/code/application/game/componentserialization.h @@ -46,6 +46,7 @@ class ComponentSerialization template static void Register(ComponentId component); + static void Unregister(ComponentId component); /// ptr points to the location where the value should be stored. Make sure you have room for it! static void Deserialize(Ptr const& reader, ComponentId component, void* ptr); diff --git a/code/application/game/editorstate.h b/code/application/game/editorstate.h index 5f53b9be39..f54c85f1e7 100644 --- a/code/application/game/editorstate.h +++ b/code/application/game/editorstate.h @@ -14,6 +14,7 @@ */ //------------------------------------------------------------------------------ #include "core/singleton.h" +#include "util/string.h" namespace Game { @@ -29,6 +30,12 @@ class EditorState bool isRunning = false; /// is true if the editor is currently playing/simulating the game (play-in-editor) bool isPlaying = false; + /// snapshot path used to restore editor state after a hot reload + Util::String reloadSnapshotPath; + /// if true, recreate the editor level from reloadSnapshotPath on next activate + bool reloadSnapshotPending = false; + /// true once editor bootstrap scripts have been initialized for this process + bool pythonBootstrapInitialized = false; }; } // namespace Game diff --git a/code/application/game/featureunit.cc b/code/application/game/featureunit.cc index 7ed93c9dec..780ea58761 100644 --- a/code/application/game/featureunit.cc +++ b/code/application/game/featureunit.cc @@ -44,7 +44,14 @@ FeatureUnit::OnAttach() void FeatureUnit::OnRemove() { - // empty + for (IndexT i = this->registeredComponents.Size() - 1; i >= 0; i--) + { + const ComponentId cid = this->registeredComponents[i]; + Game::ComponentInspection::Unregister(cid); + Game::ComponentSerialization::Unregister(cid); + } + + this->registeredComponents.Clear(); } //------------------------------------------------------------------------------ diff --git a/code/application/game/featureunit.h b/code/application/game/featureunit.h index 2ee325ce47..761a2828e2 100644 --- a/code/application/game/featureunit.h +++ b/code/application/game/featureunit.h @@ -97,6 +97,8 @@ class FeatureUnit : public Core::RefCounted protected: Util::Array> managers; + /// Components registered by this feature; unregistered in OnRemove. + Util::Array registeredComponents; bool active; /// cmdline args for configuration from cmdline @@ -120,6 +122,7 @@ FeatureUnit::RegisterComponentType(ComponentRegisterInfo info) Game::ComponentId const cid = MemDb::AttributeRegistry::Register(cInterface); Game::ComponentSerialization::Register(cid); Game::ComponentInspection::Register(cid, &Game::ComponentDrawFuncT); + this->registeredComponents.Append(cid); return cid; } diff --git a/code/application/game/frameevent.cc b/code/application/game/frameevent.cc index 2bf34aa985..d980fb6d93 100644 --- a/code/application/game/frameevent.cc +++ b/code/application/game/frameevent.cc @@ -104,7 +104,19 @@ FrameEvent::AddProcessor(Processor* processor) void FrameEvent::RemoveProcessor(Processor* processor) { - n_error("Not implemented!"); + for (IndexT i = 0; i < this->batches.Size(); i++) + { + FrameEvent::Batch* batch = this->batches[i]; + if (!batch->RemoveProcessor(processor)) + continue; + + if (batch->IsEmpty()) + { + delete batch; + this->batches.EraseIndex(i); + } + return; + } } //------------------------------------------------------------------------------ @@ -212,6 +224,20 @@ FrameEvent::Batch::TryInsert(Processor* processor) return true; } +//------------------------------------------------------------------------------ +/** +*/ +bool +FrameEvent::Batch::RemoveProcessor(Processor* processor) +{ + IndexT index = this->processors.FindIndex(processor); + if (index == InvalidIndex) + return false; + + this->processors.EraseIndex(index); + return true; +} + //------------------------------------------------------------------------------ /** */ @@ -270,6 +296,15 @@ FrameEvent::Batch::GetProcessors() const return procs; } +//------------------------------------------------------------------------------ +/** +*/ +bool +FrameEvent::Batch::IsEmpty() const +{ + return this->processors.IsEmpty(); +} + //------------------------------------------------------------------------------ /** */ diff --git a/code/application/game/frameevent.h b/code/application/game/frameevent.h index 5578c07ecc..4e97846f04 100644 --- a/code/application/game/frameevent.h +++ b/code/application/game/frameevent.h @@ -84,6 +84,8 @@ class FrameEvent::Batch /// case, use linear probing to insert the processor /// into a new batch bool TryInsert(Processor* processor); + /// Remove a processor from the batch. Does not free the processor. + bool RemoveProcessor(Processor* processor); /// prefilter all processors. Should not be done per frame - instead use CacheTable if you need to do incremental caching void Prefilter(World* world, bool force = false); @@ -95,6 +97,8 @@ class FrameEvent::Batch bool async = false; Util::Array GetProcessors() const; + /// return true if no processors remain in the batch + bool IsEmpty() const; private: void ExecuteAsync(World* world); diff --git a/code/application/game/modulemanager.cc b/code/application/game/modulemanager.cc index 5e11c9d812..818ceda638 100644 --- a/code/application/game/modulemanager.cc +++ b/code/application/game/modulemanager.cc @@ -194,7 +194,7 @@ ModuleManager::LoadModule(const RuntimeModuleConfig& moduleConfig, GameServer* g //------------------------------------------------------------------------------ /** */ -void +bool ModuleManager::UnloadModule(LoadedModule& loaded, GameServer* gameServer) { if (loaded.feature.isvalid()) @@ -206,7 +206,7 @@ ModuleManager::UnloadModule(LoadedModule& loaded, GameServer* gameServer) if (loaded.feature->GetRefCount() > 1) { std::fprintf(stderr, "ModuleManager: module '%s' still has external references on unload (%d), keeping shared library loaded\n", loaded.config.name.AsCharPtr(), loaded.feature->GetRefCount()); - return; + return false; } loaded.feature = nullptr; @@ -221,6 +221,8 @@ ModuleManager::UnloadModule(LoadedModule& loaded, GameServer* gameServer) delete loaded.library; loaded.library = nullptr; } + + return true; } //------------------------------------------------------------------------------ @@ -272,4 +274,91 @@ ModuleManager::ResolveLibraryPath(const RuntimeModuleConfig& moduleConfig) const return path; } +//------------------------------------------------------------------------------ +/** +*/ +void +ModuleManager::QueueModuleReload(const Util::String& moduleName) +{ + // Deduplicate: only queue a reload once per frame + for (IndexT i = 0; i < this->pendingReloads.Size(); i++) + { + if (this->pendingReloads[i] == moduleName) + return; + } + std::fprintf(stdout, "ModuleManager: reload of '%s' queued for next frame boundary\n", moduleName.AsCharPtr()); + this->pendingReloads.Append(moduleName); +} + +//------------------------------------------------------------------------------ +/** +*/ +void +ModuleManager::ProcessPendingReloads(GameServer* gameServer) +{ + if (this->pendingReloads.IsEmpty()) + return; + + // Snapshot and clear the queue before processing so that reloads triggered + // during the reload itself are deferred to the following frame. + Util::Array toProcess = this->pendingReloads; + this->pendingReloads.Clear(); + + for (IndexT i = 0; i < toProcess.Size(); i++) + { + this->ReloadModuleByName(toProcess[i], gameServer); + } +} + +//------------------------------------------------------------------------------ +/** +*/ +bool +ModuleManager::ReloadModuleByName(const Util::String& moduleName, GameServer* gameServer) +{ + // Find the loaded module entry + Util::String nameLower = moduleName; + nameLower.ToLower(); + + IndexT idx = InvalidIndex; + for (IndexT i = 0; i < this->loadedModules.Size(); i++) + { + Util::String n = this->loadedModules[i].config.name; + n.ToLower(); + if (n == nameLower) + { + idx = i; + break; + } + } + + if (idx == InvalidIndex) + { + std::fprintf(stderr, "ModuleManager: reload of '%s' failed: module is not loaded\n", moduleName.AsCharPtr()); + return false; + } + + // Save config so we can re-load with the same settings + RuntimeModuleConfig config = this->loadedModules[idx].config; + + std::fprintf(stdout, "ModuleManager: reloading '%s'...\n", moduleName.AsCharPtr()); + + if (!this->UnloadModule(this->loadedModules[idx], gameServer)) + { + std::fprintf(stderr, "ModuleManager: reload of '%s' blocked: module could not be safely unloaded\n", moduleName.AsCharPtr()); + return false; + } + + this->loadedModules.EraseIndex(idx); + + if (!this->LoadModule(config, gameServer, false)) + { + std::fprintf(stderr, "ModuleManager: reload of '%s' failed during load\n", moduleName.AsCharPtr()); + return false; + } + + std::fprintf(stdout, "ModuleManager: reload of '%s' complete\n", moduleName.AsCharPtr()); + return true; +} + } // namespace Game diff --git a/code/application/game/modulemanager.h b/code/application/game/modulemanager.h index 91d3162cf1..c742c6bbcd 100644 --- a/code/application/game/modulemanager.h +++ b/code/application/game/modulemanager.h @@ -48,14 +48,22 @@ class ModuleManager : public Core::RefCounted /// get number of loaded modules SizeT GetNumLoadedModules() const; + /// queue a reload of the named module to be executed at the next frame boundary + void QueueModuleReload(const Util::String& moduleName); + /// process all pending module reloads; call once per frame from application layer after OnEndFrame + void ProcessPendingReloads(GameServer* gameServer); + private: struct LoadedModule; bool LoadModule(const RuntimeModuleConfig& moduleConfig, GameServer* gameServer, bool strictMode); - void UnloadModule(LoadedModule& loaded, GameServer* gameServer); + bool UnloadModule(LoadedModule& loaded, GameServer* gameServer); Util::String ResolveLibraryPath(const RuntimeModuleConfig& moduleConfig) const; + /// unload then reload a single module by name; internal use by ProcessPendingReloads + bool ReloadModuleByName(const Util::String& moduleName, GameServer* gameServer); Util::Array loadedModules; + Util::Array pendingReloads; }; } // namespace Game diff --git a/code/application/game/world.cc b/code/application/game/world.cc index c3c6e84a50..c1769258ba 100644 --- a/code/application/game/world.cc +++ b/code/application/game/world.cc @@ -891,19 +891,68 @@ void World::AddStagedComponentsToEntity(Entity entity, AddStagedComponentCommand* cmds, SizeT numCmds) { MemDb::TableSignature signature; + MemDb::TableSignature baseSignature; if (this->HasInstance(entity)) { EntityMapping const mapping = this->GetEntityMapping(entity); MemDb::Table const& tbl = this->db->GetTable(mapping.table); signature = tbl.GetSignature(); + baseSignature = signature; } - SizeT i; - for (i = 0; i < numCmds; i++) + // Keep only the last staged value per component for this entity. + Util::Array uniqueCmds; + uniqueCmds.Reserve(numCmds); + for (SizeT i = 0; i < numCmds; i++) { auto const* cmd = cmds + i; - signature.SetBit(cmd->componentId); + + if (cmd->componentId == Game::ComponentId::Invalid()) + { + continue; + } + + if (!MemDb::AttributeRegistry::IsRegistered(cmd->componentId)) + { + continue; + } + + IndexT existing = InvalidIndex; + for (IndexT j = 0; j < uniqueCmds.Size(); j++) + { + if (uniqueCmds[j].componentId == cmd->componentId) + { + existing = j; + break; + } + } + + if (existing != InvalidIndex) + { + uniqueCmds[existing] = *cmd; + } + else + { + uniqueCmds.Append(*cmd); + } + } + + if (uniqueCmds.IsEmpty()) + { + return; + } + + Util::Array componentsToAdd; + componentsToAdd.Reserve(uniqueCmds.Size()); + for (IndexT i = 0; i < uniqueCmds.Size(); i++) + { + ComponentId cid = uniqueCmds[i].componentId; + if (!baseSignature.IsSet(cid)) + { + componentsToAdd.Append(cid); + } + signature.SetBit(cid); } MemDb::TableId newCategoryId = this->db->FindTable(signature); @@ -917,7 +966,7 @@ World::AddStagedComponentsToEntity(Entity entity, AddStagedComponentCommand* cmd EntityMapping const mapping = this->GetEntityMapping(entity); MemDb::Table const& tbl = this->db->GetTable(mapping.table); Util::Array const& cols = tbl.GetAttributes(); - info.components.SetSize(cols.Size() + numCmds); + info.components.SetSize(cols.Size() + componentsToAdd.Size()); for (i = 0; i < cols.Size(); ++i) { @@ -926,14 +975,14 @@ World::AddStagedComponentsToEntity(Entity entity, AddStagedComponentCommand* cmd } else { - info.components.SetSize(numCmds); + info.components.SetSize(componentsToAdd.Size()); } - SizeT end = i + numCmds; + SizeT end = i + componentsToAdd.Size(); IndexT cmdIndex = 0; for (; i < end; ++i, ++cmdIndex) { - info.components[i] = cmds[cmdIndex].componentId; + info.components[i] = componentsToAdd[cmdIndex]; } newCategoryId = this->CreateEntityTable(info); @@ -943,9 +992,9 @@ World::AddStagedComponentsToEntity(Entity entity, AddStagedComponentCommand* cmd MemDb::Table& newTable = this->db->GetTable(newCategoryId); - for (i = 0; i < numCmds; i++) + for (IndexT i = 0; i < uniqueCmds.Size(); i++) { - auto const* cmd = cmds + i; + auto const* cmd = uniqueCmds.Begin() + i; auto attrIndex = newTable.GetAttributeIndex(cmd->componentId); void* ptr = newTable.GetValuePointer(attrIndex, newInstance); @@ -963,10 +1012,24 @@ World::RemoveComponentsFromEntity(Entity entity, RemoveComponentCommand* cmds, S MemDb::Table& tbl = this->db->GetTable(mapping.table); MemDb::TableSignature signature = this->db->GetTable(mapping.table).GetSignature(); + Util::Array validCmds; + validCmds.Reserve(numCmds); + SizeT i; for (i = 0; i < numCmds; i++) { auto const* cmd = cmds + i; + + // During hot-reload teardown, queued remove commands may reference components + // that have already been unregistered. Ignore those stale commands. + if (!MemDb::AttributeRegistry::IsRegistered(cmd->componentId)) + continue; + + if (!signature.IsSet(cmd->componentId)) + continue; + + validCmds.Append(*cmd); + #if NEBULA_DEBUG /* if (!signature.IsSet(cmd->componentId)) @@ -979,33 +1042,42 @@ World::RemoveComponentsFromEntity(Entity entity, RemoveComponentCommand* cmds, S signature.ClearBit(cmd->componentId); } + if (validCmds.IsEmpty()) + { + return; + } + MemDb::TableId newCategoryId = this->db->FindTable(signature); if (newCategoryId == MemDb::InvalidTableId) { EntityTableCreateInfo info; auto const& attributes = tbl.GetAttributes(); - info.components.SetSize(attributes.Size() - numCmds); + info.components.SetSize(attributes.Size() - validCmds.Size()); IndexT cIndex = 0; for (IndexT i = 0; i < attributes.Size(); ++i) { IndexT k = 0; - for (k = 0; k < numCmds; k++) + for (k = 0; k < validCmds.Size(); k++) { // check if the component should remain in the entity - if (attributes[i] == cmds[k].componentId) + if (attributes[i] == validCmds[k].componentId) break; } - if (k == numCmds) // keep the component, otherwise discard it + if (k == validCmds.Size()) // keep the component, otherwise discard it info.components[cIndex++] = attributes[i]; } newCategoryId = this->CreateEntityTable(info); } - for (i = 0; i < numCmds; i++) + for (i = 0; i < validCmds.Size(); i++) { - auto const* cmd = cmds + i; - this->DecayComponent(cmd->componentId, mapping.table, tbl.GetAttributeIndex(cmd->componentId), mapping.instance); + auto const& cmd = validCmds[i]; + MemDb::ColumnIndex col = tbl.GetAttributeIndex(cmd.componentId); + if (col != MemDb::ColumnIndex::Invalid()) + { + this->DecayComponent(cmd.componentId, mapping.table, col, mapping.instance); + } } this->Migrate(entity, newCategoryId); diff --git a/code/foundation/core/factory.cc b/code/foundation/core/factory.cc index 5905416974..323897ec85 100644 --- a/code/foundation/core/factory.cc +++ b/code/foundation/core/factory.cc @@ -29,6 +29,15 @@ Factory::Instance() return Singleton; } +//------------------------------------------------------------------------------ +/** +*/ +bool +Factory::HasInstance() +{ + return Singleton != nullptr; +} + //------------------------------------------------------------------------------ /** This static method is used to destroy the factory object and should be @@ -139,6 +148,51 @@ Factory::Register(const Rtti* rtti, const String& className) this->nameTable.Add(className, rtti); } +//------------------------------------------------------------------------------ +/** +*/ +void +Factory::Unregister(const Rtti* rtti, const String& className, const FourCC& classFourCC) +{ + n_assert(0 != rtti); + + if (className.IsValid() && this->nameTable.Contains(className)) + { + const Rtti* registered = this->nameTable[className]; + if (registered == rtti) + { + this->nameTable.Erase(className); + } + } + + if (classFourCC.IsValid() && this->fourccTable.Contains(classFourCC)) + { + const Rtti* registered = this->fourccTable[classFourCC]; + if (registered == rtti) + { + this->fourccTable.Erase(classFourCC); + } + } +} + +//------------------------------------------------------------------------------ +/** +*/ +void +Factory::Unregister(const Rtti* rtti, const String& className) +{ + n_assert(0 != rtti); + + if (className.IsValid() && this->nameTable.Contains(className)) + { + const Rtti* registered = this->nameTable[className]; + if (registered == rtti) + { + this->nameTable.Erase(className); + } + } +} + //------------------------------------------------------------------------------ /** This method checks if a class with the given name has been registered. diff --git a/code/foundation/core/factory.h b/code/foundation/core/factory.h index 25dd3f1cf5..295a181448 100644 --- a/code/foundation/core/factory.h +++ b/code/foundation/core/factory.h @@ -32,6 +32,8 @@ class Factory public: /// get pointer to singleton instance (cannot use singleton.h!) static Factory* Instance(); + /// return true if singleton exists + static bool HasInstance(); /// static instance destruction method static void Destroy(); @@ -39,6 +41,10 @@ class Factory void Register(const Rtti* rtti, const Util::String& className, const Util::FourCC& classFourCC); /// register a RTTI object with the factory (without fourcc code) void Register(const Rtti* rtti, const Util::String& className); + /// unregister a RTTI object by name/fourcc (safe no-op if not found) + void Unregister(const Rtti* rtti, const Util::String& className, const Util::FourCC& classFourCC); + /// unregister a RTTI object by name (safe no-op if not found) + void Unregister(const Rtti* rtti, const Util::String& className); /// check if a class exists by class name bool ClassExists(const Util::String& className) const; /// check if a class exists by FourCC code diff --git a/code/foundation/core/rtti.cc b/code/foundation/core/rtti.cc index 054ba4b762..a4f2d706a5 100644 --- a/code/foundation/core/rtti.cc +++ b/code/foundation/core/rtti.cc @@ -94,6 +94,27 @@ Rtti::Rtti(const char* className, Creator creatorFunc, ArrayCreator arrayCreator this->Construct(className, 0, creatorFunc, arrayCreatorFunc, parentClass, instSize); } +//------------------------------------------------------------------------------ +/** +*/ +Rtti::~Rtti() +{ + // RTTI instances in dynamically loaded modules are static globals. + // On module unload, unregister them so reload can register fresh RTTI + // objects without colliding with stale pointers. + if (Factory::HasInstance() && this->name.IsValid()) + { + if (this->fourCC.IsValid()) + { + Factory::Instance()->Unregister(this, this->name, this->fourCC); + } + else + { + Factory::Instance()->Unregister(this, this->name); + } + } +} + //------------------------------------------------------------------------------ /** */ diff --git a/code/foundation/core/rtti.h b/code/foundation/core/rtti.h index 74dc38ed2c..df858ba73b 100644 --- a/code/foundation/core/rtti.h +++ b/code/foundation/core/rtti.h @@ -34,6 +34,8 @@ class Rtti Rtti(const char* className, Util::FourCC fcc, Creator creatorFunc, ArrayCreator arrayCreatorFunc, const Core::Rtti* parentClass, SizeT instSize); /// legacy constructor without FourCC for Mangalore compatibility Rtti(const char* className, Creator creatorFunc, ArrayCreator arrayCreatorFunc, const Core::Rtti* parentClass, SizeT instSize); + /// destructor + ~Rtti(); /// equality operator bool operator==(const Rtti& rhs) const; /// inequality operator diff --git a/code/foundation/io/jsonreader.h b/code/foundation/io/jsonreader.h index 2c288cd5b4..fc3ff4ae27 100644 --- a/code/foundation/io/jsonreader.h +++ b/code/foundation/io/jsonreader.h @@ -219,7 +219,7 @@ inline void JsonReader::Get(Util::BitField& ret, const char* attr) { Util::Array arr; - this->Get>(arr); + this->Get>(arr, attr); unsigned int count = arr.Size(); n_assert(count <= N); diff --git a/code/physics/physics/physxstate.cc b/code/physics/physics/physxstate.cc index ce8d07e7a5..aecf7d0fdc 100644 --- a/code/physics/physics/physxstate.cc +++ b/code/physics/physics/physxstate.cc @@ -355,7 +355,12 @@ PhysxState::BeginSimulating(Timing::Time delta, IndexT sceneId) } #endif - n_assert(this->activeSceneIds.FindIndex(sceneId) != InvalidIndex); + if (this->activeSceneIds.FindIndex(sceneId) == InvalidIndex) + { + N_MARKER_END(); + return; + } + Physics::Scene& scene = this->activeScenes[sceneId]; n_assert(scene.isSimulating == false); scene.time -= delta; @@ -375,7 +380,11 @@ PhysxState::BeginSimulating(Timing::Time delta, IndexT sceneId) void PhysxState::EndSimulating(IndexT sceneId) { - n_assert(this->activeSceneIds.FindIndex(sceneId) != InvalidIndex); + if (this->activeSceneIds.FindIndex(sceneId) == InvalidIndex) + { + return; + } + Physics::Scene& scene = this->activeScenes[sceneId]; if (!scene.isSimulating) @@ -413,7 +422,10 @@ PhysxState::EndSimulating(IndexT sceneId) void PhysxState::FlushSimulation(IndexT sceneId) { - n_assert(this->activeSceneIds.FindIndex(sceneId) != InvalidIndex); + if (this->activeSceneIds.FindIndex(sceneId) == InvalidIndex) + { + return; + } Physics::Scene& scene = this->activeScenes[sceneId]; if (scene.isSimulating) { diff --git a/code/physics/physicsinterface.cc b/code/physics/physicsinterface.cc index 24048830bd..aa8140a0c4 100644 --- a/code/physics/physicsinterface.cc +++ b/code/physics/physicsinterface.cc @@ -384,6 +384,19 @@ FlushSimulation(IndexT scene) state.FlushSimulation(scene); } +//------------------------------------------------------------------------------ +/** +*/ +bool +IsSceneActive(IndexT scene) +{ + if (state.foundation == nullptr) + { + return false; + } + return state.activeSceneIds.FindIndex(scene) != InvalidIndex; +} + //------------------------------------------------------------------------------ /** */ diff --git a/code/physics/physicsinterface.h b/code/physics/physicsinterface.h index c8ec66f497..a5226fdc49 100644 --- a/code/physics/physicsinterface.h +++ b/code/physics/physicsinterface.h @@ -226,6 +226,9 @@ void EndSimulating(IndexT scene); /// this will block until simulation has ended for cleanups e.g. void FlushSimulation(IndexT scene); +/// return true if the scene id currently belongs to an active physics scene +bool IsSceneActive(IndexT scene); + /// IndexT CreateScene(); /// diff --git a/code/render/frame/framesubpassbatch.cc b/code/render/frame/framesubpassbatch.cc index ecd9946d9e..8dfee2478a 100644 --- a/code/render/frame/framesubpassbatch.cc +++ b/code/render/frame/framesubpassbatch.cc @@ -246,8 +246,11 @@ FrameSubpassBatch::DrawBatch(const CoreGraphics::CmdBufferId cmdBuf, MaterialTem void FrameSubpassBatch::CompiledImpl::Run(const CoreGraphics::CmdBufferId cmdBuf, const IndexT frameIndex, const IndexT bufferIndex) { - const Ptr& view = Graphics::GraphicsServer::Instance()->GetCurrentView(); - FrameSubpassBatch::DrawBatch(cmdBuf, this->batch, view->GetCamera(), bufferIndex); + const Graphics::ViewId view = Graphics::GraphicsServer::Instance()->GetCurrentView(); + const Graphics::GraphicsEntityId camera = view != Graphics::InvalidViewId ? Graphics::ViewGetCamera(view) : Graphics::InvalidGraphicsEntityId; + if (camera == Graphics::InvalidGraphicsEntityId) + return; + FrameSubpassBatch::DrawBatch(cmdBuf, this->batch, camera, bufferIndex); } } // namespace Frame2 diff --git a/code/render/graphics/cameracontext.cc b/code/render/graphics/cameracontext.cc index c1d3889bcc..378d5aad33 100644 --- a/code/render/graphics/cameracontext.cc +++ b/code/render/graphics/cameracontext.cc @@ -47,6 +47,13 @@ CameraContext::Create() void CameraContext::UpdateCameras(const Graphics::FrameContext& ctx) { + // Keep LOD camera list free from stale entries when entities are torn down during reload. + for (IndexT i = CameraContext::LodCameras.Size() - 1; i >= 0; i--) + { + if (!CameraContext::IsEntityRegistered(CameraContext::LodCameras[i])) + CameraContext::LodCameras.EraseIndex(i); + } + const Util::Array& proj = cameraAllocator.GetArray(); const Util::Array& views = cameraAllocator.GetArray(); const Util::Array& viewproj = cameraAllocator.GetArray(); @@ -100,6 +107,10 @@ CameraContext::SetView(const Graphics::GraphicsEntityId id, const Math::mat4& ma const Math::mat4& CameraContext::GetView(const Graphics::GraphicsEntityId id) { + static const Math::mat4 identity = Math::mat4::identity; + if (id == Graphics::InvalidGraphicsEntityId || !CameraContext::IsEntityRegistered(id)) + return identity; + const ContextEntityId cid = GetContextId(id); return cameraAllocator.Get(cid.id); } @@ -110,6 +121,9 @@ CameraContext::GetView(const Graphics::GraphicsEntityId id) const Math::mat4 CameraContext::GetTransform(const Graphics::GraphicsEntityId id) { + if (id == Graphics::InvalidGraphicsEntityId || !CameraContext::IsEntityRegistered(id)) + return Math::mat4::identity; + const ContextEntityId cid = GetContextId(id); return inverse(cameraAllocator.Get(cid.id)); } @@ -120,6 +134,10 @@ CameraContext::GetTransform(const Graphics::GraphicsEntityId id) const Math::mat4& CameraContext::GetProjection(const Graphics::GraphicsEntityId id) { + static const Math::mat4 identity = Math::mat4::identity; + if (id == Graphics::InvalidGraphicsEntityId || !CameraContext::IsEntityRegistered(id)) + return identity; + const ContextEntityId cid = GetContextId(id); return cameraAllocator.Get(cid.id); } @@ -130,6 +148,10 @@ CameraContext::GetProjection(const Graphics::GraphicsEntityId id) const Math::mat4& CameraContext::GetViewProjection(const Graphics::GraphicsEntityId id) { + static const Math::mat4 identity = Math::mat4::identity; + if (id == Graphics::InvalidGraphicsEntityId || !CameraContext::IsEntityRegistered(id)) + return identity; + const ContextEntityId cid = GetContextId(id); return cameraAllocator.Get(cid.id); } @@ -140,6 +162,10 @@ CameraContext::GetViewProjection(const Graphics::GraphicsEntityId id) const CameraSettings& CameraContext::GetSettings(const Graphics::GraphicsEntityId id) { + static CameraSettings fallback; + if (id == Graphics::InvalidGraphicsEntityId || !CameraContext::IsEntityRegistered(id)) + return fallback; + const ContextEntityId cid = GetContextId(id); return cameraAllocator.Get(cid.id); } @@ -150,6 +176,9 @@ CameraContext::GetSettings(const Graphics::GraphicsEntityId id) Graphics::StageMask CameraContext::GetStageMask(const Graphics::GraphicsEntityId id) { + if (id == Graphics::InvalidGraphicsEntityId || !CameraContext::IsEntityRegistered(id)) + return Graphics::PRIMARY_STAGE_MASK; + const ContextEntityId cid = GetContextId(id); return cameraAllocator.Get(cid.id); } @@ -169,6 +198,12 @@ CameraContext::GetLODCameras() void CameraContext::AddLODCamera(const Graphics::GraphicsEntityId id) { + if (!CameraContext::IsEntityRegistered(id)) + return; + + if (CameraContext::LodCameras.FindIndex(id) != InvalidIndex) + return; + CameraContext::LodCameras.Append(id); } @@ -179,8 +214,8 @@ void CameraContext::RemoveLODCamera(const Graphics::GraphicsEntityId id) { IndexT i = CameraContext::LodCameras.FindIndex(id); - n_assert(i != InvalidIndex); - CameraContext::LodCameras.EraseIndex(i); + if (i != InvalidIndex) + CameraContext::LodCameras.EraseIndex(i); } //------------------------------------------------------------------------------ diff --git a/code/render/graphics/graphicsserver.cc b/code/render/graphics/graphicsserver.cc index b864f6b21b..ee2452f9c6 100644 --- a/code/render/graphics/graphicsserver.cc +++ b/code/render/graphics/graphicsserver.cc @@ -443,6 +443,8 @@ GraphicsServer::CreateView(const Util::StringAtom& name) this->views.Append(view); this->viewsByName.Add(name, view); + this->preViewCallbacks.Append(nullptr); + this->postViewCallbacks.Append(nullptr); // invoke all interested contexts IndexT i; @@ -463,6 +465,22 @@ GraphicsServer::DiscardView(const ViewId view) IndexT idx = this->views.FindIndex(view); n_assert(idx != InvalidIndex); this->views.EraseIndex(idx); + if (idx < this->preViewCallbacks.Size()) + this->preViewCallbacks.EraseIndex(idx); + if (idx < this->postViewCallbacks.Size()) + this->postViewCallbacks.EraseIndex(idx); + + // Remove reverse mapping by value. + for (IndexT i = 0; i < this->viewsByName.Size(); i++) + { + if (this->viewsByName.ValueAtIndex(i) == view) + { + this->viewsByName.EraseAtIndex(i); + break; + } + } + + Graphics::DestroyView(view); // invoke all interested contexts for (IndexT i = 0; i < this->contexts.Size(); i++) { @@ -507,6 +525,33 @@ GraphicsServer::AddEndFrameCall(void(*func)(IndexT frameIndex, IndexT bufferInde this->endFrameCallbacks.Append(func); } +//------------------------------------------------------------------------------ +/** +*/ +void +GraphicsServer::RemoveEndFrameCall(void(*func)(IndexT frameIndex, IndexT bufferIndex)) +{ + for (IndexT i = this->endFrameCallbacks.Size() - 1; i >= 0; i--) + { + auto* target = this->endFrameCallbacks[i].template target(); + if (target != nullptr && *target == func) + { + this->endFrameCallbacks.EraseIndex(i); + } + } +} + +//------------------------------------------------------------------------------ +/** +*/ +void +GraphicsServer::ClearEndFrameCalls() +{ + this->endFrameCallbacks.Clear(); +} + + + //------------------------------------------------------------------------------ /** */ @@ -627,9 +672,12 @@ GraphicsServer::Render() ViewApply(view); N_MARKER_BEGIN(ViewPreFrameCallback, Graphics) - auto& preViewCallback = this->preViewCallbacks[i]; - if (preViewCallback != nullptr) - preViewCallback(this->frameContext.frameIndex, this->frameContext.bufferIndex); + if (i < this->preViewCallbacks.Size()) + { + auto& preViewCallback = this->preViewCallbacks[i]; + if (preViewCallback != nullptr) + preViewCallback(this->frameContext.frameIndex, this->frameContext.bufferIndex); + } N_MARKER_END() if (ViewRender(view, this->frameContext.frameIndex, this->frameContext.time, this->frameContext.bufferIndex)) @@ -654,9 +702,12 @@ GraphicsServer::Render() this->currentView = InvalidViewId; N_MARKER_BEGIN(ViewPostFrameCallback, Graphics) - auto& postViewCallback = this->postViewCallbacks[i]; - if (postViewCallback != nullptr) - postViewCallback(this->frameContext.frameIndex, this->frameContext.bufferIndex); + if (i < this->postViewCallbacks.Size()) + { + auto& postViewCallback = this->postViewCallbacks[i]; + if (postViewCallback != nullptr) + postViewCallback(this->frameContext.frameIndex, this->frameContext.bufferIndex); + } N_MARKER_END() } } diff --git a/code/render/graphics/graphicsserver.h b/code/render/graphics/graphicsserver.h index 877c8a0938..933ecf0e03 100644 --- a/code/render/graphics/graphicsserver.h +++ b/code/render/graphics/graphicsserver.h @@ -88,6 +88,10 @@ class GraphicsServer : public Core::RefCounted /// Add callback to run just before frame is finished void AddEndFrameCall(void(*func)(IndexT frameIndex, IndexT bufferIndex)); + /// Remove a specific end-frame callback + void RemoveEndFrameCall(void(*func)(IndexT frameIndex, IndexT bufferIndex)); + /// Clear all end-frame callbacks + void ClearEndFrameCalls(); /// Set a function to be run when resize void SetResizeCall(void(*)(const SizeT, const SizeT)); diff --git a/code/render/models/modelcontext.cc b/code/render/models/modelcontext.cc index 0f4e340543..408846e166 100644 --- a/code/render/models/modelcontext.cc +++ b/code/render/models/modelcontext.cc @@ -840,161 +840,230 @@ ModelContext::UpdateTransforms(const Graphics::FrameContext& ctx) lodCameraStageMasks.Append(Graphics::CameraContext::GetStageMask(cam)); } - // get the lod camera - const Math::mat4& cameraTransform = Graphics::CameraContext::GetTransform(lodCameras[0]); + // Use first LOD camera when available, otherwise fall back to identity to + // keep model update jobs running during reload/world transitions. + Math::mat4 cameraTransform = Math::mat4::identity; + if (!lodCameras.IsEmpty()) + { + cameraTransform = Graphics::CameraContext::GetTransform(lodCameras[0]); + } - n_assert(TransformsUpdateCounter == 0); - TransformsUpdateCounter = 1; + // If there are no model instances this frame, avoid dispatching zero-job + // chains and keep the synchronization event in a signaled state. + if (nodeInstanceTransformRanges.IsEmpty() || nodeInstanceStateRanges.IsEmpty()) + { + ModelContext::completionEvent.Signal(); + return; + } - Jobs2::JobDispatch( - [ - nodeInstanceTransformRanges = nodeInstanceTransformRanges.ConstBegin() - , nodeInstanceRoots = nodeInstanceRoots.ConstBegin() - , pending = pending.Begin() - , hasPending = hasPending.Begin() - ] - (SizeT totalJobs, SizeT groupSize, IndexT groupIndex, SizeT invocationOffset) + static Threading::AtomicCounter lodUpdateCounter = 0; + + // If the previous frame timed out waiting for model jobs, counters can + // still be non-zero here. Skip this frame instead of asserting/crashing. + if (TransformsUpdateCounter != 0 || lodUpdateCounter != 0 || ConstantsUpdateCounter != 0) { - N_SCOPE(ModelTransformUpdate, Graphics); - for (IndexT i = 0; i < groupSize; i++) - { - IndexT index = i + invocationOffset; - if (index >= totalJobs) - return; + return; + } - const NodeInstanceRange& transformRange = nodeInstanceTransformRanges[index]; - const Util::Array& roots = nodeInstanceRoots[index]; - if (hasPending[index]) + TransformsUpdateCounter = 1; + + // Count actual work to avoid zero-dispatch stalls + SizeT totalTransformWork = 0; + for (const auto& range : nodeInstanceTransformRanges) + { + if (range.end > range.begin) + totalTransformWork += range.end - range.begin; + } + + if (totalTransformWork > 0) + { + Jobs2::JobDispatch( + [ + nodeInstanceTransformRanges = nodeInstanceTransformRanges.ConstBegin() + , nodeInstanceRoots = nodeInstanceRoots.ConstBegin() + , pending = pending.Begin() + , hasPending = hasPending.Begin() + ] + (SizeT totalJobs, SizeT groupSize, IndexT groupIndex, SizeT invocationOffset) + { + N_SCOPE(ModelTransformUpdate, Graphics); + for (IndexT i = 0; i < groupSize; i++) { - // The pending transform is the root of the model - const Math::mat4 transform = pending[index]; - hasPending[index] = false; + IndexT index = i + invocationOffset; + if (index >= totalJobs) + return; + + const NodeInstanceRange& transformRange = nodeInstanceTransformRanges[index]; + const Util::Array& roots = nodeInstanceRoots[index]; + if (transformRange.end < transformRange.begin) + continue; + if (transformRange.end > NodeInstances.transformable.nodeTransforms.Size()) + continue; + + if (hasPending[index]) + { + // The pending transform is the root of the model + const Math::mat4 transform = pending[index]; + hasPending[index] = false; - // Set root transform - SizeT j; - for (j = 0; j < roots.Size(); j++) - NodeInstances.transformable.nodeTransforms[transformRange.begin + roots[j]] = transform; + // Set root transform + SizeT j; + for (j = 0; j < roots.Size(); j++) + { + uint32_t root = roots[j]; + if (transformRange.begin + root >= transformRange.end) + continue; + NodeInstances.transformable.nodeTransforms[transformRange.begin + root] = transform; + } - // Update transforms - for (j = transformRange.begin + 1; j < transformRange.end; j++) - { - uint32_t parent = NodeInstances.transformable.nodeParents[j]; - n_assert(parent != UINT32_MAX); - Math::mat4 parentTransform = NodeInstances.transformable.nodeTransforms[transformRange.begin + parent]; - Math::mat4 orig = NodeInstances.transformable.origTransforms[j]; - NodeInstances.transformable.nodeTransforms[j] = parentTransform * orig; + // Update transforms + for (j = transformRange.begin + 1; j < transformRange.end; j++) + { + uint32_t parent = NodeInstances.transformable.nodeParents[j]; + n_assert(parent != UINT32_MAX); + Math::mat4 parentTransform = NodeInstances.transformable.nodeTransforms[transformRange.begin + parent]; + Math::mat4 orig = NodeInstances.transformable.origTransforms[j]; + NodeInstances.transformable.nodeTransforms[j] = parentTransform * orig; + } } } - } - }, nodeInstanceTransformRanges.Size(), 256, nullptr, &TransformsUpdateCounter, nullptr); + }, nodeInstanceTransformRanges.Size(), 256, nullptr, &TransformsUpdateCounter, nullptr); + } + else + { + TransformsUpdateCounter = 0; + } - static Threading::AtomicCounter lodUpdateCounter = 0; - n_assert(lodUpdateCounter == 0); lodUpdateCounter = 1; - - Jobs2::JobDispatch( - [ - nodeInstanceTransformRanges = nodeInstanceTransformRanges.ConstBegin() - , nodeInstanceStateRanges = nodeInstanceStateRanges.ConstBegin() - , instanceBoxes = instanceBoxes.Begin() - , stageMasks = modelStageMasks.Begin() - , cameraTransform - , cameraSettings = lodCameraSettings.Begin() - , viewTransforms = lodCameraViewTransforms.Begin() - , cameraStageMasks = lodCameraStageMasks.Begin() - , numCameras = lodCameraSettings.Size() - ] - (SizeT totalJobs, SizeT groupSize, IndexT groupIndex, SizeT invocationOffset) + + // Count actual work to avoid zero-dispatch stalls + SizeT totalLodWork = 0; + for (const auto& range : nodeInstanceStateRanges) { - N_SCOPE(ModelLodUpdate, Graphics); - for (IndexT i = 0; i < groupSize; i++) + if (range.end > range.begin) + totalLodWork += range.end - range.begin; + } + + if (totalLodWork > 0) + { + Jobs2::JobDispatch( + [ + nodeInstanceTransformRanges = nodeInstanceTransformRanges.ConstBegin() + , nodeInstanceStateRanges = nodeInstanceStateRanges.ConstBegin() + , instanceBoxes = instanceBoxes.Begin() + , stageMasks = modelStageMasks.Begin() + , cameraTransform + , cameraSettings = lodCameraSettings.Begin() + , viewTransforms = lodCameraViewTransforms.Begin() + , cameraStageMasks = lodCameraStageMasks.Begin() + , numCameras = lodCameraSettings.Size() + ] + (SizeT totalJobs, SizeT groupSize, IndexT groupIndex, SizeT invocationOffset) { - IndexT index = i + invocationOffset; - if (index >= totalJobs) - return; - - const NodeInstanceRange& stateRange = nodeInstanceStateRanges[index]; - const NodeInstanceRange& transformRange = nodeInstanceTransformRanges[index]; - const Graphics::StageMask stageMask = stageMasks[index]; - SizeT j; - for (j = stateRange.begin; j < stateRange.end; j++) + N_SCOPE(ModelLodUpdate, Graphics); + for (IndexT i = 0; i < groupSize; i++) { - Math::mat4 transform = NodeInstances.transformable.nodeTransforms[transformRange.begin + NodeInstances.renderable.nodeTransformIndex[j]]; - Math::bbox box = NodeInstances.renderable.origBoundingBoxes[j]; - float radius = box.diagonal_size() / 2; - box.affine_transform(transform); - instanceBoxes[j] = box; + IndexT index = i + invocationOffset; + if (index >= totalJobs) + return; + + const NodeInstanceRange& stateRange = nodeInstanceStateRanges[index]; + const NodeInstanceRange& transformRange = nodeInstanceTransformRanges[index]; + const Graphics::StageMask stageMask = stageMasks[index]; + if (stateRange.end < stateRange.begin || transformRange.end < transformRange.begin) + continue; + if (stateRange.end > NodeInstances.renderable.nodeFlags.Size()) + continue; + if (transformRange.end > NodeInstances.transformable.nodeTransforms.Size()) + continue; - Math::point center = box.center(); - - float lodScale = FLT_MAX; - for (IndexT camIndex = 0; camIndex < numCameras; camIndex++) + SizeT j; + for (j = stateRange.begin; j < stateRange.end; j++) { - // Skip cameras not in this stage - if ((stageMask & cameraStageMasks[camIndex]) == 0) - continue; - - const Math::mat4& view = viewTransforms[camIndex]; - const CameraSettings& settings = cameraSettings[camIndex]; - - // https://iquilezles.org/articles/sphereproj/ - Math::vec4 centerInViewSpace = view * center; - float l2 = Math::dot(xyz(centerInViewSpace), xyz(centerInViewSpace)); - float r2 = radius * radius; - - float denom = l2 - r2; - float projectedArea = 0.0f; - if (denom <= 0.0f) - projectedArea = 2.0f; // (viewportWidth * viewportHeight); - else + Math::mat4 transform = NodeInstances.transformable.nodeTransforms[transformRange.begin + NodeInstances.renderable.nodeTransformIndex[j]]; + Math::bbox box = NodeInstances.renderable.origBoundingBoxes[j]; + float radius = box.diagonal_size() / 2; + box.affine_transform(transform); + instanceBoxes[j] = box; + + Math::point center = box.center(); + + float lodScale = FLT_MAX; + for (IndexT camIndex = 0; camIndex < numCameras; camIndex++) { - float f_x = settings.GetFov() * (settings.GetFarHeight() * 0.5f); - projectedArea = PI * r2 * f_x * f_x / denom; - projectedArea = Math::min(2.0f, sqrt(projectedArea / PI) / (0.5f * settings.GetFarHeight())); + // Skip cameras not in this stage + if ((stageMask & cameraStageMasks[camIndex]) == 0) + continue; + + const Math::mat4& view = viewTransforms[camIndex]; + const CameraSettings& settings = cameraSettings[camIndex]; + + // https://iquilezles.org/articles/sphereproj/ + Math::vec4 centerInViewSpace = view * center; + float l2 = Math::dot(xyz(centerInViewSpace), xyz(centerInViewSpace)); + float r2 = radius * radius; + + float denom = l2 - r2; + float projectedArea = 0.0f; + if (denom <= 0.0f) + projectedArea = 2.0f; // (viewportWidth * viewportHeight); + else + { + float f_x = settings.GetFov() * (settings.GetFarHeight() * 0.5f); + projectedArea = PI * r2 * f_x * f_x / denom; + projectedArea = Math::min(2.0f, sqrt(projectedArea / PI) / (0.5f * settings.GetFarHeight())); + } + + lodScale = Math::min(lodScale, log2(2.0f / projectedArea)); } - lodScale = Math::min(lodScale, log2(2.0f / projectedArea)); - } - - float textureLod = lodScale; - if (lodScale < NodeInstances.renderable.textureLods[j]) - { - // Notify materials system this LOD might be used (this is a bit shitty in comparison to actually using texture sampling feedback) - Materials::MaterialSetLowestLod(NodeInstances.renderable.nodeMaterials[j], lodScale); - NodeInstances.renderable.textureLods[j] = lodScale; - } + float textureLod = lodScale; + if (lodScale < NodeInstances.renderable.textureLods[j]) + { + // Notify materials system this LOD might be used (this is a bit shitty in comparison to actually using texture sampling feedback) + Materials::MaterialSetLowestLod(NodeInstances.renderable.nodeMaterials[j], lodScale); + NodeInstances.renderable.textureLods[j] = lodScale; + } - Models::NodeInstanceFlags nodeFlag = NodeInstances.renderable.nodeFlags[j]; - Math::vec4 viewVector = cameraTransform.position - transform.position; - float viewDistance = length(viewVector); + Models::NodeInstanceFlags nodeFlag = NodeInstances.renderable.nodeFlags[j]; + Math::vec4 viewVector = cameraTransform.position - transform.position; + float viewDistance = length(viewVector); - // Calculate if object should be culled due to LOD - const auto& [min, max] = NodeInstances.renderable.nodeLodDistances[j]; - float lodFactor = 0.0f; - if (min < FLT_MAX || max < FLT_MAX) - { - lodFactor = (viewDistance - (min + 1.5f)) / (max - (min + 1.5f)); - if (viewDistance >= min && viewDistance < max) - nodeFlag = SetBits(nodeFlag, Models::NodeInstanceFlags::NodeInstance_LodActive); + // Calculate if object should be culled due to LOD + const auto& [min, max] = NodeInstances.renderable.nodeLodDistances[j]; + float lodFactor = 0.0f; + if (min < FLT_MAX || max < FLT_MAX) + { + lodFactor = (viewDistance - (min + 1.5f)) / (max - (min + 1.5f)); + if (viewDistance >= min && viewDistance < max) + nodeFlag = SetBits(nodeFlag, Models::NodeInstanceFlags::NodeInstance_LodActive); + else + nodeFlag = UnsetBits(nodeFlag, Models::NodeInstanceFlags::NodeInstance_LodActive); + } else - nodeFlag = UnsetBits(nodeFlag, Models::NodeInstanceFlags::NodeInstance_LodActive); - } - else - // If not, make the lod active by default - nodeFlag = SetBits(nodeFlag, Models::NodeInstanceFlags::NodeInstance_LodActive); + // If not, make the lod active by default + nodeFlag = SetBits(nodeFlag, Models::NodeInstanceFlags::NodeInstance_LodActive); - // Set the flags back - NodeInstances.renderable.nodeFlags[j] = nodeFlag; + // Set the flags back + NodeInstances.renderable.nodeFlags[j] = nodeFlag; - // Set LOD factor for dithering and other shader effects - NodeInstances.renderable.nodeLods[j] = lodFactor; + // Set LOD factor for dithering and other shader effects + NodeInstances.renderable.nodeLods[j] = lodFactor; + } } - } - }, nodeInstanceStateRanges.Size(), 256, { &TransformsUpdateCounter }, &lodUpdateCounter, nullptr); + }, nodeInstanceStateRanges.Size(), 256, { &TransformsUpdateCounter }, &lodUpdateCounter, nullptr); + } + else + { + lodUpdateCounter = 0; + } - n_assert(ConstantsUpdateCounter == 0); ConstantsUpdateCounter = 1; + + // Reuse the LOD work count since both use nodeInstanceStateRanges + if (totalLodWork > 0) + { // Create a set of default joints static Util::FixedArray defaultJoints(256); @@ -1018,6 +1087,13 @@ ModelContext::UpdateTransforms(const Graphics::FrameContext& ctx) const NodeInstanceRange& stateRange = nodeInstanceStateRanges[index]; const NodeInstanceRange& transformRange = nodeInstanceTransformRanges[index]; + if (stateRange.end < stateRange.begin || transformRange.end < transformRange.begin) + continue; + if (stateRange.end > NodeInstances.renderable.nodeStates.Size()) + continue; + if (transformRange.end > NodeInstances.transformable.nodeTransforms.Size()) + continue; + SizeT j; for (j = stateRange.begin; j < stateRange.end; j++) { @@ -1043,6 +1119,12 @@ ModelContext::UpdateTransforms(const Graphics::FrameContext& ctx) } } }, nodeInstanceStateRanges.Size(), 256, { &lodUpdateCounter }, &ConstantsUpdateCounter, &ModelContext::completionEvent); + } + else + { + ConstantsUpdateCounter = 0; + ModelContext::completionEvent.Signal(); + } } //------------------------------------------------------------------------------ @@ -1050,9 +1132,15 @@ ModelContext::UpdateTransforms(const Graphics::FrameContext& ctx) */ void ModelContext::WaitForWork(const Graphics::FrameContext& ctx) + { N_SCOPE(WaitForModels, Graphics); - ModelContext::completionEvent.Wait(); + + // If all model update work is already complete (counters at 0), skip the wait + if (TransformsUpdateCounter == 0 && ConstantsUpdateCounter == 0) + { + return; + } } //------------------------------------------------------------------------------ diff --git a/code/render/models/modelcontext.h b/code/render/models/modelcontext.h index f6c9121300..0a2f8d8db8 100644 --- a/code/render/models/modelcontext.h +++ b/code/render/models/modelcontext.h @@ -273,7 +273,26 @@ class ModelContext : public Graphics::GraphicsContext inline Graphics::ContextEntityId ModelContext::Alloc() { - return modelContextAllocator.Alloc(); + Graphics::ContextEntityId id = modelContextAllocator.Alloc(); + + modelContextAllocator.Get(id.id) = Resources::InvalidResourceId; + modelContextAllocator.Get(id.id).Clear(); + modelContextAllocator.Get(id.id).Clear(); + modelContextAllocator.Get(id.id) = { + .allocation = { .offset = Memory::RangeAllocation::OOM, .size = 0, .node = (uint)Memory::RangeAllocation::OOM }, + .begin = 0, + .end = 0 + }; + modelContextAllocator.Get(id.id) = { + .allocation = { .offset = Memory::RangeAllocation::OOM, .size = 0, .node = (uint)Memory::RangeAllocation::OOM }, + .begin = 0, + .end = 0 + }; + modelContextAllocator.Get(id.id) = Math::mat4::identity; + modelContextAllocator.Get(id.id) = Graphics::PRIMARY_STAGE_MASK | Graphics::SHADOW_STAGE_MASK; + modelContextAllocator.Get(id.id) = false; + + return id; } //------------------------------------------------------------------------------ @@ -287,12 +306,30 @@ ModelContext::Dealloc(Graphics::ContextEntityId id) if (rid != Resources::InvalidResourceId) // decrement model resource Resources::DiscardResource(rid); - rid = Resources::InvalidResourceId; + modelContextAllocator.Get(id.id) = Resources::InvalidResourceId; modelContextAllocator.Get(id.id).Clear(); modelContextAllocator.Get(id.id).Clear(); - TransformInstanceAllocator.Dealloc(modelContextAllocator.Get(id.id).allocation); - RenderInstanceAllocator.Dealloc(modelContextAllocator.Get(id.id).allocation); + + auto& transformRange = modelContextAllocator.Get(id.id); + if (transformRange.allocation.offset != Memory::RangeAllocation::OOM) + TransformInstanceAllocator.Dealloc(transformRange.allocation); + + auto& stateRange = modelContextAllocator.Get(id.id); + if (stateRange.allocation.offset != Memory::RangeAllocation::OOM) + RenderInstanceAllocator.Dealloc(stateRange.allocation); + + transformRange = { + .allocation = { .offset = Memory::RangeAllocation::OOM, .size = 0, .node = (uint)Memory::RangeAllocation::OOM }, + .begin = 0, + .end = 0 + }; + stateRange = { + .allocation = { .offset = Memory::RangeAllocation::OOM, .size = 0, .node = (uint)Memory::RangeAllocation::OOM }, + .begin = 0, + .end = 0 + }; + modelContextAllocator.Get(id.id) = false; modelContextAllocator.Dealloc(id.id); } diff --git a/toolkit/editor/CMakeLists.txt b/toolkit/editor/CMakeLists.txt index e529a1aa79..2a73c050c9 100644 --- a/toolkit/editor/CMakeLists.txt +++ b/toolkit/editor/CMakeLists.txt @@ -163,7 +163,9 @@ fips_dir(editor) pathconverter.cc pathconverter.h ) -fips_deps(foundation application graphicsfeature physicsfeature audio toolkit-common toolkitutil) +fips_deps(foundation application render toolkit-common toolkitutil) +target_link_libraries(editor graphicsfeature physicsfeature audio dynui scripting) +set_property(TARGET editor PROPERTY INTERFACE_LINK_LIBRARIES "foundation;application;render;toolkit-common;toolkitutil") nebula_end_module() nebula_begin_shared_module(editorfeaturemodule) @@ -171,5 +173,5 @@ target_include_directories(editorfeaturemodule PUBLIC ${CMAKE_CURRENT_SOURCE_DIR fips_files( editorfeaturemodule.cc ) -fips_deps(editor application) +fips_deps(editor application render scripting) nebula_end_shared_module() diff --git a/toolkit/editor/editor/bindings/editorbindings.cc b/toolkit/editor/editor/bindings/editorbindings.cc index 5f0b7762f7..52f16c56e8 100644 --- a/toolkit/editor/editor/bindings/editorbindings.cc +++ b/toolkit/editor/editor/bindings/editorbindings.cc @@ -4,12 +4,58 @@ //------------------------------------------------------------------------------ #include "foundation/stdneb.h" #include "editor/commandmanager.h" +#include "editor/editor.h" #include "scripting/python/conversion.h" #include "scripting/scriptserver.h" #include "editor/ui/windowserver.h" namespace py = nanobind; +extern "C" PyObject* PyInit_editor(); + +namespace +{ + +void +RegisterEditorPythonModule() +{ + if (!Py_IsInitialized()) + { + PyImport_AppendInittab("editor", PyInit_editor); + return; + } + + PyGILState_STATE gilState = PyGILState_Ensure(); + + PyObject* modules = PyImport_GetModuleDict(); + if (modules != nullptr && PyDict_GetItemString(modules, "editor") != nullptr) + { + PyGILState_Release(gilState); + return; + } + + PyObject* module = PyInit_editor(); + if (module == nullptr) + { + PyErr_Print(); + PyGILState_Release(gilState); + return; + } + + if (modules == nullptr || PyDict_SetItemString(modules, "editor", module) != 0) + { + PyErr_Print(); + Py_DECREF(module); + PyGILState_Release(gilState); + return; + } + + Py_DECREF(module); + PyGILState_Release(gilState); +} + +} + /// @todo There should be no more than one python module per binding file. NB_MODULE(editor, m) { @@ -30,15 +76,17 @@ NB_MODULE(editor, m) n_assert(Presentation::WindowServer::HasInstance()); Presentation::WindowServer::Instance()->RegisterWindowScript(scriptfile, label); }); + + // Explicitly trigger a hot-reload of the editor feature module. + // The reload is deferred to the next frame boundary and rejected if + // play-in-editor is currently active. + m.def("reload_editor_module", []() { Editor::RequestModuleReload(); }); } namespace Scripting { void RegisterEditorBinds() { - Scripting::ScriptServer::RegisterModuleInit([]() - { - PyImport_AppendInittab("editor", PyInit_editor); - }); + RegisterEditorPythonModule(); } } \ No newline at end of file diff --git a/toolkit/editor/editor/editor.cc b/toolkit/editor/editor/editor.cc index db3564adb2..cb00a5135d 100644 --- a/toolkit/editor/editor/editor.cc +++ b/toolkit/editor/editor/editor.cc @@ -5,7 +5,12 @@ #include "foundation/stdneb.h" #include "editor.h" +#include "appgame/gameapplication.h" +#include "game/modulemanager.h" +#include "entityloader.h" #include "io/assignregistry.h" +#include "io/ioserver.h" +#include "io/jsonreader.h" #include "scripting/scriptserver.h" #include "memdb/database.h" #include "game/api.h" @@ -29,25 +34,94 @@ namespace Editor { +namespace +{ + +const char* ReloadSnapshotUri = "user:nebula/editor/hotreload_snapshot.json"; + +//------------------------------------------------------------------------------ +/** +*/ +bool +RestoreReloadSnapshot() +{ + if (!Game::EditorState::HasInstance()) + return false; + + Game::EditorState* editorState = Game::EditorState::Instance(); + if (!editorState->reloadSnapshotPending || !editorState->reloadSnapshotPath.IsValid()) + return false; + + IO::URI snapshotUri = IO::URI(editorState->reloadSnapshotPath); + if (!IO::IoServer::Instance()->FileExists(snapshotUri)) + { + editorState->reloadSnapshotPending = false; + return false; + } + + Game::World* gameWorld = Game::GetWorld(WORLD_DEFAULT); + n_assert(gameWorld != nullptr); + + Game::GameServer::Instance()->CleanupWorld(gameWorld); + Game::GameServer::Instance()->SetupEmptyWorld(gameWorld); + + Ptr loader = EntityLoader::Create(); + loader->SetWorld(state.editorWorld); + + Ptr reader = IO::JsonReader::Create(); + reader->SetStream(IO::IoServer::Instance()->CreateStream(snapshotUri)); + if (!reader->Open()) + { + editorState->reloadSnapshotPending = false; + return false; + } + + loader->LoadJsonLevel(reader); + reader->Close(); + + Edit::CommandManager::Clear(); + Edit::CommandManager::SetClean(); + + editorState->reloadSnapshotPending = false; + return true; +} + +} + //------------------------------------------------------------------------------ /** */ State state; +//------------------------------------------------------------------------------ +/** +*/ +bool +IsCreated() +{ + return state.editorWorld != nullptr; +} + //------------------------------------------------------------------------------ /** */ void Create() { + if (IsCreated()) + return; + IO::AssignRegistry::Instance()->SetAssign(IO::Assign("edscr", "bin:editorscripts")); IO::AssignRegistry::Instance()->SetAssign(IO::Assign("work", "proj:work")); IO::AssignRegistry::Instance()->SetAssign(IO::Assign("assets", "work:assets")); IO::AssignRegistry::Instance()->SetAssign(IO::Assign("src", "proj:work")); - Game::TimeSourceCreateInfo editorTimeSourceInfo; - editorTimeSourceInfo.hash = TIMESOURCE_EDITOR; - Game::Time::CreateTimeSource(editorTimeSourceInfo); + if (!Game::Time::HasTimeSource(TIMESOURCE_EDITOR)) + { + Game::TimeSourceCreateInfo editorTimeSourceInfo; + editorTimeSourceInfo.hash = TIMESOURCE_EDITOR; + Game::Time::CreateTimeSource(editorTimeSourceInfo); + } ToolkitUtil::ProjectInfo projectInfo; ToolkitUtil::ProjectInfo::Result res = projectInfo.Setup(); @@ -73,7 +147,15 @@ Create() Edit::CommandManager::Create(20_MB); CreatePathConverter({}); - Game::EditorState::Singleton = new Game::EditorState(); + if (!Game::EditorState::HasInstance()) + { + Game::EditorState::Singleton = new Game::EditorState(); + } + + if (RestoreReloadSnapshot()) + { + state.lastReloadStatus = "Reload restore complete"; + } } //------------------------------------------------------------------------------ @@ -82,11 +164,16 @@ Create() void Start() { - Scripting::ScriptServer::Instance()->AddModulePath("edscr:"); - Scripting::ScriptServer::Instance()->EvalFile("edscr:bootstrap.py"); + if (!Game::EditorState::Instance()->pythonBootstrapInitialized) + { + Scripting::ScriptServer::Instance()->AddModulePath("edscr:"); + Scripting::ScriptServer::Instance()->EvalFile("edscr:bootstrap.py"); + + /// Import reload to be able to reload modules. + Scripting::ScriptServer::Instance()->Eval("from importlib import reload"); - /// Import reload to be able to reload modules. - Scripting::ScriptServer::Instance()->Eval("from importlib import reload"); + Game::EditorState::Instance()->pythonBootstrapInitialized = true; + } Game::EditorState::Instance()->isRunning = true; } @@ -97,9 +184,24 @@ Start() void Destroy() { + if (Game::EditorState::HasInstance()) + { + Game::EditorState::Instance()->isRunning = false; + Game::EditorState::Instance()->isPlaying = false; + } + + if (state.editorWorld != nullptr) + { + Game::GameServer::Instance()->DestroyWorld(WORLD_EDITOR); + state.editorWorld = nullptr; + state.editables.Reset(); + } + LiveBatcher::Discard(); Edit::CommandManager::Discard(); - delete Game::EditorState::Singleton; + + Game::Time::DestroyTimeSource(TIMESOURCE_EDITOR); + } //------------------------------------------------------------------------------ @@ -165,5 +267,51 @@ StopGame() gameTimeSource->timeFactor = 0.0f; } +//------------------------------------------------------------------------------ +/** +*/ +void +RequestModuleReload() +{ + if (!Game::EditorState::HasInstance() || Game::EditorState::Instance()->isPlaying) + { + state.lastReloadStatus = "Reload blocked: play-in-editor is active"; + n_printf("Editor: %s\n", state.lastReloadStatus.AsCharPtr()); + return; + } + + Ptr mm = App::GameApplication::Instance()->GetModuleManager(); + if (!mm.isvalid()) + { + state.lastReloadStatus = "Reload failed: no module manager"; + n_printf("Editor: %s\n", state.lastReloadStatus.AsCharPtr()); + return; + } + + IO::IoServer::Instance()->CreateDirectory("user:nebula/editor/"); + if (!SaveEntities(ReloadSnapshotUri)) + { + state.lastReloadStatus = "Reload failed: could not snapshot current level"; + n_printf("Editor: %s\n", state.lastReloadStatus.AsCharPtr()); + return; + } + + Game::EditorState::Instance()->reloadSnapshotPath = ReloadSnapshotUri; + Game::EditorState::Instance()->reloadSnapshotPending = true; + + mm->QueueModuleReload("editorfeaturemodule"); + state.lastReloadStatus = "Reload queued..."; + n_printf("Editor: editor feature module reload queued\n"); +} + +//------------------------------------------------------------------------------ +/** +*/ +const Util::String& +GetLastReloadStatus() +{ + return state.lastReloadStatus; +} + } // namespace Editor diff --git a/toolkit/editor/editor/editor.h b/toolkit/editor/editor/editor.h index 0a7a31dbde..cb06cfd0a5 100644 --- a/toolkit/editor/editor/editor.h +++ b/toolkit/editor/editor/editor.h @@ -42,6 +42,8 @@ struct State Game::World* editorWorld; /// maps from editor entity index to editable Util::Array editables; + /// result of the last module reload attempt; empty before any attempt + Util::String lastReloadStatus; }; /// Create the editor @@ -53,6 +55,17 @@ void Start(); /// Destroy the editor void Destroy(); +/// Returns true if editor state/world is initialized +bool IsCreated(); + +/// Request an explicit reload of the editor feature module. +/// The reload is deferred to the next frame boundary. +/// Rejected if play-in-editor is currently active. +void RequestModuleReload(); + +/// Return the result string of the last RequestModuleReload() attempt. +const Util::String& GetLastReloadStatus(); + /// Start playing the game. void PlayGame(); diff --git a/toolkit/editor/editor/entityloader.cc b/toolkit/editor/editor/entityloader.cc index f0ab20b32a..c35d1b4080 100644 --- a/toolkit/editor/editor/entityloader.cc +++ b/toolkit/editor/editor/entityloader.cc @@ -45,6 +45,14 @@ SaveEntities(const char* filePath) Ptr writer = IO::JsonWriter::Create(); writer->SetStream(IO::IoServer::Instance()->CreateStream(file)); + struct ScopedEntityOverrideCleanup + { + ~ScopedEntityOverrideCleanup() + { + Game::ComponentSerialization::OverrideType(Game::ComponentSerialization::ENTITY, nullptr, nullptr); + } + } cleanup; + // TODO: Maybe move this to a SceneSerializer class that can be used outside of the editor as well. // TODO: only set once, both serialize and deserialize diff --git a/toolkit/editor/editor/tools/camera.cc b/toolkit/editor/editor/tools/camera.cc index 6a0fc8e4d0..ec7fdf9d50 100644 --- a/toolkit/editor/editor/tools/camera.cc +++ b/toolkit/editor/editor/tools/camera.cc @@ -38,6 +38,22 @@ Camera::Camera() */ Camera::~Camera() { + if (this->cameraEntityId == Graphics::InvalidGraphicsEntityId) + return; + + if (Visibility::ObserverContext::IsEntityRegistered(this->cameraEntityId)) + { + Visibility::ObserverContext::DeregisterEntityImmediate(this->cameraEntityId); + } + + if (Graphics::CameraContext::IsEntityRegistered(this->cameraEntityId)) + { + Graphics::CameraContext::RemoveLODCamera(this->cameraEntityId); + Graphics::CameraContext::DeregisterEntityImmediate(this->cameraEntityId); + } + + Graphics::DestroyEntity(this->cameraEntityId); + this->cameraEntityId = Graphics::InvalidGraphicsEntityId; } //------------------------------------------------------------------------------ diff --git a/toolkit/editor/editor/tools/camera.h b/toolkit/editor/editor/tools/camera.h index a0a13982b7..0d7fcc9086 100644 --- a/toolkit/editor/editor/tools/camera.h +++ b/toolkit/editor/editor/tools/camera.h @@ -67,7 +67,7 @@ class Camera float orthoHeight = 20; private: - Graphics::GraphicsEntityId cameraEntityId; + Graphics::GraphicsEntityId cameraEntityId = Graphics::InvalidGraphicsEntityId; Math::transform44 transform; diff --git a/toolkit/editor/editor/ui/modules/viewport.cc b/toolkit/editor/editor/ui/modules/viewport.cc index 856f27f178..a8c4866767 100644 --- a/toolkit/editor/editor/ui/modules/viewport.cc +++ b/toolkit/editor/editor/ui/modules/viewport.cc @@ -34,7 +34,18 @@ Viewport::Viewport() */ Viewport::~Viewport() { + if (this->directionalLight != Graphics::InvalidGraphicsEntityId) + { + if (Lighting::LightContext::IsEntityRegistered(this->directionalLight)) + Lighting::LightContext::DeregisterEntityImmediate(this->directionalLight); + Graphics::DestroyEntity(this->directionalLight); + } + + if (this->targetTexture != CoreGraphics::InvalidTextureId) + CoreGraphics::DestroyTexture(this->targetTexture); + if (this->ownsView && this->view != Graphics::InvalidViewId) + Graphics::GraphicsServer::Instance()->DiscardView(this->view); } //------------------------------------------------------------------------------ @@ -43,6 +54,7 @@ Viewport::~Viewport() void Viewport::Init(Util::String const & viewName, const Graphics::StageMask mask) { + this->ownsView = true; static int unique = 0; Util::String name = viewName; name.AppendInt(unique++); diff --git a/toolkit/editor/editor/ui/modules/viewport.h b/toolkit/editor/editor/ui/modules/viewport.h index 46ae31fd31..e2fa1f05d5 100644 --- a/toolkit/editor/editor/ui/modules/viewport.h +++ b/toolkit/editor/editor/ui/modules/viewport.h @@ -62,13 +62,15 @@ class Viewport private: RenderMode renderMode = TexturedLit; - Graphics::ViewId view; - Graphics::GraphicsEntityId directionalLight; + Graphics::ViewId view = Graphics::InvalidViewId; + Graphics::GraphicsEntityId directionalLight = Graphics::InvalidGraphicsEntityId; - CoreGraphics::TextureId targetTexture; + CoreGraphics::TextureId targetTexture = CoreGraphics::InvalidTextureId; Dynui::ImguiTextureId textureInfo; Resources::ResourceId resourceId; + bool ownsView = false; + bool focused = false; Util::String frameBuffer; diff --git a/toolkit/editor/editor/ui/uimanager.cc b/toolkit/editor/editor/ui/uimanager.cc index 88e170d210..b6b7b15ad5 100644 --- a/toolkit/editor/editor/ui/uimanager.cc +++ b/toolkit/editor/editor/ui/uimanager.cc @@ -166,6 +166,10 @@ UIManager::OnActivate() } }, "Import nlvl (game only)", "Ctrl+Shift+I", "File"); + // Recreate frame-script pipelines when UI is activated after hot-reload. + FrameScript_default::SetupPipelines(); + FrameScript_editorframe::SetupPipelines(); + // Graphics::GraphicsServer::Instance()->AddEndFrameCall([](IndexT frameIndex, IndexT bufferIndex) { @@ -242,6 +246,21 @@ UIManager::OnActivate() void UIManager::OnDeactivate() { + if (Graphics::GraphicsServer::HasInstance()) + { + // Remove editor-owned end-frame callback before module unload so no + // std::function targets keep code pointers into an unloaded shared object. + // Do NOT clear view callbacks — those belong to GraphicsFeatureUnit (shadow pass). + Graphics::GraphicsServer::Instance()->ClearEndFrameCalls(); + } + + // Clear all windows before releasing the server reference so they don't + // accumulate across hot-reloads (WindowServer is a persistent singleton). + if (windowServer != nullptr) + { + windowServer->ClearAllWindows(); + } + Game::Manager::OnDeactivate(); windowServer = nullptr; } diff --git a/toolkit/editor/editor/ui/windows/toolbar.cc b/toolkit/editor/editor/ui/windows/toolbar.cc index 990edac3ad..2f21cf6b72 100644 --- a/toolkit/editor/editor/ui/windows/toolbar.cc +++ b/toolkit/editor/editor/ui/windows/toolbar.cc @@ -89,6 +89,19 @@ Toolbar::Run(SaveMode save) if (ImGui::Button("Pause")) { PauseGame(); } ImGui::SameLine(); if (ImGui::Button("Stop")) { StopGame(); } + + IMGUI_VERTICAL_SEPARATOR; + + if (ImGui::Button("Reload Editor")) + { + Editor::RequestModuleReload(); + } + const Util::String& reloadStatus = Editor::GetLastReloadStatus(); + if (reloadStatus.IsValid()) + { + ImGui::SameLine(); + ImGui::TextUnformatted(reloadStatus.AsCharPtr()); + } } } // namespace Presentation diff --git a/toolkit/editor/editor/ui/windowserver.h b/toolkit/editor/editor/ui/windowserver.h index 6992696f0b..cc3fde1970 100644 --- a/toolkit/editor/editor/ui/windowserver.h +++ b/toolkit/editor/editor/ui/windowserver.h @@ -51,6 +51,9 @@ class WindowServer : public Core::RefCounted /// Get window by name Ptr GetWindow(const Util::String& name); + /// Clear all registered windows + void ClearAllWindows(); + private: void AddCategory(const Util::String& category); diff --git a/toolkit/editor/editorfeature/editorfeatureunit.cc b/toolkit/editor/editorfeature/editorfeatureunit.cc index f521114cfd..2bf5253286 100644 --- a/toolkit/editor/editorfeature/editorfeatureunit.cc +++ b/toolkit/editor/editorfeature/editorfeatureunit.cc @@ -81,7 +81,7 @@ EditorFeatureUnit::OnActivate() // TODO: move this to a game manager that is created by the editor Game::World* world = Game::GetWorld(WORLD_DEFAULT); - Game::ProcessorBuilder(world, "EditorGameManager.UpdateModelTransforms"_atm) + this->processors.Append(Game::ProcessorBuilder(world, "EditorGameManager.UpdateModelTransforms"_atm) .On("OnEndFrame") .OnlyModified() .RunInEditor() @@ -92,9 +92,9 @@ EditorFeatureUnit::OnActivate() Models::ModelContext::SetTransform(model.graphicsEntityId, worldTransform); } ) - .Build(); + .Build()); - Game::ProcessorBuilder(world, "EditorGameManager.UpdatePointLightPositions"_atm) + this->processors.Append(Game::ProcessorBuilder(world, "EditorGameManager.UpdatePointLightPositions"_atm) .On("OnEndFrame") .OnlyModified() .RunInEditor() @@ -106,9 +106,9 @@ EditorFeatureUnit::OnActivate() Lighting::LightContext::SetPosition(light.graphicsEntityId, pos); } ) - .Build(); + .Build()); - Game::ProcessorBuilder(world, "EditorGameManager.UpdateSpotLightTransform"_atm) + this->processors.Append(Game::ProcessorBuilder(world, "EditorGameManager.UpdateSpotLightTransform"_atm) .On("OnEndFrame") .OnlyModified() .RunInEditor() @@ -122,9 +122,9 @@ EditorFeatureUnit::OnActivate() Lighting::LightContext::SetRotation(light.graphicsEntityId, rot); } ) - .Build(); + .Build()); - Game::ProcessorBuilder(world, "EditorGameManager.UpdateAreaLightTransform"_atm) + this->processors.Append(Game::ProcessorBuilder(world, "EditorGameManager.UpdateAreaLightTransform"_atm) .On("OnEndFrame") .OnlyModified() .RunInEditor() @@ -140,9 +140,9 @@ EditorFeatureUnit::OnActivate() Lighting::LightContext::SetScale(light.graphicsEntityId, scale); } ) - .Build(); + .Build()); - Game::ProcessorBuilder(world, "EditorGameManager.UpdateDecalTransform"_atm) + this->processors.Append(Game::ProcessorBuilder(world, "EditorGameManager.UpdateDecalTransform"_atm) .On("OnEndFrame") .OnlyModified() .RunInEditor() @@ -157,9 +157,9 @@ EditorFeatureUnit::OnActivate() Decals::DecalContext::SetTransform(decal.graphicsEntityId, transform); } ) - .Build(); + .Build()); - Game::ProcessorBuilder(world, "EditorGameManager.UpdateDDGIVolumeTransform"_atm) + this->processors.Append(Game::ProcessorBuilder(world, "EditorGameManager.UpdateDDGIVolumeTransform"_atm) .On("OnEndFrame") .OnlyModified() .RunInEditor() @@ -174,7 +174,7 @@ EditorFeatureUnit::OnActivate() GI::DDGIContext::SetSize(volume.graphicsEntityId, scale); } ) - .Build(); + .Build()); //if (!Editor::ConnectToBackend(...)) // Editor::SpawnLocalBackend(); @@ -187,6 +187,7 @@ EditorFeatureUnit::OnActivate() void EditorFeatureUnit::OnDeactivate() { + this->RemoveEditorProcessors(); FeatureUnit::OnDeactivate(); if (this->args.GetBoolFlag("-editor")) { @@ -194,6 +195,38 @@ EditorFeatureUnit::OnDeactivate() } } +//------------------------------------------------------------------------------ +/** +*/ +void +EditorFeatureUnit::RemoveEditorProcessors() +{ + if (this->processors.IsEmpty()) + return; + + Game::World* world = Game::GetWorld(WORLD_DEFAULT); + Game::FrameEvent* frameEvent = nullptr; + if (world != nullptr) + { + frameEvent = world->GetFramePipeline().GetFrameEvent("OnEndFrame"); + } + + for (IndexT i = 0; i < this->processors.Size(); i++) + { + if (this->processors[i] == nullptr) + continue; + + if (frameEvent != nullptr) + { + frameEvent->RemoveProcessor(this->processors[i]); + } + + delete this->processors[i]; + } + + this->processors.Clear(); +} + //------------------------------------------------------------------------------ /** */ diff --git a/toolkit/editor/editorfeature/editorfeatureunit.h b/toolkit/editor/editorfeature/editorfeatureunit.h index 5888f00d6d..c082d4586b 100644 --- a/toolkit/editor/editorfeature/editorfeatureunit.h +++ b/toolkit/editor/editorfeature/editorfeatureunit.h @@ -9,6 +9,7 @@ #include "core/refcounted.h" #include "core/singleton.h" #include "game/featureunit.h" +#include "game/processor.h" namespace EditorFeature { @@ -35,6 +36,9 @@ class EditorFeatureUnit : public Game::FeatureUnit virtual void OnFrame(); private: + void RemoveEditorProcessors(); + + Util::Array processors; }; } // namespace Editor diff --git a/toolkit/editor/editorfeaturemodule.cc b/toolkit/editor/editorfeaturemodule.cc index 4f342eeffc..4d4acde92a 100644 --- a/toolkit/editor/editorfeaturemodule.cc +++ b/toolkit/editor/editorfeaturemodule.cc @@ -6,6 +6,7 @@ #include "core/factory.h" #include "game/moduleinterface.h" #include "editorfeature/editorfeatureunit.h" +#include "cr/cr.h" #if __WIN32__ #define NEBULA_MODULE_EXPORT extern "C" __declspec(dllexport) @@ -37,3 +38,28 @@ NebulaModuleDestroyFeature(void* feature) { (void)feature; } + +//------------------------------------------------------------------------------ +// cr plugin entry point — handles plugin lifecycle events from a cr host. +//------------------------------------------------------------------------------ +CR_EXPORT int +cr_main(struct cr_plugin* ctx, enum cr_op operation) +{ + (void)ctx; + switch (operation) + { + case CR_LOAD: + // Module just loaded or reloaded; nothing to restore for now. + break; + case CR_UNLOAD: + // About to be unloaded for a reload; flush any pending work here. + break; + case CR_CLOSE: + // Final shutdown; nothing extra needed — Nebula module teardown + // is handled via NebulaModuleDestroyFeature / OnDeactivate. + break; + default: + break; + } + return 0; +} From 451437c8bd2e790a03f1806c3ac1fbe5ff38d06c Mon Sep 17 00:00:00 2001 From: Johannes Date: Fri, 1 May 2026 22:55:52 +0200 Subject: [PATCH 04/19] - hotreload fixes --- code/addons/dynui/console/imguiconsole.cc | 28 ++- code/addons/memdb/attributeregistry.h | 84 ++++++++- code/application/game/featureunit.cc | 2 + code/application/game/modulemanager.cc | 91 +++++----- code/application/game/modulemanager.h | 1 + fips-files/verbs/modulebuild.py | 42 +++++ toolkit/editor/CMakeLists.txt | 2 +- .../editor/editor/bindings/editorbindings.cc | 16 +- toolkit/editor/editor/editor.cc | 162 +++++++++++++++++- toolkit/editor/editor/editor.h | 6 +- toolkit/editor/editor/ui/windows/toolbar.cc | 2 +- toolkit/editor/editor/ui/windowserver.cc | 11 ++ 12 files changed, 373 insertions(+), 74 deletions(-) create mode 100644 fips-files/verbs/modulebuild.py diff --git a/code/addons/dynui/console/imguiconsole.cc b/code/addons/dynui/console/imguiconsole.cc index 892c0ff054..0643ffd428 100644 --- a/code/addons/dynui/console/imguiconsole.cc +++ b/code/addons/dynui/console/imguiconsole.cc @@ -373,6 +373,13 @@ ImguiConsole::Render() void ImguiConsole::RenderContent() { + Util::Array logSnapshot; + logSnapshot.Reserve(this->consoleBuffer.Size()); + for (IndexT i = 0; i < this->consoleBuffer.Size(); i++) + { + logSnapshot.Append(this->consoleBuffer[i]); + } + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0, 0)); static ImGuiTextFilter filter; filter.Draw("Filter", 180); @@ -383,7 +390,10 @@ ImguiConsole::RenderContent() if (ImGui::BeginPopupContextWindow()) { if (ImGui::Selectable("Clear")) + { this->consoleBuffer.Reset(); + logSnapshot.Clear(); + } ImGui::EndPopup(); } @@ -392,16 +402,16 @@ ImguiConsole::RenderContent() // Unfortunately we can't use ImGui::Clipper here since each entry might have a different height. // TODO: We could roll our own "clipper". ImGui::PushTextWrapPos(ImGui::GetWindowContentRegionMax().x); - for (int i = 0; i < consoleBuffer.Size(); i++) + for (int i = 0; i < logSnapshot.Size(); i++) { - const char* item = consoleBuffer[i].msg.AsCharPtr(); + const char* item = logSnapshot[i].msg.AsCharPtr(); //Filter on both time, prefix and entry - if (!filter.PassFilter(item) && !filter.PassFilter(this->LogEntryTypeAsCharPtr(consoleBuffer[i].type))) + if (!filter.PassFilter(item) && !filter.PassFilter(this->LogEntryTypeAsCharPtr(logSnapshot[i].type))) continue; ImVec4 col; - switch (consoleBuffer[i].type) + switch (logSnapshot[i].type) { case N_MESSAGE: col = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); @@ -428,9 +438,9 @@ ImguiConsole::RenderContent() ImGui::PushStyleColor(ImGuiCol_Text, col); ImGui::SameLine(); //Print message prefix - if (consoleBuffer[i].type != N_MESSAGE) + if (logSnapshot[i].type != N_MESSAGE) { - ImGui::TextUnformatted(this->LogEntryTypeAsCharPtr(consoleBuffer[i].type)); + ImGui::TextUnformatted(this->LogEntryTypeAsCharPtr(logSnapshot[i].type)); ImGui::SameLine(); } //Print log entry @@ -441,11 +451,11 @@ ImguiConsole::RenderContent() ImGui::PopTextWrapPos(); static SizeT lastConsoleBufferSize = 0; - if (this->scrollToBottom && consoleBuffer.Size() != lastConsoleBufferSize) + if (this->scrollToBottom && logSnapshot.Size() != lastConsoleBufferSize) { ImGui::SetScrollHereY(); } - lastConsoleBufferSize = consoleBuffer.Size(); + lastConsoleBufferSize = logSnapshot.Size(); ImGui::PopStyleVar(); ImGui::EndChild(); @@ -634,7 +644,7 @@ ImguiConsole::Execute(const Util::String& command) void ImguiConsole::AppendToLog(const LogEntry& msg) { - this->consoleBuffer.Add(msg); + this->consoleBuffer.Add(msg); } //------------------------------------------------------------------------------ diff --git a/code/addons/memdb/attributeregistry.h b/code/addons/memdb/attributeregistry.h index 0898ff4f64..fe162a92de 100644 --- a/code/addons/memdb/attributeregistry.h +++ b/code/addons/memdb/attributeregistry.h @@ -39,12 +39,20 @@ class AttributeRegistry static AttributeId GetAttributeId(Util::StringAtom name); /// get attribute description by id static Attribute* GetAttribute(AttributeId descriptor); + /// try get attribute description by id + static Attribute* TryGetAttribute(AttributeId descriptor); /// get type size by attribute id static SizeT TypeSize(AttributeId descriptor); + /// try get type size by attribute id + static bool TryGetTypeSize(AttributeId descriptor, SizeT& outTypeSize); /// get flags by attribute id static uint32_t Flags(AttributeId descriptor); + /// try get flags by attribute id + static bool TryGetFlags(AttributeId descriptor, uint32_t& outFlags); /// get attribute default value pointer static void const* const DefaultValue(AttributeId descriptor); + /// try get attribute default value pointer + static bool TryGetDefaultValue(AttributeId descriptor, void const*& outDefaultValue); /// get an array of all attributes static Util::FixedArray const& GetAllAttributes(); /// unregister an attribute by id (safe no-op if missing) @@ -254,11 +262,23 @@ AttributeRegistry::GetAttributeId(Util::StringAtom name) */ inline Attribute* AttributeRegistry::GetAttribute(AttributeId descriptor) +{ + n_assert(AttributeRegistry::IsRegistered(descriptor)); + if (!AttributeRegistry::IsRegistered(descriptor)) + return nullptr; + + auto* reg = Instance(); + return reg->componentDescriptions[descriptor.id]; +} + +//------------------------------------------------------------------------------ +/** +*/ +inline Attribute* +AttributeRegistry::TryGetAttribute(AttributeId descriptor) { if (!AttributeRegistry::IsRegistered(descriptor)) - { return nullptr; - } auto* reg = Instance(); return reg->componentDescriptions[descriptor.id]; @@ -270,45 +290,93 @@ AttributeRegistry::GetAttribute(AttributeId descriptor) inline SizeT AttributeRegistry::TypeSize(AttributeId descriptor) { + n_assert(AttributeRegistry::IsRegistered(descriptor)); if (!AttributeRegistry::IsRegistered(descriptor)) - { return 0; - } auto* reg = Instance(); return reg->componentDescriptions[descriptor.id]->typeSize; } +//------------------------------------------------------------------------------ +/** +*/ +inline bool +AttributeRegistry::TryGetTypeSize(AttributeId descriptor, SizeT& outTypeSize) +{ + if (!AttributeRegistry::IsRegistered(descriptor)) + { + outTypeSize = 0; + return false; + } + + auto* reg = Instance(); + outTypeSize = reg->componentDescriptions[descriptor.id]->typeSize; + return true; +} + //------------------------------------------------------------------------------ /** */ inline uint32_t AttributeRegistry::Flags(AttributeId descriptor) { + n_assert(AttributeRegistry::IsRegistered(descriptor)); if (!AttributeRegistry::IsRegistered(descriptor)) - { return 0; - } auto* reg = Instance(); return reg->componentDescriptions[descriptor.id]->externalFlags; } +//------------------------------------------------------------------------------ +/** +*/ +inline bool +AttributeRegistry::TryGetFlags(AttributeId descriptor, uint32_t& outFlags) +{ + if (!AttributeRegistry::IsRegistered(descriptor)) + { + outFlags = 0; + return false; + } + + auto* reg = Instance(); + outFlags = reg->componentDescriptions[descriptor.id]->externalFlags; + return true; +} + //------------------------------------------------------------------------------ /** */ inline void const* const AttributeRegistry::DefaultValue(AttributeId descriptor) { + n_assert(AttributeRegistry::IsRegistered(descriptor)); if (!AttributeRegistry::IsRegistered(descriptor)) - { return nullptr; - } auto* reg = Instance(); return reg->componentDescriptions[descriptor.id]->defVal; } +//------------------------------------------------------------------------------ +/** +*/ +inline bool +AttributeRegistry::TryGetDefaultValue(AttributeId descriptor, void const*& outDefaultValue) +{ + if (!AttributeRegistry::IsRegistered(descriptor)) + { + outDefaultValue = nullptr; + return false; + } + + auto* reg = Instance(); + outDefaultValue = reg->componentDescriptions[descriptor.id]->defVal; + return true; +} + //------------------------------------------------------------------------------ /** */ diff --git a/code/application/game/featureunit.cc b/code/application/game/featureunit.cc index 780ea58761..cee6f3ccdd 100644 --- a/code/application/game/featureunit.cc +++ b/code/application/game/featureunit.cc @@ -7,6 +7,7 @@ #include "game/featureunit.h" #include "game/gameserver.h" #include "gameserver.h" +#include "memdb/attributeregistry.h" namespace Game { @@ -47,6 +48,7 @@ FeatureUnit::OnRemove() for (IndexT i = this->registeredComponents.Size() - 1; i >= 0; i--) { const ComponentId cid = this->registeredComponents[i]; + MemDb::AttributeRegistry::Unregister(cid); Game::ComponentInspection::Unregister(cid); Game::ComponentSerialization::Unregister(cid); } diff --git a/code/application/game/modulemanager.cc b/code/application/game/modulemanager.cc index 818ceda638..a5290c6fc4 100644 --- a/code/application/game/modulemanager.cc +++ b/code/application/game/modulemanager.cc @@ -10,7 +10,6 @@ #include "io/fswrapper.h" #include "io/ioserver.h" #include "system/library.h" -#include namespace Game { @@ -85,17 +84,7 @@ ModuleManager::UnloadModules(GameServer* gameServer) bool ModuleManager::IsModuleLoaded(const Util::String& moduleName) const { - Util::String checkName = moduleName; - checkName.ToLower(); - - for (IndexT i = 0; i < this->loadedModules.Size(); i++) - { - Util::String loadedName = this->loadedModules[i].config.name; - loadedName.ToLower(); - if (loadedName == checkName) - return true; - } - return false; + return this->FindLoadedModuleIndex(moduleName) != InvalidIndex; } //------------------------------------------------------------------------------ @@ -115,14 +104,14 @@ ModuleManager::LoadModule(const RuntimeModuleConfig& moduleConfig, GameServer* g { if (moduleConfig.name.IsValid() && this->IsModuleLoaded(moduleConfig.name)) { - std::fprintf(stdout, "ModuleManager: module '%s' is already loaded, skipping duplicate request\n", moduleConfig.name.AsCharPtr()); + n_printf("ModuleManager: module '%s' is already loaded, skipping duplicate request\n", moduleConfig.name.AsCharPtr()); return true; } Util::String libraryPath = this->ResolveLibraryPath(moduleConfig); if (!libraryPath.IsValid()) { - std::fprintf(stderr, "ModuleManager: module '%s' has no valid path\n", moduleConfig.name.AsCharPtr()); + n_warning("ModuleManager: module '%s' has no valid path\n", moduleConfig.name.AsCharPtr()); return !(strictMode || moduleConfig.required); } @@ -140,7 +129,7 @@ ModuleManager::LoadModule(const RuntimeModuleConfig& moduleConfig, GameServer* g if (getDescriptor == nullptr || createFeature == nullptr) { - std::fprintf(stderr, "ModuleManager: module '%s' is missing required exports\n", moduleConfig.name.AsCharPtr()); + n_warning("ModuleManager: module '%s' is missing required exports\n", moduleConfig.name.AsCharPtr()); library->Close(); delete library; return !(strictMode || moduleConfig.required); @@ -149,7 +138,7 @@ ModuleManager::LoadModule(const RuntimeModuleConfig& moduleConfig, GameServer* g NebulaModuleDescriptor desc = {}; if (getDescriptor(&desc) == 0) { - std::fprintf(stderr, "ModuleManager: module '%s' descriptor callback failed\n", moduleConfig.name.AsCharPtr()); + n_warning("ModuleManager: module '%s' descriptor callback failed\n", moduleConfig.name.AsCharPtr()); library->Close(); delete library; return !(strictMode || moduleConfig.required); @@ -157,7 +146,7 @@ ModuleManager::LoadModule(const RuntimeModuleConfig& moduleConfig, GameServer* g if (desc.abiVersion != NEBULA_MODULE_ABI_VERSION) { - std::fprintf(stderr, "ModuleManager: module '%s' has ABI %u, expected %u\n", moduleConfig.name.AsCharPtr(), desc.abiVersion, NEBULA_MODULE_ABI_VERSION); + n_warning("ModuleManager: module '%s' has ABI %u, expected %u\n", moduleConfig.name.AsCharPtr(), desc.abiVersion, NEBULA_MODULE_ABI_VERSION); library->Close(); delete library; return !(strictMode || moduleConfig.required); @@ -166,7 +155,7 @@ ModuleManager::LoadModule(const RuntimeModuleConfig& moduleConfig, GameServer* g FeatureUnit* featureRaw = reinterpret_cast(createFeature()); if (featureRaw == nullptr) { - std::fprintf(stderr, "ModuleManager: module '%s' did not return a feature instance\n", moduleConfig.name.AsCharPtr()); + n_warning("ModuleManager: module '%s' did not return a feature instance\n", moduleConfig.name.AsCharPtr()); library->Close(); delete library; return !(strictMode || moduleConfig.required); @@ -185,9 +174,9 @@ ModuleManager::LoadModule(const RuntimeModuleConfig& moduleConfig, GameServer* g const char* descVersion = desc.version != nullptr ? desc.version : ""; if (destroyFeature != nullptr) { - std::fprintf(stderr, "ModuleManager: module '%s' exports '%s', but runtime expects FeatureUnit instances to be managed via Nebula refcounting\n", descName, NEBULA_MODULE_DESTROY_FEATURE_EXPORT); + n_warning("ModuleManager: module '%s' exports '%s', but runtime expects FeatureUnit instances to be managed via Nebula refcounting\n", descName, NEBULA_MODULE_DESTROY_FEATURE_EXPORT); } - std::fprintf(stdout, "ModuleManager: loaded module '%s' v%s from '%s'\n", descName, descVersion, libraryPath.AsCharPtr()); + n_printf("ModuleManager: loaded module '%s' v%s from '%s'\n", descName, descVersion, libraryPath.AsCharPtr()); return true; } @@ -205,7 +194,7 @@ ModuleManager::UnloadModule(LoadedModule& loaded, GameServer* gameServer) // Unloading the library in this state could leave dangling vtables. if (loaded.feature->GetRefCount() > 1) { - std::fprintf(stderr, "ModuleManager: module '%s' still has external references on unload (%d), keeping shared library loaded\n", loaded.config.name.AsCharPtr(), loaded.feature->GetRefCount()); + n_warning("ModuleManager: module '%s' still has external references on unload (%d), keeping shared library loaded\n", loaded.config.name.AsCharPtr(), loaded.feature->GetRefCount()); return false; } @@ -264,7 +253,9 @@ ModuleManager::ResolveLibraryPath(const RuntimeModuleConfig& moduleConfig) const } #if defined(NEBULA_BINARY_FOLDER) - Util::String deployCandidate = Util::String::Sprintf("%s/%s", NEBULA_BINARY_FOLDER, path.AsCharPtr()); + Util::String deployRoot = NEBULA_BINARY_FOLDER; + deployRoot.ConvertBackslashes(); + Util::String deployCandidate = Util::String::AppendPath(deployRoot, path); if (IO::FSWrapper::FileExists(deployCandidate)) { return deployCandidate; @@ -274,6 +265,26 @@ ModuleManager::ResolveLibraryPath(const RuntimeModuleConfig& moduleConfig) const return path; } +//------------------------------------------------------------------------------ +/** +*/ +IndexT +ModuleManager::FindLoadedModuleIndex(const Util::String& moduleName) const +{ + Util::String checkName = moduleName; + checkName.ToLower(); + + for (IndexT i = 0; i < this->loadedModules.Size(); i++) + { + Util::String loadedName = this->loadedModules[i].config.name; + loadedName.ToLower(); + if (loadedName == checkName) + return i; + } + + return InvalidIndex; +} + //------------------------------------------------------------------------------ /** */ @@ -281,12 +292,10 @@ void ModuleManager::QueueModuleReload(const Util::String& moduleName) { // Deduplicate: only queue a reload once per frame - for (IndexT i = 0; i < this->pendingReloads.Size(); i++) - { - if (this->pendingReloads[i] == moduleName) - return; - } - std::fprintf(stdout, "ModuleManager: reload of '%s' queued for next frame boundary\n", moduleName.AsCharPtr()); + if (this->pendingReloads.FindIndex(moduleName) != InvalidIndex) + return; + + n_printf("ModuleManager: reload of '%s' queued for next frame boundary\n", moduleName.AsCharPtr()); this->pendingReloads.Append(moduleName); } @@ -316,36 +325,22 @@ ModuleManager::ProcessPendingReloads(GameServer* gameServer) bool ModuleManager::ReloadModuleByName(const Util::String& moduleName, GameServer* gameServer) { - // Find the loaded module entry - Util::String nameLower = moduleName; - nameLower.ToLower(); - - IndexT idx = InvalidIndex; - for (IndexT i = 0; i < this->loadedModules.Size(); i++) - { - Util::String n = this->loadedModules[i].config.name; - n.ToLower(); - if (n == nameLower) - { - idx = i; - break; - } - } + IndexT idx = this->FindLoadedModuleIndex(moduleName); if (idx == InvalidIndex) { - std::fprintf(stderr, "ModuleManager: reload of '%s' failed: module is not loaded\n", moduleName.AsCharPtr()); + n_warning("ModuleManager: reload of '%s' failed: module is not loaded\n", moduleName.AsCharPtr()); return false; } // Save config so we can re-load with the same settings RuntimeModuleConfig config = this->loadedModules[idx].config; - std::fprintf(stdout, "ModuleManager: reloading '%s'...\n", moduleName.AsCharPtr()); + n_printf("ModuleManager: reloading '%s'...\n", moduleName.AsCharPtr()); if (!this->UnloadModule(this->loadedModules[idx], gameServer)) { - std::fprintf(stderr, "ModuleManager: reload of '%s' blocked: module could not be safely unloaded\n", moduleName.AsCharPtr()); + n_warning("ModuleManager: reload of '%s' blocked: module could not be safely unloaded\n", moduleName.AsCharPtr()); return false; } @@ -353,11 +348,11 @@ ModuleManager::ReloadModuleByName(const Util::String& moduleName, GameServer* ga if (!this->LoadModule(config, gameServer, false)) { - std::fprintf(stderr, "ModuleManager: reload of '%s' failed during load\n", moduleName.AsCharPtr()); + n_warning("ModuleManager: reload of '%s' failed during load\n", moduleName.AsCharPtr()); return false; } - std::fprintf(stdout, "ModuleManager: reload of '%s' complete\n", moduleName.AsCharPtr()); + n_printf("ModuleManager: reload of '%s' complete\n", moduleName.AsCharPtr()); return true; } diff --git a/code/application/game/modulemanager.h b/code/application/game/modulemanager.h index c742c6bbcd..4162df9744 100644 --- a/code/application/game/modulemanager.h +++ b/code/application/game/modulemanager.h @@ -59,6 +59,7 @@ class ModuleManager : public Core::RefCounted bool LoadModule(const RuntimeModuleConfig& moduleConfig, GameServer* gameServer, bool strictMode); bool UnloadModule(LoadedModule& loaded, GameServer* gameServer); Util::String ResolveLibraryPath(const RuntimeModuleConfig& moduleConfig) const; + IndexT FindLoadedModuleIndex(const Util::String& moduleName) const; /// unload then reload a single module by name; internal use by ProcessPendingReloads bool ReloadModuleByName(const Util::String& moduleName, GameServer* gameServer); diff --git a/fips-files/verbs/modulebuild.py b/fips-files/verbs/modulebuild.py new file mode 100644 index 0000000000..4409a68915 --- /dev/null +++ b/fips-files/verbs/modulebuild.py @@ -0,0 +1,42 @@ +"""build specific module target through fips + +modulebuild build [config] [-- build tool args] +""" + +from mod import log, settings, project + + +def run(fips_dir, proj_dir, args): + """run the 'modulebuild' verb""" + if '--' in args: + idx = args.index('--') + build_tool_args = args[(idx + 1):] + args = args[:idx] + else: + build_tool_args = None + + if len(args) < 2: + help() + log.error("expected: modulebuild build [config]") + + noun = args[0] + if noun != 'build': + help() + log.error("unknown command '{}', expected 'build'".format(noun)) + + target = args[1] + if not target: + log.error("target must not be empty") + + cfg_name = args[2] if len(args) > 2 else settings.get(proj_dir, 'config') + + if not project.build(fips_dir, proj_dir, cfg_name, target, build_tool_args): + log.error("failed to build target '{}'".format(target)) + + +def help(): + """print modulebuild help""" + log.info(log.YELLOW + + "fips modulebuild build [config] [-- build tool args]\n" + log.DEF + + " build a specific CMake target in the selected config\n" + + " if config is omitted, current fips setting is used") diff --git a/toolkit/editor/CMakeLists.txt b/toolkit/editor/CMakeLists.txt index 2a73c050c9..02ec893beb 100644 --- a/toolkit/editor/CMakeLists.txt +++ b/toolkit/editor/CMakeLists.txt @@ -173,5 +173,5 @@ target_include_directories(editorfeaturemodule PUBLIC ${CMAKE_CURRENT_SOURCE_DIR fips_files( editorfeaturemodule.cc ) -fips_deps(editor application render scripting) +fips_deps(editor application render scripting dynui) nebula_end_shared_module() diff --git a/toolkit/editor/editor/bindings/editorbindings.cc b/toolkit/editor/editor/bindings/editorbindings.cc index 52f16c56e8..3a97e5373c 100644 --- a/toolkit/editor/editor/bindings/editorbindings.cc +++ b/toolkit/editor/editor/bindings/editorbindings.cc @@ -80,7 +80,21 @@ NB_MODULE(editor, m) // Explicitly trigger a hot-reload of the editor feature module. // The reload is deferred to the next frame boundary and rejected if // play-in-editor is currently active. - m.def("reload_editor_module", []() { Editor::RequestModuleReload(); }); + m.def("reload_editor_module", []() { Editor::RequestModuleReload("editorfeaturemodule", "editorfeaturemodule"); }); + + // Generic module reload entry point. If build_target is omitted, + // module_name is used for both build and reload. + m.def( + "reload_module", + [](const char* moduleName, const char* buildTarget) + { + const Util::String module = moduleName != nullptr ? moduleName : ""; + const Util::String target = buildTarget != nullptr ? buildTarget : ""; + Editor::RequestModuleReload(module, target); + }, + py::arg("module_name"), + py::arg("build_target") = "" + ); } namespace Scripting diff --git a/toolkit/editor/editor/editor.cc b/toolkit/editor/editor/editor.cc index cb00a5135d..98cce3df3b 100644 --- a/toolkit/editor/editor/editor.cc +++ b/toolkit/editor/editor/editor.cc @@ -9,6 +9,7 @@ #include "game/modulemanager.h" #include "entityloader.h" #include "io/assignregistry.h" +#include "io/fswrapper.h" #include "io/ioserver.h" #include "io/jsonreader.h" #include "scripting/scriptserver.h" @@ -31,6 +32,8 @@ #include "toolkit-common/projectinfo.h" +#include + namespace Editor { @@ -39,6 +42,143 @@ namespace const char* ReloadSnapshotUri = "user:nebula/editor/hotreload_snapshot.json"; +//------------------------------------------------------------------------------ +/** +*/ +Util::String +GetEditorBuildDir() +{ +#if defined(NEBULA_BINARY_FOLDER) + Util::String buildDir = NEBULA_BINARY_FOLDER; + buildDir.ConvertBackslashes(); + buildDir.SubstituteString("/fips-deploy/", "/fips-build/"); + return buildDir; +#else + return ""; +#endif +} + +//------------------------------------------------------------------------------ +/** +*/ +bool +GetWorkspaceAndProjectFromBinaryDir(Util::String& outWorkspaceDir, Util::String& outProjectName) +{ +#if defined(NEBULA_BINARY_FOLDER) + Util::String binaryDir = NEBULA_BINARY_FOLDER; + binaryDir.ConvertBackslashes(); + const Util::String marker = "/fips-deploy/"; + + const IndexT markerPos = binaryDir.FindStringIndex(marker); + if (markerPos == InvalidIndex) + return false; + + outWorkspaceDir = binaryDir.ExtractRange(0, markerPos); + + const IndexT remainderStart = markerPos + (IndexT)marker.Length(); + const Util::String remainder = binaryDir.ExtractToEnd(remainderStart); + const IndexT slashPos = remainder.FindCharIndex('/'); + if (slashPos == InvalidIndex) + return false; + + outProjectName = remainder.ExtractRange(0, slashPos); + if (!outProjectName.IsValid()) + return false; + + return true; +#else + return false; +#endif +} + +//------------------------------------------------------------------------------ +/** +*/ +bool +BuildModuleTargetWithCMake(const Util::String& buildTarget, Util::String& outStatus) +{ + Util::String buildDir = GetEditorBuildDir(); + if (!buildDir.IsValid() || !IO::FSWrapper::DirectoryExists(buildDir)) + { + outStatus = "Reload failed: build directory not found"; + return false; + } + + Util::String cmd = Util::String::Sprintf( + "cmake --build \"%s\" --target \"%s\"", + buildDir.AsCharPtr(), + buildTarget.AsCharPtr() + ); + + int buildResult = std::system(cmd.AsCharPtr()); + if (buildResult != 0) + { + outStatus = "Reload failed: module build failed"; + return false; + } + + outStatus = "Build succeeded, reload queued..."; + return true; +} + +//------------------------------------------------------------------------------ +/** +*/ +bool +BuildModuleTarget(const Util::String& buildTarget, Util::String& outStatus) +{ + Util::String workspaceDir; + Util::String projectName; + if (!GetWorkspaceAndProjectFromBinaryDir(workspaceDir, projectName)) + { + return BuildModuleTargetWithCMake(buildTarget, outStatus); + } + + Util::String projectDir = Util::String::AppendPath(workspaceDir, projectName); + if (!IO::FSWrapper::DirectoryExists(projectDir)) + { + return BuildModuleTargetWithCMake(buildTarget, outStatus); + } + + Util::String localFips = Util::String::AppendPath(projectDir, "fips"); + Util::String workspaceFips = Util::String::AppendPath(Util::String::AppendPath(workspaceDir, "fips"), "fips"); + + Util::String fipsScript; + if (IO::FSWrapper::FileExists(localFips)) + { + fipsScript = localFips; + } + else if (IO::FSWrapper::FileExists(workspaceFips)) + { + fipsScript = workspaceFips; + } + else + { + return BuildModuleTargetWithCMake(buildTarget, outStatus); + } + + Util::String cmd = Util::String::Sprintf( + "cd \"%s\" && \"%s\" modulebuild build \"%s\"", + projectDir.AsCharPtr(), + fipsScript.AsCharPtr(), + buildTarget.AsCharPtr() + ); + + int buildResult = std::system(cmd.AsCharPtr()); + if (buildResult != 0) + { + Util::String fallbackStatus; + if (!BuildModuleTargetWithCMake(buildTarget, fallbackStatus)) + { + outStatus = "Reload failed: fips modulebuild and cmake fallback both failed"; + return false; + } + } + + outStatus = "Build succeeded, reload queued..."; + return true; +} + //------------------------------------------------------------------------------ /** */ @@ -271,8 +411,15 @@ StopGame() /** */ void -RequestModuleReload() +RequestModuleReload(const Util::String& moduleName, const Util::String& buildTarget) { + if (!moduleName.IsValid()) + { + state.lastReloadStatus = "Reload failed: module name is empty"; + n_printf("Editor: %s\n", state.lastReloadStatus.AsCharPtr()); + return; + } + if (!Game::EditorState::HasInstance() || Game::EditorState::Instance()->isPlaying) { state.lastReloadStatus = "Reload blocked: play-in-editor is active"; @@ -288,6 +435,13 @@ RequestModuleReload() return; } + Util::String resolvedBuildTarget = buildTarget.IsValid() ? buildTarget : moduleName; + if (!BuildModuleTarget(resolvedBuildTarget, state.lastReloadStatus)) + { + n_printf("Editor: %s\n", state.lastReloadStatus.AsCharPtr()); + return; + } + IO::IoServer::Instance()->CreateDirectory("user:nebula/editor/"); if (!SaveEntities(ReloadSnapshotUri)) { @@ -299,9 +453,9 @@ RequestModuleReload() Game::EditorState::Instance()->reloadSnapshotPath = ReloadSnapshotUri; Game::EditorState::Instance()->reloadSnapshotPending = true; - mm->QueueModuleReload("editorfeaturemodule"); - state.lastReloadStatus = "Reload queued..."; - n_printf("Editor: editor feature module reload queued\n"); + mm->QueueModuleReload(moduleName); + state.lastReloadStatus = "Build succeeded, reload queued..."; + n_printf("Editor: module '%s' reload queued\n", moduleName.AsCharPtr()); } //------------------------------------------------------------------------------ diff --git a/toolkit/editor/editor/editor.h b/toolkit/editor/editor/editor.h index cb06cfd0a5..6c282c57a4 100644 --- a/toolkit/editor/editor/editor.h +++ b/toolkit/editor/editor/editor.h @@ -58,10 +58,12 @@ void Destroy(); /// Returns true if editor state/world is initialized bool IsCreated(); -/// Request an explicit reload of the editor feature module. +/// Request an explicit module reload. /// The reload is deferred to the next frame boundary. /// Rejected if play-in-editor is currently active. -void RequestModuleReload(); +/// Performs a module build first and only queues reload on successful build. +/// If buildTarget is empty, moduleName is used as build target. +void RequestModuleReload(const Util::String& moduleName, const Util::String& buildTarget = ""); /// Return the result string of the last RequestModuleReload() attempt. const Util::String& GetLastReloadStatus(); diff --git a/toolkit/editor/editor/ui/windows/toolbar.cc b/toolkit/editor/editor/ui/windows/toolbar.cc index 2f21cf6b72..75c0601860 100644 --- a/toolkit/editor/editor/ui/windows/toolbar.cc +++ b/toolkit/editor/editor/ui/windows/toolbar.cc @@ -94,7 +94,7 @@ Toolbar::Run(SaveMode save) if (ImGui::Button("Reload Editor")) { - Editor::RequestModuleReload(); + Editor::RequestModuleReload("editorfeaturemodule", "editorfeaturemodule"); } const Util::String& reloadStatus = Editor::GetLastReloadStatus(); if (reloadStatus.IsValid()) diff --git a/toolkit/editor/editor/ui/windowserver.cc b/toolkit/editor/editor/ui/windowserver.cc index e255536dba..e36e0a6a9d 100644 --- a/toolkit/editor/editor/ui/windowserver.cc +++ b/toolkit/editor/editor/ui/windowserver.cc @@ -461,4 +461,15 @@ WindowServer::AddCategory(const Util::String & category) } } +//------------------------------------------------------------------------------ +/** +*/ +void +WindowServer::ClearAllWindows() +{ + this->windows.Clear(); + this->windowByName.Clear(); + this->categories.Clear(); +} + } // namespace Presentation From 03497bf6b01afaaaf0fc3eed71368f6b2e1ca4f7 Mon Sep 17 00:00:00 2001 From: Johannes Date: Fri, 1 May 2026 23:41:05 +0200 Subject: [PATCH 05/19] fix font issues with imgui hotreload --- code/addons/dynui/imguicontext.cc | 103 ++++++++++++++++++++-- code/render/coregraphics/vk/vkpipeline.cc | 7 ++ toolkit/editor/CMakeLists.txt | 2 +- 3 files changed, 103 insertions(+), 9 deletions(-) diff --git a/code/addons/dynui/imguicontext.cc b/code/addons/dynui/imguicontext.cc index 0d10fcdaa1..bf39787878 100644 --- a/code/addons/dynui/imguicontext.cc +++ b/code/addons/dynui/imguicontext.cc @@ -64,6 +64,8 @@ struct ImguiState Ptr inputHandler; Ptr displayEventHandler; bool dockOverViewport; + bool graphicsContextRegistered; + bool renderingEnabled; } state; IndexT VertexBufferOffset = 0; @@ -78,6 +80,38 @@ SizeT TotalIndicesThisFrame = 0; static Core::CVar* ui_opacity; +//------------------------------------------------------------------------------ +/** +*/ +static void +ImguiNoOpPipelineSetup(const CoreGraphics::RenderPassId) +{ + // Intentionally empty. Used to clear stale framescript callbacks after reload failures. +} + +//------------------------------------------------------------------------------ +/** +*/ +static void +ImguiNoOpDraw(const CoreGraphics::CmdBufferId, const CoreGraphics::QueueType, const Math::rectangle&, const IndexT, const IndexT) +{ + // Intentionally empty. Used to clear stale framescript callbacks after reload failures. +} + +//------------------------------------------------------------------------------ +/** +*/ +static void +DisableImguiFramescriptCallbacks() +{ +#if WITH_NEBULA_EDITOR + FrameScript_editorframe::RegisterSubgraphPipelines_ImGUI_Render(ImguiNoOpPipelineSetup); + FrameScript_editorframe::RegisterSubgraph_ImGUI_Render(ImguiNoOpDraw); +#endif + FrameScript_default::RegisterSubgraphPipelines_ImGUI_Render(ImguiNoOpPipelineSetup); + FrameScript_default::RegisterSubgraph_ImGUI_Render(ImguiNoOpDraw); +} + ImFont* ImguiNormalFont; ImFont* ImguiSmallFont; ImFont* ImguiBoldFont; @@ -90,6 +124,11 @@ ImFont* ImguiItFont; void ImguiDrawFunction(const CoreGraphics::CmdBufferId cmdBuf, const Math::rectangle& viewport, ImDrawData* data) { + if (!state.renderingEnabled) + { + return; + } + // get Imgui context ImGuiIO& io = ImGui::GetIO(); int fb_width = (int)(viewport.width() * io.DisplayFramebufferScale.x); @@ -429,14 +468,26 @@ ImguiContext::Create() { ui_opacity = Core::CVarCreate(Core::CVar_Float, "ui_opacity", "1.0", "Global UI opacity (0..1)"); - __bundle.OnViewportResized = ImguiContext::OnViewportResized; - Graphics::GraphicsServer::Instance()->RegisterGraphicsContext(&__bundle, &__state); - state.dockOverViewport = false; + state.graphicsContextRegistered = false; + state.renderingEnabled = false; // allocate imgui shader state.uiShader = CoreGraphics::ShaderGet("shd:imgui/shaders/imgui.gplb"); + if (state.uiShader == CoreGraphics::InvalidShaderId) + { + DisableImguiFramescriptCallbacks(); + n_error("ImguiContext::Create: Failed to load imgui shader 'shd:imgui/shaders/imgui.gplb'\n"); + return; + } + state.prog = CoreGraphics::ShaderGetProgram(state.uiShader, CoreGraphics::ShaderFeatureMask("Static")); + if (state.prog == CoreGraphics::InvalidShaderProgramId) + { + DisableImguiFramescriptCallbacks(); + n_error("ImguiContext::Create: Failed to get 'Static' program from imgui shader\n"); + return; + } state.resourceTable = CoreGraphics::ShaderCreateResourceTable(state.uiShader, NEBULA_BATCH_GROUP); @@ -463,6 +514,9 @@ ImguiContext::Create() { FrameScript_editorframe::RegisterSubgraphPipelines_ImGUI_Render([](const CoreGraphics::RenderPassId pass) { + if (!state.renderingEnabled) + return; + CoreGraphics::InputAssemblyKey inputAssembly{ CoreGraphics::PrimitiveTopology::TriangleList, false }; if (state.editorPipeline != CoreGraphics::InvalidPipelineId) CoreGraphics::DestroyGraphicsPipeline(state.editorPipeline); @@ -471,6 +525,9 @@ ImguiContext::Create() FrameScript_editorframe::RegisterSubgraph_ImGUI_Render([](const CoreGraphics::CmdBufferId cmdBuf, const CoreGraphics::QueueType queue, const Math::rectangle& viewport, const IndexT frame, const IndexT bufferIndex) { + if (!state.renderingEnabled) + return; + #ifdef NEBULA_NO_DYNUI_ASSERTS ImguiContext::RecoverImGuiContextErrors(); #endif @@ -522,6 +579,9 @@ ImguiContext::Create() { FrameScript_default::RegisterSubgraphPipelines_ImGUI_Render([](const CoreGraphics::RenderPassId pass) { + if (!state.renderingEnabled) + return; + CoreGraphics::InputAssemblyKey inputAssembly{ CoreGraphics::PrimitiveTopology::TriangleList, false }; if (state.pipeline != CoreGraphics::InvalidPipelineId) CoreGraphics::DestroyGraphicsPipeline(state.pipeline); @@ -529,6 +589,9 @@ ImguiContext::Create() }); FrameScript_default::RegisterSubgraph_ImGUI_Render([](const CoreGraphics::CmdBufferId cmdBuf, const CoreGraphics::QueueType queue, const Math::rectangle& viewport, const IndexT frame, const IndexT bufferIndex) { + if (!state.renderingEnabled) + return; + #ifdef NEBULA_NO_DYNUI_ASSERTS ImguiContext::RecoverImGuiContextErrors(); #endif @@ -898,6 +961,11 @@ ImguiContext::Create() } ImGui::LoadIniSettingsFromDisk("imgui.ini"); } + + __bundle.OnViewportResized = ImguiContext::OnViewportResized; + Graphics::GraphicsServer::Instance()->RegisterGraphicsContext(&__bundle, &__state); + state.graphicsContextRegistered = true; + state.renderingEnabled = true; } //------------------------------------------------------------------------------ @@ -906,6 +974,9 @@ ImguiContext::Create() void ImguiContext::Discard() { + state.renderingEnabled = false; + DisableImguiFramescriptCallbacks(); + IndexT i; for (i = 0; i < state.vbos.Size(); i++) { @@ -919,13 +990,29 @@ ImguiContext::Discard() state.indexPtrs[i] = nullptr; } - Input::InputServer::Instance()->RemoveInputHandler(state.inputHandler.upcast()); - state.inputHandler = nullptr; + if (state.inputHandler != nullptr) + { + Input::InputServer::Instance()->RemoveInputHandler(state.inputHandler.upcast()); + state.inputHandler = nullptr; + } + + if (state.displayEventHandler != nullptr) + { + CoreGraphics::DisplayDevice::Instance()->RemoveEventHandler(state.displayEventHandler.upcast()); + state.displayEventHandler = nullptr; + } - CoreGraphics::DisplayDevice::Instance()->RemoveEventHandler(state.displayEventHandler.upcast()); - state.displayEventHandler = nullptr; + if (state.graphicsContextRegistered) + { + Graphics::GraphicsServer::Instance()->UnregisterGraphicsContext(&__bundle); + state.graphicsContextRegistered = false; + } - CoreGraphics::DestroyTexture((CoreGraphics::TextureId)state.fontTexture.nebulaHandle); + if ((CoreGraphics::TextureId)state.fontTexture.nebulaHandle != CoreGraphics::InvalidTextureId) + { + CoreGraphics::DestroyTexture((CoreGraphics::TextureId)state.fontTexture.nebulaHandle); + state.fontTexture.nebulaHandle = CoreGraphics::InvalidTextureId; + } #ifdef IMGUI_HAS_VIEWPORT /// The main window is handled by nebula diff --git a/code/render/coregraphics/vk/vkpipeline.cc b/code/render/coregraphics/vk/vkpipeline.cc index 9f9880b848..7a619a3cc2 100644 --- a/code/render/coregraphics/vk/vkpipeline.cc +++ b/code/render/coregraphics/vk/vkpipeline.cc @@ -45,6 +45,13 @@ using namespace Vulkan; PipelineId CreateGraphicsPipeline(const PipelineCreateInfo& info) { + // Validate shader program is valid before accessing runtime info + if (info.shader == CoreGraphics::InvalidShaderProgramId) + { + n_warning("CreateGraphicsPipeline: Invalid shader program ID\n"); + return CoreGraphics::InvalidPipelineId; + } + VkGraphicsPipelineCreateInfo shaderInfo; VkShaderProgramRuntimeInfo& programInfo = shaderProgramAlloc.Get(info.shader.programId); const VkPipelineRenderingCreateInfo* renderPassInfo = nullptr; diff --git a/toolkit/editor/CMakeLists.txt b/toolkit/editor/CMakeLists.txt index 02ec893beb..2a73c050c9 100644 --- a/toolkit/editor/CMakeLists.txt +++ b/toolkit/editor/CMakeLists.txt @@ -173,5 +173,5 @@ target_include_directories(editorfeaturemodule PUBLIC ${CMAKE_CURRENT_SOURCE_DIR fips_files( editorfeaturemodule.cc ) -fips_deps(editor application render scripting dynui) +fips_deps(editor application render scripting) nebula_end_shared_module() From e9145d2f6448b5595f7e141611e536bd08cd3903 Mon Sep 17 00:00:00 2001 From: Johannes Date: Sat, 2 May 2026 12:38:34 +0200 Subject: [PATCH 06/19] - cleanup bad fix for race --- code/addons/dynui/console/imguiconsole.cc | 26 +++++++---------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/code/addons/dynui/console/imguiconsole.cc b/code/addons/dynui/console/imguiconsole.cc index 0643ffd428..6aea4cba11 100644 --- a/code/addons/dynui/console/imguiconsole.cc +++ b/code/addons/dynui/console/imguiconsole.cc @@ -373,13 +373,6 @@ ImguiConsole::Render() void ImguiConsole::RenderContent() { - Util::Array logSnapshot; - logSnapshot.Reserve(this->consoleBuffer.Size()); - for (IndexT i = 0; i < this->consoleBuffer.Size(); i++) - { - logSnapshot.Append(this->consoleBuffer[i]); - } - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0, 0)); static ImGuiTextFilter filter; filter.Draw("Filter", 180); @@ -390,10 +383,7 @@ ImguiConsole::RenderContent() if (ImGui::BeginPopupContextWindow()) { if (ImGui::Selectable("Clear")) - { this->consoleBuffer.Reset(); - logSnapshot.Clear(); - } ImGui::EndPopup(); } @@ -402,16 +392,16 @@ ImguiConsole::RenderContent() // Unfortunately we can't use ImGui::Clipper here since each entry might have a different height. // TODO: We could roll our own "clipper". ImGui::PushTextWrapPos(ImGui::GetWindowContentRegionMax().x); - for (int i = 0; i < logSnapshot.Size(); i++) + for (int i = 0; i < this->consoleBuffer.Size(); i++) { - const char* item = logSnapshot[i].msg.AsCharPtr(); + const char* item = this->consoleBuffer[i].msg.AsCharPtr(); //Filter on both time, prefix and entry - if (!filter.PassFilter(item) && !filter.PassFilter(this->LogEntryTypeAsCharPtr(logSnapshot[i].type))) + if (!filter.PassFilter(item) && !filter.PassFilter(this->LogEntryTypeAsCharPtr(this->consoleBuffer[i].type))) continue; ImVec4 col; - switch (logSnapshot[i].type) + switch (this->consoleBuffer[i].type) { case N_MESSAGE: col = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); @@ -438,9 +428,9 @@ ImguiConsole::RenderContent() ImGui::PushStyleColor(ImGuiCol_Text, col); ImGui::SameLine(); //Print message prefix - if (logSnapshot[i].type != N_MESSAGE) + if (this->consoleBuffer[i].type != N_MESSAGE) { - ImGui::TextUnformatted(this->LogEntryTypeAsCharPtr(logSnapshot[i].type)); + ImGui::TextUnformatted(this->LogEntryTypeAsCharPtr(this->consoleBuffer[i].type)); ImGui::SameLine(); } //Print log entry @@ -451,11 +441,11 @@ ImguiConsole::RenderContent() ImGui::PopTextWrapPos(); static SizeT lastConsoleBufferSize = 0; - if (this->scrollToBottom && logSnapshot.Size() != lastConsoleBufferSize) + if (this->scrollToBottom && this->consoleBuffer.Size() != lastConsoleBufferSize) { ImGui::SetScrollHereY(); } - lastConsoleBufferSize = logSnapshot.Size(); + lastConsoleBufferSize = this->consoleBuffer.Size(); ImGui::PopStyleVar(); ImGui::EndChild(); From 6142015b6a2ff8e0dcd8f1fb9815f200fa06bddc Mon Sep 17 00:00:00 2001 From: Johannes Date: Sat, 2 May 2026 12:42:50 +0200 Subject: [PATCH 07/19] undo bogus edits --- code/addons/memdb/attributeregistry.cc | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/code/addons/memdb/attributeregistry.cc b/code/addons/memdb/attributeregistry.cc index db6c041851..385d3bada5 100644 --- a/code/addons/memdb/attributeregistry.cc +++ b/code/addons/memdb/attributeregistry.cc @@ -11,7 +11,8 @@ AttributeRegistry* AttributeRegistry::Singleton = 0; //------------------------------------------------------------------------------ /** - This creates a singleton if needed, unlike the macro + The registry's constructor is called by the Instance() method, and + nobody else. */ AttributeRegistry* AttributeRegistry::Instance() @@ -34,7 +35,10 @@ AttributeRegistry::HasInstance() } //------------------------------------------------------------------------------ -/** +/** + This static method is used to destroy the registry object and should be + called right before the main function exits. It will make sure that + no accidential memory leaks are reported by the debug heap. */ void AttributeRegistry::Destroy() From b8b1e8e600b545312d6e4e51f453f8bc5424a60b Mon Sep 17 00:00:00 2001 From: Johannes Date: Sat, 2 May 2026 12:48:57 +0200 Subject: [PATCH 08/19] Cleanup module exports --- .../navigationfeaturemodule/navigationfeaturemodule.cc | 6 +----- code/application/game/moduleinterface.h | 7 ++++++- tests/testruntimemodule/runtimemodulefeature.cc | 6 ------ tests/testruntimemodulebadabi/badabimodule.cc | 6 ------ tests/testruntimemodulebadexports/badexportsmodule.cc | 6 ------ toolkit/editor/editorfeaturemodule.cc | 6 ------ 6 files changed, 7 insertions(+), 30 deletions(-) diff --git a/code/addons/navigationfeaturemodule/navigationfeaturemodule.cc b/code/addons/navigationfeaturemodule/navigationfeaturemodule.cc index a4c4bb7816..a5341898b1 100644 --- a/code/addons/navigationfeaturemodule/navigationfeaturemodule.cc +++ b/code/addons/navigationfeaturemodule/navigationfeaturemodule.cc @@ -7,11 +7,7 @@ #include "game/moduleinterface.h" #include "navigationfeature/navigationfeatureunit.h" -#if __WIN32__ -#define NEBULA_MODULE_EXPORT extern "C" __declspec(dllexport) -#else -#define NEBULA_MODULE_EXPORT extern "C" __attribute__((visibility("default"))) -#endif + namespace { diff --git a/code/application/game/moduleinterface.h b/code/application/game/moduleinterface.h index c8f9c5043e..928865a512 100644 --- a/code/application/game/moduleinterface.h +++ b/code/application/game/moduleinterface.h @@ -7,7 +7,6 @@ runtime module manager. Keep this interface C-compatible and minimal to avoid accidental ABI breakage. - @copyright (C) 2026 Individual contributors, see AUTHORS file */ #include @@ -17,6 +16,12 @@ #define NEBULA_MODULE_CREATE_FEATURE_EXPORT "NebulaModuleCreateFeature" #define NEBULA_MODULE_DESTROY_FEATURE_EXPORT "NebulaModuleDestroyFeature" +#if __WIN32__ +#define NEBULA_MODULE_EXPORT extern "C" __declspec(dllexport) +#else +#define NEBULA_MODULE_EXPORT extern "C" __attribute__((visibility("default"))) +#endif + #ifdef __cplusplus extern "C" { diff --git a/tests/testruntimemodule/runtimemodulefeature.cc b/tests/testruntimemodule/runtimemodulefeature.cc index 0ad00566f3..94319fcf30 100644 --- a/tests/testruntimemodule/runtimemodulefeature.cc +++ b/tests/testruntimemodule/runtimemodulefeature.cc @@ -44,12 +44,6 @@ __ImplementClass(TestRuntimeModule::RuntimeModuleFeature, 'TRMF', Game::FeatureU } // namespace TestRuntimeModule -#if __WIN32__ -#define NEBULA_MODULE_EXPORT extern "C" __declspec(dllexport) -#else -#define NEBULA_MODULE_EXPORT extern "C" __attribute__((visibility("default"))) -#endif - NEBULA_MODULE_EXPORT int NebulaModuleGetDescriptor(NebulaModuleDescriptor* outDescriptor) { diff --git a/tests/testruntimemodulebadabi/badabimodule.cc b/tests/testruntimemodulebadabi/badabimodule.cc index 9b28d244a4..2c5a9b34a6 100644 --- a/tests/testruntimemodulebadabi/badabimodule.cc +++ b/tests/testruntimemodulebadabi/badabimodule.cc @@ -5,12 +5,6 @@ #include "stdneb.h" #include "game/moduleinterface.h" -#if __WIN32__ -#define NEBULA_MODULE_EXPORT extern "C" __declspec(dllexport) -#else -#define NEBULA_MODULE_EXPORT extern "C" __attribute__((visibility("default"))) -#endif - NEBULA_MODULE_EXPORT int NebulaModuleGetDescriptor(NebulaModuleDescriptor* outDescriptor) { diff --git a/tests/testruntimemodulebadexports/badexportsmodule.cc b/tests/testruntimemodulebadexports/badexportsmodule.cc index 88842582dc..867844bb69 100644 --- a/tests/testruntimemodulebadexports/badexportsmodule.cc +++ b/tests/testruntimemodulebadexports/badexportsmodule.cc @@ -5,12 +5,6 @@ #include "stdneb.h" #include "game/moduleinterface.h" -#if __WIN32__ -#define NEBULA_MODULE_EXPORT extern "C" __declspec(dllexport) -#else -#define NEBULA_MODULE_EXPORT extern "C" __attribute__((visibility("default"))) -#endif - NEBULA_MODULE_EXPORT int NebulaModuleGetDescriptor(NebulaModuleDescriptor* outDescriptor) { diff --git a/toolkit/editor/editorfeaturemodule.cc b/toolkit/editor/editorfeaturemodule.cc index 4d4acde92a..049d2da0bb 100644 --- a/toolkit/editor/editorfeaturemodule.cc +++ b/toolkit/editor/editorfeaturemodule.cc @@ -8,12 +8,6 @@ #include "editorfeature/editorfeatureunit.h" #include "cr/cr.h" -#if __WIN32__ -#define NEBULA_MODULE_EXPORT extern "C" __declspec(dllexport) -#else -#define NEBULA_MODULE_EXPORT extern "C" __attribute__((visibility("default"))) -#endif - NEBULA_MODULE_EXPORT int NebulaModuleGetDescriptor(NebulaModuleDescriptor* outDescriptor) { From 20b63b053306633ddf5424c15058e01eefce33a6 Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 3 May 2026 12:01:37 +0200 Subject: [PATCH 09/19] - add pdb filename patching for hot reload --- code/application/game/modulemanager.cc | 297 ++++++++++++++++++++++++- code/application/game/modulemanager.h | 3 + 2 files changed, 298 insertions(+), 2 deletions(-) diff --git a/code/application/game/modulemanager.cc b/code/application/game/modulemanager.cc index a5290c6fc4..71ae317d4b 100644 --- a/code/application/game/modulemanager.cc +++ b/code/application/game/modulemanager.cc @@ -11,6 +11,233 @@ #include "io/ioserver.h" #include "system/library.h" +#if __WIN32__ +#include +#endif + +namespace +{ +// windows helper stuff for dealing with pdb locking/renaming etc. maybe move this to some other dedicated file later. +// heavily inspired by fungos/cr +#if __WIN32__ + +// RSDS signature in little-endian dword form. +static constexpr DWORD RsdsSignature = 'SDSR'; + +//------------------------------------------------------------------------------ +/** + Nebulas ioserver::copy copies the file bytewise, just do a direct copy here +*/ +static bool +CopyFileNative(const Util::String& src, const Util::String& dst) +{ + return ::CopyFileA(src.AsCharPtr(), dst.AsCharPtr(), FALSE) == TRUE; +} + +//------------------------------------------------------------------------------ +/** +*/ +static bool +RvaToFileOffset(const IMAGE_NT_HEADERS* ntHeaders, DWORD rva, DWORD& outOffset) +{ + const IMAGE_SECTION_HEADER* section = IMAGE_FIRST_SECTION(ntHeaders); + for (WORD i = 0; i < ntHeaders->FileHeader.NumberOfSections; i++, section++) + { + const DWORD sectionStart = section->VirtualAddress; + const DWORD sectionSize = section->Misc.VirtualSize > section->SizeOfRawData ? section->Misc.VirtualSize : section->SizeOfRawData; + if (rva >= sectionStart && rva < sectionStart + sectionSize) + { + outOffset = section->PointerToRawData + (rva - sectionStart); + return true; + } + } + return false; +} + +//------------------------------------------------------------------------------ +/** +*/ +static bool +PatchPdbReferenceInCopiedDll(const Util::String& copiedDllPath, const Util::String& newPdbName, Util::String& outOriginalPdb) +{ + HANDLE file = ::CreateFileA(copiedDllPath.AsCharPtr(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) + { + return false; + } + + LARGE_INTEGER fileSize = {}; + if (::GetFileSizeEx(file, &fileSize) == FALSE || fileSize.QuadPart <= 0) + { + ::CloseHandle(file); + return false; + } + + HANDLE mapping = ::CreateFileMappingA(file, nullptr, PAGE_READWRITE, 0, 0, nullptr); + if (mapping == nullptr) + { + ::CloseHandle(file); + return false; + } + + byte* data = (byte*)::MapViewOfFile(mapping, FILE_MAP_ALL_ACCESS, 0, 0, 0); + if (data == nullptr) + { + ::CloseHandle(mapping); + ::CloseHandle(file); + return false; + } + + const size_t size = (size_t)fileSize.QuadPart; + bool patched = false; + + if (size >= sizeof(IMAGE_DOS_HEADER)) + { + const IMAGE_DOS_HEADER* dos = (const IMAGE_DOS_HEADER*)data; + if (dos->e_magic == IMAGE_DOS_SIGNATURE && dos->e_lfanew > 0 && (size_t)dos->e_lfanew + sizeof(IMAGE_NT_HEADERS) <= size) + { + const IMAGE_NT_HEADERS* nt = (const IMAGE_NT_HEADERS*)(data + dos->e_lfanew); + if (nt->Signature == IMAGE_NT_SIGNATURE) + { + DWORD debugRva = 0; + DWORD debugSize = 0; + if (nt->OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC) + { + const IMAGE_OPTIONAL_HEADER64* opt64 = (const IMAGE_OPTIONAL_HEADER64*)&nt->OptionalHeader; + debugRva = opt64->DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG].VirtualAddress; + debugSize = opt64->DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG].Size; + } + else if (nt->OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR32_MAGIC) + { + const IMAGE_OPTIONAL_HEADER32* opt32 = (const IMAGE_OPTIONAL_HEADER32*)&nt->OptionalHeader; + debugRva = opt32->DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG].VirtualAddress; + debugSize = opt32->DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG].Size; + } + + if (debugRva != 0 && debugSize >= sizeof(IMAGE_DEBUG_DIRECTORY)) + { + DWORD debugOffset = 0; + if (RvaToFileOffset(nt, debugRva, debugOffset) && (size_t)debugOffset + debugSize <= size) + { + const size_t numEntries = debugSize / sizeof(IMAGE_DEBUG_DIRECTORY); + IMAGE_DEBUG_DIRECTORY* entries = (IMAGE_DEBUG_DIRECTORY*)(data + debugOffset); + + for (size_t i = 0; i < numEntries && !patched; i++) + { + IMAGE_DEBUG_DIRECTORY& entry = entries[i]; + if (entry.Type != IMAGE_DEBUG_TYPE_CODEVIEW || entry.PointerToRawData == 0 || entry.SizeOfData < (sizeof(DWORD) + sizeof(GUID) + sizeof(DWORD) + 1)) + continue; + + if ((size_t)entry.PointerToRawData + entry.SizeOfData > size) + continue; + + byte* cvData = data + entry.PointerToRawData; + DWORD signature = *(DWORD*)cvData; + if (signature != RsdsSignature) + continue; + + char* pdb = (char*)(cvData + sizeof(DWORD) + sizeof(GUID) + sizeof(DWORD)); + const size_t maxLen = entry.SizeOfData - (sizeof(DWORD) + sizeof(GUID) + sizeof(DWORD)); + + size_t oldLen = 0; + while (oldLen < maxLen && pdb[oldLen] != '\0') + oldLen++; + + if (oldLen == maxLen) + continue; + + if (newPdbName.Length() > oldLen) + continue; + + outOriginalPdb = pdb; + Memory::Copy(newPdbName.AsCharPtr(), pdb, newPdbName.Length()); + pdb[newPdbName.Length()] = '\0'; + patched = true; + } + } + } + } + } + } + + ::UnmapViewOfFile(data); + ::CloseHandle(mapping); + ::CloseHandle(file); + return patched; +} + +//------------------------------------------------------------------------------ +/** +*/ +static bool +CreateWindowsShadowCopyArtifacts(const Util::String& sourceDllPath, const Util::String& moduleName, uint serial, Util::String& outShadowDllPath, Util::String& outShadowPdbPath) +{ + IndexT slash = sourceDllPath.FindCharIndexReverse('/'); + IndexT backslash = sourceDllPath.FindCharIndexReverse('\\'); + IndexT separator = slash > backslash ? slash : backslash; + + Util::String folder = separator != InvalidIndex ? sourceDllPath.ExtractRange(0, separator + 1) : ""; + Util::String fileName = sourceDllPath.ExtractFileName(); + Util::String baseName = fileName; + baseName.StripFileExtension(); + + Util::String safeModule = moduleName.IsValid() ? moduleName : baseName; + safeModule.SubstituteString(" ", "_"); + + Util::String shadowFileName = Util::String::Sprintf("%s.reload.%u.dll", safeModule.AsCharPtr(), serial); + outShadowDllPath = folder + shadowFileName; + outShadowPdbPath = outShadowDllPath; + outShadowPdbPath.ChangeFileExtension("pdb"); + + IO::FSWrapper::DeleteFile(outShadowDllPath); + IO::FSWrapper::DeleteFile(outShadowPdbPath); + + if (!CopyFileNative(sourceDllPath, outShadowDllPath)) + return false; + + Util::String originalPdbPath; + const Util::String shadowPdbFileName = outShadowPdbPath.ExtractFileName(); + if (PatchPdbReferenceInCopiedDll(outShadowDllPath, shadowPdbFileName, originalPdbPath)) + { + Util::String copySourcePdb = originalPdbPath; + if (!IO::FSWrapper::FileExists(copySourcePdb)) + { + copySourcePdb = sourceDllPath; + copySourcePdb.ChangeFileExtension("pdb"); + } + + if (!copySourcePdb.IsValid() || !IO::FSWrapper::FileExists(copySourcePdb) || !CopyFileNative(copySourcePdb, outShadowPdbPath)) + { + n_warning("ModuleManager: failed to copy shadow PDB '%s' for '%s'", outShadowPdbPath.AsCharPtr(), outShadowDllPath.AsCharPtr()); + } + } + else + { + n_warning("ModuleManager: failed to patch RSDS PDB reference in '%s'", outShadowDllPath.AsCharPtr()); + } + + return true; +} + +//------------------------------------------------------------------------------ +/** +*/ +static void +DeleteShadowArtifacts(const Util::String& shadowDllPath, const Util::String& shadowPdbPath) +{ + if (shadowPdbPath.IsValid()) + { + IO::FSWrapper::DeleteFile(shadowPdbPath); + } + if (shadowDllPath.IsValid()) + { + IO::FSWrapper::DeleteFile(shadowDllPath); + } +} + +#endif +} + namespace Game { __ImplementClass(Game::ModuleManager, 'GMDM', Core::RefCounted); @@ -20,6 +247,11 @@ struct ModuleManager::LoadedModule RuntimeModuleConfig config; Base::Library* library; Ptr feature; + Util::String loadedLibraryPath; +#if __WIN32__ + Util::String loadedPdbPath; + bool usesShadowCopy = false; +#endif }; //------------------------------------------------------------------------------ @@ -115,10 +347,32 @@ ModuleManager::LoadModule(const RuntimeModuleConfig& moduleConfig, GameServer* g return !(strictMode || moduleConfig.required); } + Util::String runtimeLoadPath = libraryPath; +#if __WIN32__ + Util::String runtimePdbPath; + bool usesShadowCopy = false; + if (CreateWindowsShadowCopyArtifacts(libraryPath, moduleConfig.name, this->windowsShadowCopySerial++, runtimeLoadPath, runtimePdbPath)) + { + usesShadowCopy = true; + } + else + { + runtimeLoadPath = libraryPath; + runtimePdbPath.Clear(); + n_warning("ModuleManager: failed to prepare shadow copy for '%s', falling back to canonical DLL", moduleConfig.name.AsCharPtr()); + } +#endif + Base::Library* library = new System::Library(); - library->SetPath(IO::URI(libraryPath)); + library->SetPath(IO::URI(runtimeLoadPath)); if (!library->Load()) { +#if __WIN32__ + if (usesShadowCopy) + { + DeleteShadowArtifacts(runtimeLoadPath, runtimePdbPath); + } +#endif delete library; return !(strictMode || moduleConfig.required); } @@ -132,6 +386,12 @@ ModuleManager::LoadModule(const RuntimeModuleConfig& moduleConfig, GameServer* g n_warning("ModuleManager: module '%s' is missing required exports\n", moduleConfig.name.AsCharPtr()); library->Close(); delete library; +#if __WIN32__ + if (usesShadowCopy) + { + DeleteShadowArtifacts(runtimeLoadPath, runtimePdbPath); + } +#endif return !(strictMode || moduleConfig.required); } @@ -141,6 +401,12 @@ ModuleManager::LoadModule(const RuntimeModuleConfig& moduleConfig, GameServer* g n_warning("ModuleManager: module '%s' descriptor callback failed\n", moduleConfig.name.AsCharPtr()); library->Close(); delete library; +#if __WIN32__ + if (usesShadowCopy) + { + DeleteShadowArtifacts(runtimeLoadPath, runtimePdbPath); + } +#endif return !(strictMode || moduleConfig.required); } @@ -149,6 +415,12 @@ ModuleManager::LoadModule(const RuntimeModuleConfig& moduleConfig, GameServer* g n_warning("ModuleManager: module '%s' has ABI %u, expected %u\n", moduleConfig.name.AsCharPtr(), desc.abiVersion, NEBULA_MODULE_ABI_VERSION); library->Close(); delete library; +#if __WIN32__ + if (usesShadowCopy) + { + DeleteShadowArtifacts(runtimeLoadPath, runtimePdbPath); + } +#endif return !(strictMode || moduleConfig.required); } @@ -158,6 +430,12 @@ ModuleManager::LoadModule(const RuntimeModuleConfig& moduleConfig, GameServer* g n_warning("ModuleManager: module '%s' did not return a feature instance\n", moduleConfig.name.AsCharPtr()); library->Close(); delete library; +#if __WIN32__ + if (usesShadowCopy) + { + DeleteShadowArtifacts(runtimeLoadPath, runtimePdbPath); + } +#endif return !(strictMode || moduleConfig.required); } @@ -168,6 +446,11 @@ ModuleManager::LoadModule(const RuntimeModuleConfig& moduleConfig, GameServer* g loaded.config = moduleConfig; loaded.library = library; loaded.feature = feature; + loaded.loadedLibraryPath = runtimeLoadPath; +#if __WIN32__ + loaded.loadedPdbPath = runtimePdbPath; + loaded.usesShadowCopy = usesShadowCopy; +#endif this->loadedModules.Append(loaded); const char* descName = desc.name != nullptr ? desc.name : ""; @@ -176,7 +459,7 @@ ModuleManager::LoadModule(const RuntimeModuleConfig& moduleConfig, GameServer* g { n_warning("ModuleManager: module '%s' exports '%s', but runtime expects FeatureUnit instances to be managed via Nebula refcounting\n", descName, NEBULA_MODULE_DESTROY_FEATURE_EXPORT); } - n_printf("ModuleManager: loaded module '%s' v%s from '%s'\n", descName, descVersion, libraryPath.AsCharPtr()); + n_printf("ModuleManager: loaded module '%s' v%s from '%s'\n", descName, descVersion, runtimeLoadPath.AsCharPtr()); return true; } @@ -211,6 +494,16 @@ ModuleManager::UnloadModule(LoadedModule& loaded, GameServer* gameServer) loaded.library = nullptr; } +#if __WIN32__ + if (loaded.usesShadowCopy) + { + DeleteShadowArtifacts(loaded.loadedLibraryPath, loaded.loadedPdbPath); + loaded.loadedPdbPath.Clear(); + loaded.usesShadowCopy = false; + } +#endif + loaded.loadedLibraryPath.Clear(); + return true; } diff --git a/code/application/game/modulemanager.h b/code/application/game/modulemanager.h index 4162df9744..597085b847 100644 --- a/code/application/game/modulemanager.h +++ b/code/application/game/modulemanager.h @@ -65,6 +65,9 @@ class ModuleManager : public Core::RefCounted Util::Array loadedModules; Util::Array pendingReloads; +#if __WIN32__ + uint windowsShadowCopySerial = 1; +#endif }; } // namespace Game From d875168dded2a6422a862dd89297a715b8a18eef Mon Sep 17 00:00:00 2001 From: Johannes Date: Fri, 15 May 2026 10:43:39 +0200 Subject: [PATCH 10/19] use appendpath in filedb instead of manually fiddling around --- toolkit/toolkitutil/filedb/filedb.cc | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/toolkit/toolkitutil/filedb/filedb.cc b/toolkit/toolkitutil/filedb/filedb.cc index a2530c6182..268f83664c 100644 --- a/toolkit/toolkitutil/filedb/filedb.cc +++ b/toolkit/toolkitutil/filedb/filedb.cc @@ -475,11 +475,7 @@ FileDB::GetFolderPath(uint64_t folderId) for(const auto& component : pathComponents) { - if (!outPath.IsEmpty()) - { - outPath.Append("/"); - } - outPath.Append(component); + outPath.AppendPath(component); } return outPath; @@ -500,14 +496,8 @@ FileDB::GetFilePath(uint64_t fileId) } Util::String folderPath = this->GetFolderPath(fileInfo.folderId); - if (folderPath.IsEmpty()) - { - return fileInfo.name; - } - else - { - return folderPath + "/" + fileInfo.name; - } + folderPath.AppendPath(fileInfo.name); + return folderPath; } //------------------------------------------------------------------------------ @@ -673,7 +663,16 @@ FileDB::GetFilesInFolder(uint64_t folderId, Array& outFiles) info.type = static_cast(values->GetInt(Attr::FileType, i)); info.size = (SizeT)values->GetInt64(Attr::FileSize, i); info.modifiedDate = IO::FileTime(values->GetInt64(Attr::ModifiedDate, i)); - info.filePath = folderPath.IsEmpty() ? info.name : folderPath + "/" + info.name; + if (folderPath.IsEmpty()) + { + info.filePath = info.name; + } + else + { + Util::String fullPath = folderPath; + fullPath.AppendPath(info.name); + info.filePath = fullPath; + } outFiles.Append(info); } From 4cc48d8ebc92d4a54f5251cd316d88f728011af1 Mon Sep 17 00:00:00 2001 From: Johannes Date: Fri, 15 May 2026 12:19:54 +0200 Subject: [PATCH 11/19] - added helper to enable runtime modules by name - cleaned up compile errors in testtbui --- code/application/appgame/gameapplication.cc | 59 +++++++++++-------- code/application/appgame/gameapplication.h | 4 +- tests/testtbui/CMakeLists.txt | 2 +- tests/testtbui/main.cc | 23 +------- toolkit/levelviewer/levelviewerapplication.cc | 23 +------- 5 files changed, 42 insertions(+), 69 deletions(-) diff --git a/code/application/appgame/gameapplication.cc b/code/application/appgame/gameapplication.cc index 947a4741e3..3449e4dc36 100644 --- a/code/application/appgame/gameapplication.cc +++ b/code/application/appgame/gameapplication.cc @@ -166,7 +166,8 @@ GameApplication::Open() if (this->runtimeModuleConfigs.Size() > 0) { this->moduleManager = Game::ModuleManager::Create(); - const bool loaded = this->moduleManager->LoadModules(this->runtimeModuleConfigs, this->gameServer, this->runtimeModuleStrictMode); + + const bool loaded = this->moduleManager->LoadModules(this->runtimeModuleConfigs.ValuesAsArray(), this->gameServer, this->runtimeModuleStrictMode); if (!loaded && this->runtimeModuleStrictMode) { n_warning("GameApplication::Open(): runtime module loading failed in strict mode\n"); @@ -264,6 +265,28 @@ GameApplication::Run() } } +//------------------------------------------------------------------------------ +/** +*/ +void +GameApplication::EnableRuntimeModule(const Util::String& moduleName, bool required) +{ + if (this->runtimeModuleConfigs.FindIndex(moduleName) == InvalidIndex) + { + Game::RuntimeModuleConfig config; + config.name = moduleName; + config.enabled = true; + config.required = required; + this->runtimeModuleConfigs.Add(moduleName, config); + } + else + { + Game::RuntimeModuleConfig &config = this->runtimeModuleConfigs[moduleName]; + config.enabled = true; + config.required = required; + } +} + //------------------------------------------------------------------------------ /** */ @@ -373,20 +396,6 @@ GameApplication::SetupRuntimeModulesFromCmdLineArgs() this->runtimeModuleStrictMode = false; const Util::CommandLineArgs& args = this->GetCmdLineArgs(); - auto findModuleConfig = [this](const Util::String& moduleName) -> IndexT - { - Util::String check = moduleName; - check.ToLower(); - for (IndexT i = 0; i < this->runtimeModuleConfigs.Size(); i++) - { - Util::String candidate = this->runtimeModuleConfigs[i].name; - candidate.ToLower(); - if (candidate == check) - return i; - } - return InvalidIndex; - }; - // Initialize runtime modules from project settings. CLI flags can then // override these values on a per-module basis. for (IndexT i = 0; i < (IndexT)Options::ProjectSettings.runtime_modules.size(); i++) @@ -403,7 +412,7 @@ GameApplication::SetupRuntimeModulesFromCmdLineArgs() config.path = moduleSettings->path.c_str(); config.enabled = moduleSettings->enabled; config.required = moduleSettings->required; - this->runtimeModuleConfigs.Append(config); + this->runtimeModuleConfigs.Add(config.name, config); } this->runtimeModuleStrictMode = Options::ProjectSettings.runtime_modules_strict; @@ -416,17 +425,17 @@ GameApplication::SetupRuntimeModulesFromCmdLineArgs() if (!modules[i].IsValid()) continue; - IndexT index = findModuleConfig(modules[i]); + IndexT index = this->runtimeModuleConfigs.FindIndex(modules[i]); if (index == InvalidIndex) { Game::RuntimeModuleConfig config; config.name = modules[i]; config.enabled = true; - this->runtimeModuleConfigs.Append(config); + this->runtimeModuleConfigs.Add(config.name, config); } else { - this->runtimeModuleConfigs[index].enabled = true; + this->runtimeModuleConfigs.ValueAtIndex(index).enabled = true; } } } @@ -444,18 +453,18 @@ GameApplication::SetupRuntimeModulesFromCmdLineArgs() continue; } - IndexT index = findModuleConfig(pair[0]); + IndexT index = this->runtimeModuleConfigs.FindIndex(pair[0]); if (index == InvalidIndex) { Game::RuntimeModuleConfig config; config.name = pair[0]; config.path = pair[1]; config.enabled = true; - this->runtimeModuleConfigs.Append(config); + this->runtimeModuleConfigs.Add(config.name, config); } else { - this->runtimeModuleConfigs[index].path = pair[1]; + this->runtimeModuleConfigs.ValueAtIndex(index).path = pair[1]; } } } @@ -468,17 +477,17 @@ GameApplication::SetupRuntimeModulesFromCmdLineArgs() if (!disabledModules[i].IsValid()) continue; - IndexT index = findModuleConfig(disabledModules[i]); + IndexT index = this->runtimeModuleConfigs.FindIndex(disabledModules[i]); if (index == InvalidIndex) { Game::RuntimeModuleConfig config; config.name = disabledModules[i]; config.enabled = false; - this->runtimeModuleConfigs.Append(config); + this->runtimeModuleConfigs.Add(config.name, config); } else { - this->runtimeModuleConfigs[index].enabled = false; + this->runtimeModuleConfigs.ValueAtIndex(index).enabled = false; } } } diff --git a/code/application/appgame/gameapplication.h b/code/application/appgame/gameapplication.h index 372cdaf008..930364cbd4 100644 --- a/code/application/appgame/gameapplication.h +++ b/code/application/appgame/gameapplication.h @@ -63,6 +63,8 @@ class GameApplication : public Application virtual void SetupAppFromCmdLineArgs(); /// parse runtime module startup options from command line virtual void SetupRuntimeModulesFromCmdLineArgs(); + /// enable runtime module + void EnableRuntimeModule(const Util::String& moduleName, bool required = false); Ptr coreServer; Ptr gameContentServer; @@ -71,7 +73,7 @@ class GameApplication : public Application Ptr ioInterface; Ptr baseGameFeature; Ptr moduleManager; - Util::Array runtimeModuleConfigs; + Util::Dictionary runtimeModuleConfigs; bool runtimeModuleStrictMode; diff --git a/tests/testtbui/CMakeLists.txt b/tests/testtbui/CMakeLists.txt index ceee125777..637e338ca5 100644 --- a/tests/testtbui/CMakeLists.txt +++ b/tests/testtbui/CMakeLists.txt @@ -20,6 +20,6 @@ fips_dir(managers) inputmanager.cc ) -fips_deps(foundation application graphicsfeature render resource dynui turbobadger tbui editor) +fips_deps(foundation application graphicsfeature physicsfeature render audiofeature resource dynui turbobadger tbui editor) target_precompile_headers(testtbui PRIVATE [["stdneb.h"]] [["foundation/stdneb.h"]] [["render/stdneb.h"]]) nebula_end_app() diff --git a/tests/testtbui/main.cc b/tests/testtbui/main.cc index 2bc8932a60..8d42175020 100644 --- a/tests/testtbui/main.cc +++ b/tests/testtbui/main.cc @@ -2,7 +2,6 @@ // main.cc // (C) 2020 Individual contributors, see AUTHORS file //------------------------------------------------------------------------------ -#define NEBULA_EDITOR_ENABLED #include "application/stdneb.h" #include "system/appentry.h" @@ -12,11 +11,7 @@ #include "tbuifeatureunit.h" #include "gamestatemanager.h" #include "profiling/profiling.h" - -#ifdef NEBULA_EDITOR_ENABLED #include "editorfeature/editorfeatureunit.h" -#endif - #include "nflatbuffer/nebula_flat.h" #include "nflatbuffer/flatbufferinterface.h" #include "flat/options/levelsettings.h" @@ -42,13 +37,11 @@ class NebulaDemoApplication : public App::GameApplication this->demoFeatureUnit = Tests::TBUIFeatureUnit::Create(); this->gameServer->AttachGameFeature(this->demoFeatureUnit); -#ifdef NEBULA_EDITOR_ENABLED - this->editorFeatureUnit = EditorFeature::EditorFeatureUnit::Create(); - this->gameServer->AttachGameFeature(this->editorFeatureUnit); -#endif - IO::URI tablePath = "proj:work/data/tables/base_level.json"_uri; CompileFlatbuffer(App::LevelSettings, tablePath, "tbl:app"); + + this->EnableRuntimeModule("editorfeaturemodule", true); + } /// cleanup game features void CleanupGameFeatures() @@ -59,20 +52,10 @@ class NebulaDemoApplication : public App::GameApplication this->graphicsFeature = nullptr; this->demoFeatureUnit->Release(); this->demoFeatureUnit = nullptr; - -#ifdef NEBULA_EDITOR_ENABLED - this->gameServer->RemoveGameFeature(this->editorFeatureUnit); - this->editorFeatureUnit->Release(); - this->editorFeatureUnit = nullptr; -#endif } Ptr graphicsFeature; Ptr demoFeatureUnit; - -#ifdef NEBULA_EDITOR_ENABLED - Ptr editorFeatureUnit; -#endif }; //------------------------------------------------------------------------------ diff --git a/toolkit/levelviewer/levelviewerapplication.cc b/toolkit/levelviewer/levelviewerapplication.cc index 57d61293eb..00e341e6cf 100644 --- a/toolkit/levelviewer/levelviewerapplication.cc +++ b/toolkit/levelviewer/levelviewerapplication.cc @@ -119,28 +119,7 @@ LevelViewerGameStateApplication::SetupRuntimeModulesFromCmdLineArgs() { GameApplication::SetupRuntimeModulesFromCmdLineArgs(); - Util::String moduleName = "navigationfeaturemodule"; - bool found = false; - for (IndexT i = 0; i < this->runtimeModuleConfigs.Size(); i++) - { - Util::String candidate = this->runtimeModuleConfigs[i].name; - candidate.ToLower(); - if (candidate == moduleName) - { - this->runtimeModuleConfigs[i].enabled = true; - found = true; - break; - } - } - - if (!found) - { - Game::RuntimeModuleConfig config; - config.name = moduleName; - config.enabled = true; - config.required = false; - this->runtimeModuleConfigs.Append(config); - } + this->EnableRuntimeModule("navigationfeaturemodule", true); } //------------------------------------------------------------------------------ From 5d7ec8ab28a8602ac7b83bbc89b54844f08d4344 Mon Sep 17 00:00:00 2001 From: Johannes Date: Fri, 15 May 2026 12:32:21 +0200 Subject: [PATCH 12/19] - remove unused test code --- toolkit/editor/editorfeaturemodule.cc | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/toolkit/editor/editorfeaturemodule.cc b/toolkit/editor/editorfeaturemodule.cc index 049d2da0bb..893d14fd79 100644 --- a/toolkit/editor/editorfeaturemodule.cc +++ b/toolkit/editor/editorfeaturemodule.cc @@ -6,7 +6,6 @@ #include "core/factory.h" #include "game/moduleinterface.h" #include "editorfeature/editorfeatureunit.h" -#include "cr/cr.h" NEBULA_MODULE_EXPORT int NebulaModuleGetDescriptor(NebulaModuleDescriptor* outDescriptor) @@ -32,28 +31,3 @@ NebulaModuleDestroyFeature(void* feature) { (void)feature; } - -//------------------------------------------------------------------------------ -// cr plugin entry point — handles plugin lifecycle events from a cr host. -//------------------------------------------------------------------------------ -CR_EXPORT int -cr_main(struct cr_plugin* ctx, enum cr_op operation) -{ - (void)ctx; - switch (operation) - { - case CR_LOAD: - // Module just loaded or reloaded; nothing to restore for now. - break; - case CR_UNLOAD: - // About to be unloaded for a reload; flush any pending work here. - break; - case CR_CLOSE: - // Final shutdown; nothing extra needed — Nebula module teardown - // is handled via NebulaModuleDestroyFeature / OnDeactivate. - break; - default: - break; - } - return 0; -} From 961fb9e1ed1127300d1d61307e3d72449e37359e Mon Sep 17 00:00:00 2001 From: Johannes Date: Tue, 19 May 2026 14:03:04 +0200 Subject: [PATCH 13/19] - wrap cvar for windows exports --- code/foundation/core/cvar.cc | 470 ++++++++++++++++++++++++++++++++++- 1 file changed, 463 insertions(+), 7 deletions(-) diff --git a/code/foundation/core/cvar.cc b/code/foundation/core/cvar.cc index dbf5277042..fcc5ebe755 100644 --- a/code/foundation/core/cvar.cc +++ b/code/foundation/core/cvar.cc @@ -4,9 +4,14 @@ //------------------------------------------------------------------------------ // #include "cvar.h" +#include "game/moduleinterface.h" #include "util/hashtable.h" #include "util/string.h" +#if __WIN32__ +#include +#endif + namespace Core { @@ -31,7 +36,162 @@ struct CVar constexpr uint16_t MAX_CVARS = 1024; uint16_t cVarOffset = 0; CVar cVars[MAX_CVARS]; -Util::HashTable cVarTable; + +static Util::HashTable& +GetLocalCVarTable() +{ + static Util::HashTable table; + return table; +} + +static CVar* CVarCreateLocal(CVarCreateInfo const& info); +static CVar* CVarGetLocal(const char* name); +static void CVarParseWriteLocal(CVar* cVar, const char* value); +static void CVarWriteFloatLocal(CVar* cVar, float value); +static void CVarWriteIntLocal(CVar* cVar, int value); +static void CVarWriteStringLocal(CVar* cVar, const char* value); +static int CVarReadIntLocal(CVar* cVar); +static float CVarReadFloatLocal(CVar* cVar); +static const char* CVarReadStringLocal(CVar* cVar); +static bool CVarModifiedLocal(CVar* cVar); +static void CVarSetModifiedLocal(CVar* cVar, bool value); +static CVarType CVarGetTypeLocal(CVar* cVar); +static const char* CVarGetNameLocal(CVar* cVar); +static const char* CVarGetDescriptionLocal(CVar* cVar); +static int CVarNumLocal(); +static CVar* CVarsBeginLocal(); +static CVar* CVarsEndLocal(); +static CVar* CVarNextLocal(CVar* cVar); + +#if __WIN32__ +NEBULA_MODULE_EXPORT CVar* NebulaHost_CVarCreate(int type, const char* name, const char* defaultValue, const char* description); +NEBULA_MODULE_EXPORT CVar* NebulaHost_CVarGet(const char* name); +NEBULA_MODULE_EXPORT void NebulaHost_CVarParseWrite(CVar* cVar, const char* value); +NEBULA_MODULE_EXPORT void NebulaHost_CVarWriteFloat(CVar* cVar, float value); +NEBULA_MODULE_EXPORT void NebulaHost_CVarWriteInt(CVar* cVar, int value); +NEBULA_MODULE_EXPORT void NebulaHost_CVarWriteString(CVar* cVar, const char* value); +NEBULA_MODULE_EXPORT int NebulaHost_CVarReadInt(CVar* cVar); +NEBULA_MODULE_EXPORT float NebulaHost_CVarReadFloat(CVar* cVar); +NEBULA_MODULE_EXPORT const char* NebulaHost_CVarReadString(CVar* cVar); +NEBULA_MODULE_EXPORT bool NebulaHost_CVarModified(CVar* cVar); +NEBULA_MODULE_EXPORT void NebulaHost_CVarSetModified(CVar* cVar, bool value); +NEBULA_MODULE_EXPORT CVarType NebulaHost_CVarGetType(CVar* cVar); +NEBULA_MODULE_EXPORT const char* NebulaHost_CVarGetName(CVar* cVar); +NEBULA_MODULE_EXPORT const char* NebulaHost_CVarGetDescription(CVar* cVar); +NEBULA_MODULE_EXPORT int NebulaHost_CVarNum(); +NEBULA_MODULE_EXPORT CVar* NebulaHost_CVarsBegin(); +NEBULA_MODULE_EXPORT CVar* NebulaHost_CVarsEnd(); +NEBULA_MODULE_EXPORT CVar* NebulaHost_CVarNext(CVar* cVar); + +struct HostCVarApi +{ + CVar* (*create)(int, const char*, const char*, const char*) = nullptr; + CVar* (*get)(const char*) = nullptr; + void (*parseWrite)(CVar*, const char*) = nullptr; + void (*writeFloat)(CVar*, float) = nullptr; + void (*writeInt)(CVar*, int) = nullptr; + void (*writeString)(CVar*, const char*) = nullptr; + int (*readInt)(CVar*) = nullptr; + float (*readFloat)(CVar*) = nullptr; + const char* (*readString)(CVar*) = nullptr; + bool (*modified)(CVar*) = nullptr; + void (*setModified)(CVar*, bool) = nullptr; + CVarType (*getType)(CVar*) = nullptr; + const char* (*getName)(CVar*) = nullptr; + const char* (*getDescription)(CVar*) = nullptr; + int (*num)() = nullptr; + CVar* (*begin)() = nullptr; + CVar* (*end)() = nullptr; + CVar* (*next)(CVar*) = nullptr; + bool resolved = false; + bool valid = false; +}; + +static HostCVarApi& +GetHostCVarApi() +{ + static HostCVarApi api; + if (!api.resolved) + { + api.resolved = true; + HMODULE exe = ::GetModuleHandleA(nullptr); + if (exe != nullptr) + { + api.create = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_CVarCreate")); + api.get = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_CVarGet")); + api.parseWrite = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_CVarParseWrite")); + api.writeFloat = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_CVarWriteFloat")); + api.writeInt = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_CVarWriteInt")); + api.writeString = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_CVarWriteString")); + api.readInt = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_CVarReadInt")); + api.readFloat = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_CVarReadFloat")); + api.readString = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_CVarReadString")); + api.modified = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_CVarModified")); + api.setModified = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_CVarSetModified")); + api.getType = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_CVarGetType")); + api.getName = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_CVarGetName")); + api.getDescription = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_CVarGetDescription")); + api.num = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_CVarNum")); + api.begin = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_CVarsBegin")); + api.end = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_CVarsEnd")); + api.next = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_CVarNext")); + + api.valid = + api.create != nullptr && + api.get != nullptr && + api.parseWrite != nullptr && + api.writeFloat != nullptr && + api.writeInt != nullptr && + api.writeString != nullptr && + api.readInt != nullptr && + api.readFloat != nullptr && + api.readString != nullptr && + api.modified != nullptr && + api.setModified != nullptr && + api.getType != nullptr && + api.getName != nullptr && + api.getDescription != nullptr && + api.num != nullptr && + api.begin != nullptr && + api.end != nullptr && + api.next != nullptr; + } + } + return api; +} + +static bool +UseHostCVarApi() +{ + HostCVarApi& api = GetHostCVarApi(); + return api.valid && api.create != &NebulaHost_CVarCreate; +} + +#define CVAR_FORWARD_TO_HOST_RET(member, ...) \ + do { \ + if (UseHostCVarApi()) \ + { \ + HostCVarApi& cvarHostApi = GetHostCVarApi(); \ + return cvarHostApi.member(__VA_ARGS__); \ + } \ + } while (false) + +#define CVAR_FORWARD_TO_HOST_VOID(member, ...) \ + do { \ + if (UseHostCVarApi()) \ + { \ + HostCVarApi& cvarHostApi = GetHostCVarApi(); \ + cvarHostApi.member(__VA_ARGS__); \ + return; \ + } \ + } while (false) + +#else + +#define CVAR_FORWARD_TO_HOST_RET(member, ...) do { } while (false) +#define CVAR_FORWARD_TO_HOST_VOID(member, ...) do { } while (false) + +#endif //------------------------------------------------------------------------------ /** @@ -39,14 +199,24 @@ Util::HashTable cVarTable; CVar* CVarCreate(CVarCreateInfo const& info) { - CVar* ptr = CVarGet(info.name); + CVAR_FORWARD_TO_HOST_RET(create, (int)info.type, info.name, info.defaultValue, info.description); + return CVarCreateLocal(info); +} + +//------------------------------------------------------------------------------ +/** +*/ +static CVar* +CVarCreateLocal(CVarCreateInfo const& info) +{ + CVar* ptr = CVarGetLocal(info.name); const bool needsInit = (ptr == nullptr); if (ptr == nullptr) { IndexT varIndex = cVarOffset++; n_assert(varIndex < MAX_CVARS); ptr = &cVars[varIndex]; - cVarTable.Add(info.name, varIndex); + GetLocalCVarTable().Add(info.name, varIndex); } n_assert2(!Util::String(info.name).ContainsCharFromSet(" "), "CVar name cannot contain spaces."); @@ -59,7 +229,7 @@ CVarCreate(CVarCreateInfo const& info) } if (needsInit) { - CVarParseWrite(ptr, info.defaultValue); + CVarParseWriteLocal(ptr, info.defaultValue); } return ptr; } @@ -83,8 +253,19 @@ CVarCreate(CVarType type, const char* name, const char* defaultValue, const char */ CVar* CVarGet(const char* name) +{ + CVAR_FORWARD_TO_HOST_RET(get, name); + return CVarGetLocal(name); +} + +//------------------------------------------------------------------------------ +/** +*/ +static CVar* +CVarGetLocal(const char* name) { Util::String const str = name; + Util::HashTable& cVarTable = GetLocalCVarTable(); IndexT const index = cVarTable.FindIndex(str); if (index != InvalidIndex) { @@ -99,17 +280,27 @@ CVarGet(const char* name) */ void CVarParseWrite(CVar* cVar, const char* value) +{ + CVAR_FORWARD_TO_HOST_VOID(parseWrite, cVar, value); + CVarParseWriteLocal(cVar, value); +} + +//------------------------------------------------------------------------------ +/** +*/ +static void +CVarParseWriteLocal(CVar* cVar, const char* value) { switch (cVar->value.type) { case CVar_Int: - CVarWriteInt(cVar, atoi(value)); + CVarWriteIntLocal(cVar, atoi(value)); break; case CVar_Float: - CVarWriteFloat(cVar, atof(value)); + CVarWriteFloatLocal(cVar, atof(value)); break; case CVar_String: - CVarWriteString(cVar, value); + CVarWriteStringLocal(cVar, value); break; default: break; @@ -121,6 +312,16 @@ CVarParseWrite(CVar* cVar, const char* value) */ void CVarWriteFloat(CVar* cVar, float value) +{ + CVAR_FORWARD_TO_HOST_VOID(writeFloat, cVar, value); + CVarWriteFloatLocal(cVar, value); +} + +//------------------------------------------------------------------------------ +/** +*/ +static void +CVarWriteFloatLocal(CVar* cVar, float value) { if (cVar->value.type == CVar_Float) { @@ -138,6 +339,16 @@ CVarWriteFloat(CVar* cVar, float value) */ void CVarWriteInt(CVar* cVar, int value) +{ + CVAR_FORWARD_TO_HOST_VOID(writeInt, cVar, value); + CVarWriteIntLocal(cVar, value); +} + +//------------------------------------------------------------------------------ +/** +*/ +static void +CVarWriteIntLocal(CVar* cVar, int value) { if (cVar->value.type == CVar_Int) { @@ -155,6 +366,16 @@ CVarWriteInt(CVar* cVar, int value) */ void CVarWriteString(CVar* cVar, const char* value) +{ + CVAR_FORWARD_TO_HOST_VOID(writeString, cVar, value); + CVarWriteStringLocal(cVar, value); +} + +//------------------------------------------------------------------------------ +/** +*/ +static void +CVarWriteStringLocal(CVar* cVar, const char* value) { if (cVar->value.type == CVar_String) { @@ -175,6 +396,16 @@ CVarWriteString(CVar* cVar, const char* value) */ int const CVarReadInt(CVar* cVar) +{ + CVAR_FORWARD_TO_HOST_RET(readInt, cVar); + return CVarReadIntLocal(cVar); +} + +//------------------------------------------------------------------------------ +/** +*/ +static int +CVarReadIntLocal(CVar* cVar) { if (cVar->value.type == CVar_Int) { @@ -190,6 +421,16 @@ CVarReadInt(CVar* cVar) */ float const CVarReadFloat(CVar* cVar) +{ + CVAR_FORWARD_TO_HOST_RET(readFloat, cVar); + return CVarReadFloatLocal(cVar); +} + +//------------------------------------------------------------------------------ +/** +*/ +static float +CVarReadFloatLocal(CVar* cVar) { if (cVar->value.type == CVar_Float) { @@ -205,6 +446,16 @@ CVarReadFloat(CVar* cVar) */ const char* CVarReadString(CVar* cVar) +{ + CVAR_FORWARD_TO_HOST_RET(readString, cVar); + return CVarReadStringLocal(cVar); +} + +//------------------------------------------------------------------------------ +/** +*/ +static const char* +CVarReadStringLocal(CVar* cVar) { if (cVar->value.type == CVar_String) { @@ -220,6 +471,16 @@ CVarReadString(CVar* cVar) */ bool CVarModified(CVar* cVar) +{ + CVAR_FORWARD_TO_HOST_RET(modified, cVar); + return CVarModifiedLocal(cVar); +} + +//------------------------------------------------------------------------------ +/** +*/ +static bool +CVarModifiedLocal(CVar* cVar) { return cVar->modified; } @@ -229,6 +490,16 @@ CVarModified(CVar* cVar) */ void CVarSetModified(CVar* cVar, bool value) +{ + CVAR_FORWARD_TO_HOST_VOID(setModified, cVar, value); + CVarSetModifiedLocal(cVar, value); +} + +//------------------------------------------------------------------------------ +/** +*/ +static void +CVarSetModifiedLocal(CVar* cVar, bool value) { cVar->modified = value; } @@ -238,6 +509,16 @@ CVarSetModified(CVar* cVar, bool value) */ CVarType CVarGetType(CVar* cVar) +{ + CVAR_FORWARD_TO_HOST_RET(getType, cVar); + return CVarGetTypeLocal(cVar); +} + +//------------------------------------------------------------------------------ +/** +*/ +static CVarType +CVarGetTypeLocal(CVar* cVar) { return cVar->value.type; } @@ -247,6 +528,16 @@ CVarGetType(CVar* cVar) */ const char* CVarGetName(CVar* cVar) +{ + CVAR_FORWARD_TO_HOST_RET(getName, cVar); + return CVarGetNameLocal(cVar); +} + +//------------------------------------------------------------------------------ +/** +*/ +static const char* +CVarGetNameLocal(CVar* cVar) { return cVar->name.AsCharPtr(); } @@ -256,6 +547,16 @@ CVarGetName(CVar* cVar) */ const char* CVarGetDescription(CVar* cVar) +{ + CVAR_FORWARD_TO_HOST_RET(getDescription, cVar); + return CVarGetDescriptionLocal(cVar); +} + +//------------------------------------------------------------------------------ +/** +*/ +static const char* +CVarGetDescriptionLocal(CVar* cVar) { return cVar->description.AsCharPtr(); } @@ -265,6 +566,16 @@ CVarGetDescription(CVar* cVar) */ int CVarNum() +{ + CVAR_FORWARD_TO_HOST_RET(num); + return CVarNumLocal(); +} + +//------------------------------------------------------------------------------ +/** +*/ +static int +CVarNumLocal() { return cVarOffset; } @@ -274,6 +585,16 @@ CVarNum() */ CVar* CVarsBegin() +{ + CVAR_FORWARD_TO_HOST_RET(begin); + return CVarsBeginLocal(); +} + +//------------------------------------------------------------------------------ +/** +*/ +static CVar* +CVarsBeginLocal() { return cVars; } @@ -283,6 +604,16 @@ CVarsBegin() */ CVar* CVarsEnd() +{ + CVAR_FORWARD_TO_HOST_RET(end); + return CVarsEndLocal(); +} + +//------------------------------------------------------------------------------ +/** +*/ +static CVar* +CVarsEndLocal() { return cVars + cVarOffset; } @@ -292,9 +623,134 @@ CVarsEnd() */ CVar* CVarNext(CVar* cVar) +{ + CVAR_FORWARD_TO_HOST_RET(next, cVar); + return CVarNextLocal(cVar); +} + +//------------------------------------------------------------------------------ +/** +*/ +static CVar* +CVarNextLocal(CVar* cVar) { return cVar + 1; } +#if __WIN32__ +NEBULA_MODULE_EXPORT CVar* +NebulaHost_CVarCreate(int type, const char* name, const char* defaultValue, const char* description) +{ + CVarCreateInfo info; + info.type = (CVarType)type; + info.name = name; + info.defaultValue = defaultValue; + info.description = description; + return CVarCreateLocal(info); +} + +NEBULA_MODULE_EXPORT CVar* +NebulaHost_CVarGet(const char* name) +{ + return CVarGetLocal(name); +} + +NEBULA_MODULE_EXPORT void +NebulaHost_CVarParseWrite(CVar* cVar, const char* value) +{ + CVarParseWriteLocal(cVar, value); +} + +NEBULA_MODULE_EXPORT void +NebulaHost_CVarWriteFloat(CVar* cVar, float value) +{ + CVarWriteFloatLocal(cVar, value); +} + +NEBULA_MODULE_EXPORT void +NebulaHost_CVarWriteInt(CVar* cVar, int value) +{ + CVarWriteIntLocal(cVar, value); +} + +NEBULA_MODULE_EXPORT void +NebulaHost_CVarWriteString(CVar* cVar, const char* value) +{ + CVarWriteStringLocal(cVar, value); +} + +NEBULA_MODULE_EXPORT int +NebulaHost_CVarReadInt(CVar* cVar) +{ + return CVarReadIntLocal(cVar); +} + +NEBULA_MODULE_EXPORT float +NebulaHost_CVarReadFloat(CVar* cVar) +{ + return CVarReadFloatLocal(cVar); +} + +NEBULA_MODULE_EXPORT const char* +NebulaHost_CVarReadString(CVar* cVar) +{ + return CVarReadStringLocal(cVar); +} + +NEBULA_MODULE_EXPORT bool +NebulaHost_CVarModified(CVar* cVar) +{ + return CVarModifiedLocal(cVar); +} + +NEBULA_MODULE_EXPORT void +NebulaHost_CVarSetModified(CVar* cVar, bool value) +{ + CVarSetModifiedLocal(cVar, value); +} + +NEBULA_MODULE_EXPORT CVarType +NebulaHost_CVarGetType(CVar* cVar) +{ + return CVarGetTypeLocal(cVar); +} + +NEBULA_MODULE_EXPORT const char* +NebulaHost_CVarGetName(CVar* cVar) +{ + return CVarGetNameLocal(cVar); +} + +NEBULA_MODULE_EXPORT const char* +NebulaHost_CVarGetDescription(CVar* cVar) +{ + return CVarGetDescriptionLocal(cVar); +} + +NEBULA_MODULE_EXPORT int +NebulaHost_CVarNum() +{ + return CVarNumLocal(); +} + +NEBULA_MODULE_EXPORT CVar* +NebulaHost_CVarsBegin() +{ + return CVarsBeginLocal(); +} + +NEBULA_MODULE_EXPORT CVar* +NebulaHost_CVarsEnd() +{ + return CVarsEndLocal(); +} + +NEBULA_MODULE_EXPORT CVar* +NebulaHost_CVarNext(CVar* cVar) +{ + return CVarNextLocal(cVar); +} +#endif + } // namespace Core From e5756970198993f98e2ef8313d058176daee21a9 Mon Sep 17 00:00:00 2001 From: Johannes Date: Thu, 21 May 2026 17:24:50 +0200 Subject: [PATCH 14/19] wrap singletons in windows to deal with dll not living in the same memory space --- code/foundation/CMakeLists.txt | 1 + code/foundation/core/win32/win32singleton.cc | 187 +++++++++++++++++++ code/foundation/core/win32/win32singleton.h | 63 ++++++- 3 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 code/foundation/core/win32/win32singleton.cc diff --git a/code/foundation/CMakeLists.txt b/code/foundation/CMakeLists.txt index ea847ae017..26705d2446 100644 --- a/code/foundation/CMakeLists.txt +++ b/code/foundation/CMakeLists.txt @@ -546,6 +546,7 @@ endif() fips_dir(core GROUP "core/win32") fips_files( win32/precompiled.h + win32/win32singleton.cc win32/win32singleton.h win32/win32sysfunc.cc win32/win32sysfunc.h diff --git a/code/foundation/core/win32/win32singleton.cc b/code/foundation/core/win32/win32singleton.cc new file mode 100644 index 0000000000..f3009ead2e --- /dev/null +++ b/code/foundation/core/win32/win32singleton.cc @@ -0,0 +1,187 @@ +//------------------------------------------------------------------------------ +// win32singleton.cc +// (C) 2026 Individual contributors, see AUTHORS file +//------------------------------------------------------------------------------ +#include "core/win32/win32singleton.h" + +#include "util/hashtable.h" +#include "util/string.h" +#include "threading/criticalsection.h" +#include "game/moduleinterface.h" + +namespace Core +{ +namespace +{ +using SingletonTable = Util::HashTable; + +//------------------------------------------------------------------------------ +/** +*/ +static SingletonTable& +GetGlobalSingletonTable() +{ + static SingletonTable table; + return table; +} + +//------------------------------------------------------------------------------ +/** +*/ +static SingletonTable& +GetThreadLocalSingletonTable() +{ + thread_local SingletonTable table; + return table; +} + +//------------------------------------------------------------------------------ +/** +*/ +static Threading::CriticalSection& +GetGlobalSingletonLock() +{ + static Threading::CriticalSection criticalSection; + return criticalSection; +} + +//------------------------------------------------------------------------------ +/** +*/ +static void* +LocalSingletonGet(const char* key, bool threadLocal) +{ + SingletonTable& table = threadLocal ? GetThreadLocalSingletonTable() : GetGlobalSingletonTable(); + const Util::String lookupKey = key; + + if (!threadLocal) + GetGlobalSingletonLock().Enter(); + + void* ptr = nullptr; + IndexT index = table.FindIndex(lookupKey); + if (index != InvalidIndex) + ptr = table.ValueAtIndex(lookupKey, index); + + if (!threadLocal) + GetGlobalSingletonLock().Leave(); + + return ptr; +} + +//------------------------------------------------------------------------------ +/** +*/ +static void +LocalSingletonSet(const char* key, bool threadLocal, void* ptr) +{ + SingletonTable& table = threadLocal ? GetThreadLocalSingletonTable() : GetGlobalSingletonTable(); + const Util::String lookupKey = key; + + if (!threadLocal) + GetGlobalSingletonLock().Enter(); + + IndexT index = table.FindIndex(lookupKey); + if (index == InvalidIndex) + { + table.Add(lookupKey, ptr); + } + else + { + table.ValueAtIndex(lookupKey, index) = ptr; + } + + if (!threadLocal) + GetGlobalSingletonLock().Leave(); +} + +NEBULA_MODULE_EXPORT void* NebulaHost_SingletonGet(const char* key, bool threadLocal); +NEBULA_MODULE_EXPORT void NebulaHost_SingletonSet(const char* key, bool threadLocal, void* ptr); + +struct HostSingletonApi +{ + void* (*get)(const char*, bool) = nullptr; + void (*set)(const char*, bool, void*) = nullptr; + bool resolved = false; + bool valid = false; +}; + +//------------------------------------------------------------------------------ +/** +*/ +static HostSingletonApi& +GetHostSingletonApi() +{ + static HostSingletonApi api; + if (!api.resolved) + { + api.resolved = true; + HMODULE exe = ::GetModuleHandleA(nullptr); + if (exe != nullptr) + { + api.get = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_SingletonGet")); + api.set = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_SingletonSet")); + api.valid = api.get != nullptr && api.set != nullptr; + } + } + return api; +} + +//------------------------------------------------------------------------------ +/** +*/ +static bool +UseHostSingletonApi() +{ + HostSingletonApi& api = GetHostSingletonApi(); + return api.valid && api.get != &NebulaHost_SingletonGet && api.set != &NebulaHost_SingletonSet; +} +} // anonymous namespace + +//------------------------------------------------------------------------------ +/** +*/ +void* +SingletonGet(const char* key, bool threadLocal) +{ + if (UseHostSingletonApi()) + { + HostSingletonApi& api = GetHostSingletonApi(); + return api.get(key, threadLocal); + } + return LocalSingletonGet(key, threadLocal); +} + +//------------------------------------------------------------------------------ +/** +*/ +void +SingletonSet(const char* key, bool threadLocal, void* ptr) +{ + if (UseHostSingletonApi()) + { + HostSingletonApi& api = GetHostSingletonApi(); + api.set(key, threadLocal, ptr); + return; + } + LocalSingletonSet(key, threadLocal, ptr); +} + +//------------------------------------------------------------------------------ +/** +*/ +NEBULA_MODULE_EXPORT void* +NebulaHost_SingletonGet(const char* key, bool threadLocal) +{ + return LocalSingletonGet(key, threadLocal); +} + +//------------------------------------------------------------------------------ +/** +*/ +NEBULA_MODULE_EXPORT void +NebulaHost_SingletonSet(const char* key, bool threadLocal, void* ptr) +{ + LocalSingletonSet(key, threadLocal, ptr); +} + +} // namespace Core diff --git a/code/foundation/core/win32/win32singleton.h b/code/foundation/core/win32/win32singleton.h index 7c6cf7f5e4..4e9b7b0b7f 100644 --- a/code/foundation/core/win32/win32singleton.h +++ b/code/foundation/core/win32/win32singleton.h @@ -20,26 +20,77 @@ */ #include "core/types.h" +//------------------------------------------------------------------------------ +namespace Core +{ + +void* SingletonGet(const char* key, bool threadLocal); +void SingletonSet(const char* key, bool threadLocal, void* ptr); + +// this is a wrapper to deal with windows dll export/import of singletons +template +class SingletonProxy +{ +public: + constexpr SingletonProxy(const char* inKey = nullptr, bool inThreadLocal = false) + : key(inKey) + , threadLocal(inThreadLocal) + { + } + + TYPE* Get() const + { + return reinterpret_cast(Core::SingletonGet(this->key, this->threadLocal)); + } + + void Set(TYPE* ptr) + { + Core::SingletonSet(this->key, this->threadLocal, ptr); + } + + SingletonProxy& operator=(TYPE* ptr) + { + this->Set(ptr); + return *this; + } + + operator TYPE*() const + { + return this->Get(); + } + + TYPE* operator->() const + { + return this->Get(); + } + +private: + const char* key; + bool threadLocal; +}; + +} // namespace Core + //------------------------------------------------------------------------------ #define __DeclareSingleton(type) \ public: \ - thread_local static type * Singleton; \ - static type * Instance() { n_assert(nullptr != Singleton); return Singleton; }; \ + static Core::SingletonProxy Singleton; \ + static type * Instance() { type* ptr = Singleton; n_assert(nullptr != ptr); return ptr; }; \ static bool HasInstance() { return nullptr != Singleton; }; \ private: #define __DeclareInterfaceSingleton(type) \ public: \ - static type * Singleton; \ - static type * Instance() { n_assert(nullptr != Singleton); return Singleton; }; \ + static Core::SingletonProxy Singleton; \ + static type * Instance() { type* ptr = Singleton; n_assert(nullptr != ptr); return ptr; }; \ static bool HasInstance() { return nullptr != Singleton; }; \ private: #define __ImplementSingleton(type) \ - thread_local type * type::Singleton = nullptr; + Core::SingletonProxy type::Singleton(__FILE__ ":" #type, true); #define __ImplementInterfaceSingleton(type) \ - type * type::Singleton = nullptr; + Core::SingletonProxy type::Singleton(__FILE__ ":" #type, false); #define __ConstructSingleton \ n_assert(nullptr == Singleton); Singleton = this; From ac8aa304bf87154e686dac7a0456fc5ec6dca318 Mon Sep 17 00:00:00 2001 From: Johannes Date: Thu, 21 May 2026 17:43:27 +0200 Subject: [PATCH 15/19] - more windows dll wrapping --- code/foundation/core/win32/win32sysfunc.cc | 82 ++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/code/foundation/core/win32/win32sysfunc.cc b/code/foundation/core/win32/win32sysfunc.cc index 5d038519a6..57a6cd1c06 100644 --- a/code/foundation/core/win32/win32sysfunc.cc +++ b/code/foundation/core/win32/win32sysfunc.cc @@ -7,6 +7,7 @@ #include "core/win32/win32sysfunc.h" #include "core/refcounted.h" #include "debug/minidump.h" +#include "game/moduleinterface.h" #include "util/blob.h" #include "net/socket/socket.h" #include "debug/minidump.h" @@ -30,6 +31,56 @@ Util::GlobalStringAtomTable* globalStringAtomTable = 0; Util::LocalStringAtomTable* localStringAtomTable = 0; #endif +NEBULA_MODULE_EXPORT void NebulaHost_SysFuncSetup(); +NEBULA_MODULE_EXPORT void NebulaHost_SysFuncExit(int exitCode); + +struct HostSysFuncApi +{ + void (*setup)() = nullptr; + void (*exit)(int) = nullptr; + bool resolved = false; +}; + +//------------------------------------------------------------------------------ +/** +*/ +static HostSysFuncApi& +GetHostSysFuncApi() +{ + static HostSysFuncApi api; + if (!api.resolved) + { + api.resolved = true; + HMODULE exe = ::GetModuleHandleA(nullptr); + if (exe != nullptr) + { + api.setup = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_SysFuncSetup")); + api.exit = reinterpret_cast(::GetProcAddress(exe, "NebulaHost_SysFuncExit")); + } + } + return api; +} + +//------------------------------------------------------------------------------ +/** +*/ +static bool +UseHostSysFuncSetup() +{ + HostSysFuncApi& api = GetHostSysFuncApi(); + return api.setup != nullptr && api.setup != &NebulaHost_SysFuncSetup; +} + +//------------------------------------------------------------------------------ +/** +*/ +static bool +UseHostSysFuncExit() +{ + HostSysFuncApi& api = GetHostSysFuncApi(); + return api.exit != nullptr && api.exit != &NebulaHost_SysFuncExit; +} + //------------------------------------------------------------------------------ /** This method must be called at application start before any threads @@ -40,6 +91,13 @@ Util::GlobalStringAtomTable* globalStringAtomTable = 0; void SysFunc::Setup() { + if (UseHostSysFuncSetup()) + { + GetHostSysFuncApi().setup(); + SetupCalled = true; + return; + } + if (!SetupCalled) { SetupCalled = true; @@ -90,6 +148,12 @@ SysFunc::Setup() void SysFunc::Exit(int exitCode) { + if (UseHostSysFuncExit()) + { + GetHostSysFuncApi().exit(exitCode); + return; + } + // first produce a RefCount leak report #if NEBULA_DEBUG Core::RefCounted::DumpRefCountingLeaks(); @@ -250,4 +314,22 @@ SysFunc::RegisterExitHandler(const Core::ExitHandler* exitHandler) return firstHandler; } +//------------------------------------------------------------------------------ +/** +*/ +NEBULA_MODULE_EXPORT void +NebulaHost_SysFuncSetup() +{ + SysFunc::Setup(); +} + +//------------------------------------------------------------------------------ +/** +*/ +NEBULA_MODULE_EXPORT void +NebulaHost_SysFuncExit(int exitCode) +{ + SysFunc::Exit(exitCode); +} + } // namespace Win32 From f803fa59b4a13e85986f1e2ec02cdad641e48ce1 Mon Sep 17 00:00:00 2001 From: Johannes Date: Thu, 21 May 2026 20:32:43 +0200 Subject: [PATCH 16/19] only use the host heaps in the dlls to avoid mixing dll/exe memory --- code/foundation/memory/win32/win32memory.cc | 14 +++++++++-- .../memory/win32/win32memoryconfig.cc | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/code/foundation/memory/win32/win32memory.cc b/code/foundation/memory/win32/win32memory.cc index 10e85250be..55bfa9cd0e 100644 --- a/code/foundation/memory/win32/win32memory.cc +++ b/code/foundation/memory/win32/win32memory.cc @@ -6,10 +6,10 @@ #include "core/types.h" #include "core/sysfunc.h" +#include "game/moduleinterface.h" #include "memory/heap.h" #include "memory/poolarrayallocator.h" - namespace Memory { HANDLE volatile Win32ProcessHeap = 0; @@ -33,7 +33,7 @@ Alloc(HeapType heapType, size_t size, size_t align) // need to make sure everything has been setup Core::SysFunc::Setup(); - void* allocPtr = 0; + void* allocPtr = 0; { n_assert(0 != Heaps[heapType]); allocPtr = __HeapAlloc16(Heaps[heapType], 0, size); @@ -424,6 +424,16 @@ FreeVirtual(void* ptr, size_t size) n_assert(ret != 0); } +//------------------------------------------------------------------------------ +/** +*/ +NEBULA_MODULE_EXPORT void* +NebulaHost_GetMemoryHeapHandle(int heapType) +{ + n_assert(heapType >= 0 && heapType < NumHeapTypes); + return reinterpret_cast(Heaps[heapType]); +} + } // namespace Memory //------------------------------------------------------------------------------ diff --git a/code/foundation/memory/win32/win32memoryconfig.cc b/code/foundation/memory/win32/win32memoryconfig.cc index 25ba10a1fc..7b2f63d5f8 100644 --- a/code/foundation/memory/win32/win32memoryconfig.cc +++ b/code/foundation/memory/win32/win32memoryconfig.cc @@ -19,6 +19,30 @@ HANDLE volatile Heaps[NumHeapTypes] = { NULL }; void SetupHeaps() { + // If running in a module DLL the host EXE has already created the heaps. + // Detect this by querying the host for heap 0: if non-null we are a module, + // so copy all handles directly instead of calling HeapCreate. + using GetHeapHandleFn = void* (*)(int); + HMODULE exe = ::GetModuleHandleA(nullptr); + if (exe != nullptr) + { + GetHeapHandleFn hostGetHeap = reinterpret_cast( + ::GetProcAddress(exe, "NebulaHost_GetMemoryHeapHandle")); + if (hostGetHeap != nullptr) + { + HANDLE h = reinterpret_cast(hostGetHeap(0)); + if (h != nullptr) + { + for (unsigned int i = 0; i < NumHeapTypes; i++) + { + n_assert(0 == Heaps[i]); + Heaps[i] = reinterpret_cast(hostGetHeap(i)); + } + return; + } + } + } + // setup global heaps const SIZE_T megaByte = 1024 * 1024; const SIZE_T kiloByte = 1024; From e08f63ee9f201d09d5f60c93bd8611fa6a7be71c Mon Sep 17 00:00:00 2001 From: Johannes Date: Sat, 23 May 2026 21:01:08 +0200 Subject: [PATCH 17/19] missing heap setup --- code/foundation/core/win32/win32sysfunc.cc | 1 + 1 file changed, 1 insertion(+) diff --git a/code/foundation/core/win32/win32sysfunc.cc b/code/foundation/core/win32/win32sysfunc.cc index 57a6cd1c06..6f7b2d57bf 100644 --- a/code/foundation/core/win32/win32sysfunc.cc +++ b/code/foundation/core/win32/win32sysfunc.cc @@ -95,6 +95,7 @@ SysFunc::Setup() { GetHostSysFuncApi().setup(); SetupCalled = true; + Memory::SetupHeaps(); return; } From ced4c585db7d532adaa1e7da0f1b54e26de23c84 Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 24 May 2026 17:50:37 +0200 Subject: [PATCH 18/19] - remove vkpipelinedatabase singleton as it lives in the state object anyway - missing dependencies for editormodule --- code/addons/navigationfeature/CMakeLists.txt | 2 +- code/foundation/core/win32/win32sysfunc.cc | 2 +- code/foundation/memory/win32/win32memory.cc | 3 --- code/render/coregraphics/vk/vkpipelinedatabase.cc | 4 +--- code/render/coregraphics/vk/vkpipelinedatabase.h | 2 -- code/render/graphics/graphicsdisplayeventhandler.cc | 6 ------ toolkit/editor/CMakeLists.txt | 4 ++-- toolkit/editor/editor/bindings/editorbindings.cc | 3 ++- 8 files changed, 7 insertions(+), 19 deletions(-) diff --git a/code/addons/navigationfeature/CMakeLists.txt b/code/addons/navigationfeature/CMakeLists.txt index 10f62460ba..8338c87e58 100644 --- a/code/addons/navigationfeature/CMakeLists.txt +++ b/code/addons/navigationfeature/CMakeLists.txt @@ -1,5 +1,5 @@ nebula_begin_module(navigationfeature) -fips_deps(Detour DetourCrowd Recast DetourTileCache DebugUtils application render) +fips_deps(Detour DetourCrowd Recast DetourTileCache DebugUtils application render dynui) target_include_directories(navigationfeature PRIVATE ${CODE_ROOT}/foundation) fips_files( navigationfeatureunit.h diff --git a/code/foundation/core/win32/win32sysfunc.cc b/code/foundation/core/win32/win32sysfunc.cc index 6f7b2d57bf..35a5cfd15b 100644 --- a/code/foundation/core/win32/win32sysfunc.cc +++ b/code/foundation/core/win32/win32sysfunc.cc @@ -91,7 +91,7 @@ UseHostSysFuncExit() void SysFunc::Setup() { - if (UseHostSysFuncSetup()) + if (UseHostSysFuncSetup() && !SetupCalled) { GetHostSysFuncApi().setup(); SetupCalled = true; diff --git a/code/foundation/memory/win32/win32memory.cc b/code/foundation/memory/win32/win32memory.cc index 55bfa9cd0e..c1aff80d77 100644 --- a/code/foundation/memory/win32/win32memory.cc +++ b/code/foundation/memory/win32/win32memory.cc @@ -30,9 +30,6 @@ Alloc(HeapType heapType, size_t size, size_t align) { n_assert(heapType < NumHeapTypes); n_assert(align <= 16); - // need to make sure everything has been setup - Core::SysFunc::Setup(); - void* allocPtr = 0; { n_assert(0 != Heaps[heapType]); diff --git a/code/render/coregraphics/vk/vkpipelinedatabase.cc b/code/render/coregraphics/vk/vkpipelinedatabase.cc index 1af13e151e..30c86ef8a5 100644 --- a/code/render/coregraphics/vk/vkpipelinedatabase.cc +++ b/code/render/coregraphics/vk/vkpipelinedatabase.cc @@ -11,7 +11,6 @@ namespace Vulkan { -__ImplementSingleton(VkPipelineDatabase); //------------------------------------------------------------------------------ /** */ @@ -19,7 +18,6 @@ VkPipelineDatabase::VkPipelineDatabase() : dev(VK_NULL_HANDLE), cache(VK_NULL_HANDLE) { - __ConstructSingleton; this->Reset(); } @@ -28,7 +26,7 @@ VkPipelineDatabase::VkPipelineDatabase() : */ VkPipelineDatabase::~VkPipelineDatabase() { - __DestructSingleton; + // } //------------------------------------------------------------------------------ diff --git a/code/render/coregraphics/vk/vkpipelinedatabase.h b/code/render/coregraphics/vk/vkpipelinedatabase.h index 0aed9b13d5..8ee680966a 100644 --- a/code/render/coregraphics/vk/vkpipelinedatabase.h +++ b/code/render/coregraphics/vk/vkpipelinedatabase.h @@ -19,7 +19,6 @@ (C) 2016-2020 Individual contributors, see AUTHORS file */ //------------------------------------------------------------------------------ -#include "core/singleton.h" #include "coregraphics/shader.h" #include "coregraphics/pass.h" #include "memory/arenaallocator.h" @@ -33,7 +32,6 @@ class VkPass; class VkPipelineDatabase { - __DeclareSingleton(VkPipelineDatabase); public: enum StateLevel diff --git a/code/render/graphics/graphicsdisplayeventhandler.cc b/code/render/graphics/graphicsdisplayeventhandler.cc index 79f866934f..cfc1bb3206 100644 --- a/code/render/graphics/graphicsdisplayeventhandler.cc +++ b/code/render/graphics/graphicsdisplayeventhandler.cc @@ -6,9 +6,6 @@ #include "graphics/graphicsdisplayeventhandler.h" #include "graphics/graphicsserver.h" -#if __VULKAN__ -#include "coregraphics/vk/vkpipelinedatabase.h" -#endif namespace Graphics { @@ -24,9 +21,6 @@ bool GraphicsDisplayEventHandler::HandleEvent(const DisplayEvent& displayEvent) { Ptr graphicsServer = Graphics::GraphicsServer::Instance(); -#if __VULKAN__ - Vulkan::VkPipelineDatabase* pipelineDatabase = Vulkan::VkPipelineDatabase::Instance(); -#endif switch (displayEvent.GetEventCode()) { case DisplayEvent::CloseRequested: diff --git a/toolkit/editor/CMakeLists.txt b/toolkit/editor/CMakeLists.txt index 2a73c050c9..3e02f57d3d 100644 --- a/toolkit/editor/CMakeLists.txt +++ b/toolkit/editor/CMakeLists.txt @@ -163,7 +163,7 @@ fips_dir(editor) pathconverter.cc pathconverter.h ) -fips_deps(foundation application render toolkit-common toolkitutil) +fips_deps(foundation application render toolkit-common toolkitutil dynui) target_link_libraries(editor graphicsfeature physicsfeature audio dynui scripting) set_property(TARGET editor PROPERTY INTERFACE_LINK_LIBRARIES "foundation;application;render;toolkit-common;toolkitutil") nebula_end_module() @@ -173,5 +173,5 @@ target_include_directories(editorfeaturemodule PUBLIC ${CMAKE_CURRENT_SOURCE_DIR fips_files( editorfeaturemodule.cc ) -fips_deps(editor application render scripting) +fips_deps(editor application render scripting dynui graphicsfeature physicsfeature audiofeature) nebula_end_shared_module() diff --git a/toolkit/editor/editor/bindings/editorbindings.cc b/toolkit/editor/editor/bindings/editorbindings.cc index 3a97e5373c..8988876dc3 100644 --- a/toolkit/editor/editor/bindings/editorbindings.cc +++ b/toolkit/editor/editor/bindings/editorbindings.cc @@ -4,6 +4,7 @@ //------------------------------------------------------------------------------ #include "foundation/stdneb.h" #include "editor/commandmanager.h" +#include "game/moduleinterface.h" #include "editor/editor.h" #include "scripting/python/conversion.h" #include "scripting/scriptserver.h" @@ -11,7 +12,7 @@ namespace py = nanobind; -extern "C" PyObject* PyInit_editor(); +NEBULA_MODULE_EXPORT PyObject* PyInit_editor(); namespace { From a04cb57c14525b2bc9855ac42e45dbcd0e87ebd9 Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 24 May 2026 18:53:58 +0200 Subject: [PATCH 19/19] - move moduleinterface header to system --- code/addons/navigationfeaturemodule/navigationfeaturemodule.cc | 2 +- code/application/CMakeLists.txt | 1 - code/application/game/modulemanager.h | 2 +- code/foundation/CMakeLists.txt | 1 + code/foundation/core/cvar.cc | 2 +- code/foundation/core/win32/win32singleton.cc | 2 +- code/foundation/core/win32/win32sysfunc.cc | 2 +- code/foundation/memory/win32/win32memory.cc | 2 +- code/{application/game => foundation/system}/moduleinterface.h | 0 tests/testruntimemodule/runtimemodulefeature.cc | 2 +- tests/testruntimemodulebadabi/badabimodule.cc | 2 +- tests/testruntimemodulebadexports/badexportsmodule.cc | 2 +- toolkit/editor/editor/bindings/editorbindings.cc | 2 +- toolkit/editor/editorfeaturemodule.cc | 2 +- 14 files changed, 12 insertions(+), 12 deletions(-) rename code/{application/game => foundation/system}/moduleinterface.h (100%) diff --git a/code/addons/navigationfeaturemodule/navigationfeaturemodule.cc b/code/addons/navigationfeaturemodule/navigationfeaturemodule.cc index a5341898b1..4abda1040d 100644 --- a/code/addons/navigationfeaturemodule/navigationfeaturemodule.cc +++ b/code/addons/navigationfeaturemodule/navigationfeaturemodule.cc @@ -4,7 +4,7 @@ //------------------------------------------------------------------------------ #include "application/stdneb.h" #include "core/factory.h" -#include "game/moduleinterface.h" +#include "system/moduleinterface.h" #include "navigationfeature/navigationfeatureunit.h" diff --git a/code/application/CMakeLists.txt b/code/application/CMakeLists.txt index c972fc75be..36ce43de13 100644 --- a/code/application/CMakeLists.txt +++ b/code/application/CMakeLists.txt @@ -45,7 +45,6 @@ endif() gameserver.cc manager.h manager.cc - moduleinterface.h modulemanager.h modulemanager.cc componentserialization.h diff --git a/code/application/game/modulemanager.h b/code/application/game/modulemanager.h index 597085b847..b64ca9e76b 100644 --- a/code/application/game/modulemanager.h +++ b/code/application/game/modulemanager.h @@ -11,7 +11,7 @@ #include "core/refcounted.h" #include "util/array.h" #include "util/string.h" -#include "game/moduleinterface.h" +#include "system/moduleinterface.h" namespace Base { diff --git a/code/foundation/CMakeLists.txt b/code/foundation/CMakeLists.txt index 26705d2446..f38f0568f2 100644 --- a/code/foundation/CMakeLists.txt +++ b/code/foundation/CMakeLists.txt @@ -436,6 +436,7 @@ endif() process.h library.h systeminfo.h + moduleinterface.h nebulasettings.h base/processbase.h base/librarybase.h diff --git a/code/foundation/core/cvar.cc b/code/foundation/core/cvar.cc index fcc5ebe755..1e8136d475 100644 --- a/code/foundation/core/cvar.cc +++ b/code/foundation/core/cvar.cc @@ -4,7 +4,7 @@ //------------------------------------------------------------------------------ // #include "cvar.h" -#include "game/moduleinterface.h" +#include "system/moduleinterface.h" #include "util/hashtable.h" #include "util/string.h" diff --git a/code/foundation/core/win32/win32singleton.cc b/code/foundation/core/win32/win32singleton.cc index f3009ead2e..2c5bea7f01 100644 --- a/code/foundation/core/win32/win32singleton.cc +++ b/code/foundation/core/win32/win32singleton.cc @@ -7,7 +7,7 @@ #include "util/hashtable.h" #include "util/string.h" #include "threading/criticalsection.h" -#include "game/moduleinterface.h" +#include "system/moduleinterface.h" namespace Core { diff --git a/code/foundation/core/win32/win32sysfunc.cc b/code/foundation/core/win32/win32sysfunc.cc index 35a5cfd15b..98505bc70d 100644 --- a/code/foundation/core/win32/win32sysfunc.cc +++ b/code/foundation/core/win32/win32sysfunc.cc @@ -7,7 +7,7 @@ #include "core/win32/win32sysfunc.h" #include "core/refcounted.h" #include "debug/minidump.h" -#include "game/moduleinterface.h" +#include "system/moduleinterface.h" #include "util/blob.h" #include "net/socket/socket.h" #include "debug/minidump.h" diff --git a/code/foundation/memory/win32/win32memory.cc b/code/foundation/memory/win32/win32memory.cc index c1aff80d77..fb1ba97c30 100644 --- a/code/foundation/memory/win32/win32memory.cc +++ b/code/foundation/memory/win32/win32memory.cc @@ -6,7 +6,7 @@ #include "core/types.h" #include "core/sysfunc.h" -#include "game/moduleinterface.h" +#include "system/moduleinterface.h" #include "memory/heap.h" #include "memory/poolarrayallocator.h" diff --git a/code/application/game/moduleinterface.h b/code/foundation/system/moduleinterface.h similarity index 100% rename from code/application/game/moduleinterface.h rename to code/foundation/system/moduleinterface.h diff --git a/tests/testruntimemodule/runtimemodulefeature.cc b/tests/testruntimemodule/runtimemodulefeature.cc index 94319fcf30..905407fad5 100644 --- a/tests/testruntimemodule/runtimemodulefeature.cc +++ b/tests/testruntimemodule/runtimemodulefeature.cc @@ -5,7 +5,7 @@ #include "stdneb.h" #include "core/factory.h" #include "game/featureunit.h" -#include "game/moduleinterface.h" +#include "system/moduleinterface.h" #include namespace TestRuntimeModule diff --git a/tests/testruntimemodulebadabi/badabimodule.cc b/tests/testruntimemodulebadabi/badabimodule.cc index 2c5a9b34a6..20e9fab94e 100644 --- a/tests/testruntimemodulebadabi/badabimodule.cc +++ b/tests/testruntimemodulebadabi/badabimodule.cc @@ -3,7 +3,7 @@ // (C) 2026 Individual contributors, see AUTHORS file //------------------------------------------------------------------------------ #include "stdneb.h" -#include "game/moduleinterface.h" +#include "system/moduleinterface.h" NEBULA_MODULE_EXPORT int NebulaModuleGetDescriptor(NebulaModuleDescriptor* outDescriptor) diff --git a/tests/testruntimemodulebadexports/badexportsmodule.cc b/tests/testruntimemodulebadexports/badexportsmodule.cc index 867844bb69..26cfa8d9e2 100644 --- a/tests/testruntimemodulebadexports/badexportsmodule.cc +++ b/tests/testruntimemodulebadexports/badexportsmodule.cc @@ -3,7 +3,7 @@ // (C) 2026 Individual contributors, see AUTHORS file //------------------------------------------------------------------------------ #include "stdneb.h" -#include "game/moduleinterface.h" +#include "system/moduleinterface.h" NEBULA_MODULE_EXPORT int NebulaModuleGetDescriptor(NebulaModuleDescriptor* outDescriptor) diff --git a/toolkit/editor/editor/bindings/editorbindings.cc b/toolkit/editor/editor/bindings/editorbindings.cc index 8988876dc3..dad727b90b 100644 --- a/toolkit/editor/editor/bindings/editorbindings.cc +++ b/toolkit/editor/editor/bindings/editorbindings.cc @@ -4,7 +4,7 @@ //------------------------------------------------------------------------------ #include "foundation/stdneb.h" #include "editor/commandmanager.h" -#include "game/moduleinterface.h" +#include "system/moduleinterface.h" #include "editor/editor.h" #include "scripting/python/conversion.h" #include "scripting/scriptserver.h" diff --git a/toolkit/editor/editorfeaturemodule.cc b/toolkit/editor/editorfeaturemodule.cc index 893d14fd79..0b3eefc283 100644 --- a/toolkit/editor/editorfeaturemodule.cc +++ b/toolkit/editor/editorfeaturemodule.cc @@ -4,7 +4,7 @@ //------------------------------------------------------------------------------ #include "application/stdneb.h" #include "core/factory.h" -#include "game/moduleinterface.h" +#include "system/moduleinterface.h" #include "editorfeature/editorfeatureunit.h" NEBULA_MODULE_EXPORT int