diff --git a/.gitignore b/.gitignore index 6b7edb17..a61c4751 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,11 @@ CMakeSettings.json *.patch .claude/ images/ +/Source/Resources/Automation/Artifacts/ +/Source/Resources/Automation/Reports/ + +# Local validation findings +/ISSUES.md + +# Local agent guidance +/AGENTS.md diff --git a/ShadowIssue.jpg b/ShadowIssue.jpg new file mode 100644 index 00000000..64de5899 Binary files /dev/null and b/ShadowIssue.jpg differ diff --git a/Source/Game-App/Game-App.lua b/Source/Game-App/Game-App.lua index af71d74c..ded35650 100644 --- a/Source/Game-App/Game-App.lua +++ b/Source/Game-App/Game-App.lua @@ -9,6 +9,7 @@ Solution.Util.CreateConsoleApp(mod.Name, Solution.Projects.Current.BinDir, mod.D local projFile = mod.Path .. "/" .. mod.Name .. ".lua" local files = Solution.Util.GetFilesForCpp(mod.Path) table.insert(files, projFile) + table.insert(files, mod.Path .. "/Game-App/Resources/renderdoc.json") Solution.Util.SetFiles(files) Solution.Util.SetIncludes(mod.Path) @@ -22,9 +23,14 @@ Solution.Util.CreateConsoleApp(mod.Name, Solution.Projects.Current.BinDir, mod.D } Solution.Util.SetFiles(appIconFiles) + postbuildcommands + { + '{COPYFILE} "' .. mod.Path .. '/Game-App/Resources/renderdoc.json" "%{cfg.targetdir}/renderdoc.json"' + } + vpaths { - ['Resources/*'] = { '*.rc', '**.ico' }, + ['Resources/*'] = { '*.rc', '**.ico', '**.json' }, ["/*"] = { "*.lua", mod.Name .. "/**" } } end) diff --git a/Source/Game-App/Game-App/Resources/renderdoc.json b/Source/Game-App/Game-App/Resources/renderdoc.json new file mode 100644 index 00000000..96014e18 --- /dev/null +++ b/Source/Game-App/Game-App/Resources/renderdoc.json @@ -0,0 +1,42 @@ +{ + "file_format_version": "1.1.2", + "layer": { + "name": "VK_LAYER_RENDERDOC_Capture", + "type": "GLOBAL", + "library_path": ".\\renderdoc.dll", + "api_version": "1.4.324", + "implementation_version": "45", + "description": "RenderDoc capture layer", + "functions": { + "vkGetInstanceProcAddr": "VK_LAYER_RENDERDOC_CaptureGetInstanceProcAddr", + "vkGetDeviceProcAddr": "VK_LAYER_RENDERDOC_CaptureGetDeviceProcAddr", + "vkNegotiateLoaderLayerInterfaceVersion": "VK_LAYER_RENDERDOC_CaptureNegotiateLoaderLayerInterfaceVersion" + }, + "instance_extensions": [ + { + "name": "VK_EXT_debug_utils", + "spec_version": "1" + } + ], + "device_extensions": [ + { + "name": "VK_EXT_debug_marker", + "spec_version": "4", + "entrypoints": [ + "vkDebugMarkerSetObjectTagEXT", + "vkDebugMarkerSetObjectNameEXT", + "vkCmdDebugMarkerBeginEXT", + "vkCmdDebugMarkerEndEXT", + "vkCmdDebugMarkerInsertEXT" + ] + }, + { + "name": "VK_EXT_tooling_info", + "spec_version": "1", + "entrypoints": [ + "vkGetPhysicalDeviceToolPropertiesEXT" + ] + } + ] + } +} diff --git a/Source/Game-App/Game-App/main.cpp b/Source/Game-App/Game-App/main.cpp index 36cc7eff..740f801b 100644 --- a/Source/Game-App/Game-App/main.cpp +++ b/Source/Game-App/Game-App/main.cpp @@ -10,7 +10,9 @@ #include #include +#include #include +#include #include #if WIN32 @@ -22,40 +24,48 @@ #include #endif -i32 main() +i32 main(i32 argc, char* argv[]) { + // MCPTools redirects the process streams to pipes. The C runtime otherwise + // block-buffers plain Luau print output, hiding automation progress until + // the buffer fills or the process exits. + std::setvbuf(stdout, nullptr, _IONBF, 0); + std::setvbuf(stderr, nullptr, _IONBF, 0); + #if WIN32 timeBeginPeriod(1); #endif + bool enableRenderDoc = false; + for (i32 argumentIndex = 1; argumentIndex < argc; argumentIndex++) + { + if (std::string_view(argv[argumentIndex]) == "-renderdoc") + enableRenderDoc = true; + } + quill::Backend::start(); - auto console_sink = quill::Frontend::create_or_get_sink("console_sink_1"); + auto console_sink = quill::Frontend::create_or_get_sink("console_sink_1", false); quill::Logger* logger = quill::Frontend::create_or_get_logger("root", std::move(console_sink), "%(time:<16) LOG_%(log_level:<11) %(message)", "%H:%M:%S.%Qms", quill::Timezone::LocalTime, quill::ClockSourceType::System); Application app; - app.Start(true); + app.Start(true, enableRenderDoc); ConsoleCommandHandler commandHandler; #if WIN32 moodycamel::ConcurrentQueue consoleCommands; std::atomic_bool consoleInputRunning = true; - HANDLE consoleInput = GetStdHandle(STD_INPUT_HANDLE); - std::thread consoleInputThread; - if (consoleInput != nullptr && consoleInput != INVALID_HANDLE_VALUE) + std::thread consoleInputThread([&consoleCommands, &consoleInputRunning]() { - consoleInputThread = std::thread([&consoleCommands, &consoleInputRunning]() + while (consoleInputRunning) { - while (consoleInputRunning) - { - std::string command = StringUtils::GetLineFromCin(); - if (!consoleInputRunning || std::cin.fail()) - break; + std::string command = StringUtils::GetLineFromCin(); + if (!consoleInputRunning || std::cin.fail()) + break; - consoleCommands.enqueue(std::move(command)); - } - }); - } + consoleCommands.enqueue(std::move(command)); + } + }); #else pollfd consoleInput = { diff --git a/Source/Game-Lib/Game-Lib/Application/Application.cpp b/Source/Game-Lib/Game-Lib/Application/Application.cpp index c9449df5..acbce888 100644 --- a/Source/Game-Lib/Game-Lib/Application/Application.cpp +++ b/Source/Game-Lib/Game-Lib/Application/Application.cpp @@ -34,11 +34,13 @@ #include "Game-Lib/Scripting/Handlers/GameHandler.h" #include "Game-Lib/Scripting/Handlers/UnitHandler.h" #include "Game-Lib/Scripting/Handlers/TimeHandler.h" +#include "Game-Lib/Scripting/Handlers/SchedulerHandler.h" #include "Game-Lib/Scripting/Handlers/CameraHandler.h" #include "Game-Lib/Scripting/Handlers/MapHandler.h" #include "Game-Lib/Scripting/Handlers/SceneHandler.h" #include "Game-Lib/Scripting/Handlers/EditorToolHandler.h" #include "Game-Lib/Scripting/Handlers/AssetHandler.h" +#include "Game-Lib/Util/AutomationUtil.h" #include "Game-Lib/Util/AssetPath.h" #include "Game-Lib/Util/AssetWriter.h" #include "Game-Lib/Util/ClientDBUtil.h" @@ -119,7 +121,9 @@ namespace } } -Application::Application() : _messagesInbound(256), _messagesOutbound(256) +Application::Application() + : _messagesInbound(256) + , _messagesOutbound(256) { ServiceLocator::SetApplication(this); } @@ -136,7 +140,7 @@ Application::~Application() delete _assetWriter; } -void Application::Start(bool startInSeparateThread) +void Application::Start(bool startInSeparateThread, bool enableRenderDoc) { if (_isRunning) return; @@ -145,12 +149,13 @@ void Application::Start(bool startInSeparateThread) { _isRunning = true; - std::thread applicationThread = std::thread(&Application::Run, this); + std::thread applicationThread = + std::thread(&Application::Run, this, enableRenderDoc); applicationThread.detach(); } else { - _isRunning = Init(); + _isRunning = Init(enableRenderDoc); } } @@ -224,11 +229,11 @@ bool Application::TryGetMessageOutbound(MessageOutbound& message) return messageFound; } -void Application::Run() +void Application::Run(bool enableRenderDoc) { tracy::SetThreadName("Application Thread"); - if (Init()) + if (Init(enableRenderDoc)) { Timer timer; Timer updateTimer; @@ -325,7 +330,7 @@ void Application::Run() Stop(); } -bool Application::Init() +bool Application::Init(bool enableRenderDoc) { _registries.gameRegistry = new entt::registry(); _registries.uiRegistry = new entt::registry(); @@ -431,7 +436,7 @@ bool Application::Init() Util::Texture::DiscoverAll(); Util::ClientDB::DiscoverAll(); - _gameRenderer = new GameRenderer(); + _gameRenderer = new GameRenderer(enableRenderDoc); _imguiInputBridge = new ImGuiInputBridge(*_inputSystem); NC_LOG_INFO("EditorHandler : Initializing"); @@ -461,6 +466,7 @@ bool Application::Init() _luaManager->SetLuaHandler((Scripting::LuaHandlerID)MetaGen::Game::Lua::LuaHandlerTypeEnum::Game, new Scripting::Game::GameHandler()); _luaManager->SetLuaHandler((Scripting::LuaHandlerID)MetaGen::Game::Lua::LuaHandlerTypeEnum::Unit, new Scripting::Unit::UnitHandler()); _luaManager->SetLuaHandler((Scripting::LuaHandlerID)MetaGen::Game::Lua::LuaHandlerTypeEnum::Time, new Scripting::Time::TimeHandler()); + _luaManager->SetLuaHandler((Scripting::LuaHandlerID)MetaGen::Game::Lua::LuaHandlerTypeEnum::Scheduler, new Scripting::Scheduler::SchedulerHandler()); _luaManager->SetLuaHandler((Scripting::LuaHandlerID)MetaGen::Game::Lua::LuaHandlerTypeEnum::Camera, new Scripting::Camera::CameraHandler()); _luaManager->SetLuaHandler((Scripting::LuaHandlerID)MetaGen::Game::Lua::LuaHandlerTypeEnum::Map, new Scripting::Map::MapHandler()); _luaManager->SetLuaHandler((Scripting::LuaHandlerID)MetaGen::Game::Lua::LuaHandlerTypeEnum::Scene, new Scripting::Scene::SceneHandler()); @@ -555,6 +561,12 @@ bool Application::Tick(f32 deltaTime) break; } + case MessageInbound::Type::AutomationRun: + { + Util::Automation::ExecuteScript(*_luaManager, message.requestId, message.data); + break; + } + case MessageInbound::Type::ReloadScripts: { ServiceLocator::GetLuaManager()->SetDirty(); diff --git a/Source/Game-Lib/Game-Lib/Application/Application.h b/Source/Game-Lib/Game-Lib/Application/Application.h index c4a31af9..c2536793 100644 --- a/Source/Game-Lib/Game-Lib/Application/Application.h +++ b/Source/Game-Lib/Game-Lib/Application/Application.h @@ -43,7 +43,7 @@ class Application Application(); ~Application(); - void Start(bool startInSeparateThread); + void Start(bool startInSeparateThread, bool enableRenderDoc = false); void Stop(); void RequestExit(); @@ -54,9 +54,9 @@ class Application bool Tick(f32 deltaTime); private: - void Run(); + void Run(bool enableRenderDoc); - bool Init(); + bool Init(bool enableRenderDoc); bool Render(f32 deltaTime, f32& timeSpentWaiting); void DatabaseReload(); diff --git a/Source/Game-Lib/Game-Lib/Application/ConsoleCommandHandler.cpp b/Source/Game-Lib/Game-Lib/Application/ConsoleCommandHandler.cpp index b0c02407..9343413c 100644 --- a/Source/Game-Lib/Game-Lib/Application/ConsoleCommandHandler.cpp +++ b/Source/Game-Lib/Game-Lib/Application/ConsoleCommandHandler.cpp @@ -10,6 +10,7 @@ ConsoleCommandHandler::ConsoleCommandHandler() RegisterCommand("ping"_h, &ConsoleCommands::CommandPing); RegisterCommand("lua"_h, &ConsoleCommands::CommandDoString); RegisterCommand("eval"_h, &ConsoleCommands::CommandDoString); + RegisterCommand("automation_run"_h, &ConsoleCommands::CommandAutomationRun); RegisterCommand("r"_h, &ConsoleCommands::CommandReloadScripts); RegisterCommand("reload"_h, &ConsoleCommands::CommandReloadScripts); RegisterCommand("reloadscripts"_h, &ConsoleCommands::CommandReloadScripts); diff --git a/Source/Game-Lib/Game-Lib/Application/ConsoleCommands.cpp b/Source/Game-Lib/Game-Lib/Application/ConsoleCommands.cpp index d32ed747..df4ed71f 100644 --- a/Source/Game-Lib/Game-Lib/Application/ConsoleCommands.cpp +++ b/Source/Game-Lib/Game-Lib/Application/ConsoleCommands.cpp @@ -3,6 +3,8 @@ #include "Application.h" #include "Message.h" +#include + void ConsoleCommands::CommandPrint(Application& app, std::vector& subCommands) { if (subCommands.size() == 0) @@ -55,6 +57,18 @@ void ConsoleCommands::CommandDoString(Application& app, std::vector app.PassMessage(message); } +void ConsoleCommands::CommandAutomationRun(Application& app, std::vector& subCommands) +{ + if (subCommands.size() != 2) + { + NC_LOG_ERROR("Usage: automation_run Scripts/.luau"); + return; + } + + MessageInbound message(MessageInbound::Type::AutomationRun, subCommands[1], subCommands[0]); + app.PassMessage(message); +} + void ConsoleCommands::CommandReloadScripts(Application& app, std::vector& subCommands) { MessageInbound message(MessageInbound::Type::ReloadScripts); diff --git a/Source/Game-Lib/Game-Lib/Application/ConsoleCommands.h b/Source/Game-Lib/Game-Lib/Application/ConsoleCommands.h index 3e05c702..6e08cb0b 100644 --- a/Source/Game-Lib/Game-Lib/Application/ConsoleCommands.h +++ b/Source/Game-Lib/Game-Lib/Application/ConsoleCommands.h @@ -10,6 +10,7 @@ class ConsoleCommands static void CommandPing(Application& app, std::vector& subCommands); static void CommandExit(Application& app, std::vector& subCommands); static void CommandDoString(Application& app, std::vector& subCommands); + static void CommandAutomationRun(Application& app, std::vector& subCommands); static void CommandReloadScripts(Application& app, std::vector& subCommands); static void CommandRefreshDB(Application& app, std::vector& subCommands); -}; \ No newline at end of file +}; diff --git a/Source/Game-Lib/Game-Lib/Application/Message.h b/Source/Game-Lib/Game-Lib/Application/Message.h index 0828689a..c8d7895c 100644 --- a/Source/Game-Lib/Game-Lib/Application/Message.h +++ b/Source/Game-Lib/Game-Lib/Application/Message.h @@ -1,6 +1,9 @@ #pragma once #include +#include +#include + struct MessageInbound { public: @@ -10,6 +13,7 @@ struct MessageInbound Print, Ping, DoString, + AutomationRun, ReloadScripts, RefreshDB, Exit @@ -17,10 +21,12 @@ struct MessageInbound public: MessageInbound() { } - MessageInbound(Type inType, std::string inData = "") : type(inType), data(inData) { } + MessageInbound(Type inType, std::string inData = "", std::string inRequestId = "") + : type(inType), data(std::move(inData)), requestId(std::move(inRequestId)) { } Type type = Type::Invalid; std::string data = ""; + std::string requestId = ""; }; struct MessageOutbound @@ -40,4 +46,4 @@ struct MessageOutbound Type type = Type::Invalid; std::string data = ""; -}; \ No newline at end of file +}; diff --git a/Source/Game-Lib/Game-Lib/ECS/Components/Events.h b/Source/Game-Lib/Game-Lib/ECS/Components/Events.h index daba6037..8700bf42 100644 --- a/Source/Game-Lib/Game-Lib/ECS/Components/Events.h +++ b/Source/Game-Lib/Game-Lib/ECS/Components/Events.h @@ -12,6 +12,23 @@ namespace ECS::Components u32 mapId; }; + enum class MapLoadFailureReason : u8 + { + MissingDatabaseRecord, + MissingHeader, + InvalidHeader, + MissingBaseModel, + NoChunks, + NoAvailableChunks, + }; + + struct MapLoadFailedEvent + { + public: + u32 mapId; + MapLoadFailureReason reason; + }; + struct ModelLoadedEventFlags { public: @@ -24,4 +41,4 @@ namespace ECS::Components public: ModelLoadedEventFlags flags = { 0 }; }; -} \ No newline at end of file +} diff --git a/Source/Game-Lib/Game-Lib/Gameplay/MapLoader.cpp b/Source/Game-Lib/Game-Lib/Gameplay/MapLoader.cpp index 6e20c26b..74677e9a 100644 --- a/Source/Game-Lib/Game-Lib/Gameplay/MapLoader.cpp +++ b/Source/Game-Lib/Game-Lib/Gameplay/MapLoader.cpp @@ -64,11 +64,17 @@ void MapLoader::Update(f32 deltaTime) const robin_hood::unordered_map& internalNameHashToID = mapSingleton.mapInternalNameHashToID; if (!internalNameHashToID.contains(_loadRequest.internalMapNameHash)) + { + ReportLoadFailure(std::numeric_limits().max(), ECS::Components::MapLoadFailureReason::MissingDatabaseRecord); return; + } u32 mapID = internalNameHashToID.at(_loadRequest.internalMapNameHash); if (!mapStorage->Has(mapID)) + { + ReportLoadFailure(mapID, ECS::Components::MapLoadFailureReason::MissingDatabaseRecord); return; + } const auto& currentMap = mapStorage->Get(mapID); const std::string& mapInternalName = mapStorage->GetString(currentMap.nameInternal); @@ -78,19 +84,28 @@ void MapLoader::Update(f32 deltaTime) PACT::PactFileHandle fileHandle; if (pactStorage->ReadFile(mapHeaderPath, fileHandle) != PACT::PactReadResult::Success) + { + ReportLoadFailure(mapID, ECS::Components::MapLoadFailureReason::MissingHeader); return; + } Map::MapHeader mapHeader; std::shared_ptr buffer = std::make_shared(const_cast(fileHandle.GetData()), fileHandle.GetSize()); buffer->writtenData = fileHandle.GetSize(); if (!Map::MapHeader::Read(buffer, mapHeader)) + { + ReportLoadFailure(mapID, ECS::Components::MapLoadFailureReason::InvalidHeader); return; + } if (mapHeader.flags.UseMapObjectAsBase) { if (!_modelLoader->ContainsDiscoveredModel(mapHeader.placement.nameHash)) + { + ReportLoadFailure(mapID, ECS::Components::MapLoadFailureReason::MissingBaseModel); return; + } _currentMapID = mapID; @@ -136,6 +151,12 @@ void MapLoader::LoadMap(u32 mapHash) _loadRequest.internalMapNameHash = mapHash; } +void MapLoader::ReportLoadFailure(u32 mapID, ECS::Components::MapLoadFailureReason reason) +{ + NC_LOG_ERROR("MapLoader : Failed to load map ID {0} (reason {1})", mapID, static_cast(reason)); + ECS::Util::EventUtil::PushEvent(ECS::Components::MapLoadFailedEvent{ mapID, reason }); +} + void MapLoader::ClearRenderersForMap() { _terrainLoader->Clear(); diff --git a/Source/Game-Lib/Game-Lib/Gameplay/MapLoader.h b/Source/Game-Lib/Game-Lib/Gameplay/MapLoader.h index c34d689f..75f2b7cb 100644 --- a/Source/Game-Lib/Game-Lib/Gameplay/MapLoader.h +++ b/Source/Game-Lib/Game-Lib/Gameplay/MapLoader.h @@ -1,4 +1,5 @@ #pragma once +#include "Game-Lib/ECS/Components/Events.h" #include "Game-Lib/Rendering/Terrain/TerrainLoader.h" #include "Game-Lib/Rendering/Model/ModelLoader.h" @@ -27,6 +28,7 @@ class MapLoader void UnloadMap(); void UnloadMapImmediately(); void LoadMap(u32 mapHash); + void ReportLoadFailure(u32 mapID, ECS::Components::MapLoadFailureReason reason); const u32 GetCurrentMapID() { return _currentMapID; } @@ -40,4 +42,4 @@ class MapLoader u32 _currentMapID = std::numeric_limits().max(); LoadDesc _loadRequest; -}; \ No newline at end of file +}; diff --git a/Source/Game-Lib/Game-Lib/Input/InputActionSystem.cpp b/Source/Game-Lib/Game-Lib/Input/InputActionSystem.cpp index 0b0c9069..55d16451 100644 --- a/Source/Game-Lib/Game-Lib/Input/InputActionSystem.cpp +++ b/Source/Game-Lib/Game-Lib/Input/InputActionSystem.cpp @@ -217,13 +217,13 @@ InputActionContextHandle InputActionSystem::CreateContext(const InputActionConte auto existingContext = _contextHashToIndex.find(nameHash); if (existingContext != _contextHashToIndex.end()) { - NC_LOG_CRITICAL("InputActionSystem: Cannot create context '{}'; hash is already used by context '{}'", desc.name, _contexts[existingContext->second].info.name); + NC_LOG_WARNING("InputActionSystem: Cannot create context '{}'; hash is already used by context '{}'", desc.name, _contexts[existingContext->second].info.name); return {}; } if (_contexts.size() >= MAX_CONTEXTS) { - NC_LOG_CRITICAL("InputActionSystem: Cannot create context '{}'; the action context capacity has been reached", desc.name); + NC_LOG_WARNING("InputActionSystem: Cannot create context '{}'; the action context capacity has been reached", desc.name); return {}; } @@ -307,13 +307,13 @@ InputActionHandle InputActionSystem::RegisterAction(InputActionContextHandle con if (existingAction != _actionHashToIndex.end()) { const Action& action = _actions[existingAction->second]; - NC_LOG_CRITICAL("InputActionSystem: Cannot register action '{}'; hash is already used by action '{}'", desc.name, action.info.name); + NC_LOG_WARNING("InputActionSystem: Cannot register action '{}'; hash is already used by action '{}'", desc.name, action.info.name); return {}; } if (_actions.size() >= std::numeric_limits::max()) { - NC_LOG_CRITICAL("InputActionSystem: Cannot register action '{}'; the action capacity has been reached", desc.name); + NC_LOG_WARNING("InputActionSystem: Cannot register action '{}'; the action capacity has been reached", desc.name); return {}; } @@ -563,7 +563,8 @@ InputBindingChangeResult InputActionSystem::SetBinding(InputActionHandle actionH if (binding) result.conflicts = FindBindingConflicts(actionHandle, bindingSlot, *binding); - if (action.info.bindings[bindingSlot] == binding) + if (action.info.bindings[bindingSlot] == binding + && (result.conflicts.empty() || policy == InputBindingConflictPolicy::Allow)) { result.status = result.conflicts.empty() ? InputBindingChangeStatus::Applied : InputBindingChangeStatus::AppliedWithConflicts; return result; @@ -1074,7 +1075,8 @@ InputBindingChangeResult InputActionSystem::ApplyBindingChange(InputActionHandle if (binding) result.conflicts = FindBindingConflicts(actionHandle, bindingSlot, *binding); - if (action.info.bindings[bindingSlot] == binding) + if (action.info.bindings[bindingSlot] == binding + && (result.conflicts.empty() || policy == InputBindingConflictPolicy::Allow)) { result.status = result.conflicts.empty() ? InputBindingChangeStatus::Applied : InputBindingChangeStatus::AppliedWithConflicts; return result; diff --git a/Source/Game-Lib/Game-Lib/Rendering/GameRenderer.cpp b/Source/Game-Lib/Game-Lib/Rendering/GameRenderer.cpp index a6edb369..5efc420a 100644 --- a/Source/Game-Lib/Game-Lib/Rendering/GameRenderer.cpp +++ b/Source/Game-Lib/Game-Lib/Rendering/GameRenderer.cpp @@ -17,6 +17,8 @@ #include "Editor/EditorRenderer.h" #include "Effect/EffectRenderer.h" #include "Shadow/ShadowRenderer.h" +#include "RenderTargetCapture.h" +#include "RenderDocCapture.h" #include "PixelQuery.h" #include "CullUtils.h" @@ -164,7 +166,7 @@ void WindowIconifyCallback(GLFWwindow* window, int iconified) userWindow->SetIsMinimized(iconified == 1); } -GameRenderer::GameRenderer() +GameRenderer::GameRenderer(bool enableRenderDoc) { NC_LOG_INFO("GameRenderer : Initializing"); ServiceLocator::SetGameRenderer(this); @@ -198,7 +200,15 @@ GameRenderer::GameRenderer() ECS::Util::CameraUtil::InitializeCursorMode(); _renderer->InitDebug(); + if (enableRenderDoc) + { + // The Vulkan loader initializes RenderDoc's capture layer while the renderer + // is created. Bind the in-application API afterward so both use the same DLL. + _renderDocCapture = new RenderDocCapture(); + } + CreatePermanentResources(); + _renderTargetCapture = new RenderTargetCapture(_renderer); RenderUtils::Init(_renderer, this); DepthPyramidUtils::Init(_renderer, this); @@ -238,7 +248,9 @@ GameRenderer::GameRenderer() GameRenderer::~GameRenderer() { + delete _renderTargetCapture; delete _renderer; + delete _renderDocCapture; } bool GameRenderer::UpdateWindow(f32 deltaTime) @@ -289,6 +301,9 @@ f32 GameRenderer::Render() return 0.0f; } + if (_renderDocCapture) + _renderDocCapture->BeginFrame(); + Editor::EditorHandler* editorHandler = ServiceLocator::GetEditorHandler(); bool isEditorMode = editorHandler->GetViewport()->IsEditorMode(); @@ -516,8 +531,11 @@ f32 GameRenderer::Render() renderGraph.Setup(); renderGraph.Execute(); + _renderTargetCapture->ProcessPending(); _renderer->Present(_window, finalTarget, _resources.sceneRenderedSemaphore); + if (_renderDocCapture) + _renderDocCapture->EndFrame(); // Render is done; re-open staging uploads for the next frame's Update phase. Uploads are locked // from FlipFrame's ExecuteUploadTasks until here, so anything trying to upload during render-graph @@ -762,6 +780,8 @@ void GameRenderer::CreateRenderTargets() sceneColorDesc.clearColor = Color(0.52f, 0.80f, 0.92f, 1.0f); // Sky blue _resources.sceneColor = _renderer->CreateImage(sceneColorDesc); + + sceneColorDesc.debugName = "SkyboxColor"; _resources.skyboxColor = _renderer->CreateImage(sceneColorDesc); sceneColorDesc.debugName = "FinalColor"; @@ -832,7 +852,11 @@ void GameRenderer::CreateRenderTargets() mainDepthDesc.depthClearValue = 0.0f; _resources.depth = _renderer->CreateDepthImage(mainDepthDesc); + + mainDepthDesc.debugName = "SkyboxDepth"; _resources.skyboxDepth = _renderer->CreateDepthImage(mainDepthDesc); + + mainDepthDesc.debugName = "DebugRendererDepth"; _resources.debugRendererDepth = _renderer->CreateDepthImage(mainDepthDesc); // Copy of the depth, as a color rendertarget diff --git a/Source/Game-Lib/Game-Lib/Rendering/GameRenderer.h b/Source/Game-Lib/Game-Lib/Rendering/GameRenderer.h index 3bc4b12e..f9671bed 100644 --- a/Source/Game-Lib/Game-Lib/Rendering/GameRenderer.h +++ b/Source/Game-Lib/Game-Lib/Rendering/GameRenderer.h @@ -42,6 +42,8 @@ class UIRenderer; class PixelQuery; class EffectRenderer; class ShadowRenderer; +class RenderTargetCapture; +class RenderDocCapture; struct ImGuiTheme { @@ -53,7 +55,7 @@ struct ImGuiTheme class GameRenderer { public: - GameRenderer(); + explicit GameRenderer(bool enableRenderDoc = false); ~GameRenderer(); bool UpdateWindow(f32 deltaTime); @@ -92,6 +94,8 @@ class GameRenderer RenderResources& GetRenderResources() { return _resources; } PixelQuery* GetPixelQuery() { return _pixelQuery; } + RenderTargetCapture* GetRenderTargetCapture() { return _renderTargetCapture; } + RenderDocCapture* GetRenderDocCapture() { return _renderDocCapture; } const Renderer::ShaderEntry* GetShaderEntry(u32 shaderNameHash, const std::string& debugName); Renderer::GraphicsPipelineID GetBlitPipeline(u32 shaderNameHash); @@ -128,6 +132,8 @@ class GameRenderer Renderer::Renderer* _renderer = nullptr; Novus::Window* _window = nullptr; PixelQuery* _pixelQuery = nullptr; + RenderTargetCapture* _renderTargetCapture = nullptr; + RenderDocCapture* _renderDocCapture = nullptr; Memory::StackAllocator* _frameAllocator[2]; diff --git a/Source/Game-Lib/Game-Lib/Rendering/Model/ModelLoader.h b/Source/Game-Lib/Game-Lib/Rendering/Model/ModelLoader.h index 1ff01586..8a9c6571 100644 --- a/Source/Game-Lib/Game-Lib/Rendering/Model/ModelLoader.h +++ b/Source/Game-Lib/Game-Lib/Rendering/Model/ModelLoader.h @@ -126,6 +126,7 @@ class ModelLoader public: // Load Request Helpers void SetTerrainLoader(TerrainLoader* terrainLoader) { _terrainLoader = terrainLoader; } void SetTerrainLoading(bool loading) { _terrainLoading = loading; } + bool IsTerrainLoading() const { return _terrainLoading; } f32 GetLoadingProgress() const; diff --git a/Source/Game-Lib/Game-Lib/Rendering/Model/ModelRenderer.cpp b/Source/Game-Lib/Game-Lib/Rendering/Model/ModelRenderer.cpp index 68a0d152..22b2295a 100644 --- a/Source/Game-Lib/Game-Lib/Rendering/Model/ModelRenderer.cpp +++ b/Source/Game-Lib/Game-Lib/Rendering/Model/ModelRenderer.cpp @@ -292,9 +292,8 @@ void ModelRenderer::Update(f32 deltaTime) } } - // Oversized casters would exceed the dynamic marker's 1024-page cutoff in EVERY clipmap - // ring (span quarters per coarser ring) and must never rely on the dynamic pool: extent - // beyond 32 pages of the coarsest ring routes to the static path instead + // World-scale casters wider than half the coarsest clipmap remain on the static path + // instead of consuming the transient dynamic pool across the entire shadow window. const u32 numClipmaps = static_cast(glm::clamp(*cvarSystem->GetIntCVar(CVarCategory::Client | CVarCategory::Rendering, "svsmNumClipmaps"_h), 1, 8)); const f32 clipmap0Extent = static_cast(*cvarSystem->GetFloatCVar(CVarCategory::Client | CVarCategory::Rendering, "svsmClipmap0Extent"_h)); const f32 oversizeLimit = clipmap0Extent * static_cast(1u << (numClipmaps - 1)) * 0.5f; diff --git a/Source/Game-Lib/Game-Lib/Rendering/RenderDocCapture.cpp b/Source/Game-Lib/Game-Lib/Rendering/RenderDocCapture.cpp new file mode 100644 index 00000000..e6cb7eb3 --- /dev/null +++ b/Source/Game-Lib/Game-Lib/Rendering/RenderDocCapture.cpp @@ -0,0 +1,355 @@ +#include "RenderDocCapture.h" + +#include "Game-Lib/Util/AutomationUtil.h" + +#include + +#include +#include + +#if defined(_WIN32) +#include +#endif + +namespace fs = std::filesystem; + +namespace +{ +#if defined(_WIN32) + using RenderDocGetApi = i32(__cdecl*)(i32 version, void** api); + using GetApiVersion = void(__cdecl*)(i32* major, i32* minor, i32* patch); + using SetCaptureFilePathTemplate = void(__cdecl*)(const char* pathTemplate); + using GetNumCaptures = u32(__cdecl*)(); + using GetCapture = u32(__cdecl*)(u32 index, char* filename, u32* pathLength, u64* timestamp); + using StartFrameCapture = void(__cdecl*)(void* device, void* window); + using IsFrameCapturing = u32(__cdecl*)(); + using EndFrameCapture = u32(__cdecl*)(void* device, void* window); + + // RenderDoc 1.0's stable function-table prefix. Unused entries remain opaque + // pointers so this integration does not vendor RenderDoc's large public header. + struct RenderDocApiPrefix + { + GetApiVersion getApiVersion; + void* setCaptureOptionU32; + void* setCaptureOptionF32; + void* getCaptureOptionU32; + void* getCaptureOptionF32; + void* setFocusToggleKeys; + void* setCaptureKeys; + void* getOverlayBits; + void* maskOverlayBits; + void* removeHooks; + void* unloadCrashHandler; + SetCaptureFilePathTemplate setCaptureFilePathTemplate; + void* getCaptureFilePathTemplate; + GetNumCaptures getNumCaptures; + GetCapture getCapture; + void* triggerCapture; + void* isTargetControlConnected; + void* launchReplayUi; + void* setActiveWindow; + StartFrameCapture startFrameCapture; + IsFrameCapturing isFrameCapturing; + EndFrameCapture endFrameCapture; + }; + + constexpr i32 RenderDocApiVersion100 = 10000; +#endif + + std::string EscapeJson(const std::string& value) + { + std::string result; + result.reserve(value.size()); + constexpr char Hex[] = "0123456789abcdef"; + for (const unsigned char character : value) + { + switch (character) + { + case '"': result += "\\\""; break; + case '\\': result += "\\\\"; break; + case '\b': result += "\\b"; break; + case '\f': result += "\\f"; break; + case '\n': result += "\\n"; break; + case '\r': result += "\\r"; break; + case '\t': result += "\\t"; break; + default: + if (character < 0x20) + { + result += "\\u00"; + result.push_back(Hex[character >> 4]); + result.push_back(Hex[character & 0x0f]); + } + else + { + result.push_back(static_cast(character)); + } + break; + } + } + return result; + } + + std::string ToUtf8(const fs::path& path) + { + const std::u8string value = path.generic_u8string(); + return std::string( + reinterpret_cast(value.data()), + value.size()); + } + + fs::path FromUtf8(const char* value) + { + return fs::path(reinterpret_cast(value)); + } + + bool PublishCapture( + const fs::path& source, + const fs::path& destination, + std::string& error) + { + if (source == destination) + return true; + +#if defined(_WIN32) + if (MoveFileExW( + source.c_str(), + destination.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0) + { + return true; + } + error = "Failed to publish RenderDoc capture atomically (Windows error " + + std::to_string(GetLastError()) + ")"; + return false; +#else + std::error_code moveError; + fs::rename(source, destination, moveError); + if (!moveError) + return true; + error = "Failed to publish RenderDoc capture atomically: " + moveError.message(); + return false; +#endif + } +} + +struct RenderDocCapture::Api +{ +#if defined(_WIN32) + RenderDocApiPrefix* functions = nullptr; +#endif +}; + +RenderDocCapture::RenderDocCapture() +{ +#if defined(_WIN32) + HMODULE module = GetModuleHandleW(L"renderdoc.dll"); + if (!module) + { + _availabilityError = + "-renderdoc was specified, but the RenderDoc Vulkan layer did not load"; + return; + } + + const auto getApi = reinterpret_cast( + GetProcAddress(module, "RENDERDOC_GetAPI")); + if (!getApi) + { + _availabilityError = "renderdoc.dll does not export RENDERDOC_GetAPI"; + return; + } + + void* functions = nullptr; + if (getApi(RenderDocApiVersion100, &functions) != 1 || !functions) + { + _availabilityError = "RENDERDOC_GetAPI rejected API version 1.0.0"; + return; + } + + _api = new Api(); + _api->functions = static_cast(functions); + _api->functions->getApiVersion(&_versionMajor, &_versionMinor, &_versionPatch); + NC_LOG_INFO( + "RenderDoc in-application API {}.{}.{} is available", + _versionMajor, + _versionMinor, + _versionPatch); +#else + _availabilityError = "RenderDoc automation is currently implemented only on Windows"; +#endif +} + +RenderDocCapture::~RenderDocCapture() +{ + delete _api; +} + +bool RenderDocCapture::ResolveArtifactPath( + const fs::path& automationRoot, + const fs::path& requestedPath, + fs::path& resolvedPath, + std::string& error) +{ + return Util::Automation::ResolveArtifactPath( + automationRoot, + requestedPath, + ".rdc", + resolvedPath, + error); +} + +bool RenderDocCapture::QueueNextFrame( + const fs::path& artifactPath, + std::string& error) +{ + if (!_api) + { + error = _availabilityError; + return false; + } + if (_capturing || !_pendingPath.empty()) + { + error = "A RenderDoc capture is already queued or in progress"; + return false; + } + + const char* automationRoot = std::getenv("NOVUS_AUTOMATION_ROOT"); + if (!automationRoot || automationRoot[0] == '\0') + { + error = "NOVUS_AUTOMATION_ROOT is not configured"; + return false; + } + if (!ResolveArtifactPath(automationRoot, artifactPath, _pendingPath, error)) + return false; + + NC_LOG_INFO( + "RenderDoc capture queued for next frame: {}", + _pendingPath.generic_string()); + return true; +} + +void RenderDocCapture::BeginFrame() +{ +#if defined(_WIN32) + if (!_api || _pendingPath.empty() || _capturing) + return; + + std::error_code pathError; + fs::create_directories(_pendingPath.parent_path(), pathError); + if (pathError) + { + Fail("Failed to create artifact directory: " + pathError.message()); + return; + } + + fs::path captureTemplate = _pendingPath; + captureTemplate.replace_extension(); + const std::string captureTemplateUtf8 = ToUtf8(captureTemplate); + + NC_LOG_INFO("RenderDoc capture: querying existing capture count"); + _captureCountBeforeFrame = _api->functions->getNumCaptures(); + NC_LOG_INFO( + "RenderDoc capture: setting path template to {}", + captureTemplate.generic_string()); + _api->functions->setCaptureFilePathTemplate(captureTemplateUtf8.c_str()); + NC_LOG_INFO("RenderDoc capture: starting frame"); + _api->functions->startFrameCapture(nullptr, nullptr); + NC_LOG_INFO("RenderDoc capture: checking capture state"); + _capturing = _api->functions->isFrameCapturing() != 0; + if (!_capturing) + Fail("RenderDoc did not begin capturing the requested frame"); + else + NC_LOG_INFO("RenderDoc capture: frame capture started"); +#endif +} + +void RenderDocCapture::EndFrame() +{ +#if defined(_WIN32) + if (!_api || !_capturing) + return; + + _capturing = false; + NC_LOG_INFO("RenderDoc capture: ending frame"); + if (_api->functions->endFrameCapture(nullptr, nullptr) == 0) + { + Fail("RenderDoc failed to end the frame capture"); + return; + } + + const u32 captureCount = _api->functions->getNumCaptures(); + if (captureCount <= _captureCountBeforeFrame) + { + Fail("RenderDoc completed without registering a capture file"); + return; + } + + u32 pathLength = 0; + if (_api->functions->getCapture(captureCount - 1, nullptr, &pathLength, nullptr) == 0 || + pathLength == 0) + { + Fail("RenderDoc did not report the generated capture path"); + return; + } + + std::vector generatedPath(pathLength + 1, '\0'); + if (_api->functions->getCapture( + captureCount - 1, + generatedPath.data(), + &pathLength, + nullptr) == 0) + { + Fail("RenderDoc failed to return the generated capture path"); + return; + } + + const fs::path sourcePath = FromUtf8(generatedPath.data()); + std::error_code fileError; + if (!fs::is_regular_file(sourcePath, fileError) || fileError) + { + Fail("RenderDoc capture file is missing after capture completion"); + return; + } + + std::string publishError; + if (!PublishCapture(sourcePath, _pendingPath, publishError)) + { + Fail(publishError); + return; + } + + EmitMarker("artifact_ready"); + _pendingPath.clear(); +#endif +} + +void RenderDocCapture::Fail(const std::string& error) +{ + _capturing = false; + EmitMarker("artifact_failed", error); + _pendingPath.clear(); +} + +void RenderDocCapture::EmitMarker( + const char* event, + const std::string& error) const +{ + std::string marker = + "NOVUS_ARTIFACT {\"type\":\"renderdoc\",\"event\":\"" + std::string(event) + + "\",\"path\":\"" + EscapeJson(_pendingPath.generic_string()) + + "\",\"apiVersion\":\"" + + std::to_string(_versionMajor) + "." + + std::to_string(_versionMinor) + "." + + std::to_string(_versionPatch) + "\""; + if (!error.empty()) + marker += ",\"error\":\"" + EscapeJson(error) + "\""; + marker += "}"; + + if (error.empty()) + { + NC_LOG_INFO("{}", marker); + } + else + { + NC_LOG_ERROR("{}", marker); + } +} diff --git a/Source/Game-Lib/Game-Lib/Rendering/RenderDocCapture.h b/Source/Game-Lib/Game-Lib/Rendering/RenderDocCapture.h new file mode 100644 index 00000000..3a61b5e5 --- /dev/null +++ b/Source/Game-Lib/Game-Lib/Rendering/RenderDocCapture.h @@ -0,0 +1,43 @@ +#pragma once + +#include + +#include +#include + +class RenderDocCapture +{ +public: + RenderDocCapture(); + ~RenderDocCapture(); + + bool IsAvailable() const { return _api != nullptr; } + const std::string& GetAvailabilityError() const { return _availabilityError; } + + bool QueueNextFrame( + const std::filesystem::path& artifactPath, + std::string& error); + void BeginFrame(); + void EndFrame(); + + static bool ResolveArtifactPath( + const std::filesystem::path& automationRoot, + const std::filesystem::path& requestedPath, + std::filesystem::path& resolvedPath, + std::string& error); + +private: + struct Api; + + void Fail(const std::string& error); + void EmitMarker(const char* event, const std::string& error = {}) const; + + Api* _api = nullptr; + std::string _availabilityError; + std::filesystem::path _pendingPath; + u32 _captureCountBeforeFrame = 0; + i32 _versionMajor = 0; + i32 _versionMinor = 0; + i32 _versionPatch = 0; + bool _capturing = false; +}; diff --git a/Source/Game-Lib/Game-Lib/Rendering/RenderTargetCapture.cpp b/Source/Game-Lib/Game-Lib/Rendering/RenderTargetCapture.cpp new file mode 100644 index 00000000..ef79a768 --- /dev/null +++ b/Source/Game-Lib/Game-Lib/Rendering/RenderTargetCapture.cpp @@ -0,0 +1,841 @@ +#include "RenderTargetCapture.h" + +#include "Game-Lib/Util/AutomationUtil.h" + +#include + +#include + +#define STB_IMAGE_WRITE_IMPLEMENTATION +#include "../../../../Submodules/Engine/Dependencies/glfw/deps/stb_image_write.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#endif + +namespace fs = std::filesystem; + +namespace +{ + constexpr u64 MaxCaptureBytes = 512ull * 1024ull * 1024ull; + + struct DecodedImage + { + std::vector> pixels; + u8 components = 0; + bool adaptive = false; + bool preserveAlpha = false; + }; + + std::string EscapeJson(const std::string& value) + { + std::string result; + result.reserve(value.size()); + constexpr char Hex[] = "0123456789abcdef"; + for (const unsigned char character : value) + { + switch (character) + { + case '"': result += "\\\""; break; + case '\\': result += "\\\\"; break; + case '\b': result += "\\b"; break; + case '\f': result += "\\f"; break; + case '\n': result += "\\n"; break; + case '\r': result += "\\r"; break; + case '\t': result += "\\t"; break; + default: + if (character < 0x20) + { + result += "\\u00"; + result.push_back(Hex[character >> 4]); + result.push_back(Hex[character & 0x0f]); + } + else + { + result.push_back(static_cast(character)); + } + break; + } + } + return result; + } + + void EmitArtifactMarker( + const char* event, + const std::string& name, + const fs::path& path, + uvec2 dimensions = {}, + const std::string& format = {}, + const std::string& error = {}) + { + std::string marker = + "NOVUS_ARTIFACT {\"type\":\"render_target\",\"event\":\"" + std::string(event) + + "\",\"name\":\"" + EscapeJson(name) + + "\",\"path\":\"" + EscapeJson(path.generic_string()) + "\""; + if (dimensions.x && dimensions.y) + { + marker += + ",\"width\":" + std::to_string(dimensions.x) + + ",\"height\":" + std::to_string(dimensions.y); + } + if (!format.empty()) + marker += ",\"format\":\"" + EscapeJson(format) + "\""; + if (!error.empty()) + marker += ",\"error\":\"" + EscapeJson(error) + "\""; + marker += "}"; + + if (error.empty()) + { + NC_LOG_INFO("{}", marker); + } + else + { + NC_LOG_ERROR("{}", marker); + } + } + + bool ReplaceFile(const fs::path& temporaryPath, const fs::path& destinationPath) + { +#if defined(_WIN32) + return MoveFileExW( + temporaryPath.c_str(), + destinationPath.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0; +#else + std::error_code error; + fs::rename(temporaryPath, destinationPath, error); + return !error; +#endif + } + + size_t ColorPixelSize(Renderer::ImageFormat format) + { + using Renderer::ImageFormat; + switch (format) + { + case ImageFormat::R32G32B32A32_FLOAT: + case ImageFormat::R32G32B32A32_UINT: + case ImageFormat::R32G32B32A32_SINT: return 16; + case ImageFormat::R32G32B32_FLOAT: + case ImageFormat::R32G32B32_UINT: + case ImageFormat::R32G32B32_SINT: return 12; + case ImageFormat::R16G16B16A16_FLOAT: + case ImageFormat::R16G16B16A16_UNORM: + case ImageFormat::R16G16B16A16_UINT: + case ImageFormat::R16G16B16A16_SNORM: + case ImageFormat::R16G16B16A16_SINT: + case ImageFormat::R32G32_FLOAT: + case ImageFormat::R32G32_UINT: + case ImageFormat::R32G32_SINT: return 8; + case ImageFormat::R10G10B10A2_UNORM: + case ImageFormat::R10G10B10A2_UINT: + case ImageFormat::R11G11B10_UFLOAT: + case ImageFormat::R8G8B8A8_UNORM: + case ImageFormat::R8G8B8A8_UNORM_SRGB: + case ImageFormat::R8G8B8A8_UINT: + case ImageFormat::R8G8B8A8_SNORM: + case ImageFormat::R8G8B8A8_SINT: + case ImageFormat::B8G8R8A8_UNORM: + case ImageFormat::B8G8R8A8_UNORM_SRGB: + case ImageFormat::B8G8R8A8_SNORM: + case ImageFormat::B8G8R8A8_UINT: + case ImageFormat::B8G8R8A8_SINT: + case ImageFormat::R16G16_FLOAT: + case ImageFormat::R16G16_UNORM: + case ImageFormat::R16G16_UINT: + case ImageFormat::R16G16_SNORM: + case ImageFormat::R16G16_SINT: + case ImageFormat::R32_FLOAT: + case ImageFormat::R32_UINT: + case ImageFormat::R32_SINT: return 4; + case ImageFormat::R8G8_UNORM: + case ImageFormat::R8G8_UINT: + case ImageFormat::R8G8_SNORM: + case ImageFormat::R8G8_SINT: + case ImageFormat::R16_FLOAT: + case ImageFormat::D16_UNORM: + case ImageFormat::R16_UNORM: + case ImageFormat::R16_UINT: + case ImageFormat::R16_SNORM: + case ImageFormat::R16_SINT: return 2; + case ImageFormat::R8_UNORM: + case ImageFormat::R8_UINT: + case ImageFormat::R8_SNORM: + case ImageFormat::R8_SINT: return 1; + default: return 0; + } + } + + size_t DepthPixelSize(Renderer::DepthImageFormat format) + { + using Renderer::DepthImageFormat; + switch (format) + { + case DepthImageFormat::D32_FLOAT_S8X24_UINT: + case DepthImageFormat::D32_FLOAT: + case DepthImageFormat::R32_FLOAT: + case DepthImageFormat::D24_UNORM_S8_UINT: return 4; + case DepthImageFormat::D16_UNORM: + case DepthImageFormat::R16_UNORM: return 2; + default: return 0; + } + } + + const char* FormatName(Renderer::ImageFormat format) + { + using Renderer::ImageFormat; +#define FORMAT_NAME(value) case ImageFormat::value: return #value + switch (format) + { + FORMAT_NAME(R32G32B32A32_FLOAT); + FORMAT_NAME(R32G32B32A32_UINT); + FORMAT_NAME(R32G32B32A32_SINT); + FORMAT_NAME(R32G32B32_FLOAT); + FORMAT_NAME(R32G32B32_UINT); + FORMAT_NAME(R32G32B32_SINT); + FORMAT_NAME(R16G16B16A16_FLOAT); + FORMAT_NAME(R16G16B16A16_UNORM); + FORMAT_NAME(R16G16B16A16_UINT); + FORMAT_NAME(R16G16B16A16_SNORM); + FORMAT_NAME(R16G16B16A16_SINT); + FORMAT_NAME(R32G32_FLOAT); + FORMAT_NAME(R32G32_UINT); + FORMAT_NAME(R32G32_SINT); + FORMAT_NAME(R10G10B10A2_UNORM); + FORMAT_NAME(R10G10B10A2_UINT); + FORMAT_NAME(R11G11B10_UFLOAT); + FORMAT_NAME(R8G8B8A8_UNORM); + FORMAT_NAME(R8G8B8A8_UNORM_SRGB); + FORMAT_NAME(R8G8B8A8_UINT); + FORMAT_NAME(R8G8B8A8_SNORM); + FORMAT_NAME(R8G8B8A8_SINT); + FORMAT_NAME(B8G8R8A8_UNORM); + FORMAT_NAME(B8G8R8A8_UNORM_SRGB); + FORMAT_NAME(B8G8R8A8_SNORM); + FORMAT_NAME(B8G8R8A8_UINT); + FORMAT_NAME(B8G8R8A8_SINT); + FORMAT_NAME(R16G16_FLOAT); + FORMAT_NAME(R16G16_UNORM); + FORMAT_NAME(R16G16_UINT); + FORMAT_NAME(R16G16_SNORM); + FORMAT_NAME(R16G16_SINT); + FORMAT_NAME(R32_FLOAT); + FORMAT_NAME(R32_UINT); + FORMAT_NAME(R32_SINT); + FORMAT_NAME(R8G8_UNORM); + FORMAT_NAME(R8G8_UINT); + FORMAT_NAME(R8G8_SNORM); + FORMAT_NAME(R8G8_SINT); + FORMAT_NAME(R16_FLOAT); + FORMAT_NAME(D16_UNORM); + FORMAT_NAME(R16_UNORM); + FORMAT_NAME(R16_UINT); + FORMAT_NAME(R16_SNORM); + FORMAT_NAME(R16_SINT); + FORMAT_NAME(R8_UNORM); + FORMAT_NAME(R8_UINT); + FORMAT_NAME(R8_SNORM); + FORMAT_NAME(R8_SINT); + default: return "UNKNOWN"; + } +#undef FORMAT_NAME + } + + const char* FormatName(Renderer::DepthImageFormat format) + { + using Renderer::DepthImageFormat; + switch (format) + { + case DepthImageFormat::D32_FLOAT_S8X24_UINT: return "D32_FLOAT_S8X24_UINT"; + case DepthImageFormat::D32_FLOAT: return "D32_FLOAT"; + case DepthImageFormat::R32_FLOAT: return "R32_FLOAT"; + case DepthImageFormat::D24_UNORM_S8_UINT: return "D24_UNORM_S8_UINT"; + case DepthImageFormat::D16_UNORM: return "D16_UNORM"; + case DepthImageFormat::R16_UNORM: return "R16_UNORM"; + default: return "UNKNOWN"; + } + } + + template + T Read(const u8* source) + { + T value; + std::memcpy(&value, source, sizeof(T)); + return value; + } + + template + void DecodeComponents( + const u8* source, + std::array& destination, + Transform&& transform) + { + for (size_t component = 0; component < N; ++component) + destination[component] = transform(Read(source + component * sizeof(T))); + } + + double Snorm(i64 value, i64 maximum) + { + return std::clamp(static_cast(value) / static_cast(maximum), -1.0, 1.0) * 0.5 + 0.5; + } + + bool DecodeColor( + Renderer::ImageFormat format, + uvec2 dimensions, + const std::vector& source, + DecodedImage& decoded, + std::string& error) + { + using Renderer::ImageFormat; + const size_t pixelSize = ColorPixelSize(format); + const u64 pixelCount = static_cast(dimensions.x) * dimensions.y; + if (!pixelSize || source.size() != pixelCount * pixelSize) + { + error = pixelSize ? "Raw render-target size does not match its dimensions" : "Unsupported render-target format"; + return false; + } + + decoded.pixels.resize(static_cast(pixelCount), { 0.0, 0.0, 0.0, 1.0 }); + switch (format) + { + case ImageFormat::R32G32B32A32_FLOAT: + case ImageFormat::R32G32B32A32_UINT: + case ImageFormat::R32G32B32A32_SINT: + case ImageFormat::R16G16B16A16_FLOAT: + case ImageFormat::R16G16B16A16_UNORM: + case ImageFormat::R16G16B16A16_UINT: + case ImageFormat::R16G16B16A16_SNORM: + case ImageFormat::R16G16B16A16_SINT: + case ImageFormat::R10G10B10A2_UNORM: + case ImageFormat::R10G10B10A2_UINT: + case ImageFormat::R8G8B8A8_UNORM: + case ImageFormat::R8G8B8A8_UNORM_SRGB: + case ImageFormat::R8G8B8A8_UINT: + case ImageFormat::R8G8B8A8_SNORM: + case ImageFormat::R8G8B8A8_SINT: + case ImageFormat::B8G8R8A8_UNORM: + case ImageFormat::B8G8R8A8_UNORM_SRGB: + case ImageFormat::B8G8R8A8_SNORM: + case ImageFormat::B8G8R8A8_UINT: + case ImageFormat::B8G8R8A8_SINT: + decoded.components = 4; + break; + case ImageFormat::R32G32B32_FLOAT: + case ImageFormat::R32G32B32_UINT: + case ImageFormat::R32G32B32_SINT: + case ImageFormat::R11G11B10_UFLOAT: + decoded.components = 3; + break; + case ImageFormat::R32G32_FLOAT: + case ImageFormat::R32G32_UINT: + case ImageFormat::R32G32_SINT: + case ImageFormat::R16G16_FLOAT: + case ImageFormat::R16G16_UNORM: + case ImageFormat::R16G16_UINT: + case ImageFormat::R16G16_SNORM: + case ImageFormat::R16G16_SINT: + case ImageFormat::R8G8_UNORM: + case ImageFormat::R8G8_UINT: + case ImageFormat::R8G8_SNORM: + case ImageFormat::R8G8_SINT: + decoded.components = 2; + break; + default: + decoded.components = 1; + break; + } + + const auto isInteger = [format]() + { + return Renderer::ToImageComponentType(format) == Renderer::ImageComponentType::UINT || + Renderer::ToImageComponentType(format) == Renderer::ImageComponentType::SINT; + }; + decoded.adaptive = + isInteger() || + (decoded.components == 1 && + Renderer::ToImageComponentType(format) == Renderer::ImageComponentType::FLOAT); + decoded.preserveAlpha = decoded.components == 4 && !decoded.adaptive; + + for (size_t pixelIndex = 0; pixelIndex < decoded.pixels.size(); ++pixelIndex) + { + const u8* pixel = source.data() + pixelIndex * pixelSize; + auto& output = decoded.pixels[pixelIndex]; + + switch (format) + { + case ImageFormat::R32G32B32A32_FLOAT: DecodeComponents(pixel, output, [](f32 v) { return v; }); break; + case ImageFormat::R32G32B32_FLOAT: DecodeComponents(pixel, output, [](f32 v) { return v; }); break; + case ImageFormat::R32G32_FLOAT: DecodeComponents(pixel, output, [](f32 v) { return v; }); break; + case ImageFormat::R32_FLOAT: DecodeComponents(pixel, output, [](f32 v) { return v; }); break; + + case ImageFormat::R32G32B32A32_UINT: DecodeComponents(pixel, output, [](u32 v) { return v; }); break; + case ImageFormat::R32G32B32_UINT: DecodeComponents(pixel, output, [](u32 v) { return v; }); break; + case ImageFormat::R32G32_UINT: DecodeComponents(pixel, output, [](u32 v) { return v; }); break; + case ImageFormat::R32_UINT: DecodeComponents(pixel, output, [](u32 v) { return v; }); break; + + case ImageFormat::R32G32B32A32_SINT: DecodeComponents(pixel, output, [](i32 v) { return v; }); break; + case ImageFormat::R32G32B32_SINT: DecodeComponents(pixel, output, [](i32 v) { return v; }); break; + case ImageFormat::R32G32_SINT: DecodeComponents(pixel, output, [](i32 v) { return v; }); break; + case ImageFormat::R32_SINT: DecodeComponents(pixel, output, [](i32 v) { return v; }); break; + + case ImageFormat::R16G16B16A16_FLOAT: DecodeComponents(pixel, output, [](u16 v) { return glm::unpackHalf1x16(v); }); break; + case ImageFormat::R16G16_FLOAT: DecodeComponents(pixel, output, [](u16 v) { return glm::unpackHalf1x16(v); }); break; + case ImageFormat::R16_FLOAT: DecodeComponents(pixel, output, [](u16 v) { return glm::unpackHalf1x16(v); }); break; + + case ImageFormat::R16G16B16A16_UNORM: DecodeComponents(pixel, output, [](u16 v) { return v / 65535.0; }); break; + case ImageFormat::R16G16_UNORM: DecodeComponents(pixel, output, [](u16 v) { return v / 65535.0; }); break; + case ImageFormat::D16_UNORM: + case ImageFormat::R16_UNORM: DecodeComponents(pixel, output, [](u16 v) { return v / 65535.0; }); break; + + case ImageFormat::R16G16B16A16_UINT: DecodeComponents(pixel, output, [](u16 v) { return v; }); break; + case ImageFormat::R16G16_UINT: DecodeComponents(pixel, output, [](u16 v) { return v; }); break; + case ImageFormat::R16_UINT: DecodeComponents(pixel, output, [](u16 v) { return v; }); break; + + case ImageFormat::R16G16B16A16_SNORM: DecodeComponents(pixel, output, [](i16 v) { return Snorm(v, 32767); }); break; + case ImageFormat::R16G16_SNORM: DecodeComponents(pixel, output, [](i16 v) { return Snorm(v, 32767); }); break; + case ImageFormat::R16_SNORM: DecodeComponents(pixel, output, [](i16 v) { return Snorm(v, 32767); }); break; + + case ImageFormat::R16G16B16A16_SINT: DecodeComponents(pixel, output, [](i16 v) { return v; }); break; + case ImageFormat::R16G16_SINT: DecodeComponents(pixel, output, [](i16 v) { return v; }); break; + case ImageFormat::R16_SINT: DecodeComponents(pixel, output, [](i16 v) { return v; }); break; + + case ImageFormat::R8G8B8A8_UNORM: + case ImageFormat::R8G8B8A8_UNORM_SRGB: DecodeComponents(pixel, output, [](u8 v) { return v / 255.0; }); break; + case ImageFormat::R8G8_UNORM: DecodeComponents(pixel, output, [](u8 v) { return v / 255.0; }); break; + case ImageFormat::R8_UNORM: DecodeComponents(pixel, output, [](u8 v) { return v / 255.0; }); break; + + case ImageFormat::B8G8R8A8_UNORM: + case ImageFormat::B8G8R8A8_UNORM_SRGB: + output = { pixel[2] / 255.0, pixel[1] / 255.0, pixel[0] / 255.0, pixel[3] / 255.0 }; + break; + + case ImageFormat::R8G8B8A8_UINT: DecodeComponents(pixel, output, [](u8 v) { return v; }); break; + case ImageFormat::B8G8R8A8_UINT: + output = { static_cast(pixel[2]), static_cast(pixel[1]), static_cast(pixel[0]), static_cast(pixel[3]) }; + break; + case ImageFormat::R8G8_UINT: DecodeComponents(pixel, output, [](u8 v) { return v; }); break; + case ImageFormat::R8_UINT: DecodeComponents(pixel, output, [](u8 v) { return v; }); break; + + case ImageFormat::R8G8B8A8_SNORM: DecodeComponents(pixel, output, [](i8 v) { return Snorm(v, 127); }); break; + case ImageFormat::B8G8R8A8_SNORM: + output = { Snorm(static_cast(pixel[2]), 127), Snorm(static_cast(pixel[1]), 127), Snorm(static_cast(pixel[0]), 127), Snorm(static_cast(pixel[3]), 127) }; + break; + case ImageFormat::R8G8_SNORM: DecodeComponents(pixel, output, [](i8 v) { return Snorm(v, 127); }); break; + case ImageFormat::R8_SNORM: DecodeComponents(pixel, output, [](i8 v) { return Snorm(v, 127); }); break; + + case ImageFormat::R8G8B8A8_SINT: DecodeComponents(pixel, output, [](i8 v) { return v; }); break; + case ImageFormat::B8G8R8A8_SINT: + output = { static_cast(static_cast(pixel[2])), static_cast(static_cast(pixel[1])), static_cast(static_cast(pixel[0])), static_cast(static_cast(pixel[3])) }; + break; + case ImageFormat::R8G8_SINT: DecodeComponents(pixel, output, [](i8 v) { return v; }); break; + case ImageFormat::R8_SINT: DecodeComponents(pixel, output, [](i8 v) { return v; }); break; + + case ImageFormat::R10G10B10A2_UNORM: + case ImageFormat::R10G10B10A2_UINT: + { + const u32 packed = Read(pixel); + output = { + static_cast((packed >> 20) & 0x3ff), + static_cast((packed >> 10) & 0x3ff), + static_cast(packed & 0x3ff), + static_cast((packed >> 30) & 0x3) + }; + if (format == ImageFormat::R10G10B10A2_UNORM) + { + output[0] /= 1023.0; + output[1] /= 1023.0; + output[2] /= 1023.0; + output[3] /= 3.0; + } + break; + } + case ImageFormat::R11G11B10_UFLOAT: + { + const vec3 unpacked = glm::unpackF2x11_1x10(Read(pixel)); + output = { unpacked.x, unpacked.y, unpacked.z, 1.0 }; + break; + } + default: + error = "Unsupported render-target format"; + return false; + } + } + + return true; + } + + u8 ToByte(double value) + { + if (!std::isfinite(value)) + return 0; + return static_cast(std::lround(std::clamp(value, 0.0, 1.0) * 255.0)); + } + + void AppendPngBytes(void* context, void* data, int size) + { + auto& output = *static_cast*>(context); + const auto* bytes = static_cast(data); + output.insert(output.end(), bytes, bytes + size); + } + + bool EncodeAndPublish( + const fs::path& path, + uvec2 dimensions, + const std::vector& rgba, + std::string& error) + { + std::vector png; + if (!stbi_write_png_to_func( + AppendPngBytes, + &png, + static_cast(dimensions.x), + static_cast(dimensions.y), + 4, + rgba.data(), + static_cast(dimensions.x * 4))) + { + error = "PNG encoding failed"; + return false; + } + + std::error_code pathError; + fs::create_directories(path.parent_path(), pathError); + if (pathError) + { + error = "Failed to create artifact directory: " + pathError.message(); + return false; + } + + fs::path temporaryPath = path; + temporaryPath += ".tmp"; + { + std::ofstream stream(temporaryPath, std::ios::binary | std::ios::trunc); + if (!stream) + { + error = "Failed to open temporary artifact"; + return false; + } + stream.write(reinterpret_cast(png.data()), static_cast(png.size())); + stream.flush(); + if (!stream) + { + error = "Failed to write temporary artifact"; + stream.close(); + fs::remove(temporaryPath, pathError); + return false; + } + } + + if (!ReplaceFile(temporaryPath, path)) + { + error = "Failed to publish artifact atomically"; + fs::remove(temporaryPath, pathError); + return false; + } + return true; + } +} + +RenderTargetCapture::RenderTargetCapture(Renderer::Renderer* renderer) + : _renderer(renderer) +{ +} + +bool RenderTargetCapture::ResolveArtifactPath( + const fs::path& automationRoot, + const fs::path& requestedPath, + fs::path& resolvedPath, + std::string& error) +{ + return Util::Automation::ResolveArtifactPath( + automationRoot, + requestedPath, + ".png", + resolvedPath, + error); +} + +bool RenderTargetCapture::FindTarget( + const std::string& debugName, + Request& request, + std::string& error) const +{ + u32 matches = 0; + for (u32 index = 0; index < _renderer->GetNumImages(); ++index) + { + const Renderer::ImageID image(static_cast(index)); + if (_renderer->GetDesc(image).debugName == debugName) + { + request.kind = TargetKind::Color; + request.image = image; + ++matches; + } + } + for (u32 index = 0; index < _renderer->GetNumDepthImages(); ++index) + { + const Renderer::DepthImageID image(static_cast(index)); + if (_renderer->GetDesc(image).debugName == debugName) + { + request.kind = TargetKind::Depth; + request.depthImage = image; + ++matches; + } + } + + if (matches == 0) + { + error = "Render target not found: " + debugName; + return false; + } + if (matches > 1) + { + error = "Render target name is ambiguous: " + debugName; + return false; + } + return true; +} + +bool RenderTargetCapture::Queue( + const std::string& debugName, + const fs::path& artifactPath, + std::string& error) +{ + if (debugName.empty()) + { + error = "Render-target debug name must not be empty"; + return false; + } + + const char* automationRoot = std::getenv("NOVUS_AUTOMATION_ROOT"); + if (!automationRoot || automationRoot[0] == '\0') + { + error = "NOVUS_AUTOMATION_ROOT is not configured"; + return false; + } + + Request request; + request.debugName = debugName; + if (!ResolveArtifactPath(automationRoot, artifactPath, request.path, error)) + return false; + if (!FindTarget(debugName, request, error)) + return false; + + _pending.push_back(std::move(request)); + return true; +} + +bool RenderTargetCapture::ConvertColorToRGBA8( + Renderer::ImageFormat format, + uvec2 dimensions, + const std::vector& source, + std::vector& destination, + std::string& error) +{ + DecodedImage decoded; + if (!DecodeColor(format, dimensions, source, decoded, error)) + return false; + + std::array minima = { + std::numeric_limits::infinity(), + std::numeric_limits::infinity(), + std::numeric_limits::infinity(), + std::numeric_limits::infinity() + }; + std::array maxima = { + -std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + -std::numeric_limits::infinity() + }; + if (decoded.adaptive) + { + for (const auto& pixel : decoded.pixels) + { + for (u8 component = 0; component < decoded.components; ++component) + { + if (!std::isfinite(pixel[component])) + continue; + minima[component] = std::min(minima[component], pixel[component]); + maxima[component] = std::max(maxima[component], pixel[component]); + } + } + } + + destination.resize(decoded.pixels.size() * 4); + for (size_t index = 0; index < decoded.pixels.size(); ++index) + { + std::array color = decoded.pixels[index]; + if (decoded.adaptive) + { + for (u8 component = 0; component < decoded.components; ++component) + { + const double range = maxima[component] - minima[component]; + color[component] = + std::isfinite(range) && range > std::numeric_limits::epsilon() + ? (color[component] - minima[component]) / range + : (color[component] == 0.0 ? 0.0 : 1.0); + } + } + + if (decoded.components == 1) + color[1] = color[2] = color[0]; + else if (decoded.components == 2) + color[2] = 0.0; + + destination[index * 4 + 0] = ToByte(color[0]); + destination[index * 4 + 1] = ToByte(color[1]); + destination[index * 4 + 2] = ToByte(color[2]); + destination[index * 4 + 3] = decoded.preserveAlpha ? ToByte(color[3]) : 255; + } + return true; +} + +bool RenderTargetCapture::ConvertDepthToRGBA8( + Renderer::DepthImageFormat format, + uvec2 dimensions, + const std::vector& source, + std::vector& destination, + std::string& error) +{ + const size_t pixelSize = DepthPixelSize(format); + const u64 pixelCount = static_cast(dimensions.x) * dimensions.y; + if (!pixelSize || source.size() != pixelCount * pixelSize) + { + error = pixelSize ? "Raw depth-target size does not match its dimensions" : "Unsupported depth-target format"; + return false; + } + + std::vector values(static_cast(pixelCount)); + for (size_t index = 0; index < values.size(); ++index) + { + const u8* pixel = source.data() + index * pixelSize; + switch (format) + { + case Renderer::DepthImageFormat::D32_FLOAT_S8X24_UINT: + case Renderer::DepthImageFormat::D32_FLOAT: + case Renderer::DepthImageFormat::R32_FLOAT: + values[index] = Read(pixel); + break; + case Renderer::DepthImageFormat::D24_UNORM_S8_UINT: + values[index] = (Read(pixel) & 0x00ffffffu) / static_cast(0x00ffffffu); + break; + case Renderer::DepthImageFormat::D16_UNORM: + case Renderer::DepthImageFormat::R16_UNORM: + values[index] = Read(pixel) / 65535.0; + break; + default: + error = "Unsupported depth-target format"; + return false; + } + } + + double minimum = std::numeric_limits::infinity(); + double maximum = -std::numeric_limits::infinity(); + for (const double value : values) + { + if (std::isfinite(value)) + { + minimum = std::min(minimum, value); + maximum = std::max(maximum, value); + } + } + + destination.resize(values.size() * 4); + const double range = maximum - minimum; + for (size_t index = 0; index < values.size(); ++index) + { + const double normalized = + std::isfinite(range) && range > std::numeric_limits::epsilon() + ? (maximum - values[index]) / range + : 1.0 - std::clamp(values[index], 0.0, 1.0); + const u8 grayscale = ToByte(normalized); + destination[index * 4 + 0] = grayscale; + destination[index * 4 + 1] = grayscale; + destination[index * 4 + 2] = grayscale; + destination[index * 4 + 3] = 255; + } + return true; +} + +void RenderTargetCapture::ProcessPending() +{ + if (_pending.empty()) + return; + + Request request = std::move(_pending.front()); + _pending.pop_front(); + Process(std::move(request)); +} + +void RenderTargetCapture::Process(Request request) +{ + uvec2 dimensions; + size_t pixelSize = 0; + std::string format; + if (request.kind == TargetKind::Color) + { + const Renderer::ImageDesc& desc = _renderer->GetDesc(request.image); + dimensions = _renderer->GetImageDimensions(request.image); + pixelSize = ColorPixelSize(desc.format); + format = FormatName(desc.format); + if (desc.sampleCount != Renderer::SampleCount::SAMPLE_COUNT_1 || desc.depth != 1) + { + EmitArtifactMarker("artifact_failed", request.debugName, request.path, dimensions, format, "Multisampled and array render targets are not supported"); + return; + } + } + else + { + const Renderer::DepthImageDesc& desc = _renderer->GetDesc(request.depthImage); + dimensions = _renderer->GetImageDimensions(request.depthImage); + pixelSize = DepthPixelSize(desc.format); + format = FormatName(desc.format); + if (desc.sampleCount != Renderer::SampleCount::SAMPLE_COUNT_1) + { + EmitArtifactMarker("artifact_failed", request.debugName, request.path, dimensions, format, "Multisampled depth targets are not supported"); + return; + } + } + + const u64 byteCount = static_cast(dimensions.x) * dimensions.y * pixelSize; + if (!pixelSize || byteCount == 0 || byteCount > MaxCaptureBytes) + { + EmitArtifactMarker("artifact_failed", request.debugName, request.path, dimensions, format, + pixelSize ? "Render target exceeds the 512 MiB capture limit" : "Unsupported render-target format"); + return; + } + + std::vector source(static_cast(byteCount)); + const bool read = request.kind == TargetKind::Color + ? _renderer->ReadImageImmediate(request.image, source.data(), source.size()) + : _renderer->ReadImageImmediate(request.depthImage, source.data(), source.size()); + if (!read) + { + EmitArtifactMarker("artifact_failed", request.debugName, request.path, dimensions, format, "GPU readback failed"); + return; + } + + std::vector rgba; + std::string error; + const bool converted = request.kind == TargetKind::Color + ? ConvertColorToRGBA8(_renderer->GetDesc(request.image).format, dimensions, source, rgba, error) + : ConvertDepthToRGBA8(_renderer->GetDesc(request.depthImage).format, dimensions, source, rgba, error); + if (!converted || !EncodeAndPublish(request.path, dimensions, rgba, error)) + { + EmitArtifactMarker("artifact_failed", request.debugName, request.path, dimensions, format, error); + return; + } + + EmitArtifactMarker("artifact_ready", request.debugName, request.path, dimensions, format); +} diff --git a/Source/Game-Lib/Game-Lib/Rendering/RenderTargetCapture.h b/Source/Game-Lib/Game-Lib/Rendering/RenderTargetCapture.h new file mode 100644 index 00000000..86cd8b27 --- /dev/null +++ b/Source/Game-Lib/Game-Lib/Rendering/RenderTargetCapture.h @@ -0,0 +1,70 @@ +#pragma once + +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace Renderer +{ + class Renderer; +} + +class RenderTargetCapture +{ +public: + explicit RenderTargetCapture(Renderer::Renderer* renderer); + + bool Queue( + const std::string& debugName, + const std::filesystem::path& artifactPath, + std::string& error); + void ProcessPending(); + + static bool ResolveArtifactPath( + const std::filesystem::path& automationRoot, + const std::filesystem::path& requestedPath, + std::filesystem::path& resolvedPath, + std::string& error); + + static bool ConvertColorToRGBA8( + Renderer::ImageFormat format, + uvec2 dimensions, + const std::vector& source, + std::vector& destination, + std::string& error); + static bool ConvertDepthToRGBA8( + Renderer::DepthImageFormat format, + uvec2 dimensions, + const std::vector& source, + std::vector& destination, + std::string& error); + +private: + enum class TargetKind + { + Color, + Depth + }; + + struct Request + { + std::string debugName; + std::filesystem::path path; + TargetKind kind = TargetKind::Color; + Renderer::ImageID image = Renderer::ImageID::Invalid(); + Renderer::DepthImageID depthImage = Renderer::DepthImageID::Invalid(); + }; + + bool FindTarget(const std::string& debugName, Request& request, std::string& error) const; + void Process(Request request); + + Renderer::Renderer* _renderer = nullptr; + std::deque _pending; +}; diff --git a/Source/Game-Lib/Game-Lib/Rendering/Shadow/ShadowRenderer.cpp b/Source/Game-Lib/Game-Lib/Rendering/Shadow/ShadowRenderer.cpp index 9fa35bb8..d83c49bf 100644 --- a/Source/Game-Lib/Game-Lib/Rendering/Shadow/ShadowRenderer.cpp +++ b/Source/Game-Lib/Game-Lib/Rendering/Shadow/ShadowRenderer.cpp @@ -39,7 +39,7 @@ AutoCVar_Int CVAR_SVSMPageEvictAge(CVarCategory::Client | CVarCategory::Renderin AutoCVar_Float CVAR_SVSMMarkBorderTexels(CVarCategory::Client | CVarCategory::Rendering, "svsmMarkBorderTexels", "filter footprint margin in texels, samples near a page border also mark the neighbor page", 4.0f); AutoCVar_Float CVAR_SVSMResolutionScale(CVarCategory::Client | CVarCategory::Rendering, "svsmResolutionScale", "eye-distance clipmap floor: skip rings finer than the sample's screen footprint times this, 0 disables, lower = sharper distant shadows for more pages. Below ~0.25 the pool pressure-evicts and churns", 1.0f); AutoCVar_Int CVAR_SVSMDynamicSplit(CVarCategory::Client | CVarCategory::Rendering, "svsmDynamicSplit", "static/dynamic caster split: animated and moving casters render into a transient dynamic pool instead of churning the static cache, 0 reverts to v1 behavior", 1, CVarFlags::EditCheckbox); -AutoCVar_Int CVAR_SVSMDynamicPoolSize(CVarCategory::Client | CVarCategory::Rendering, "svsmDynamicPoolSize", "dynamic page pool texture resolution, restart-only once shadows have been enabled", 2048); +AutoCVar_Int CVAR_SVSMDynamicPoolSize(CVarCategory::Client | CVarCategory::Rendering, "svsmDynamicPoolSize", "dynamic page pool texture resolution, restart-only once shadows have been enabled", 3072); AutoCVar_Int CVAR_SVSMRenderBudget(CVarCategory::Client | CVarCategory::Rendering, "svsmRenderBudget", "static pages rendered per frame, 0 = unlimited; overflow refines over following frames coarse-to-fine, the coarsest two rings are exempt", 0); AutoCVar_Float CVAR_SVSMAnimatedCasterRange(CVarCategory::Client | CVarCategory::Rendering, "svsmAnimatedCasterRange", "camera range in meters within which animated doodads (windmills, flags) cast dynamic shadows, beyond it their pose bakes static, 0 disables", 128.0f); AutoCVar_Int CVAR_SVSMFreeze(CVarCategory::Client | CVarCategory::Rendering, "svsmFreeze", "freeze SVSM page marking and lifecycle to inspect the cached state. Stale dirty state stays live and pages never clear, so dynamic content ghost-accumulates while frozen; unfreezing re-bakes the cache if anything spawned/despawned meanwhile", 0, CVarFlags::EditCheckbox); @@ -197,16 +197,21 @@ struct SVSMDerivedConfig u32 dynamicPoolPagesPerRow = 0; }; -static SVSMDerivedConfig DeriveSVSMConfig(u32 maxPageTableSize) +static SVSMDerivedConfig DeriveSVSMConfig(u32 maxPageTableSize, u32 poolSize, u32 dynamicPoolSize) { SVSMDerivedConfig config; config.pageSize = static_cast(glm::max(CVAR_SVSMPageSize.Get(), 16)); config.pageTableSize = glm::clamp(static_cast(CVAR_SVSMVirtualSize.Get()) / config.pageSize, 16u, maxPageTableSize); - config.poolPagesPerRow = glm::min(static_cast(CVAR_SVSMPoolSize.Get()) / config.pageSize, maxPageTableSize); - config.dynamicPoolPagesPerRow = glm::min(static_cast(CVAR_SVSMDynamicPoolSize.Get()) / config.pageSize, maxPageTableSize); + config.poolPagesPerRow = glm::min(poolSize / config.pageSize, maxPageTableSize); + config.dynamicPoolPagesPerRow = glm::min(dynamicPoolSize / config.pageSize, maxPageTableSize); return config; } +static SVSMDerivedConfig DeriveSVSMConfig(u32 maxPageTableSize) +{ + return DeriveSVSMConfig(maxPageTableSize, static_cast(CVAR_SVSMPoolSize.Get()), static_cast(CVAR_SVSMDynamicPoolSize.Get())); +} + ShadowRenderer::ShadowRenderer(Renderer::Renderer* renderer, GameRenderer* gameRenderer, TerrainRenderer* terrainRenderer, ModelRenderer* modelRenderer, RenderResources& resources) : _renderer(renderer) , _gameRenderer(gameRenderer) @@ -243,21 +248,31 @@ void ShadowRenderer::Update(f32 deltaTime, RenderResources& resources) } // Live config edits: a pageSize change reshapes the pool page counts and free lists in place - // (the pool textures keep their dimensions, only the page grid over them changes). The pool - // size cvars are restart-only once the pool images exist — the Engine cannot destroy images, - // so recreating them at new dimensions would leak the old texture — and revert with a warning + // (the pool textures keep their dimensions, only the page grid over them changes). Pool-size + // edits remain pending in the cvars so they persist and apply next restart; live consumers + // continue using the dimensions of the images that were actually allocated { - if (_svsmPagePool != Renderer::ImageID::Invalid() && - (static_cast(CVAR_SVSMPoolSize.Get()) != _svsmAppliedPoolSize || static_cast(CVAR_SVSMDynamicPoolSize.Get()) != _svsmAppliedDynamicPoolSize)) + const bool poolsExist = _svsmPagePool != Renderer::ImageID::Invalid(); + const u32 requestedPoolSize = static_cast(CVAR_SVSMPoolSize.Get()); + const u32 requestedDynamicPoolSize = static_cast(CVAR_SVSMDynamicPoolSize.Get()); + const bool poolSizeChanged = requestedPoolSize != _svsmAppliedPoolSize || requestedDynamicPoolSize != _svsmAppliedDynamicPoolSize; + + if (poolsExist && poolSizeChanged && + (requestedPoolSize != _svsmPendingPoolSize || requestedDynamicPoolSize != _svsmPendingDynamicPoolSize)) + { + NC_LOG_WARNING("SVSM: svsmPoolSize/svsmDynamicPoolSize change to {0}/{1} is pending and will apply after restart; currently using {2}/{3}", + requestedPoolSize, requestedDynamicPoolSize, _svsmAppliedPoolSize, _svsmAppliedDynamicPoolSize); + _svsmPendingPoolSize = requestedPoolSize; + _svsmPendingDynamicPoolSize = requestedDynamicPoolSize; + } + else if (!poolSizeChanged) { - NC_LOG_WARNING("SVSM: svsmPoolSize/svsmDynamicPoolSize changes need a restart once the pools exist, reverting to {0}/{1}", _svsmAppliedPoolSize, _svsmAppliedDynamicPoolSize); - CVAR_SVSMPoolSize.Set(static_cast(_svsmAppliedPoolSize)); - CVAR_SVSMDynamicPoolSize.Set(static_cast(_svsmAppliedDynamicPoolSize)); + _svsmPendingPoolSize = requestedPoolSize; + _svsmPendingDynamicPoolSize = requestedDynamicPoolSize; } const bool configChanged = static_cast(glm::max(CVAR_SVSMPageSize.Get(), 16)) != _svsmAppliedPageSize - || static_cast(CVAR_SVSMPoolSize.Get()) != _svsmAppliedPoolSize - || static_cast(CVAR_SVSMDynamicPoolSize.Get()) != _svsmAppliedDynamicPoolSize; + || (!poolsExist && poolSizeChanged); if (configChanged) { ResetSVSMPoolState(resources); @@ -328,7 +343,7 @@ void ShadowRenderer::Update(f32 deltaTime, RenderResources& resources) { Renderer::ImageDesc poolDesc; poolDesc.debugName = "SVSMPagePool"; - poolDesc.dimensions = vec2(CVAR_SVSMPoolSize.Get(), CVAR_SVSMPoolSize.Get()); + poolDesc.dimensions = vec2(_svsmAppliedPoolSize, _svsmAppliedPoolSize); poolDesc.dimensionType = Renderer::ImageDimensionType::DIMENSION_ABSOLUTE; poolDesc.format = Renderer::ImageFormat::R32_UINT; poolDesc.sampleCount = Renderer::SampleCount::SAMPLE_COUNT_1; @@ -337,7 +352,7 @@ void ShadowRenderer::Update(f32 deltaTime, RenderResources& resources) _svsmPagePool = _renderer->CreateImage(poolDesc); poolDesc.debugName = "SVSMDynamicPagePool"; - poolDesc.dimensions = vec2(CVAR_SVSMDynamicPoolSize.Get(), CVAR_SVSMDynamicPoolSize.Get()); + poolDesc.dimensions = vec2(_svsmAppliedDynamicPoolSize, _svsmAppliedDynamicPoolSize); _svsmDynamicPagePool = _renderer->CreateImage(poolDesc); _svsmPoolNeedsClear = true; // Fresh VRAM is garbage that must never be sampled, zero both once @@ -616,7 +631,7 @@ ShadowRenderer::SVSMUpdateRecorder::SVSMUpdateRecorder(ShadowRenderer& owner, SV , numDirtyAABBs(numDirtyAABBs) , numDynamicAABBs(numDynamicAABBs) , dynamicSplit(dynamicSplit) - , config(DeriveSVSMConfig(SVSM_MAX_PAGE_TABLE_SIZE)) + , config(DeriveSVSMConfig(SVSM_MAX_PAGE_TABLE_SIZE, owner._svsmAppliedPoolSize, owner._svsmAppliedDynamicPoolSize)) , tableCapacity(SVSM_MAX_CLIPMAPS * SVSM_MAX_PAGE_TABLE_SIZE * SVSM_MAX_PAGE_TABLE_SIZE) , constants(graphResources.FrameNew()) { @@ -996,7 +1011,7 @@ void ShadowRenderer::AddSVSMDebugOverlayPass(Renderer::RenderGraph* renderGraph, const u32 regionSize = 512; PoolDebugConstants* constants = graphResources.FrameNew(); - constants->poolSize = static_cast(poolMode == 2 ? CVAR_SVSMDynamicPoolSize.Get() : CVAR_SVSMPoolSize.Get()); + constants->poolSize = poolMode == 2 ? _svsmAppliedDynamicPoolSize : _svsmAppliedPoolSize; constants->regionSize = regionSize; constants->screenOffset = ivec2(glm::max(static_cast(targetDimensions.x) - static_cast(regionSize) - 8, 0), glm::max(static_cast(targetDimensions.y) - static_cast(regionSize) - 8, 0)); @@ -1271,10 +1286,13 @@ void ShadowRenderer::ResetSVSMPoolState(RenderResources& resources) // past the frames in flight), so every consumer set rebinds below. The pool textures are NOT // recreated — their dimensions only depend on the pool size cvars, which are restart-only // once the pools exist (the Engine cannot destroy images) - const SVSMDerivedConfig config = DeriveSVSMConfig(SVSM_MAX_PAGE_TABLE_SIZE); + const bool poolsExist = _svsmPagePool != Renderer::ImageID::Invalid(); + const u32 poolSize = poolsExist ? _svsmAppliedPoolSize : static_cast(CVAR_SVSMPoolSize.Get()); + const u32 dynamicPoolSize = poolsExist ? _svsmAppliedDynamicPoolSize : static_cast(CVAR_SVSMDynamicPoolSize.Get()); + const SVSMDerivedConfig config = DeriveSVSMConfig(SVSM_MAX_PAGE_TABLE_SIZE, poolSize, dynamicPoolSize); _svsmAppliedPageSize = config.pageSize; - _svsmAppliedPoolSize = static_cast(CVAR_SVSMPoolSize.Get()); - _svsmAppliedDynamicPoolSize = static_cast(CVAR_SVSMDynamicPoolSize.Get()); + _svsmAppliedPoolSize = poolSize; + _svsmAppliedDynamicPoolSize = dynamicPoolSize; // The physical page index is 12 bits in the table entry _svsmPoolPages = glm::min(config.poolPagesPerRow * config.poolPagesPerRow, SVSM_MAX_POOL_PAGES); diff --git a/Source/Game-Lib/Game-Lib/Rendering/Shadow/ShadowRenderer.h b/Source/Game-Lib/Game-Lib/Rendering/Shadow/ShadowRenderer.h index 4b5852e3..c05c7b5b 100644 --- a/Source/Game-Lib/Game-Lib/Rendering/Shadow/ShadowRenderer.h +++ b/Source/Game-Lib/Game-Lib/Rendering/Shadow/ShadowRenderer.h @@ -198,6 +198,8 @@ class ShadowRenderer u32 _svsmAppliedPageSize = 0; u32 _svsmAppliedPoolSize = 0; u32 _svsmAppliedDynamicPoolSize = 0; + u32 _svsmPendingPoolSize = 0; + u32 _svsmPendingDynamicPoolSize = 0; // Caster-toggle cvar states as of last Update: a flip must re-bake the whole static cache, // resident pages keep the toggled class's baked depth forever otherwise (marked pages never diff --git a/Source/Game-Lib/Game-Lib/Rendering/Terrain/TerrainLoader.cpp b/Source/Game-Lib/Game-Lib/Rendering/Terrain/TerrainLoader.cpp index b62bc24b..1d70f29f 100644 --- a/Source/Game-Lib/Game-Lib/Rendering/Terrain/TerrainLoader.cpp +++ b/Source/Game-Lib/Game-Lib/Rendering/Terrain/TerrainLoader.cpp @@ -479,6 +479,14 @@ bool TerrainLoader::LoadFullMapRequest(const LoadRequestInternal& request) const std::string& mapName = request.mapName; if (mapName == _currentMapInternalName) { + // The requested map is already current, so do not load it again. + // If it has finished loading, notify this caller immediately. Otherwise, + // the load already in progress will send MapLoadedEvent when it finishes. + if (!_modelLoader->IsTerrainLoading()) + { + const u32 mapID = ServiceLocator::GetGameRenderer()->GetMapLoader()->GetCurrentMapID(); + ECS::Util::EventUtil::PushEvent(ECS::Components::MapLoadedEvent{ mapID }); + } return false; } @@ -488,19 +496,29 @@ bool TerrainLoader::LoadFullMapRequest(const LoadRequestInternal& request) PACT::PactFileHandle fileHandle; if (pactStorage->ReadFile(mapHeaderPath, fileHandle) != PACT::PactReadResult::Success) + { + MapLoader* mapLoader = ServiceLocator::GetGameRenderer()->GetMapLoader(); + mapLoader->ReportLoadFailure(mapLoader->GetCurrentMapID(), ECS::Components::MapLoadFailureReason::MissingHeader); return false; + } Map::MapHeader mapHeader; std::shared_ptr mapHeaderBuffer = std::make_shared(const_cast(fileHandle.GetData()), fileHandle.GetSize()); mapHeaderBuffer->writtenData = fileHandle.GetSize(); if (!Map::MapHeader::Read(mapHeaderBuffer, mapHeader)) + { + MapLoader* mapLoader = ServiceLocator::GetGameRenderer()->GetMapLoader(); + mapLoader->ReportLoadFailure(mapLoader->GetCurrentMapID(), ECS::Components::MapLoadFailureReason::InvalidHeader); return false; + } u32 numChunks = static_cast(mapHeader.chunkHashes.size()); if (numChunks == 0) { NC_LOG_ERROR("TerrainLoader : Map '{0}' has no chunks", request.mapName); + MapLoader* mapLoader = ServiceLocator::GetGameRenderer()->GetMapLoader(); + mapLoader->ReportLoadFailure(mapLoader->GetCurrentMapID(), ECS::Components::MapLoadFailureReason::NoChunks); return false; } @@ -543,6 +561,8 @@ bool TerrainLoader::LoadFullMapRequest(const LoadRequestInternal& request) if (numChunksToLoad == 0) { NC_LOG_ERROR("TerrainLoader : Map '{0}' could not prepare chunks", request.mapName); + MapLoader* mapLoader = ServiceLocator::GetGameRenderer()->GetMapLoader(); + mapLoader->ReportLoadFailure(mapLoader->GetCurrentMapID(), ECS::Components::MapLoadFailureReason::NoAvailableChunks); return false; } diff --git a/Source/Game-Lib/Game-Lib/Scripting/Handlers/EventHandler.cpp b/Source/Game-Lib/Game-Lib/Scripting/Handlers/EventHandler.cpp index 34f6a1bb..aedda13f 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/Handlers/EventHandler.cpp +++ b/Source/Game-Lib/Game-Lib/Scripting/Handlers/EventHandler.cpp @@ -66,6 +66,8 @@ namespace Scripting zenith->RegisterEventTypeID(MetaGen::Game::Lua::GameEvent::Updated); zenith->RegisterEventTypeID(MetaGen::Game::Lua::GameEvent::CharacterListChanged); zenith->RegisterEventTypeID(MetaGen::Game::Lua::GameEvent::MapLoading); + zenith->RegisterEventTypeID(MetaGen::Game::Lua::GameEvent::MapLoaded); + zenith->RegisterEventTypeID(MetaGen::Game::Lua::GameEvent::MapLoadFailed); zenith->RegisterEventTypeID(MetaGen::Game::Lua::GameEvent::ChatMessageReceived); zenith->RegisterEventTypeID(MetaGen::Game::Lua::GameEvent::LocalMoverChanged); diff --git a/Source/Game-Lib/Game-Lib/Scripting/Handlers/GameHandler.cpp b/Source/Game-Lib/Game-Lib/Scripting/Handlers/GameHandler.cpp index abac063b..03cc267f 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/Handlers/GameHandler.cpp +++ b/Source/Game-Lib/Game-Lib/Scripting/Handlers/GameHandler.cpp @@ -1,12 +1,136 @@ #include "GameHandler.h" +#include "Game-Lib/Application/EnttRegistries.h" +#include "Game-Lib/ECS/Components/Events.h" +#include "Game-Lib/ECS/Singletons/Database/ClientDBSingleton.h" +#include "Game-Lib/ECS/Util/EventUtil.h" #include "Game-Lib/Scripting/Game/Container.h" +#include "Game-Lib/Scripting/Handlers/RenderTargetHandler.h" +#include "Game-Lib/Scripting/Handlers/RenderDocHandler.h" +#include "Game-Lib/Scripting/Handlers/TracyHandler.h" +#include "Game-Lib/Util/ServiceLocator.h" +#include +#include +#include +#include +#include + +#include #include +#include +#include namespace Scripting::Game { void GameHandler::Register(Zenith* zenith) { + LuaMethodTable::Set(zenith, gameGlobalMethods, "Game"); Scripting::Game::Container::Register(zenith); + Scripting::RenderTarget::RenderTargetHandler::Register(zenith); + Scripting::RenderDoc::RenderDocHandler::Register(zenith); + Scripting::Tracy::TracyHandler::Register(zenith); + } + + void GameHandler::Clear(Zenith*) + { + _isLoaded = false; + } + + void GameHandler::PostLoad(Zenith*) + { + _isLoaded = true; + } + + void GameHandler::Update(Zenith* zenith, f32) + { + ECS::Util::EventUtil::OnEvent( + [zenith](const ECS::Components::MapLoadedEvent& event) + { + const bool isLoaded = event.mapId != std::numeric_limits::max(); + std::string mapInternalName; + + if (isLoaded) + { + EnttRegistries* registries = ServiceLocator::GetEnttRegistries(); + if (registries && registries->dbRegistry) + { + auto& context = registries->dbRegistry->ctx(); + if (context.contains()) + { + auto& clientDB = context.get(); + ClientDB::Data* mapStorage = clientDB.Get(ClientDBHash::Map); + if (mapStorage && mapStorage->Has(event.mapId)) + { + const auto& map = mapStorage->Get(event.mapId); + mapInternalName = mapStorage->GetString(map.nameInternal); + } + } + } + } + + zenith->CallEvent( + MetaGen::Game::Lua::GameEvent::MapLoaded, + MetaGen::Game::Lua::GameEventDataMapLoaded{ + .mapID = event.mapId, + .mapInternalName = mapInternalName, + .isLoaded = isLoaded, + }); + }); + + ECS::Util::EventUtil::OnEvent( + [zenith](const ECS::Components::MapLoadFailedEvent& event) + { + std::string mapInternalName; + EnttRegistries* registries = ServiceLocator::GetEnttRegistries(); + if (registries && registries->dbRegistry) + { + auto& context = registries->dbRegistry->ctx(); + if (context.contains()) + { + auto& clientDB = context.get(); + ClientDB::Data* mapStorage = clientDB.Get(ClientDBHash::Map); + if (mapStorage && mapStorage->Has(event.mapId)) + { + const auto& map = mapStorage->Get(event.mapId); + mapInternalName = mapStorage->GetString(map.nameInternal); + } + } + } + + const char* reason = "unknown"; + switch (event.reason) + { + case ECS::Components::MapLoadFailureReason::MissingDatabaseRecord: reason = "missing-database-record"; break; + case ECS::Components::MapLoadFailureReason::MissingHeader: reason = "missing-header"; break; + case ECS::Components::MapLoadFailureReason::InvalidHeader: reason = "invalid-header"; break; + case ECS::Components::MapLoadFailureReason::MissingBaseModel: reason = "missing-base-model"; break; + case ECS::Components::MapLoadFailureReason::NoChunks: reason = "no-chunks"; break; + case ECS::Components::MapLoadFailureReason::NoAvailableChunks: reason = "no-available-chunks"; break; + } + + zenith->CallEvent( + MetaGen::Game::Lua::GameEvent::MapLoadFailed, + MetaGen::Game::Lua::GameEventDataMapLoadFailed{ + .mapID = event.mapId, + .mapInternalName = mapInternalName, + .reason = reason, + }); + }); + } + + i32 GameHandler::IsLoaded(Zenith* zenith) + { + zenith->GetGlobalKey("Zenith"); + LuaManager* luaManager = zenith->IsLightUserData(-1) + ? static_cast(zenith->ToLightUserData(-1)) + : nullptr; + zenith->Pop(); + + GameHandler* self = luaManager + ? luaManager->GetLuaHandler( + static_cast(MetaGen::Game::Lua::LuaHandlerTypeEnum::Game)) + : nullptr; + zenith->Push(self && self->_isLoaded); + return 1; } } diff --git a/Source/Game-Lib/Game-Lib/Scripting/Handlers/GameHandler.h b/Source/Game-Lib/Game-Lib/Scripting/Handlers/GameHandler.h index a4b19e78..baa94d25 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/Handlers/GameHandler.h +++ b/Source/Game-Lib/Game-Lib/Scripting/Handlers/GameHandler.h @@ -10,9 +10,19 @@ namespace Scripting::Game { public: void Register(Zenith* zenith); - void Clear(Zenith* zenith) {} + void Clear(Zenith* zenith); - void PostLoad(Zenith* zenith) {} - void Update(Zenith* zenith, f32 deltaTime) {} + void PostLoad(Zenith* zenith); + void Update(Zenith* zenith, f32 deltaTime); + + static i32 IsLoaded(Zenith* zenith); + + private: + bool _isLoaded = false; + }; + + static LuaRegister<> gameGlobalMethods[] = + { + { "IsLoaded", GameHandler::IsLoaded }, }; -} \ No newline at end of file +} diff --git a/Source/Game-Lib/Game-Lib/Scripting/Handlers/RenderDocHandler.cpp b/Source/Game-Lib/Game-Lib/Scripting/Handlers/RenderDocHandler.cpp new file mode 100644 index 00000000..20b1007e --- /dev/null +++ b/Source/Game-Lib/Game-Lib/Scripting/Handlers/RenderDocHandler.cpp @@ -0,0 +1,57 @@ +#include "RenderDocHandler.h" + +#include "Game-Lib/Rendering/GameRenderer.h" +#include "Game-Lib/Rendering/RenderDocCapture.h" +#include "Game-Lib/Util/ServiceLocator.h" + +#include +#include + +#include + +namespace Scripting::RenderDoc +{ + void RenderDocHandler::Register(Zenith* zenith) + { + LuaManager* luaManager = ServiceLocator::GetLuaManager(); + const bool inDeveloperMode = luaManager && luaManager->IsDeveloperMode(); + const Scripting::LuaMethodFlags excludeFlags = inDeveloperMode + ? Scripting::LuaMethodFlags::None + : Scripting::LuaMethodFlags::DeveloperOnly; + + LuaMethodTable::Set(zenith, renderDocGlobalMethods, "RenderDoc", excludeFlags); + } + + i32 RenderDocHandler::IsAvailable(Zenith* zenith) + { + GameRenderer* gameRenderer = ServiceLocator::GetGameRenderer(); + RenderDocCapture* capture = gameRenderer + ? gameRenderer->GetRenderDocCapture() + : nullptr; + + zenith->Push(capture && capture->IsAvailable()); + return 1; + } + + i32 RenderDocHandler::CaptureNextFrame(Zenith* zenith) + { + const char* artifactPathRaw = zenith->CheckVal(1); + const std::string artifactPath = artifactPathRaw ? artifactPathRaw : ""; + + GameRenderer* gameRenderer = ServiceLocator::GetGameRenderer(); + RenderDocCapture* capture = gameRenderer + ? gameRenderer->GetRenderDocCapture() + : nullptr; + + std::string error; + const bool queued = + capture && + capture->QueueNextFrame(artifactPath, error); + if (!queued && error.empty()) + error = "RenderDoc capture is unavailable"; + + zenith->Push(queued); + zenith->Push(error.c_str()); + return 2; + } +} diff --git a/Source/Game-Lib/Game-Lib/Scripting/Handlers/RenderDocHandler.h b/Source/Game-Lib/Game-Lib/Scripting/Handlers/RenderDocHandler.h new file mode 100644 index 00000000..1303427c --- /dev/null +++ b/Source/Game-Lib/Game-Lib/Scripting/Handlers/RenderDocHandler.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include + +namespace Scripting +{ + struct Zenith; +} + +namespace Scripting::RenderDoc +{ + class RenderDocHandler + { + public: + static void Register(Zenith* zenith); + static i32 IsAvailable(Zenith* zenith); + static i32 CaptureNextFrame(Zenith* zenith); + }; + + static LuaRegister<> renderDocGlobalMethods[] = + { + { "IsAvailable", RenderDocHandler::IsAvailable, Scripting::LuaMethodFlags::DeveloperOnly }, + { "CaptureNextFrame", RenderDocHandler::CaptureNextFrame, Scripting::LuaMethodFlags::DeveloperOnly }, + }; +} diff --git a/Source/Game-Lib/Game-Lib/Scripting/Handlers/RenderTargetHandler.cpp b/Source/Game-Lib/Game-Lib/Scripting/Handlers/RenderTargetHandler.cpp new file mode 100644 index 00000000..0c743488 --- /dev/null +++ b/Source/Game-Lib/Game-Lib/Scripting/Handlers/RenderTargetHandler.cpp @@ -0,0 +1,46 @@ +#include "RenderTargetHandler.h" + +#include "Game-Lib/Rendering/GameRenderer.h" +#include "Game-Lib/Rendering/RenderTargetCapture.h" +#include "Game-Lib/Util/ServiceLocator.h" + +#include +#include + +#include + +namespace Scripting::RenderTarget +{ + void RenderTargetHandler::Register(Zenith* zenith) + { + LuaManager* luaManager = ServiceLocator::GetLuaManager(); + const bool inDeveloperMode = luaManager && luaManager->IsDeveloperMode(); + const Scripting::LuaMethodFlags excludeFlags = inDeveloperMode + ? Scripting::LuaMethodFlags::None + : Scripting::LuaMethodFlags::DeveloperOnly; + + LuaMethodTable::Set(zenith, renderTargetGlobalMethods, "RenderTarget", excludeFlags); + } + + i32 RenderTargetHandler::Dump(Zenith* zenith) + { + const char* debugNameRaw = zenith->CheckVal(1); + const char* artifactPathRaw = zenith->CheckVal(2); + const std::string debugName = debugNameRaw ? debugNameRaw : ""; + const std::string artifactPath = artifactPathRaw ? artifactPathRaw : ""; + + GameRenderer* gameRenderer = ServiceLocator::GetGameRenderer(); + std::string error; + const bool queued = + gameRenderer && + gameRenderer->GetRenderTargetCapture() && + gameRenderer->GetRenderTargetCapture()->Queue(debugName, artifactPath, error); + + if (!queued && error.empty()) + error = "Render-target capture is unavailable"; + + zenith->Push(queued); + zenith->Push(error.c_str()); + return 2; + } +} diff --git a/Source/Game-Lib/Game-Lib/Scripting/Handlers/RenderTargetHandler.h b/Source/Game-Lib/Game-Lib/Scripting/Handlers/RenderTargetHandler.h new file mode 100644 index 00000000..4672b69f --- /dev/null +++ b/Source/Game-Lib/Game-Lib/Scripting/Handlers/RenderTargetHandler.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +#include + +namespace Scripting +{ + struct Zenith; +} + +namespace Scripting::RenderTarget +{ + class RenderTargetHandler + { + public: + static void Register(Zenith* zenith); + static i32 Dump(Zenith* zenith); + }; + + static LuaRegister<> renderTargetGlobalMethods[] = + { + { "Dump", RenderTargetHandler::Dump, Scripting::LuaMethodFlags::DeveloperOnly }, + }; +} diff --git a/Source/Game-Lib/Game-Lib/Scripting/Handlers/SchedulerHandler.cpp b/Source/Game-Lib/Game-Lib/Scripting/Handlers/SchedulerHandler.cpp new file mode 100644 index 00000000..ed908e80 --- /dev/null +++ b/Source/Game-Lib/Game-Lib/Scripting/Handlers/SchedulerHandler.cpp @@ -0,0 +1,235 @@ +#include "SchedulerHandler.h" +#include "Game-Lib/Scripting/Util/ZenithUtil.h" + +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace Scripting::Scheduler +{ + namespace + { + SchedulerHandler* GetSelf(Zenith* zenith) + { + zenith->GetGlobalKey("Zenith"); + LuaManager* luaManager = zenith->IsLightUserData(-1) + ? static_cast(zenith->ToLightUserData(-1)) + : nullptr; + zenith->Pop(); + + if (!luaManager) + return nullptr; + + return luaManager->GetLuaHandler( + static_cast(MetaGen::Game::Lua::LuaHandlerTypeEnum::Scheduler)); + } + } + + void SchedulerHandler::Register(Zenith* zenith) + { + LuaMethodTable::Set(zenith, schedulerGlobalMethods, "Scheduler"); + } + + void SchedulerHandler::Clear(Zenith* zenith) + { + for (auto iterator = _callbacks.begin(); iterator != _callbacks.end();) + { + if (iterator->second.owner != zenith) + { + ++iterator; + continue; + } + + Scripting::Util::Zenith::Unref(zenith, iterator->second.callbackRef); + iterator = _callbacks.erase(iterator); + } + _frames.erase(zenith); + } + + void SchedulerHandler::Update(Zenith* zenith, f32) + { + const u64 currentFrame = ++_frames[zenith]; + const Clock::time_point now = Clock::now(); + std::vector dueCallbacks; + dueCallbacks.reserve(_callbacks.size()); + + for (const auto& [handle, callback] : _callbacks) + { + if (callback.owner != zenith) + continue; + + const bool isDue = callback.type == ScheduleType::Seconds + ? callback.deadline <= now + : callback.targetFrame <= currentFrame; + if (isDue) + dueCallbacks.push_back(handle); + } + + std::sort(dueCallbacks.begin(), dueCallbacks.end()); + + for (const u64 handle : dueCallbacks) + { + auto iterator = _callbacks.find(handle); + if (iterator == _callbacks.end() || iterator->second.owner != zenith) + continue; + + const i32 callbackRef = iterator->second.callbackRef; + zenith->GetRawI(LUA_REGISTRYINDEX, callbackRef); + Scripting::Util::Zenith::Unref(zenith, callbackRef); + _callbacks.erase(iterator); + zenith->PCall(); + } + } + + i32 SchedulerHandler::AfterSeconds(Zenith* zenith) + { + if (zenith->GetTop() != 2) + { + luaL_error(zenith->state, "Scheduler.AfterSeconds expects seconds and callback"); + return 0; + } + + const f64 seconds = zenith->CheckVal(1); + if (!std::isfinite(seconds) || seconds < 0.0) + { + luaL_error(zenith->state, "Scheduler.AfterSeconds seconds must be finite and non-negative"); + return 0; + } + + const Clock::time_point now = Clock::now(); + const f64 maxSeconds = std::chrono::duration( + Clock::time_point::max() - now).count(); + if (seconds > maxSeconds) + { + luaL_error(zenith->state, "Scheduler.AfterSeconds seconds exceed the supported clock range"); + return 0; + } + + if (!zenith->IsFunction(2)) + { + luaL_error(zenith->state, "Scheduler.AfterSeconds callback must be a function"); + return 0; + } + + SchedulerHandler* self = GetSelf(zenith); + if (!self) + { + luaL_error(zenith->state, "Scheduler handler is unavailable"); + return 0; + } + + const auto duration = std::chrono::duration_cast( + std::chrono::duration(seconds)); + const u64 handle = self->AllocateHandle(); + const i32 callbackRef = zenith->GetRef(2); + self->_callbacks.emplace(handle, PendingCallback{ + .owner = zenith, + .type = ScheduleType::Seconds, + .deadline = now + duration, + .callbackRef = callbackRef, + }); + + zenith->Push(handle); + return 1; + } + + i32 SchedulerHandler::AfterFrames(Zenith* zenith) + { + if (zenith->GetTop() != 2) + { + luaL_error(zenith->state, "Scheduler.AfterFrames expects frames and callback"); + return 0; + } + + const f64 frameValue = zenith->CheckVal(1); + if (!std::isfinite(frameValue) || + frameValue < 1.0 || + frameValue > static_cast(std::numeric_limits::max()) || + std::floor(frameValue) != frameValue) + { + luaL_error(zenith->state, "Scheduler.AfterFrames frames must be a positive integer"); + return 0; + } + + if (!zenith->IsFunction(2)) + { + luaL_error(zenith->state, "Scheduler.AfterFrames callback must be a function"); + return 0; + } + + SchedulerHandler* self = GetSelf(zenith); + if (!self) + { + luaL_error(zenith->state, "Scheduler handler is unavailable"); + return 0; + } + + const u64 frames = static_cast(frameValue); + const u64 currentFrame = self->_frames[zenith]; + if (frames > std::numeric_limits::max() - currentFrame) + { + luaL_error(zenith->state, "Scheduler.AfterFrames frames exceed the supported range"); + return 0; + } + + const u64 handle = self->AllocateHandle(); + const i32 callbackRef = zenith->GetRef(2); + self->_callbacks.emplace(handle, PendingCallback{ + .owner = zenith, + .type = ScheduleType::Frames, + .targetFrame = currentFrame + frames, + .callbackRef = callbackRef, + }); + + zenith->Push(handle); + return 1; + } + + i32 SchedulerHandler::Cancel(Zenith* zenith) + { + if (zenith->GetTop() != 1) + { + luaL_error(zenith->state, "Scheduler.Cancel expects a scheduler handle"); + return 0; + } + + SchedulerHandler* self = GetSelf(zenith); + if (!self) + { + zenith->Push(false); + return 1; + } + + const u64 handle = zenith->CheckVal(1); + auto iterator = self->_callbacks.find(handle); + if (iterator == self->_callbacks.end() || iterator->second.owner != zenith) + { + zenith->Push(false); + return 1; + } + + Scripting::Util::Zenith::Unref(zenith, iterator->second.callbackRef); + self->_callbacks.erase(iterator); + zenith->Push(true); + return 1; + } + + u64 SchedulerHandler::AllocateHandle() + { + u64 handle = 0; + do + { + handle = _nextHandle++; + } while (handle == 0 || _callbacks.contains(handle)); + return handle; + } +} diff --git a/Source/Game-Lib/Game-Lib/Scripting/Handlers/SchedulerHandler.h b/Source/Game-Lib/Game-Lib/Scripting/Handlers/SchedulerHandler.h new file mode 100644 index 00000000..83c07dba --- /dev/null +++ b/Source/Game-Lib/Game-Lib/Scripting/Handlers/SchedulerHandler.h @@ -0,0 +1,56 @@ +#pragma once +#include + +#include +#include + +#include +#include + +namespace Scripting::Scheduler +{ + class SchedulerHandler : public LuaHandlerBase + { + public: + void Register(Zenith* zenith); + void Clear(Zenith* zenith); + + void PostLoad(Zenith* zenith) {} + void Update(Zenith* zenith, f32 deltaTime); + + static i32 AfterSeconds(Zenith* zenith); + static i32 AfterFrames(Zenith* zenith); + static i32 Cancel(Zenith* zenith); + + private: + using Clock = std::chrono::steady_clock; + + enum class ScheduleType : u8 + { + Seconds, + Frames, + }; + + struct PendingCallback + { + Zenith* owner = nullptr; + ScheduleType type = ScheduleType::Seconds; + Clock::time_point deadline; + u64 targetFrame = 0; + i32 callbackRef = -1; + }; + + u64 AllocateHandle(); + + u64 _nextHandle = 1; + std::unordered_map _callbacks; + std::unordered_map _frames; + }; + + static LuaRegister<> schedulerGlobalMethods[] = + { + { "AfterSeconds", SchedulerHandler::AfterSeconds }, + { "AfterFrames", SchedulerHandler::AfterFrames }, + { "Cancel", SchedulerHandler::Cancel }, + }; +} diff --git a/Source/Game-Lib/Game-Lib/Scripting/Handlers/TracyHandler.cpp b/Source/Game-Lib/Game-Lib/Scripting/Handlers/TracyHandler.cpp new file mode 100644 index 00000000..1c2c3afc --- /dev/null +++ b/Source/Game-Lib/Game-Lib/Scripting/Handlers/TracyHandler.cpp @@ -0,0 +1,40 @@ +#include "TracyHandler.h" + +#include "Game-Lib/Util/ServiceLocator.h" + +#include +#include + +#include + +#include + +namespace Scripting::Tracy +{ + void TracyHandler::Register(Zenith* zenith) + { + LuaManager* luaManager = ServiceLocator::GetLuaManager(); + const bool inDeveloperMode = luaManager && luaManager->IsDeveloperMode(); + const Scripting::LuaMethodFlags excludeFlags = inDeveloperMode + ? Scripting::LuaMethodFlags::None + : Scripting::LuaMethodFlags::DeveloperOnly; + + LuaMethodTable::Set(zenith, tracyGlobalMethods, "Tracy", excludeFlags); + } + + i32 TracyHandler::IsConnected(Zenith* zenith) + { + zenith->Push(TracyIsConnected); + return 1; + } + + i32 TracyHandler::Message(Zenith* zenith) + { + const char* message = zenith->CheckVal(1); + if (message) + { + TracyMessage(message, std::strlen(message)); + } + return 0; + } +} diff --git a/Source/Game-Lib/Game-Lib/Scripting/Handlers/TracyHandler.h b/Source/Game-Lib/Game-Lib/Scripting/Handlers/TracyHandler.h new file mode 100644 index 00000000..040da908 --- /dev/null +++ b/Source/Game-Lib/Game-Lib/Scripting/Handlers/TracyHandler.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include + +namespace Scripting +{ + struct Zenith; +} + +namespace Scripting::Tracy +{ + class TracyHandler + { + public: + static void Register(Zenith* zenith); + static i32 IsConnected(Zenith* zenith); + static i32 Message(Zenith* zenith); + }; + + static LuaRegister<> tracyGlobalMethods[] = + { + { "IsConnected", TracyHandler::IsConnected, Scripting::LuaMethodFlags::DeveloperOnly }, + { "Message", TracyHandler::Message, Scripting::LuaMethodFlags::DeveloperOnly }, + }; +} diff --git a/Source/Game-Lib/Game-Lib/Util/AutomationUtil.cpp b/Source/Game-Lib/Game-Lib/Util/AutomationUtil.cpp new file mode 100644 index 00000000..7db4feeb --- /dev/null +++ b/Source/Game-Lib/Game-Lib/Util/AutomationUtil.cpp @@ -0,0 +1,256 @@ +#include "AutomationUtil.h" + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace +{ + constexpr std::uintmax_t MaxAutomationScriptSize = 8 * 1024 * 1024; + + std::string EscapeJson(const std::string& value) + { + std::string result; + result.reserve(value.size()); + constexpr char Hex[] = "0123456789abcdef"; + for (const unsigned char character : value) + { + switch (character) + { + case '"': result += "\\\""; break; + case '\\': result += "\\\\"; break; + case '\b': result += "\\b"; break; + case '\f': result += "\\f"; break; + case '\n': result += "\\n"; break; + case '\r': result += "\\r"; break; + case '\t': result += "\\t"; break; + default: + if (character < 0x20) + { + result += "\\u00"; + result.push_back(Hex[character >> 4]); + result.push_back(Hex[character & 0x0f]); + } + else + { + result.push_back(static_cast(character)); + } + break; + } + } + return result; + } + + bool IsValidRequestId(const std::string& requestId) + { + if (requestId.empty() || requestId.size() > 128) + return false; + return std::ranges::all_of(requestId, [](unsigned char character) + { + return std::isalnum(character) != 0 || + character == '-' || + character == '_' || + character == '.' || + character == ':'; + }); + } + + void EmitMarker( + const std::string& requestId, + const char* event, + const std::string& script, + const std::string& error = "") + { + std::string marker = + "NOVUS_AUTOMATION {\"requestId\":\"" + EscapeJson(requestId) + + "\",\"event\":\"" + event + + "\",\"script\":\"" + EscapeJson(script) + "\""; + if (!error.empty()) + marker += ",\"error\":\"" + EscapeJson(error) + "\""; + marker += "}"; + NC_LOG_INFO("{}", marker); + } + + bool IsBelow(const fs::path& root, const fs::path& candidate) + { + auto rootIterator = root.begin(); + auto candidateIterator = candidate.begin(); + while (rootIterator != root.end()) + { + if (candidateIterator == candidate.end() || *rootIterator != *candidateIterator) + return false; + ++rootIterator; + ++candidateIterator; + } + return true; + } +} + +namespace Util::Automation +{ + bool ResolveArtifactPath( + const fs::path& automationRoot, + const fs::path& requestedPath, + std::string_view requiredExtension, + fs::path& resolvedPath, + std::string& error) + { + if (automationRoot.empty() || !automationRoot.is_absolute()) + { + error = "NOVUS_AUTOMATION_ROOT must be an absolute path"; + return false; + } + if (requestedPath.empty() || requestedPath.is_absolute()) + { + error = "Artifact path must be relative"; + return false; + } + if (requestedPath.extension() != requiredExtension) + { + error = "Artifact path must use the " + std::string(requiredExtension) + " extension"; + return false; + } + + const fs::path lexicalRoot = (automationRoot / "Artifacts").lexically_normal(); + std::error_code pathError; + fs::path artifactRoot = fs::weakly_canonical(lexicalRoot, pathError); + if (pathError) + { + pathError.clear(); + artifactRoot = lexicalRoot; + } + + resolvedPath = fs::weakly_canonical(artifactRoot / requestedPath, pathError); + if (pathError) + resolvedPath = (artifactRoot / requestedPath).lexically_normal(); + + if (!IsBelow(artifactRoot, resolvedPath)) + { + error = "Artifact path escapes the configured Artifacts root"; + return false; + } + return true; + } + + bool ResolveScriptPath( + const fs::path& automationRoot, + const fs::path& requestedPath, + fs::path& resolvedPath, + std::string& error) + { + if (automationRoot.empty() || !automationRoot.is_absolute()) + { + error = "NOVUS_AUTOMATION_ROOT must be an absolute path"; + return false; + } + if (requestedPath.empty() || requestedPath.is_absolute()) + { + error = "Script path must be relative"; + return false; + } + if (requestedPath.extension() != ".luau") + { + error = "Script path must use the .luau extension"; + return false; + } + + auto firstComponent = requestedPath.begin(); + if (firstComponent == requestedPath.end() || *firstComponent != "Scripts") + { + error = "Script path must be below Scripts"; + return false; + } + + std::error_code pathError; + const fs::path scriptsRoot = fs::weakly_canonical(automationRoot / "Scripts", pathError); + if (pathError) + { + error = "Failed to resolve Scripts root: " + pathError.message(); + return false; + } + + resolvedPath = fs::canonical(automationRoot / requestedPath, pathError); + if (pathError) + { + error = "Failed to resolve script: " + pathError.message(); + return false; + } + if (!IsBelow(scriptsRoot, resolvedPath)) + { + error = "Script path escapes the configured Scripts root"; + return false; + } + if (!fs::is_regular_file(resolvedPath, pathError) || pathError) + { + error = "Script path is not a regular file"; + return false; + } + if (fs::file_size(resolvedPath, pathError) > MaxAutomationScriptSize || pathError) + { + error = pathError ? "Failed to inspect script size" : "Script exceeds the 8 MiB size limit"; + return false; + } + return true; + } + + bool ExecuteScript( + Scripting::LuaManager& luaManager, + const std::string& requestId, + const std::string& requestedPath) + { + if (!IsValidRequestId(requestId)) + { + NC_LOG_ERROR("Invalid automation request ID"); + return false; + } + + const char* automationRootRaw = std::getenv("NOVUS_AUTOMATION_ROOT"); + if (!automationRootRaw || automationRootRaw[0] == '\0') + { + EmitMarker(requestId, "failed", requestedPath, "NOVUS_AUTOMATION_ROOT is not configured"); + return false; + } + + fs::path resolvedPath; + std::string error; + if (!ResolveScriptPath(automationRootRaw, requestedPath, resolvedPath, error)) + { + EmitMarker(requestId, "failed", requestedPath, error); + return false; + } + + std::ifstream stream(resolvedPath, std::ios::binary); + std::string source{ + std::istreambuf_iterator(stream), + std::istreambuf_iterator()}; + if (!stream && !stream.eof()) + { + EmitMarker(requestId, "failed", requestedPath, "Failed to read script"); + return false; + } + + const auto key = Scripting::ZenithInfoKey::MakeGlobal(0, 0); + Scripting::Zenith* zenith = luaManager.GetZenithStateManager().Get(key); + if (!zenith) + { + EmitMarker(requestId, "failed", requestedPath, "Global Luau state is unavailable"); + return false; + } + + EmitMarker(requestId, "started", requestedPath); + const bool succeeded = luaManager.DoString(zenith, source); + EmitMarker(requestId, succeeded ? "succeeded" : "failed", requestedPath, + succeeded ? "" : "Luau execution failed"); + return succeeded; + } +} diff --git a/Source/Game-Lib/Game-Lib/Util/AutomationUtil.h b/Source/Game-Lib/Game-Lib/Util/AutomationUtil.h new file mode 100644 index 00000000..65a41691 --- /dev/null +++ b/Source/Game-Lib/Game-Lib/Util/AutomationUtil.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +namespace Scripting +{ + class LuaManager; +} + +namespace Util::Automation +{ + bool ResolveArtifactPath( + const std::filesystem::path& automationRoot, + const std::filesystem::path& requestedPath, + std::string_view requiredExtension, + std::filesystem::path& resolvedPath, + std::string& error); + + bool ResolveScriptPath( + const std::filesystem::path& automationRoot, + const std::filesystem::path& requestedPath, + std::filesystem::path& resolvedPath, + std::string& error); + + bool ExecuteScript( + Scripting::LuaManager& luaManager, + const std::string& requestId, + const std::string& requestedPath); +} diff --git a/Source/Game-Tests/Game-Tests/AutomationUtilTests.cpp b/Source/Game-Tests/Game-Tests/AutomationUtilTests.cpp new file mode 100644 index 00000000..b4606bdb --- /dev/null +++ b/Source/Game-Tests/Game-Tests/AutomationUtilTests.cpp @@ -0,0 +1,70 @@ +#include + +#include + +#include +#include +#include + +namespace +{ + class TemporaryAutomationRoot + { + public: + TemporaryAutomationRoot() + { + _path = std::filesystem::temp_directory_path() / + ("novus-automation-" + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directories(_path / "Scripts"); + std::ofstream(_path / "Scripts" / "smoke.luau") << "print(\"smoke\")"; + std::filesystem::create_directories(_path / "Outside"); + std::ofstream(_path / "Outside" / "escape.luau") << "print(\"escape\")"; + } + + ~TemporaryAutomationRoot() + { + std::error_code error; + std::filesystem::remove_all(_path, error); + } + + const std::filesystem::path& GetPath() const { return _path; } + + private: + std::filesystem::path _path; + }; +} + +TEST_CASE("Automation scripts resolve below the configured Scripts root") +{ + TemporaryAutomationRoot root; + std::filesystem::path resolved; + std::string error; + + REQUIRE(Util::Automation::ResolveScriptPath( + root.GetPath(), + "Scripts/smoke.luau", + resolved, + error)); + CHECK(resolved.filename() == "smoke.luau"); +} + +TEST_CASE("Automation scripts reject traversal and non-Luau files") +{ + TemporaryAutomationRoot root; + std::filesystem::path resolved; + std::string error; + + CHECK_FALSE(Util::Automation::ResolveScriptPath( + root.GetPath(), + "Scripts/../Outside/escape.luau", + resolved, + error)); + CHECK(error.find("escapes") != std::string::npos); + + CHECK_FALSE(Util::Automation::ResolveScriptPath( + root.GetPath(), + "Scripts/smoke.lua", + resolved, + error)); +} diff --git a/Source/Game-Tests/Game-Tests/RenderDocCaptureTests.cpp b/Source/Game-Tests/Game-Tests/RenderDocCaptureTests.cpp new file mode 100644 index 00000000..7445bd59 --- /dev/null +++ b/Source/Game-Tests/Game-Tests/RenderDocCaptureTests.cpp @@ -0,0 +1,49 @@ +#include + +#include + +#include + +TEST_CASE("RenderDoc artifacts resolve below the configured Artifacts root") +{ + const std::filesystem::path automationRoot = + std::filesystem::temp_directory_path() / "novus-renderdoc-tests"; + std::filesystem::path resolved; + std::string error; + + REQUIRE(RenderDocCapture::ResolveArtifactPath( + automationRoot, + "feature-184/frame.rdc", + resolved, + error)); + CHECK(resolved == automationRoot / "Artifacts" / "feature-184" / "frame.rdc"); +} + +TEST_CASE("RenderDoc artifacts reject traversal, absolute paths, and other extensions") +{ + const std::filesystem::path automationRoot = + std::filesystem::temp_directory_path() / "novus-renderdoc-tests"; + std::filesystem::path resolved; + std::string error; + + CHECK_FALSE(RenderDocCapture::ResolveArtifactPath( + automationRoot, + "../outside.rdc", + resolved, + error)); + CHECK(error.find("escapes") != std::string::npos); + + CHECK_FALSE(RenderDocCapture::ResolveArtifactPath( + automationRoot, + automationRoot / "absolute.rdc", + resolved, + error)); + CHECK(error.find("relative") != std::string::npos); + + CHECK_FALSE(RenderDocCapture::ResolveArtifactPath( + automationRoot, + "feature-184/frame.png", + resolved, + error)); + CHECK(error.find(".rdc") != std::string::npos); +} diff --git a/Source/Game-Tests/Game-Tests/RenderTargetCaptureTests.cpp b/Source/Game-Tests/Game-Tests/RenderTargetCaptureTests.cpp new file mode 100644 index 00000000..0e8332f1 --- /dev/null +++ b/Source/Game-Tests/Game-Tests/RenderTargetCaptureTests.cpp @@ -0,0 +1,153 @@ +#include + +#include + +#include +#include + +namespace +{ + template + void Append(std::vector& bytes, T value) + { + const size_t offset = bytes.size(); + bytes.resize(offset + sizeof(T)); + std::memcpy(bytes.data() + offset, &value, sizeof(T)); + } +} + +TEST_CASE("Render-target artifacts resolve below the configured Artifacts root") +{ + const std::filesystem::path automationRoot = + std::filesystem::temp_directory_path() / "novus-render-target-tests"; + std::filesystem::path resolved; + std::string error; + + REQUIRE(RenderTargetCapture::ResolveArtifactPath( + automationRoot, + "captures/scene.png", + resolved, + error)); + CHECK(resolved == automationRoot / "Artifacts" / "captures" / "scene.png"); + + CHECK_FALSE(RenderTargetCapture::ResolveArtifactPath( + automationRoot, + "../outside.png", + resolved, + error)); + CHECK(error.find("escapes") != std::string::npos); + + CHECK_FALSE(RenderTargetCapture::ResolveArtifactPath( + automationRoot, + "captures/scene.jpg", + resolved, + error)); +} + +TEST_CASE("Normalized color targets convert to RGBA8") +{ + const std::vector source = { + 255, 0, 128, 255, + 10, 20, 30, 40 + }; + std::vector destination; + std::string error; + + REQUIRE(RenderTargetCapture::ConvertColorToRGBA8( + Renderer::ImageFormat::R8G8B8A8_UNORM, + uvec2(2, 1), + source, + destination, + error)); + CHECK(destination == source); + + REQUIRE(RenderTargetCapture::ConvertColorToRGBA8( + Renderer::ImageFormat::B8G8R8A8_UNORM, + uvec2(2, 1), + source, + destination, + error)); + CHECK(destination == std::vector({ + 128, 0, 255, 255, + 30, 20, 10, 40 + })); +} + +TEST_CASE("Integer color targets are normalized for inspection") +{ + std::vector source; + Append(source, 100); + Append(source, 200); + std::vector destination; + std::string error; + + REQUIRE(RenderTargetCapture::ConvertColorToRGBA8( + Renderer::ImageFormat::R32_UINT, + uvec2(2, 1), + source, + destination, + error)); + CHECK(destination == std::vector({ + 0, 0, 0, 255, + 255, 255, 255, 255 + })); +} + +TEST_CASE("Float color targets preserve inspectable values") +{ + std::vector source; + Append(source, 0x0000); + Append(source, 0x3800); + Append(source, 0x3c00); + Append(source, 0x3c00); + std::vector destination; + std::string error; + + REQUIRE(RenderTargetCapture::ConvertColorToRGBA8( + Renderer::ImageFormat::R16G16B16A16_FLOAT, + uvec2(1, 1), + source, + destination, + error)); + CHECK(destination == std::vector({ 0, 128, 255, 255 })); +} + +TEST_CASE("Scalar float targets are normalized for inspection") +{ + std::vector source; + Append(source, 0.25f); + Append(source, 0.75f); + std::vector destination; + std::string error; + + REQUIRE(RenderTargetCapture::ConvertColorToRGBA8( + Renderer::ImageFormat::R32_FLOAT, + uvec2(2, 1), + source, + destination, + error)); + CHECK(destination == std::vector({ + 0, 0, 0, 255, + 255, 255, 255, 255 + })); +} + +TEST_CASE("Depth targets are normalized and inverted for inspection") +{ + std::vector source; + Append(source, 0.25f); + Append(source, 0.75f); + std::vector destination; + std::string error; + + REQUIRE(RenderTargetCapture::ConvertDepthToRGBA8( + Renderer::DepthImageFormat::D32_FLOAT, + uvec2(2, 1), + source, + destination, + error)); + CHECK(destination == std::vector({ + 255, 255, 255, 255, + 0, 0, 0, 255 + })); +} diff --git a/Source/Game-Tests/Game-Tests/ScriptingAutomationTests.cpp b/Source/Game-Tests/Game-Tests/ScriptingAutomationTests.cpp new file mode 100644 index 00000000..c0ddee09 --- /dev/null +++ b/Source/Game-Tests/Game-Tests/ScriptingAutomationTests.cpp @@ -0,0 +1,183 @@ +#include +#include + +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace +{ + class ScriptingAutomationHarness + { + public: + ScriptingAutomationHarness() + { + _luaManager.PrepareToAddLuaHandlers( + static_cast(MetaGen::Game::Lua::LuaHandlerTypeEnum::Count)); + + _gameHandler = std::make_unique(); + _schedulerHandler = std::make_unique(); + _luaManager.SetLuaHandler( + static_cast(MetaGen::Game::Lua::LuaHandlerTypeEnum::Game), + _gameHandler.get()); + _luaManager.SetLuaHandler( + static_cast(MetaGen::Game::Lua::LuaHandlerTypeEnum::Scheduler), + _schedulerHandler.get()); + + REQUIRE(_luaManager.GetZenithStateManager().Add(_key)); + _zenith = _luaManager.GetZenithStateManager().Get(_key); + REQUIRE(_zenith != nullptr); + + _zenith->SetState(luaL_newstate()); + REQUIRE(_zenith->state != nullptr); + REQUIRE(_luaManager.GetZenithStateManager().Add(_key, _zenith->state)); + + _zenith->RegisterDefaultLibraries(); + _zenith->PushLightUserData(&_luaManager); + _zenith->SetGlobalKey("Zenith"); + + Scripting::LuaMethodTable::Set(_zenith, Scripting::Game::gameGlobalMethods, "Game"); + _schedulerHandler->Register(_zenith); + } + + ~ScriptingAutomationHarness() + { + _schedulerHandler->Clear(_zenith); + _luaManager.GetZenithStateManager().Remove(_key); + } + + bool Execute(const std::string& source) + { + return _luaManager.DoString(_zenith, source); + } + + i32 GetGlobalInteger(const char* name) + { + _zenith->GetGlobalKey(name); + const i32 value = _zenith->Get(-1); + _zenith->Pop(); + return value; + } + + bool GetGlobalBoolean(const char* name) + { + _zenith->GetGlobalKey(name); + const bool value = _zenith->Get(-1); + _zenith->Pop(); + return value; + } + + Scripting::Game::GameHandler& GameHandler() { return *_gameHandler; } + Scripting::Scheduler::SchedulerHandler& SchedulerHandler() { return *_schedulerHandler; } + Scripting::Zenith* Zenith() { return _zenith; } + + private: + Scripting::ZenithInfoKey _key = Scripting::ZenithInfoKey::MakeGlobal(0, 0); + Scripting::LuaManager _luaManager; + std::unique_ptr _gameHandler; + std::unique_ptr _schedulerHandler; + Scripting::Zenith* _zenith = nullptr; + }; +} + +TEST_CASE("Native automation primitives support deterministic script execution") +{ + ScriptingAutomationHarness harness; + + REQUIRE(harness.Execute(R"( + fired = 0 + Scheduler.AfterSeconds(0, function() + fired += 1 + Scheduler.AfterSeconds(0, function() + fired += 10 + end) + end) + + cancelledFired = false + cancelledHandle = Scheduler.AfterSeconds(0, function() + cancelledFired = true + end) + firstCancel = Scheduler.Cancel(cancelledHandle) + secondCancel = Scheduler.Cancel(cancelledHandle) + )")); + + CHECK(harness.GetGlobalInteger("fired") == 0); + CHECK(harness.GetGlobalBoolean("firstCancel")); + CHECK_FALSE(harness.GetGlobalBoolean("secondCancel")); + + harness.SchedulerHandler().Update(harness.Zenith(), 0.0f); + CHECK(harness.GetGlobalInteger("fired") == 1); + CHECK_FALSE(harness.GetGlobalBoolean("cancelledFired")); + + harness.SchedulerHandler().Update(harness.Zenith(), 0.0f); + CHECK(harness.GetGlobalInteger("fired") == 11); + + REQUIRE(harness.Execute(R"( + delayedFired = false + Scheduler.AfterSeconds(0.05, function() + delayedFired = true + end) + )")); + + harness.SchedulerHandler().Update(harness.Zenith(), 0.0f); + CHECK_FALSE(harness.GetGlobalBoolean("delayedFired")); + + std::this_thread::sleep_for(std::chrono::milliseconds(60)); + harness.SchedulerHandler().Update(harness.Zenith(), 0.0f); + CHECK(harness.GetGlobalBoolean("delayedFired")); + + REQUIRE(harness.Execute(R"( + frameStage = 0 + Scheduler.AfterFrames(3, function() + frameStage = 1 + Scheduler.AfterFrames(2, function() + frameStage = 2 + end) + end) + )")); + + harness.SchedulerHandler().Update(harness.Zenith(), 0.0f); + CHECK(harness.GetGlobalInteger("frameStage") == 0); + harness.SchedulerHandler().Update(harness.Zenith(), 0.0f); + CHECK(harness.GetGlobalInteger("frameStage") == 0); + harness.SchedulerHandler().Update(harness.Zenith(), 0.0f); + CHECK(harness.GetGlobalInteger("frameStage") == 1); + harness.SchedulerHandler().Update(harness.Zenith(), 0.0f); + CHECK(harness.GetGlobalInteger("frameStage") == 1); + harness.SchedulerHandler().Update(harness.Zenith(), 0.0f); + CHECK(harness.GetGlobalInteger("frameStage") == 2); + + REQUIRE(harness.Execute(R"( + firedAfterClear = false + Scheduler.AfterSeconds(0, function() + firedAfterClear = true + end) + Scheduler.AfterFrames(1, function() + firedAfterClear = true + end) + )")); + + harness.SchedulerHandler().Clear(harness.Zenith()); + harness.SchedulerHandler().Update(harness.Zenith(), 0.0f); + CHECK_FALSE(harness.GetGlobalBoolean("firedAfterClear")); + + REQUIRE(harness.Execute("gameLoadedBeforePostLoad = Game.IsLoaded()")); + CHECK_FALSE(harness.GetGlobalBoolean("gameLoadedBeforePostLoad")); + + harness.GameHandler().PostLoad(harness.Zenith()); + REQUIRE(harness.Execute("gameLoadedAfterPostLoad = Game.IsLoaded()")); + CHECK(harness.GetGlobalBoolean("gameLoadedAfterPostLoad")); + + harness.GameHandler().Clear(harness.Zenith()); + REQUIRE(harness.Execute("gameLoadedAfterClear = Game.IsLoaded()")); + CHECK_FALSE(harness.GetGlobalBoolean("gameLoadedAfterClear")); +} diff --git a/Source/Game-Tests/Game-Tests/TestEnvironment.cpp b/Source/Game-Tests/Game-Tests/TestEnvironment.cpp new file mode 100644 index 00000000..73b06cd2 --- /dev/null +++ b/Source/Game-Tests/Game-Tests/TestEnvironment.cpp @@ -0,0 +1,51 @@ +#include + +#include +#include +#include + +#include + +namespace +{ + void FlushTestLogs() + { + for (auto* logger : quill::Frontend::get_all_loggers()) + { + logger->flush_log(); + } + } + + [[maybe_unused]] const bool TestLoggerReady = [] + { + quill::Backend::start(); + auto sink = quill::Frontend::create_or_get_sink("server_tests_console"); + quill::Frontend::create_or_get_logger("root", std::move(sink), + "%(time:<16) LOG_%(log_level:<11) %(message)", "%H:%M:%S.%Qms", + quill::Timezone::LocalTime, quill::ClockSourceType::System); + return true; + }(); + + class QuillFlushListener final : public Catch::EventListenerBase + { + public: + using Catch::EventListenerBase::EventListenerBase; + + void testCasePartialEnded(const Catch::TestCaseStats&, uint64_t) override + { + FlushTestLogs(); + } + + void testCaseEnded(const Catch::TestCaseStats&) override + { + FlushTestLogs(); + } + + void testRunEnded(const Catch::TestRunStats&) override + { + FlushTestLogs(); + } + }; +} + +CATCH_REGISTER_LISTENER(QuillFlushListener) diff --git a/Source/Meta/Definitions/Game/Lua/Enum.lua b/Source/Meta/Definitions/Game/Lua/Enum.lua index a3b48a42..df3a7575 100644 --- a/Source/Meta/Definitions/Game/Lua/Enum.lua +++ b/Source/Meta/Definitions/Game/Lua/Enum.lua @@ -12,6 +12,7 @@ return D.Definitions D.Field("Game"), D.Field("Unit"), D.Field("Time"), + D.Field("Scheduler"), D.Field("Camera"), D.Field("Map"), D.Field("Scene"), @@ -30,6 +31,8 @@ return D.Definitions D.Field("Updated"), D.Field("CharacterListChanged"), D.Field("MapLoading"), + D.Field("MapLoaded"), + D.Field("MapLoadFailed"), D.Field("ChatMessageReceived"), D.Field("LocalMoverChanged"), D.Field("Count") diff --git a/Source/Meta/Definitions/Game/Lua/Event.lua b/Source/Meta/Definitions/Game/Lua/Event.lua index 09173b93..e1b73cdb 100644 --- a/Source/Meta/Definitions/Game/Lua/Event.lua +++ b/Source/Meta/Definitions/Game/Lua/Event.lua @@ -21,6 +21,20 @@ return D.Definitions D.Field("mapInternalName", Type.STRING) }), + D.LuaEvent("GameEventDataMapLoaded", + { + D.Field("mapID", Type.U32), + D.Field("mapInternalName", Type.STRING), + D.Field("isLoaded", Type.BOOL) + }), + + D.LuaEvent("GameEventDataMapLoadFailed", + { + D.Field("mapID", Type.U32), + D.Field("mapInternalName", Type.STRING), + D.Field("reason", Type.STRING) + }), + D.LuaEvent("GameEventDataChatMessageReceived", { D.Field("sender", Type.STRING), diff --git a/Source/Resources/Automation/README.md b/Source/Resources/Automation/README.md new file mode 100644 index 00000000..f1c1772e --- /dev/null +++ b/Source/Resources/Automation/README.md @@ -0,0 +1,165 @@ +# Novus Game automation + +This directory contains deterministic Luau workloads for AI-driven Game +verification. MCPTools configures it as `NOVUS_AUTOMATION_ROOT`; Game accepts +only canonical regular `.luau` files below `Scripts`. + +Execute a workload through the redirected Game console: + +```text +automation_run Scripts/