diff --git a/.gitmodules b/.gitmodules index 97738494e..b34db1d72 100644 --- a/.gitmodules +++ b/.gitmodules @@ -17,3 +17,4 @@ [submodule "ThirdParty/SDL2"] path = ThirdParty/SDL2 url = https://github.com/libsdl-org/SDL.git + branch = SDL2 diff --git a/CMakeLists.txt b/CMakeLists.txt index 24121702c..2cb5d2f86 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -55,7 +55,16 @@ if(APPLE AND NOT CMAKE_OSX_DEPLOYMENT_TARGET) endif() set(SPARK_ENGINE_VERSION "1.0.0" CACHE STRING "SparkEngine semantic version used for package metadata") -project(SparkEngine VERSION ${SPARK_ENGINE_VERSION} LANGUAGES CXX C) + +# Objective-C / Objective-C++ are required on macOS so SDL2's cocoa/.m sources +# (SDL_cocoaevents.m, SDL_cocoametalview.m, SDL_rwopsbundlesupport.m, etc.) and +# our own MetalDevice.mm can compile. Without them CMake silently skips .m/.mm +# sources and the link fails with "no such file or directory" on the .m.o files. +if(APPLE) + project(SparkEngine VERSION ${SPARK_ENGINE_VERSION} LANGUAGES CXX C OBJC OBJCXX) +else() + project(SparkEngine VERSION ${SPARK_ENGINE_VERSION} LANGUAGES CXX C) +endif() # Set consistent runtime library for all targets using modern CMake (CMP0091) # Must be after project() so that the MSVC variable is defined. @@ -519,20 +528,56 @@ if(NOT WIN32 AND ENABLE_SDL2 AND EXISTS "${SDL2_SUBMODULE_DIR}/CMakeLists.txt") set(SDL_SHARED ON CACHE BOOL "" FORCE) set(SDL_STATIC OFF CACHE BOOL "" FORCE) set(SDL_TEST OFF CACHE BOOL "" FORCE) - set(SDL_WAYLAND OFF CACHE BOOL "" FORCE) - set(SDL_KMSDRM OFF CACHE BOOL "" FORCE) - set(SDL_DBUS OFF CACHE BOOL "" FORCE) - set(SDL_IBUS OFF CACHE BOOL "" FORCE) - set(SDL_PULSEAUDIO OFF CACHE BOOL "" FORCE) - set(SDL_ALSA OFF CACHE BOOL "" FORCE) - set(SDL_PIPEWIRE OFF CACHE BOOL "" FORCE) - set(SDL_SNDIO OFF CACHE BOOL "" FORCE) - set(SDL_JACK OFF CACHE BOOL "" FORCE) - set(SDL_X11 ON CACHE BOOL "" FORCE) set(SDL_OPENGL ON CACHE BOOL "" FORCE) + if(APPLE) + # macOS: Cocoa + CoreAudio. X11/Wayland/ALSA etc. are irrelevant. + set(SDL_X11 OFF CACHE BOOL "" FORCE) + set(SDL_WAYLAND OFF CACHE BOOL "" FORCE) + set(SDL_KMSDRM OFF CACHE BOOL "" FORCE) + set(SDL_DBUS OFF CACHE BOOL "" FORCE) + set(SDL_IBUS OFF CACHE BOOL "" FORCE) + set(SDL_PULSEAUDIO OFF CACHE BOOL "" FORCE) + set(SDL_ALSA OFF CACHE BOOL "" FORCE) + set(SDL_PIPEWIRE OFF CACHE BOOL "" FORCE) + set(SDL_SNDIO OFF CACHE BOOL "" FORCE) + set(SDL_JACK OFF CACHE BOOL "" FORCE) + else() + # Linux: X11 + OpenGL, everything else off to keep the build lean. + set(SDL_X11 ON CACHE BOOL "" FORCE) + set(SDL_WAYLAND OFF CACHE BOOL "" FORCE) + set(SDL_KMSDRM OFF CACHE BOOL "" FORCE) + set(SDL_DBUS OFF CACHE BOOL "" FORCE) + set(SDL_IBUS OFF CACHE BOOL "" FORCE) + set(SDL_PULSEAUDIO OFF CACHE BOOL "" FORCE) + set(SDL_ALSA OFF CACHE BOOL "" FORCE) + set(SDL_PIPEWIRE OFF CACHE BOOL "" FORCE) + set(SDL_SNDIO OFF CACHE BOOL "" FORCE) + set(SDL_JACK OFF CACHE BOOL "" FORCE) + endif() + add_subdirectory("${SDL2_SUBMODULE_DIR}" "${CMAKE_BINARY_DIR}/ThirdParty/SDL2" EXCLUDE_FROM_ALL) set(SDL2_FOUND TRUE) + + # Eagerly copy SDL2's public headers to the build tree at configure time. + # SDL2 normally copies them via a build-time custom_target ("sdl_headers_copy"), + # but consumers that pick up SDL2's INTERFACE_INCLUDE_DIRECTORIES without a + # direct build-order dep on SDL2 (e.g. cached CI restores, tests using + # through SparkEngineLib's PUBLIC include path) can race the copy and fail + # with "fatal error: SDL_main.h: No such file or directory" at + # build/ThirdParty/SDL2/include/SDL2/SDL.h. Copying at configure time makes + # the headers available before any compilation job runs. + file(GLOB _SDL2_PUBLIC_HEADERS "${SDL2_SUBMODULE_DIR}/include/*.h") + file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/ThirdParty/SDL2/include/SDL2") + foreach(_hdr IN LISTS _SDL2_PUBLIC_HEADERS) + if(NOT _hdr MATCHES ".*(SDL_config|SDL_revision).*") + get_filename_component(_name "${_hdr}" NAME) + configure_file("${_hdr}" + "${CMAKE_BINARY_DIR}/ThirdParty/SDL2/include/SDL2/${_name}" + COPYONLY) + endif() + endforeach() + message(STATUS "SDL2 built from submodule (ThirdParty/SDL2)") endif() diff --git a/SparkBuild/CMakeLists.txt b/SparkBuild/CMakeLists.txt index 3b3df88e7..d4c827485 100644 --- a/SparkBuild/CMakeLists.txt +++ b/SparkBuild/CMakeLists.txt @@ -50,10 +50,10 @@ if(WIN32) ) endif() -if(MSVC) - set_property(TARGET SparkBuildCore PROPERTY - MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") -endif() +# Inherit the top-level /MD[d] CRT (CMAKE_MSVC_RUNTIME_LIBRARY is set by the +# engine's root CMakeLists). Forcing /MT[d] here causes SparkInstaller — which +# also links imgui (built with /MD[d] via BuildImGui.cmake) — to pull in both +# CRT variants and fail with LNK1169 / LNK2005 on duplicate symbols. if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") target_link_libraries(SparkBuildCore PUBLIC pthread) @@ -77,10 +77,7 @@ endif() target_link_libraries(SparkBuild PRIVATE SparkBuildCore) -if(MSVC) - set_property(TARGET SparkBuild PROPERTY - MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") -endif() +# Inherit the top-level /MD[d] CRT — see comment above SparkBuildCore. if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") target_compile_options(SparkBuild PRIVATE -Wall -Wextra -Wpedantic) diff --git a/SparkEditor/Source/Core/EditorFonts.cpp b/SparkEditor/Source/Core/EditorFonts.cpp index 4dc7bbfab..aa092ed99 100644 --- a/SparkEditor/Source/Core/EditorFonts.cpp +++ b/SparkEditor/Source/Core/EditorFonts.cpp @@ -53,11 +53,8 @@ namespace SparkEditor #endif const char* fontPaths[] = { - exeFontsPath.c_str(), // next to binary (works regardless of CWD) - "EditorAssets/Fonts/", - "Fonts/", - "../SparkEditor/Fonts/", - "../Fonts/", + exeFontsPath.c_str(), // next to binary (works regardless of CWD) + "EditorAssets/Fonts/", "Fonts/", "../SparkEditor/Fonts/", "../Fonts/", }; std::string fontDir; diff --git a/SparkEditor/Source/Core/EditorTheme.cpp b/SparkEditor/Source/Core/EditorTheme.cpp index 9d5cbe507..5bc788d15 100644 --- a/SparkEditor/Source/Core/EditorTheme.cpp +++ b/SparkEditor/Source/Core/EditorTheme.cpp @@ -593,23 +593,23 @@ namespace SparkEditor t.author = "Spark Engine Team"; // Warm neutral background stack (hue ≈ 60, very low chroma). - t.background = ThemeColor::FromHex("#1A1716"); // bg-1: panel - t.backgroundDark = ThemeColor::FromHex("#110E0D"); // bg-0: deepest - t.backgroundLight = ThemeColor::FromHex("#302D2A"); // bg-3: hover/input + t.background = ThemeColor::FromHex("#1A1716"); // bg-1: panel + t.backgroundDark = ThemeColor::FromHex("#110E0D"); // bg-0: deepest + t.backgroundLight = ThemeColor::FromHex("#302D2A"); // bg-3: hover/input t.backgroundAccent = ThemeColor::FromHex("#AF530D").WithAlpha(0.22f); - t.backgroundHeader = ThemeColor::FromHex("#23201E"); // bg-2: header row + t.backgroundHeader = ThemeColor::FromHex("#23201E"); // bg-2: header row t.backgroundActive = ThemeColor::FromHex("#F1823A").WithAlpha(0.20f); t.backgroundHover = ThemeColor::FromHex("#302D2A"); t.backgroundSelected = ThemeColor::FromHex("#F1823A").WithAlpha(0.16f); // Warm off-white foreground (hue ≈ 80). - t.text = ThemeColor::FromHex("#C5C3C1"); // fg-1 - t.textDisabled = ThemeColor::FromHex("#64625F"); // fg-3 - t.textSecondary = ThemeColor::FromHex("#8D8B88"); // fg-2 - t.textAccent = ThemeColor::FromHex("#F1823A"); // ember - t.textWarning = ThemeColor::FromHex("#EDCF59"); // yellow - t.textError = ThemeColor::FromHex("#FA6862"); // red - t.textSuccess = ThemeColor::FromHex("#6ED086"); // green + t.text = ThemeColor::FromHex("#C5C3C1"); // fg-1 + t.textDisabled = ThemeColor::FromHex("#64625F"); // fg-3 + t.textSecondary = ThemeColor::FromHex("#8D8B88"); // fg-2 + t.textAccent = ThemeColor::FromHex("#F1823A"); // ember + t.textWarning = ThemeColor::FromHex("#EDCF59"); // yellow + t.textError = ThemeColor::FromHex("#FA6862"); // red + t.textSuccess = ThemeColor::FromHex("#6ED086"); // green // Buttons — slightly raised from panel. t.button = ThemeColor::FromHex("#23201E"); @@ -623,10 +623,10 @@ namespace SparkEditor t.frameActive = ThemeColor::FromHex("#F1823A").WithAlpha(0.35f); // Borders — barely visible on idle, ember on focus. - t.border = ThemeColor::FromHex("#35322F"); // line - t.borderLight = ThemeColor::FromHex("#403C38"); // bg-4 + t.border = ThemeColor::FromHex("#35322F"); // line + t.borderLight = ThemeColor::FromHex("#403C38"); // bg-4 t.borderAccent = ThemeColor::FromHex("#F1823A"); - t.borderSeparator = ThemeColor::FromHex("#282523"); // line-soft + t.borderSeparator = ThemeColor::FromHex("#282523"); // line-soft // Title bar — deepest charcoal; active tab uses a dim ember wash. t.titleBar = ThemeColor::FromHex("#110E0D"); diff --git a/SparkEditor/Source/Panels/HierarchyPanel.cpp b/SparkEditor/Source/Panels/HierarchyPanel.cpp index 5c8e90826..f13b012e2 100644 --- a/SparkEditor/Source/Panels/HierarchyPanel.cpp +++ b/SparkEditor/Source/Panels/HierarchyPanel.cpp @@ -485,10 +485,9 @@ namespace SparkEditor const ImVec2 itemMin = ImGui::GetItemRectMin(); const ImVec2 itemMax = ImGui::GetItemRectMax(); const ImVec4 accent = ImGui::GetStyleColorVec4(ImGuiCol_NavHighlight); - ImGui::GetWindowDrawList()->AddRectFilled( - ImVec2(ImGui::GetWindowPos().x, itemMin.y), - ImVec2(ImGui::GetWindowPos().x + 2.0f, itemMax.y), - ImGui::GetColorU32(accent)); + ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(ImGui::GetWindowPos().x, itemMin.y), + ImVec2(ImGui::GetWindowPos().x + 2.0f, itemMax.y), + ImGui::GetColorU32(accent)); } if (!object->active) diff --git a/SparkEditor/Source/Panels/ProjectBrowserPanel.cpp b/SparkEditor/Source/Panels/ProjectBrowserPanel.cpp index e31fa521d..cd1711626 100644 --- a/SparkEditor/Source/Panels/ProjectBrowserPanel.cpp +++ b/SparkEditor/Source/Panels/ProjectBrowserPanel.cpp @@ -17,6 +17,11 @@ #include #include +#ifdef _WIN32 +#include +#include +#endif + namespace fs = std::filesystem; namespace SparkEditor diff --git a/SparkEngine/Source/Audio/OpenALAudioEngine.cpp b/SparkEngine/Source/Audio/OpenALAudioEngine.cpp index 76bdc9f32..a32948ee9 100644 --- a/SparkEngine/Source/Audio/OpenALAudioEngine.cpp +++ b/SparkEngine/Source/Audio/OpenALAudioEngine.cpp @@ -15,8 +15,16 @@ #include "../Utils/Validate.h" #ifdef SPARK_OPENAL_AVAILABLE +// On macOS the system OpenAL.framework lays headers out as . +// openal-soft (Homebrew, Linux) uses the form. Prefer the +// framework style on Apple so the build works with either provider. +#if defined(__APPLE__) && __has_include() +#include +#include +#else #include #include +#endif #else // Stub typedefs when OpenAL is not available - all operations become no-ops typedef int ALuint; diff --git a/SparkEngine/Source/Core/SparkEngineLinux.cpp b/SparkEngine/Source/Core/SparkEngineLinux.cpp index f69a4ddca..c94bd4302 100644 --- a/SparkEngine/Source/Core/SparkEngineLinux.cpp +++ b/SparkEngine/Source/Core/SparkEngineLinux.cpp @@ -679,22 +679,15 @@ static bool HandleSDLEvent(const SDL_Event& event) } /** - * @brief Initialize SDL2 windowed-mode subsystems: window, graphics, input, - * engine context, modules, audio, and console commands. + * @brief Construct and initialize GraphicsEngine against the given window / + * native render handle. Logs the outcome via SimpleConsole. * - * @param window The SDL2 window (already created by the caller). - * @param argc Argument count from main(). - * @param argv Argument values from main(). + * Extracted so the caller (RunSDL2Windowed) can detect a backend fallback + * (e.g. Vulkan requested → OpenGL selected by RHIBridge) and recreate the + * window + graphics pair with the correct SDL window flag. */ -static void InitializeSDL2Subsystems(SDL_Window* window, void* nativeRenderHandle, int argc, char* argv[]) +static HRESULT InitializeGraphicsForWindow(SDL_Window* window, void* nativeRenderHandle) { - auto& settings = EngineSettings::GetInstance(); - - // Core engine objects - GetEngineRuntime().timer = std::make_unique(); - GetEngineRuntime().eventBus = std::make_unique(); - GetEngineRuntime().input = std::make_unique(); - GetEngineRuntime().input->Initialize(static_cast(window)); GetEngineRuntime().graphics = std::make_unique(); // On macOS+Metal the RHI needs an NSView/CAMetalLayer, not the SDL_Window. @@ -707,6 +700,31 @@ static void InitializeSDL2Subsystems(SDL_Window* window, void* nativeRenderHandl console.LogInfo("Graphics engine initialized (RHI backend)."); else console.LogWarning("Graphics engine initialization deferred (headless fallback)."); + return hr; +} + +/** + * @brief Initialize SDL2 windowed-mode subsystems: input, engine context, + * modules, audio, and console commands. + * + * GraphicsEngine must already be constructed and initialized by the caller. + * The caller owns that step because a Vulkan→OpenGL fallback may require + * recreating the window before graphics init succeeds against the right + * surface type. + * + * @param window The SDL2 window (already created by the caller). + * @param argc Argument count from main(). + * @param argv Argument values from main(). + */ +static void InitializeSDL2Subsystems(SDL_Window* window, int argc, char* argv[]) +{ + auto& settings = EngineSettings::GetInstance(); + + // Core engine objects (graphics is already initialized by the caller) + GetEngineRuntime().timer = std::make_unique(); + GetEngineRuntime().eventBus = std::make_unique(); + GetEngineRuntime().input = std::make_unique(); + GetEngineRuntime().input->Initialize(static_cast(window)); // Engine context, physics, core subsystems, gameplay subsystems InitLinuxCoreSubsystems(/*registerGameplay=*/true); @@ -714,8 +732,9 @@ static void InitializeSDL2Subsystems(SDL_Window* window, void* nativeRenderHandl // Modules, audio, console commands InitLinuxModulesAndCommands(argc, argv, /*initAudio=*/true); - // Update window title with primary module name - if (GetEngineRuntime().moduleManager) + // Update window title with primary module name (only if we have a window — + // the windowless NullRHIDevice fallback path sets window = nullptr). + if (window && GetEngineRuntime().moduleManager) { auto* primary = GetEngineRuntime().moduleManager->GetPrimaryModule(); if (primary) @@ -746,11 +765,16 @@ static void InitializeSDL2Subsystems(SDL_Window* window, void* nativeRenderHandl * * Processes SDL events via HandleSDLEvent(), then calls TickFrame() for * the engine update. Returns when the window is closed or SIGINT is received. + * + * @param pollSdlEvents When false (SDL_Init failed), skip event polling and + * run a pure tick loop. The engine still ticks frames and respects + * SIGINT / test-frame-limit, just without SDL event input. */ -static void RunSDL2MainLoop() +static void RunSDL2MainLoop(bool pollSdlEvents) { auto& console = Spark::SimpleConsole::GetInstance(); - console.LogInfo("Starting main engine loop (SDL2)..."); + console.LogInfo(pollSdlEvents ? "Starting main engine loop (SDL2)..." + : "Starting main engine loop (SDL2 uninitialized — tick only)..."); if (g_testFrameLimit > 0) console.LogInfo(std::format("Test mode: will exit after {} frames", g_testFrameLimit)); @@ -765,15 +789,18 @@ static void RunSDL2MainLoop() break; } - SDL_Event event; bool running = true; - while (SDL_PollEvent(&event)) + if (pollSdlEvents) { - if (!HandleSDLEvent(event)) + SDL_Event event; + while (SDL_PollEvent(&event)) { - running = false; - break; + if (!HandleSDLEvent(event)) + { + running = false; + break; + } } } @@ -808,10 +835,16 @@ static int RunSDL2Windowed(int argc, char* argv[]) SDL_SetHint("SDL_VIDEO_X11_FORCE_EGL", "1"); SDL_SetHint(SDL_HINT_VIDEO_X11_FORCE_EGL, "1"); - if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS | SDL_INIT_TIMER | SDL_INIT_GAMECONTROLLER) != 0) + const bool sdlInitOk = (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS | SDL_INIT_TIMER | SDL_INIT_GAMECONTROLLER) == 0); + if (!sdlInitOk) { - Spark::SimpleConsole::GetInstance().LogError(std::string("SDL_Init failed: ") + SDL_GetError()); - return -1; + // SDL_Init can fail on sandboxed hosts with no display server, no + // joystick subsystem, or when dbus/udev aren't reachable. Fall + // through to the windowless / NullRHIDevice path instead of + // aborting — the engine is still useful for running game logic, + // physics, scripting, and networking headlessly. + Spark::SimpleConsole::GetInstance().LogWarning(std::string("SDL_Init failed: ") + SDL_GetError() + + " — falling back to windowless / NullRHIDevice mode"); } auto& settings = EngineSettings::GetInstance(); @@ -831,7 +864,8 @@ static int RunSDL2Windowed(int argc, char* argv[]) // ICD can set SPARK_DISABLE_VULKAN=1 and this branch will pick // OpenGL instead — matching what the RHIBridge will also choose. bool preferVulkan = false; - bool preferMetal = Spark::MacOS::ShouldPreferMetal(); + bool preferMetal = sdlInitOk && Spark::MacOS::ShouldPreferMetal(); + if (sdlInitOk) { const char* sdlDriver = SDL_GetCurrentVideoDriver(); SPARK_LOG_INFO(Spark::LogCategory::Graphics, "SDL2 video driver: %s", sdlDriver ? sdlDriver : ""); @@ -877,7 +911,7 @@ static int RunSDL2Windowed(int argc, char* argv[]) } } - if (!preferVulkan && !preferMetal) + if (sdlInitOk && !preferVulkan && !preferMetal) { // OpenGL path — set attributes before window creation (required // for Mesa llvmpipe and other software rasterizers). @@ -890,6 +924,22 @@ static int RunSDL2Windowed(int argc, char* argv[]) SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); } + // If SDL itself failed to initialize, or RHIBridge already decided there + // is no usable GPU backend (e.g. under gVisor or on a host missing + // libEGL/libvulkan), skip SDL window creation entirely. SDL2's offscreen + // video driver dlopen()s libEGL during SDL_CreateWindow — if libEGL is + // missing the process exits with -1 without surfacing an error through + // SDL_GetError(). Running windowless in that case lets the engine come up + // on NullRHIDevice. + const bool noGpuBackend = + !sdlInitOk || ((!preferVulkan && !preferMetal) && + (Spark::RHI::RHIBridge::GetRecommendedBackend() == Spark::RHI::GraphicsBackend::None)); + if (noGpuBackend) + { + SPARK_LOG_INFO(Spark::LogCategory::Graphics, + "No GPU backend available — skipping SDL window creation, running on NullRHIDevice"); + } + Uint32 windowFlags = SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE; if (settings.Graphics().fullscreen) windowFlags |= SDL_WINDOW_FULLSCREEN_DESKTOP; @@ -900,15 +950,25 @@ static int RunSDL2Windowed(int argc, char* argv[]) else windowFlags |= SDL_WINDOW_OPENGL; - SDL_Window* window = - SDL_CreateWindow("Spark Engine", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, winW, winH, windowFlags); - if (!window) + SDL_Window* window = nullptr; + if (!noGpuBackend) { - Spark::SimpleConsole::GetInstance().LogError(std::string("SDL_CreateWindow failed: ") + SDL_GetError()); - if (preferVulkan) - SDL_Vulkan_UnloadLibrary(); - SDL_Quit(); - return -1; + window = + SDL_CreateWindow("Spark Engine", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, winW, winH, windowFlags); + if (!window) + { + // Treat window-creation failure as recoverable: the engine will + // initialize graphics against a null handle (NullRHIDevice) and + // run the main loop windowless. This matches the headless + // fallback the rest of the stack already handles. + Spark::SimpleConsole::GetInstance().LogWarning(std::string("SDL_CreateWindow failed: ") + SDL_GetError() + + " — falling back to windowless / NullRHIDevice mode"); + if (preferVulkan) + { + SDL_Vulkan_UnloadLibrary(); + preferVulkan = false; + } + } } // On macOS, extract a Metal-capable view so MetalDevice can attach a @@ -917,14 +977,20 @@ static int RunSDL2Windowed(int argc, char* argv[]) // so MetalSwapChain::ConfigureMetalLayer() reuses it. On non-macOS the // call is a no-op that returns nullptr. void* sdlMetalView = nullptr; - if (preferMetal) + if (preferMetal && window) { sdlMetalView = Spark::MacOS::CreateMetalView(window); if (!sdlMetalView) { + // Metal framework unavailable / sandbox restriction. Tear down + // the Metal window and continue windowless — RHIBridge will + // select NullRHIDevice and the engine will run headlessly rather + // than aborting. + Spark::SimpleConsole::GetInstance().LogWarning( + "Spark::MacOS::CreateMetalView returned null — running windowless on NullRHIDevice"); SDL_DestroyWindow(window); - SDL_Quit(); - return -1; + window = nullptr; + preferMetal = false; } } @@ -934,7 +1000,7 @@ static int RunSDL2Windowed(int argc, char* argv[]) // surface out of SDL_Vulkan_CreateSurface() inside CreateSwapChain(). // Metal has no pre-init step either — the Metal view was created above. SDL_GLContext glContext = nullptr; - if (!preferVulkan && !preferMetal) + if (!preferVulkan && !preferMetal && window) { glContext = SDL_GL_CreateContext(window); if (!glContext) @@ -963,51 +1029,95 @@ static int RunSDL2Windowed(int argc, char* argv[]) SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); } - // If the engine was created with a Vulkan window but GraphicsEngine - // fell back to OpenGL (Vulkan loaded but couldn't create a usable - // surface/device), recreate the window with OpenGL flags and create - // a GL context so the fallback backend can actually render. - if (preferVulkan && GetEngineRuntime().graphics) + void* nativeRenderHandle = (preferMetal && sdlMetalView) ? sdlMetalView : nullptr; + + // Initialize graphics here (not inside InitializeSDL2Subsystems) so we + // can detect the Vulkan→OpenGL fallback case below. SDL2 bakes the + // backend choice into the window flags at creation time, so if + // RHIBridge falls back from Vulkan to OpenGL we need to recreate the + // window with SDL_WINDOW_OPENGL and re-run graphics init. + InitializeGraphicsForWindow(window, nativeRenderHandle); + + if (preferVulkan) { - auto* rhiDevice = GetEngineRuntime().graphics->GetRHIDevice(); - bool vulkanActive = rhiDevice && rhiDevice->GetBackendType() == Spark::RHI::GraphicsBackend::Vulkan; + auto* rhiDev = GetEngineRuntime().graphics->GetRHIDevice(); auto* rhiBridge = GetEngineRuntime().graphics->GetRHIBridge(); - bool headless = rhiBridge && rhiBridge->IsHeadless(); + const bool vulkanActive = rhiDev && rhiDev->GetBackendType() == Spark::RHI::GraphicsBackend::Vulkan; + const bool headless = rhiBridge && rhiBridge->IsHeadless(); + if (!vulkanActive && !headless) { - Spark::SimpleConsole::GetInstance().LogInfo( - "Vulkan backend unavailable — recreating window with OpenGL support"); + SPARK_LOG_WARN(Spark::LogCategory::Graphics, + "Vulkan requested but RHI selected %s — recreating window with SDL_WINDOW_OPENGL", + rhiDev ? Spark::RHI::GetBackendName(rhiDev->GetBackendType()) : ""); + + // Tear down graphics + Vulkan window before rebuilding. + GetEngineRuntime().graphics->Shutdown(); + GetEngineRuntime().graphics.reset(); SDL_DestroyWindow(window); - windowFlags = (windowFlags & ~SDL_WINDOW_VULKAN) | SDL_WINDOW_OPENGL; + SDL_Vulkan_UnloadLibrary(); + preferVulkan = false; + + windowFlags &= ~static_cast(SDL_WINDOW_VULKAN); + windowFlags |= SDL_WINDOW_OPENGL; + + SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); + SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24); + SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); + window = SDL_CreateWindow("Spark Engine", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, winW, winH, windowFlags); - if (window) + if (!window) + { + // Second-chance GL window creation also failed. Don't abort; + // initialize graphics against a null handle and run the engine + // on NullRHIDevice, matching the noGpuBackend path above. + Spark::SimpleConsole::GetInstance().LogWarning(std::string("SDL_CreateWindow (GL fallback) failed: ") + + SDL_GetError() + + " — falling back to windowless / NullRHIDevice mode"); + } + else { glContext = SDL_GL_CreateContext(window); - if (glContext) + if (!glContext) + { + Spark::SimpleConsole::GetInstance().LogWarning( + std::string("SDL_GL_CreateContext (GL fallback) failed: ") + SDL_GetError() + + " — engine will try headless fallback"); + } + else { SDL_GL_MakeCurrent(window, glContext); SDL_GL_SetSwapInterval(1); + Spark::SimpleConsole::GetInstance().LogInfo("SDL2 OpenGL context created after Vulkan fallback"); } - GetEngineRuntime().graphics->Shutdown(); - GetEngineRuntime().graphics->Initialize(static_cast(window)); } + + // Metal view is only valid for a preferMetal path — Vulkan fallback + // never creates one, so nativeRenderHandle stays null here. + InitializeGraphicsForWindow(window, /*nativeRenderHandle=*/nullptr); } } - void* nativeRenderHandle = (preferMetal && sdlMetalView) ? sdlMetalView : nullptr; - - InitializeSDL2Subsystems(window, nativeRenderHandle, argc, argv); - RunSDL2MainLoop(); + InitializeSDL2Subsystems(window, argc, argv); + RunSDL2MainLoop(/*pollSdlEvents=*/sdlInitOk); ShutdownLinux(); if (glContext) SDL_GL_DeleteContext(glContext); Spark::MacOS::DestroyMetalView(sdlMetalView); - SDL_DestroyWindow(window); - if (preferVulkan) - SDL_Vulkan_UnloadLibrary(); - SDL_Quit(); + if (window) + SDL_DestroyWindow(window); + if (sdlInitOk) + { + if (preferVulkan) + SDL_Vulkan_UnloadLibrary(); + SDL_Quit(); + } return 0; } #endif // SPARK_SDL2_AVAILABLE diff --git a/SparkEngine/Source/Core/SubsystemConsoleCommands.cpp b/SparkEngine/Source/Core/SubsystemConsoleCommands.cpp index a3451bd58..c2e8d36ed 100644 --- a/SparkEngine/Source/Core/SubsystemConsoleCommands.cpp +++ b/SparkEngine/Source/Core/SubsystemConsoleCommands.cpp @@ -408,8 +408,19 @@ namespace Spark if (it == types.end()) return "Unknown weather type. Options: clear, cloudy, rain, snow, fog, storm"; - float intensity = (args.size() > 1) ? std::stof(args[1]) : -1.0f; - float transition = (args.size() > 2) ? std::stof(args[2]) : 3.0f; + float intensity = -1.0f; + float transition = 3.0f; + try + { + if (args.size() > 1) + intensity = std::stof(args[1]); + if (args.size() > 2) + transition = std::stof(args[2]); + } + catch (const std::exception&) + { + return "Invalid numeric argument. Usage: weather_set [intensity] [transition_sec]"; + } weather->SetWeather(it->second, intensity, transition); return "Weather set to " + args[0]; }, @@ -427,7 +438,7 @@ namespace Spark int typeIdx = static_cast(state.type); std::stringstream ss; ss << "=== Weather State ===\n" - << " Type: " << (typeIdx < 6 ? typeNames[typeIdx] : "Unknown") << "\n" + << " Type: " << (typeIdx >= 0 && typeIdx < 6 ? typeNames[typeIdx] : "Unknown") << "\n" << " Intensity: " << state.intensity << "\n" << " Wind: speed=" << state.windSpeed << " dir=(" << state.windDirection.x << ", " << state.windDirection.y << ", " << state.windDirection.z << ")\n" diff --git a/SparkEngine/Source/Core/SubsystemConsoleCommandsExt.cpp b/SparkEngine/Source/Core/SubsystemConsoleCommandsExt.cpp index fd6fc0fd3..c309ee5b6 100644 --- a/SparkEngine/Source/Core/SubsystemConsoleCommandsExt.cpp +++ b/SparkEngine/Source/Core/SubsystemConsoleCommandsExt.cpp @@ -468,11 +468,20 @@ namespace Spark { if (args.empty()) return "FOV: " + EngineSettings::GetInstance().GetValue("Game", "FieldOfView"); + float fovValue = 0.0f; + try + { + fovValue = std::stof(args[0]); + } + catch (const std::exception&) + { + return "Invalid FOV value. Expected a number."; + } EngineSettings::GetInstance().SetValue("Game", "FieldOfView", args[0]); // Also update the live camera auto* cam = EngineContext::Get() ? EngineContext::Get()->GetCamera() : nullptr; if (cam) - cam->Console_SetFOV(std::stof(args[0])); + cam->Console_SetFOV(fovValue); return "FOV set to " + args[0]; }, "Get/set field of view", "Game"); @@ -532,9 +541,12 @@ namespace Spark { auto& a = EngineSettings::GetInstance().Accessibility(); const char* cbNames[] = {"Off", "Protanopia", "Deuteranopia", "Tritanopia"}; + const int cbCount = static_cast(sizeof(cbNames) / sizeof(cbNames[0])); std::stringstream ss; ss << "Accessibility Settings:\n"; - ss << " Colorblind Mode: " << cbNames[a.colorblindMode] << "\n"; + ss << " Colorblind Mode: " + << (a.colorblindMode >= 0 && a.colorblindMode < cbCount ? cbNames[a.colorblindMode] : "Unknown") + << "\n"; ss << " High Contrast: " << (a.highContrast ? "on" : "off") << "\n"; ss << " Large Text: " << (a.largeText ? "on" : "off") << "\n"; ss << " Reduce Motion: " << (a.reduceMotion ? "on" : "off") << "\n"; @@ -593,14 +605,21 @@ namespace Spark auto& vr = EngineSettings::GetInstance().VR(); const char* trackNames[] = {"Seated", "Room Scale"}; const char* comfortNames[] = {"Off", "Vignette", "Snap Turn"}; + const int trackCount = static_cast(sizeof(trackNames) / sizeof(trackNames[0])); + const int comfortCount = static_cast(sizeof(comfortNames) / sizeof(comfortNames[0])); std::stringstream ss; ss << "VR Settings:\n"; ss << " Enabled: " << (vr.enabled ? "yes" : "no") << "\n"; ss << " Render Target: " << vr.renderTargetWidth << "x" << vr.renderTargetHeight << "\n"; ss << " Render Scale: " << vr.renderScale << "\n"; - ss << " Tracking: " << trackNames[vr.trackingSpace] << "\n"; + ss << " Tracking: " + << (vr.trackingSpace >= 0 && vr.trackingSpace < trackCount ? trackNames[vr.trackingSpace] + : "Unknown") + << "\n"; ss << " IPD: " << vr.ipd << " m\n"; - ss << " Comfort Mode: " << comfortNames[vr.comfortMode] << "\n"; + ss << " Comfort Mode: " + << (vr.comfortMode >= 0 && vr.comfortMode < comfortCount ? comfortNames[vr.comfortMode] : "Unknown") + << "\n"; ss << " Reprojection: " << (vr.reprojection ? "on" : "off") << "\n"; return ss.str(); }, @@ -613,7 +632,14 @@ namespace Spark auto& vr = EngineSettings::GetInstance().VR(); if (args.empty()) return "VR render scale: " + std::to_string(vr.renderScale); - vr.renderScale = std::clamp(std::stof(args[0]), 0.5f, 2.0f); + try + { + vr.renderScale = std::clamp(std::stof(args[0]), 0.5f, 2.0f); + } + catch (const std::exception&) + { + return "Invalid render scale value. Expected a number between 0.5 and 2.0."; + } return "VR render scale set to " + std::to_string(vr.renderScale); }, "Get/set VR supersampling scale (0.5-2.0)", "VR"); @@ -702,7 +728,14 @@ namespace Spark auto& sv = EngineSettings::GetInstance().SaveSystem(); if (args.empty()) return "Auto-save interval: " + std::to_string(static_cast(sv.autoSaveInterval)) + " s"; - sv.autoSaveInterval = std::clamp(std::stof(args[0]), 0.0f, 3600.0f); + try + { + sv.autoSaveInterval = std::clamp(std::stof(args[0]), 0.0f, 3600.0f); + } + catch (const std::exception&) + { + return "Invalid interval. Expected a number of seconds (0-3600)."; + } return "Auto-save interval set to " + std::to_string(static_cast(sv.autoSaveInterval)) + " s"; }, "Get/set auto-save interval (seconds, 0=disabled)", "SaveSystem"); diff --git a/SparkEngine/Source/Engine/Animation/SkeletalAnimation.cpp b/SparkEngine/Source/Engine/Animation/SkeletalAnimation.cpp index 765ec2b77..ec8d0afd6 100644 --- a/SparkEngine/Source/Engine/Animation/SkeletalAnimation.cpp +++ b/SparkEngine/Source/Engine/Animation/SkeletalAnimation.cpp @@ -149,6 +149,11 @@ namespace Spark::Animation if (boneIdx < 0) continue; } + // A cached channel.boneIndex may outlive the skeleton that produced it + // (retargeting, runtime skeleton swap, corrupted clip data). Bounds-check + // against the current outLocalTransforms size before indexing. + if (static_cast(boneIdx) >= outLocalTransforms.size()) + continue; XMFLOAT3 pos = channel.InterpolatePosition(animTime); XMFLOAT4 rot = channel.InterpolateRotation(animTime); diff --git a/SparkEngine/Source/Graphics/PostProcessingPipeline.cpp b/SparkEngine/Source/Graphics/PostProcessingPipeline.cpp index 0c0474ab8..2b7b74391 100644 --- a/SparkEngine/Source/Graphics/PostProcessingPipeline.cpp +++ b/SparkEngine/Source/Graphics/PostProcessingPipeline.cpp @@ -467,6 +467,12 @@ namespace Spark::Graphics // GPU Resource Creation // ============================================================================= +// Windows D3D11 implementations of the six functions below (CreatePingPongTargets, +// CreatePostProcessShaders, CompileEffectShaders, BeginPass, DrawFullscreen, +// ProcessPass) live in PostProcessingPipelineWindows.cpp. Emitting them here on +// Windows too would produce LNK4006 duplicate-definition warnings. +#ifndef SPARK_PLATFORM_WINDOWS + bool PostProcessingPipeline::CreatePingPongTargets() { #ifdef SPARK_PLATFORM_WINDOWS @@ -1527,4 +1533,6 @@ namespace Spark::Graphics m_passTimings[static_cast(pass)] = ms; } +#endif // !SPARK_PLATFORM_WINDOWS + } // namespace Spark::Graphics diff --git a/SparkEngine/Source/Graphics/RHI/Metal/MetalDevice.mm b/SparkEngine/Source/Graphics/RHI/Metal/MetalDevice.mm index e790500f7..5cfe58a2d 100644 --- a/SparkEngine/Source/Graphics/RHI/Metal/MetalDevice.mm +++ b/SparkEngine/Source/Graphics/RHI/Metal/MetalDevice.mm @@ -8,6 +8,7 @@ #include "MetalDevice.h" #include "../../../Utils/Logger.h" +#include "../../../Utils/LogMacros.h" #import #import @@ -1224,7 +1225,7 @@ void MetalDevice::BeginFrame() { - ++m_statistics.frameCount; + ResetStatistics(); } void MetalDevice::EndFrame() diff --git a/SparkEngine/Source/Physics/ClothSimulation.cpp b/SparkEngine/Source/Physics/ClothSimulation.cpp index 8dff382ac..eafb3eb6d 100644 --- a/SparkEngine/Source/Physics/ClothSimulation.cpp +++ b/SparkEngine/Source/Physics/ClothSimulation.cpp @@ -241,6 +241,10 @@ namespace Spark::Physics void ClothSimulation::IntegratePositions(ClothInstance& cloth, float deltaTime) { + // Skip integration on zero-delta ticks; the divide at the end would produce NaN velocities + // (0 position delta / 0 dt) that persist to downstream consumers. + if (deltaTime <= 1e-8f) + return; float dt2 = deltaTime * deltaTime; float dampFactor = 1.0f - cloth.damping; diff --git a/SparkInstaller/CMakeLists.txt b/SparkInstaller/CMakeLists.txt index 8f9f24c17..f8f93571e 100644 --- a/SparkInstaller/CMakeLists.txt +++ b/SparkInstaller/CMakeLists.txt @@ -84,8 +84,10 @@ if(INSTALLER_GUI_AVAILABLE) endif() if(MSVC) + # Match imgui's CRT linkage (cmake/BuildImGui.cmake uses the DLL CRT). + # Mismatched /MT vs /MD causes LNK2019 on __imp_* symbols from imgui.lib. set_property(TARGET SparkInstaller PROPERTY - MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") target_compile_definitions(SparkInstaller PRIVATE _CRT_SECURE_NO_WARNINGS NOMINMAX diff --git a/ThirdParty/SDL2 b/ThirdParty/SDL2 index e4f75bac4..859844eae 160000 --- a/ThirdParty/SDL2 +++ b/ThirdParty/SDL2 @@ -1 +1 @@ -Subproject commit e4f75bac451230d31695158ed35f459e7a04585c +Subproject commit 859844eae358447be8d66e6da59b6fb3df0ed778 diff --git a/ThirdParty/dependencies.lock b/ThirdParty/dependencies.lock index 138d62c38..b85608cbe 100644 --- a/ThirdParty/dependencies.lock +++ b/ThirdParty/dependencies.lock @@ -2,7 +2,7 @@ # Format: # "name|source|version_or_commit|license|local_path|required_files_csv|feature_macro|fallback_or_stub_path|severity" # severity: ERROR (configure-time fatal), WARN (configure-time warning) -# Last sync: 2026-04-16 (SparkDaemon subproject added: `add_subdirectory(SparkDaemon)` in top-level CMakeLists.txt introduces the new first-party SparkDaemon executable + `SparkEngine/Source/Utils/DaemonLifecycle*` engine wiring. No third-party wiring, no vendored deps, no submodule pointer changes — the manifest-sync regex trips on `CMAKE_SOURCE_DIR` ("_DIR" token) inside the new subproject gate. Previously — 2026-04-12 Platform split: CMakeLists.txt SPARK_ENGINE_ENTRY_POINT changed to SPARK_ENGINE_ENTRY_POINTS list for SparkEngine.cpp/Windows/Linux three-file split — no third-party wiring or vendored dependency changes.) +# Last sync: 2026-04-20 (SDL2 branch pin + CMake split: `.gitmodules` adds `branch = SDL2` hint for libsdl-org/SDL so Dependabot stays on the SDL2 line rather than auto-bumping to SDL3, and CMakeLists.txt SDL2 config block now splits Linux/macOS options (X11 off on Apple, Objective-C/Objective-C++ enabled via project() so SDL2's cocoa .m sources compile) and copies SDL2's public headers at configure time to avoid a build-order race with the `sdl_headers_copy` target. Submodule pointer unchanged (859844eae, SDL 2.30.0). Previously — 2026-04-16 (SparkDaemon subproject added: `add_subdirectory(SparkDaemon)` in top-level CMakeLists.txt introduces the new first-party SparkDaemon executable + `SparkEngine/Source/Utils/DaemonLifecycle*` engine wiring. No third-party wiring, no vendored deps, no submodule pointer changes — the manifest-sync regex trips on `CMAKE_SOURCE_DIR` ("_DIR" token) inside the new subproject gate. Previously — 2026-04-12 Platform split: CMakeLists.txt SPARK_ENGINE_ENTRY_POINT changed to SPARK_ENGINE_ENTRY_POINTS list for SparkEngine.cpp/Windows/Linux three-file split — no third-party wiring or vendored dependency changes.) set(SPARK_THIRDPARTY_AUDIT_ENTRIES "Jolt Physics|https://github.com/jrouwe/JoltPhysics|v5.5.1 (vendored snapshot)|MIT|ThirdParty/Physics/JoltPhysics|Jolt/Jolt.h,Build/CMakeLists.txt|SPARK_JOLT_PHYSICS_AVAILABLE|SparkEngine/Source/Physics/PhysicsSystemStub.cpp|ERROR"