diff --git a/.github/workflows/linux_build.yml b/.github/workflows/linux_build.yml index 48c9eb61..8d176057 100644 --- a/.github/workflows/linux_build.yml +++ b/.github/workflows/linux_build.yml @@ -40,10 +40,11 @@ jobs: run: | mkdir torch-release mv build-cmake/torch torch-release/ + mv build-cmake/torch-lus-assets.o2r torch-release/ cp "$(ldconfig -p | grep libbz2.so.1.0 | tr ' ' '\n' | grep /| head -n1)" torch-release/ - name: Publish packaged artifacts if: ${{ matrix.config == 'Release' && matrix.standalone == 'ON' && matrix.toolchain == 'linux-gnu' }} uses: actions/upload-artifact@v4 with: name: torch-linux-x64 - path: torch-release \ No newline at end of file + path: torch-release diff --git a/.github/workflows/mac_build.yml b/.github/workflows/mac_build.yml index aa6bfea6..6c65eac1 100644 --- a/.github/workflows/mac_build.yml +++ b/.github/workflows/mac_build.yml @@ -48,6 +48,7 @@ jobs: run: | mkdir torch-release mv build-cmake/torch torch-release/ + mv build-cmake/torch-lus-assets.o2r torch-release/ - name: Publish packaged artifacts if: ${{ matrix.config == 'Release' && matrix.standalone == 'ON' && matrix.toolchain == 'macos-clang' }} uses: actions/upload-artifact@v4 diff --git a/.github/workflows/windows_build.yml b/.github/workflows/windows_build.yml index 97850a2b..a7dbbe45 100644 --- a/.github/workflows/windows_build.yml +++ b/.github/workflows/windows_build.yml @@ -67,5 +67,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: torch-windows-${{ matrix.arch }}-${{ matrix.toolchain }} - path: build/${{ matrix.arch }}/Release/torch.exe - if-no-files-found: error \ No newline at end of file + path: | + build/${{ matrix.arch }}/Release/torch.exe + build/${{ matrix.arch }}/Release/torch-lus-assets.o2r + if-no-files-found: error diff --git a/CMakeLists.txt b/CMakeLists.txt index 8c8f2271..11a2311b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -117,6 +117,8 @@ if(BUILD_UI) ) FetchContent_MakeAvailable(libultraship) + find_package(Python3 REQUIRED COMPONENTS Interpreter) + include_directories( ${libultraship_SOURCE_DIR}/include ${libultraship_SOURCE_DIR}/include/libultraship @@ -127,11 +129,22 @@ if(BUILD_UI) # The Fast3D Metal/GL shaders are loaded as resources ("shaders/...") and must # match THIS libultraship's prism context. Game .o2r archives ship their own - # (often fork-modified) shaders, so copy the upstream LUS shaders next to the - # build and mount them last (overriding) at runtime. See LusBackend.cpp. - file(COPY ${libultraship_SOURCE_DIR}/src/fast/shaders - DESTINATION ${CMAKE_BINARY_DIR}/torch-lus-assets) - add_compile_definitions(TORCH_LUS_SHADER_DIR="${CMAKE_BINARY_DIR}/torch-lus-assets") + # (often fork-modified) shaders, so package the upstream LUS shaders and mount + # them last (overriding) at runtime. A file archive also avoids LUS's fragile + # Windows folder-archive path handling. See LusBackend.cpp. + set(TORCH_LUS_SHADER_ROOT ${libultraship_SOURCE_DIR}/src/fast) + set(TORCH_LUS_SHADER_ARCHIVE ${CMAKE_BINARY_DIR}/generated/torch-lus-assets.o2r) + file(GLOB_RECURSE TORCH_LUS_SHADER_FILES CONFIGURE_DEPENDS + LIST_DIRECTORIES false ${TORCH_LUS_SHADER_ROOT}/shaders/*) + add_custom_command( + OUTPUT ${TORCH_LUS_SHADER_ARCHIVE} + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/cmake/package_lus_shaders.py + --source ${TORCH_LUS_SHADER_ROOT} + --output ${TORCH_LUS_SHADER_ARCHIVE} + DEPENDS ${TORCH_LUS_SHADER_FILES} ${CMAKE_CURRENT_SOURCE_DIR}/cmake/package_lus_shaders.py + COMMENT "Packaging libultraship shaders" + VERBATIM + ) endif() # Source files @@ -234,6 +247,21 @@ else() set(LINK_TYPE "MT") endif() +if(BUILD_UI) + add_custom_target(torch_lus_assets + DEPENDS ${TORCH_LUS_SHADER_ARCHIVE} + ) + add_dependencies(${PROJECT_NAME} torch_lus_assets) + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${TORCH_LUS_SHADER_ARCHIVE} + $/torch-lus-assets.o2r + COMMENT "Installing libultraship shaders next to Torch" + VERBATIM + ) +endif() + if (BUILD_STORMLIB) add_definitions(-DUSE_STORMLIB) endif() diff --git a/README.md b/README.md index a582bcbc..dbd2a934 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,9 @@ A generic asset processor for games `./torch otr baserom.z64` `./torch code baserom.z64` +Viewer builds (`-DBUILD_UI=ON`) also produce `torch-lus-assets.o2r`. Keep that +shader archive beside the Torch executable when copying or packaging it. + # Windows ## Visual Studio diff --git a/cmake/package_lus_shaders.py b/cmake/package_lus_shaders.py new file mode 100644 index 00000000..dddd5141 --- /dev/null +++ b/cmake/package_lus_shaders.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 + +import argparse +from pathlib import Path +import zipfile + + +FIXED_TIMESTAMP = (2000, 1, 1, 0, 0, 0) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Package libultraship shaders reproducibly.") + parser.add_argument("--source", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + + source = args.source.resolve() + shaders = source / "shaders" + files = sorted(path for path in shaders.rglob("*") if path.is_file()) + if not files: + parser.error(f"no shader files found below {shaders}") + + output = args.output.resolve() + output.parent.mkdir(parents=True, exist_ok=True) + temporary = output.with_suffix(output.suffix + ".tmp") + + try: + with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_STORED) as archive: + for path in files: + name = path.relative_to(source).as_posix() + entry = zipfile.ZipInfo(name, FIXED_TIMESTAMP) + entry.create_system = 3 + entry.external_attr = 0o100644 << 16 + archive.writestr(entry, path.read_bytes()) + temporary.replace(output) + finally: + temporary.unlink(missing_ok=True) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/main.cpp b/src/main.cpp index 561accae..de5937fb 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -419,35 +419,37 @@ int main(int argc, char* argv[]) { }); #endif + try { #ifdef BUILD_UI - // Default behavior with no subcommand: if the cwd looks like a project (has a - // config.yml), open the viewer and prompt for a ROM instead of printing help. - if (argc == 1 && std::filesystem::exists("config.yml")) { - const std::string rom = PromptForRom(); - if (rom.empty()) { - std::cout << app.help() << std::endl; - return 0; - } - if (!std::filesystem::exists(rom)) { - std::cout << "ROM not found: " << rom << std::endl; - return 1; - } - Companion::Instance = new Companion(rom, ArchiveType::None, false, "", ""); + // Default behavior with no subcommand: if the cwd looks like a project (has a + // config.yml), open the viewer and prompt for a ROM instead of printing help. + if (argc == 1 && std::filesystem::exists("config.yml")) { + const std::string rom = PromptForRom(); + if (rom.empty()) { + std::cout << app.help() << std::endl; + return 0; + } + if (!std::filesystem::exists(rom)) { + std::cout << "ROM not found: " << rom << std::endl; + return 1; + } + Companion::Instance = new Companion(rom, ArchiveType::None, false, "", ""); #ifdef PM64_SUPPORT - PM64Audio::SetPreviewAssets(true); + PM64Audio::SetPreviewAssets(true); #endif - std::atomic assetCount{ 0 }; - Companion::Instance->Init(ExportType::Binary, assetCount, false); - LaunchUI(); - return 0; - } + std::atomic assetCount{ 0 }; + Companion::Instance->Init(ExportType::Binary, assetCount, false); + LaunchUI(); + return 0; + } #endif - - try { app.parse(argc, argv); } catch (const CLI::ParseError& e) { std::cout << app.help() << std::endl; return app.exit(e); + } catch (const std::exception& e) { + std::cerr << "Torch failed: " << e.what() << std::endl; + return 1; } // No arguments --> display help. diff --git a/src/ui/backends/LusBackend.cpp b/src/ui/backends/LusBackend.cpp index d25cede3..39adb355 100644 --- a/src/ui/backends/LusBackend.cpp +++ b/src/ui/backends/LusBackend.cpp @@ -3,7 +3,10 @@ #include "ui/backends/LusBackend.h" #include +#include +#include #include +#include #include "Companion.h" #include "ui/Theme.h" @@ -87,18 +90,116 @@ class ViewerControlDeck final : public Ship::ControlDeck { class ViewerGui final : public Fast::Fast3dGui { public: using Fast::Fast3dGui::Fast3dGui; + + // This LUS revision destroys its renderer before the base Window destructor + // shuts ImGui down. Release the platform/render backends while both the + // Context and renderer are still alive; the guarded overrides let the base + // destructor finish by destroying only the ImGui context. + void PrepareForShutdown() { + if (!mBackendsShutDown) { + Fast::Fast3dGui::ImGuiWMShutdown(); + Fast::Fast3dGui::ImGuiBackendShutdown(); + mBackendsShutDown = true; + } + } + void DrawGame() override { } + + protected: + void ImGuiWMShutdown() override { + if (!mBackendsShutDown) { + Fast::Fast3dGui::ImGuiWMShutdown(); + } + } + + void ImGuiBackendShutdown() override { + if (!mBackendsShutDown) { + Fast::Fast3dGui::ImGuiBackendShutdown(); + } + } + + private: + bool mBackendsShutDown = false; +}; + +class ViewerShutdownGuard final { + public: + explicit ViewerShutdownGuard(std::shared_ptr gui) : mGui(std::move(gui)) { + } + + ~ViewerShutdownGuard() { + mGui->PrepareForShutdown(); + } + + private: + std::shared_ptr mGui; +}; + +void ValidateShaderArchive(const std::string& path) { + int errorCode = 0; + std::unique_ptr archive(zip_open(path.c_str(), ZIP_RDONLY, &errorCode), + &zip_discard); + if (!archive) { + throw std::runtime_error("Torch viewer shader archive could not be opened: " + path); + } + + constexpr const char* kRequiredShaders[] = { + "shaders/directx/default.shader.hlsl", + "shaders/metal/default.shader.metal", + "shaders/opengl/default.shader.glsl", + }; + for (const char* shader : kRequiredShaders) { + if (zip_name_locate(archive.get(), shader, 0) < 0) { + throw std::runtime_error("Torch viewer shader archive is incomplete: " + path + " (missing " + shader + + ")"); + } + } +} + +void AbandonPartiallyInitializedContext(std::shared_ptr context) { + // This pinned LUS Context destructor assumes a Window exists. A startup + // exception is terminal, so retain the incomplete Context until process exit + // and let main report the original error instead of crashing in teardown. + static auto* abandonedContexts = new std::vector>(); + abandonedContexts->push_back(std::move(context)); +} + +class ContextStartupGuard final { + public: + explicit ContextStartupGuard(std::shared_ptr& context) : mContext(context) { + } + + ~ContextStartupGuard() { + if (mArmed) { + AbandonPartiallyInitializedContext(std::move(mContext)); + } + } + + void Release() { + mArmed = false; + } + + private: + std::shared_ptr& mContext; + bool mArmed = true; }; class LusBackend final : public BaseBackend { public: void RunViewer(const std::shared_ptr& views) override { + const auto shaderArchive = Ship::Context::GetPathRelativeToAppBundle("torch-lus-assets.o2r"); + if (!std::filesystem::is_regular_file(shaderArchive)) { + throw std::runtime_error("Torch viewer shader archive is missing: " + shaderArchive); + } + ValidateShaderArchive(shaderArchive); + auto ctx = Ship::Context::CreateUninitializedInstance("Torch", "torch", "torch.cfg.json"); - ctx->InitConfiguration(); - ctx->InitConsoleVariables(); - ctx->InitLogging(); - ctx->InitControlDeck(std::make_shared()); + ContextStartupGuard contextStartupGuard(ctx); + if (!ctx->InitConfiguration() || !ctx->InitConsoleVariables() || !ctx->InitLogging() || + !ctx->InitControlDeck(std::make_shared())) { + throw std::runtime_error("Torch viewer could not initialize its LUS context"); + } // Mount the .o2r archives from the working directory so Fast3D can // resolve the resources referenced by the previewed assets. @@ -109,16 +210,15 @@ class LusBackend final : public BaseBackend { archives.push_back(entry.path().string()); } } -#ifdef TORCH_LUS_SHADER_DIR // Mount the upstream LUS shaders last so they override any fork-modified // shaders shipped inside the game archives (last archive added wins). - if (std::filesystem::exists(TORCH_LUS_SHADER_DIR)) { - archives.push_back(TORCH_LUS_SHADER_DIR); + archives.push_back(shaderArchive); + if (!ctx->InitResourceManager(archives, {}, 1)) { + throw std::runtime_error("Torch viewer could not initialize its resource manager"); + } + if (!ctx->InitConsole() || !ctx->InitAudio(Ship::AudioSettings{})) { + throw std::runtime_error("Torch viewer could not initialize its LUS services"); } -#endif - ctx->InitResourceManager(archives, {}, 1); - ctx->InitConsole(); - ctx->InitAudio(Ship::AudioSettings{}); // Fast3D binary resource factories (DisplayList/Vertex/Texture/Matrix/Light). auto loader = ctx->GetResourceManager()->GetResourceLoader(); @@ -142,8 +242,14 @@ class LusBackend final : public BaseBackend { // Context::GetWindow(). auto gui = std::make_shared(std::vector>{}); auto window = std::make_shared(gui); - ctx->InitWindow(window); - ctx->InitEventSystem(); + if (!ctx->InitWindow(window)) { + throw std::runtime_error("Torch viewer could not initialize its window"); + } + ViewerShutdownGuard shutdownGuard(gui); + contextStartupGuard.Release(); + if (!ctx->InitEventSystem()) { + throw std::runtime_error("Torch viewer could not initialize its event system"); + } window->GetGui()->AddGuiWindow(std::make_shared(views)); @@ -159,6 +265,9 @@ class LusBackend final : public BaseBackend { static Gfx emptyDl[] = { gsSPEndDisplayList() }; while (window->IsRunning()) { window->HandleEvents(); + if (!window->IsRunning()) { + break; + } PumpAudio(); // Render the previous frame's preview requests; the gui draw blits