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/dynui/console/imguiconsole.cc b/code/addons/dynui/console/imguiconsole.cc index 892c0ff054..6aea4cba11 100644 --- a/code/addons/dynui/console/imguiconsole.cc +++ b/code/addons/dynui/console/imguiconsole.cc @@ -392,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 < consoleBuffer.Size(); i++) + for (int i = 0; i < this->consoleBuffer.Size(); i++) { - const char* item = consoleBuffer[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(consoleBuffer[i].type))) + if (!filter.PassFilter(item) && !filter.PassFilter(this->LogEntryTypeAsCharPtr(this->consoleBuffer[i].type))) continue; ImVec4 col; - switch (consoleBuffer[i].type) + switch (this->consoleBuffer[i].type) { case N_MESSAGE: col = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); @@ -428,9 +428,9 @@ ImguiConsole::RenderContent() ImGui::PushStyleColor(ImGuiCol_Text, col); ImGui::SameLine(); //Print message prefix - if (consoleBuffer[i].type != N_MESSAGE) + if (this->consoleBuffer[i].type != N_MESSAGE) { - ImGui::TextUnformatted(this->LogEntryTypeAsCharPtr(consoleBuffer[i].type)); + ImGui::TextUnformatted(this->LogEntryTypeAsCharPtr(this->consoleBuffer[i].type)); ImGui::SameLine(); } //Print log entry @@ -441,11 +441,11 @@ ImguiConsole::RenderContent() ImGui::PopTextWrapPos(); static SizeT lastConsoleBufferSize = 0; - if (this->scrollToBottom && consoleBuffer.Size() != lastConsoleBufferSize) + if (this->scrollToBottom && this->consoleBuffer.Size() != lastConsoleBufferSize) { ImGui::SetScrollHereY(); } - lastConsoleBufferSize = consoleBuffer.Size(); + lastConsoleBufferSize = this->consoleBuffer.Size(); ImGui::PopStyleVar(); ImGui::EndChild(); @@ -634,7 +634,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/dynui/imguicontext.cc b/code/addons/dynui/imguicontext.cc index a691278836..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); @@ -100,9 +139,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 @@ -418,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); @@ -452,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); @@ -460,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 @@ -497,8 +565,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 @@ -506,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); @@ -513,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 @@ -550,8 +629,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]); + } + } }); } @@ -873,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; } //------------------------------------------------------------------------------ @@ -881,6 +974,9 @@ ImguiContext::Create() void ImguiContext::Discard() { + state.renderingEnabled = false; + DisableImguiFramescriptCallbacks(); + IndexT i; for (i = 0; i < state.vbos.Size(); i++) { @@ -894,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; + } - CoreGraphics::DisplayDevice::Instance()->RemoveEventHandler(state.displayEventHandler.upcast()); - state.displayEventHandler = nullptr; + if (state.displayEventHandler != nullptr) + { + CoreGraphics::DisplayDevice::Instance()->RemoveEventHandler(state.displayEventHandler.upcast()); + state.displayEventHandler = nullptr; + } - CoreGraphics::DestroyTexture((CoreGraphics::TextureId)state.fontTexture.nebulaHandle); + if (state.graphicsContextRegistered) + { + Graphics::GraphicsServer::Instance()->UnregisterGraphicsContext(&__bundle); + state.graphicsContextRegistered = false; + } + + 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/addons/memdb/attributeregistry.cc b/code/addons/memdb/attributeregistry.cc index 7610fdb187..385d3bada5 100644 --- a/code/addons/memdb/attributeregistry.cc +++ b/code/addons/memdb/attributeregistry.cc @@ -27,6 +27,15 @@ AttributeRegistry::Instance() //------------------------------------------------------------------------------ /** +*/ +bool +AttributeRegistry::HasInstance() +{ + return Singleton != nullptr; +} + +//------------------------------------------------------------------------------ +/** 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. diff --git a/code/addons/memdb/attributeregistry.h b/code/addons/memdb/attributeregistry.h index 16333abea5..fe162a92de 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); @@ -34,14 +39,24 @@ 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) + static void Unregister(AttributeId descriptor); private: static AttributeRegistry* Instance(); @@ -88,6 +103,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 +263,25 @@ 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(); - if (descriptor.id >= 0 && descriptor.id < reg->componentDescriptions.Size()) - { - 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 reg->componentDescriptions[descriptor.id]; +} + +//------------------------------------------------------------------------------ +/** +*/ +inline Attribute* +AttributeRegistry::TryGetAttribute(AttributeId descriptor) +{ + if (!AttributeRegistry::IsRegistered(descriptor)) + return nullptr; + + auto* reg = Instance(); + return reg->componentDescriptions[descriptor.id]; } //------------------------------------------------------------------------------ @@ -246,33 +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(); - n_assert(descriptor.id >= 0 && descriptor.id < reg->componentDescriptions.Size()); 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(); - n_assert(descriptor.id >= 0 && descriptor.id < reg->componentDescriptions.Size()); 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(); - n_assert(descriptor.id >= 0 && descriptor.id < reg->componentDescriptions.Size()); 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; +} + //------------------------------------------------------------------------------ /** */ @@ -283,4 +387,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/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/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..4abda1040d --- /dev/null +++ b/code/addons/navigationfeaturemodule/navigationfeaturemodule.cc @@ -0,0 +1,47 @@ +//------------------------------------------------------------------------------ +// navigationfeaturemodule.cc +// (C) 2026 Individual contributors, see AUTHORS file +//------------------------------------------------------------------------------ +#include "application/stdneb.h" +#include "core/factory.h" +#include "system/moduleinterface.h" +#include "navigationfeature/navigationfeatureunit.h" + + + +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/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/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/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 e6e8870fff..36ce43de13 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) @@ -41,6 +45,8 @@ nebula_begin_module(application) gameserver.cc manager.h manager.cc + modulemanager.h + modulemanager.cc componentserialization.h componentserialization.cc componentinspection.h @@ -91,7 +97,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/application/appgame/gameapplication.cc b/code/application/appgame/gameapplication.cc index 6a97448ab8..3449e4dc36 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,21 @@ 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.ValuesAsArray(), 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 +204,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; @@ -240,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; + } +} + //------------------------------------------------------------------------------ /** */ @@ -278,6 +325,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); @@ -307,6 +362,15 @@ GameApplication::CleanupGameFeatures() // cleanup your features in derived class } +//------------------------------------------------------------------------------ +/** +*/ +Ptr +GameApplication::GetModuleManager() const +{ + return this->moduleManager; +} + //------------------------------------------------------------------------------ /** */ @@ -322,4 +386,117 @@ GameApplication::SetupAppFromCmdLineArgs() editorEnabled = args.GetBoolFlag("-editor"); } +//------------------------------------------------------------------------------ +/** +*/ +void +GameApplication::SetupRuntimeModulesFromCmdLineArgs() +{ + this->runtimeModuleConfigs.Clear(); + this->runtimeModuleStrictMode = false; + const Util::CommandLineArgs& args = this->GetCmdLineArgs(); + + // 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.Add(config.name, 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 = this->runtimeModuleConfigs.FindIndex(modules[i]); + if (index == InvalidIndex) + { + Game::RuntimeModuleConfig config; + config.name = modules[i]; + config.enabled = true; + this->runtimeModuleConfigs.Add(config.name, config); + } + else + { + this->runtimeModuleConfigs.ValueAtIndex(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 = this->runtimeModuleConfigs.FindIndex(pair[0]); + if (index == InvalidIndex) + { + Game::RuntimeModuleConfig config; + config.name = pair[0]; + config.path = pair[1]; + config.enabled = true; + this->runtimeModuleConfigs.Add(config.name, config); + } + else + { + this->runtimeModuleConfigs.ValueAtIndex(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 = this->runtimeModuleConfigs.FindIndex(disabledModules[i]); + if (index == InvalidIndex) + { + Game::RuntimeModuleConfig config; + config.name = disabledModules[i]; + config.enabled = false; + this->runtimeModuleConfigs.Add(config.name, config); + } + else + { + this->runtimeModuleConfigs.ValueAtIndex(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..930364cbd4 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 @@ -49,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 @@ -57,6 +61,10 @@ 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(); + /// enable runtime module + void EnableRuntimeModule(const Util::String& moduleName, bool required = false); Ptr coreServer; Ptr gameContentServer; @@ -64,6 +72,9 @@ class GameApplication : public Application Ptr ioServer; Ptr ioInterface; Ptr baseGameFeature; + Ptr moduleManager; + Util::Dictionary runtimeModuleConfigs; + bool runtimeModuleStrictMode; static bool editorEnabled; 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..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 { @@ -44,7 +45,15 @@ FeatureUnit::OnAttach() void FeatureUnit::OnRemove() { - // empty + 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); + } + + 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 new file mode 100644 index 0000000000..71ae317d4b --- /dev/null +++ b/code/application/game/modulemanager.cc @@ -0,0 +1,652 @@ +//------------------------------------------------------------------------------ +// 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" + +#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); + +struct ModuleManager::LoadedModule +{ + RuntimeModuleConfig config; + Base::Library* library; + Ptr feature; + Util::String loadedLibraryPath; +#if __WIN32__ + Util::String loadedPdbPath; + bool usesShadowCopy = false; +#endif +}; + +//------------------------------------------------------------------------------ +/** +*/ +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 +{ + return this->FindLoadedModuleIndex(moduleName) != InvalidIndex; +} + +//------------------------------------------------------------------------------ +/** +*/ +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)) + { + 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()) + { + n_warning("ModuleManager: module '%s' has no valid path\n", moduleConfig.name.AsCharPtr()); + 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(runtimeLoadPath)); + if (!library->Load()) + { +#if __WIN32__ + if (usesShadowCopy) + { + DeleteShadowArtifacts(runtimeLoadPath, runtimePdbPath); + } +#endif + 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) + { + 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); + } + + NebulaModuleDescriptor desc = {}; + if (getDescriptor(&desc) == 0) + { + 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); + } + + if (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; +#if __WIN32__ + if (usesShadowCopy) + { + DeleteShadowArtifacts(runtimeLoadPath, runtimePdbPath); + } +#endif + return !(strictMode || moduleConfig.required); + } + + FeatureUnit* featureRaw = reinterpret_cast(createFeature()); + if (featureRaw == nullptr) + { + 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); + } + + Ptr feature = featureRaw; + gameServer->AttachGameFeature(feature); + + LoadedModule loaded; + 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 : ""; + const char* descVersion = desc.version != nullptr ? desc.version : ""; + if (destroyFeature != nullptr) + { + 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, runtimeLoadPath.AsCharPtr()); + return true; +} + +//------------------------------------------------------------------------------ +/** +*/ +bool +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) + { + 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; + } + + loaded.feature = nullptr; + } + + if (loaded.library != nullptr) + { + if (loaded.library->IsLoaded()) + { + loaded.library->Close(); + } + delete loaded.library; + 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; +} + +//------------------------------------------------------------------------------ +/** +*/ +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 deployRoot = NEBULA_BINARY_FOLDER; + deployRoot.ConvertBackslashes(); + Util::String deployCandidate = Util::String::AppendPath(deployRoot, path); + if (IO::FSWrapper::FileExists(deployCandidate)) + { + return deployCandidate; + } +#endif + + 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; +} + +//------------------------------------------------------------------------------ +/** +*/ +void +ModuleManager::QueueModuleReload(const Util::String& moduleName) +{ + // Deduplicate: only queue a reload once per frame + 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); +} + +//------------------------------------------------------------------------------ +/** +*/ +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) +{ + IndexT idx = this->FindLoadedModuleIndex(moduleName); + + if (idx == InvalidIndex) + { + 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; + + n_printf("ModuleManager: reloading '%s'...\n", moduleName.AsCharPtr()); + + if (!this->UnloadModule(this->loadedModules[idx], gameServer)) + { + n_warning("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)) + { + n_warning("ModuleManager: reload of '%s' failed during load\n", moduleName.AsCharPtr()); + return false; + } + + n_printf("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 new file mode 100644 index 0000000000..b64ca9e76b --- /dev/null +++ b/code/application/game/modulemanager.h @@ -0,0 +1,74 @@ +#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 "system/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; + + /// 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); + 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); + + Util::Array loadedModules; + Util::Array pendingReloads; +#if __WIN32__ + uint windowsShadowCopySerial = 1; +#endif +}; + +} // 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/CMakeLists.txt b/code/foundation/CMakeLists.txt index 8f0dfc59ff..f38f0568f2 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) @@ -432,6 +436,7 @@ nebula_begin_module(foundation) process.h library.h systeminfo.h + moduleinterface.h nebulasettings.h base/processbase.h base/librarybase.h @@ -542,6 +547,7 @@ nebula_begin_module(foundation) fips_dir(core GROUP "core/win32") fips_files( win32/precompiled.h + win32/win32singleton.cc win32/win32singleton.h win32/win32sysfunc.cc win32/win32sysfunc.h @@ -658,6 +664,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 ) @@ -701,6 +709,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/foundation/core/cvar.cc b/code/foundation/core/cvar.cc index dbf5277042..1e8136d475 100644 --- a/code/foundation/core/cvar.cc +++ b/code/foundation/core/cvar.cc @@ -4,9 +4,14 @@ //------------------------------------------------------------------------------ // #include "cvar.h" +#include "system/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 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/core/win32/win32singleton.cc b/code/foundation/core/win32/win32singleton.cc new file mode 100644 index 0000000000..2c5bea7f01 --- /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 "system/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; diff --git a/code/foundation/core/win32/win32sysfunc.cc b/code/foundation/core/win32/win32sysfunc.cc index 5d038519a6..98505bc70d 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 "system/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,14 @@ Util::GlobalStringAtomTable* globalStringAtomTable = 0; void SysFunc::Setup() { + if (UseHostSysFuncSetup() && !SetupCalled) + { + GetHostSysFuncApi().setup(); + SetupCalled = true; + Memory::SetupHeaps(); + return; + } + if (!SetupCalled) { SetupCalled = true; @@ -90,6 +149,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 +315,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 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/foundation/memory/win32/win32memory.cc b/code/foundation/memory/win32/win32memory.cc index 10e85250be..fb1ba97c30 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 "system/moduleinterface.h" #include "memory/heap.h" #include "memory/poolarrayallocator.h" - namespace Memory { HANDLE volatile Win32ProcessHeap = 0; @@ -30,10 +30,7 @@ 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; + void* allocPtr = 0; { n_assert(0 != Heaps[heapType]); allocPtr = __HeapAlloc16(Heaps[heapType], 0, size); @@ -424,6 +421,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; 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/moduleinterface.h b/code/foundation/system/moduleinterface.h new file mode 100644 index 0000000000..928865a512 --- /dev/null +++ b/code/foundation/system/moduleinterface.h @@ -0,0 +1,47 @@ +#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. + + (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" + +#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" +{ +#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/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/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/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/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/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/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/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/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/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/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/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/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..905407fad5 --- /dev/null +++ b/tests/testruntimemodule/runtimemodulefeature.cc @@ -0,0 +1,71 @@ +//------------------------------------------------------------------------------ +// runtimemodulefeature.cc +// (C) 2026 Individual contributors, see AUTHORS file +//------------------------------------------------------------------------------ +#include "stdneb.h" +#include "core/factory.h" +#include "game/featureunit.h" +#include "system/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 + +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..20e9fab94e --- /dev/null +++ b/tests/testruntimemodulebadabi/badabimodule.cc @@ -0,0 +1,31 @@ +//------------------------------------------------------------------------------ +// badabimodule.cc +// (C) 2026 Individual contributors, see AUTHORS file +//------------------------------------------------------------------------------ +#include "stdneb.h" +#include "system/moduleinterface.h" + +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..26cfa8d9e2 --- /dev/null +++ b/tests/testruntimemodulebadexports/badexportsmodule.cc @@ -0,0 +1,19 @@ +//------------------------------------------------------------------------------ +// badexportsmodule.cc +// (C) 2026 Individual contributors, see AUTHORS file +//------------------------------------------------------------------------------ +#include "stdneb.h" +#include "system/moduleinterface.h" + +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/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/editor/CMakeLists.txt b/toolkit/editor/CMakeLists.txt index d373c9344f..3e02f57d3d 100644 --- a/toolkit/editor/CMakeLists.txt +++ b/toolkit/editor/CMakeLists.txt @@ -163,5 +163,15 @@ fips_dir(editor) pathconverter.cc pathconverter.h ) -fips_deps(foundation application graphicsfeature physicsfeature navigationfeature audio 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() + +nebula_begin_shared_module(editorfeaturemodule) +target_include_directories(editorfeaturemodule PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +fips_files( + editorfeaturemodule.cc +) +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 5f0b7762f7..dad727b90b 100644 --- a/toolkit/editor/editor/bindings/editorbindings.cc +++ b/toolkit/editor/editor/bindings/editorbindings.cc @@ -4,12 +4,59 @@ //------------------------------------------------------------------------------ #include "foundation/stdneb.h" #include "editor/commandmanager.h" +#include "system/moduleinterface.h" +#include "editor/editor.h" #include "scripting/python/conversion.h" #include "scripting/scriptserver.h" #include "editor/ui/windowserver.h" namespace py = nanobind; +NEBULA_MODULE_EXPORT 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 +77,31 @@ 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("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 { 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 4388675485..98cce3df3b 100644 --- a/toolkit/editor/editor/editor.cc +++ b/toolkit/editor/editor/editor.cc @@ -5,7 +5,13 @@ #include "foundation/stdneb.h" #include "editor.h" +#include "appgame/gameapplication.h" +#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" #include "memdb/database.h" #include "game/api.h" @@ -20,33 +26,242 @@ #include "tools/pathconverter.h" #include "io/assignregistry.h" #include "tools/livebatcher.h" +#include "editor/ui/windows/navigation.h" #include "game/editorstate.h" #include "toolkit-common/projectinfo.h" +#include + namespace Editor { +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; +} + +//------------------------------------------------------------------------------ +/** +*/ +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(); @@ -54,6 +269,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); @@ -68,7 +287,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"; + } } //------------------------------------------------------------------------------ @@ -77,11 +304,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; } @@ -92,9 +324,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); + } //------------------------------------------------------------------------------ @@ -160,5 +407,65 @@ StopGame() gameTimeSource->timeFactor = 0.0f; } +//------------------------------------------------------------------------------ +/** +*/ +void +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"; + 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; + } + + 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)) + { + 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(moduleName); + state.lastReloadStatus = "Build succeeded, reload queued..."; + n_printf("Editor: module '%s' reload queued\n", moduleName.AsCharPtr()); +} + +//------------------------------------------------------------------------------ +/** +*/ +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..6c282c57a4 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,19 @@ void Start(); /// Destroy the editor void Destroy(); +/// Returns true if editor state/world is initialized +bool IsCreated(); + +/// Request an explicit module reload. +/// The reload is deferred to the next frame boundary. +/// Rejected if play-in-editor is currently active. +/// 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(); + /// 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/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/editor/editor/ui/windows/toolbar.cc b/toolkit/editor/editor/ui/windows/toolbar.cc index 990edac3ad..75c0601860 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("editorfeaturemodule", "editorfeaturemodule"); + } + 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.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 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 new file mode 100644 index 0000000000..0b3eefc283 --- /dev/null +++ b/toolkit/editor/editorfeaturemodule.cc @@ -0,0 +1,33 @@ +//------------------------------------------------------------------------------ +// editorfeaturemodule.cc +// (C) 2026 Individual contributors, see AUTHORS file +//------------------------------------------------------------------------------ +#include "application/stdneb.h" +#include "core/factory.h" +#include "system/moduleinterface.h" +#include "editorfeature/editorfeatureunit.h" + +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; +} diff --git a/toolkit/levelviewer/levelviewerapplication.cc b/toolkit/levelviewer/levelviewerapplication.cc index 7e1d559780..00e341e6cf 100644 --- a/toolkit/levelviewer/levelviewerapplication.cc +++ b/toolkit/levelviewer/levelviewerapplication.cc @@ -111,6 +111,17 @@ LevelViewerGameStateApplication::SetupStateHandlers() } +//------------------------------------------------------------------------------ +/** +*/ +void +LevelViewerGameStateApplication::SetupRuntimeModulesFromCmdLineArgs() +{ + GameApplication::SetupRuntimeModulesFromCmdLineArgs(); + + this->EnableRuntimeModule("navigationfeaturemodule", true); +} + //------------------------------------------------------------------------------ /** */ @@ -158,15 +169,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 +246,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; 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); }