diff options
| author | Lywx <kiritodev01@gmail.com> | 2026-07-02 20:42:38 -0600 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-02 20:42:38 -0600 |
| commit | 58527d7bf8778bc67179454a19ad8c84a74aff13 (patch) | |
| tree | 37df9df3eb558977b84b5a33b8143f3859c5d057 | |
| parent | b42a4fa16f7a75e2a96a7c107e6359c80b685863 (diff) | |
Implemented UI Framework (#225)
* Implemented base renderer among main UI framework implementation
* Implemented basic geo layout rendering
* Implemented animation support among fixing lightning among other things
* Implemented multiple ui factories for sm64
* Implemented first iteration of the custom sequence driver
* Added export buttons on multiple factories
* More sequence player fixes among f3dex2 working correctly
* Implemented pm64 viewers
* Implemented audio support for pm64
* Fully implemented pm64 factories
* Implemented more sm64 factories
* Added tons of missing sf64 factories
* Implemented all assets among sf64 wrong padding
* Run formatting
* Fixed wrong spacing on gfx too
* Fixed CI
* Fixed bad pipeline
97 files changed, 12770 insertions, 347 deletions
diff --git a/.github/workflows/linux_build.yml b/.github/workflows/linux_build.yml index 702766a..f5f35c7 100644 --- a/.github/workflows/linux_build.yml +++ b/.github/workflows/linux_build.yml @@ -22,10 +22,18 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install dependencies - run: sudo apt-get install cmake ninja-build libbz2-dev + run: | + sudo apt-get update + # Base build tools plus the libultraship/Fast3D deps the interactive + # viewer (BUILD_UI) needs: SDL2, OpenGL, libzip and nlohmann-json. + # (tinyxml2 and spdlog are vendored via FetchContent.) + sudo apt-get install -y cmake ninja-build libbz2-dev \ + libsdl2-dev libgl1-mesa-dev libopengl-dev libzip-dev nlohmann-json3-dev - name: Build run: | - cmake -H. -Bbuild-cmake -GNinja -DCMAKE_BUILD_TYPE=${{ matrix.config }} -DUSE_STANDALONE=${{ matrix.standalone }} -DCMAKE_TOOLCHAIN_FILE=cmake/toolchain/${{ matrix.toolchain }}.cmake + # Enable the viewer in CI (BUILD_UI is off by default); both Linux + # toolchains build the GL backend. + cmake -H. -Bbuild-cmake -GNinja -DCMAKE_BUILD_TYPE=${{ matrix.config }} -DUSE_STANDALONE=${{ matrix.standalone }} -DBUILD_UI=ON -DCMAKE_TOOLCHAIN_FILE=cmake/toolchain/${{ matrix.toolchain }}.cmake cmake --build build-cmake -j - name: Create Package if: ${{ matrix.config == 'Release' && matrix.standalone == 'ON' && matrix.toolchain == 'linux-gnu' }} diff --git a/.github/workflows/mac_build.yml b/.github/workflows/mac_build.yml index 6b1d170..d3dcaa4 100644 --- a/.github/workflows/mac_build.yml +++ b/.github/workflows/mac_build.yml @@ -22,7 +22,11 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install dependencies - run: brew install cmake ninja xcodes + run: | + # Base tools plus the libultraship/Fast3D deps the interactive viewer + # (BUILD_UI) needs: SDL2, GLEW, libzip and nlohmann-json. The mac + # backend is Metal (metal-cpp is fetched), so no Vulkan is required. + brew install cmake ninja xcodes sdl2 glew libzip nlohmann-json - name: Download llvm if: ${{ matrix.toolchain == 'macos-clang-llvm' }} run: | @@ -35,7 +39,9 @@ jobs: echo "/opt/homebrew/opt/gcc/bin" >> $GITHUB_PATH - name: Build run: | - cmake -H. -Bbuild-cmake -GNinja -DCMAKE_BUILD_TYPE=${{ matrix.config }} -DUSE_STANDALONE=${{ matrix.standalone }} -DCMAKE_TOOLCHAIN_FILE=cmake/toolchain/${{ matrix.toolchain }}.cmake + # Enable the viewer in CI (BUILD_UI is off by default), except on the gcc + # toolchain: the Metal backend needs clang/Objective-C++. + cmake -H. -Bbuild-cmake -GNinja -DCMAKE_BUILD_TYPE=${{ matrix.config }} -DUSE_STANDALONE=${{ matrix.standalone }} -DBUILD_UI=${{ matrix.toolchain == 'macos-gnu' && 'OFF' || 'ON' }} -DCMAKE_TOOLCHAIN_FILE=cmake/toolchain/${{ matrix.toolchain }}.cmake cmake --build build-cmake -j - name: Create Package if: ${{ matrix.config == 'Release' && matrix.standalone == 'ON' && matrix.toolchain == 'macos-clang' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05c5b7e..d69d6dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,11 +16,13 @@ jobs: - name: Install Linux Dependencies run: | sudo apt-get update - sudo apt-get install -y cmake ninja-build libbz2-dev + # Includes the libultraship/Fast3D deps for the interactive viewer. + sudo apt-get install -y cmake ninja-build libbz2-dev \ + libsdl2-dev libgl1-mesa-dev libopengl-dev libzip-dev nlohmann-json3-dev - name: Configure and Build Linux Binaries run: | - cmake -H. -Bbuild-cmake -GNinja -DCMAKE_BUILD_TYPE=Release + cmake -H. -Bbuild-cmake -GNinja -DCMAKE_BUILD_TYPE=Release -DBUILD_UI=ON cmake --build build-cmake -j - name: Publish Linux Release @@ -72,11 +74,12 @@ jobs: - name: Install macOS Dependencies run: | - brew install cmake ninja + # Includes the libultraship/Fast3D deps for the interactive viewer. + brew install cmake ninja sdl2 glew libzip nlohmann-json - name: Configure and Build macOS Binaries run: | - cmake -H. -Bbuild-cmake -GNinja -DCMAKE_BUILD_TYPE=Release + cmake -H. -Bbuild-cmake -GNinja -DCMAKE_BUILD_TYPE=Release -DBUILD_UI=ON cmake --build build-cmake -j - name: Publish macOS Release diff --git a/.github/workflows/windows_build.yml b/.github/workflows/windows_build.yml index 6afa6f9..9c9d964 100644 --- a/.github/workflows/windows_build.yml +++ b/.github/workflows/windows_build.yml @@ -18,20 +18,21 @@ jobs: arch: [x64, Win32] isPR: - ${{ github.event_name == 'pull_request' }} - exclude: + exclude: - toolchain: windows-clang-cl arch: Win32 - isPR: true standalone: OFF - exclude: - - toolchain: windows-clang-cl - arch: Win32 steps: - uses: actions/checkout@v4 - name: Build Visual Studio if: ${{ matrix.generate == 'Visual Studio 17 2022' }} run: | - cmake -S . -B "build/${{ matrix.arch }}" -G "${{ matrix.generate }}" -DCMAKE_TOOLCHAIN_FILE="cmake/toolchain/${{ matrix.toolchain }}.cmake" -DCMAKE_GENERATOR_PLATFORM=${{ matrix.arch }} -DCMAKE_BUILD_TYPE:STRING=${{ matrix.config }} -DUSE_STANDALONE=${{ matrix.standalone }} + # Enable the viewer in CI (BUILD_UI is off by default), except on Win32: + # libultraship is x64-only. Its automate-vcpkg bootstraps the runner's + # vcpkg to fetch SDL2/GLEW/libzip/nlohmann-json/tinyxml2/spdlog. + $env:VCPKG_ROOT=$env:VCPKG_INSTALLATION_ROOT + cmake -S . -B "build/${{ matrix.arch }}" -G "${{ matrix.generate }}" -DCMAKE_TOOLCHAIN_FILE="cmake/toolchain/${{ matrix.toolchain }}.cmake" -DCMAKE_GENERATOR_PLATFORM=${{ matrix.arch }} -DCMAKE_BUILD_TYPE:STRING=${{ matrix.config }} -DUSE_STANDALONE=${{ matrix.standalone }} -DBUILD_UI=${{ matrix.arch == 'Win32' && 'OFF' || 'ON' }} -DUSE_AUTO_VCPKG=ON -DVCPKG_TARGET_TRIPLET=x64-windows-static cmake --build ./build/${{ matrix.arch }} - name: Setup Variables if: ${{ matrix.generate == 'Ninja' }} @@ -41,7 +42,11 @@ jobs: - name: Build Ninja if: ${{ matrix.generate == 'Ninja' }} run: | - cmake -S . -B "build/${{ matrix.arch }}" -G "${{ matrix.generate }}" -DCMAKE_TOOLCHAIN_FILE="cmake/toolchain/${{ matrix.toolchain }}.cmake" -DCMAKE_BUILD_TYPE:STRING=${{ matrix.config }} -DUSE_STANDALONE=${{ matrix.standalone }} + # Enable the viewer in CI, off on Win32 (libultraship is x64-only). Ninja + # is single-platform, so name the vcpkg triplet explicitly (there is no + # CMAKE_VS_PLATFORM_NAME for automate-vcpkg to key off). + $env:VCPKG_ROOT=$env:VCPKG_INSTALLATION_ROOT + cmake -S . -B "build/${{ matrix.arch }}" -G "${{ matrix.generate }}" -DCMAKE_TOOLCHAIN_FILE="cmake/toolchain/${{ matrix.toolchain }}.cmake" -DCMAKE_BUILD_TYPE:STRING=${{ matrix.config }} -DUSE_STANDALONE=${{ matrix.standalone }} -DBUILD_UI=${{ matrix.arch == 'Win32' && 'OFF' || 'ON' }} -DUSE_AUTO_VCPKG=ON -DVCPKG_TARGET_TRIPLET=x64-windows-static cmake --build ./build/${{ matrix.arch }} - name: Publish packaged artifacts if: ${{ matrix.config == 'Release' && matrix.generate == 'Visual Studio 17 2022' && matrix.standalone == 'ON' }} diff --git a/CMakeLists.txt b/CMakeLists.txt index d63295d..81c9bcb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,6 +23,8 @@ option(BUILD_BK64 "Build with Banjo Kazooie support" ON) option(BUILD_MARIO_ARTIST "Build with Mario Artist support" ON) option(BUILD_NAUDIO "Build with NAudio support" ON) +option(BUILD_UI "Build with the interactive viewer (libultraship + Fast3D + ImGui)" OFF) + if(EMSCRIPTEN) set(BUILD_SM64 OFF) # TODO: This is broken for some reason add_definitions(-D__EMSCRIPTEN__) @@ -67,6 +69,61 @@ if(USE_STANDALONE) endforeach() endif() endif() + +# Interactive viewer renderer: libultraship + Fast3D (true N64 rendering). LUS +# bundles its own ImGui, so it fully replaces the old raylib/rlImGui backend. +if(BUILD_UI) + add_definitions(-DBUILD_UI) + + # libultraship integration. Recipe mirrors the Ghostship port. + set(EXCLUDE_MPQ_SUPPORT TRUE CACHE BOOL "") + add_compile_definitions(EXCLUDE_MPQ_SUPPORT) + set(ENABLE_SCRIPTING OFF CACHE BOOL "" FORCE) + + # tinyxml2 must resolve before LUS's find_package(tinyxml2 REQUIRED). + set(tinyxml2_BUILD_TESTING OFF) + FetchContent_Declare( + tinyxml2 + GIT_REPOSITORY https://github.com/leethomason/tinyxml2.git + GIT_TAG 10.0.0 + OVERRIDE_FIND_PACKAGE + ) + FetchContent_MakeAvailable(tinyxml2) + + FetchContent_Declare( + dr_libs + GIT_REPOSITORY https://github.com/mackron/dr_libs.git + GIT_TAG da35f9d6c7374a95353fd1df1d394d44ab66cf01 + ) + FetchContent_MakeAvailable(dr_libs) + + # Kenix3 upstream for now. NOTE: the HM64 ports build against a fork + # (KiritoDv/better-mipmaps @ 5959b97) with fixes not on upstream; re-pin here + # if upstream proves incompatible. + FetchContent_Declare( + libultraship + GIT_REPOSITORY https://github.com/Kenix3/libultraship.git + GIT_TAG main + ) + FetchContent_MakeAvailable(libultraship) + + include_directories( + ${libultraship_SOURCE_DIR}/include + ${libultraship_SOURCE_DIR}/include/libultraship + ${libultraship_SOURCE_DIR}/src + ${dr_libs_SOURCE_DIR} + ${imgui_SOURCE_DIR} + ) + + # 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") +endif() + # Source files include_directories(${CMAKE_CURRENT_SOURCE_DIR}) @@ -74,7 +131,15 @@ include_directories(${CMAKE_CURRENT_SOURCE_DIR}/lib) include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src) file(GLOB_RECURSE CXX_FILES ${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/**/*.cpp ${CMAKE_CURRENT_SOURCE_DIR}/lib/strhash64/*.cpp ${CMAKE_CURRENT_SOURCE_DIR}/lib/bk_zip/*.cpp) file(GLOB C_FILES ${CMAKE_CURRENT_SOURCE_DIR}/src/*.c ${CMAKE_CURRENT_SOURCE_DIR}/src/**/*.c ${CMAKE_CURRENT_SOURCE_DIR}/lib/libmio0/*.c ${CMAKE_CURRENT_SOURCE_DIR}/lib/libyay0/*.c) -set(SRC_DIR ${CXX_FILES} ${C_FILES} ${LGFXD_FILES}) +set(SRC_DIR ${CXX_FILES} ${C_FILES} ${LGFXD_FILES} ${UI_FILES}) + +if(BUILD_UI) + # libultraship bundles StringHelper and StrHash64 (shared origin with Torch). + # Linking it WHOLE_ARCHIVE pulls those in, so drop our copies and resolve + # against LUS's (a superset) to avoid duplicate-symbol link errors. + list(FILTER SRC_DIR EXCLUDE REGEX "src/utils/StringHelper.cpp") + list(FILTER SRC_DIR EXCLUDE REGEX "lib/strhash64/StrHash64.cpp") +endif() if(BUILD_SM64) add_definitions(-DSM64_SUPPORT) @@ -326,6 +391,11 @@ FetchContent_MakeAvailable(zlib) target_link_libraries(${PROJECT_NAME} PRIVATE tinyxml2 zlibstatic yaml-cpp N64Graphics BinaryTools) +if(BUILD_UI) + target_link_libraries(${PROJECT_NAME} PRIVATE "$<LINK_LIBRARY:WHOLE_ARCHIVE,libultraship>") + set_target_properties(${PROJECT_NAME} PROPERTIES ENABLE_EXPORTS TRUE) +endif() + if(NOT USE_STANDALONE) target_include_directories(${PROJECT_NAME} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(${PROJECT_NAME} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/lib) @@ -41,6 +41,13 @@ type:=debug generator:=$(shell if [ -x "$$(which ninja)" ]; then echo "Ninja"; else echo "Unix Makefiles"; fi) build_path:=$(shell if [ "$(call to_lower_case,$(generator))" = "xcode" ]; then echo "xcode-build"; else echo "cmake-build-$(call to_lower_case,$(type))"; fi) +# Extra -D options forwarded to CMake configure, e.g. cmake_args="-DBUILD_STORMLIB=ON" +cmake_args:= +# Convenience passthrough: `make BUILD_UI=ON` -> `-DBUILD_UI=ON` +ifdef BUILD_UI +override cmake_args += -DBUILD_UI=$(BUILD_UI) +endif + # ============================================================================= # Constants # ============================================================================= @@ -154,11 +161,13 @@ init: configure: validate-type validate-generator init ifeq ($(cmake_generator_arg), Xcode) @cmake -S . -B $(build_path) \ - -G "$(cmake_generator_arg)"; + -G "$(cmake_generator_arg)" \ + $(cmake_args); else @cmake -S . -B $(build_path) \ -DCMAKE_BUILD_TYPE=$(cmake_build_type_arg) \ - -G "$(cmake_generator_arg)"; + -G "$(cmake_generator_arg)" \ + $(cmake_args); endif #: Build the project for the selected type and generator diff --git a/lib/n64graphics/CMakeLists.txt b/lib/n64graphics/CMakeLists.txt index 3e2386b..797b3a8 100644 --- a/lib/n64graphics/CMakeLists.txt +++ b/lib/n64graphics/CMakeLists.txt @@ -7,4 +7,26 @@ file(GLOB CXX_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.c) set(SRC_DIR ${CXX_FILES}) add_library(${PROJECT_NAME} STATIC ${SRC_DIR}) -target_include_directories(${PROJECT_NAME} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
\ No newline at end of file +target_include_directories(${PROJECT_NAME} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + +if(BUILD_UI) + target_compile_definitions(${PROJECT_NAME} PRIVATE + stbi_write_png=n64g_stbi_write_png + stbi_write_bmp=n64g_stbi_write_bmp + stbi_write_tga=n64g_stbi_write_tga + stbi_write_hdr=n64g_stbi_write_hdr + stbi_write_jpg=n64g_stbi_write_jpg + stbi_write_png_to_func=n64g_stbi_write_png_to_func + stbi_write_bmp_to_func=n64g_stbi_write_bmp_to_func + stbi_write_tga_to_func=n64g_stbi_write_tga_to_func + stbi_write_hdr_to_func=n64g_stbi_write_hdr_to_func + stbi_write_jpg_to_func=n64g_stbi_write_jpg_to_func + stbi_write_png_to_mem=n64g_stbi_write_png_to_mem + stbi_flip_vertically_on_write=n64g_stbi_flip_vertically_on_write + stbiw_convert_wchar_to_utf8=n64g_stbiw_convert_wchar_to_utf8 + stbi_zlib_compress=n64g_stbi_zlib_compress + stbi_write_tga_with_rle=n64g_stbi_write_tga_with_rle + stbi_write_png_compression_level=n64g_stbi_write_png_compression_level + stbi_write_force_png_filter=n64g_stbi_write_force_png_filter + ) +endif()
\ No newline at end of file diff --git a/src/Companion.cpp b/src/Companion.cpp index 40899ec..59d42a1 100644 --- a/src/Companion.cpp +++ b/src/Companion.cpp @@ -33,6 +33,7 @@ #ifdef SM64_SUPPORT #include "factories/sm64/AnimationFactory.h" +#include "factories/sm64/TextPreview.h" #include "factories/sm64/BehaviorScriptFactory.h" #include "factories/sm64/CollisionFactory.h" #include "factories/sm64/DialogFactory.h" @@ -66,6 +67,7 @@ #include "factories/sf64/MessageFactory.h" #include "factories/sf64/MessageLookupFactory.h" #include "factories/sf64/SkeletonFactory.h" +#include "factories/sf64/SF64Preview.h" #include "factories/sf64/AnimFactory.h" #include "factories/sf64/ScriptFactory.h" #include "factories/sf64/HitboxFactory.h" @@ -78,6 +80,8 @@ #include "factories/pm64/SpriteFactory.h" #include "factories/pm64/ShapeFactory.h" #include "factories/pm64/BackgroundFactory.h" +#include "factories/pm64/AudioPreview.h" +#include "factories/pm64/ImagePreview.h" #include "factories/pm64/CollisionFactory.h" #include "factories/pm64/MapTextureFactory.h" #include "factories/pm64/AudioFactory.h" @@ -119,6 +123,11 @@ #include "factories/naudio/v0/BankFactory.h" #include "factories/naudio/v0/SampleFactory.h" #include "factories/naudio/v0/SequenceFactory.h" +#ifdef BUILD_UI +#include "factories/naudio/v0/SequencePlayerV0.h" +#include "factories/naudio/v1/SequencePlayerV1.h" +#include "ui/audio/SequenceDriver.h" +#endif #include "factories/naudio/v1/AudioContext.h" #include "factories/naudio/v1/SoundFontFactory.h" @@ -217,6 +226,8 @@ void Companion::Init(const ExportType type, std::atomic<size_t>& assetCount, boo this->RegisterFactory("PM64:SPRITE", std::make_shared<PM64SpriteFactory>()); this->RegisterFactory("PM64:SHAPE", std::make_shared<PM64ShapeFactory>()); this->RegisterFactory("PM64:BACKGROUND", std::make_shared<PM64BackgroundFactory>()); + this->RegisterFactory("PM64:BGM", std::make_shared<PM64BgmFactory>()); + this->RegisterFactory("PM64:BK_SAMPLE", std::make_shared<PM64BkSampleFactory>()); this->RegisterFactory("PM64:COLLISION", std::make_shared<PM64CollisionFactory>()); this->RegisterFactory("PM64:MAP_TEXTURE", std::make_shared<PM64MapTextureFactory>()); this->RegisterFactory("PM64:AUDIO", std::make_shared<PM64AudioFactory>()); @@ -282,6 +293,67 @@ void Companion::Init(const ExportType type, std::atomic<size_t>& assetCount, boo this->RegisterFactory("NAUDIO:V1:ADPCM_BOOK", std::make_shared<ADPCMBookFactory>()); this->RegisterFactory("NAUDIO:V1:SEQUENCE", std::make_shared<NSequenceFactory>()); #endif + +#ifdef BUILD_UI + // Custom preview renderers; other types fall back to BaseFactoryUI. + this->RegisterUIFactory("TEXTURE", std::make_shared<TextureFactoryUI>()); + this->RegisterUIFactory("VTX", std::make_shared<VtxFactoryUI>()); + this->RegisterUIFactory("GFX", std::make_shared<DListFactoryUI>()); + this->RegisterUIFactory("LIGHTS", std::make_shared<LightsFactoryUI>()); +#ifdef SM64_SUPPORT + this->RegisterUIFactory("SM64:GEO_LAYOUT", std::make_shared<SM64::GeoLayoutFactoryUI>()); + this->RegisterUIFactory("SM64:COLLISION", std::make_shared<SM64::CollisionFactoryUI>()); + this->RegisterUIFactory("SM64:TRAJECTORY", std::make_shared<SM64::TrajectoryFactoryUI>()); + this->RegisterUIFactory("SM64:ANIM", std::make_shared<SM64::AnimationFactoryUI>()); + this->RegisterUIFactory("SM64:DIALOG", std::make_shared<SM64::DialogFactoryUI>()); + this->RegisterUIFactory("SM64:TEXT", std::make_shared<SM64::TextFactoryUI>()); + this->RegisterUIFactory("SM64:DICTIONARY", std::make_shared<SM64::DictionaryFactoryUI>()); + this->RegisterUIFactory("SM64:BEHAVIOR_SCRIPT", std::make_shared<BaseFactoryUI>()); + this->RegisterUIFactory("SM64:LEVEL_SCRIPT", std::make_shared<BaseFactoryUI>()); + this->RegisterUIFactory("SM64:MACRO", std::make_shared<BaseFactoryUI>()); + this->RegisterUIFactory("SM64:MOVTEX", std::make_shared<BaseFactoryUI>()); + this->RegisterUIFactory("SM64:MOVTEX_QUAD", std::make_shared<BaseFactoryUI>()); + this->RegisterUIFactory("SM64:PAINTING", std::make_shared<BaseFactoryUI>()); + this->RegisterUIFactory("SM64:PAINTING_MAP", std::make_shared<BaseFactoryUI>()); + this->RegisterUIFactory("SM64:WATER_DROPLET", std::make_shared<BaseFactoryUI>()); +#endif +#ifdef PM64_SUPPORT + this->RegisterUIFactory("PM64:SHAPE", std::make_shared<PM64ShapeFactoryUI>()); + this->RegisterUIFactory("PM64:COLLISION", std::make_shared<PM64CollisionFactoryUI>()); + this->RegisterUIFactory("PM64:BK_SAMPLE", std::make_shared<PM64BkSampleFactoryUI>()); + this->RegisterUIFactory("PM64:SPRITE", std::make_shared<PM64SpriteFactoryUI>()); + this->RegisterUIFactory("PM64:MAP_TEXTURE", std::make_shared<PM64MapTextureFactoryUI>()); + this->RegisterUIFactory("PM64:IMGFX_ANIM", std::make_shared<PM64ImgFXAnimFactoryUI>()); + this->RegisterUIFactory("PM64:TITLE_DATA", std::make_shared<PM64TitleDataFactoryUI>()); + this->RegisterUIFactory("PM64:STORY_IMAGE", std::make_shared<PM64StoryImageFactoryUI>()); + UI::RegisterSequenceDriver("PM64:BGM", std::make_shared<SequencePlayerPM64>()); + this->RegisterUIFactory("PM64:BGM", std::make_shared<UI::SequencePreviewUI>()); +#endif +#ifdef NAUDIO_SUPPORT + this->RegisterUIFactory("NAUDIO:V0:SAMPLE", std::make_shared<SampleFactoryUI>()); + UI::RegisterSequenceDriver("NAUDIO:V0:SEQUENCE", std::make_shared<SequencePlayerV0>()); + this->RegisterUIFactory("NAUDIO:V0:SEQUENCE", std::make_shared<UI::SequencePreviewUI>()); + UI::RegisterSequenceDriver("NAUDIO:V1:SEQUENCE", std::make_shared<SequencePlayerV1>()); + this->RegisterUIFactory("NAUDIO:V1:SEQUENCE", std::make_shared<UI::SequencePreviewUI>()); + this->RegisterUIFactory("NAUDIO:V1:SAMPLE", std::make_shared<NSampleFactoryUI>()); +#ifdef SF64_SUPPORT + // SF64: 3D skeleton preview, decoded messages, code preview for the rest. + this->RegisterUIFactory("SF64:SKELETON", std::make_shared<SF64::SkeletonFactoryUI>()); + this->RegisterUIFactory("SF64:MESSAGE", std::make_shared<SF64::MessageFactoryUI>()); + // 3D previews for collision meshes and hitbox volumes. + this->RegisterUIFactory("SF64:HITBOX", std::make_shared<SF64::HitboxFactoryUI>()); + this->RegisterUIFactory("SF64:COLPOLY", std::make_shared<SF64::ColPolyFactoryUI>()); + this->RegisterUIFactory("SF64:TRIANGLE", std::make_shared<SF64::TriangleFactoryUI>()); + // Data/script types use the default code-preview card. + this->RegisterUIFactory("SF64:ANIM", std::make_shared<BaseFactoryUI>()); + this->RegisterUIFactory("SF64:SCRIPT", std::make_shared<BaseFactoryUI>()); + this->RegisterUIFactory("SF64:ENVIRONMENT", std::make_shared<BaseFactoryUI>()); + this->RegisterUIFactory("SF64:OBJECT_INIT", std::make_shared<BaseFactoryUI>()); + this->RegisterUIFactory("SF64:MSG_TABLE", std::make_shared<BaseFactoryUI>()); +#endif +#endif +#endif + #ifndef __EMSCRIPTEN__ // We call this manually this->Process(assetCount); #endif @@ -1192,6 +1264,7 @@ void Companion::Process(std::atomic<size_t>& assetCount) { this->gCartridge = std::make_shared<N64::Cartridge>(this->gRomData); this->gCartridge->Initialize(); + this->gGameTitle = this->gCartridge->GetGameTitle(); if (!config[this->gCartridge->GetHash()]) { bool synthesized = false; @@ -1229,6 +1302,7 @@ void Companion::Process(std::atomic<size_t>& assetCount) { this->gRomData = CompTool::Decompress(this->gRomData); this->gCartridge = std::make_shared<N64::Cartridge>(this->gRomData); this->gCartridge->Initialize(); + this->gGameTitle = this->gCartridge->GetGameTitle(); auto hash = this->gCartridge->GetHash(); @@ -1284,37 +1358,42 @@ void Companion::Process(std::atomic<size_t>& assetCount) { auto output_path = this->gDestinationDirectory; this->gConfig.moddingPath = (this->gDestinationDirectory / modding_path).string(); - switch (this->gConfig.exporterType) { - case ExportType::Binary: { - std::string extension = ""; - switch (this->gConfig.otrMode) { - case ArchiveType::OTR: - extension = ".otr"; - break; - case ArchiveType::O2R: - extension = ".o2r"; - break; - default: - throw std::runtime_error("Invalid archive type for export type Binary"); + // The output path is only consumed by the exporters. In parse-only mode + // (mShouldProcess == false, e.g. the `ui` viewer) nothing is written, so skip + // the setup — this also avoids requiring an archive type for Binary parsing. + if (this->mShouldProcess) { + switch (this->gConfig.exporterType) { + case ExportType::Binary: { + std::string extension = ""; + switch (this->gConfig.otrMode) { + case ArchiveType::OTR: + extension = ".otr"; + break; + case ArchiveType::O2R: + extension = ".o2r"; + break; + default: + throw std::runtime_error("Invalid archive type for export type Binary"); + } + output_path /= opath && opath["binary"] ? opath["binary"].as<std::string>() : ("generic" + extension); + break; + } + case ExportType::Header: { + output_path /= opath && opath["headers"] ? opath["headers"].as<std::string>() : "headers"; + break; + } + case ExportType::Code: { + output_path /= opath && opath["code"] ? opath["code"].as<std::string>() : "code"; + break; + } + case ExportType::XML: + case ExportType::Modding: { + output_path /= modding_path; + break; } - output_path /= opath && opath["binary"] ? opath["binary"].as<std::string>() : ("generic" + extension); - break; - } - case ExportType::Header: { - output_path /= opath && opath["headers"] ? opath["headers"].as<std::string>() : "headers"; - break; - } - case ExportType::Code: { - output_path /= opath && opath["code"] ? opath["code"].as<std::string>() : "code"; - break; - } - case ExportType::XML: - case ExportType::Modding: { - output_path /= modding_path; - break; } + this->gConfig.outputPath = output_path.string(); } - this->gConfig.outputPath = output_path.string(); if (auto outParent = output_path.parent_path(); !outParent.empty() && !exists(outParent)) { create_directories(outParent); } @@ -1351,6 +1430,8 @@ void Companion::Process(std::atomic<size_t>& assetCount) { } } + this->gConfig.defaultShading = GetSafeNode<std::string>(cfg, "default_shading", ""); + if (auto sort = cfg["sort"]) { if (sort.IsSequence()) { this->gWriteOrder = sort.as<std::vector<std::string>>(); @@ -1788,6 +1869,21 @@ std::optional<std::shared_ptr<BaseFactory>> Companion::GetFactory(const std::str return this->gFactories[type]; } +#ifdef BUILD_UI +void Companion::RegisterUIFactory(const std::string& type, const std::shared_ptr<BaseFactoryUI>& factory) { + this->gUIFactories[type] = factory; + SPDLOG_INFO("Registered UI factory for {}", type); +} + +std::optional<std::shared_ptr<BaseFactoryUI>> Companion::GetUIFactory(const std::string& type) { + if (!Torch::contains(this->gUIFactories, type)) { + return std::nullopt; + } + + return this->gUIFactories[type]; +} +#endif + std::optional<Table> Companion::SearchTable(uint32_t addr) { for (auto& table : this->gTables) { if (addr >= table.start && addr <= table.end) { @@ -1885,6 +1981,31 @@ std::optional<std::tuple<std::string, YAML::Node>> Companion::GetNodeByAddr(uint return this->gAddrMap[this->gCurrentFile][addr]; } +std::optional<std::tuple<std::string, YAML::Node>> Companion::GetNodeByAddr(uint32_t addr, const std::string& file) { + if (Torch::contains(this->gAddrMap, file) && Torch::contains(this->gAddrMap[file], addr)) { + return this->gAddrMap[file][addr]; + } + + // The external_files context used during parsing is unavailable here, so + // approximate it: sibling ymls first, then any file. Segmented addresses + // can repeat across groups, so proximity ordering matters. + const auto slash = file.find_last_of('/'); + const std::string dir = slash != std::string::npos ? file.substr(0, slash + 1) : ""; + if (!dir.empty()) { + for (auto& [f, map] : this->gAddrMap) { + if (f != file && f.rfind(dir, 0) == 0 && Torch::contains(map, addr)) { + return map[addr]; + } + } + } + for (auto& [f, map] : this->gAddrMap) { + if (f != file && Torch::contains(map, addr)) { + return map[addr]; + } + } + return std::nullopt; +} + std::optional<std::string> Companion::GetStringByAddr(const uint32_t addr) { if (Torch::contains(this->gManualSegments, addr)) { return this->gManualSegments[addr]; diff --git a/src/Companion.h b/src/Companion.h index 59d1940..f91fb55 100644 --- a/src/Companion.h +++ b/src/Companion.h @@ -101,6 +101,9 @@ struct TorchConfig { bool textureDefines; bool includeAutogen; bool dialogPack = false; + // Default model-preview shading mode name (e.g. "textured + lit"); empty + // uses each viewer's built-in fallback. See UI::ShadeSetupIndexByName. + std::string defaultShading; }; struct ParseResultData { @@ -164,6 +167,8 @@ public: bool AddTextureDefines() const { return this->gConfig.textureDefines; } N64::Cartridge* GetCartridge() const { return this->gCartridge.get(); } + // Survives cartridge teardown so the UI can key game-specific behavior. + const std::string& GetGameTitle() const { return this->gGameTitle; } std::vector<uint8_t>& GetRomData() { return this->gRomData; } std::string GetOutputPath() { return this->gConfig.outputPath; } const std::string& GetAssetPath() const { return this->gAssetPath; } @@ -183,6 +188,9 @@ public: std::optional<std::shared_ptr<BaseFactory>> GetFactory(const std::string& type); uint32_t PatchVirtualAddr(uint32_t addr); std::optional<std::tuple<std::string, YAML::Node>> GetNodeByAddr(uint32_t addr); + // File-scoped variant for use outside the parse/export passes, where + // gCurrentFile no longer points at the owning file. + std::optional<std::tuple<std::string, YAML::Node>> GetNodeByAddr(uint32_t addr, const std::string& file); std::optional<std::string> GetStringByAddr(uint32_t addr); std::optional<std::tuple<std::string, YAML::Node>> GetSafeNodeByAddr(const uint32_t addr, std::string type); std::optional<std::string> GetSafeStringByAddr(const uint32_t addr, std::string type); @@ -227,6 +235,12 @@ public: std::optional<YAML::Node> AddAsset(YAML::Node asset); void SetCompressedSegment(uint32_t segmentId, uint32_t compressedFileOffset, uint32_t offset); bool GetCompressedSegmentOffset(uint32_t* addr); + +#ifdef BUILD_UI + void RegisterUIFactory(const std::string& type, const std::shared_ptr<BaseFactoryUI>& factory); + std::optional<std::shared_ptr<BaseFactoryUI>> GetUIFactory(const std::string& type); + const std::unordered_map<std::string, std::vector<ParseResultData>>& GetParseResults() { return this->gParseResults; } +#endif private: TorchConfig gConfig; YAML::Node gModdingConfig; @@ -242,6 +256,7 @@ private: bool gIndividualIncludes = false; YAML::Node gHashNode; std::shared_ptr<N64::Cartridge> gCartridge; + std::string gGameTitle; std::unordered_map<std::string, std::vector<YAML::Node>> gCourseMetadata; std::unordered_map<std::string, std::unordered_map<int32_t, std::string>> gEnums; BinaryWrapper* gCurrentWrapper; @@ -276,6 +291,9 @@ private: std::unordered_map<std::string, std::string> gModdedAssetPaths; std::variant<std::vector<std::string>, std::string> gWriteOrder; std::unordered_map<std::string, std::shared_ptr<BaseFactory>> gFactories; +#ifdef BUILD_UI + std::unordered_map<std::string, std::shared_ptr<BaseFactoryUI>> gUIFactories; +#endif std::unordered_map<std::string, std::map<std::string, std::vector<WriteEntry>>> gWriteMap; std::unordered_map<std::string, std::tuple<uint32_t, uint32_t>> gVirtualAddrMap; std::unordered_map<std::string, std::unordered_map<uint32_t, std::tuple<std::string, YAML::Node>>> gAddrMap; diff --git a/src/factories/BaseFactory.cpp b/src/factories/BaseFactory.cpp index 62f46a9..acd3b34 100644 --- a/src/factories/BaseFactory.cpp +++ b/src/factories/BaseFactory.cpp @@ -1,5 +1,55 @@ #include "BaseFactory.h" +#ifdef BUILD_UI +#include <sstream> +#include <unordered_map> +#include "Companion.h" +#include "ui/Widgets.h" + +float BaseFactoryUI::GetItemHeight(const ParseResultData&) { + return UI::AssetCardHeight(); +} + +void BaseFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + UI::BeginAssetBody(); + + // Exporter output is stable per asset; cache it instead of re-exporting + // every frame. + static std::unordered_map<std::string, std::string> sPreviewCache; + auto cached = sPreviewCache.find(item.name); + if (cached == sPreviewCache.end()) { + std::string text; + const auto factory = Companion::Instance->GetFactory(item.type); + if (factory.has_value() && factory.value()->CanPreviewCode() && item.data.has_value()) { + if (const auto exporter = factory.value()->GetExporter(ExportType::Code); exporter.has_value()) { + try { + std::ostringstream out; + std::string entryName = item.name; + std::string replacement = item.name; + YAML::Node node = item.node; + exporter.value()->Export(out, item.data.value(), entryName, node, &replacement); + text = out.str(); + } catch (const std::exception& e) { + text = std::string("// preview unavailable: ") + e.what(); + } catch (...) { text = "// preview unavailable"; } + } + } + + if (text.empty()) { + // No code preview: fall back to the asset's YAML config. + YAML::Node node = item.node; + text = YAML::Dump(node); + } + cached = sPreviewCache.emplace(item.name, std::move(text)).first; + } + const std::string& text = cached->second; + + ImGui::TextUnformatted(text.c_str()); + UI::EndAssetBody(); +} +#endif + void BaseExporter::WriteHeader(LUS::BinaryWriter& writer, Torch::ResourceType resType, int32_t version) { writer.Write((int8_t)(Torch::Endianness::Native)); // 0x00 - Endianness writer.Write((int8_t)0); // 0x01 - Is Asset Custom diff --git a/src/factories/BaseFactory.h b/src/factories/BaseFactory.h index 630f2cd..3335827 100644 --- a/src/factories/BaseFactory.h +++ b/src/factories/BaseFactory.h @@ -16,8 +16,15 @@ #include "lib/binarytools/BinaryWriter.h" #include "lib/binarytools/BinaryReader.h" +#ifdef BUILD_UI +#define IMGUI_DEFINE_MATH_OPERATORS +#include "imgui.h" +#endif + namespace fs = std::filesystem; +struct ParseResultData; + #define REGISTER(type, c) { ExportType::type, std::make_shared<c>() }, #define SEGMENT_OFFSET(a) ((uint32_t)(a) & 0x00FFFFFF) @@ -120,8 +127,27 @@ public: virtual std::optional<std::shared_ptr<IParsedData>> CreateDataPointer() { return std::nullopt; } + // When true, the viewer's default preview renders this factory's Code + // exporter output as read-only text. Only enable for exporters that just + // format to the stream (no file writes or other side effects). + virtual bool CanPreviewCode() { + return false; + } private: virtual std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() { return {}; } -};
\ No newline at end of file +}; + +#ifdef BUILD_UI +// Base preview renderer. The default implementation (see BaseFactory.cpp) shows +// the asset's Code-exporter text when the factory opts in via CanPreviewCode(), +// otherwise its YAML config. Subclasses override DrawUI for richer visuals. +class BaseFactoryUI { +public: + virtual ~BaseFactoryUI() = default; + + virtual float GetItemHeight(const ParseResultData& data); + virtual void DrawUI(const ParseResultData& data); +}; +#endif
\ No newline at end of file diff --git a/src/factories/DisplayListFactory.cpp b/src/factories/DisplayListFactory.cpp index 144d666..ce4e77d 100644 --- a/src/factories/DisplayListFactory.cpp +++ b/src/factories/DisplayListFactory.cpp @@ -660,3 +660,51 @@ std::optional<std::shared_ptr<IParsedData>> DListFactory::parse(std::vector<uint return std::make_shared<DListData>(gfxs); } + +#ifdef BUILD_UI +#include <algorithm> +#include <cmath> +#include <cstring> +#include <unordered_map> + +#include "ui/BaseBackend.h" +#include "ui/Widgets.h" + +namespace { +std::unordered_map<std::string, UI::OrbitView> sModelViews; +} // namespace + +float DListFactoryUI::GetItemHeight(const ParseResultData& item) { + const float line = ImGui::GetTextLineHeightWithSpacing(); + const float frame = ImGui::GetFrameHeightWithSpacing(); + const float sep = ImGui::GetStyle().ItemSpacing.y * 2.0f + 1.0f; + return line * 2.0f + frame + sep + UI::PreviewBlockHeight(item.name); +} + +void DListFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + ImGui::TextDisabled("display list \xe2\x80\x94 drag to orbit, shift+drag to pan"); + + static std::unordered_map<std::string, int> sShade; + const int cfgShade = UI::ShadeSetupIndexByName(Companion::Instance->GetConfig().defaultShading); + int& shadeIdx = sShade.try_emplace(item.name, cfgShade >= 0 ? cfgShade : 0).first->second; + UI::ShadeSetupCombo("##dlshade", shadeIdx); + ImGui::SameLine(); + UI::LightingControls(); + + UI::OrbitView& view = sModelViews[item.name]; + const UI::PreviewCanvas canvas = UI::BeginResizableCanvas("##modelview", item.name, view); + // Only on-screen rows request the offscreen render. + if (canvas.visible) { + const UI::ShadeSetup shade = UI::ShadeSetupFor(shadeIdx); + UI::ModelPart part; + part.resource = item.name; + static const float kIdentity[4][4] = { { 1, 0, 0, 0 }, { 0, 1, 0, 0 }, { 0, 0, 1, 0 }, { 0, 0, 0, 1 } }; + std::memcpy(part.mtx, kIdentity, sizeof(kIdentity)); + part.gameShade = shade.gameShade; + part.unlit = shade.unlit; + part.fullAmbient = shade.fullAmbient; + UI::GetBackend()->DrawModelParts(item.name, { part }, canvas.origin, canvas.size, view); + } +} +#endif diff --git a/src/factories/DisplayListFactory.h b/src/factories/DisplayListFactory.h index e46ab22..705ef74 100644 --- a/src/factories/DisplayListFactory.h +++ b/src/factories/DisplayListFactory.h @@ -42,3 +42,13 @@ public: return 8; } }; + +#ifdef BUILD_UI +// Previews the display list as a shaded model via Fast3D. Requires the +// asset's .o2r archive to be mounted. +class DListFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; +#endif diff --git a/src/factories/LightsFactory.cpp b/src/factories/LightsFactory.cpp index 6b21643..9b69d8a 100644 --- a/src/factories/LightsFactory.cpp +++ b/src/factories/LightsFactory.cpp @@ -123,3 +123,28 @@ std::optional<std::shared_ptr<IParsedData>> LightsFactory::parse(std::vector<uin reader.Read((char*)&lights, sizeof(Lights1Raw)); return std::make_shared<LightsData>(lights); } + +#ifdef BUILD_UI +#include "ui/Widgets.h" + +void LightsFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + const auto& lights = std::static_pointer_cast<LightsData>(item.data.value())->mLights; + const auto& amb = lights.a.l.col; + const auto& dif = lights.l[0].l.col; + const auto& dir = lights.l[0].l.dir; + + const ImVec4 ambColor(amb[0] / 255.0f, amb[1] / 255.0f, amb[2] / 255.0f, 1.0f); + const ImVec4 difColor(dif[0] / 255.0f, dif[1] / 255.0f, dif[2] / 255.0f, 1.0f); + ImGui::ColorButton("##amb", ambColor, ImGuiColorEditFlags_NoTooltip); + ImGui::SameLine(); + ImGui::Text("ambient #%02X%02X%02X", amb[0], amb[1], amb[2]); + ImGui::ColorButton("##dif", difColor, ImGuiColorEditFlags_NoTooltip); + ImGui::SameLine(); + ImGui::Text("diffuse #%02X%02X%02X dir (%d, %d, %d)", dif[0], dif[1], dif[2], dir[0], dir[1], dir[2]); +} +#endif // BUILD_UI diff --git a/src/factories/LightsFactory.h b/src/factories/LightsFactory.h index 8771434..2cd8836 100644 --- a/src/factories/LightsFactory.h +++ b/src/factories/LightsFactory.h @@ -63,4 +63,12 @@ public: REGISTER(Binary, LightsBinaryExporter) }; } -};
\ No newline at end of file +}; + +#ifdef BUILD_UI +// Shows the Lights1 colors and direction. +class LightsFactoryUI : public BaseFactoryUI { +public: + void DrawUI(const ParseResultData& data) override; +}; +#endif diff --git a/src/factories/MtxFactory.cpp b/src/factories/MtxFactory.cpp index b6ff955..33c5a07 100644 --- a/src/factories/MtxFactory.cpp +++ b/src/factories/MtxFactory.cpp @@ -112,9 +112,9 @@ ExportResult MtxBinaryExporter::Export(std::ostream& write, std::shared_ptr<IPar WriteHeader(writer, Torch::ResourceType::Matrix, 0); - if(floats){ - for(size_t i = 0; i < 4; i++){ - for(size_t j = 0; j < 4; j++){ + if (floats) { + for (size_t i = 0; i < 4; i++) { + for (size_t j = 0; j < 4; j++) { writer.Write(mtx->mMtxs[0].mtx[i * 4 + j]); } } @@ -124,15 +124,15 @@ ExportResult MtxBinaryExporter::Export(std::ostream& write, std::shared_ptr<IPar // N64 format: each int32 packs two adjacent columns as (col_even << 16) | col_odd. auto& mt = mtx->mMtxs[0].mt; // First 8 words: integer parts (4 rows × 2 column-pairs) - for(size_t i = 0; i < 4; i++){ - for(size_t j = 0; j < 2; j++){ + for (size_t i = 0; i < 4; i++) { + for (size_t j = 0; j < 2; j++) { int32_t packed = ((int32_t)mt.intPart[i][j * 2] << 16) | mt.intPart[i][j * 2 + 1]; writer.Write(packed); } } // Next 8 words: fractional parts (4 rows × 2 column-pairs) - for(size_t i = 0; i < 4; i++){ - for(size_t j = 0; j < 2; j++){ + for (size_t i = 0; i < 4; i++) { + for (size_t j = 0; j < 2; j++) { int32_t packed = ((int32_t)mt.fracPart[i][j * 2] << 16) | mt.fracPart[i][j * 2 + 1]; writer.Write(packed); } diff --git a/src/factories/TextureFactory.cpp b/src/factories/TextureFactory.cpp index c8d94ee..2042288 100644 --- a/src/factories/TextureFactory.cpp +++ b/src/factories/TextureFactory.cpp @@ -483,3 +483,221 @@ std::optional<std::shared_ptr<IParsedData>> TextureFactory::parse_modding(std::v return std::make_shared<TextureData>(fmt, width, height, result); } + +#ifdef BUILD_UI +#include <array> +#include <fstream> +#include <unordered_map> +#include "ui/BaseBackend.h" +#include "ui/ExportUtils.h" + +// symbol -> handle, so each texture is decoded and uploaded only once. The +// backend owns the GPU resources and frees them on shutdown. +static std::unordered_map<std::string, UI::TextureHandle> sTextureCache = {}; + +static rgba* DecodeToRGBA(const std::shared_ptr<TextureData>& texture, YAML::Node& node) { + const auto format = texture->mFormat; + const int w = (int)texture->mWidth; + const int h = (int)texture->mHeight; + uint8_t* raw = texture->mBuffer.data(); + + switch (format.type) { + case TextureType::TLUT: + case TextureType::RGBA16bpp: + case TextureType::RGBA32bpp: + return raw2rgba(raw, w, h, format.depth); + case TextureType::GrayscaleAlpha16bpp: + case TextureType::GrayscaleAlpha8bpp: + case TextureType::GrayscaleAlpha4bpp: + case TextureType::GrayscaleAlpha1bpp: { + ia* img = raw2ia(raw, w, h, format.depth); + if (img == nullptr) { + return nullptr; + } + auto* out = (rgba*)malloc(sizeof(rgba) * w * h); + for (int i = 0; i < w * h; i++) { + out[i] = { img[i].intensity, img[i].intensity, img[i].intensity, img[i].alpha }; + } + free(img); + return out; + } + case TextureType::Grayscale8bpp: + case TextureType::Grayscale4bpp: { + ia* img = raw2i(raw, w, h, format.depth); + if (img == nullptr) { + return nullptr; + } + auto* out = (rgba*)malloc(sizeof(rgba) * w * h); + for (int i = 0; i < w * h; i++) { + out[i] = { img[i].intensity, img[i].intensity, img[i].intensity, img[i].alpha }; + } + free(img); + return out; + } + case TextureType::Palette8bpp: + case TextureType::Palette4bpp: { + std::optional<ParseResultData> palette; + if (node["tlut_symbol"]) { + palette = Companion::Instance->GetParseDataBySymbol(GetSafeNode<std::string>(node, "tlut_symbol")); + } else if (node["tlut"]) { + palette = Companion::Instance->GetParseDataByAddr(GetSafeNode<uint32_t>(node, "tlut")); + } + if (!palette.has_value() || !palette->data.has_value()) { + return nullptr; + } + auto palTexture = std::static_pointer_cast<TextureData>(palette->data.value()); + // CI + palette -> RGBA16 raw, then RGBA16 -> intermediate RGBA. + uint8_t* rgba16 = ci2raw(raw, palTexture->mBuffer.data(), w, h, format.depth); + if (rgba16 == nullptr) { + return nullptr; + } + rgba* out = raw2rgba(rgba16, w, h, 16); + free(rgba16); + return out; + } + default: + return nullptr; + } +} + +static UI::TextureHandle GetOrLoadTexture(const ParseResultData& item) { + auto& node = const_cast<YAML::Node&>(item.node); + const auto symbol = GetSafeNode<std::string>(node, "symbol", item.name); + + const auto cached = sTextureCache.find(symbol); + if (cached != sTextureCache.end()) { + return cached->second; + } + + auto texture = std::static_pointer_cast<TextureData>(item.data.value()); + rgba* pixels = DecodeToRGBA(texture, node); + + UI::TextureHandle handle = UI::kInvalidTexture; + if (pixels != nullptr) { + // Upload is synchronous, so the buffer can be freed right after. Caching + // a failed kInvalidTexture avoids retrying an undecodable texture. + handle = UI::GetBackend()->UploadRGBA8(reinterpret_cast<const uint8_t*>(pixels), (int)texture->mWidth, + (int)texture->mHeight); + free(pixels); + } + + sTextureCache[symbol] = handle; + return handle; +} + +// Checkerboard backdrop so texture transparency reads against the panel. +static void DrawCheckerboard(ImDrawList* dl, const ImVec2& min, const ImVec2& size) { + constexpr float kCell = 8.0f; + const ImU32 a = IM_COL32(48, 48, 52, 255); + const ImU32 b = IM_COL32(64, 64, 70, 255); + dl->AddRectFilled(min, min + size, a); + for (float y = 0; y < size.y; y += kCell) { + for (float x = 0; x < size.x; x += kCell) { + if ((int)((x + y) / kCell) % 2 == 0) { + continue; + } + const ImVec2 c0 = min + ImVec2(x, y); + const ImVec2 c1 = c0 + ImVec2(std::min(kCell, size.x - x), std::min(kCell, size.y - y)); + dl->AddRectFilled(c0, c1, b); + } + } +} + +float TextureFactoryUI::GetItemHeight(const ParseResultData& item) { + return 138.0f; +} + +void TextureFactoryUI::DrawUI(const ParseResultData& item) { + auto& node = const_cast<YAML::Node&>(item.node); + auto texture = std::static_pointer_cast<TextureData>(item.data.value()); + const auto format = GetSafeNode<std::string>(node, "format"); + const auto offset = GetSafeNode<uint32_t>(node, "offset"); + const UI::TextureHandle handle = GetOrLoadTexture(item); + + constexpr float kThumb = 128.0f; + const ImVec2 thumb(kThumb, kThumb); + + ImGui::PushID(item.name.c_str()); + ImGui::BeginGroup(); + + ImDrawList* dl = ImGui::GetWindowDrawList(); + const ImVec2 origin = ImGui::GetCursorScreenPos(); + DrawCheckerboard(dl, origin, thumb); + + if (handle != UI::kInvalidTexture) { + const float aspect = (float)texture->mWidth / (float)texture->mHeight; + ImVec2 fit = thumb; + if (aspect >= 1.0f) { + fit.y = kThumb / aspect; + } else { + fit.x = kThumb * aspect; + } + const ImVec2 pad((kThumb - fit.x) * 0.5f, (kThumb - fit.y) * 0.5f); + ImGui::SetCursorScreenPos(origin + pad); + ImGui::Image(handle, fit); + + // Hover -> larger preview tooltip. + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + constexpr float kBig = 256.0f; + ImVec2 big(kBig, kBig); + if (aspect >= 1.0f) { + big.y = kBig / aspect; + } else { + big.x = kBig * aspect; + } + ImGui::Image(handle, big); + ImGui::Text("%u x %u %s", texture->mWidth, texture->mHeight, format.c_str()); + ImGui::EndTooltip(); + } + } else { + const ImVec2 center = origin + thumb * 0.5f; + const char* label = "No Preview"; + dl->AddText(center - ImGui::CalcTextSize(label) * 0.5f, IM_COL32(150, 150, 150, 255), label); + } + dl->AddRect(origin, origin + thumb, IM_COL32(255, 255, 255, 40), 4.0f); + + // Reserve the full box so SameLine aligns the metadata to its edge. + ImGui::SetCursorScreenPos(origin); + ImGui::Dummy(thumb); + ImGui::EndGroup(); + + ImGui::SameLine(); + + ImGui::BeginGroup(); + ImGui::TextUnformatted(item.name.c_str()); + ImGui::Separator(); + ImGui::Text("Format %s (%d bpp)", format.c_str(), texture->mFormat.depth); + ImGui::Text("Resolution %u x %u", texture->mWidth, texture->mHeight); + ImGui::Text("Size %zu bytes", texture->mBuffer.size()); + ImGui::Text("Offset %s", Torch::to_hex(offset).c_str()); + ImGui::Spacing(); + if (ImGui::SmallButton("Copy symbol")) { + ImGui::SetClipboardText(item.name.c_str()); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Export PNG")) { + const auto path = UI::ExportFilePath(item.name, "png"); + std::string result = path.string(); + try { + const auto factory = Companion::Instance->GetFactory(item.type); + const auto exporter = + factory.has_value() ? factory.value()->GetExporter(ExportType::Modding) : std::nullopt; + if (!exporter.has_value()) { + result = "no png exporter for this type"; + } else { + std::ofstream file(path, std::ios::binary); + std::string entryName = item.name; + std::string replacement = item.name; + YAML::Node copy = YAML::Clone(item.node); + exporter.value()->Export(file, item.data.value(), entryName, copy, &replacement); + } + } catch (const std::exception& e) { result = std::string("export failed: ") + e.what(); } + UI::NoteExport(item.name, result); + } + UI::DrawExportMarker(item.name); + ImGui::EndGroup(); + + ImGui::PopID(); +} +#endif diff --git a/src/factories/TextureFactory.h b/src/factories/TextureFactory.h index 9fa0006..863db5c 100644 --- a/src/factories/TextureFactory.h +++ b/src/factories/TextureFactory.h @@ -42,4 +42,13 @@ public: }; } bool SupportModdedAssets() override { return true; } -};
\ No newline at end of file +}; + +#ifdef BUILD_UI +// Previews the decoded texture alongside its format/resolution/offset metadata. +class TextureFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; +#endif
\ No newline at end of file diff --git a/src/factories/VtxFactory.cpp b/src/factories/VtxFactory.cpp index de9395a..8cb6efb 100644 --- a/src/factories/VtxFactory.cpp +++ b/src/factories/VtxFactory.cpp @@ -181,3 +181,44 @@ std::optional<std::shared_ptr<IParsedData>> VtxFactory::parse(std::vector<uint8_ return std::make_shared<VtxData>(vertices); } + +#ifdef BUILD_UI +#include <algorithm> +#include <cmath> +#include <unordered_map> + +#include "ui/BaseBackend.h" +#include "ui/Widgets.h" + +namespace { +std::unordered_map<std::string, UI::OrbitView> sVtxViews; +} // namespace + +float VtxFactoryUI::GetItemHeight(const ParseResultData& item) { + return ImGui::GetTextLineHeightWithSpacing() * 2.0f + UI::PreviewBlockHeight(item.name) + 4.0f + + ImGui::GetStyle().ItemSpacing.y * 3.0f; +} + +void VtxFactoryUI::DrawUI(const ParseResultData& item) { + const auto vtx = std::static_pointer_cast<VtxData>(item.data.value()); + UI::AssetHeader(item.name, item.type); + ImGui::TextDisabled( + "%zu vertices \xe2\x80\x94 drag to orbit, shift+drag to pan, \xe2\x8c\x98/Ctrl+scroll to zoom", + vtx->mVtxs.size()); + + UI::OrbitView& view = sVtxViews[item.name]; + const UI::PreviewCanvas canvas = UI::BeginResizableCanvas("##vtxview", item.name, view); + if (!canvas.visible) { + return; + } + + std::vector<UI::PreviewVertex> points; + points.reserve(vtx->mVtxs.size()); + for (const auto& v : vtx->mVtxs) { + points.push_back({ { (float)v.ob[0], (float)v.ob[1], (float)v.ob[2] }, { v.cn[0], v.cn[1], v.cn[2], 255 } }); + } + + const uint64_t id = std::hash<std::string>{}(item.name); + UI::GetBackend()->DrawPointCloud(id, points, canvas.origin, canvas.size, view); +} +#endif diff --git a/src/factories/VtxFactory.h b/src/factories/VtxFactory.h index 75bd906..ca83230 100644 --- a/src/factories/VtxFactory.h +++ b/src/factories/VtxFactory.h @@ -41,4 +41,14 @@ public: uint32_t GetAlignment() override { return 8; }; -};
\ No newline at end of file +}; + +#ifdef BUILD_UI +// Previews the vertices as an interactive 3D point cloud (drag to orbit, scroll +// to zoom). Vtx has no triangle connectivity, so this is points, not a mesh. +class VtxFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; +#endif
\ No newline at end of file diff --git a/src/factories/bk64/BKAssetFactory.cpp b/src/factories/bk64/BKAssetFactory.cpp index b109c17..6a0d903 100644 --- a/src/factories/bk64/BKAssetFactory.cpp +++ b/src/factories/bk64/BKAssetFactory.cpp @@ -191,8 +191,7 @@ static void RegisterLangInfo(YAML::Node& node) { if (langNode && langNode.IsSequence()) { for (auto entry : langNode) { - langs.emplace_back(GetSafeNode<std::string>(entry, "name"), - GetSafeNode<uint32_t>(entry, "index", 0), + langs.emplace_back(GetSafeNode<std::string>(entry, "name"), GetSafeNode<uint32_t>(entry, "index", 0), GetSafeNode<uint32_t>(entry, "script", 0)); } } else if (cfg.dialogPack) { @@ -259,8 +258,7 @@ std::optional<std::shared_ptr<IParsedData>> BKAssetFactory::parse(std::vector<ui RegisterLangInfo(node); // Resolve the pack's language folder. - const std::string langRegion = - Companion::Instance->GetConfig().dialogPack ? ResolveLangRegion() : std::string(); + const std::string langRegion = Companion::Instance->GetConfig().dialogPack ? ResolveLangRegion() : std::string(); // Romhack o2rs overlay the base bk.o2r, whose manifest already lives there: walk the // table for side effects (BB config, modified-slot registration) but don't output a @@ -362,246 +360,245 @@ std::optional<std::shared_ptr<IParsedData>> BKAssetFactory::parse(std::vector<ui size_t parseFailures = 0; for (uint32_t i = 0; i < assetCount - 1; i++) { - try { - auto assetInfo = assetTableInfo.at(i); - - // Size is the gap to the next asset's offset. - uint32_t assetSize = assetTableInfo.at(i + 1).offset - assetInfo.offset; - - // Same offset means an empty slot sits in between; skip ahead until the - // offset actually changes to find the real boundary. - if (assetSize == 0) { - for (uint32_t j = i + 2; j < assetCount; j++) { - if (assetTableInfo.at(j).offset != assetInfo.offset) { - assetSize = assetTableInfo.at(j).offset - assetInfo.offset; - break; + try { + auto assetInfo = assetTableInfo.at(i); + + // Size is the gap to the next asset's offset. + uint32_t assetSize = assetTableInfo.at(i + 1).offset - assetInfo.offset; + + // Same offset means an empty slot sits in between; skip ahead until the + // offset actually changes to find the real boundary. + if (assetSize == 0) { + for (uint32_t j = i + 2; j < assetCount; j++) { + if (assetTableInfo.at(j).offset != assetInfo.offset) { + assetSize = assetTableInfo.at(j).offset - assetInfo.offset; + break; + } } } - } - auto assetOffset = dataStartRomOffset + assetInfo.offset; - BKAssetType assetType; + auto assetOffset = dataStartRomOffset + assetInfo.offset; + BKAssetType assetType; - if (assetInfo.tFlag == 4) { - continue; - } + if (assetInfo.tFlag == 4) { + continue; + } - if (assetOffset + assetSize > buffer.size()) { - SPDLOG_ERROR("Asset {} offset 0x{:X} + size 0x{:X} = 0x{:X} exceeds ROM " - "buffer 0x{:X}", - assetInfo.index, assetOffset, assetSize, assetOffset + assetSize, buffer.size()); - continue; - } + if (assetOffset + assetSize > buffer.size()) { + SPDLOG_ERROR("Asset {} offset 0x{:X} + size 0x{:X} = 0x{:X} exceeds ROM " + "buffer 0x{:X}", + assetInfo.index, assetOffset, assetSize, assetOffset + assetSize, buffer.size()); + continue; + } - SPDLOG_TRACE("Parsing asset {} mode={} offset=0x{:X} size=0x{:X} comp={}", assetInfo.index, assetInfo.assetMode, - assetOffset, assetSize, assetInfo.compressionFlag); - - if (romhackMode && assetSize > 0) { - const std::string& baselineHash = GetBaselineAssetHash(assetInfo.index); - if (!baselineHash.empty()) { - if (assetOffset + assetSize <= rom.size()) { - sha1::SHA1 s; - s.processBytes(rom.data() + assetOffset, assetSize); - uint32_t digest[5]; - s.getDigest(digest); - char buf[41]; - std::snprintf(buf, sizeof(buf), "%08x%08x%08x%08x%08x", digest[0], digest[1], digest[2], digest[3], - digest[4]); - if (baselineHash == buf) { - skipHits++; - if ((skipHits % 500) == 0) { - SPDLOG_WARN("Romhack hash-skip progress: {} hits, {} misses so far", skipHits, skipMisses); + SPDLOG_TRACE("Parsing asset {} mode={} offset=0x{:X} size=0x{:X} comp={}", assetInfo.index, + assetInfo.assetMode, assetOffset, assetSize, assetInfo.compressionFlag); + + if (romhackMode && assetSize > 0) { + const std::string& baselineHash = GetBaselineAssetHash(assetInfo.index); + if (!baselineHash.empty()) { + if (assetOffset + assetSize <= rom.size()) { + sha1::SHA1 s; + s.processBytes(rom.data() + assetOffset, assetSize); + uint32_t digest[5]; + s.getDigest(digest); + char buf[41]; + std::snprintf(buf, sizeof(buf), "%08x%08x%08x%08x%08x", digest[0], digest[1], digest[2], + digest[3], digest[4]); + if (baselineHash == buf) { + skipHits++; + if ((skipHits % 500) == 0) { + SPDLOG_WARN("Romhack hash-skip progress: {} hits, {} misses so far", skipHits, + skipMisses); + } + continue; } - continue; + skipMisses++; } - skipMisses++; } } - } - switch (assetInfo.assetMode) { - case 0: - assetType = BKAssetType::Animation; - break; - case 1: - case 3: - case 7: { - uint8_t* dataBuf; - if (assetInfo.compressionFlag != 0) { - DataChunk* uncompressedData = - Decompressor::Decode(buffer, assetOffset, CompressionType::BKZIP, assetSize); - dataBuf = uncompressedData->data; - } else { - dataBuf = buffer.data() + assetOffset; + switch (assetInfo.assetMode) { + case 0: + assetType = BKAssetType::Animation; + break; + case 1: + case 3: + case 7: { + uint8_t* dataBuf; + if (assetInfo.compressionFlag != 0) { + DataChunk* uncompressedData = + Decompressor::Decode(buffer, assetOffset, CompressionType::BKZIP, assetSize); + dataBuf = uncompressedData->data; + } else { + dataBuf = buffer.data() + assetOffset; + } + if (dataBuf[0] == 0 && dataBuf[1] == 0 && dataBuf[2] == 0 && dataBuf[3] == 11) { + assetType = BKAssetType::Model; + } else { + assetType = BKAssetType::Sprite; + } + break; } - if (dataBuf[0] == 0 && dataBuf[1] == 0 && dataBuf[2] == 0 && dataBuf[3] == 11) { - assetType = BKAssetType::Model; - } else { - assetType = BKAssetType::Sprite; + case 2: + assetType = BKAssetType::Map; + break; + case 4: { + uint8_t* dataBuf; + if (assetInfo.compressionFlag != 0) { + DataChunk* uncompressedData = + Decompressor::Decode(buffer, assetOffset, CompressionType::BKZIP, assetSize); + dataBuf = uncompressedData->data; + } else { + dataBuf = buffer.data() + assetOffset; + } + // US headers + if (dataBuf[0] == 1 && dataBuf[1] == 1 && dataBuf[2] == 2 && dataBuf[3] == 5 && dataBuf[4] == 0) { + assetType = BKAssetType::QuizQuestion; + } else if (dataBuf[0] == 1 && dataBuf[1] == 3 && dataBuf[2] == 0 && dataBuf[3] == 5 && + dataBuf[4] == 0) { + assetType = BKAssetType::GruntyQuestion; + } else if (dataBuf[0] == 1 && dataBuf[1] == 3 && dataBuf[2] == 0) { + assetType = BKAssetType::Dialog; + // PAL headers + } else if (dataBuf[0] == 3 && dataBuf[1] == 1 && dataBuf[2] == 2) { + assetType = BKAssetType::QuizQuestion; + } else if (dataBuf[0] == 3 && dataBuf[1] == 3 && dataBuf[2] == 0) { + assetType = BKAssetType::GruntyQuestion; + } else if (dataBuf[0] == 3 && dataBuf[1] == 7 && dataBuf[2] == 0) { + assetType = BKAssetType::Dialog; + } else { + assetType = BKAssetType::DemoInput; + } + break; } - break; + case 5: + assetType = BKAssetType::Model; + break; + case 6: + assetType = BKAssetType::Midi; + break; + default: + assetType = BKAssetType::Binary; + break; } - case 2: - assetType = BKAssetType::Map; - break; - case 4: { - uint8_t* dataBuf; - if (assetInfo.compressionFlag != 0) { - DataChunk* uncompressedData = - Decompressor::Decode(buffer, assetOffset, CompressionType::BKZIP, assetSize); - dataBuf = uncompressedData->data; - } else { - dataBuf = buffer.data() + assetOffset; + + // Dialog-pack mode only wants what actually differs by language: the text + // assets, plus the assets a pack may redraw for a new script. The font + // masks and the in-world text/sign models apply to any region, so custom + // packs (not just the retail JP cart) can carry and replace them. + if (Companion::Instance->GetConfig().dialogPack) { + const bool isText = assetType == BKAssetType::Dialog || assetType == BKAssetType::GruntyQuestion || + assetType == BKAssetType::QuizQuestion; + // Font masks + text-bearing models / signs / overlays a language pack + // may relocalize. + static const std::unordered_set<uint32_t> kLangAssets = { + 0x6EB, // SPRITE_DIALOG_FONT_ALPHAMASK (dialog/quiz/grunty text) + 0x6EC, // SPRITE_BOLD_FONT_LETTERS_ALPHAMASK (world names, headers) + 0x2EE, // ON_VACATIOIN_SIGN + 0x46C, // JIGSAW_PUZZLE + 0x486, // XMAS_TREE_SWITCH + 0x48B, // JIGGY_PODIUM + 0x4EA, // RACE_BANNER_FINISH + 0x4EB, // RACE_BANNER_START + 0x50A, // SHARKFOOD_ISLAND (model with sign) + 0x54C, // GAME OVER + 0x54D, // BANJO_KAZOOIE_SIGN + 0x54E, // COPYRIGHT_OVERLAY + 0x55C, // PRESS_START_OVERLAY + 0x55D, // NO_CONTROLLER_OVERLAY + 0x563, // LEVEL_ENTRY_SIGNS + 0x56C, // THE_END_SIGN + }; + const uint32_t idx = assetInfo.index; + bool isLangAsset = kLangAssets.count(idx) != 0; + // The JP cart additionally carries the kana dialog font and the + // pre-rendered kana pause-menu world-name banners. + if (auto* cart = Companion::Instance->GetCartridge(); + cart != nullptr && cart->GetCountry() == N64::CountryCode::Japan) { + isLangAsset = isLangAsset || idx == 0x6EA || (idx >= 0xE2C && idx <= 0xE38); } - // US headers - if (dataBuf[0] == 1 && dataBuf[1] == 1 && dataBuf[2] == 2 && dataBuf[3] == 5 && dataBuf[4] == 0) { - assetType = BKAssetType::QuizQuestion; - } else if (dataBuf[0] == 1 && dataBuf[1] == 3 && dataBuf[2] == 0 && dataBuf[3] == 5 && - dataBuf[4] == 0) { - assetType = BKAssetType::GruntyQuestion; - } else if (dataBuf[0] == 1 && dataBuf[1] == 3 && dataBuf[2] == 0) { - assetType = BKAssetType::Dialog; - // PAL headers - } else if (dataBuf[0] == 3 && dataBuf[1] == 1 && dataBuf[2] == 2) { - assetType = BKAssetType::QuizQuestion; - } else if (dataBuf[0] == 3 && dataBuf[1] == 3 && dataBuf[2] == 0) { - assetType = BKAssetType::GruntyQuestion; - } else if (dataBuf[0] == 3 && dataBuf[1] == 7 && dataBuf[2] == 0) { - assetType = BKAssetType::Dialog; - } else { - assetType = BKAssetType::DemoInput; + if (!isText && !isLangAsset) { + continue; } - break; } - case 5: - assetType = BKAssetType::Model; - break; - case 6: - assetType = BKAssetType::Midi; - break; - default: - assetType = BKAssetType::Binary; - break; - } - - // Dialog-pack mode only wants what actually differs by language: the text - // assets, plus the assets a pack may redraw for a new script. The font - // masks and the in-world text/sign models apply to any region, so custom - // packs (not just the retail JP cart) can carry and replace them. - if (Companion::Instance->GetConfig().dialogPack) { - const bool isText = assetType == BKAssetType::Dialog || - assetType == BKAssetType::GruntyQuestion || - assetType == BKAssetType::QuizQuestion; - // Font masks + text-bearing models / signs / overlays a language pack - // may relocalize. - static const std::unordered_set<uint32_t> kLangAssets = { - 0x6EB, // SPRITE_DIALOG_FONT_ALPHAMASK (dialog/quiz/grunty text) - 0x6EC, // SPRITE_BOLD_FONT_LETTERS_ALPHAMASK (world names, headers) - 0x2EE, // ON_VACATIOIN_SIGN - 0x46C, // JIGSAW_PUZZLE - 0x486, // XMAS_TREE_SWITCH - 0x48B, // JIGGY_PODIUM - 0x4EA, // RACE_BANNER_FINISH - 0x4EB, // RACE_BANNER_START - 0x50A, // SHARKFOOD_ISLAND (model with sign) - 0x54C, // GAME OVER - 0x54D, // BANJO_KAZOOIE_SIGN - 0x54E, // COPYRIGHT_OVERLAY - 0x55C, // PRESS_START_OVERLAY - 0x55D, // NO_CONTROLLER_OVERLAY - 0x563, // LEVEL_ENTRY_SIGNS - 0x56C, // THE_END_SIGN - }; - const uint32_t idx = assetInfo.index; - bool isLangAsset = kLangAssets.count(idx) != 0; - // The JP cart additionally carries the kana dialog font and the - // pre-rendered kana pause-menu world-name banners. - if (auto* cart = Companion::Instance->GetCartridge(); - cart != nullptr && cart->GetCountry() == N64::CountryCode::Japan) { - isLangAsset = isLangAsset || idx == 0x6EA || (idx >= 0xE2C && idx <= 0xE38); - } - if (!isText && !isLangAsset) { - continue; - } - } - std::string assetSymbol; - std::string assetIndexStr = std::to_string(assetInfo.index); + std::string assetSymbol; + std::string assetIndexStr = std::to_string(assetInfo.index); - if (symbolMapExists && node["symbol_map"][assetIndexStr]) { - assetSymbol = node["symbol_map"][assetIndexStr].as<std::string>(); - } else { - std::stringstream assetStream; + if (symbolMapExists && node["symbol_map"][assetIndexStr]) { + assetSymbol = node["symbol_map"][assetIndexStr].as<std::string>(); + } else { + std::stringstream assetStream; - assetStream << "D_" << sAssetSymbolPrefixes.at(assetType) << "_" << std::to_string(assetInfo.index); + assetStream << "D_" << sAssetSymbolPrefixes.at(assetType) << "_" << std::to_string(assetInfo.index); - assetSymbol = assetStream.str(); - } + assetSymbol = assetStream.str(); + } - if (Companion::Instance->GetConfig().dialogPack) { - assetSymbol = "lang/" + langRegion + "/" + assetSymbol; - } + if (Companion::Instance->GetConfig().dialogPack) { + assetSymbol = "lang/" + langRegion + "/" + assetSymbol; + } - symbolMap[assetInfo.index] = assetSymbol; + symbolMap[assetInfo.index] = assetSymbol; - YAML::Node bkAssetNode; - bkAssetNode["offset"] = assetOffset; - bkAssetNode["symbol"] = assetSymbol; - CompressionType compressionType = - (assetInfo.compressionFlag != 0) ? CompressionType::BKZIP : CompressionType::None; + YAML::Node bkAssetNode; + bkAssetNode["offset"] = assetOffset; + bkAssetNode["symbol"] = assetSymbol; + CompressionType compressionType = + (assetInfo.compressionFlag != 0) ? CompressionType::BKZIP : CompressionType::None; - switch (assetType) { - case BKAssetType::Animation: - bkAssetNode["type"] = "BK64:ANIM"; - Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); - break; - case BKAssetType::Binary: - bkAssetNode["type"] = "BLOB"; - Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); - break; - case BKAssetType::DemoInput: - bkAssetNode["type"] = "BK64:DEMO"; - Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); - break; - case BKAssetType::Dialog: - bkAssetNode["type"] = "BK64:DIALOG"; - Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); - break; - case BKAssetType::GruntyQuestion: - bkAssetNode["type"] = "BK64:GRUNTYQ"; - Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); - break; - case BKAssetType::Map: - bkAssetNode["type"] = "BK64:MAP"; - Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); - break; - case BKAssetType::Midi: - bkAssetNode["type"] = "BLOB"; - Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); - break; - case BKAssetType::Model: - bkAssetNode["type"] = "BK64:MODEL"; - Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); - break; - case BKAssetType::QuizQuestion: - bkAssetNode["type"] = "BK64:QUIZQ"; - Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); - break; - case BKAssetType::Sprite: - bkAssetNode["type"] = "BK64:SPRITE"; - Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); - break; - default: - // We assigned assetType ourselves, so getting here means a bug. - throw std::runtime_error("Invalid BKAsset Type Found"); + switch (assetType) { + case BKAssetType::Animation: + bkAssetNode["type"] = "BK64:ANIM"; + Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); + break; + case BKAssetType::Binary: + bkAssetNode["type"] = "BLOB"; + Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); + break; + case BKAssetType::DemoInput: + bkAssetNode["type"] = "BK64:DEMO"; + Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); + break; + case BKAssetType::Dialog: + bkAssetNode["type"] = "BK64:DIALOG"; + Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); + break; + case BKAssetType::GruntyQuestion: + bkAssetNode["type"] = "BK64:GRUNTYQ"; + Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); + break; + case BKAssetType::Map: + bkAssetNode["type"] = "BK64:MAP"; + Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); + break; + case BKAssetType::Midi: + bkAssetNode["type"] = "BLOB"; + Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); + break; + case BKAssetType::Model: + bkAssetNode["type"] = "BK64:MODEL"; + Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); + break; + case BKAssetType::QuizQuestion: + bkAssetNode["type"] = "BK64:QUIZQ"; + Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); + break; + case BKAssetType::Sprite: + bkAssetNode["type"] = "BK64:SPRITE"; + Companion::Instance->AddSubFileAsset(bkAssetNode, assetSymbol, compressionType, assetSize); + break; + default: + // We assigned assetType ourselves, so getting here means a bug. + throw std::runtime_error("Invalid BKAsset Type Found"); + } + } catch (const std::exception& e) { + parseFailures++; + const auto& bad = assetTableInfo.at(i); + SPDLOG_ERROR("[BKAssetFactory] skipping slot {} (compFlag={} tFlag={} mode={} offset=0x{:X}): {}", i, + bad.compressionFlag, bad.tFlag, bad.assetMode, dataStartRomOffset + bad.offset, e.what()); } - } catch (const std::exception& e) { - parseFailures++; - const auto& bad = assetTableInfo.at(i); - SPDLOG_ERROR("[BKAssetFactory] skipping slot {} (compFlag={} tFlag={} mode={} offset=0x{:X}): {}", i, - bad.compressionFlag, bad.tFlag, bad.assetMode, - dataStartRomOffset + bad.offset, e.what()); - } } if (romhackMode) { @@ -662,13 +659,12 @@ std::optional<std::shared_ptr<IParsedData>> BKAssetFactory::parse(std::vector<ui } size_t idStart = 6; // strlen("ASSET_") size_t idEnd = stem.find('_', idStart); - std::string idHex = (idEnd == std::string::npos) ? stem.substr(idStart) : stem.substr(idStart, idEnd - idStart); + std::string idHex = + (idEnd == std::string::npos) ? stem.substr(idStart) : stem.substr(idStart, idEnd - idStart); uint32_t id = 0; try { id = static_cast<uint32_t>(std::stoul(idHex, nullptr, 16)); - } catch (const std::exception&) { - continue; - } + } catch (const std::exception&) { continue; } if (symbolMap.count(id)) { continue; // the base table already covers this slot } diff --git a/src/factories/bk64/ConfigFactory.cpp b/src/factories/bk64/ConfigFactory.cpp index 87e30a3..93f2f61 100644 --- a/src/factories/bk64/ConfigFactory.cpp +++ b/src/factories/bk64/ConfigFactory.cpp @@ -301,9 +301,7 @@ static bool DetectCustomCodeBlob(const std::vector<uint8_t>& rom, uint32_t& outB uint32_t unzippedSize = expectedSize; try { unzipped = bk_unzip(rom.data() + off, &unzippedSize); - } catch (...) { - continue; - } + } catch (...) { continue; } if (!unzipped || unzippedSize != expectedSize) { if (unzipped) free(unzipped); @@ -327,8 +325,7 @@ static bool DetectCustomCodeBlob(const std::vector<uint8_t>& rom, uint32_t& outB std::vector<uint8_t> payload(unzipped, unzipped + unzippedSize); free(unzipped); uint32_t firstTarget = 0; - int jals = - CountInternalCustomCodeJals(payload, 0, static_cast<uint32_t>(payload.size()), firstTarget); + int jals = CountInternalCustomCodeJals(payload, 0, static_cast<uint32_t>(payload.size()), firstTarget); if (jals < CUSTOM_CODE_MIN_INTERNAL_JALS) { continue; } @@ -450,8 +447,7 @@ std::vector<char> BuildGameConfigBlob(const std::vector<uint8_t>& rom, const std sectionCount++; SPDLOG_INFO("[ConfigFactory] ROM_HASH = {}", romHash); } else { - SPDLOG_WARN("[ConfigFactory] ROM hash unexpected length {}, skipping ROM_HASH section", - romHash.size()); + SPDLOG_WARN("[ConfigFactory] ROM hash unexpected length {}, skipping ROM_HASH section", romHash.size()); } } @@ -617,7 +613,7 @@ std::vector<char> BuildGameConfigBlob(const std::vector<uint8_t>& rom, const std for (int layer = 0; layer < 3; layer++) { uint32_t base = off + 4 + layer * 12; blob.writeS16(static_cast<int16_t>(readBE16(modF9, base))); // model_id - blob.writeU32(readBE32(modF9, base + 4)); // scale (BE f32 bits -> LE u32) + blob.writeU32(readBE32(modF9, base + 4)); // scale (BE f32 bits -> LE u32) blob.writeU32(readBE32(modF9, base + 8)); // rotation (BE f32 bits -> LE u32) } count++; @@ -860,10 +856,10 @@ std::vector<char> BuildGameConfigBlob(const std::vector<uint8_t>& rom, const std // Asset-table hashing // // Lighthouse ships a per-slot SHA-1 of the v1.0 baseline at assets/yaml/<region>/<rev>/hashes.yaml. -// When extracting a BB romhack, we hash each slot's compressed bytes: a match means the slot is bit-for-bit +// When extracting a BB romhack, we hash each slot's compressed bytes: a match means the slot is bit-for-bit // vanilla, so skip the factory entirely. -// Walk the BK64 asset table at ROM 0x5E90 and invoke `onSlot(index, sha1Hex)` for every non-empty, +// Walk the BK64 asset table at ROM 0x5E90 and invoke `onSlot(index, sha1Hex)` for every non-empty, // non-zero-sized slot. Returns the total slot count (including empty slots), or 0 on parse failure. static size_t WalkAssetTable(const std::vector<uint8_t>& rom, const std::function<void(uint32_t, const std::string&)>& onSlot) { @@ -1061,8 +1057,7 @@ bool TrySynthesizeRomConfig(YAML::Node& config, const std::string& cartHash, con uint32_t ovlAddr = 0; uint8_t* ovl = FindPatchedOverlay(rom, ovlSize, ovlAddr); if (ovl == nullptr) { - SPDLOG_WARN("[ConfigFactory] {} is >16MB but doesn't look like a BB romhack. Aborting extraction.", - cartHash); + SPDLOG_WARN("[ConfigFactory] {} is >16MB but doesn't look like a BB romhack. Aborting extraction.", cartHash); return false; } free(ovl); // we only needed the validation, not the bytes diff --git a/src/factories/bk64/ModelFactory.cpp b/src/factories/bk64/ModelFactory.cpp index 0906e78..71977bc 100644 --- a/src/factories/bk64/ModelFactory.cpp +++ b/src/factories/bk64/ModelFactory.cpp @@ -240,8 +240,7 @@ ExportResult BK64::ModelBinaryExporter::Export(std::ostream& write, std::shared_ for (size_t i = 0; i + 1 < model->mRawDLWords.size(); i += 2) { uint32_t w0 = model->mRawDLWords[i]; uint32_t w1 = model->mRawDLWords[i + 1]; - if (!isFramebufferSubstitutionModel && (w0 >> 24) == 0xFD /* G_SETTIMG */ && - SEGMENT_NUMBER(w1) == 2) { + if (!isFramebufferSubstitutionModel && (w0 >> 24) == 0xFD /* G_SETTIMG */ && SEGMENT_NUMBER(w1) == 2) { auto it = imageOffsetToTex.find(SEGMENT_OFFSET(w1)); if (it != imageOffsetToTex.end()) { w1 = 0xFF000000u | (it->second & 0x00FFFFFFu); @@ -850,8 +849,7 @@ std::optional<std::shared_ptr<IParsedData>> ModelFactory::parse(std::vector<uint } uint16_t geoCnt = (uint16_t)((segment.data[at + 0x10] << 8) | segment.data[at + 0x11]); uint16_t triCnt = (uint16_t)((segment.data[at + 0x14] << 8) | segment.data[at + 0x15]); - size_t needed = - (size_t)kCollHeaderSize + (size_t)geoCnt * kGeoCubeSize + (size_t)triCnt * kCollTriSize; + size_t needed = (size_t)kCollHeaderSize + (size_t)geoCnt * kGeoCubeSize + (size_t)triCnt * kCollTriSize; return at + needed <= segment.size; }; diff --git a/src/factories/bk64/SpriteFactory.cpp b/src/factories/bk64/SpriteFactory.cpp index 1313d03..0dd065e 100644 --- a/src/factories/bk64/SpriteFactory.cpp +++ b/src/factories/bk64/SpriteFactory.cpp @@ -418,12 +418,12 @@ std::optional<std::shared_ptr<IParsedData>> SpriteFactory::parse_modding(std::ve unkA = GetSafeNode<int16_t>(content, "UnkA", positions[0].second); YAML::Node fh = frame0["FrameHeader"]; if (fh) { - frameHeaders.push_back({ GetSafeNode<int16_t>(fh, "x", i16(0)), GetSafeNode<int16_t>(fh, "y", i16(0)), - GetSafeNode<int16_t>(fh, "w", positions[0].first), - GetSafeNode<int16_t>(fh, "h", positions[0].second), - GetSafeNode<int16_t>(fh, "unkA", i16(0)), GetSafeNode<int16_t>(fh, "unkC", i16(0)), - GetSafeNode<int16_t>(fh, "unkE", i16(0)), GetSafeNode<int16_t>(fh, "unk10", i16(0)), - GetSafeNode<int16_t>(fh, "unk12", i16(0)) }); + frameHeaders.push_back( + { GetSafeNode<int16_t>(fh, "x", i16(0)), GetSafeNode<int16_t>(fh, "y", i16(0)), + GetSafeNode<int16_t>(fh, "w", positions[0].first), GetSafeNode<int16_t>(fh, "h", positions[0].second), + GetSafeNode<int16_t>(fh, "unkA", i16(0)), GetSafeNode<int16_t>(fh, "unkC", i16(0)), + GetSafeNode<int16_t>(fh, "unkE", i16(0)), GetSafeNode<int16_t>(fh, "unk10", i16(0)), + GetSafeNode<int16_t>(fh, "unk12", i16(0)) }); } else { frameHeaders.push_back({ 0, 0, positions[0].first, positions[0].second, 0, 0, 0, 0, 0 }); } @@ -431,15 +431,33 @@ std::optional<std::shared_ptr<IParsedData>> SpriteFactory::parse_modding(std::ve std::string format; switch (formatCode) { - case 0x1: format = "CI4"; break; - case 0x4: format = "CI8"; break; - case 0x20: format = "I4"; break; - case 0x40: format = "I8"; break; - case 0x80: format = "IA4"; break; - case 0x100: format = "IA8"; break; - case 0x400: format = "RGBA16"; break; - case 0x800: format = "RGBA32"; break; - default: format = "IA8"; break; + case 0x1: + format = "CI4"; + break; + case 0x4: + format = "CI8"; + break; + case 0x20: + format = "I4"; + break; + case 0x40: + format = "I8"; + break; + case 0x80: + format = "IA4"; + break; + case 0x100: + format = "IA8"; + break; + case 0x400: + format = "RGBA16"; + break; + case 0x800: + format = "RGBA32"; + break; + default: + format = "IA8"; + break; } const auto symbol = GetSafeNode<std::string>(node, "symbol"); diff --git a/src/factories/mk64/PackedDisplayListFactory.cpp b/src/factories/mk64/PackedDisplayListFactory.cpp index abf79ef..da9c727 100644 --- a/src/factories/mk64/PackedDisplayListFactory.cpp +++ b/src/factories/mk64/PackedDisplayListFactory.cpp @@ -101,10 +101,7 @@ #define G_TEXRECTFLIP 0xe5 /* -27 */ #define G_TEXRECT 0xe4 /* -28 */ -#define gsDPNoParam(cmd) \ - { \ - { _SHIFTL(cmd, 24, 8), 0 } \ - } +#define gsDPNoParam(cmd) { { _SHIFTL(cmd, 24, 8), 0 } } #define G_CCMUX_COMBINED 0 #define G_CCMUX_TEXEL0 1 diff --git a/src/factories/naudio/v0/AudioManager.cpp b/src/factories/naudio/v0/AudioManager.cpp index a340e0b..15d89a2 100644 --- a/src/factories/naudio/v0/AudioManager.cpp +++ b/src/factories/naudio/v0/AudioManager.cpp @@ -449,6 +449,9 @@ TBLFile AudioManager::parse_tbl(std::vector<uint8_t>& data, std::vector<Entry>& void AudioManager::initialize(std::vector<uint8_t>& buffer, YAML::Node& data) { + this->dialect = GetSafeNode<std::string>(data, "dialect", ""); + this->sessionFrequency = GetSafeNode<uint32_t>(data, "frequency", 0); + auto ctlOffset = data["ctl"]["offset"].as<size_t>(); auto ctlSize = data["ctl"]["size"].as<size_t>(); diff --git a/src/factories/naudio/v0/AudioManager.h b/src/factories/naudio/v0/AudioManager.h index 6f4a880..04f01a1 100644 --- a/src/factories/naudio/v0/AudioManager.h +++ b/src/factories/naudio/v0/AudioManager.h @@ -125,6 +125,13 @@ class AudioManager { public: static AudioManager* Instance; void initialize(std::vector<uint8_t>& buffer, YAML::Node& data); + + // Optional AUDIO_HEADER yaml settings: `dialect` (US/EU opcode set) and + // `frequency` (session rate the bank tunings are relative to). + const std::string& GetDialect() const { return dialect; } + uint32_t GetSessionFrequency() const { return sessionFrequency; } + std::string dialect; + uint32_t sessionFrequency = 0; void bind_sample(YAML::Node& node, const std::string& path); std::string& get_sample(uint32_t id); AudioBankSample get_aifc(int32_t index); diff --git a/src/factories/naudio/v0/SampleFactory.cpp b/src/factories/naudio/v0/SampleFactory.cpp index c800df4..7f1898d 100644 --- a/src/factories/naudio/v0/SampleFactory.cpp +++ b/src/factories/naudio/v0/SampleFactory.cpp @@ -62,4 +62,220 @@ std::optional<std::shared_ptr<IParsedData>> SampleFactory::parse(std::vector<uin } AudioBankSample entry = AudioManager::Instance->get_aifc(id); return std::make_shared<SampleData>(entry); -}
\ No newline at end of file +} +#ifdef BUILD_UI +#include <cmath> +#include <cstring> + +#include "ui/BaseBackend.h" +#include "ui/ExportUtils.h" +#include "ui/Widgets.h" + +namespace { + +struct DecodedSample { + std::string name; + std::vector<int16_t> pcm; + int rate = 0; + int channels = 1; +}; +DecodedSample sDecoded; // last decoded sample (they can be large) +std::string sPlayingName; +std::unordered_map<std::string, float> sSampleSpeeds; + +uint32_t ReadU32BE(const uint8_t* p) { + return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3]; +} + +// 80-bit IEEE 754 extended float, as used by the AIFF COMM sample rate. +double ReadExtended80(const uint8_t* p) { + const int exponent = (((p[0] & 0x7F) << 8) | p[1]) - 16383; + uint64_t mantissa = 0; + for (int i = 0; i < 8; ++i) { + mantissa = (mantissa << 8) | p[2 + i]; + } + double value = (double)mantissa * std::ldexp(1.0, exponent - 63); + return (p[0] & 0x80) != 0 ? -value : value; +} + +bool ParseAiff(const std::vector<char>& bytes, DecodedSample& out) { + const auto* data = (const uint8_t*)bytes.data(); + const size_t size = bytes.size(); + if (size < 12 || std::memcmp(data, "FORM", 4) != 0 || std::memcmp(data + 8, "AIFF", 4) != 0) { + return false; + } + uint32_t frames = 0; + int sampleBits = 16; + size_t pos = 12; + bool gotCommon = false, gotData = false; + while (pos + 8 <= size) { + const uint32_t ckSize = ReadU32BE(data + pos + 4); + const uint8_t* ck = data + pos + 8; + // The AIFC decoder over-declares the SSND size (the header overwrites + // the head of the sample data), so clamp instead of rejecting. + const size_t avail = std::min((size_t)ckSize, size - pos - 8); + if (std::memcmp(data + pos, "COMM", 4) == 0 && avail >= 18) { + out.channels = (int16_t)((ck[0] << 8) | ck[1]); + frames = ReadU32BE(ck + 2); + sampleBits = (int16_t)((ck[6] << 8) | ck[7]); + out.rate = (int)std::lround(ReadExtended80(ck + 8)); + gotCommon = true; + } else if (std::memcmp(data + pos, "SSND", 4) == 0 && avail >= 8) { + const uint32_t offset = ReadU32BE(ck); + if (avail > 8 + offset) { + const size_t count = (avail - 8 - offset) / 2; + out.pcm.resize(count); + const uint8_t* smp = ck + 8 + offset; + for (size_t i = 0; i < count; ++i) { + out.pcm[i] = (int16_t)((smp[i * 2] << 8) | smp[i * 2 + 1]); + } + gotData = true; + } + } + pos += 8 + ckSize + (ckSize & 1); + } + (void)frames; + return gotCommon && gotData && sampleBits == 16 && out.rate > 0 && out.channels > 0 && !out.pcm.empty(); +} + +bool DecodeSample(const ParseResultData& item) { + if (sDecoded.name == item.name) { + return !sDecoded.pcm.empty(); + } + sDecoded = {}; + sDecoded.name = item.name; + + auto sample = std::static_pointer_cast<SampleData>(item.data.value()); + const bool ok = DecodeSampleToPcm(&sample->mSample, sDecoded.pcm, sDecoded.rate); + sDecoded.channels = 1; + return ok; +} + +} // namespace + +bool DecodeAiffBytes(const std::vector<char>& bytes, std::vector<int16_t>& pcm, int& rate) { + DecodedSample decoded; + if (!ParseAiff(bytes, decoded)) { + return false; + } + pcm = std::move(decoded.pcm); + rate = decoded.rate; + return true; +} + +bool DecodeSampleToPcm(AudioBankSample* sample, std::vector<int16_t>& pcm, int& rate) { + LUS::BinaryWriter aifc; + AudioConverter::SampleV0ToAIFC(sample, aifc); + LUS::BinaryWriter aiff; + write_aiff(aifc.ToVector(), aiff); + aifc.Close(); + + DecodedSample decoded; + const bool ok = ParseAiff(aiff.ToVector(), decoded); + aiff.Close(); + if (!ok) { + return false; + } + // The converter's header overwrites the head of the decoded stream; pad the + // front back to the expected frame count so loop points stay aligned. + const size_t expected = sample->data.size() * 16 / 9; + if (decoded.pcm.size() < expected) { + decoded.pcm.insert(decoded.pcm.begin(), expected - decoded.pcm.size(), 0); + } + // Tunings are stored as floats, so derived rates land near the standard + // recording rates; snap when within 6%. + static const int kRates[] = { 8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000 }; + for (const int snap : kRates) { + if (std::fabs((float)decoded.rate - (float)snap) <= snap * 0.06f) { + decoded.rate = snap; + break; + } + } + pcm = std::move(decoded.pcm); + rate = decoded.rate; + return true; +} + +float SampleFactoryUI::GetItemHeight(const ParseResultData&) { + return ImGui::GetTextLineHeightWithSpacing() * 3.0f + ImGui::GetFrameHeightWithSpacing() * 2.0f + + ImGui::GetStyle().ItemSpacing.y * 4.0f; +} + +void SampleFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + const auto& sample = std::static_pointer_cast<SampleData>(item.data.value())->mSample; + ImGui::TextDisabled("sample \xe2\x80\x94 %zu bytes vadpcm, loop %u-%u (x%d), book order %d", sample.data.size(), + sample.loop.start, sample.loop.end, sample.loop.count, sample.book.order); + + auto speedIt = sSampleSpeeds.emplace(item.name, 1.0f).first; + const bool playingThis = sPlayingName == item.name && UI::GetBackend()->AudioProgress() >= 0.0f; + if (ImGui::Button(playingThis ? "Stop##sample" : "Play##sample")) { + if (playingThis) { + UI::GetBackend()->StopAudio(); + sPlayingName.clear(); + } else if (DecodeSample(item)) { + if (UI::GetBackend()->PlaySamples(sDecoded.pcm.data(), sDecoded.pcm.size() / sDecoded.channels, + sDecoded.rate, sDecoded.channels)) { + sPlayingName = item.name; + UI::GetBackend()->SetAudioSpeed(speedIt->second); + } + } + } + ImGui::SameLine(); + if (ImGui::Button("WAV##sampleexp")) { + if (DecodeSample(item)) { + const auto path = UI::ExportFilePath(item.name, "wav"); + UI::NoteExport(item.name, + UI::WriteWavFile(path, sDecoded.pcm.data(), sDecoded.pcm.size() / sDecoded.channels, + sDecoded.channels, sDecoded.rate) + ? path.string() + : "export failed"); + } else { + UI::NoteExport(item.name, "decode failed"); + } + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Export decoded sample to torch-exports/"); + } + UI::DrawExportMarker(item.name); + ImGui::SameLine(); + float volume = UI::GetBackend()->GetAudioVolume(); + ImGui::SetNextItemWidth(140.0f); + if (ImGui::SliderFloat("##vol", &volume, 0.0f, 1.0f, "vol %.2f")) { + UI::GetBackend()->SetAudioVolume(volume); + } + ImGui::SameLine(); + float& speed = speedIt->second; + ImGui::SetNextItemWidth(140.0f); + if (ImGui::SliderFloat("##speed", &speed, 0.25f, 4.0f, "%.2fx", ImGuiSliderFlags_Logarithmic)) { + // Snap near the common half/normal/double rates. + static const float kSnaps[] = { 0.5f, 1.0f, 2.0f }; + for (const float snap : kSnaps) { + if (std::fabs(speed - snap) < 0.05f) { + speed = snap; + break; + } + } + if (playingThis) { + UI::GetBackend()->SetAudioSpeed(speed); + } + } + ImGui::SameLine(); + if (sDecoded.name == item.name && !sDecoded.pcm.empty()) { + const float seconds = (float)sDecoded.pcm.size() / (float)(sDecoded.rate * sDecoded.channels); + ImGui::TextDisabled("%d Hz, %s, %.2fs", sDecoded.rate, sDecoded.channels == 1 ? "mono" : "stereo", seconds); + } else { + ImGui::TextDisabled("press play to decode"); + } + + float progress = playingThis ? std::max(UI::GetBackend()->AudioProgress(), 0.0f) : 0.0f; + ImGui::SetNextItemWidth(std::min(ImGui::GetContentRegionAvail().x, 420.0f)); + if (ImGui::SliderFloat("##seek", &progress, 0.0f, 1.0f, "") && playingThis) { + UI::GetBackend()->SeekAudio(progress); + } +} +#endif // BUILD_UI diff --git a/src/factories/naudio/v0/SampleFactory.h b/src/factories/naudio/v0/SampleFactory.h index 50161b6..bf0d5a0 100644 --- a/src/factories/naudio/v0/SampleFactory.h +++ b/src/factories/naudio/v0/SampleFactory.h @@ -32,4 +32,21 @@ public: } bool SupportModdedAssets() override { return true; } -};
\ No newline at end of file +}; + +#ifdef BUILD_UI +// Decodes a sample to PCM16 through the AIFF converter. The PCM is 1:1 with +// the vadpcm frames (front-padded so loop points stay aligned); `rate` is the +// tuning-derived natural rate. +bool DecodeSampleToPcm(AudioBankSample* sample, std::vector<int16_t>& pcm, int& rate); + +// Parses an in-memory AIFF (as produced by write_aiff) into PCM16. +bool DecodeAiffBytes(const std::vector<char>& bytes, std::vector<int16_t>& pcm, int& rate); + +// Plays the sample (decoded through the AIFF converter) with basic controls. +class SampleFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; +#endif diff --git a/src/factories/naudio/v0/SequenceFactory.cpp b/src/factories/naudio/v0/SequenceFactory.cpp index d9c1f96..d3fc828 100644 --- a/src/factories/naudio/v0/SequenceFactory.cpp +++ b/src/factories/naudio/v0/SequenceFactory.cpp @@ -69,4 +69,4 @@ std::optional<std::shared_ptr<IParsedData>> SequenceFactory::parse(std::vector<u std::optional<std::shared_ptr<IParsedData>> SequenceFactory::parse_modding(std::vector<uint8_t>& buffer, YAML::Node& node) { return std::make_shared<RawBuffer>(buffer.data(), buffer.size()); -}
\ No newline at end of file +} diff --git a/src/factories/naudio/v0/SequenceFactory.h b/src/factories/naudio/v0/SequenceFactory.h index 3c706d0..0b4d9e0 100644 --- a/src/factories/naudio/v0/SequenceFactory.h +++ b/src/factories/naudio/v0/SequenceFactory.h @@ -41,4 +41,4 @@ public: } bool SupportModdedAssets() override { return true; } -};
\ No newline at end of file +}; diff --git a/src/factories/naudio/v0/SequencePlayerV0.cpp b/src/factories/naudio/v0/SequencePlayerV0.cpp new file mode 100644 index 0000000..dc14547 --- /dev/null +++ b/src/factories/naudio/v0/SequencePlayerV0.cpp @@ -0,0 +1,1220 @@ +#ifdef BUILD_UI + +#include "SequencePlayerV0.h" + +#include <algorithm> +#include <climits> +#include <cmath> +#include <cstring> +#include <map> +#include <memory> +#include <unordered_map> + +#include "AudioManager.h" +#include "SampleFactory.h" +#include "SequenceFactory.h" +#include "spdlog/spdlog.h" + +namespace { + +constexpr int kMaxTicks = 60000; +constexpr size_t kMaxEvents = 60000; + +using NoteEvent = UI::SynthNote; + +struct M64Exec { + const uint8_t* data = nullptr; + size_t size = 0; + size_t pc = 0; + size_t stack[4]{}; + int sp = 0; + struct { + size_t addr; + int count; + } loops[4]{}; + int lp = 0; + int delay = 0; + bool active = false; + bool failed = false; + + uint8_t U8() { + if (pc >= size) { + failed = true; + return 0; + } + return data[pc++]; + } + int8_t S8() { + return (int8_t)U8(); + } + uint16_t U16() { + const uint16_t hi = U8(); + return (uint16_t)((hi << 8) | U8()); + } + int Var() { // m64_read_compressed_u16 + int v = U8(); + if (v & 0x80) { + v = ((v & 0x7F) << 8) | U8(); + } + return v; + } + void Start(size_t addr) { + pc = addr; + sp = 0; + lp = 0; + delay = 0; + active = addr < size; + failed = false; + } + void End() { + active = false; + } +}; + +struct LayerSt { + M64Exec ex; + int transpose = 0; + int noteDuration = 0x80; + int lastDelay = 0; + int shortVel = 0x50; + int shortDur = 0; + int shortDefaultDelay = 0; + int portaMode = 0; + int portaTarget = 0; + int portaTime = 0; + bool continuous = false; + int lastEvent = -1; // events index of the previous note on this layer + double lastSlotEnd = 0.0; + float lastVel = 1.0f; + float pan = -1.0f; // <0 = inherit channel + int envOverride = -1; // EU layer 0xCB + int releaseRate = -1; // EU layer 0xCB + bool ignoreDrumPan = false; + const Instrument* inst = nullptr; // layer override + bool drums = false; + bool instSet = false; +}; + +struct ChanSt { + M64Exec ex; + bool largeNotes = false; + bool hasInst = false; + bool drums = false; + const Instrument* inst = nullptr; + const Bank* bank = nullptr; + float vol = 1.0f; + float volScale = 1.0f; + float freqScale = 1.0f; + float pan = 0.5f; + int transpose = 0; + int8_t value = 0; + int dynTable = -1; + uint8_t vibExtent = 0; + uint8_t vibExtentStart = 0; + uint8_t vibRate = 0; + uint8_t vibRateStart = 0; + uint8_t vibRateRamp = 0; + uint8_t vibDelay = 0; + uint8_t vibRamp = 0; + uint8_t reverb = 0; + uint8_t releaseRate = 0x20; + float sustain = 0.0f; // fraction of gate-end level (0xD2 / 256) + float panWeight = 1.0f; // channel share of the pan blend (0xDC / 128) + int envOverride = -1; // bank envelope offset from 0xDA + LayerSt layers[8]; +}; + +struct SeqSt { + M64Exec ex; + float vol = 1.0f; + int transpose = 0; + int tempo = 120; + int8_t value = 0; + ChanSt chans[16]; +}; + +// EU engines derive their ADSR tick rate from the session frequency +// (60 fps frames, 192-sample chunks); US SM64 runs a fixed 240 ticks/s. +float AdsrTickRate(bool euDialect, uint32_t frequency) { + if (!euDialect) { + return 240.0f; + } + const uint32_t samplesPerFrame = ((frequency / 60) + 15) & ~15u; + return 60.0f * (float)((samplesPerFrame + 16) / 192 + 1); +} + +// US: fadeOutVel = rate*24 against 0x8000 at 240 ticks/s. +// EU (MK64): fadeOutVel = rate*(3/2560)/updates against 1.0. +float ReleaseSeconds(uint8_t rate, bool euDialect, float tickRate) { + if (rate == 0) { + return 0.016f; // one-frame fade + } + if (euDialect) { + return 2560.0f / ((float)rate * tickRate); + } + return 32768.0f / ((float)rate * 24.0f * 240.0f); +} + +std::shared_ptr<UI::SynthSample> SynthSampleFor(AudioBankSample* sample) { + static std::unordered_map<AudioBankSample*, std::shared_ptr<UI::SynthSample>> sCache; + if (sample == nullptr) { + return nullptr; + } + auto it = sCache.find(sample); + if (it != sCache.end()) { + return it->second; + } + auto synth = std::make_shared<UI::SynthSample>(); + int rate = 0; + DecodeSampleToPcm(sample, synth->pcm, rate); + synth->loopStart = sample->loop.start; + synth->loopEnd = sample->loop.end; + synth->looped = sample->loop.count != 0; + return sCache.emplace(sample, std::move(synth)).first->second; +} + +// ADSR envelope to breakpoints: delay in 1/240s updates, level squared; +// ADSR_GOTO (-2) / ADSR_RESTART (-3) set the loop point. +template <typename Points> void FillEnvelopePoints(UI::SynthNote& ev, const Points& points) { + std::vector<int> entryPt; + double at = 0.0; + for (const auto& entry : points) { + if (entry.delay > 0) { + at += (double)entry.delay / 240.0; + const float lvl = (float)entry.arg / 32767.0f; + entryPt.push_back((int)ev.envPoints.size()); + ev.envPoints.emplace_back((float)at, lvl * lvl); + if (ev.envPoints.size() >= 16) { + break; + } + } else if (entry.delay == -2) { + if (entry.arg >= 0 && entry.arg < (int)entryPt.size() && entryPt[entry.arg] >= 0) { + ev.envLoopStartT = ev.envPoints[entryPt[entry.arg]].first; + } + break; + } else if (entry.delay == -3) { + ev.envLoopStartT = 0.0f; + break; + } else { + break; + } + entryPt.push_back(-1); + } +} + +struct EnvPoint { + int16_t delay; + int16_t arg; +}; + +// Envelope overrides (chan 0xDA, layer 0xCB) point into the sequence data +// itself; bank envelopes are keyed by ctl offset. +void FillEnvelope(UI::SynthNote& ev, const Bank* bank, int envOverride, uint32_t envKey, + const std::vector<uint8_t>& seq) { + if (envOverride >= 0) { + std::vector<EnvPoint> points; + for (size_t at = (size_t)envOverride; at + 3 < seq.size() && points.size() < 24; at += 4) { + const int16_t delay = (int16_t)((seq[at] << 8) | seq[at + 1]); + const int16_t arg = (int16_t)((seq[at + 2] << 8) | seq[at + 3]); + points.push_back({ delay, arg }); + if (delay <= 0) { + break; + } + } + FillEnvelopePoints(ev, points); + return; + } + const auto eit = bank->envelopes.find(envKey); + if (eit == bank->envelopes.end()) { + return; + } + FillEnvelopePoints(ev, eit->second.entries); +} + +float NoteFreq(int note) { + return std::pow(2.0f, (float)(note - 39) / 12.0f); +} + +AudioBankSample* SampleByOffset(const Bank* bank, uint32_t offset) { + if (bank == nullptr) { + return nullptr; + } + auto it = bank->samples.find(offset); + return it != bank->samples.end() ? it->second : nullptr; +} + +struct SeqRenderer { + const std::vector<uint8_t>& buf; + std::vector<const Bank*> banks; + SeqSt seq; + std::vector<NoteEvent> events; + double sec = 0.0; + int8_t seqVariation = 0; + int shortVelTable = -1; + int shortDurTable = -1; + // MK64 ships the EU revision of the library: seq 0xDA/0xDB are fade + // setup/target, channel 0x6N is delayshort, layers gain 0xCB/0xCC. + bool euDialect = false; + // MK64 skips SM64's resampleRate (32000/sessionFreq) multiply, so its + // tunings are relative to the 26800 Hz session rate, not 32000. + float freqRatio = 1.0f; + float adsrTick = 240.0f; + int pendingFadeTicks = 0; + bool initChanMask[16]{}; + // Master*channel gain over time, so fades reach already-sounding notes. + UI::GainAutomation gainAuto[16]; + // Channel freq multiplier over time (0xD3/0xDE bends on sounding notes). + UI::PitchAutomation pitchAuto[16]; + + void PushGain(int idx) { + const ChanSt& ch = seq.chans[idx]; + // The EU engine squares the whole channel volume chain + // (appliedVolume = channelVolume^2); US applies it linearly. + float g = seq.vol * ch.vol * ch.volScale; + if (euDialect) { + g *= g; + } + auto& a = gainAuto[idx]; + if (a.empty() || a.back().second != g) { + a.emplace_back((float)sec, g); + } + } + + void PushGainAll() { + for (int i = 0; i < 16; ++i) { + if (seq.chans[i].ex.data != nullptr) { + PushGain(i); + } + } + } + + void PushPitch(int idx) { + auto& a = pitchAuto[idx]; + const float f = seq.chans[idx].freqScale; + if (a.empty() || a.back().second != f) { + a.emplace_back((float)sec, f); + } + } + + int ReadU16At(int offset) const { + if (offset < 0 || offset + 1 >= (int)buf.size()) { + return -1; + } + return (buf[offset] << 8) | buf[offset + 1]; + } + + uint8_t ReadU8At(int offset) const { + return offset >= 0 && offset < (int)buf.size() ? buf[offset] : 0; + } + + explicit SeqRenderer(const std::vector<uint8_t>& b) : buf(b) { + } + + // Enabling a channel keeps its state (instrument, volume, pan, large-note + // mode); only the script restarts and layers are freed. + void StartChannel(int idx, uint16_t addr) { + if (idx < 0 || idx >= 16) { + return; + } + ChanSt& ch = seq.chans[idx]; + if (ch.ex.data == nullptr) { + ch = ChanSt{}; + ch.bank = banks.empty() ? nullptr : banks[0]; + } + for (auto& ly : ch.layers) { + ly = LayerSt{}; + } + ch.value = 0; + ch.ex.data = buf.data(); + ch.ex.size = buf.size(); + ch.ex.Start(addr); + PushGain(idx); + PushPitch(idx); + } + + void EmitNote(ChanSt& ch, LayerSt& ly, int pitch, int vel, int delayTicks, int durByte) { + if (!ch.hasInst && !ly.instSet) { + return; + } + const bool drums = ly.instSet ? ly.drums : ch.drums; + const Instrument* inst = ly.instSet ? ly.inst : ch.inst; + const Bank* bank = ch.bank; + if (bank == nullptr || events.size() >= kMaxEvents) { + return; + } + + NoteEvent ev{}; + ev.startSec = sec; + const double secPerTick = 1.25 / (double)std::max(seq.tempo, 1); + // The duration byte sets the release tail: the note decays once the + // remaining delay reaches noteDuration * delay / 256. + const int soundTicks = delayTicks - delayTicks * durByte / 256; + ev.soundSec = std::max(soundTicks, 1) * secPerTick; + const float velGain = ((float)vel / 127.0f) * ((float)vel / 127.0f); + ev.gain = velGain; + ev.chan = (int)(&ch - seq.chans); + // notePan = chanPan*weight + layerPan*(1-weight); weight defaults to 1. + const float layerPan = ly.pan >= 0.0f ? ly.pan : 0.5f; + ev.pan = ch.pan * ch.panWeight + layerPan * (1.0f - ch.panWeight); + ev.reverb = (float)ch.reverb / 127.0f; + ev.releaseSec = + ReleaseSeconds(ly.releaseRate >= 0 ? (uint8_t)ly.releaseRate : ch.releaseRate, euDialect, adsrTick); + ev.sustainLevel = ch.sustain; + const float tick = adsrTick; + ev.sustainHoldSec = 128.0f / tick; + ev.vibDepthStart = (float)ch.vibExtentStart / 127.0f; + ev.vibDepthEnd = (float)ch.vibExtent / 127.0f; + ev.vibDelaySec = (float)ch.vibDelay * 16.0f / tick; + ev.vibRampSec = (float)ch.vibRamp * 16.0f / tick; + ev.vibRateStartHz = (float)ch.vibRateStart / 16.0f * (tick / 240.0f); + ev.vibRateEndHz = (float)ch.vibRate / 16.0f * (tick / 240.0f); + ev.vibRateRampSec = (float)ch.vibRateRamp * 16.0f / tick; + ev.portaRatio = 1.0f; + ev.portaSec = 0.0f; + + if (drums) { + const int idx = std::clamp(pitch + ch.transpose + ly.transpose, 0, + bank->drums.empty() ? 0 : (int)bank->drums.size() - 1); + if (bank->drums.empty()) { + return; + } + const Drum& drum = bank->drums[idx]; + ev.sample = SynthSampleFor(SampleByOffset(bank, drum.sound.offset)); + ev.freqScale = drum.sound.tuning; + if (!ly.ignoreDrumPan) { + ev.pan = ch.pan * ch.panWeight + ((float)drum.pan / 128.0f) * (1.0f - ch.panWeight); + } + ev.releaseSec = ReleaseSeconds(drum.releaseRate, euDialect, adsrTick); + FillEnvelope(ev, bank, ly.envOverride >= 0 ? ly.envOverride : ch.envOverride, drum.envelope, buf); + } else { + if (inst == nullptr) { + return; + } + const int note = pitch + seq.transpose + ch.transpose + ly.transpose; + if (note < 0 || note >= 0x80) { + return; + } + const std::optional<AudioBankSound>* sound = &inst->soundMed; + if (note < inst->normalRangeLo && inst->soundLo.has_value()) { + sound = &inst->soundLo; + } else if (note > inst->normalRangeHi && inst->soundHi.has_value()) { + sound = &inst->soundHi; + } + if (!sound->has_value()) { + return; + } + ev.sample = SynthSampleFor(SampleByOffset(bank, sound->value().offset)); + ev.freqScale = NoteFreq(note) * sound->value().tuning; + FillEnvelope(ev, bank, ly.envOverride >= 0 ? ly.envOverride : ch.envOverride, inst->envelope, buf); + if (ly.portaMode != 0 && ly.portaTime > 0) { + const int target = std::clamp(ly.portaTarget + seq.transpose + ch.transpose + ly.transpose, 0, 127); + const float targetFreq = NoteFreq(target) * sound->value().tuning; + // Odd modes slide note->target; even modes slide target->note. + if ((ly.portaMode & 1) != 0) { + ev.portaRatio = targetFreq / ev.freqScale; + } else { + ev.portaRatio = ev.freqScale / targetFreq; + ev.freqScale = targetFreq; + } + ev.portaSec = (float)((ly.portaMode & 0x80) != 0 ? ev.soundSec * (double)ly.portaTime / 255.0 + : (double)ly.portaTime * secPerTick); + } + } + ev.freqScale *= freqRatio; + if (ev.sample == nullptr || ev.freqScale <= 0.0f) { + return; + } + // Legato: extend the previous note on this layer instead of retriggering + // when the notes are adjacent and use the same sample. + if (ly.continuous && ly.lastEvent >= 0 && ly.lastEvent < (int)events.size()) { + NoteEvent& prev = events[ly.lastEvent]; + if (prev.sample == ev.sample && std::fabs(ly.lastSlotEnd - sec) < secPerTick * 0.5 && + prev.segs.size() < 96) { + prev.soundSec = (sec - prev.startSec) + ev.soundSec; + NoteEvent::Seg seg; + seg.t = (float)(sec - prev.startSec); + seg.freq = ev.freqScale; + seg.gainMul = ly.lastVel > 0.0001f ? velGain / ly.lastVel : 1.0f; + prev.segs.push_back(seg); + ly.lastSlotEnd = sec + delayTicks * secPerTick; + return; + } + } + ly.lastEvent = (int)events.size(); + ly.lastSlotEnd = sec + delayTicks * secPerTick; + ly.lastVel = velGain; + events.push_back(ev); + } + + void SetChanInstrument(ChanSt& ch, uint8_t instId) { + ch.hasInst = false; + ch.drums = false; + ch.inst = nullptr; + if (instId >= 0x80) { + return; // raw waves unsupported + } + if (instId == 0x7F) { + ch.drums = true; + ch.hasInst = true; + return; + } + if (ch.bank != nullptr && instId < ch.bank->insts.size() && ch.bank->insts[instId].valid) { + ch.inst = &ch.bank->insts[instId]; + ch.releaseRate = ch.inst->releaseRate; + ch.hasInst = true; + } + } + + // Shared flow commands; returns true when handled. + bool Flow(M64Exec& ex, uint8_t cmd, int8_t value) { + switch (cmd) { + case 0xFF: + if (ex.sp > 0) { + ex.pc = ex.stack[--ex.sp]; + } else { + ex.End(); + } + return true; + case 0xFE: + ex.delay = 1; + return true; + case 0xFD: + ex.delay = std::max(ex.Var(), 1); + return true; + case 0xFC: { + const uint16_t addr = ex.U16(); + if (ex.sp < 4) { + ex.stack[ex.sp++] = ex.pc; + ex.pc = addr; + } + return true; + } + case 0xFB: + ex.pc = ex.U16(); + return true; + case 0xFA: { + const uint16_t addr = ex.U16(); + if (value == 0) { + ex.pc = addr; + } + return true; + } + case 0xF9: { + const uint16_t addr = ex.U16(); + if (value < 0) { + ex.pc = addr; + } + return true; + } + case 0xF5: { + const uint16_t addr = ex.U16(); + if (value >= 0) { + ex.pc = addr; + } + return true; + } + case 0xF8: { + int count = ex.U8(); + if (count == 0) { + count = 256; + } + if (ex.lp < 4) { + ex.loops[ex.lp].addr = ex.pc; + ex.loops[ex.lp].count = count; + ex.lp++; + } + return true; + } + case 0xF7: + if (ex.lp > 0) { + if (--ex.loops[ex.lp - 1].count > 0) { + ex.pc = ex.loops[ex.lp - 1].addr; + } else { + ex.lp--; + } + } + return true; + case 0xF6: + if (ex.lp > 0) { + ex.lp--; + } + return true; + case 0xF4: { + const int8_t rel = ex.S8(); + ex.pc += rel; + return true; + } + case 0xF3: { + const int8_t rel = ex.S8(); + if (value == 0) { + ex.pc += rel; + } + return true; + } + case 0xF2: { + const int8_t rel = ex.S8(); + if (value < 0) { + ex.pc += rel; + } + return true; + } + default: + return false; + } + } + + void StepSeq() { + M64Exec& ex = seq.ex; + int guard = 0; + while (ex.active && !ex.failed && ex.delay == 0 && guard++ < 1000) { + const uint8_t cmd = ex.U8(); + if (Flow(ex, cmd, seq.value)) { + continue; + } + switch (cmd) { + case 0xF1: + ex.U8(); + break; + case 0xF0: + break; + case 0xDF: + seq.transpose = ex.S8(); + break; + case 0xDE: + seq.transpose += ex.S8(); + break; + case 0xDD: + seq.tempo = std::max<int>(ex.U8(), 1); + break; + case 0xDC: + seq.tempo = std::max(seq.tempo + ex.S8(), 1); + break; + case 0xDB: { + const float target = (float)ex.U8() / 127.0f; + if (euDialect && pendingFadeTicks > 0) { + const double secPerTick = 1.25 / (double)std::max(seq.tempo, 1); + const float startVol = seq.vol; + const int steps = std::min(pendingFadeTicks, 64); + for (int i = 1; i <= steps; ++i) { + const float f = (float)i / (float)steps; + seq.vol = startVol + (target - startVol) * f; + const double at = sec + pendingFadeTicks * secPerTick * f; + for (int c = 0; c < 16; ++c) { + if (seq.chans[c].ex.data != nullptr) { + const ChanSt& c2 = seq.chans[c]; + const float cg = seq.vol * c2.vol * c2.volScale; + gainAuto[c].emplace_back((float)at, cg * cg); + } + } + } + pendingFadeTicks = 0; + } else { + seq.vol = target; + PushGainAll(); + } + break; + } + case 0xDA: + if (euDialect) { // fade setup: mode + duration + ex.U8(); + pendingFadeTicks = ex.U16(); + } else { // changevol + seq.vol = std::clamp(seq.vol + (float)ex.S8() / 127.0f, 0.0f, 2.0f); + PushGainAll(); + } + break; + case 0xD9: + ex.U8(); + break; + case 0xD7: { + const uint16_t mask = ex.U16(); + for (int i = 0; i < 16; ++i) { + initChanMask[i] = (mask >> i) & 1; + if (initChanMask[i]) { + seq.chans[i].ex.End(); + } + } + break; + } + case 0xD6: { + const uint16_t mask = ex.U16(); + for (int i = 0; i < 16; ++i) { + if ((mask >> i) & 1) { + seq.chans[i].ex.End(); + } + } + break; + } + case 0xD5: + ex.S8(); + break; + case 0xD4: + break; + case 0xD3: + ex.U8(); + break; + case 0xD2: + shortVelTable = ex.U16(); + break; + case 0xD1: + shortDurTable = ex.U16(); + break; + case 0xD0: + ex.U8(); + break; + case 0xCC: + seq.value = (int8_t)ex.U8(); + break; + case 0xC9: + seq.value = (int8_t)(seq.value & ex.U8()); + break; + case 0xC8: + seq.value = (int8_t)(seq.value - ex.U8()); + break; + case 0xC7: // writeseq + ex.U8(); + ex.U16(); + break; + case 0xC6: + ex.End(); + break; + default: { + if (cmd >= 0xC0) { + break; // the game skips undefined upper commands too + } + const int lo = cmd & 0x0F; + switch (cmd & 0xF0) { + case 0x90: + StartChannel(lo, ex.U16()); + break; + case 0x00: + seq.value = (int8_t)(lo < 16 && !seq.chans[lo].ex.active ? 1 : 0); + break; + case 0x50: + seq.value = (int8_t)(seq.value - seqVariation); + break; + case 0x70: + seqVariation = seq.value; + break; + case 0x80: + seq.value = seqVariation; + break; + default: + break; // remaining low commands take no arguments + } + break; + } + } + } + } + + void StepChan(ChanSt& ch) { + M64Exec& ex = ch.ex; + int guard = 0; + while (ex.active && !ex.failed && ex.delay == 0 && guard++ < 1000) { + const uint8_t cmd = ex.U8(); + if (Flow(ex, cmd, ch.value)) { + continue; + } + switch (cmd) { + case 0xF1: + ex.U8(); + break; + case 0xF0: + break; + case 0xEA: + ex.End(); + break; + case 0xEB: { // setbankandinstr + const uint8_t idx = ex.U8(); + if (idx < banks.size()) { + ch.bank = banks[idx]; + } + SetChanInstrument(ch, ex.U8()); + break; + } + case 0xC1: + SetChanInstrument(ch, ex.U8()); + break; + case 0xC2: + ch.dynTable = ex.U16(); + break; + case 0xC3: + ch.largeNotes = false; + break; + case 0xC4: + ch.largeNotes = true; + break; + case 0xC5: // dynsetdyntable + if (ch.dynTable >= 0 && ch.value != -1) { + ch.dynTable = ReadU16At(ch.dynTable + 2 * (uint8_t)ch.value); + } + break; + case 0xC6: { + const uint8_t idx = ex.U8(); + if (idx < banks.size()) { + ch.bank = banks[idx]; + } + break; + } + case 0xC7: + ex.U8(); + ex.U16(); + break; + case 0xC8: + ch.value = (int8_t)(ch.value - ex.U8()); + break; + case 0xC9: + ch.value = (int8_t)(ch.value & ex.U8()); + break; + case 0xCA: + ex.U8(); + break; + case 0xCB: + ex.U16(); + break; + case 0xCC: + ch.value = (int8_t)ex.U8(); + break; + case 0xD0: + case 0xD1: + ex.U8(); + break; + case 0xD2: // adsr sustain + ch.sustain = (float)ex.U8() / 256.0f; + break; + case 0xD3: // pitch bend, +/- one octave + ch.freqScale = std::pow(2.0f, (float)ex.S8() / 127.0f); + PushPitch((int)(&ch - seq.chans)); + break; + case 0xD4: + ch.reverb = ex.U8(); + break; + case 0xD6: + ex.U8(); + break; + case 0xD9: + ch.releaseRate = ex.U8(); + break; + case 0xD7: + ch.vibRate = ex.U8(); + ch.vibRateStart = ch.vibRate; + ch.vibRateRamp = 0; + break; + case 0xD8: + ch.vibExtent = ex.U8(); + ch.vibExtentStart = ch.vibExtent; + ch.vibRamp = 0; + break; + case 0xDA: + ch.envOverride = ex.U16(); + break; + case 0xDB: + ch.transpose = ex.S8(); + break; + case 0xDC: // pan channel weight + ch.panWeight = (float)ex.U8() / 128.0f; + break; + case 0xDD: + ch.pan = (float)std::clamp<int>(ex.U8(), 0, 128) / 128.0f; + break; + case 0xDE: // raw frequency multiplier N/2^15 + ch.freqScale = (float)ex.U16() / 32768.0f; + PushPitch((int)(&ch - seq.chans)); + break; + case 0xDF: + ch.vol = (float)ex.U8() / 127.0f; + PushGain((int)(&ch - seq.chans)); + break; + case 0xE0: + ch.volScale = (float)ex.U8() / 128.0f; + PushGain((int)(&ch - seq.chans)); + break; + case 0xE1: { // vibrato rate linear: start, target, delay + ch.vibRateStart = ex.U8(); + ch.vibRate = ex.U8(); + ch.vibRateRamp = ex.U8(); + break; + } + case 0xE2: { // vibrato extent linear: start, target, delay + ch.vibExtentStart = ex.U8(); + ch.vibExtent = ex.U8(); + ch.vibRamp = ex.U8(); + break; + } + case 0xE3: + ch.vibDelay = ex.U8(); + break; + case 0xE5: + case 0xE6: + case 0xE9: + case 0xED: + case 0xEE: + ex.U8(); + break; + case 0xE4: { // dyncall + if (ch.dynTable >= 0 && ch.value != -1) { + const int addr = ReadU16At(ch.dynTable + 2 * (uint8_t)ch.value); + if (addr >= 0 && ex.sp < 4) { + ex.stack[ex.sp++] = ex.pc; + ex.pc = (size_t)addr; + } + } + break; + } + case 0xEC: + break; + case 0xE7: + case 0xCE: + case 0xCF: + ex.U16(); + break; + case 0xEF: + ex.U16(); + ex.U8(); + break; + case 0xE8: + for (int i = 0; i < 8; ++i) { + ex.U8(); + } + break; + case 0xCD: { // disable channel by index + const uint8_t idx = ex.U8(); + if (idx < 16) { + seq.chans[idx].ex.End(); + } + break; + } + default: { + const int lo = cmd & 0x0F; + switch (cmd & 0xF0) { + case 0x00: + ch.value = (int8_t)(lo < 8 && !ch.layers[lo].ex.active ? 1 : 0); + break; + case 0x10: + StartChannel(lo, ex.U16()); + break; + case 0x20: + if (lo < 16) { + seq.chans[lo].ex.End(); + } + break; + case 0x30: + case 0x40: + ex.U8(); + break; + case 0x50: + case 0x70: + case 0x80: + break; + case 0x60: // US: setnotepriority; EU: delayshort + if (euDialect) { + ex.delay = std::max(lo, 1); + } + break; + case 0xB0: { // dynsetlayer + if (ch.dynTable >= 0 && ch.value != -1 && lo < 8) { + const int addr = ReadU16At(ch.dynTable + 2 * (uint8_t)ch.value); + if (addr >= 0) { + ch.layers[lo] = LayerSt{}; + ch.layers[lo].ex.data = buf.data(); + ch.layers[lo].ex.size = buf.size(); + ch.layers[lo].ex.Start((size_t)addr); + } + } + break; + } + case 0x90: { + const uint16_t addr = ex.U16(); + if (lo < 8) { + ch.layers[lo] = LayerSt{}; + ch.layers[lo].ex.data = buf.data(); + ch.layers[lo].ex.size = buf.size(); + ch.layers[lo].ex.Start(addr); + } + break; + } + case 0xA0: + if (lo < 8) { + ch.layers[lo].ex.End(); + } + break; + default: + break; // remaining low commands take no arguments + } + break; + } + } + } + } + + void StepLayer(ChanSt& ch, LayerSt& ly) { + M64Exec& ex = ly.ex; + int guard = 0; + while (ex.active && !ex.failed && ex.delay == 0 && guard++ < 1000) { + const uint8_t cmd = ex.U8(); + if (cmd >= 0xF2 && Flow(ex, cmd, 0)) { + continue; + } + if (cmd >= 0xC0) { + switch (cmd) { + case 0xC0: + ex.delay = std::max(ex.Var(), 1); + break; + case 0xC1: + ly.shortVel = ex.U8(); + break; + case 0xC2: + ly.transpose = ex.S8(); + break; + case 0xC3: + ly.shortDefaultDelay = ex.Var(); + break; + case 0xC4: + ly.continuous = true; + break; + case 0xC5: + ly.continuous = false; + break; + case 0xC8: + ly.portaMode = 0; + break; + case 0xC6: { + const uint8_t instId = ex.U8(); + ly.instSet = true; + ly.drums = false; + ly.inst = nullptr; + if (instId == 0x7F) { + ly.drums = true; + } else if (instId < 0x80 && ch.bank != nullptr && instId < ch.bank->insts.size() && + ch.bank->insts[instId].valid) { + ly.inst = &ch.bank->insts[instId]; + ly.releaseRate = ly.inst->releaseRate; + } else { + ly.instSet = false; + } + break; + } + case 0xC7: { // portamento + ly.portaMode = ex.U8(); + ly.portaTarget = ex.U8(); + if ((ly.portaMode & 0x80) != 0) { + ly.portaTime = ex.U8(); + } else { + ly.portaTime = ex.Var(); + } + break; + } + case 0xC9: + ly.shortDur = ex.U8(); + break; + case 0xCA: + ly.pan = (float)std::clamp<int>(ex.U8(), 0, 128) / 128.0f; + break; + case 0xCB: // EU: envelope + release rate + ly.envOverride = ex.U16(); + ly.releaseRate = ex.U8(); + break; + case 0xCC: // EU: ignore drum pan + ly.ignoreDrumPan = true; + break; + default: + if ((cmd & 0xF0) == 0xD0) { // velocity from seq table + if (shortVelTable >= 0) { + ly.shortVel = ReadU8At(shortVelTable + (cmd & 0x0F)); + } + break; + } + if ((cmd & 0xF0) == 0xE0) { // duration from seq table + if (shortDurTable >= 0) { + ly.shortDur = ReadU8At(shortDurTable + (cmd & 0x0F)); + } + break; + } + ex.failed = true; + break; + } + continue; + } + + // Note command. + int pitch = cmd & 0x3F; + int vel = 0x40; + int delayTicks = 0; + int durByte = ly.noteDuration; + if (ch.largeNotes) { + switch (cmd & 0xC0) { + case 0x00: + delayTicks = ex.Var(); + vel = ex.U8(); + durByte = ex.U8(); + ly.lastDelay = delayTicks; + break; + case 0x40: + delayTicks = ex.Var(); + vel = ex.U8(); + durByte = 0; + ly.lastDelay = delayTicks; + break; + case 0x80: + delayTicks = ly.lastDelay; + vel = ex.U8(); + durByte = ex.U8(); + break; + } + } else { + switch (cmd & 0xC0) { + case 0x00: + delayTicks = ex.Var(); + ly.lastDelay = delayTicks; + break; + case 0x40: + delayTicks = ly.shortDefaultDelay; + break; + case 0x80: + delayTicks = ly.lastDelay; + break; + } + vel = ly.shortVel; + durByte = ly.shortDur; + } + delayTicks = std::max(delayTicks, 1); + EmitNote(ch, ly, pitch, std::clamp(vel, 0, 127), delayTicks, durByte); + ex.delay = delayTicks; + } + } + + void Run() { + seq.ex.data = buf.data(); + seq.ex.size = buf.size(); + seq.ex.Start(0); + + for (int tick = 0; tick < kMaxTicks && sec < UI::kSynthMaxSeconds; ++tick) { + if (seq.ex.active) { + if (seq.ex.delay > 0) { + seq.ex.delay--; + } + if (seq.ex.delay == 0) { + StepSeq(); + } + } + bool anyChan = false; + for (auto& ch : seq.chans) { + if (!ch.ex.active) { + continue; + } + anyChan = true; + if (ch.ex.delay > 0) { + ch.ex.delay--; + } + if (ch.ex.delay == 0) { + StepChan(ch); + } + for (auto& ly : ch.layers) { + if (!ly.ex.active) { + continue; + } + if (ly.ex.delay > 0) { + ly.ex.delay--; + } + if (ly.ex.delay == 0) { + StepLayer(ch, ly); + } + } + } + if (!seq.ex.active && !anyChan) { + break; + } + sec += 1.25 / (double)std::max(seq.tempo, 1); + } + } +}; + +const Bank* FindBankByName(const std::map<uint32_t, Bank>& banks, const std::string& name) { + const auto lower = [](std::string v) { + std::transform(v.begin(), v.end(), v.begin(), ::tolower); + return v; + }; + const auto slash = name.find_last_of('/'); + const std::string base = lower(slash != std::string::npos ? name.substr(slash + 1) : name); + for (const auto& [id, bank] : banks) { + if (lower(bank.name) == base) { + return &bank; + } + } + // Names like "bank_2" carry the index as a trailing decimal number. + size_t digits = base.size(); + while (digits > 0 && std::isdigit((unsigned char)base[digits - 1])) { + digits--; + } + if (digits < base.size()) { + const uint32_t idx = (uint32_t)std::stoul(base.substr(digits)); + const auto it = banks.find(idx); + if (it != banks.end()) { + return &it->second; + } + } + for (const auto& [id, bank] : banks) { + const std::string bn = lower(bank.name); + if (base.find(bn) != std::string::npos || bn.find(base) != std::string::npos) { + return &bank; + } + } + return nullptr; +} + +} // namespace + +bool SequencePlayerV0::Render(const ParseResultData& item, int, UI::RenderedAudio& out) { + auto data = std::static_pointer_cast<SequenceData>(item.data.value()); + if (AudioManager::Instance == nullptr) { + return false; + } + static std::map<uint32_t, Bank> sBanks; + if (sBanks.empty()) { + sBanks = AudioManager::Instance->get_banks(); + } + + SeqRenderer renderer(data->mBuffer); + // Prefer the AUDIO_HEADER yaml settings; fall back to the cartridge + // title for configs that don't declare them (MK64 = EU dialect, 26800 Hz + // session rate). + std::string dialect = AudioManager::Instance->GetDialect(); + uint32_t frequency = AudioManager::Instance->GetSessionFrequency(); + if (dialect.empty() && Companion::Instance->GetGameTitle().find("KART") != std::string::npos) { + dialect = "EU"; + if (frequency == 0) { + frequency = 26800; + } + } + renderer.euDialect = dialect == "EU"; + if (frequency != 0) { + renderer.freqRatio = (float)frequency / 32000.0f; + } + renderer.adsrTick = AdsrTickRate(renderer.euDialect, frequency != 0 ? frequency : 32000); + for (const auto& bankName : data->mBanks) { + const Bank* bank = FindBankByName(sBanks, bankName); + if (bank != nullptr) { + renderer.banks.push_back(bank); + } else { + SPDLOG_WARN("Sequence '{}': bank '{}' not found", item.name, bankName); + } + } + if (renderer.banks.empty()) { + return false; + } + + renderer.Run(); + if (renderer.events.empty()) { + return false; + } + double end = 0.0; + for (const auto& ev : renderer.events) { + end = std::max(end, ev.startSec + ev.soundSec + 0.5); + } + out.pcm = UI::Synthesize(renderer.events, renderer.gainAuto, renderer.pitchAuto, end); + out.noteCount = renderer.events.size(); + return !out.pcm.empty(); +} + +#endif // BUILD_UI diff --git a/src/factories/naudio/v0/SequencePlayerV0.h b/src/factories/naudio/v0/SequencePlayerV0.h new file mode 100644 index 0000000..f2a70ca --- /dev/null +++ b/src/factories/naudio/v0/SequencePlayerV0.h @@ -0,0 +1,13 @@ +#pragma once + +#ifdef BUILD_UI + +#include "ui/audio/SequenceDriver.h" + +// naudio v0 (SM64-era library) m64 sequence driver. +class SequencePlayerV0 : public UI::SequenceDriver { +public: + bool Render(const ParseResultData& item, int option, UI::RenderedAudio& out) override; +}; + +#endif // BUILD_UI diff --git a/src/factories/naudio/v1/AudioContext.cpp b/src/factories/naudio/v1/AudioContext.cpp index 45beef5..bc185e7 100644 --- a/src/factories/naudio/v1/AudioContext.cpp +++ b/src/factories/naudio/v1/AudioContext.cpp @@ -3,6 +3,8 @@ #include "Companion.h" std::unordered_map<AudioTableType, TableEntry> AudioContext::tables; +uint32_t AudioContext::sessionFrequency = 32000; +uint32_t AudioContext::numBuffers = 2; NAudioDrivers AudioContext::driver = NAudioDrivers::UNKNOWN; std::unordered_map<uint64_t, std::string> AudioContext::sampleDedup; std::unordered_map<uint32_t, std::string> AudioContext::sampleAddrRemap; @@ -18,6 +20,9 @@ std::optional<std::shared_ptr<IParsedData>> AudioContextFactory::parse(std::vect throw std::runtime_error("Unknown NAudio driver"); } + AudioContext::sessionFrequency = GetSafeNode<uint32_t>(node, "frequency", 32000); + AudioContext::numBuffers = GetSafeNode<uint32_t>(node, "buffers", 2); + auto seq = GetSafeNode<YAML::Node>(node, "audio_seq"); auto seqSize = GetSafeNode<uint32_t>(seq, "size"); auto seqOffset = GetSafeNode<uint32_t>(seq, "offset"); diff --git a/src/factories/naudio/v1/AudioContext.h b/src/factories/naudio/v1/AudioContext.h index 05e5516..5ff27e9 100644 --- a/src/factories/naudio/v1/AudioContext.h +++ b/src/factories/naudio/v1/AudioContext.h @@ -27,6 +27,10 @@ class AudioContext { public: static std::unordered_map<AudioTableType, TableEntry> tables; static NAudioDrivers driver; + // Audio spec values the engine timing derives from (AUDIO_SETUP yaml: + // `frequency`, `buffers`). + static uint32_t sessionFrequency; + static uint32_t numBuffers; // Key: (sampleBankId << 32) | sampleAddr → canonical archive path. // Populated during parse so all dedup decisions are made before any export runs. static std::unordered_map<uint64_t, std::string> sampleDedup; diff --git a/src/factories/naudio/v1/AudioConverter.cpp b/src/factories/naudio/v1/AudioConverter.cpp index 5b49fc9..59bbd3c 100644 --- a/src/factories/naudio/v1/AudioConverter.cpp +++ b/src/factories/naudio/v1/AudioConverter.cpp @@ -168,6 +168,11 @@ void AudioConverter::SampleV1ToAIFC(NSampleData* sample, LUS::BinaryWriter& out) std::static_pointer_cast<ADPCMLoopData>(Companion::Instance->GetParseDataByAddr(sample->loop)->data.value()); auto book = std::static_pointer_cast<ADPCMBookData>(Companion::Instance->GetParseDataByAddr(sample->book)->data.value()); + SampleV1ToAIFC(sample, loop.get(), book.get(), out); +} + +void AudioConverter::SampleV1ToAIFC(NSampleData* sample, const ADPCMLoopData* loop, const ADPCMBookData* book, + LUS::BinaryWriter& out) { auto entry = AudioContext::tables[AudioTableType::SAMPLE_TABLE]; auto sampleData = entry.buffer.data() + entry.info->entries[sample->sampleBankId].addr + sample->sampleAddr; auto aifc = AIFCWriter(); @@ -179,6 +184,11 @@ void AudioConverter::SampleV1ToAIFC(NSampleData* sample, LUS::BinaryWriter& out) if (sample_rate == 0) { sample_rate = 32000 * sample->tuning; } + // Explicit yaml entries carry no tuning; a zero rate makes the AIFF + // invalid (decoders reject it), so fall back to the mixer reference. + if (sample_rate == 0) { + sample_rate = 32000; + } int16_t num_channels = 1; int16_t sample_size = 16; diff --git a/src/factories/naudio/v1/AudioConverter.h b/src/factories/naudio/v1/AudioConverter.h index 1840ab0..37da0e4 100644 --- a/src/factories/naudio/v1/AudioConverter.h +++ b/src/factories/naudio/v1/AudioConverter.h @@ -3,6 +3,8 @@ #include <factories/BaseFactory.h> #include <factories/naudio/v1/SampleFactory.h> #include <factories/naudio/v0/AudioManager.h> +#include <factories/naudio/v1/LoopFactory.h> +#include <factories/naudio/v1/BookFactory.h> enum AIFCMagicValues { FORM = (uint32_t) 0x464f524d, @@ -18,6 +20,7 @@ class AudioConverter { public: static void SampleV0ToAIFC(AudioBankSample* entry, LUS::BinaryWriter &out); static void SampleV1ToAIFC(NSampleData* tSample, LUS::BinaryWriter &out); + static void SampleV1ToAIFC(NSampleData* tSample, const ADPCMLoopData* loop, const ADPCMBookData* book, LUS::BinaryWriter &out); }; struct AIFCChunk { diff --git a/src/factories/naudio/v1/AudioTableFactory.cpp b/src/factories/naudio/v1/AudioTableFactory.cpp index be80f08..c53db1c 100644 --- a/src/factories/naudio/v1/AudioTableFactory.cpp +++ b/src/factories/naudio/v1/AudioTableFactory.cpp @@ -156,13 +156,13 @@ std::optional<std::shared_ptr<IParsedData>> AudioTableFactory::parse(std::vector continue; } if (sampleOffset == 0 || sampleOffset + 8 > fontBuf.size()) { - SPDLOG_WARN("sampleDedup pre-pop: skipping {} — offset 0x{:X} out of fontBuf bounds (0x{:X})", - path, sampleOffset, fontBuf.size()); + SPDLOG_WARN("sampleDedup pre-pop: skipping {} — offset 0x{:X} out of fontBuf bounds (0x{:X})", path, + sampleOffset, fontBuf.size()); skippedCount++; continue; } auto sReader = AudioContext::MakeReader(AudioTableType::FONT_TABLE, sampleOffset); - sReader.ReadUInt32(); // skip flags + sReader.ReadUInt32(); // skip flags uint32_t sampleAddr = sReader.ReadUInt32(); if (sampleAddr == 0) { skippedCount++; @@ -203,9 +203,13 @@ std::optional<std::shared_ptr<IParsedData>> AudioTableFactory::parse(std::vector font["sd2"] = (int32_t)sd2; font["sd3"] = (int32_t)sd3; - Companion::Instance->AddAsset(font); - std::string path = font["vpath"].as<std::string>(); - crc = CRC64(path.c_str()); + // On re-parse AddAsset returns the already-registered node. + auto res = Companion::Instance->AddAsset(font); + if (res.has_value() && (*res)["vpath"]) { + crc = CRC64((*res)["vpath"].as<std::string>().c_str()); + } else if (font["vpath"]) { + crc = CRC64(font["vpath"].as<std::string>().c_str()); + } break; } case AudioTableType::SEQ_TABLE: { @@ -215,9 +219,12 @@ std::optional<std::shared_ptr<IParsedData>> AudioTableFactory::parse(std::vector seq["type"] = "NAUDIO:V1:SEQUENCE"; seq["offset"] = parent + addr; seq["size"] = size; - Companion::Instance->AddAsset(seq); - auto path = seq["vpath"].as<std::string>(); - crc = CRC64(path.c_str()); + auto res = Companion::Instance->AddAsset(seq); + if (res.has_value() && (*res)["vpath"]) { + crc = CRC64((*res)["vpath"].as<std::string>().c_str()); + } else if (seq["vpath"]) { + crc = CRC64(seq["vpath"].as<std::string>().c_str()); + } break; } } diff --git a/src/factories/naudio/v1/SampleFactory.cpp b/src/factories/naudio/v1/SampleFactory.cpp index d72f79d..544c12b 100644 --- a/src/factories/naudio/v1/SampleFactory.cpp +++ b/src/factories/naudio/v1/SampleFactory.cpp @@ -221,3 +221,120 @@ std::optional<std::shared_ptr<IParsedData>> NSampleFactory::parse(std::vector<ui return sample; } + +#ifdef BUILD_UI +#include <cmath> +#include <unordered_map> +#include "imgui.h" +#include "SequencePlayerV1.h" +#include "ui/BaseBackend.h" +#include "ui/ExportUtils.h" +#include "ui/Widgets.h" + +namespace { + +struct DecodedNSample { + std::string name; + std::vector<int16_t> pcm; + int rate = 0; +}; + +DecodedNSample sNDecoded; // last decoded sample (they can be large) +std::string sNPlayingName; +std::unordered_map<std::string, float> sNSampleSpeeds; + +bool DecodeNSample(const ParseResultData& item) { + if (sNDecoded.name == item.name) { + return !sNDecoded.pcm.empty(); + } + sNDecoded = {}; + sNDecoded.name = item.name; + if (!DecodeV1SampleToPcm(item, sNDecoded.pcm, sNDecoded.rate)) { + sNDecoded.pcm.clear(); + return false; + } + return true; +} + +} // namespace + +float NSampleFactoryUI::GetItemHeight(const ParseResultData&) { + return ImGui::GetTextLineHeightWithSpacing() * 3.0f + ImGui::GetFrameHeightWithSpacing() * 2.0f + + ImGui::GetStyle().ItemSpacing.y * 4.0f; +} + +void NSampleFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + const auto sample = std::static_pointer_cast<NSampleData>(item.data.value()); + ImGui::TextDisabled("sample \xe2\x80\x94 %u bytes codec %u, bank %u, %u Hz, tuning %.3f", (uint32_t)sample->size, + (uint32_t)sample->codec, sample->sampleBankId, sample->sampleRate, sample->tuning); + + auto speedIt = sNSampleSpeeds.emplace(item.name, 1.0f).first; + const bool playingThis = sNPlayingName == item.name && UI::GetBackend()->AudioProgress() >= 0.0f; + if (ImGui::Button(playingThis ? "Stop##nsample" : "Play##nsample")) { + if (playingThis) { + UI::GetBackend()->StopAudio(); + sNPlayingName.clear(); + } else if (DecodeNSample(item)) { + if (UI::GetBackend()->PlaySamples(sNDecoded.pcm.data(), sNDecoded.pcm.size(), sNDecoded.rate, 1)) { + sNPlayingName = item.name; + UI::GetBackend()->SetAudioSpeed(speedIt->second); + } + } + } + ImGui::SameLine(); + if (ImGui::Button("WAV##nsampleexp")) { + if (DecodeNSample(item)) { + const auto path = UI::ExportFilePath(item.name, "wav"); + UI::NoteExport(item.name, + UI::WriteWavFile(path, sNDecoded.pcm.data(), sNDecoded.pcm.size(), 1, sNDecoded.rate) + ? path.string() + : "export failed"); + } else { + UI::NoteExport(item.name, "decode failed"); + } + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Export decoded sample to torch-exports/"); + } + UI::DrawExportMarker(item.name); + ImGui::SameLine(); + float volume = UI::GetBackend()->GetAudioVolume(); + ImGui::SetNextItemWidth(140.0f); + if (ImGui::SliderFloat("##nvol", &volume, 0.0f, 1.0f, "vol %.2f")) { + UI::GetBackend()->SetAudioVolume(volume); + } + ImGui::SameLine(); + float& speed = speedIt->second; + ImGui::SetNextItemWidth(140.0f); + if (ImGui::SliderFloat("##nspeed", &speed, 0.25f, 4.0f, "%.2fx", ImGuiSliderFlags_Logarithmic)) { + static const float kSnaps[] = { 0.5f, 1.0f, 2.0f }; + for (const float snap : kSnaps) { + if (std::fabs(speed - snap) < 0.05f) { + speed = snap; + break; + } + } + if (playingThis) { + UI::GetBackend()->SetAudioSpeed(speed); + } + } + ImGui::SameLine(); + if (sNDecoded.name == item.name && !sNDecoded.pcm.empty()) { + const float seconds = (float)sNDecoded.pcm.size() / (float)sNDecoded.rate; + ImGui::TextDisabled("%d Hz, mono, %.2fs", sNDecoded.rate, seconds); + } else { + ImGui::TextDisabled("press play to decode"); + } + + float progress = playingThis ? std::max(UI::GetBackend()->AudioProgress(), 0.0f) : 0.0f; + ImGui::SetNextItemWidth(std::min(ImGui::GetContentRegionAvail().x, 420.0f)); + if (ImGui::SliderFloat("##nseek", &progress, 0.0f, 1.0f, "") && playingThis) { + UI::GetBackend()->SeekAudio(progress); + } +} +#endif diff --git a/src/factories/naudio/v1/SampleFactory.h b/src/factories/naudio/v1/SampleFactory.h index eb7cff5..a72dd7e 100644 --- a/src/factories/naudio/v1/SampleFactory.h +++ b/src/factories/naudio/v1/SampleFactory.h @@ -40,6 +40,14 @@ class NSampleXMLExporter : public BaseExporter { ExportResult Export(std::ostream& write, std::shared_ptr<IParsedData> data, std::string& entryName, YAML::Node& node, std::string* replacement) override; }; +#ifdef BUILD_UI +class NSampleFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; +#endif + class NSampleFactory : public BaseFactory { public: std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; diff --git a/src/factories/naudio/v1/SequenceFactory.cpp b/src/factories/naudio/v1/SequenceFactory.cpp index a1ac0d9..6985b6a 100644 --- a/src/factories/naudio/v1/SequenceFactory.cpp +++ b/src/factories/naudio/v1/SequenceFactory.cpp @@ -76,14 +76,15 @@ ExportResult NSequenceBinaryExporter::Export(std::ostream& write, std::shared_pt auto& seqTable = AudioContext::tables[AudioTableType::SEQ_TABLE]; auto it = seqTable.entries.find(offset - seqTable.offset); if (it != seqTable.entries.end()) { - medium = (uint8_t)it->second.medium; + medium = (uint8_t)it->second.medium; cachePolicy = (uint8_t)it->second.cachePolicy; } // Derive seqNumber from the entry's position in the table uint32_t idx = 0; for (auto& [entryOffset, entry] : seqTable.entries) { - if (entryOffset == (offset - seqTable.offset)) break; + if (entryOffset == (offset - seqTable.offset)) + break; idx++; } seqNumber = (uint8_t)idx; diff --git a/src/factories/naudio/v1/SequencePlayerV1.cpp b/src/factories/naudio/v1/SequencePlayerV1.cpp new file mode 100644 index 0000000..e48bb93 --- /dev/null +++ b/src/factories/naudio/v1/SequencePlayerV1.cpp @@ -0,0 +1,1458 @@ +#ifdef BUILD_UI + +#include "SequencePlayerV1.h" + +#include <algorithm> +#include <cmath> +#include <cstring> +#include <map> +#include <memory> +#include <unordered_map> + +#include "AudioContext.h" +#include "AudioConverter.h" +#include "BookFactory.h" +#include "DrumFactory.h" +#include "EnvelopeFactory.h" +#include "InstrumentFactory.h" +#include "LoopFactory.h" +#include "SampleFactory.h" +#include "SoundFontFactory.h" +#include "factories/naudio/v0/AIFCDecode.h" +#include "factories/naudio/v0/SampleFactory.h" +#include "factories/GenericArrayFactory.h" +#include "spdlog/spdlog.h" +#include "types/RawBuffer.h" +#include "binarytools/endianness.h" + +#ifdef SF64_SUPPORT +#include "factories/sf64/audio/AudioDecompressor.h" +#endif + +namespace { + +constexpr int kMaxTicks = 60000; +constexpr size_t kMaxEvents = 60000; + +using NoteEvent = UI::SynthNote; + +// Parsed-asset graph, resolved once: fonts by id, referenced assets by offset. +struct FontRef { + const SoundFontData* font = nullptr; + std::string name; + uint32_t id = 0; + std::vector<const InstrumentData*> insts; + std::vector<const DrumData*> drums; +}; + +struct AssetIndex { + std::vector<FontRef> fonts; + std::unordered_map<uint32_t, const InstrumentData*> instByOffset; + std::unordered_map<uint32_t, const DrumData*> drumByOffset; + std::unordered_map<uint32_t, NSampleData*> sampleByOffset; + std::unordered_map<std::string, NSampleData*> sampleByName; + std::unordered_map<uint32_t, const EnvelopeData*> envByOffset; + std::unordered_map<uint32_t, const ADPCMLoopData*> loopByOffset; + std::unordered_map<uint32_t, const ADPCMBookData*> bookByOffset; + // Per-sample reference tuning: of every tuning that references the sample + // (instrument slots + drums), the one closest to 1.0. Natural rate is + // tuning * 32000. + std::unordered_map<const NSampleData*, float> refTuning; + // gSeqFontTable blob: u16 offsets per seqId, then {count, fontIds...}. + std::vector<uint8_t> seqFontTable; +}; + +uint32_t NodeOffset(const ParseResultData& r) { + return GetSafeNode<uint32_t>(const_cast<ParseResultData&>(r).node, "offset", 0); +} + +AssetIndex& Assets() { + static AssetIndex index; + static bool built = false; + if (built) { + return index; + } + built = true; + + std::vector<std::pair<uint32_t, FontRef>> fonts; + for (const auto& [file, results] : Companion::Instance->GetParseResults()) { + for (const auto& r : results) { + if (!r.data.has_value()) { + continue; + } + const uint32_t offset = NodeOffset(r); + if (r.type == "NAUDIO:V1:INSTRUMENT") { + index.instByOffset[offset] = std::static_pointer_cast<InstrumentData>(r.data.value()).get(); + } else if (r.type == "NAUDIO:V1:DRUM") { + index.drumByOffset[offset] = std::static_pointer_cast<DrumData>(r.data.value()).get(); + } else if (r.type == "NAUDIO:V1:SAMPLE") { + auto* sample = std::static_pointer_cast<NSampleData>(r.data.value()).get(); + index.sampleByOffset[offset] = sample; + index.sampleByName[r.name] = sample; + } else if (r.type == "NAUDIO:V1:ENVELOPE") { + index.envByOffset[offset] = std::static_pointer_cast<EnvelopeData>(r.data.value()).get(); + } else if (r.type == "NAUDIO:V1:ADPCM_LOOP") { + index.loopByOffset[offset] = std::static_pointer_cast<ADPCMLoopData>(r.data.value()).get(); + } else if (r.type == "NAUDIO:V1:ADPCM_BOOK") { + index.bookByOffset[offset] = std::static_pointer_cast<ADPCMBookData>(r.data.value()).get(); + } else if (r.type == "ARRAY") { + std::string base = r.name; + std::transform(base.begin(), base.end(), base.begin(), ::tolower); + base.erase(std::remove(base.begin(), base.end(), '_'), base.end()); + if (base.find("seqfont") != std::string::npos) { + for (const auto& datum : std::static_pointer_cast<GenericArray>(r.data.value())->mData) { + if (std::holds_alternative<uint8_t>(datum)) { + index.seqFontTable.push_back(std::get<uint8_t>(datum)); + } + } + } + } else if (r.type == "NAUDIO:V1:SOUND_FONT") { + FontRef ref; + ref.font = std::static_pointer_cast<SoundFontData>(r.data.value()).get(); + ref.name = r.name; + fonts.emplace_back(GetSafeNode<uint32_t>(const_cast<ParseResultData&>(r).node, "id", 0), ref); + } + } + } + // Resolve dedup-suppressed addresses to their canonical sample. + const auto resolve = [&](uint32_t addr) -> const NSampleData* { + const auto it = index.sampleByOffset.find(addr); + if (it != index.sampleByOffset.end()) { + return it->second; + } + const auto rit = AudioContext::sampleAddrRemap.find(addr); + if (rit != AudioContext::sampleAddrRemap.end()) { + const auto nit = index.sampleByName.find(rit->second); + if (nit != index.sampleByName.end()) { + return nit->second; + } + } + return nullptr; + }; + const auto addTuning = [&](uint32_t addr, float tuning) { + const NSampleData* sample = resolve(addr); + if (sample == nullptr || tuning <= 0.0f) { + return; + } + const auto it = index.refTuning.find(sample); + if (it == index.refTuning.end() || std::fabs(tuning - 1.0f) < std::fabs(it->second - 1.0f)) { + index.refTuning[sample] = tuning; + } + }; + for (const auto& [off, inst] : index.instByOffset) { + addTuning(inst->lowPitchTunedSample.sample, inst->lowPitchTunedSample.tuning); + addTuning(inst->normalPitchTunedSample.sample, inst->normalPitchTunedSample.tuning); + addTuning(inst->highPitchTunedSample.sample, inst->highPitchTunedSample.tuning); + } + for (const auto& [off, drum] : index.drumByOffset) { + addTuning(drum->tunedSample.sample, drum->tunedSample.tuning); + } + + std::sort(fonts.begin(), fonts.end(), [](const auto& a, const auto& b) { return a.first < b.first; }); + for (auto& [id, ref] : fonts) { + ref.id = id; + for (const uint32_t addr : ref.font->instruments) { + auto it = index.instByOffset.find(addr); + ref.insts.push_back(it != index.instByOffset.end() ? it->second : nullptr); + } + for (const uint32_t addr : ref.font->drums) { + auto it = index.drumByOffset.find(addr); + ref.drums.push_back(it != index.drumByOffset.end() ? it->second : nullptr); + } + index.fonts.push_back(std::move(ref)); + } + return index; +} + +// Dedup-suppressed samples only exist under their canonical path. +NSampleData* SampleByAddr(uint32_t addr) { + auto& index = Assets(); + const auto it = index.sampleByOffset.find(addr); + if (it != index.sampleByOffset.end()) { + return it->second; + } + const auto rit = AudioContext::sampleAddrRemap.find(addr); + if (rit != AudioContext::sampleAddrRemap.end()) { + const auto nit = index.sampleByName.find(rit->second); + if (nit != index.sampleByName.end()) { + return nit->second; + } + } + return nullptr; +} + +// fadeOutVel = decayIndex/2560 against 1.0 at 180 ticks/s. +// Engine timing from the audio spec: ticksPerUpdate derives from the +// session frequency (60 fps frames, 192-sample chunks), the ADSR runs at +// 60 * ticksPerUpdate ticks/s. +int TicksPerUpdate() { + const uint32_t samplesPerFrame = ((AudioContext::sessionFrequency / 60) + 15) & ~15u; + return (int)((samplesPerFrame + 16) / 192 + 1); +} + +float AdsrTickRate() { + return 60.0f * (float)TicksPerUpdate(); +} + +float ReleaseSeconds(uint8_t decayIndex) { + if (decayIndex == 0) { + return 0.016f; // one-frame fade + } + return 2560.0f / ((float)decayIndex * AdsrTickRate()); +} + +std::shared_ptr<UI::SynthSample> SynthSampleFor(NSampleData* sample) { + static std::unordered_map<NSampleData*, std::shared_ptr<UI::SynthSample>> sCache; + if (sample == nullptr) { + return nullptr; + } + auto it = sCache.find(sample); + if (it != sCache.end()) { + return it->second; + } + auto synth = std::make_shared<UI::SynthSample>(); + +#ifdef SF64_SUPPORT + if (AudioContext::driver == NAudioDrivers::SF64 && sample->codec == 2) { + auto& table = AudioContext::tables[AudioTableType::SAMPLE_TABLE]; + const uint8_t* ptr = table.buffer.data() + table.info->entries[sample->sampleBankId].addr + sample->sampleAddr; + std::vector<uint8_t> raw(ptr, ptr + sample->size); + std::vector<int16_t> out(sample->size * 2, 0); + SF64::DecompressAudio(raw, out.data()); + synth->pcm.assign(out.begin(), out.begin() + sample->size); + } else +#endif + { + auto& assets = Assets(); + const auto lit0 = assets.loopByOffset.find(sample->loop); + const auto bit = assets.bookByOffset.find(sample->book); + if (lit0 == assets.loopByOffset.end() || bit == assets.bookByOffset.end()) { + SPDLOG_WARN("Sample missing loop/book asset (loop 0x{:X}, book 0x{:X})", sample->loop, sample->book); + return sCache.emplace(sample, std::move(synth)).first->second; + } + LUS::BinaryWriter aifc; + AudioConverter::SampleV1ToAIFC(sample, lit0->second, bit->second, aifc); + const auto bytes = aifc.ToVector(); + if (!bytes.empty()) { + LUS::BinaryWriter aiff; + write_aiff(bytes, aiff); + int rate = 0; + const bool ok = DecodeAiffBytes(aiff.ToVector(), synth->pcm, rate); + if ((!ok || synth->pcm.empty()) && std::getenv("TORCH_SEQ_DEBUG") != nullptr) { + printf("[sample] DECODE FAIL addr=0x%X book(order=%d npred=%d coeffs=%zu) loop(%u..%u x%u) aifc=%zu\n", + sample->sampleAddr, bit->second->order, bit->second->numPredictors, bit->second->book.size(), + lit0->second->start, lit0->second->end, lit0->second->count, bytes.size()); + } + aiff.Close(); + const size_t expected = (size_t)sample->size * 16 / 9; + if (synth->pcm.size() < expected) { + synth->pcm.insert(synth->pcm.begin(), expected - synth->pcm.size(), 0); + } + } + aifc.Close(); + } + + const auto lit = Assets().loopByOffset.find(sample->loop); + if (lit != Assets().loopByOffset.end()) { + synth->loopStart = lit->second->start; + synth->loopEnd = lit->second->end; + synth->looped = lit->second->count != 0; + } + if (std::getenv("TORCH_SEQ_DEBUG") != nullptr) { + static int sDumped = 0; + float pk = 0; + for (size_t i = 0; i < synth->pcm.size(); ++i) + pk = std::max(pk, std::fabs((float)synth->pcm[i])); + if (sDumped++ < 8) { + printf("[sample] codec=%u size=%u addr=0x%X bank=%u loop=0x%X book=0x%X tuning=%.3f pcm=%zu peak=%.0f\n", + (uint32_t)sample->codec, (uint32_t)sample->size, sample->sampleAddr, sample->sampleBankId, + sample->loop, sample->book, sample->tuning, synth->pcm.size(), pk); + } + } + return sCache.emplace(sample, std::move(synth)).first->second; +} + +// Envelope points: delay in 1/240s updates, level squared; -2/-3 loop. +void FillEnvelopePoints(UI::SynthNote& ev, const std::vector<EnvelopePoint>& points) { + std::vector<int> entryPt; + double at = 0.0; + for (const auto& entry : points) { + const int16_t delay = entry.delay; + const int16_t arg = entry.arg; + if (delay > 0) { + // Engine scales delays >= 4 by ticksPerUpdate/numBuffers/4. + const int tpu = TicksPerUpdate(); + const int ticks = delay >= 4 ? (delay * tpu / (int)AudioContext::numBuffers) / 4 : delay; + at += (double)ticks / (double)AdsrTickRate(); + const float lvl = (float)arg / 32767.0f; + entryPt.push_back((int)ev.envPoints.size()); + ev.envPoints.emplace_back((float)at, lvl * lvl); + if (ev.envPoints.size() >= 16) { + break; + } + } else if (delay == -2) { + if (arg >= 0 && arg < (int)entryPt.size() && entryPt[arg] >= 0) { + ev.envLoopStartT = ev.envPoints[entryPt[arg]].first; + } + break; + } else if (delay == -3) { + ev.envLoopStartT = 0.0f; + break; + } else { + break; + } + entryPt.push_back(-1); + } +} + +// Font envelopes store values byte-swapped (the port keeps envelopes +// big-endian in memory and swaps at use). Overrides (chan 0xDA, layer 0xCB) +// point into the sequence data itself. +void FillEnvelope(UI::SynthNote& ev, int envOverride, uint32_t envKey, const std::vector<uint8_t>& seq) { + std::vector<EnvelopePoint> points; + if (envOverride >= 0) { + for (size_t at = (size_t)envOverride; at + 3 < seq.size() && points.size() < 24; at += 4) { + const int16_t delay = (int16_t)((seq[at] << 8) | seq[at + 1]); + const int16_t arg = (int16_t)((seq[at + 2] << 8) | seq[at + 3]); + points.push_back({ delay, arg }); + if (delay <= 0) { + break; + } + } + FillEnvelopePoints(ev, points); + return; + } + const auto eit = Assets().envByOffset.find(envKey); + if (eit == Assets().envByOffset.end()) { + return; + } + for (const auto& entry : eit->second->points) { + points.push_back({ (int16_t)BSWAP16((uint16_t)entry.delay), (int16_t)BSWAP16((uint16_t)entry.arg) }); + } + FillEnvelopePoints(ev, points); +} + +float NoteFreq(int note) { + return std::pow(2.0f, (float)(note - 39) / 12.0f); +} + +struct M64Exec { + const uint8_t* data = nullptr; + size_t size = 0; + size_t pc = 0; + size_t stack[4]{}; + int sp = 0; + struct { + size_t addr; + int count; + } loops[4]{}; + int lp = 0; + int delay = 0; + bool active = false; + bool failed = false; + + uint8_t U8() { + if (pc >= size) { + failed = true; + return 0; + } + return data[pc++]; + } + int8_t S8() { + return (int8_t)U8(); + } + uint16_t U16() { + const uint16_t hi = U8(); + return (uint16_t)((hi << 8) | U8()); + } + int Var() { + int v = U8(); + if (v & 0x80) { + v = ((v & 0x7F) << 8) | U8(); + } + return v; + } + void Start(size_t addr) { + pc = addr; + sp = 0; + lp = 0; + delay = 0; + active = addr < size; + failed = false; + } + void End() { + active = false; + } +}; + +struct LayerSt { + M64Exec ex; + int releaseRate = -1; // layer 0xCB + bool ignoreDrumPan = false; + int transpose = 0; + int noteDuration = 0x80; + int lastDelay = 0; + int shortVel = 0x50; + int shortDur = 0; + int shortDefaultDelay = 0; + int portaMode = 0; + int portaTarget = 0; + int portaTime = 0; + int envOverride = -1; + bool continuous = false; + int lastEvent = -1; + double lastSlotEnd = 0.0; + float lastVel = 1.0f; + float pan = -1.0f; + const InstrumentData* inst = nullptr; + bool drums = false; + bool instSet = false; +}; + +struct ChanSt { + M64Exec ex; + bool largeNotes = false; + bool hasInst = false; + bool drums = false; + const InstrumentData* inst = nullptr; + const FontRef* font = nullptr; + float vol = 1.0f; + float volScale = 1.0f; + float freqScale = 1.0f; + float pan = 0.5f; + int transpose = 0; + int8_t value = 0; + int dynTable = -1; + uint8_t vibExtent = 0; + uint8_t vibExtentStart = 0; + uint8_t vibRate = 0; + uint8_t vibRateStart = 0; + uint8_t vibRateRamp = 0; + uint8_t vibDelay = 0; + uint8_t vibRamp = 0; + uint8_t reverb = 0; + uint8_t decayIndex = 208; + float sustain = 0.0f; // fraction of gate-end level (0xD2 / 256) + float panWeight = 1.0f; // channel share of the pan blend (0xDC / 128) + int envOverride = -1; + LayerSt layers[8]; +}; + +struct SeqSt { + M64Exec ex; + float vol = 1.0f; + int transpose = 0; + int tempo = 120; + int8_t value = 0; + ChanSt chans[16]; +}; + +struct SeqRenderer { + const std::vector<uint8_t>& buf; + const FontRef* defaultFont; + // This sequence's font id list from the seq-font table (script font ops + // index it in reverse: operand 0 = last entry). + std::vector<uint8_t> seqFonts; + SeqSt seq; + std::vector<NoteEvent> events; + double sec = 0.0; + int8_t seqVariation = 0; + int shortVelTable = -1; + int shortDurTable = -1; + UI::GainAutomation gainAuto[16]; + int pendingFadeTicks = 0; + // Channel freq multiplier over time (0xD3/0xDE/0xEE bends on sounding notes). + UI::PitchAutomation pitchAuto[16]; + + SeqRenderer(const std::vector<uint8_t>& b, const FontRef* font) : buf(b), defaultFont(font) { + } + + void PushPitch(int idx) { + auto& a = pitchAuto[idx]; + const float f = seq.chans[idx].freqScale; + if (a.empty() || a.back().second != f) { + a.emplace_back((float)sec, f); + } + } + + int ReadU16At(int offset) const { + if (offset < 0 || offset + 1 >= (int)buf.size()) { + return -1; + } + return (buf[offset] << 8) | buf[offset + 1]; + } + + uint8_t ReadU8At(int offset) const { + return offset >= 0 && offset < (int)buf.size() ? buf[offset] : 0; + } + + void PushGain(int idx) { + const ChanSt& ch = seq.chans[idx]; + // appliedVolume = SQ(channelVolume): the engine squares the whole + // channel volume chain. + float g = seq.vol * ch.vol * ch.volScale; + g *= g; + auto& a = gainAuto[idx]; + if (a.empty() || a.back().second != g) { + a.emplace_back((float)sec, g); + } + } + + void PushGainAll() { + for (int i = 0; i < 16; ++i) { + if (seq.chans[i].ex.data != nullptr) { + PushGain(i); + } + } + } + + void StartChannel(int idx, uint16_t addr) { + if (idx < 0 || idx >= 16) { + return; + } + ChanSt& ch = seq.chans[idx]; + if (ch.ex.data == nullptr) { + ch = ChanSt{}; + ch.font = defaultFont; + } + for (auto& ly : ch.layers) { + ly = LayerSt{}; + } + ch.value = 0; + ch.ex.data = buf.data(); + ch.ex.size = buf.size(); + ch.ex.Start(addr); + PushGain(idx); + PushPitch(idx); + } + + const FontRef* FontById(uint8_t id) { + for (const auto& font : Assets().fonts) { + if (font.id == id) { + return &font; + } + } + return nullptr; + } + + const FontRef* FontByListIndex(uint8_t operand) { + if (seqFonts.empty() || operand >= seqFonts.size()) { + return nullptr; + } + return FontById(seqFonts[seqFonts.size() - 1 - operand]); + } + + void SetChanInstrument(ChanSt& ch, uint8_t instId) { + ch.hasInst = false; + ch.drums = false; + ch.inst = nullptr; + if (instId >= 0x80) { + return; + } + if (instId == 0x7F) { + ch.drums = true; + ch.hasInst = true; + return; + } + if (ch.font != nullptr && instId < ch.font->insts.size() && ch.font->insts[instId] != nullptr) { + ch.inst = ch.font->insts[instId]; + ch.decayIndex = (uint8_t)ch.inst->adsrDecayIndex; + ch.hasInst = true; + } + } + + void EmitNote(ChanSt& ch, LayerSt& ly, int pitch, int vel, int delayTicks, int durByte) { + if (!ch.hasInst && !ly.instSet) { + return; + } + const bool drums = ly.instSet ? ly.drums : ch.drums; + const InstrumentData* inst = ly.instSet ? ly.inst : ch.inst; + const FontRef* font = ch.font; + if (font == nullptr || events.size() >= kMaxEvents) { + return; + } + + NoteEvent ev{}; + ev.startSec = sec; + const double secPerTick = 1.25 / (double)std::max(seq.tempo, 1); + const int soundTicks = delayTicks - delayTicks * durByte / 256; + ev.soundSec = std::max(soundTicks, 1) * secPerTick; + const float velGain = ((float)vel / 127.0f) * ((float)vel / 127.0f); + ev.gain = velGain; + ev.chan = (int)(&ch - seq.chans); + // notePan = chanPan*weight + layerPan*(1-weight); weight defaults to 1. + const float layerPan = ly.pan >= 0.0f ? ly.pan : 0.5f; + ev.pan = ch.pan * ch.panWeight + layerPan * (1.0f - ch.panWeight); + ev.reverb = (float)ch.reverb / 127.0f; + ev.releaseSec = ReleaseSeconds(ly.releaseRate >= 0 ? (uint8_t)ly.releaseRate : ch.decayIndex); + ev.sustainLevel = ch.sustain; + const float tick = AdsrTickRate(); + ev.sustainHoldSec = 128.0f / tick; + ev.vibDepthStart = (float)ch.vibExtentStart / 127.0f; + ev.vibDepthEnd = (float)ch.vibExtent / 127.0f; + ev.vibDelaySec = (float)ch.vibDelay * 16.0f / tick; + ev.vibRampSec = (float)ch.vibRamp * 16.0f / tick; + ev.vibRateStartHz = (float)ch.vibRateStart / 16.0f * (tick / 240.0f); + ev.vibRateEndHz = (float)ch.vibRate / 16.0f * (tick / 240.0f); + ev.vibRateRampSec = (float)ch.vibRateRamp * 16.0f / tick; + ev.portaRatio = 1.0f; + ev.portaSec = 0.0f; + + auto& assets = Assets(); + if (drums) { + if (font->drums.empty()) { + return; + } + const int idx = std::clamp(pitch + ch.transpose + ly.transpose, 0, (int)font->drums.size() - 1); + const DrumData* drum = font->drums[idx]; + if (drum == nullptr) { + return; + } + ev.sample = SynthSampleFor(SampleByAddr(drum->tunedSample.sample)); + ev.freqScale = drum->tunedSample.tuning; + if (!ly.ignoreDrumPan) { + ev.pan = ch.pan * ch.panWeight + ((float)drum->pan / 128.0f) * (1.0f - ch.panWeight); + } + ev.releaseSec = ReleaseSeconds(drum->adsrDecayIndex); + FillEnvelope(ev, ly.envOverride >= 0 ? ly.envOverride : ch.envOverride, drum->envelope, buf); + } else { + if (inst == nullptr) { + return; + } + const int note = pitch + seq.transpose + ch.transpose + ly.transpose; + if (note < 0 || note >= 0x80) { + return; + } + const TunedSample* sound = &inst->normalPitchTunedSample; + if (note < inst->normalRangeLo && inst->lowPitchTunedSample.sample != 0) { + sound = &inst->lowPitchTunedSample; + } else if (note > inst->normalRangeHi && inst->highPitchTunedSample.sample != 0) { + sound = &inst->highPitchTunedSample; + } + ev.sample = SynthSampleFor(SampleByAddr(sound->sample)); + ev.freqScale = NoteFreq(note) * sound->tuning; + FillEnvelope(ev, ly.envOverride >= 0 ? ly.envOverride : ch.envOverride, inst->envelope, buf); + if (ly.portaMode != 0 && ly.portaTime > 0) { + const int target = std::clamp(ly.portaTarget + seq.transpose + ch.transpose + ly.transpose, 0, 127); + const float targetFreq = NoteFreq(target) * sound->tuning; + if ((ly.portaMode & 1) != 0) { + ev.portaRatio = targetFreq / ev.freqScale; + } else { + ev.portaRatio = ev.freqScale / targetFreq; + ev.freqScale = targetFreq; + } + ev.portaSec = (float)((ly.portaMode & 0x80) != 0 ? ev.soundSec * (double)ly.portaTime / 255.0 + : (double)ly.portaTime * secPerTick); + } + } + if (ev.sample == nullptr || ev.freqScale <= 0.0f) { + return; + } + if (ly.continuous && ly.lastEvent >= 0 && ly.lastEvent < (int)events.size()) { + NoteEvent& prev = events[ly.lastEvent]; + if (prev.sample == ev.sample && std::fabs(ly.lastSlotEnd - sec) < secPerTick * 0.5 && + prev.segs.size() < 96) { + prev.soundSec = (sec - prev.startSec) + ev.soundSec; + NoteEvent::Seg seg; + seg.t = (float)(sec - prev.startSec); + seg.freq = ev.freqScale; + seg.gainMul = ly.lastVel > 0.0001f ? velGain / ly.lastVel : 1.0f; + prev.segs.push_back(seg); + ly.lastSlotEnd = sec + delayTicks * secPerTick; + return; + } + } + ly.lastEvent = (int)events.size(); + ly.lastSlotEnd = sec + delayTicks * secPerTick; + ly.lastVel = velGain; + events.push_back(ev); + } + + bool Flow(M64Exec& ex, uint8_t cmd, int8_t value) { + switch (cmd) { + case 0xFF: + if (ex.sp > 0) { + ex.pc = ex.stack[--ex.sp]; + } else { + ex.End(); + } + return true; + case 0xFE: + ex.delay = 1; + return true; + case 0xFD: + ex.delay = std::max(ex.Var(), 1); + return true; + case 0xFC: { + const uint16_t addr = ex.U16(); + if (ex.sp < 4) { + ex.stack[ex.sp++] = ex.pc; + ex.pc = addr; + } + return true; + } + case 0xFB: + ex.pc = ex.U16(); + return true; + case 0xFA: { + const uint16_t addr = ex.U16(); + if (value == 0) { + ex.pc = addr; + } + return true; + } + case 0xF9: { + const uint16_t addr = ex.U16(); + if (value < 0) { + ex.pc = addr; + } + return true; + } + case 0xF5: { + const uint16_t addr = ex.U16(); + if (value >= 0) { + ex.pc = addr; + } + return true; + } + case 0xF8: { + int count = ex.U8(); + if (count == 0) { + count = 256; + } + if (ex.lp < 4) { + ex.loops[ex.lp].addr = ex.pc; + ex.loops[ex.lp].count = count; + ex.lp++; + } + return true; + } + case 0xF7: + if (ex.lp > 0) { + if (--ex.loops[ex.lp - 1].count > 0) { + ex.pc = ex.loops[ex.lp - 1].addr; + } else { + ex.lp--; + } + } + return true; + case 0xF6: + if (ex.lp > 0) { + ex.lp--; + } + return true; + case 0xF4: { + const int8_t rel = ex.S8(); + ex.pc += rel; + return true; + } + case 0xF3: { + const int8_t rel = ex.S8(); + if (value == 0) { + ex.pc += rel; + } + return true; + } + case 0xF2: { + const int8_t rel = ex.S8(); + if (value < 0) { + ex.pc += rel; + } + return true; + } + default: + return false; + } + } + + void StepSeq() { + M64Exec& ex = seq.ex; + int guard = 0; + while (ex.active && !ex.failed && ex.delay == 0 && guard++ < 1000) { + const uint8_t cmd = ex.U8(); + if (Flow(ex, cmd, seq.value)) { + continue; + } + switch (cmd) { + case 0xF1: + ex.U8(); + break; + case 0xF0: + break; + case 0xDF: + seq.transpose = ex.S8(); + break; + case 0xDE: + seq.transpose += ex.S8(); + break; + case 0xDD: + seq.tempo = std::max<int>(ex.U8(), 1); + break; + case 0xDC: + seq.tempo = std::max(seq.tempo + ex.S8(), 1); + break; + case 0xDB: { // volume, honoring a pending timed fade + const float target = (float)ex.U8() / 127.0f; + if (pendingFadeTicks > 0) { + const double secPerTick = 1.25 / (double)std::max(seq.tempo, 1); + const float startVol = seq.vol; + const int steps = std::min(pendingFadeTicks, 64); + for (int i = 1; i <= steps; ++i) { + const float f = (float)i / (float)steps; + seq.vol = startVol + (target - startVol) * f; + const double at = sec + pendingFadeTicks * secPerTick * f; + for (int c = 0; c < 16; ++c) { + if (seq.chans[c].ex.data != nullptr) { + const ChanSt& ch = seq.chans[c]; + const float cg = seq.vol * ch.vol * ch.volScale; + gainAuto[c].emplace_back((float)at, cg * cg); + } + } + } + pendingFadeTicks = 0; + } else { + seq.vol = target; + PushGainAll(); + } + break; + } + case 0xDA: // fade setup: mode + duration + ex.U8(); + pendingFadeTicks = ex.U16(); + break; + case 0xD9: + ex.U8(); + break; + case 0xD7: { + const uint16_t mask = ex.U16(); + for (int i = 0; i < 16; ++i) { + if ((mask >> i) & 1) { + seq.chans[i].ex.End(); + } + } + break; + } + case 0xD6: { + const uint16_t mask = ex.U16(); + for (int i = 0; i < 16; ++i) { + if ((mask >> i) & 1) { + seq.chans[i].ex.End(); + } + } + break; + } + case 0xD5: + ex.S8(); + break; + case 0xD4: + break; + case 0xD3: + ex.U8(); + break; + case 0xD2: + shortVelTable = ex.U16(); + break; + case 0xD1: + shortDurTable = ex.U16(); + break; + case 0xD0: + ex.U8(); + break; + case 0xCC: + seq.value = (int8_t)ex.U8(); + break; + case 0xC9: + seq.value = (int8_t)(seq.value & ex.U8()); + break; + case 0xC8: + seq.value = (int8_t)(seq.value - ex.U8()); + break; + case 0xC7: + ex.U8(); + ex.U16(); + break; + case 0xC6: + ex.End(); + break; + default: { + if (cmd >= 0xC0) { + break; + } + const int lo = cmd & 0x0F; + switch (cmd & 0xF0) { + case 0x90: + StartChannel(lo, ex.U16()); + break; + case 0x00: + seq.value = (int8_t)(lo < 16 && !seq.chans[lo].ex.active ? 1 : 0); + break; + case 0x50: + seq.value = (int8_t)(seq.value - seqVariation); + break; + case 0x70: + seqVariation = seq.value; + break; + case 0x80: + seq.value = seqVariation; + break; + default: + break; + } + break; + } + } + } + } + + void StepChan(ChanSt& ch) { + M64Exec& ex = ch.ex; + int guard = 0; + while (ex.active && !ex.failed && ex.delay == 0 && guard++ < 1000) { + const uint8_t cmd = ex.U8(); + if (Flow(ex, cmd, ch.value)) { + continue; + } + switch (cmd) { + case 0xF1: + ex.U8(); + break; + case 0xF0: + break; + case 0xEA: + ex.End(); + break; + case 0xEB: { // setfontandinstr + const FontRef* font = FontByListIndex(ex.U8()); + if (font != nullptr) { + ch.font = font; + } + SetChanInstrument(ch, ex.U8()); + break; + } + case 0xC1: + SetChanInstrument(ch, ex.U8()); + break; + case 0xC2: + ch.dynTable = ex.U16(); + break; + case 0xC3: + ch.largeNotes = false; + break; + case 0xC4: + ch.largeNotes = true; + break; + case 0xC5: + if (ch.dynTable >= 0 && ch.value != -1) { + ch.dynTable = ReadU16At(ch.dynTable + 2 * (uint8_t)ch.value); + } + break; + case 0xC6: { // setfont (reverse index into the seq's font list) + const FontRef* font = FontByListIndex(ex.U8()); + if (font != nullptr) { + ch.font = font; + } + break; + } + case 0xC7: + ex.U8(); + ex.U16(); + break; + case 0xC8: + ch.value = (int8_t)(ch.value - ex.U8()); + break; + case 0xC9: + ch.value = (int8_t)(ch.value & ex.U8()); + break; + case 0xCA: + ex.U8(); + break; + case 0xCB: + ex.U16(); + break; + case 0xCC: + ch.value = (int8_t)ex.U8(); + break; + case 0xCD: { + const uint8_t idx = ex.U8(); + if (idx < 16) { + seq.chans[idx].ex.End(); + } + break; + } + case 0xD0: + case 0xD1: + ex.U8(); + break; + case 0xD2: // adsr sustain + ch.sustain = (float)ex.U8() / 256.0f; + break; + case 0xD5: + case 0xD6: + break; + case 0xD3: + ch.freqScale = std::pow(2.0f, (float)ex.S8() / 127.0f); + PushPitch((int)(&ch - seq.chans)); + break; + case 0xD4: + ch.reverb = ex.U8(); + break; + case 0xD7: + ch.vibRate = ex.U8(); + ch.vibRateStart = ch.vibRate; + ch.vibRateRamp = 0; + break; + case 0xD8: + ch.vibExtent = ex.U8(); + ch.vibExtentStart = ch.vibExtent; + ch.vibRamp = 0; + break; + case 0xD9: + ch.decayIndex = ex.U8(); + break; + case 0xDA: + ch.envOverride = ex.U16(); + break; + case 0xDB: + ch.transpose = ex.S8(); + break; + case 0xDC: // pan channel weight + ch.panWeight = (float)ex.U8() / 128.0f; + break; + case 0xDD: + ch.pan = (float)std::clamp<int>(ex.U8(), 0, 128) / 128.0f; + break; + case 0xDE: // raw frequency multiplier N/2^15 + ch.freqScale = (float)ex.U16() / 32768.0f; + PushPitch((int)(&ch - seq.chans)); + break; + case 0xDF: + ch.vol = (float)ex.U8() / 127.0f; + PushGain((int)(&ch - seq.chans)); + break; + case 0xE0: + ch.volScale = (float)ex.U8() / 128.0f; + PushGain((int)(&ch - seq.chans)); + break; + case 0xE1: // vibrato rate linear: start, target, delay + ch.vibRateStart = ex.U8(); + ch.vibRate = ex.U8(); + ch.vibRateRamp = ex.U8(); + break; + case 0xE2: + ch.vibExtentStart = ex.U8(); + ch.vibExtent = ex.U8(); + ch.vibRamp = ex.U8(); + break; + case 0xE3: + ch.vibDelay = ex.U8(); + break; + case 0xE5: + case 0xE6: + case 0xE9: + case 0xED: + ex.U8(); + break; + case 0xEE: // pitch bend, +/- two semitones + ch.freqScale = std::pow(2.0f, (float)ex.S8() / 762.0f); + PushPitch((int)(&ch - seq.chans)); + break; + case 0xE4: { + if (ch.dynTable >= 0 && ch.value != -1) { + const int addr = ReadU16At(ch.dynTable + 2 * (uint8_t)ch.value); + if (addr >= 0 && ex.sp < 4) { + ex.stack[ex.sp++] = ex.pc; + ex.pc = (size_t)addr; + } + } + break; + } + case 0xEC: // vibrato + pitch reset + ch.vibExtent = 0; + ch.vibExtentStart = 0; + ch.vibRamp = 0; + ch.vibRate = 0; + ch.vibRateStart = 0; + ch.vibRateRamp = 0; + ch.freqScale = 1.0f; + PushPitch((int)(&ch - seq.chans)); + break; + case 0xE7: + case 0xCE: + case 0xCF: + ex.U16(); + break; + case 0xE8: + for (int i = 0; i < 8; ++i) { + ex.U8(); + } + break; + case 0xEF: + ex.U16(); + ex.U8(); + break; + default: { + const int lo = cmd & 0x0F; + switch (cmd & 0xF0) { + case 0x00: + ch.value = (int8_t)(lo < 8 && !ch.layers[lo].ex.active ? 1 : 0); + break; + case 0x10: // sample load io + break; + case 0x20: // startchannel + StartChannel(lo, ex.U16()); + break; + case 0x30: + case 0x40: + ex.U8(); + break; + case 0x50: + case 0x70: + case 0x80: + break; + case 0x60: // delayshort + ex.delay = std::max(lo, 1); + break; + case 0x90: { + const uint16_t addr = ex.U16(); + if (lo < 8) { + ch.layers[lo] = LayerSt{}; + ch.layers[lo].ex.data = buf.data(); + ch.layers[lo].ex.size = buf.size(); + ch.layers[lo].ex.Start(addr); + } + break; + } + case 0xA0: + if (lo < 8) { + ch.layers[lo].ex.End(); + } + break; + case 0xB0: { + if (ch.dynTable >= 0 && ch.value != -1 && lo < 8) { + const int addr = ReadU16At(ch.dynTable + 2 * (uint8_t)ch.value); + if (addr >= 0) { + ch.layers[lo] = LayerSt{}; + ch.layers[lo].ex.data = buf.data(); + ch.layers[lo].ex.size = buf.size(); + ch.layers[lo].ex.Start((size_t)addr); + } + } + break; + } + default: + break; + } + break; + } + } + } + } + + void StepLayer(ChanSt& ch, LayerSt& ly) { + M64Exec& ex = ly.ex; + int guard = 0; + while (ex.active && !ex.failed && ex.delay == 0 && guard++ < 1000) { + const uint8_t cmd = ex.U8(); + if (cmd >= 0xF2 && Flow(ex, cmd, 0)) { + continue; + } + if (cmd >= 0xC0) { + switch (cmd) { + case 0xC0: + ex.delay = std::max(ex.Var(), 1); + break; + case 0xC1: + ly.shortVel = ex.U8(); + break; + case 0xC2: + ly.transpose = ex.S8(); + break; + case 0xC3: + ly.shortDefaultDelay = ex.Var(); + break; + case 0xC4: + ly.continuous = true; + break; + case 0xC5: + ly.continuous = false; + break; + case 0xC6: { + const uint8_t instId = ex.U8(); + ly.instSet = true; + ly.drums = false; + ly.inst = nullptr; + if (instId == 0x7F) { + ly.drums = true; + } else if (instId < 0x80 && ch.font != nullptr && instId < ch.font->insts.size() && + ch.font->insts[instId] != nullptr) { + ly.inst = ch.font->insts[instId]; + ly.releaseRate = ly.inst->adsrDecayIndex; + } else { + ly.instSet = false; + } + break; + } + case 0xC7: { + ly.portaMode = ex.U8(); + ly.portaTarget = ex.U8(); + if ((ly.portaMode & 0x80) != 0) { + ly.portaTime = ex.U8(); + } else { + ly.portaTime = ex.Var(); + } + break; + } + case 0xC8: + ly.portaMode = 0; + break; + case 0xC9: // shortnotegatetime + ly.shortDur = ex.U8(); + break; + case 0xCA: + ly.pan = (float)std::clamp<int>(ex.U8(), 0, 128) / 128.0f; + break; + case 0xCB: // envelope override + decay + ly.envOverride = ex.U16(); + ly.releaseRate = ex.U8(); + break; + case 0xCC: // ignore drum pan + ly.ignoreDrumPan = true; + break; + case 0xCD: + ex.U8(); + break; + default: + if ((cmd & 0xF0) == 0xD0) { + if (shortVelTable >= 0) { + ly.shortVel = ReadU8At(shortVelTable + (cmd & 0x0F)); + } + break; + } + if ((cmd & 0xF0) == 0xE0) { + if (shortDurTable >= 0) { + ly.shortDur = ReadU8At(shortDurTable + (cmd & 0x0F)); + } + break; + } + ex.failed = true; + break; + } + continue; + } + + int pitch = cmd & 0x3F; + int vel = 0x40; + int delayTicks = 0; + int durByte = ly.noteDuration; + if (ch.largeNotes) { + switch (cmd & 0xC0) { + case 0x00: + delayTicks = ex.Var(); + vel = ex.U8(); + durByte = ex.U8(); + ly.lastDelay = delayTicks; + break; + case 0x40: + delayTicks = ex.Var(); + vel = ex.U8(); + durByte = 0; + ly.lastDelay = delayTicks; + break; + case 0x80: + delayTicks = ly.lastDelay; + vel = ex.U8(); + durByte = ex.U8(); + break; + } + } else { + switch (cmd & 0xC0) { + case 0x00: + delayTicks = ex.Var(); + ly.lastDelay = delayTicks; + break; + case 0x40: + delayTicks = ly.shortDefaultDelay; + break; + case 0x80: + delayTicks = ly.lastDelay; + break; + } + vel = ly.shortVel; + durByte = ly.shortDur; + } + delayTicks = std::max(delayTicks, 1); + EmitNote(ch, ly, pitch, std::clamp(vel, 0, 127), delayTicks, durByte); + ex.delay = delayTicks; + } + } + + void Run() { + seq.ex.data = buf.data(); + seq.ex.size = buf.size(); + seq.ex.Start(0); + + for (int tick = 0; tick < kMaxTicks && sec < UI::kSynthMaxSeconds; ++tick) { + if (seq.ex.active) { + if (seq.ex.delay > 0) { + seq.ex.delay--; + } + if (seq.ex.delay == 0) { + StepSeq(); + } + } + bool anyChan = false; + for (auto& ch : seq.chans) { + if (!ch.ex.active) { + continue; + } + anyChan = true; + if (ch.ex.delay > 0) { + ch.ex.delay--; + } + if (ch.ex.delay == 0) { + StepChan(ch); + } + for (auto& ly : ch.layers) { + if (!ly.ex.active) { + continue; + } + if (ly.ex.delay > 0) { + ly.ex.delay--; + } + if (ly.ex.delay == 0) { + StepLayer(ch, ly); + } + } + } + if (!seq.ex.active && !anyChan) { + break; + } + sec += 1.25 / (double)std::max(seq.tempo, 1); + } + } +}; + +} // namespace + +// Sequence id = position of this sequence's entry in the sequence table. +int SeqIdForItem(const ParseResultData& item) { + auto it = AudioContext::tables.find(AudioTableType::SEQ_TABLE); + if (it == AudioContext::tables.end() || it->second.info == nullptr) { + return -1; + } + const uint32_t rel = + GetSafeNode<uint32_t>(const_cast<ParseResultData&>(item).node, "offset", 0) - it->second.offset; + const auto& entries = it->second.info->entries; + for (size_t i = 0; i < entries.size(); ++i) { + if (entries[i].addr == rel) { + return (int)i; + } + } + return -1; +} + +// The seq-font table lists each sequence's font ids; the game's default +// font is the LAST entry (AudioLoad_GetFontsForSequence ends there). +std::vector<uint8_t> SeqFontsForItem(const ParseResultData& item) { + const auto& blob = Assets().seqFontTable; + const int seqId = SeqIdForItem(item); + if (seqId < 0 || blob.size() < (size_t)(seqId * 2 + 2)) { + return {}; + } + const size_t at = ((size_t)blob[seqId * 2] << 8) | blob[seqId * 2 + 1]; + if (at >= blob.size() || blob[at] == 0 || at + blob[at] >= blob.size()) { + return {}; + } + return std::vector<uint8_t>(blob.begin() + at + 1, blob.begin() + at + 1 + blob[at]); +} + +bool DecodeV1SampleToPcm(const ParseResultData& item, std::vector<int16_t>& pcm, int& rate) { + if (!item.data.has_value()) { + return false; + } + auto* sample = std::static_pointer_cast<NSampleData>(item.data.value()).get(); + const auto& assets = Assets(); + const auto tit = assets.refTuning.find(sample); + if (tit != assets.refTuning.end()) { + rate = (int)std::lround(tit->second * 32000.0f); + } else if (sample->sampleRate != 0) { + rate = (int)sample->sampleRate; + } else { + rate = (int)(32000.0f * sample->tuning); + } + if (rate <= 0) { + rate = 32000; + } + +#ifdef SF64_SUPPORT + if (AudioContext::driver == NAudioDrivers::SF64 && sample->codec == 2) { + auto& table = AudioContext::tables[AudioTableType::SAMPLE_TABLE]; + const uint8_t* ptr = table.buffer.data() + table.info->entries[sample->sampleBankId].addr + sample->sampleAddr; + std::vector<uint8_t> raw(ptr, ptr + sample->size); + std::vector<int16_t> out(sample->size * 2, 0); + SF64::DecompressAudio(raw, out.data()); + pcm.assign(out.begin(), out.begin() + sample->size); + return !pcm.empty(); + } +#endif + + const auto lit = assets.loopByOffset.find(sample->loop); + const auto bit = assets.bookByOffset.find(sample->book); + if (lit == assets.loopByOffset.end() || bit == assets.bookByOffset.end()) { + return false; + } + LUS::BinaryWriter aifc; + AudioConverter::SampleV1ToAIFC(sample, lit->second, bit->second, aifc); + const auto bytes = aifc.ToVector(); + aifc.Close(); + if (bytes.empty()) { + return false; + } + LUS::BinaryWriter aiff; + write_aiff(bytes, aiff); + const bool ok = DecodeAiffBytes(aiff.ToVector(), pcm, rate); + aiff.Close(); + return ok && !pcm.empty(); +} + +bool SequencePlayerV1::Render(const ParseResultData& item, int, UI::RenderedAudio& out) { + auto data = std::static_pointer_cast<RawBuffer>(item.data.value()); + const auto& fonts = Assets().fonts; + if (fonts.empty() || data->mBuffer.empty()) { + return false; + } + const std::vector<uint8_t> seqFonts = SeqFontsForItem(item); + const FontRef* font = nullptr; + if (!seqFonts.empty()) { + for (const auto& f : fonts) { + if (f.id == seqFonts.back()) { + font = &f; + break; + } + } + } + if (font == nullptr) { + SPDLOG_WARN("Sequence '{}': no seq-font table match, using font 0", item.name); + font = &fonts.front(); + } + + SeqRenderer renderer(data->mBuffer, font); + renderer.seqFonts = seqFonts; + renderer.Run(); + SPDLOG_INFO("Sequence '{}': font {} ({} insts, {} drums), {} notes, {} fonts indexed, seqFontTable {} bytes", + item.name, font->id, font->insts.size(), font->drums.size(), renderer.events.size(), fonts.size(), + Assets().seqFontTable.size()); + if (renderer.events.empty()) { + SPDLOG_WARN("Sequence '{}' rendered zero notes", item.name); + return false; + } + double end = 0.0; + for (const auto& ev : renderer.events) { + end = std::max(end, ev.startSec + ev.soundSec + 0.5); + } + out.pcm = UI::Synthesize(renderer.events, renderer.gainAuto, renderer.pitchAuto, end); + out.noteCount = renderer.events.size(); + return !out.pcm.empty(); +} + +#endif // BUILD_UI diff --git a/src/factories/naudio/v1/SequencePlayerV1.h b/src/factories/naudio/v1/SequencePlayerV1.h new file mode 100644 index 0000000..882b933 --- /dev/null +++ b/src/factories/naudio/v1/SequencePlayerV1.h @@ -0,0 +1,18 @@ +#pragma once + +#ifdef BUILD_UI + +#include "ui/audio/SequenceDriver.h" + +// Decodes a NAUDIO:V1:SAMPLE parse result to PCM16 at its natural rate. +// Resolves loop/book through the driver's parse-result index. +bool DecodeV1SampleToPcm(const ParseResultData& item, std::vector<int16_t>& pcm, int& rate); + +// naudio v1 (SF64-era library) sequence driver. Fonts resolve through the +// game's seq-font table (extracted as the gSeqFontTable ARRAY asset). +class SequencePlayerV1 : public UI::SequenceDriver { +public: + bool Render(const ParseResultData& item, int option, UI::RenderedAudio& out) override; +}; + +#endif // BUILD_UI diff --git a/src/factories/pm64/AudioFactory.cpp b/src/factories/pm64/AudioFactory.cpp index 655d579..acd84d4 100644 --- a/src/factories/pm64/AudioFactory.cpp +++ b/src/factories/pm64/AudioFactory.cpp @@ -1,4 +1,5 @@ #include "AudioFactory.h" +#include "AudioPreview.h" #include "Companion.h" #include "spdlog/spdlog.h" #include <set> @@ -604,6 +605,12 @@ std::optional<std::shared_ptr<IParsedData>> PM64AudioFactory::parse(std::vector< // Byte-swap for little-endian ByteSwapAudioData(audioData.data(), audioData.size()); + // Viewer mode: expose songs and bank instruments as preview assets, + // parsed from the untouched big-endian ROM data. + if (PM64Audio::PreviewAssets()) { + PM64Audio::RegisterPreviewAssets(buffer, offset); + } + return std::make_shared<RawBuffer>(audioData); } diff --git a/src/factories/pm64/AudioPreview.cpp b/src/factories/pm64/AudioPreview.cpp new file mode 100644 index 0000000..6bcc59b --- /dev/null +++ b/src/factories/pm64/AudioPreview.cpp @@ -0,0 +1,1227 @@ +#include "AudioPreview.h" + +#include <algorithm> +#include <cmath> +#include <cstring> + +#include "Companion.h" +#include "spdlog/spdlog.h" + +// PM64 audio preview, ported from pmret (src/audio/bgm_player.c, +// core/engine.c, core/voice.c). The SBN container indexes BGM songs, BK +// instrument banks and the INIT tables binding songs to banks. + +namespace PM64Audio { +namespace { + +uint32_t RD32(const std::vector<uint8_t>& d, uint64_t off) { + if (off + 4 > d.size()) { + return 0; + } + return ((uint32_t)d[off] << 24) | ((uint32_t)d[off + 1] << 16) | ((uint32_t)d[off + 2] << 8) | d[off + 3]; +} + +uint16_t RD16(const std::vector<uint8_t>& d, uint64_t off) { + if (off + 2 > d.size()) { + return 0; + } + return (uint16_t)((d[off] << 8) | d[off + 1]); +} + +uint8_t RD8(const std::vector<uint8_t>& d, uint64_t off) { + return off < d.size() ? d[off] : 0; +} + +std::string RdName(const std::vector<uint8_t>& d, uint64_t off) { + std::string out; + for (int i = 0; i < 4; ++i) { + const char c = (char)RD8(d, off + i); + if (c > 0x20 && c < 0x7F) { + out.push_back(c); + } + } + return out.empty() ? "____" : out; +} + +// Copies an envelope command list (interval/value pairs) up to and including +// its ENV_CMD_END terminator. +std::vector<uint8_t> CopyEnvList(const std::vector<uint8_t>& d, uint64_t off) { + std::vector<uint8_t> out; + for (int i = 0; i < 64; ++i) { + const uint8_t a = RD8(d, off + i * 2); + const uint8_t b = RD8(d, off + i * 2 + 1); + out.push_back(a); + out.push_back(b); + if (a == 0xFF) { // ENV_CMD_END + break; + } + } + return out; +} + +bool sPreviewAssets = false; + +} // namespace + +std::map<uint32_t, std::shared_ptr<Bank>>& BankRegistry() { + static std::map<uint32_t, std::shared_ptr<Bank>> registry; + return registry; +} + +GlobalData& Globals() { + static GlobalData data; + return data; +} + +void SetPreviewAssets(bool enabled) { + sPreviewAssets = enabled; +} + +bool PreviewAssets() { + return sPreviewAssets; +} + +std::shared_ptr<Bank> ParseBank(const std::vector<uint8_t>& rom, uint32_t off) { + auto& registry = BankRegistry(); + const auto it = registry.find(off); + if (it != registry.end()) { + return it->second; + } + auto bank = std::make_shared<Bank>(); + if (RD16(rom, off) != 0x424B || RD16(rom, off + 0x0C) != 0x4352) { // 'BK', format 'CR' + registry[off] = bank; + return bank; + } + const std::string name = RdName(rom, off + 8); + std::memcpy(bank->name, name.c_str(), std::min<size_t>(4, name.size())); + + for (int slot = 0; slot < 16; ++slot) { + const uint16_t insOff = RD16(rom, off + 0x12 + slot * 2); + if (insOff == 0) { + continue; + } + const uint64_t p = (uint64_t)off + insOff; + auto ins = std::make_shared<Instrument>(); + const uint32_t wavOff = RD32(rom, p); + const uint32_t wavLen = RD32(rom, p + 4); + ins->loopStart = (int32_t)RD32(rom, p + 0x0C); + ins->loopEnd = (int32_t)RD32(rom, p + 0x10); + ins->loopCount = (int32_t)RD32(rom, p + 0x14); + const uint32_t predOff = RD32(rom, p + 0x18); + const uint16_t bookSize = RD16(rom, p + 0x1C); + ins->keyBase = RD16(rom, p + 0x1E); + ins->sampleRate = (int32_t)RD32(rom, p + 0x20); + ins->type = RD8(rom, p + 0x24); + const uint32_t envOff = RD32(rom, p + 0x2C); + + if (wavLen == 0 || wavLen > 0x400000 || (uint64_t)off + wavOff + wavLen > rom.size()) { + continue; + } + ins->wav.assign(rom.begin() + off + wavOff, rom.begin() + off + wavOff + wavLen); + const int bookCount = bookSize / 2; + ins->book.reserve(bookCount); + for (int i = 0; i < bookCount; ++i) { + ins->book.push_back((int16_t)RD16(rom, (uint64_t)off + predOff + i * 2)); + } + if (envOff != 0) { + const uint64_t env = (uint64_t)off + envOff; + const uint8_t count = RD8(rom, env); + for (int e = 0; e < count && e < 4; ++e) { + const uint16_t press = RD16(rom, env + 4 + e * 4); + const uint16_t release = RD16(rom, env + 4 + e * 4 + 2); + ins->envelopes.emplace_back(CopyEnvList(rom, env + press), CopyEnvList(rom, env + release)); + } + } + bank->instruments[slot] = std::move(ins); + } + registry[off] = bank; + return bank; +} + +void RegisterPreviewAssets(const std::vector<uint8_t>& rom, uint32_t sbnBase) { + if (RD32(rom, sbnBase) != 0x53424E20) { // 'SBN ' + return; + } + const uint32_t fileListOff = RD32(rom, sbnBase + 0x10); + const uint32_t numEntries = RD32(rom, sbnBase + 0x14); + const uint32_t initOff = RD32(rom, sbnBase + 0x24); + if (numEntries == 0 || numEntries > 4096 || initOff == 0) { + return; + } + + struct FileEntry { + uint32_t romOff = 0; + uint32_t size = 0; + uint8_t fmt = 0; + }; + std::vector<FileEntry> files(numEntries); + for (uint32_t i = 0; i < numEntries; ++i) { + const uint64_t e = (uint64_t)sbnBase + fileListOff + i * 8; + const uint32_t data = RD32(rom, e + 4); + files[i].romOff = sbnBase + (RD32(rom, e) & 0xFFFFFF); + files[i].size = data & 0xFFFFFF; + files[i].fmt = data >> 24; + } + + // Register a sample card per bank instrument (AU_FMT_BK = 0x30). + for (uint32_t i = 0; i < numEntries; ++i) { + if (files[i].fmt != 0x30) { + continue; + } + auto bank = ParseBank(rom, files[i].romOff); + char base[64]; + snprintf(base, sizeof(base), "banks/%02X_%s", i, bank->name); + for (int slot = 0; slot < 16; ++slot) { + if (bank->instruments[slot] == nullptr) { + continue; + } + YAML::Node node; + node["type"] = "PM64:BK_SAMPLE"; + node["offset"] = files[i].romOff; + node["instrument"] = slot; + node["autogen"] = true; + char name[80]; + snprintf(name, sizeof(name), "%s/%02d", base, slot); + Companion::Instance->RegisterAsset(name, node); + } + } + + // INIT tables: global bank assignments, songs, and the extra file list + // ([0] SEF, [1] PER, [2] PRG). + const uint64_t init = (uint64_t)sbnBase + initOff; + const uint16_t bankListOff = RD16(rom, init + 0x08); + const uint16_t songListOff = RD16(rom, init + 0x0C); + const uint16_t songListSize = RD16(rom, init + 0x0E); + const uint16_t extraListOff = RD16(rom, init + 0x10); + + // Global banks: (bankSet, bankIndex) -> BK rom offset. BankSet values map + // onto BankSetIndex slots: AUX(1)->0 and 7, 2->1, MUSIC(3)->3, 4..6 as-is. + std::vector<std::pair<uint32_t, uint32_t>> globalBanks; // (setIdx<<8|bankIdx, romOff) + for (int i = 0; i < 80; ++i) { + const uint64_t e = init + bankListOff + i * 4; + const uint16_t fileIndex = RD16(rom, e); + if (fileIndex == 0xFFFF) { + break; + } + const uint8_t bankIndex = RD8(rom, e + 2); + const uint8_t bankSet = RD8(rom, e + 3); + if (fileIndex >= numEntries) { + continue; + } + const uint32_t romOff = files[fileIndex].romOff; + ParseBank(rom, romOff); + switch (bankSet) { + case 1: + globalBanks.emplace_back((0u << 8) | bankIndex, romOff); + globalBanks.emplace_back((7u << 8) | bankIndex, romOff); + break; + case 2: + globalBanks.emplace_back((1u << 8) | bankIndex, romOff); + break; + case 3: + globalBanks.emplace_back((3u << 8) | bankIndex, romOff); + break; + case 4: + case 5: + case 6: + globalBanks.emplace_back(((uint32_t)bankSet << 8) | bankIndex, romOff); + break; + default: + break; + } + } + + // PER drums and PRG programs. + Globals().perDrums.clear(); + Globals().prgPrograms.clear(); + const uint16_t perIndex = RD16(rom, init + extraListOff + 2); + const uint16_t prgIndex = RD16(rom, init + extraListOff + 4); + if (perIndex < numEntries && files[perIndex].size > 0x10) { + const uint64_t per = files[perIndex].romOff + 0x10; + const uint32_t count = std::min<uint32_t>((files[perIndex].size - 0x10) / 0x0C, 72); + for (uint32_t i = 0; i < count; ++i) { + const uint64_t p = per + i * 0x0C; + DrumInfo d; + d.bankPatch = RD16(rom, p); + d.keyBase = RD16(rom, p + 2); + d.volume = RD8(rom, p + 4); + d.pan = (int8_t)RD8(rom, p + 5); + d.reverb = RD8(rom, p + 6); + d.randTune = RD8(rom, p + 7); + d.randVolume = RD8(rom, p + 8); + d.randPan = RD8(rom, p + 9); + d.randReverb = RD8(rom, p + 10); + Globals().perDrums.push_back(d); + } + } + if (prgIndex < numEntries && files[prgIndex].size > 0x10) { + const uint64_t prg = files[prgIndex].romOff + 0x10; + const uint32_t count = std::min<uint32_t>((files[prgIndex].size - 0x10) / 8, 64); + for (uint32_t i = 0; i < count; ++i) { + const uint64_t p = prg + i * 8; + ProgramInfo pr; + pr.bankPatch = RD16(rom, p); + pr.volume = RD8(rom, p + 2); + pr.pan = (int8_t)RD8(rom, p + 3); + pr.reverb = RD8(rom, p + 4); + pr.coarseTune = (int8_t)RD8(rom, p + 5); + pr.fineTune = (int8_t)RD8(rom, p + 6); + Globals().prgPrograms.push_back(pr); + } + } + + // Songs: one PM64:BGM asset each, carrying its bank bindings (global + // banks plus the song's aux BK files loaded into AUX slots 0-2). + const uint32_t songCount = songListSize / 8 > 0 ? songListSize / 8 - 1 : 0; + for (uint32_t i = 0; i < songCount; ++i) { + const uint64_t e = init + songListOff + i * 8; + const uint16_t bgmIndex = RD16(rom, e); + if (bgmIndex == 0 || bgmIndex >= numEntries || files[bgmIndex].fmt != 0x10) { + continue; + } + YAML::Node node; + node["type"] = "PM64:BGM"; + node["offset"] = files[bgmIndex].romOff; + node["size"] = files[bgmIndex].size; + node["autogen"] = true; + for (const auto& [key, romOff] : globalBanks) { + node["banks"].push_back(key); + node["banks"].push_back(romOff); + } + for (int b = 0; b < 3; ++b) { + const uint16_t bkIndex = RD16(rom, e + 2 + b * 2); + if (bkIndex != 0 && bkIndex < numEntries && files[bkIndex].fmt == 0x30) { + ParseBank(rom, files[bkIndex].romOff); + node["banks"].push_back((0u << 8) | b); + node["banks"].push_back(files[bkIndex].romOff); + node["banks"].push_back((7u << 8) | b); + node["banks"].push_back(files[bkIndex].romOff); + } + } + char name[64]; + snprintf(name, sizeof(name), "bgm/%03d_%s", i, RdName(rom, files[bgmIndex].romOff + 8).c_str()); + Companion::Instance->RegisterAsset(name, node); + } + SPDLOG_INFO("PM64 audio preview: {} SBN files, {} songs, {} global banks, {} drums, {} programs", numEntries, + songCount, globalBanks.size(), Globals().perDrums.size(), Globals().prgPrograms.size()); +} + +} // namespace PM64Audio + +std::optional<std::shared_ptr<IParsedData>> PM64BkSampleFactory::parse(std::vector<uint8_t>& buffer, YAML::Node& node) { + auto data = std::make_shared<PM64BkSampleData>(); + data->bank = PM64Audio::ParseBank(buffer, GetSafeNode<uint32_t>(node, "offset")); + data->slot = GetSafeNode<int>(node, "instrument"); + if (data->bank == nullptr || data->slot < 0 || data->slot >= 16 || data->bank->instruments[data->slot] == nullptr) { + return std::nullopt; + } + return data; +} + +ExportResult PM64BkSampleBinaryExporter::Export(std::ostream& write, std::shared_ptr<IParsedData> raw, + std::string& entryName, YAML::Node& node, std::string* replacement) { + const auto data = std::static_pointer_cast<PM64BkSampleData>(raw); + const auto& ins = data->bank->instruments[data->slot]; + if (ins != nullptr) { + write.write((const char*)ins->wav.data(), ins->wav.size()); + } + return std::nullopt; +} + +ExportResult PM64BgmBinaryExporter::Export(std::ostream& write, std::shared_ptr<IParsedData> raw, + std::string& entryName, YAML::Node& node, std::string* replacement) { + const auto data = std::static_pointer_cast<PM64BgmData>(raw); + write.write((const char*)data->file.data(), data->file.size()); + return std::nullopt; +} + +std::optional<std::shared_ptr<IParsedData>> PM64BgmFactory::parse(std::vector<uint8_t>& buffer, YAML::Node& node) { + const uint32_t offset = GetSafeNode<uint32_t>(node, "offset"); + const uint32_t size = GetSafeNode<uint32_t>(node, "size"); + if (size < 0x24 || (uint64_t)offset + size > buffer.size()) { + return std::nullopt; + } + auto data = std::make_shared<PM64BgmData>(); + data->file.assign(buffer.begin() + offset, buffer.begin() + offset + size); + return data; +} + +#ifdef BUILD_UI + +#include <map> +#include <unordered_map> +#include "imgui.h" +#include "ui/BaseBackend.h" +#include "ui/ExportUtils.h" +#include "ui/Widgets.h" +#include "ui/audio/SequenceSynth.h" + +namespace { + +// pmret AuEnvelopeIntervals: envelope command intervals in microseconds. +// Indices 0-79 are the seconds table; 80-93 are N x 5750us audio frames. +double EnvIntervalSec(uint8_t idx) { + static const double kSeconds[80] = { + 60, 55, 50, 45, 40, 35, 30, 27.5, 25, 22.5, 20, 19, 18, 17, 16, 15, + 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4.5, 4, 3.5, 3, 2.75, 2.5, + 2.25, 2, 1.9, 1.8, 1.7, 1.6, 1.5, 1.4, 1.3, 1.2, 1.1, 1, 0.95, 0.9, 0.85, 0.8, + 0.75, 0.7, 0.65, 0.6, 0.55, 0.5, 0.45, 0.4, 0.375, 0.35, 0.325, 0.3, 0.29, 0.28, 0.27, 0.26, + 0.25, 0.24, 0.23, 0.22, 0.21, 0.2, 0.19, 0.18, 0.17, 0.16, 0.15, 0.14, 0.13, 0.12, 0.11, 0.1, + }; + static const int kUnits[14] = { 16, 14, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 }; + if (idx < 80) { + return kSeconds[idx]; + } + if (idx < 94) { + return kUnits[idx - 80] * 0.00575; + } + return 0.0; +} + +// VADPCM (order 2) decode. Decoding linearly from the start reproduces the +// loop-point state, so the stored ADPCM_STATE is not needed. +std::shared_ptr<UI::SynthSample> DecodeInstrument(const PM64Audio::Instrument& ins) { + static std::map<const PM64Audio::Instrument*, std::shared_ptr<UI::SynthSample>> sCache; + const auto it = sCache.find(&ins); + if (it != sCache.end()) { + return it->second; + } + auto sample = std::make_shared<UI::SynthSample>(); + const size_t npred = ins.book.size() / 16; + if (npred == 0) { + sCache[&ins] = sample; + return sample; + } + sample->pcm.reserve(ins.wav.size() / 9 * 16); + // The expanded codebook propagates the recursion within an 8-sample + // vector through the residual columns; history stays fixed per vector + // (sliding it per sample double-applies the filter and diverges). + int32_t h1 = 0, h2 = 0; // last two samples of the previous vector + for (size_t f = 0; f + 9 <= ins.wav.size(); f += 9) { + const uint8_t hdr = ins.wav[f]; + const int32_t scale = 1 << (hdr >> 4); + const size_t pred = std::min<size_t>(hdr & 0xF, npred - 1); + const int16_t* c0 = ins.book.data() + pred * 16; + const int16_t* c1 = c0 + 8; + int32_t r[16]; + for (int i = 0; i < 8; ++i) { + const uint8_t b = ins.wav[f + 1 + i]; + int32_t hi = b >> 4, lo = b & 0xF; + r[i * 2] = (hi > 7 ? hi - 16 : hi) * scale; + r[i * 2 + 1] = (lo > 7 ? lo - 16 : lo) * scale; + } + for (int g = 0; g < 2; ++g) { + const int32_t* rg = r + g * 8; + int32_t v[8]; + for (int i = 0; i < 8; ++i) { + int64_t acc = (int64_t)c0[i] * h2 + (int64_t)c1[i] * h1; + for (int j = 0; j < i; ++j) { + acc += (int64_t)rg[j] * c1[i - j - 1]; + } + // floor division by 2^11, then the raw residual + int64_t dout = acc >> 11; + v[i] = (int32_t)dout + rg[i]; + } + for (int i = 0; i < 8; ++i) { + sample->pcm.push_back((int16_t)std::clamp(v[i], -32768, 32767)); + } + h2 = v[6]; + h1 = v[7]; + } + } + if (ins.loopEnd > ins.loopStart && ins.loopCount != 0 && (size_t)ins.loopEnd <= sample->pcm.size()) { + sample->looped = true; + sample->loopStart = (uint32_t)ins.loopStart; + sample->loopEnd = (uint32_t)ins.loopEnd; + } + sCache[&ins] = sample; + return sample; +} + +// Envelope command list -> synth breakpoints (levels 0-127 normalized). +// Returns the loop start time (or -1) via loopT. +std::vector<std::pair<float, float>> EnvToPoints(const std::vector<uint8_t>& cmds, float& loopT) { + std::vector<std::pair<float, float>> points; + loopT = -1.0f; + double t = 0.0; + float scale = 1.0f; + for (size_t i = 0; i + 1 < cmds.size(); i += 2) { + const uint8_t a = cmds[i]; + const uint8_t b = cmds[i + 1]; + if (a < 0x80) { + t += EnvIntervalSec(a); + points.emplace_back((float)t, (float)(b & 0x7F) / 127.0f * scale); + } else if (a == 0xFE) { // ENV_CMD_SET_SCALE + scale = std::min<int>(b, 128) / 128.0f; + } else if (a == 0xFD) { // ENV_CMD_ADD_SCALE + scale = std::clamp(scale + (float)(int8_t)b / 128.0f, 0.0f, 1.0f); + } else if (a == 0xFC) { // ENV_CMD_START_LOOP + loopT = (float)t; + } else if (a == 0xFB || a == 0xFF) { // ENV_CMD_END_LOOP / ENV_CMD_END + break; + } + } + return points; +} + +// Release list: total ramp time to silence. +float EnvReleaseSec(const std::vector<uint8_t>& cmds) { + double t = 0.0; + for (size_t i = 0; i + 1 < cmds.size(); i += 2) { + const uint8_t a = cmds[i]; + if (a < 0x80) { + t += EnvIntervalSec(a); + } else if (a == 0xFB || a == 0xFF) { + break; + } + } + return std::max((float)t, 0.005f); +} + +const std::vector<uint8_t> kDefaultPress = { 61, 127, 0xFF, 0 }; // 280ms to full +const std::vector<uint8_t> kDefaultRelease = { 52, 0, 0xFF, 0 }; // 550ms to silence + +std::string sPlayingName; +UI::RenderedAudio sSamplePcm; +std::string sSampleName; + +bool DecodeSampleCard(const ParseResultData& item, int& rate) { + const auto data = std::static_pointer_cast<PM64BkSampleData>(item.data.value()); + const auto& ins = data->bank->instruments[data->slot]; + rate = ins->sampleRate > 0 ? ins->sampleRate : 32000; + if (sSampleName == item.name) { + return !sSamplePcm.pcm.empty(); + } + sSampleName = item.name; + sSamplePcm = {}; + const auto sample = DecodeInstrument(*ins); + sSamplePcm.pcm = sample->pcm; + return !sSamplePcm.pcm.empty(); +} + +} // namespace + +float PM64BkSampleFactoryUI::GetItemHeight(const ParseResultData&) { + return ImGui::GetTextLineHeightWithSpacing() * 3.0f + ImGui::GetFrameHeightWithSpacing() * 2.0f + + ImGui::GetStyle().ItemSpacing.y * 4.0f; +} + +void PM64BkSampleFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + const auto data = std::static_pointer_cast<PM64BkSampleData>(item.data.value()); + const auto& ins = data->bank->instruments[data->slot]; + ImGui::TextDisabled("bank %s slot %d \xe2\x80\x94 %zu bytes vadpcm, %d Hz, key %.1f, loop %d-%d (x%d), %zu envs", + data->bank->name, data->slot, ins->wav.size(), ins->sampleRate, ins->keyBase / 100.0f, + ins->loopStart, ins->loopEnd, ins->loopCount, ins->envelopes.size()); + + int rate = 32000; + const bool playingThis = sPlayingName == item.name && UI::GetBackend()->AudioProgress() >= 0.0f; + if (ImGui::Button(playingThis ? "Stop##bksample" : "Play##bksample")) { + if (playingThis) { + UI::GetBackend()->StopAudio(); + sPlayingName.clear(); + } else if (DecodeSampleCard(item, rate)) { + UI::GetBackend()->SetAudioSpeed(1.0f); + if (UI::GetBackend()->PlaySamples(sSamplePcm.pcm.data(), sSamplePcm.pcm.size(), rate, 1)) { + sPlayingName = item.name; + } + } + } + ImGui::SameLine(); + if (ImGui::Button("WAV##bksampleexp")) { + if (DecodeSampleCard(item, rate)) { + const auto path = UI::ExportFilePath(item.name, "wav"); + UI::NoteExport(item.name, UI::WriteWavFile(path, sSamplePcm.pcm.data(), sSamplePcm.pcm.size(), 1, rate) + ? path.string() + : "export failed"); + } else { + UI::NoteExport(item.name, "decode failed"); + } + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Export decoded sample to torch-exports/"); + } + UI::DrawExportMarker(item.name); + ImGui::SameLine(); + float volume = UI::GetBackend()->GetAudioVolume(); + ImGui::SetNextItemWidth(140.0f); + if (ImGui::SliderFloat("##bkvol", &volume, 0.0f, 1.0f, "vol %.2f")) { + UI::GetBackend()->SetAudioVolume(volume); + } +} + +namespace { + +// pmret BgmTicksRates, indexed by BGMFileInfo.timingPreset & 7. +const int kTickRates[8] = { 48, 24, 32, 40, 48, 56, 64, 48 }; +// pmret SeqCmdArgCounts for opcodes E0-FF. +const int kArgCounts[32] = { 2, 1, 1, 1, 4, 3, 2, 0, 2, 1, 1, 1, 1, 1, 1, 2, + 3, 1, 1, 0, 2, 1, 3, 1, 0, 0, 0, 0, 3, 3, 3, 3 }; +// pmret polyphonyCounts by track polyphonicIdx. +const int kPolyphony[8] = { 0, 1, 0, 0, 0, 2, 3, 4 }; + +struct BgmBankMap { + // (bankSetIndex << 8 | bankSlot) -> bank + std::map<uint32_t, std::shared_ptr<PM64Audio::Bank>> banks; + + const PM64Audio::Instrument* Get(uint32_t bankByte, uint32_t patch, int& envIdx) const { + envIdx = bankByte & 3; + const uint32_t setIdx = (bankByte & 0x70) >> 4; + const auto it = banks.find((setIdx << 8) | (patch >> 4)); + if (it == banks.end() || it->second == nullptr) { + return nullptr; + } + return it->second->instruments[patch & 15].get(); + } +}; + +struct BgmTrack { + uint32_t pos = 0; + bool active = false; + bool muted = false; + bool isDrum = false; + int delay = 0; + uint32_t savedPos = 0; // detour return + int detourLen = 0; + uint32_t prevPos = 0; // branch return + int voiceCount = 1; + + const PM64Audio::Instrument* ins = nullptr; + const std::vector<uint8_t>* envPress = nullptr; + const std::vector<uint8_t>* envRelease = nullptr; + double insVolume = 1.0; + double insVolTarget = 1.0, insVolStep = 0.0; + int insVolTicks = 0; + int volume = 127; + int pan = 64; + int reverb = 0; + int coarse = 0, fine = 0, detune = 0; + int tremDelay = 0, tremRate = 0, tremDepth = 0; + bool autoDirty = true; + + struct Voice { + int note = -1; // index into the notes vector + int remain = 0; + }; + std::vector<Voice> voices; +}; + +double CentsRatio(double cents) { + return std::pow(2.0, cents / 1200.0); +} + +} // namespace + +std::vector<std::string> SequencePlayerPM64::Options(const ParseResultData& item) { + std::vector<std::string> options; + if (!item.data.has_value()) { + return options; + } + const auto& file = std::static_pointer_cast<PM64BgmData>(item.data.value())->file; + const auto rd16 = [&file](uint64_t off) -> uint16_t { + return off + 2 <= file.size() ? (uint16_t)((file[off] << 8) | file[off + 1]) : 0; + }; + for (int i = 0; i < 4; ++i) { + if (rd16(0x14 + i * 2) != 0) { + options.push_back("variation " + std::to_string(i)); + } + } + if (options.size() < 2) { + options.clear(); // hide the picker for single-variation songs + } + return options; +} + +bool SequencePlayerPM64::Render(const ParseResultData& item, int option, UI::RenderedAudio& out) { + if (!item.data.has_value()) { + return false; + } + const auto& file = std::static_pointer_cast<PM64BgmData>(item.data.value())->file; + const auto rd8 = [&file](uint64_t off) -> uint8_t { return off < file.size() ? file[off] : 0; }; + const auto rd16 = [&file](uint64_t off) -> uint16_t { + return off + 2 <= file.size() ? (uint16_t)((file[off] << 8) | file[off + 1]) : 0; + }; + const auto rd32 = [&file](uint64_t off) -> uint32_t { + return off + 4 <= file.size() ? ((uint32_t)file[off] << 24) | ((uint32_t)file[off + 1] << 16) | + ((uint32_t)file[off + 2] << 8) | file[off + 3] + : 0; + }; + + // Bank bindings from the registered node. + BgmBankMap bankMap; + const YAML::Node banksNode = item.node["banks"]; + if (banksNode.IsSequence()) { + for (size_t i = 0; i + 1 < banksNode.size(); i += 2) { + const uint32_t key = banksNode[i].as<uint32_t>(); + const uint32_t romOff = banksNode[i + 1].as<uint32_t>(); + const auto bit = PM64Audio::BankRegistry().find(romOff); + if (bit != PM64Audio::BankRegistry().end()) { + bankMap.banks[key] = bit->second; + } + } + } + + // Header (BGMFileInfo at 0x10). + const int timingPreset = rd8(0x10); + uint16_t compositions[4]; + int variations = 0; + uint16_t chosen = 0; + for (int i = 0; i < 4; ++i) { + compositions[i] = rd16(0x14 + i * 2); + if (compositions[i] != 0 && variations++ == option) { + chosen = compositions[i]; + } + } + if (chosen == 0) { + chosen = compositions[0]; + } + if (chosen == 0) { + return false; + } + const uint32_t drumsOff = (uint32_t)rd16(0x1C) << 2; + const int drumCount = rd16(0x1E); + const uint32_t insOff = (uint32_t)rd16(0x20) << 2; + const int insCount = rd16(0x22); + + const int ticksPerBeat = kTickRates[timingPreset & 7]; + // pmret au_bgm_set_tick_resolution: milli-frames per tick, from the 5.75ms + // audio frame cadence, bounds the usable tempo range. + const double mframesPerMinute = 1000.0 * (60.0 * 32000.0 / 184.0); + double mframesPerTick = mframesPerMinute / ticksPerBeat; + mframesPerTick = std::clamp(mframesPerTick, 80000.0, 500000.0); + const double maxTempo = (mframesPerTick / 1000.0) * 100.0; // BPM*100 cap + + // Master state. + double masterTempo = std::min(156.0 * 100.0, maxTempo); + double tempoTarget = 0.0, tempoStep = 0.0; + int tempoTicks = 0; + double masterVolume = 1.0; + double volTarget = 0.0, volStep = 0.0; + int volTicks = 0; + int masterPitchShift = 0; + + // Composition stream (u32 words, offsets <<2 relative to the file). + const uint32_t compBase = (uint32_t)chosen << 2; + uint32_t compPos = compBase; + uint32_t labels[32]; + for (auto& l : labels) { + l = compBase; + } + for (uint32_t scan = compBase;;) { + const uint32_t cmd = rd32(scan); + scan += 4; + if (cmd == 0 || scan >= file.size()) { + break; + } + if ((cmd >> 28) == 3) { // BGM_COMP_START_LOOP + labels[cmd & 0x1F] = scan; + } + } + uint32_t loopEndPos[5] = {}; + int loopCounters[5] = {}; + int loopDepth = 0; + // Count-0 composition loops repeat forever in-game; the preview passes + // through and plays the body once. + + std::vector<BgmTrack> tracks(16); + int skippedNoIns = 0, skippedDrum = 0; + std::map<uint32_t, int> missingBank; + std::vector<UI::SynthNote> notes; + UI::GainAutomation gainAuto[16]; + UI::PitchAutomation pitchAuto[16]; + + double now = 0.0; + double tickSec = 60.0 / ((masterTempo / 100.0) * ticksPerBeat); + enum { FETCH, ACTIVE, DONE } state = FETCH; + const double kMaxSeconds = 300.0; + long safety = 4000000; + + const auto pushGain = [&](int t) { + gainAuto[t].emplace_back((float)now, (float)(masterVolume * tracks[t].insVolume)); + }; + + const auto killVoice = [&](BgmTrack& tr, int v) { + if (tr.voices[v].note >= 0) { + UI::SynthNote& n = notes[tr.voices[v].note]; + n.soundSec = std::max(0.001, now - n.startSec); + n.releaseSec = 0.01; + tr.voices[v].note = -1; + } + }; + + while (state != DONE && now < kMaxSeconds && safety-- > 0) { + if (state == FETCH) { + const uint32_t cmd = rd32(compPos); + compPos += 4; + if (cmd == 0 || compPos > file.size()) { + state = DONE; + break; + } + switch (cmd >> 28) { + case 1: { // BGM_COMP_PLAY_PHRASE + const uint32_t phraseStart = compBase + ((cmd & 0xFFFF) << 2); + for (int i = 0; i < 16; ++i) { + BgmTrack& tr = tracks[i]; + tr.voices.clear(); + const uint32_t info = rd32(phraseStart + i * 4); + const uint32_t rel = info >> 16; + tr.active = false; + if (rel != 0 && (info & 0x100) == 0) { + const uint32_t linked = (info >> 9) & 0xF; + if (linked != 0) { + // Linked tracks start muted (proximity mixes). + continue; + } + tr.active = true; + tr.pos = phraseStart + rel; + tr.delay = 1; + tr.isDrum = (info >> 7) & 1; + tr.voiceCount = kPolyphony[(info >> 13) & 7]; + tr.voices.assign(tr.voiceCount, BgmTrack::Voice{}); + tr.savedPos = 0; + tr.detourLen = 0; + tr.prevPos = 0; + } + } + state = ACTIVE; + break; + } + case 3: // BGM_COMP_START_LOOP + break; + case 4: // BGM_COMP_WAIT: consume one tick + now += tickSec; + break; + case 5: // BGM_COMP_END_LOOP + case 6: // conditional variants (flag defaults false -> case 6 loops) + case 7: { + if ((cmd >> 28) == 7) { + break; // loop-if-true with flag false: skip + } + const int label = cmd & 0x1F; + const int iter = (cmd >> 5) & 0x7F; + if (iter == 0) { + break; // infinite loop: play through once for preview + } + int depth = loopDepth; + if (loopEndPos[depth] != 0 && loopEndPos[depth] == compPos) { + if (loopCounters[depth] != 0) { + loopCounters[depth]--; + if (loopCounters[depth] == 0) { + loopEndPos[depth] = 0; + if (depth > 0) { + depth--; + } + } else { + compPos = labels[label]; + } + } else { + compPos = labels[label]; + } + } else if (loopEndPos[depth] == 0) { + loopEndPos[depth] = compPos; + loopCounters[depth] = iter; + compPos = labels[label]; + } else if (depth < 4) { + depth++; + loopEndPos[depth] = compPos; + loopCounters[depth] = iter; + compPos = labels[label]; + } + loopDepth = depth; + break; + } + default: + state = DONE; + break; + } + continue; + } + + // One tick, ported from au_bgm_player_update_playing. + bool finished = false; + + if (tempoTicks != 0) { + tempoTicks--; + masterTempo = tempoTicks == 0 ? tempoTarget : masterTempo + tempoStep; + masterTempo = std::clamp(masterTempo, 100.0, maxTempo); + tickSec = 60.0 / ((masterTempo / 100.0) * ticksPerBeat); + } + bool masterVolChanged = false; + if (volTicks != 0) { + volTicks--; + masterVolume = volTicks == 0 ? volTarget : masterVolume + volStep; + masterVolChanged = true; + } + + for (int ti = 0; ti < 16; ++ti) { + BgmTrack& tr = tracks[ti]; + if (!tr.active) { + continue; + } + if (tr.insVolTicks != 0) { + tr.insVolTicks--; + tr.insVolume = tr.insVolTicks == 0 ? tr.insVolTarget : tr.insVolume + tr.insVolStep; + tr.autoDirty = true; + } + if (masterVolChanged || tr.autoDirty) { + pushGain(ti); + tr.autoDirty = false; + } + + // Byte reader with detour bookkeeping (POST_BGM_READ). + const auto readByte = [&]() -> uint8_t { + const uint8_t b = rd8(tr.pos); + tr.pos++; + if (tr.detourLen != 0) { + tr.detourLen--; + if (tr.detourLen == 0) { + tr.pos = tr.savedPos; + } + } + return b; + }; + + tr.delay--; + int guard = 4096; + while (tr.delay == 0 && guard-- > 0) { + const uint8_t op = readByte(); + if (op < 0x80) { + if (op == 0) { + if (tr.prevPos != 0) { + tr.pos = tr.prevPos; + tr.prevPos = 0; + } else { + finished = true; + break; + } + } else if (op >= 0x78) { + tr.delay = ((op & 7) << 8) + readByte() + 0x78; + } else { + tr.delay = op; + } + } else if (op < 0xD4) { + // note + const int pitch = op & 0x7F; + const int velocity = readByte(); + int length = readByte(); + if (length >= 0xC0) { + length = ((length & ~0xC0) << 8) + readByte() + 0xC0; + } + if (tr.muted || length <= 1 || tr.voices.empty()) { + continue; + } + // acquire a voice: first free, else steal shortest + int slot = -1; + for (size_t v = 0; v < tr.voices.size(); ++v) { + if (tr.voices[v].note < 0) { + slot = (int)v; + break; + } + } + if (slot < 0) { + int shortest = INT32_MAX; + for (size_t v = 0; v < tr.voices.size(); ++v) { + if (tr.voices[v].remain < shortest) { + shortest = tr.voices[v].remain; + slot = (int)v; + } + } + killVoice(tr, slot); + } + + const PM64Audio::Instrument* ins = tr.ins; + const std::vector<uint8_t>* press = tr.envPress; + const std::vector<uint8_t>* release = tr.envRelease; + double cents; + double gain = (tr.volume / 127.0) * ((velocity > 0 ? velocity + 1 : 0) / 128.0); + double pan = tr.pan; + double reverb = tr.reverb; + if (tr.isDrum) { + const PM64Audio::DrumInfo* drum = nullptr; + if (pitch < 72) { + if ((size_t)pitch < PM64Audio::Globals().perDrums.size()) { + drum = &PM64Audio::Globals().perDrums[pitch]; + } + } else if (pitch - 72 < drumCount) { + static PM64Audio::DrumInfo sBgmDrum; + const uint64_t d = drumsOff + (uint64_t)(pitch - 72) * 0x0C; + sBgmDrum.bankPatch = rd16(d); + sBgmDrum.keyBase = rd16(d + 2); + sBgmDrum.volume = rd8(d + 4); + sBgmDrum.pan = (int8_t)rd8(d + 5); + sBgmDrum.reverb = rd8(d + 6); + drum = &sBgmDrum; + } + if (drum == nullptr) { + skippedDrum++; + continue; + } + int envIdx = 0; + ins = bankMap.Get(drum->bankPatch >> 8, drum->bankPatch & 0xFF, envIdx); + if (ins == nullptr) { + missingBank[drum->bankPatch]++; + continue; + } + if (envIdx < (int)ins->envelopes.size()) { + press = &ins->envelopes[envIdx].first; + release = &ins->envelopes[envIdx].second; + } else { + press = release = nullptr; + } + gain *= drum->volume / 127.0; + pan = drum->pan; + reverb = drum->reverb; + cents = (double)drum->keyBase + tr.coarse + tr.fine - ins->keyBase; + } else { + if (ins == nullptr) { + skippedNoIns++; + continue; + } + cents = pitch * 100.0 + tr.coarse + masterPitchShift + tr.fine - ins->keyBase; + } + cents += tr.detune; + + UI::SynthNote note; + note.startSec = now; + note.soundSec = length * tickSec; // re-measured at expiry (tempo fades) + note.chan = ti; + note.gain = (float)gain; + note.pan = (float)std::clamp(pan / 127.0, 0.0, 1.0); + note.reverb = (float)(reverb / 127.0) * 0.5f; + note.sample = DecodeInstrument(*ins); + const int rate = ins->sampleRate > 0 ? ins->sampleRate : 32000; + note.freqScale = (float)(CentsRatio(cents) * rate / UI::kSynthRate); + note.envPoints = EnvToPoints(press != nullptr ? *press : kDefaultPress, note.envLoopStartT); + note.releaseSec = EnvReleaseSec(release != nullptr ? *release : kDefaultRelease); + if (tr.tremDepth != 0) { + note.vibDelaySec = (float)(tr.tremDelay * tickSec); + const float hz = (float)((tr.tremRate / 256.0) / tickSec); + note.vibRateStartHz = note.vibRateEndHz = hz; + // triangle amplitude is ~(1024 * depth) >> 8 cents + note.vibDepthStart = note.vibDepthEnd = (float)(tr.tremDepth * 4.0 / 100.0); + } + if (std::getenv("TORCH_BGM_DEBUG") != nullptr && notes.size() < 120) { + fprintf(stderr, + "[bgm] t=%7.3f trk=%2d pitch=%3d vel=%3d len=%4d cents=%7.0f step=%.4f gain=%.3f " + "rate=%d key=%d env=%zupt rel=%.2f drum=%d\n", + now, ti, pitch, velocity, length, cents, note.freqScale, note.gain, ins->sampleRate, + ins->keyBase, note.envPoints.size(), note.releaseSec, (int)tr.isDrum); + } + tr.voices[slot].note = (int)notes.size(); + tr.voices[slot].remain = length; + notes.push_back(std::move(note)); + } else { + // command E0-FF (D4-DF unused in retail data) + const int idx = op >= 0xE0 ? op - 0xE0 : 0; + uint8_t args[4] = {}; + const int count = op >= 0xE0 ? kArgCounts[idx] : 0; + for (int a = 0; a < count; ++a) { + args[a] = readByte(); + } + switch (op) { + case 0xE0: { // MasterTempo + masterTempo = std::clamp((double)((args[0] << 8) | args[1]) * 100.0, 100.0, maxTempo); + tickSec = 60.0 / ((masterTempo / 100.0) * ticksPerBeat); + tempoTicks = 0; + break; + } + case 0xE1: // MasterVolume + masterVolume = (args[0] & 0x7F) / 127.0; + volTicks = 0; + pushGain(ti); + break; + case 0xE2: // MasterDetune (cents, signed semis*100) + masterPitchShift = (int8_t)args[0] * 100; + break; + case 0xE4: { // MasterTempoFade + int time = (args[0] << 8) | args[1]; + const double target = + std::clamp((double)((args[2] << 8) | args[3]) * 100.0, 100.0, maxTempo); + if (time <= 0) { + time = 1; + } + tempoTicks = time; + tempoTarget = target; + tempoStep = (target - masterTempo) / time; + break; + } + case 0xE5: { // MasterVolumeFade + int time = (args[0] << 8) | args[1]; + const double target = (args[2] & 0x7F) / 127.0; + if (time <= 0) { + time = 1; + } + volTicks = time; + volTarget = target; + volStep = (target - masterVolume) / time; + break; + } + case 0xE8: { // TrackOverridePatch + int envIdx = 0; + tr.ins = bankMap.Get(args[0], args[1], envIdx); + tr.envPress = tr.envRelease = nullptr; + if (tr.ins != nullptr && envIdx < (int)tr.ins->envelopes.size()) { + tr.envPress = &tr.ins->envelopes[envIdx].first; + tr.envRelease = &tr.ins->envelopes[envIdx].second; + } + break; + } + case 0xE9: // InstrumentVolume + tr.insVolume = (args[0] & 0x7F) / 127.0; + tr.insVolTicks = 0; + pushGain(ti); + break; + case 0xEA: // InstrumentPan + tr.pan = args[0] & 0x7F; + break; + case 0xEB: // InstrumentReverb + tr.reverb = args[0] & 0x7F; + break; + case 0xEC: // TrackVolume + tr.volume = args[0] & 0x7F; + break; + case 0xED: // InstrumentCoarseTune (semitones) + tr.coarse = (int8_t)args[0] * 100; + break; + case 0xEE: // InstrumentFineTune (cents) + tr.fine = (int8_t)args[0]; + break; + case 0xEF: // TrackDetune (s16 cents) + tr.detune = (int16_t)((args[0] << 8) | args[1]); + break; + case 0xF0: // TrackTremolo + tr.tremDelay = args[0]; + tr.tremRate = args[1]; + tr.tremDepth = args[2]; + break; + case 0xF1: + tr.tremRate = args[0]; + break; + case 0xF2: + tr.tremDepth = args[0]; + break; + case 0xF3: + tr.tremDepth = 0; + break; + case 0xF4: // SubTrackRandomPan (use base pan) + tr.pan = args[0] & 0x7F; + break; + case 0xF5: { // UseInstrument + PM64Audio::ProgramInfo info; + const uint32_t index = args[0]; + bool have = false; + if (index < 0x80) { + if ((int)index < insCount) { + const uint64_t p = insOff + (uint64_t)index * 8; + info.bankPatch = rd16(p); + info.volume = rd8(p + 2); + info.pan = (int8_t)rd8(p + 3); + info.reverb = rd8(p + 4); + info.coarseTune = (int8_t)rd8(p + 5); + info.fineTune = (int8_t)rd8(p + 6); + have = true; + } + } else if (index - 0x80 < PM64Audio::Globals().prgPrograms.size()) { + info = PM64Audio::Globals().prgPrograms[index - 0x80]; + have = true; + } + if (!have) { + break; + } + int envIdx = 0; + tr.ins = bankMap.Get(info.bankPatch >> 8, info.bankPatch & 0xFF, envIdx); + tr.envPress = tr.envRelease = nullptr; + if (tr.ins != nullptr && envIdx < (int)tr.ins->envelopes.size()) { + tr.envPress = &tr.ins->envelopes[envIdx].first; + tr.envRelease = &tr.ins->envelopes[envIdx].second; + } + tr.insVolume = (info.volume & 0x7F) / 127.0; + tr.insVolTicks = 0; + tr.pan = info.pan & 0x7F; + tr.reverb = info.reverb & 0x7F; + tr.coarse = info.coarseTune * 100; + tr.fine = info.fineTune; + pushGain(ti); + break; + } + case 0xF6: { // InstrumentVolumeLerp + int time = (args[0] << 8) | args[1]; + const double target = (args[2] & 0x7F) / 127.0; + if (target != tr.insVolume) { + if (time <= 0) { + time = 1; + } + tr.insVolTicks = time; + tr.insVolTarget = target; + tr.insVolStep = (target - tr.insVolume) / time; + } + break; + } + case 0xFC: { // Branch (jump table; prox mix id 0) + const uint32_t table = (args[0] << 8) | args[1]; + tr.prevPos = tr.pos; + tr.pos = ((uint32_t)rd8(table) << 8) | rd8(table + 1); + tr.isDrum = rd8(table + 2) != 0; + tr.coarse = 0; + tr.fine = 0; + tr.detune = 0; + tr.tremDepth = 0; + tr.insVolTicks = 0; + break; + } + case 0xFE: { // Detour + const uint32_t off = (args[0] << 8) | args[1]; + tr.detourLen = args[2]; + tr.savedPos = tr.pos; + tr.pos = off; + break; + } + default: // E3/E6/E7/F7-FB/FD/FF: effects, events; no-op here + break; + } + } + } + if (finished) { + break; + } + + // decrement sounding notes; finalize on expiry + for (auto& v : tr.voices) { + if (v.note >= 0) { + if (--v.remain <= 0) { + notes[v.note].soundSec = std::max(0.001, now + tickSec - notes[v.note].startSec); + v.note = -1; + } + } + } + } + + now += tickSec; + if (finished) { + state = FETCH; + } + } + + if (std::getenv("TORCH_BGM_DEBUG") != nullptr) { + std::string missing; + for (const auto& [bp, count] : missingBank) { + char buf[32]; + snprintf(buf, sizeof(buf), " %04X:%d", bp, count); + missing += buf; + } + fprintf(stderr, "[bgmstat] %s notes=%zu noIns=%d noDrum=%d missingBank:%s\n", item.name.c_str(), notes.size(), + skippedNoIns, skippedDrum, missing.c_str()); + } + if (notes.empty()) { + return false; + } + out.pcm = UI::Synthesize(notes, gainAuto, pitchAuto, now); + out.noteCount = notes.size(); + return !out.pcm.empty(); +} + +#endif // BUILD_UI diff --git a/src/factories/pm64/AudioPreview.h b/src/factories/pm64/AudioPreview.h new file mode 100644 index 0000000..fb17c5c --- /dev/null +++ b/src/factories/pm64/AudioPreview.h @@ -0,0 +1,134 @@ +#pragma once + +#include <cstdint> +#include <map> +#include <memory> +#include <vector> + +#include "factories/BaseFactory.h" + +#ifdef BUILD_UI +#include "ui/audio/SequenceDriver.h" +#endif + +// Preview-only PM64 audio assets. When preview mode is on, the PM64:AUDIO +// factory walks the SBN container and registers one PM64:BGM asset per song +// and one PM64:BK_SAMPLE asset per bank instrument (see pmret src/audio). +namespace PM64Audio { + +// One BK instrument: VADPCM wave data plus its codebook, loop, tuning and +// envelope presets (press/release command list pairs). +struct Instrument { + std::vector<uint8_t> wav; + std::vector<int16_t> book; // codebook s16s (order 2) + int32_t loopStart = 0; + int32_t loopEnd = 0; + int32_t loopCount = 0; + uint16_t keyBase = 4800; // cents + int32_t sampleRate = 32000; + uint8_t type = 0; + std::vector<std::pair<std::vector<uint8_t>, std::vector<uint8_t>>> envelopes; +}; + +struct Bank { + char name[5] = {}; + std::shared_ptr<Instrument> instruments[16]; +}; + +// Banks parsed from ROM, keyed by BK file ROM offset; filled during parsing so +// the BGM driver can resolve (bank set, patch) at preview time. +std::map<uint32_t, std::shared_ptr<Bank>>& BankRegistry(); +std::shared_ptr<Bank> ParseBank(const std::vector<uint8_t>& rom, uint32_t off); + +// Global PER drum table and PRG program table (parsed from the SBN's INIT +// extra files by the PM64:AUDIO factory). +struct DrumInfo { + uint16_t bankPatch = 0; + uint16_t keyBase = 4800; + uint8_t volume = 127; + int8_t pan = 64; + uint8_t reverb = 0; + uint8_t randTune = 0, randVolume = 0, randPan = 0, randReverb = 0; +}; + +struct ProgramInfo { + uint16_t bankPatch = 0; + uint8_t volume = 127; + int8_t pan = 64; + uint8_t reverb = 0; + int8_t coarseTune = 0, fineTune = 0; +}; + +struct GlobalData { + std::vector<DrumInfo> perDrums; + std::vector<ProgramInfo> prgPrograms; +}; +GlobalData& Globals(); + +// Preview asset registration is opt-in so extraction runs stay clean. +void SetPreviewAssets(bool enabled); +bool PreviewAssets(); + +// Walks the SBN container (big-endian ROM data) and registers the per-song +// and per-instrument preview assets. Called by the PM64:AUDIO factory. +void RegisterPreviewAssets(const std::vector<uint8_t>& rom, uint32_t sbnBase); + +} // namespace PM64Audio + +class PM64BkSampleData : public IParsedData { +public: + std::shared_ptr<PM64Audio::Bank> bank; + int slot = 0; +}; + +// Preview assets still need an exporter registered or ParseNode discards the +// parse result; Binary writes the raw payload. +class PM64BkSampleBinaryExporter : public BaseExporter { + ExportResult Export(std::ostream& write, std::shared_ptr<IParsedData> data, std::string& entryName, + YAML::Node& node, std::string* replacement) override; +}; + +class PM64BkSampleFactory : public BaseFactory { +public: + std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; + inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { + return { + REGISTER(Binary, PM64BkSampleBinaryExporter) + }; + } +}; + +class PM64BgmData : public IParsedData { +public: + std::vector<uint8_t> file; +}; + +class PM64BgmBinaryExporter : public BaseExporter { + ExportResult Export(std::ostream& write, std::shared_ptr<IParsedData> data, std::string& entryName, + YAML::Node& node, std::string* replacement) override; +}; + +class PM64BgmFactory : public BaseFactory { +public: + std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; + inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { + return { + REGISTER(Binary, PM64BgmBinaryExporter) + }; + } +}; + +#ifdef BUILD_UI +class PM64BkSampleFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; + +// Offline BGM interpreter (port of pmret au_bgm_player_update_playing). +class SequencePlayerPM64 : public UI::SequenceDriver { +public: + bool Render(const ParseResultData& item, int option, UI::RenderedAudio& out) override; + std::vector<std::string> Options(const ParseResultData& item) override; +}; +#endif diff --git a/src/factories/pm64/CollisionFactory.cpp b/src/factories/pm64/CollisionFactory.cpp index 53a4e45..907026d 100644 --- a/src/factories/pm64/CollisionFactory.cpp +++ b/src/factories/pm64/CollisionFactory.cpp @@ -194,3 +194,128 @@ ExportResult PM64CollisionHeaderExporter::Export(std::ostream& write, std::share write << "extern u8 " << symbol << "[];\n"; return std::nullopt; } + +#ifdef BUILD_UI +#include <cmath> +#include <cstring> +#include <map> +#include "imgui.h" +#include "ui/BaseBackend.h" +#include "ui/Widgets.h" + +namespace { + +uint32_t CRD32(const std::vector<uint8_t>& b, uint32_t off) { + if (off + 4 > b.size()) { + return 0; + } + uint32_t v; + std::memcpy(&v, b.data() + off, 4); + return v; +} + +int16_t CRD16(const std::vector<uint8_t>& b, uint32_t off) { + if (off + 2 > b.size()) { + return 0; + } + int16_t v; + std::memcpy(&v, b.data() + off, 2); + return v; +} + +std::map<std::string, std::vector<UI::PreviewVertex>> sHitTris; +std::map<std::string, UI::OrbitView> sHitViews; + +const std::vector<UI::PreviewVertex>& HitTris(const ParseResultData& item) { + auto it = sHitTris.find(item.name); + if (it != sHitTris.end()) { + return it->second; + } + std::vector<UI::PreviewVertex> tris; + const auto& d = std::static_pointer_cast<RawBuffer>(item.data.value())->mBuffer; + const uint32_t collisionOff = CRD32(d, 0); + if (collisionOff != 0 && collisionOff + 0x18 <= d.size()) { + const int16_t numColliders = CRD16(d, collisionOff); + const uint32_t collidersOff = CRD32(d, collisionOff + 0x04); + const int16_t numVertices = CRD16(d, collisionOff + 0x08); + const uint32_t verticesOff = CRD32(d, collisionOff + 0x0C); + for (int16_t c = 0; c < numColliders; ++c) { + const uint32_t col = collidersOff + (uint32_t)c * 0xC; + const int16_t numTris = CRD16(d, col + 0x06); + const uint32_t trisOff = CRD32(d, col + 0x08); + // Per-collider hue so regions read apart. + const float hue = (float)((c * 47) % 360) / 360.0f; + const float rr = 0.5f + 0.5f * std::cos(6.2832f * hue); + const float gg = 0.5f + 0.5f * std::cos(6.2832f * (hue + 0.33f)); + const float bb = 0.5f + 0.5f * std::cos(6.2832f * (hue + 0.67f)); + for (int16_t t = 0; t < numTris; ++t) { + const uint32_t packed = CRD32(d, trisOff + (uint32_t)t * 4); + const uint32_t idx[3] = { packed & 0x3FF, (packed >> 10) & 0x3FF, (packed >> 20) & 0x3FF }; + float pos[3][3]; + bool ok = true; + for (int k = 0; k < 3 && ok; ++k) { + if ((int32_t)idx[k] >= numVertices) { + ok = false; + break; + } + const uint32_t v = verticesOff + idx[k] * 6; + pos[k][0] = (float)CRD16(d, v); + pos[k][1] = (float)CRD16(d, v + 2); + pos[k][2] = (float)CRD16(d, v + 4); + } + if (!ok) { + continue; + } + // Baked shading from the face normal. + const float ux = pos[1][0] - pos[0][0], uy = pos[1][1] - pos[0][1], uz = pos[1][2] - pos[0][2]; + const float vx = pos[2][0] - pos[0][0], vy = pos[2][1] - pos[0][1], vz = pos[2][2] - pos[0][2]; + float nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx; + const float nl = std::sqrt(nx * nx + ny * ny + nz * nz); + if (nl > 0.0001f) { + nx /= nl; + ny /= nl; + nz /= nl; + } + const float light = 0.55f + 0.45f * std::max(0.0f, nx * 0.3f + ny * 0.8f + nz * 0.52f); + for (int k = 0; k < 3; ++k) { + UI::PreviewVertex pv{}; + pv.position[0] = pos[k][0]; + pv.position[1] = pos[k][1]; + pv.position[2] = pos[k][2]; + pv.color[0] = (unsigned char)(rr * light * 255.0f); + pv.color[1] = (unsigned char)(gg * light * 255.0f); + pv.color[2] = (unsigned char)(bb * light * 255.0f); + pv.color[3] = 255; + tris.push_back(pv); + } + } + } + } + return sHitTris.emplace(item.name, std::move(tris)).first->second; +} + +} // namespace + +float PM64CollisionFactoryUI::GetItemHeight(const ParseResultData& item) { + return 60.0f + UI::PreviewBlockHeight(item.name); +} + +void PM64CollisionFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + const auto& tris = HitTris(item); + ImGui::TextDisabled("collision \xe2\x80\x94 %zu tris", tris.size() / 3); + if (tris.empty()) { + ImGui::TextDisabled("nothing drawable"); + return; + } + UI::OrbitView& view = sHitViews[item.name]; + const UI::PreviewCanvas canvas = UI::BeginResizableCanvas("##hitview", item.name, view); + if (canvas.visible) { + UI::GetBackend()->DrawTriangles(item.name, tris, canvas.origin, canvas.size, view); + } +} +#endif // BUILD_UI diff --git a/src/factories/pm64/CollisionFactory.h b/src/factories/pm64/CollisionFactory.h index 617cbcc..847779f 100644 --- a/src/factories/pm64/CollisionFactory.h +++ b/src/factories/pm64/CollisionFactory.h @@ -11,6 +11,14 @@ class PM64CollisionHeaderExporter : public BaseExporter { ExportResult Export(std::ostream& write, std::shared_ptr<IParsedData> data, std::string& entryName, YAML::Node& node, std::string* replacement) override; }; +#ifdef BUILD_UI +class PM64CollisionFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; +#endif + class PM64CollisionFactory : public BaseFactory { public: std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; diff --git a/src/factories/pm64/ImagePreview.cpp b/src/factories/pm64/ImagePreview.cpp new file mode 100644 index 0000000..0cbeaff --- /dev/null +++ b/src/factories/pm64/ImagePreview.cpp @@ -0,0 +1,929 @@ +#include "ImagePreview.h" + +#ifdef BUILD_UI + +#include <algorithm> +#include <cmath> +#include <cstring> +#include <map> +#include <string> +#include <vector> + +#include "Companion.h" +#include "imgui.h" +#include "types/RawBuffer.h" +#include "ui/BaseBackend.h" +#include "ui/ExportUtils.h" +#include "ui/Widgets.h" + +extern "C" { +#include "n64graphics/n64graphics.h" +} + +namespace { + +uint32_t LE32(const std::vector<uint8_t>& d, uint64_t off) { + if (off + 4 > d.size()) { + return 0; + } + uint32_t v; + std::memcpy(&v, d.data() + off, 4); + return v; +} + +uint16_t LE16(const std::vector<uint8_t>& d, uint64_t off) { + if (off + 2 > d.size()) { + return 0; + } + uint16_t v; + std::memcpy(&v, d.data() + off, 2); + return v; +} + +// Palettes stay big-endian RGBA5551 in every parsed PM64 blob. +void Put5551BE(std::vector<uint8_t>& out, const uint8_t* p) { + const uint16_t c = (uint16_t)((p[0] << 8) | p[1]); + out.push_back((uint8_t)(((c >> 11) & 31) * 255 / 31)); + out.push_back((uint8_t)(((c >> 6) & 31) * 255 / 31)); + out.push_back((uint8_t)(((c >> 1) & 31) * 255 / 31)); + out.push_back((c & 1) ? 255 : 0); +} + +// fmt/siz are G_IM_FMT_* / G_IM_SIZ_* values; pal points at big-endian RGBA5551. +std::vector<uint8_t> DecodeN64(const uint8_t* raster, const uint8_t* pal, int fmt, int siz, int w, int h) { + std::vector<uint8_t> out; + if (w <= 0 || h <= 0) { + return out; + } + out.reserve((size_t)w * h * 4); + const int count = w * h; + for (int i = 0; i < count; ++i) { + switch (fmt) { + case 0: // RGBA + if (siz == 3) { + out.insert(out.end(), raster + i * 4, raster + i * 4 + 4); + } else { + Put5551BE(out, raster + i * 2); + } + break; + case 2: { // CI + const int idx = siz == 1 ? raster[i] : (i % 2 == 0 ? raster[i / 2] >> 4 : raster[i / 2] & 0xF); + Put5551BE(out, pal + idx * 2); + break; + } + case 3: { // IA + uint8_t in, a; + if (siz == 2) { + in = raster[i * 2]; + a = raster[i * 2 + 1]; + } else if (siz == 1) { + in = (uint8_t)(raster[i] & 0xF0); + in |= in >> 4; + a = (uint8_t)((raster[i] & 0xF) * 17); + } else { + const uint8_t n = i % 2 == 0 ? raster[i / 2] >> 4 : raster[i / 2] & 0xF; + in = (uint8_t)(((n >> 1) & 7) * 255 / 7); + a = (n & 1) ? 255 : 0; + } + out.push_back(in); + out.push_back(in); + out.push_back(in); + out.push_back(a); + break; + } + case 4: { // I + const uint8_t in = + siz == 1 ? raster[i] : (uint8_t)((i % 2 == 0 ? raster[i / 2] >> 4 : raster[i / 2] & 0xF) * 17); + out.push_back(in); + out.push_back(in); + out.push_back(in); + out.push_back(in); + break; + } + default: + out.push_back(255); + out.push_back(0); + out.push_back(255); + out.push_back(255); + break; + } + } + return out; +} + +std::map<std::string, UI::TextureHandle>& TextureCache() { + static std::map<std::string, UI::TextureHandle> sCache; + return sCache; +} + +const UI::TextureHandle* FindCached(const std::string& key) { + const auto it = TextureCache().find(key); + return it != TextureCache().end() ? &it->second : nullptr; +} + +UI::TextureHandle UploadCached(const std::string& key, const std::vector<uint8_t>& rgba, int w, int h) { + const auto it = TextureCache().find(key); + if (it != TextureCache().end()) { + return it->second; + } + UI::TextureHandle handle = UI::kInvalidTexture; + if (!rgba.empty() && rgba.size() >= (size_t)w * h * 4 && UI::GetBackend() != nullptr) { + handle = UI::GetBackend()->UploadRGBA8(rgba.data(), w, h); + } + TextureCache()[key] = handle; + return handle; +} + +void DrawCheckerboard(ImDrawList* dl, const ImVec2& min, const ImVec2& size) { + constexpr float kCell = 8.0f; + dl->AddRectFilled(min, min + size, IM_COL32(48, 48, 52, 255)); + for (float y = 0; y < size.y; y += kCell) { + for (float x = 0; x < size.x; x += kCell) { + if ((int)((x + y) / kCell) % 2 == 0) { + continue; + } + const ImVec2 c0 = min + ImVec2(x, y); + const ImVec2 c1 = c0 + ImVec2(std::min(kCell, size.x - x), std::min(kCell, size.y - y)); + dl->AddRectFilled(c0, c1, IM_COL32(64, 64, 70, 255)); + } + } +} + +// Checkerboard-backed thumbnail with a zoomed hover tooltip. +void DrawThumb(UI::TextureHandle handle, int w, int h, float side, const char* info) { + ImDrawList* dl = ImGui::GetWindowDrawList(); + const ImVec2 origin = ImGui::GetCursorScreenPos(); + const ImVec2 box(side, side); + DrawCheckerboard(dl, origin, box); + if (handle != UI::kInvalidTexture && w > 0 && h > 0) { + const float aspect = (float)w / (float)h; + ImVec2 fit = box; + if (aspect >= 1.0f) { + fit.y = side / aspect; + } else { + fit.x = side * aspect; + } + ImGui::SetCursorScreenPos(origin + ImVec2((side - fit.x) * 0.5f, (side - fit.y) * 0.5f)); + ImGui::Image(handle, fit); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + const float big = std::min(384.0f, std::max((float)w, (float)h) * 3.0f); + ImVec2 bigFit(big, big); + if (aspect >= 1.0f) { + bigFit.y = big / aspect; + } else { + bigFit.x = big * aspect; + } + ImGui::Image(handle, bigFit); + if (info != nullptr) { + ImGui::TextUnformatted(info); + } + ImGui::EndTooltip(); + } + } + dl->AddRect(origin, origin + box, IM_COL32(255, 255, 255, 40), 4.0f); + ImGui::SetCursorScreenPos(origin); + ImGui::Dummy(box); +} + +bool WritePng(const std::filesystem::path& path, const std::vector<uint8_t>& pixels, int w, int h) { + if (pixels.empty()) { + return false; + } + unsigned char* png = nullptr; + int size = 0; + if (rgba2png(&png, &size, (const rgba*)pixels.data(), w, h) == 0 || png == nullptr) { + return false; + } + FILE* f = fopen(path.string().c_str(), "wb"); + bool ok = false; + if (f != nullptr) { + fwrite(png, 1, size, f); + fclose(f); + ok = true; + } + free(png); + return ok; +} + +void ExportPngButton(const std::string& assetName, const std::vector<uint8_t>& pixels, int w, int h) { + if (ImGui::SmallButton("PNG##imgexp")) { + const auto path = UI::ExportFilePath(assetName, "png"); + UI::NoteExport(assetName, WritePng(path, pixels, w, h) ? path.string() : "export failed"); + } + UI::DrawExportMarker(assetName); +} + +} // namespace + +float PM64StoryImageFactoryUI::GetItemHeight(const ParseResultData&) { + return ImGui::GetTextLineHeightWithSpacing() + 148.0f; +} + +void PM64StoryImageFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + auto& node = const_cast<YAML::Node&>(item.node); + const auto& d = std::static_pointer_cast<RawBuffer>(item.data.value())->mBuffer; + const int w = (int)GetSafeNode<uint32_t>(node, "width"); + const int h = (int)GetSafeNode<uint32_t>(node, "height"); + const bool hasPal = GetSafeNode<bool>(node, "has_palette"); + + static std::map<std::string, std::vector<uint8_t>> sPixels; + auto pit = sPixels.find(item.name); + if (pit == sPixels.end()) { + const uint8_t* pal = hasPal ? d.data() + (size_t)w * h : nullptr; + pit = sPixels.emplace(item.name, DecodeN64(d.data(), pal, hasPal ? 2 : 3, 1, w, h)).first; + } + const UI::TextureHandle handle = UploadCached(item.name, pit->second, w, h); + + ImGui::BeginGroup(); + DrawThumb(handle, w, h, 128.0f, hasPal ? "CI8" : "IA8"); + ImGui::EndGroup(); + ImGui::SameLine(); + ImGui::BeginGroup(); + ImGui::TextDisabled("story image \xe2\x80\x94 %dx%d %s", w, h, hasPal ? "CI8 + palette" : "IA8"); + ExportPngButton(item.name, pit->second, w, h); + ImGui::EndGroup(); +} + +namespace { + +struct TitleLayout { + int w, h; + int fmt, siz; + const char* label; +}; + +// TITLE_DATA blobs have no per-image header; dimensions come from the game +// code (title screen) or from the known raster sizes (partner portraits). +bool TitleLayoutFor(const std::string& symbol, size_t size, TitleLayout& out) { + static const std::map<std::string, TitleLayout> kKnown = { + { "title_logo", { 200, 112, 0, 3, "RGBA32" } }, + { "title_press_start", { 128, 32, 3, 1, "IA8" } }, + { "title_copyright", { 144, 32, 3, 1, "IA8" } }, + }; + const auto it = kKnown.find(symbol); + if (it != kKnown.end()) { + out = it->second; + return true; + } + static const std::map<size_t, TitleLayout> kBySize = { + { 15750, { 150, 105, 2, 1, "CI8" } }, // partner portraits + }; + const auto sit = kBySize.find(size); + if (sit != kBySize.end()) { + out = sit->second; + return true; + } + return false; +} + +// Sibling "<name>_pal" asset holding the big-endian RGBA5551 palette. +const std::shared_ptr<RawBuffer> FindSiblingPalette(const std::string& name) { + const std::string palName = name + "_pal"; + for (const auto& [file, results] : Companion::Instance->GetParseResults()) { + for (const auto& r : results) { + if (r.name == palName && r.data.has_value()) { + return std::static_pointer_cast<RawBuffer>(r.data.value()); + } + } + } + return nullptr; +} + +bool IsPaletteAsset(const std::string& name, size_t size) { + return name.size() > 4 && name.compare(name.size() - 4, 4, "_pal") == 0 && (size == 0x200 || size == 0x20); +} + +} // namespace + +float PM64TitleDataFactoryUI::GetItemHeight(const ParseResultData&) { + return ImGui::GetTextLineHeightWithSpacing() + 148.0f; +} + +void PM64TitleDataFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + auto& node = const_cast<YAML::Node&>(item.node); + const auto& d = std::static_pointer_cast<RawBuffer>(item.data.value())->mBuffer; + const auto symbol = GetSafeNode<std::string>(node, "symbol", item.name); + + static std::map<std::string, std::vector<uint8_t>> sPixels; + + // Palette blobs render as a swatch grid. + if (IsPaletteAsset(item.name, d.size())) { + const int colors = (int)d.size() / 2; + const int w = 16, h = colors / 16 > 0 ? colors / 16 : 1; + auto pit = sPixels.find(item.name); + if (pit == sPixels.end()) { + std::vector<uint8_t> rgba; + for (int i = 0; i < colors; ++i) { + Put5551BE(rgba, d.data() + i * 2); + } + pit = sPixels.emplace(item.name, std::move(rgba)).first; + } + const UI::TextureHandle handle = UploadCached(item.name, pit->second, w, h); + ImGui::BeginGroup(); + DrawThumb(handle, w, h, 128.0f, "RGBA5551 palette"); + ImGui::EndGroup(); + ImGui::SameLine(); + ImGui::BeginGroup(); + ImGui::TextDisabled("palette \xe2\x80\x94 %d colors", colors); + ExportPngButton(item.name, pit->second, w, h); + ImGui::EndGroup(); + return; + } + + TitleLayout layout{}; + if (!TitleLayoutFor(symbol, d.size(), layout)) { + ImGui::TextDisabled("image \xe2\x80\x94 %zu bytes (unknown layout)", d.size()); + return; + } + auto pit = sPixels.find(item.name); + if (pit == sPixels.end()) { + const uint8_t* pal = nullptr; + std::shared_ptr<RawBuffer> palBuf; + if (layout.fmt == 2) { + palBuf = FindSiblingPalette(item.name); + if (palBuf == nullptr || palBuf->mBuffer.size() < 0x200) { + ImGui::TextDisabled("image \xe2\x80\x94 CI8 with no sibling palette"); + return; + } + pal = palBuf->mBuffer.data(); + } + pit = sPixels.emplace(item.name, DecodeN64(d.data(), pal, layout.fmt, layout.siz, layout.w, layout.h)).first; + } + const UI::TextureHandle handle = UploadCached(item.name, pit->second, layout.w, layout.h); + + ImGui::BeginGroup(); + DrawThumb(handle, layout.w, layout.h, 128.0f, layout.label); + ImGui::EndGroup(); + ImGui::SameLine(); + ImGui::BeginGroup(); + ImGui::TextDisabled("image \xe2\x80\x94 %dx%d %s, %zu bytes", layout.w, layout.h, layout.label, d.size()); + ExportPngButton(item.name, pit->second, layout.w, layout.h); + ImGui::EndGroup(); +} + +namespace { + +struct MapTexEntry { + std::string name; + uint32_t rasterOff = 0, palOff = 0; + uint16_t w = 0, h = 0; + uint8_t fmt = 0, siz = 0, extra = 0; +}; + +uint32_t MapTexRasterSize(uint16_t w, uint16_t h, uint8_t siz, uint8_t extra) { + uint32_t size = (uint32_t)w * h; + if (extra == 1) { + const int minW[4] = { 16, 8, 4, 2 }; + for (int div = 2; w / div >= minW[siz & 3] && h / div > 0; div *= 2) { + size += (uint32_t)(w / div) * (h / div); + } + } + switch (siz) { + case 0: + return size / 2; + case 2: + return size * 2; + case 3: + return size * 4; + default: + return size; + } +} + +// Same walk as the shape viewer's tex archive indexing (parsed blob is LE with +// main fields in the upper nibbles). +std::vector<MapTexEntry> WalkMapTexArchive(const std::vector<uint8_t>& d) { + std::vector<MapTexEntry> entries; + uint32_t off = 0; + while (off + 0x30 <= d.size() && entries.size() < 512) { + MapTexEntry e; + for (int i = 0; i < 32 && d[off + i] != 0; ++i) { + e.name.push_back((char)d[off + i]); + } + if (e.name.empty()) { + break; + } + const uint16_t auxW = LE16(d, off + 0x20), mainW = LE16(d, off + 0x22); + const uint16_t auxH = LE16(d, off + 0x24), mainH = LE16(d, off + 0x26); + e.extra = d[off + 0x29]; + e.fmt = d[off + 0x2B] >> 4; + e.siz = d[off + 0x2C] >> 4; + if (mainW == 0 || mainH == 0 || mainW > 1024 || mainH > 1024) { + break; + } + const uint32_t rasterSize = MapTexRasterSize(mainW, mainH, e.siz, e.extra); + const uint32_t palSize = e.fmt == 2 ? (e.siz == 1 ? 0x200 : 0x20) : 0; + uint32_t auxRaster = 0, auxPal = 0; + if (e.extra == 3) { + const uint8_t auxFmt = d[off + 0x2B] & 0xF, auxSiz = d[off + 0x2C] & 0xF; + auxRaster = MapTexRasterSize(auxW, auxH, auxSiz, 0); + auxPal = auxFmt == 2 ? (auxSiz == 1 ? 0x200 : 0x20) : 0; + } + e.rasterOff = off + 0x30; + e.palOff = e.rasterOff + rasterSize; + e.w = mainW; + e.h = e.extra == 2 ? (uint16_t)(mainH / 2) : mainH; + if ((uint64_t)e.rasterOff + rasterSize + palSize <= d.size()) { + entries.push_back(e); + } + off += 0x30 + rasterSize + palSize + auxRaster + auxPal; + } + return entries; +} + +} // namespace + +float PM64MapTextureFactoryUI::GetItemHeight(const ParseResultData&) { + return 420.0f; +} + +void PM64MapTextureFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + const auto buf = std::static_pointer_cast<RawBuffer>(item.data.value()); + static std::map<std::string, std::vector<MapTexEntry>> sEntries; + auto eit = sEntries.find(item.name); + if (eit == sEntries.end()) { + eit = sEntries.emplace(item.name, WalkMapTexArchive(buf->mBuffer)).first; + } + const auto& entries = eit->second; + ImGui::TextDisabled("texture archive \xe2\x80\x94 %zu textures", entries.size()); + ImGui::SameLine(); + if (ImGui::SmallButton("PNG all##maptex")) { + int written = 0; + for (const auto& e : entries) { + const auto& d = buf->mBuffer; + const auto pixels = + DecodeN64(d.data() + e.rasterOff, e.fmt == 2 ? d.data() + e.palOff : nullptr, e.fmt, e.siz, e.w, e.h); + if (WritePng(UI::ExportFilePath(item.name + "/" + e.name, "png"), pixels, e.w, e.h)) { + written++; + } + } + UI::NoteExport(item.name, std::to_string(written) + " png -> torch-exports/" + item.name + "/"); + } + UI::DrawExportMarker(item.name); + + ImGui::BeginChild("##maptexgrid", ImVec2(0, 360.0f), false); + const float avail = ImGui::GetContentRegionAvail().x; + constexpr float kThumb = 64.0f; + const int cols = std::max(1, (int)(avail / (kThumb + 10.0f))); + int col = 0; + for (const auto& e : entries) { + if (col != 0) { + ImGui::SameLine(); + } + ImGui::BeginGroup(); + const std::string key = item.name + "/" + e.name; + const auto& d = buf->mBuffer; + const UI::TextureHandle* cached = FindCached(key); + const UI::TextureHandle handle = + cached != nullptr + ? *cached + : UploadCached(key, + DecodeN64(d.data() + e.rasterOff, e.fmt == 2 ? d.data() + e.palOff : nullptr, e.fmt, + e.siz, e.w, e.h), + e.w, e.h); + char info[96]; + static const char* kFmt[] = { "RGBA", "YUV", "CI", "IA", "I" }; + snprintf(info, sizeof(info), "%s %ux%u %s%d%s", e.name.c_str(), e.w, e.h, e.fmt < 5 ? kFmt[e.fmt] : "?", + 4 << e.siz, e.extra == 2 || e.extra == 3 ? " +aux" : ""); + DrawThumb(handle, e.w, e.h, kThumb, info); + ImGui::EndGroup(); + col = (col + 1) % cols; + } + ImGui::EndChild(); +} + +namespace { + +struct SpriteRaster { + uint32_t imgOff = 0; + uint8_t w = 0, h = 0; + int8_t palette = 0; +}; + +struct SpriteInfo { + std::vector<SpriteRaster> rasters; + std::vector<uint32_t> palettes; // offsets to 16-color RGBA5551 (BE) + int animCount = 0; +}; + +uint32_t BE32(const std::vector<uint8_t>& d, uint64_t off) { + if (off + 4 > d.size()) { + return 0; + } + return ((uint32_t)d[off] << 24) | ((uint32_t)d[off + 1] << 16) | ((uint32_t)d[off + 2] << 8) | d[off + 3]; +} + +// Player sprite rasters live in a shared blob streamed through descriptors +// (spr_get_player_raster): desc = size/16 in the top 3 nibbles, segment +// offset in the low 5; the header gives the image data segment base. +struct PlayerRasters { + std::vector<uint8_t> image; + std::vector<uint32_t> sets; // per-sprite descriptor index ranges + std::vector<uint32_t> descs; + uint32_t imageBase = 0; + bool valid = false; +}; + +const std::shared_ptr<RawBuffer> FindBlob(const char* suffix) { + for (const auto& [file, results] : Companion::Instance->GetParseResults()) { + for (const auto& r : results) { + if (r.data.has_value() && r.name.size() >= strlen(suffix) && + r.name.compare(r.name.size() - strlen(suffix), strlen(suffix), suffix) == 0) { + return std::static_pointer_cast<RawBuffer>(r.data.value()); + } + } + } + return nullptr; +} + +const PlayerRasters& GetPlayerRasters() { + static PlayerRasters sShared; + static bool sInit = false; + if (sInit) { + return sShared; + } + sInit = true; + const auto header = FindBlob("player_raster_header"); + const auto sets = FindBlob("player_raster_sets"); + const auto descs = FindBlob("player_raster_load_descriptors"); + const auto image = FindBlob("player_raster_image_data"); + if (header == nullptr || sets == nullptr || descs == nullptr || image == nullptr) { + return sShared; + } + sShared.imageBase = BE32(header->mBuffer, 8); + for (uint64_t o = 0; o + 4 <= sets->mBuffer.size(); o += 4) { + sShared.sets.push_back(BE32(sets->mBuffer, o)); + } + for (uint64_t o = 0; o + 4 <= descs->mBuffer.size(); o += 4) { + sShared.descs.push_back(BE32(descs->mBuffer, o)); + } + sShared.image = image->mBuffer; + sShared.valid = !sShared.sets.empty() && !sShared.descs.empty(); + return sShared; +} + +// player_sprite_N asset name -> N, or -1 for NPC sprites. +int PlayerSpriteIndex(const std::string& name) { + const auto pos = name.rfind("player_sprite_"); + if (pos == std::string::npos) { + return -1; + } + return atoi(name.c_str() + pos + strlen("player_sprite_")); +} + +SpriteInfo WalkSprite(const std::vector<uint8_t>& d) { + SpriteInfo out; + const uint32_t rastersOff = LE32(d, 0); + const uint32_t palettesOff = LE32(d, 4); + for (uint32_t p = 0x10; p + 4 <= d.size(); p += 4) { + const uint32_t v = LE32(d, p); + if (v == 0xFFFFFFFF) { + break; + } + out.animCount++; + } + for (uint32_t p = palettesOff; p + 4 <= d.size() && out.palettes.size() < 64; p += 4) { + const uint32_t v = LE32(d, p); + if (v == 0xFFFFFFFF) { + break; + } + if (v + 32 <= d.size()) { + out.palettes.push_back(v); + } + } + for (uint32_t p = rastersOff; p + 4 <= d.size() && out.rasters.size() < 512; p += 4) { + const uint32_t v = LE32(d, p); + if (v == 0xFFFFFFFF) { + break; + } + if (v + 8 > d.size()) { + continue; + } + SpriteRaster r; + r.imgOff = LE32(d, v); + r.w = d[v + 4]; + r.h = d[v + 5]; + r.palette = (int8_t)d[v + 6]; + if (r.w > 0 && r.h > 0) { + out.rasters.push_back(r); + } + } + return out; +} + +// Raster pixels for one sprite frame; player sprites read the shared blob. +const uint8_t* SpriteRasterPixels(const std::vector<uint8_t>& d, const SpriteRaster& r, int playerIdx, + size_t rasterIndex) { + const size_t bytes = (size_t)r.w * r.h / 2; + if (playerIdx >= 0) { + const PlayerRasters& shared = GetPlayerRasters(); + if (!shared.valid || (size_t)playerIdx + 1 >= shared.sets.size()) { + return nullptr; + } + const uint32_t begin = shared.sets[playerIdx]; + if (begin + rasterIndex >= shared.descs.size()) { + return nullptr; + } + const uint32_t desc = shared.descs[begin + rasterIndex]; + const int64_t off = (int64_t)(desc & 0xFFFFF) - shared.imageBase; + if (off < 0 || (uint64_t)off + bytes > shared.image.size()) { + return nullptr; + } + return shared.image.data() + off; + } + if ((uint64_t)r.imgOff + bytes > d.size()) { + return nullptr; + } + return d.data() + r.imgOff; +} + +} // namespace + +float PM64SpriteFactoryUI::GetItemHeight(const ParseResultData&) { + return 420.0f; +} + +void PM64SpriteFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + const auto buf = std::static_pointer_cast<RawBuffer>(item.data.value()); + const auto& d = buf->mBuffer; + static std::map<std::string, SpriteInfo> sInfo; + auto iit = sInfo.find(item.name); + if (iit == sInfo.end()) { + iit = sInfo.emplace(item.name, WalkSprite(d)).first; + } + const SpriteInfo& info = iit->second; + + static std::map<std::string, int> sPalChoice; + int& palChoice = sPalChoice.try_emplace(item.name, -1).first->second; + ImGui::TextDisabled("sprite \xe2\x80\x94 %zu rasters, %zu palettes, %d animations", info.rasters.size(), + info.palettes.size(), info.animCount); + ImGui::SetNextItemWidth(150.0f); + const int playerIdx = PlayerSpriteIndex(item.name); + if (ImGui::SmallButton("PNG all##sprite") && !info.palettes.empty()) { + int written = 0; + for (size_t i = 0; i < info.rasters.size(); ++i) { + const SpriteRaster& r = info.rasters[i]; + int pal = palChoice >= 0 ? palChoice : r.palette; + if (pal < 0 || pal >= (int)info.palettes.size()) { + pal = 0; + } + const uint8_t* raster = SpriteRasterPixels(d, r, playerIdx, i); + if (raster == nullptr) { + continue; + } + char frame[32]; + snprintf(frame, sizeof(frame), "/raster_%03zu", i); + const auto pixels = DecodeN64(raster, d.data() + info.palettes[pal], 2, 0, r.w, r.h); + if (WritePng(UI::ExportFilePath(item.name + frame, "png"), pixels, r.w, r.h)) { + written++; + } + } + UI::NoteExport(item.name, std::to_string(written) + " png -> torch-exports/" + item.name + "/"); + } + UI::DrawExportMarker(item.name); + ImGui::SameLine(); + const std::string palLabel = palChoice < 0 ? "palette: default" : "palette: " + std::to_string(palChoice); + if (ImGui::BeginCombo("##spritepal", palLabel.c_str())) { + if (ImGui::Selectable("default", palChoice < 0)) { + palChoice = -1; + } + for (int i = 0; i < (int)info.palettes.size(); ++i) { + if (ImGui::Selectable(std::to_string(i).c_str(), palChoice == i)) { + palChoice = i; + } + } + ImGui::EndCombo(); + } + + ImGui::BeginChild("##spritegrid", ImVec2(0, 320.0f), false); + const float avail = ImGui::GetContentRegionAvail().x; + constexpr float kThumb = 56.0f; + const int cols = std::max(1, (int)(avail / (kThumb + 10.0f))); + int col = 0; + for (size_t i = 0; i < info.rasters.size() && !info.palettes.empty(); ++i) { + const SpriteRaster& r = info.rasters[i]; + int pal = palChoice >= 0 ? palChoice : r.palette; + if (pal < 0 || pal >= (int)info.palettes.size()) { + pal = 0; + } + if (col != 0) { + ImGui::SameLine(); + } + ImGui::BeginGroup(); + char key[192]; + snprintf(key, sizeof(key), "%s/%zu/p%d", item.name.c_str(), i, pal); + const UI::TextureHandle* cached = FindCached(key); + UI::TextureHandle handle; + if (cached != nullptr) { + handle = *cached; + } else { + const uint8_t* raster = SpriteRasterPixels(d, r, playerIdx, i); + handle = UploadCached(key, + raster != nullptr ? DecodeN64(raster, d.data() + info.palettes[pal], 2, 0, r.w, r.h) + : std::vector<uint8_t>{}, + r.w, r.h); + } + char label[64]; + snprintf(label, sizeof(label), "raster %zu %ux%u CI4 pal %d", i, r.w, r.h, pal); + DrawThumb(handle, r.w, r.h, kThumb, label); + ImGui::EndGroup(); + col = (col + 1) % cols; + } + ImGui::EndChild(); +} + +namespace { + +struct ImgFXModel { + int vtxCount = 0; + int frameCount = 0; + std::vector<int> tris; // absolute frame-0 vertex indices + float mn[3] = { 0, 0, 0 }; + float mx[3] = { 0, 0, 0 }; // bounds across every keyframe +}; + +// Blob layout from PM64ImgFXAnimFactory: LE header, 12-byte vertices +// (frame-major), then f3dex2 Gfx words referencing frame-0 vertex addresses. +ImgFXModel ParseImgFX(const std::vector<uint8_t>& d) { + ImgFXModel m; + const uint32_t kfOff = LE32(d, 0x00); + m.vtxCount = LE16(d, 0x08); + const int gfxCount = LE16(d, 0x0A); + m.frameCount = LE16(d, 0x0C); + if (m.vtxCount <= 0 || m.frameCount <= 0) { + return m; + } + const uint32_t gfxBase = 16 + (uint32_t)m.frameCount * m.vtxCount * 12; + int slots[64] = {}; + for (int g = 0; g < gfxCount; ++g) { + const uint32_t w0 = LE32(d, gfxBase + g * 8); + const uint32_t w1 = LE32(d, gfxBase + g * 8 + 4); + const uint8_t op = w0 >> 24; + if (op == 0x01) { // G_VTX + const int numv = (w0 >> 12) & 0xFF; + const int vend = (w0 >> 1) & 0x7F; + const int v0 = vend - numv; + const int src = (int)((w1 - kfOff) / 12); + for (int k = 0; k < numv && v0 + k < 64; ++k) { + slots[v0 + k] = (src + k) % m.vtxCount; + } + } else if (op == 0x05) { // G_TRI1 + m.tris.push_back(slots[((w0 >> 16) & 0xFF) / 2]); + m.tris.push_back(slots[((w0 >> 8) & 0xFF) / 2]); + m.tris.push_back(slots[(w0 & 0xFF) / 2]); + } else if (op == 0x06) { // G_TRI2 + m.tris.push_back(slots[((w0 >> 16) & 0xFF) / 2]); + m.tris.push_back(slots[((w0 >> 8) & 0xFF) / 2]); + m.tris.push_back(slots[(w0 & 0xFF) / 2]); + m.tris.push_back(slots[((w1 >> 16) & 0xFF) / 2]); + m.tris.push_back(slots[((w1 >> 8) & 0xFF) / 2]); + m.tris.push_back(slots[(w1 & 0xFF) / 2]); + } else if (op == 0xDF) { + break; + } + } + // Whole-animation bounds keep the preview camera stable across frames + // (several effects collapse to a point on their first/last frames). + bool any = false; + for (int k = 0; k < m.frameCount * m.vtxCount; ++k) { + const uint64_t v = 16 + (uint64_t)k * 12; + const float pos[3] = { (float)(int16_t)LE16(d, v), (float)(int16_t)LE16(d, v + 2), + (float)(int16_t)LE16(d, v + 4) }; + for (int a = 0; a < 3; ++a) { + m.mn[a] = any ? std::min(m.mn[a], pos[a]) : pos[a]; + m.mx[a] = any ? std::max(m.mx[a], pos[a]) : pos[a]; + } + any = true; + } + return m; +} + +std::map<std::string, UI::OrbitView> sImgFXViews; + +} // namespace + +float PM64ImgFXAnimFactoryUI::GetItemHeight(const ParseResultData& item) { + return 76.0f + UI::PreviewBlockHeight(item.name); +} + +void PM64ImgFXAnimFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + const auto buf = std::static_pointer_cast<RawBuffer>(item.data.value()); + const auto& d = buf->mBuffer; + static std::map<std::string, ImgFXModel> sModels; + auto mit = sModels.find(item.name); + if (mit == sModels.end()) { + mit = sModels.emplace(item.name, ParseImgFX(d)).first; + } + const ImgFXModel& m = mit->second; + ImGui::TextDisabled("imgfx animation \xe2\x80\x94 %d frames, %d vertices, %zu tris", m.frameCount, m.vtxCount, + m.tris.size() / 3); + if (m.tris.empty()) { + ImGui::TextDisabled("nothing drawable"); + return; + } + + struct PlayState { + bool playing = true; + float frame = 0.0f; + }; + static std::map<std::string, PlayState> sPlay; + PlayState& play = sPlay[item.name]; + if (play.playing) { + play.frame += ImGui::GetIO().DeltaTime * 15.0f; + if (play.frame >= (float)m.frameCount) { + play.frame = 0.0f; + } + } + if (ImGui::SmallButton(play.playing ? "Pause##imgfx" : "Play##imgfx")) { + play.playing = !play.playing; + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(220.0f); + ImGui::SliderFloat("##imgfxframe", &play.frame, 0.0f, (float)(m.frameCount - 1), "frame %.0f"); + ImGui::SameLine(); + if (ImGui::SmallButton("OBJ##imgfx")) { + const int f = std::clamp((int)play.frame, 0, m.frameCount - 1); + char suffix[32]; + snprintf(suffix, sizeof(suffix), "_frame_%02d", f); + const auto path = UI::ExportFilePath(item.name + suffix, "obj"); + FILE* out = fopen(path.string().c_str(), "w"); + if (out != nullptr) { + for (int i = 0; i < m.vtxCount; ++i) { + const uint64_t v = 16 + ((uint64_t)f * m.vtxCount + i) * 12; + fprintf(out, "v %d %d %d\n", (int16_t)LE16(d, v), (int16_t)LE16(d, v + 2), (int16_t)LE16(d, v + 4)); + fprintf(out, "vt %f %f\n", d[v + 6] / 255.0, 1.0 - d[v + 7] / 255.0); + } + for (size_t t = 0; t + 2 < m.tris.size(); t += 3) { + fprintf(out, "f %d/%d %d/%d %d/%d\n", m.tris[t] + 1, m.tris[t] + 1, m.tris[t + 1] + 1, + m.tris[t + 1] + 1, m.tris[t + 2] + 1, m.tris[t + 2] + 1); + } + fclose(out); + UI::NoteExport(item.name, path.string()); + } else { + UI::NoteExport(item.name, "export failed"); + } + } + UI::DrawExportMarker(item.name); + + const int frame = std::clamp((int)play.frame, 0, m.frameCount - 1); + std::vector<UI::PreviewVertex> tris; + tris.reserve(m.tris.size() + 6); + // Invisible degenerate corner triangles pin the camera to the animation's + // global bounds so per-frame refits don't pump or blank the view. + for (int corner = 0; corner < 2; ++corner) { + UI::PreviewVertex pv{}; + pv.position[0] = corner == 0 ? m.mn[0] : m.mx[0]; + pv.position[1] = corner == 0 ? m.mn[1] : m.mx[1]; + pv.position[2] = corner == 0 ? m.mn[2] : m.mx[2]; + pv.color[3] = 0; + tris.push_back(pv); + tris.push_back(pv); + tris.push_back(pv); + } + for (const int idx : m.tris) { + const uint64_t v = 16 + ((uint64_t)frame * m.vtxCount + idx) * 12; + UI::PreviewVertex pv{}; + pv.position[0] = (float)(int16_t)LE16(d, v); + pv.position[1] = (float)(int16_t)LE16(d, v + 2); + pv.position[2] = (float)(int16_t)LE16(d, v + 4); + // uv as color so the mesh structure reads + pv.color[0] = (uint8_t)(80 + d[v + 6] / 2); + pv.color[1] = (uint8_t)(120 + d[v + 7] / 3); + pv.color[2] = 220; + pv.color[3] = 255; + tris.push_back(pv); + } + UI::OrbitView& view = sImgFXViews[item.name]; + const UI::PreviewCanvas canvas = UI::BeginResizableCanvas("##imgfxview", item.name, view); + if (canvas.visible) { + UI::GetBackend()->DrawTriangles(item.name, tris, canvas.origin, canvas.size, view); + } +} + +#endif // BUILD_UI diff --git a/src/factories/pm64/ImagePreview.h b/src/factories/pm64/ImagePreview.h new file mode 100644 index 0000000..f35c002 --- /dev/null +++ b/src/factories/pm64/ImagePreview.h @@ -0,0 +1,37 @@ +#pragma once + +#include "factories/BaseFactory.h" + +// Viewer cards for the PM64 image-like assets: sprites, map texture archives, +// imgfx vertex animations, title screen images and intro story images. +#ifdef BUILD_UI +class PM64SpriteFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; + +class PM64MapTextureFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; + +class PM64ImgFXAnimFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; + +class PM64TitleDataFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; + +class PM64StoryImageFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; +#endif diff --git a/src/factories/pm64/ShapeFactory.cpp b/src/factories/pm64/ShapeFactory.cpp index 398f0c4..eac233c 100644 --- a/src/factories/pm64/ShapeFactory.cpp +++ b/src/factories/pm64/ShapeFactory.cpp @@ -357,9 +357,9 @@ static void ByteSwapModelNode(uint8_t* data, uint32_t offset, size_t size) { static uint32_t FindRootNodeOffset(uint8_t* data, size_t size) { uint32_t headerRootAddr = BSWAP32(*reinterpret_cast<uint32_t*>(data)); - uint32_t firstValid = 0; // fallback: behaviour before the disambiguation - uint32_t bestExact = 0; // implied base == 0x80210000 (the known PM64 base) - uint32_t bestAligned = 0; // implied base aligned to 0x10000 + uint32_t firstValid = 0; // fallback: behaviour before the disambiguation + uint32_t bestExact = 0; // implied base == 0x80210000 (the known PM64 base) + uint32_t bestAligned = 0; // implied base aligned to 0x10000 for (uint32_t offset = 0x20; offset < size - 0x14; offset += 4) { int32_t type = static_cast<int32_t>(BSWAP32(*reinterpret_cast<uint32_t*>(data + offset))); @@ -797,3 +797,699 @@ ExportResult PM64ShapeHeaderExporter::Export(std::ostream& write, std::shared_pt write << "extern u8 " << symbol << "[];\n"; return std::nullopt; } + +#ifdef BUILD_UI +#include <algorithm> +#include <cmath> +#include <cstdlib> +#include <map> +#include "imgui.h" +#include "types/RawBuffer.h" +#include "ui/BaseBackend.h" +#include "ui/Widgets.h" + +namespace { + +uint32_t RD32(const std::vector<uint8_t>& b, uint32_t off) { + if (off + 4 > b.size()) { + return 0; + } + uint32_t v; + std::memcpy(&v, b.data() + off, 4); + return v; +} + +uint16_t RD16(const std::vector<uint8_t>& b, uint32_t off) { + if (off + 2 > b.size()) { + return 0; + } + uint16_t v; + std::memcpy(&v, b.data() + off, 2); + return v; +} + +std::string RDStr(const std::vector<uint8_t>& b, uint32_t off, size_t maxLen = 64) { + std::string out; + while (off < b.size() && b[off] != 0 && out.size() < maxLen) { + out.push_back((char)b[off++]); + } + return out; +} + +void MatIdentity(float m[4][4]) { + for (int i = 0; i < 4; ++i) { + for (int j = 0; j < 4; ++j) { + m[i][j] = i == j ? 1.0f : 0.0f; + } + } +} + +// Row-vector convention: world = local * parent. +void MatMul(float out[4][4], const float a[4][4], const float b[4][4]) { + float r[4][4]; + for (int i = 0; i < 4; ++i) { + for (int j = 0; j < 4; ++j) { + r[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j] + a[i][3] * b[3][j]; + } + } + std::memcpy(out, r, sizeof(r)); +} + +// Fixed-point N64 Mtx: 8 words of interleaved integer parts, 8 of fractions. +void ReadMtx(const std::vector<uint8_t>& blob, uint32_t off, float out[4][4]) { + MatIdentity(out); + if (off == 0 || off + 0x40 > blob.size()) { + return; + } + if (Companion::Instance != nullptr && Companion::Instance->GetConfig().gbi.useFloats) { + std::memcpy(out, blob.data() + off, 0x40); + return; + } + for (int i = 0; i < 4; ++i) { + for (int j = 0; j < 2; ++j) { + const int32_t intPart = (int32_t)RD32(blob, off + (i * 2 + j) * 4); + const uint32_t fracPart = RD32(blob, off + 0x20 + (i * 2 + j) * 4); + out[i][j * 2] = (float)((int32_t)((intPart & 0xFFFF0000) | (fracPart >> 16))) / 65536.0f; + out[i][j * 2 + 1] = (float)((int32_t)((intPart << 16) | (fracPart & 0xFFFF))) / 65536.0f; + } + } +} + +// libultra render mode preset words (Torch's gbi header lacks the G_RM set). +#define PMRM_AA_ZB_OPA_SURF 0x00442078U, 0x00112078U +#define PMRM_ZB_OPA_SURF 0x00442230U, 0x00112230U +#define PMRM_AA_OPA_SURF 0x00442048U, 0x00112048U +#define PMRM_AA_ZB_OPA_DECAL 0x00442D58U, 0x00112D58U +#define PMRM_ZB_OPA_DECAL 0x00442E10U, 0x00112E10U +#define PMRM_AA_ZB_OPA_INTER 0x00442478U, 0x00112478U +#define PMRM_AA_ZB_TEX_EDGE 0x00443078U, 0x00113078U +#define PMRM_AA_TEX_EDGE 0x00443048U, 0x00113048U +#define PMRM_AA_ZB_XLU_SURF 0x004049D8U, 0x001049D8U +#define PMRM_ZB_XLU_SURF 0x00404A50U, 0x00104A50U +#define PMRM_AA_XLU_SURF 0x004041C8U, 0x001041C8U +#define PMRM_AA_ZB_XLU_DECAL 0x00404DD8U, 0x00104DD8U +#define PMRM_ZB_OVL_SURF 0x00404F50U, 0x00104F50U +#define PMRM_AA_ZB_XLU_INTER 0x004045D8U, 0x001045D8U + +// pmret model.c ModelRenderModes (RENDER_CLASS_1CYC): exact othermode-L +// cycle words per RenderMode value. The game draws the model tree in order +// with these; there is no cross-model layer sorting. +void RenderModeFor(uint32_t mode, uint32_t& c1, uint32_t& c2) { + const auto set = [&](uint32_t a, uint32_t b) { + c1 = a; + c2 = b; + }; + switch (mode) { + case 0x03: + set(PMRM_ZB_OPA_SURF); + break; // OPA_NO_AA + case 0x04: + set(PMRM_AA_OPA_SURF); + break; // OPA_NO_ZB + case 0x05: + set(PMRM_AA_ZB_OPA_DECAL); + break; // DECAL_OPA + case 0x07: + set(PMRM_ZB_OPA_DECAL); + break; // DECAL_OPA_NO_AA + case 0x09: + set(PMRM_AA_ZB_OPA_INTER); + break; // INTERSECTING + case 0x0D: + case 0x0F: + set(PMRM_AA_ZB_TEX_EDGE); + break; // ALPHATEST + case 0x10: + set(PMRM_AA_TEX_EDGE); + break; // ALPHATEST_NO_ZB + case 0x11: + case 0x16: + case 0x22: + case 0x15: + case 0x20: + set(PMRM_AA_ZB_XLU_SURF); + break; // XLU / SHADOW + case 0x13: + set(PMRM_ZB_XLU_SURF); + break; // XLU_NO_AA + case 0x14: + set(PMRM_AA_XLU_SURF); + break; // XLU_NO_ZB + case 0x1A: + case 0x1E: + set(PMRM_AA_ZB_XLU_DECAL); + break; // DECAL_XLU + case 0x1C: + set(PMRM_ZB_OVL_SURF); + break; // DECAL_XLU_NO_AA + case 0x26: + set(PMRM_AA_ZB_XLU_INTER); + break; // INTERSECTING_XLU + default: + set(PMRM_AA_ZB_OPA_SURF); + break; // SURFACE_OPA + } +} + +struct TexEntry { + uint32_t rasterOff = 0; + uint32_t palOff = 0; + uint16_t w = 0, h = 0; + uint8_t fmt = 0, siz = 0, cmS = 0, cmT = 0; + uint8_t extraTiles = 0; // 1 mipmaps, 2 aux-shared, 3 aux-independent + uint8_t combineSub = 0; // main-only combine subtype (0 mod, 1 blend, 2 decal) + uint32_t auxRasterOff = 0, auxPalOff = 0; + uint16_t auxW = 0, auxH = 0; + uint8_t auxFmt = 0, auxSiz = 0, auxCmS = 0, auxCmT = 0; +}; + +struct TexArchive { + std::shared_ptr<std::vector<uint8_t>> blob; + std::map<std::string, TexEntry> entries; +}; + +uint32_t TexRasterSize(uint16_t w, uint16_t h, uint8_t siz, uint8_t extraTiles) { + uint32_t size = (uint32_t)w * h; + if (extraTiles == 1) { // mipmaps + const int minW[4] = { 16, 8, 4, 2 }; + for (int d = 2; w / d >= minW[siz & 3] && h / d > 0; d *= 2) { + size += (uint32_t)(w / d) * (h / d); + } + } + switch (siz) { + case 0: + return size / 2; + case 2: + return size * 2; + case 3: + return size * 4; + default: + return size; + } +} + +uint32_t TexPaletteSize(uint8_t fmt, uint8_t siz) { + if (fmt != 2) { // CI only + return 0; + } + return siz == 1 ? 0x200 : 0x20; +} + +// PM64 wrap values map directly onto G_TX flags (0 wrap, 1 mirror, 2 clamp). +std::shared_ptr<TexArchive> IndexTexArchive(const std::shared_ptr<std::vector<uint8_t>>& blob) { + auto arc = std::make_shared<TexArchive>(); + arc->blob = blob; + const auto& d = *blob; + uint32_t off = 0; + while (off + 0x30 <= d.size()) { + std::string name = RDStr(d, off, 32); + if (name.empty()) { + break; + } + const uint16_t auxW = RD16(d, off + 0x20), mainW = RD16(d, off + 0x22); + const uint16_t auxH = RD16(d, off + 0x24), mainH = RD16(d, off + 0x26); + const uint8_t extraTiles = d[off + 0x29]; + // Parser rearranged 0x2A to (subType << 6) | combineType. + const uint8_t combineType = d[off + 0x2A] & 0x3F; + const uint8_t combineSub = (d[off + 0x2A] >> 6) & 0x3; + const uint8_t mainFmt = d[off + 0x2B] >> 4, auxFmt = d[off + 0x2B] & 0xF; + const uint8_t mainSiz = d[off + 0x2C] >> 4, auxSiz = d[off + 0x2C] & 0xF; + const uint8_t wrapS = d[off + 0x2D] >> 4, auxWrapS = d[off + 0x2D] & 0xF; + const uint8_t wrapT = d[off + 0x2E] >> 4, auxWrapT = d[off + 0x2E] & 0xF; + if (mainW == 0 || mainH == 0 || mainW > 1024 || mainH > 1024) { + break; + } + const uint32_t rasterSize = TexRasterSize(mainW, mainH, mainSiz, extraTiles); + const uint32_t palSize = TexPaletteSize(mainFmt, mainSiz); + uint32_t auxRaster = 0, auxPal = 0; + if (extraTiles == 3) { + auxRaster = TexRasterSize(auxW, auxH, auxSiz, 0); + auxPal = TexPaletteSize(auxFmt, auxSiz); + } + TexEntry e; + e.rasterOff = off + 0x30; + e.palOff = e.rasterOff + rasterSize; + e.w = mainW; + // AUX_SAME_AS_MAIN packs main+aux vertically in one raster; the game + // renders tiles of mainH/2. + e.h = extraTiles == 2 ? (uint16_t)(mainH / 2) : mainH; + e.fmt = mainFmt; + e.siz = mainSiz; + e.cmS = wrapS <= 2 ? wrapS : 0; + e.cmT = wrapT <= 2 ? wrapT : 0; + e.extraTiles = extraTiles; + e.combineSub = combineType < 3 ? combineSub : 0; + if (extraTiles == 2) { + // Aux tile = lower half of the main raster, same palette and wraps. + e.auxRasterOff = e.rasterOff + (uint32_t)mainW * (mainH / 2) * (4u << mainSiz) / 8; + e.auxPalOff = e.palOff; + e.auxW = mainW; + e.auxH = mainH / 2; + e.auxFmt = mainFmt; + e.auxSiz = mainSiz; + e.auxCmS = e.cmS; + e.auxCmT = e.cmT; + } else if (extraTiles == 3) { + e.auxRasterOff = e.palOff + palSize; + e.auxPalOff = e.auxRasterOff + auxRaster; + e.auxW = auxW; + e.auxH = auxH; + e.auxFmt = auxFmt; + e.auxSiz = auxSiz; + e.auxCmS = auxWrapS <= 2 ? auxWrapS : 0; + e.auxCmT = auxWrapT <= 2 ? auxWrapT : 0; + if (auxW == 0 || auxH == 0 || auxW > 1024 || auxH > 1024 || + (uint64_t)e.auxRasterOff + auxRaster + auxPal > d.size()) { + e.extraTiles = 0; + e.auxRasterOff = 0; + } + } + if ((uint64_t)e.rasterOff + rasterSize + palSize <= d.size()) { + arc->entries[name] = e; + } + off += 0x30 + rasterSize + palSize + auxRaster + auxPal; + } + return arc; +} + +// Finds the tex archive for a shape: same area prefix (e.g. arn_02_shape -> arn_tex). +std::shared_ptr<TexArchive> TexArchiveForShape(const std::string& shapeName) { + static std::map<std::string, std::shared_ptr<TexArchive>> sCache; + const auto slash = shapeName.find_last_of('/'); + const std::string base = slash != std::string::npos ? shapeName.substr(slash + 1) : shapeName; + const std::string area = base.substr(0, base.find('_')); + if (area.empty()) { + return nullptr; + } + const auto cit = sCache.find(area); + if (cit != sCache.end()) { + return cit->second; + } + std::shared_ptr<TexArchive> found; + for (const auto& [file, results] : Companion::Instance->GetParseResults()) { + for (const auto& r : results) { + if (r.type != "PM64:MAP_TEXTURE" || !r.data.has_value()) { + continue; + } + const auto rslash = r.name.find_last_of('/'); + const std::string rbase = rslash != std::string::npos ? r.name.substr(rslash + 1) : r.name; + if (rbase.rfind(area + "_tex", 0) == 0 || rbase == area) { + auto buf = std::static_pointer_cast<RawBuffer>(r.data.value()); + found = IndexTexArchive(std::make_shared<std::vector<uint8_t>>(buf->mBuffer)); + break; + } + } + if (found != nullptr) { + break; + } + } + if (found != nullptr && std::getenv("TORCH_PM64_DEBUG") != nullptr) { + printf("[shape] tex archive '%s': %zu entries\n", area.c_str(), found->entries.size()); + for (const auto& [n, e] : found->entries) { + printf("[shape] %-24s %ux%u fmt=%u siz=%u wrap=(%u,%u) raster=0x%X pal=0x%X extra=%u sub=%u aux=%ux%u " + "fmt=%u siz=%u\n", + n.c_str(), e.w, e.h, e.fmt, e.siz, e.cmS, e.cmT, e.rasterOff, e.palOff, e.extraTiles, e.combineSub, + e.auxW, e.auxH, e.auxFmt, e.auxSiz); + } + } + sCache[area] = found; + return found; +} + +struct ShapeModel { + std::vector<UI::ModelPart> parts; + size_t nodeCount = 0; + size_t texturedParts = 0; +}; + +struct WalkCtx { + const std::vector<uint8_t>* blob; + std::shared_ptr<std::vector<uint8_t>> blobRef; + UI::GfxBundle* bundle; + std::shared_ptr<TexArchive> tex; + std::string itemName; + ShapeModel* out; + int depth = 0; +}; + +void WalkNode(WalkCtx& ctx, uint32_t nodeOff, const float parent[4][4]) { + const auto& blob = *ctx.blob; + if (nodeOff == 0 || nodeOff + 0x14 > blob.size() || ctx.depth > 64 || ctx.out->parts.size() > 4096) { + return; + } + ctx.out->nodeCount++; + const uint32_t nodeType = RD32(blob, nodeOff + 0x00); + const uint32_t displayData = RD32(blob, nodeOff + 0x04); + const int32_t numProps = (int32_t)RD32(blob, nodeOff + 0x08); + const uint32_t propList = RD32(blob, nodeOff + 0x0C); + const uint32_t groupData = RD32(blob, nodeOff + 0x10); + + std::string texName; + uint32_t renderMode = 1; + uint32_t groupType = 0; + if (numProps > 0 && numProps < 128 && propList != 0) { + for (int32_t i = 0; i < numProps; ++i) { + const uint32_t p = propList + (uint32_t)i * 0xC; + const uint32_t key = RD32(blob, p); + const uint32_t val = RD32(blob, p + 8); + if (key == 0x5E) { // MODEL_PROP_KEY_TEXTURE_NAME + texName = RDStr(blob, val); + } else if (key == 0x5C) { // MODEL_PROP_KEY_RENDER_MODE + renderMode = val; + } else if (key == 0x60) { // MODEL_PROP_KEY_GROUP_INFO + groupType = val; + } + } + } + + // Only MODEL leaves (type 2) draw. Group rendering expands children into + // per-child models (func_80117D00); group/root display lists re-call every + // child list and drawing them double-draws the map untextured. + constexpr uint32_t kShapeTypeModel = 2; + const bool drawSelf = nodeType == kShapeTypeModel; + (void)groupType; + + float world[4][4]; + std::memcpy(world, parent, sizeof(world)); + uint32_t numChildren = 0, childList = 0; + if (groupData != 0 && groupData + 0x14 <= blob.size()) { + const uint32_t mtxOff = RD32(blob, groupData); + numChildren = RD32(blob, groupData + 0x0C); + childList = RD32(blob, groupData + 0x10); + if (mtxOff != 0) { + float local[4][4]; + ReadMtx(blob, mtxOff, local); + MatMul(world, local, parent); + } + } + + if (drawSelf && displayData != 0 && displayData + 8 <= blob.size()) { + const uint32_t dlOff = RD32(blob, displayData); + if (dlOff != 0 && dlOff < blob.size()) { + char suffix[16]; + snprintf(suffix, sizeof(suffix), "#%X", dlOff); + UI::ModelPart part; + part.resource = ctx.itemName + suffix; + const bool registered = UI::GetBackend() != nullptr + ? UI::GetBackend()->RegisterGameDList(part.resource, *ctx.bundle, dlOff) + : true; + if (registered) { + std::memcpy(part.mtx, world, sizeof(part.mtx)); + // Tree order is the game's draw order; keep one layer so the + // preview's stable sort preserves it. + part.layer = 1; + RenderModeFor(renderMode, part.renderMode1, part.renderMode2); + part.unlit = true; // shape geometry is vertex-colored + if (std::getenv("TORCH_PM64_DEBUG") != nullptr) { + const bool found = !texName.empty() && ctx.tex != nullptr && ctx.tex->entries.count(texName) != 0; + printf("[shape] %s dl=0x%X rmode=%u tex='%s' %s\n", ctx.itemName.c_str(), dlOff, renderMode, + texName.c_str(), + texName.empty() ? "(untextured)" + : ctx.tex == nullptr ? "NO ARCHIVE" + : found ? "ok" + : "NOT FOUND"); + } + if (!texName.empty() && ctx.tex != nullptr) { + const auto tit = ctx.tex->entries.find(texName); + if (tit != ctx.tex->entries.end()) { + const TexEntry& te = tit->second; + auto texture = std::make_shared<UI::PartTexture>(); + texture->blob = ctx.tex->blob; + texture->rasterOffset = te.rasterOff; + texture->paletteOffset = te.palOff; + texture->width = te.w; + texture->height = te.h; + texture->fmt = te.fmt; + texture->siz = te.siz; + texture->cmS = te.cmS; + texture->cmT = te.cmT; + texture->combine = te.combineSub; + if (te.extraTiles == 2 || te.extraTiles == 3) { + texture->auxMode = te.extraTiles; + texture->auxRasterOffset = te.auxRasterOff; + texture->auxPaletteOffset = te.auxPalOff; + texture->auxWidth = te.auxW; + texture->auxHeight = te.auxH; + texture->auxFmt = te.auxFmt; + texture->auxSiz = te.auxSiz; + texture->auxCmS = te.auxCmS; + texture->auxCmT = te.auxCmT; + // Aux blends are two-cycle (RENDER_CLASS_2CYC): + // pass in blender cycle 1, surface mode in cycle 2. + part.renderMode1 = 0x0C084000; // G_RM_PASS + part.cycleType = 2; + } + part.texture = std::move(texture); + ctx.out->texturedParts++; + } + } + ctx.out->parts.push_back(std::move(part)); + } + } + } + + if (numChildren > 0 && numChildren < 1024 && childList != 0) { + ctx.depth++; + for (uint32_t i = 0; i < numChildren; ++i) { + WalkNode(ctx, RD32(blob, childList + i * 4), world); + } + ctx.depth--; + } +} + +std::string BaseName(const std::string& name) { + const auto slash = name.find_last_of('/'); + return slash != std::string::npos ? name.substr(slash + 1) : name; +} + +// All parsed PM64:BACKGROUND asset names. +const std::vector<std::string>& BackgroundNames() { + static std::vector<std::string> names; + static bool init = false; + if (!init) { + init = true; + for (const auto& [file, results] : Companion::Instance->GetParseResults()) { + for (const auto& r : results) { + if (r.type == "PM64:BACKGROUND" && r.data.has_value()) { + names.push_back(r.name); + } + } + } + std::sort(names.begin(), names.end()); + } + return names; +} + +struct BgImage { + std::vector<uint8_t> rgba; + int w = 0, h = 0; +}; + +// Decodes a background (CI8 raster @0x210, big-endian RGBA5551 palette @0x10) +// to RGBA8; cached per asset. +const BgImage* GetBackground(const std::string& name) { + static std::map<std::string, BgImage> sCache; + auto it = sCache.find(name); + if (it == sCache.end()) { + BgImage img; + std::shared_ptr<RawBuffer> buf; + for (const auto& [file, results] : Companion::Instance->GetParseResults()) { + for (const auto& r : results) { + if (r.type == "PM64:BACKGROUND" && r.name == name && r.data.has_value()) { + buf = std::static_pointer_cast<RawBuffer>(r.data.value()); + break; + } + } + if (buf != nullptr) { + break; + } + } + if (buf != nullptr && buf->mBuffer.size() >= 0x210) { + const auto& d = buf->mBuffer; + const uint32_t rasterOff = RD32(d, 0x00); + const uint32_t palOff = RD32(d, 0x04); + const int w = RD16(d, 0x0C); + const int h = RD16(d, 0x0E); + if (w > 0 && h > 0 && w <= 1024 && h <= 1024 && (uint64_t)palOff + 0x200 <= d.size() && + (uint64_t)rasterOff + (uint64_t)w * h <= d.size()) { + img.rgba.resize((size_t)w * h * 4); + for (int i = 0; i < w * h; ++i) { + const uint8_t ci = d[rasterOff + i]; + const uint16_t c = ((uint16_t)d[palOff + ci * 2] << 8) | d[palOff + ci * 2 + 1]; + img.rgba[i * 4 + 0] = (uint8_t)(((c >> 11) & 31) * 255 / 31); + img.rgba[i * 4 + 1] = (uint8_t)(((c >> 6) & 31) * 255 / 31); + img.rgba[i * 4 + 2] = (uint8_t)(((c >> 1) & 31) * 255 / 31); + img.rgba[i * 4 + 3] = 255; + } + img.w = w; + img.h = h; + } + } + if (std::getenv("TORCH_PM64_DEBUG") != nullptr) { + printf("[bg] %s: buf=%zu %dx%d\n", name.c_str(), buf != nullptr ? buf->mBuffer.size() : 0, img.w, img.h); + } + it = sCache.emplace(name, img).first; + } + return !it->second.rgba.empty() ? &it->second : nullptr; +} + +// Backgrounds are matched per map area; a few areas use another area's sky. +std::string DefaultBackgroundFor(const std::string& shapeName) { + std::string area = BaseName(shapeName); + area = area.substr(0, area.find('_')); + static const std::map<std::string, std::string> kAlias = { + { "dro", "sbk" }, { "isk", "sbk" }, { "flo", "fla" }, { "mim", "obk" }, { "tik", "jan" }, + }; + const auto ait = kAlias.find(area); + if (ait != kAlias.end()) { + area = ait->second; + } + std::string found; + for (const auto& n : BackgroundNames()) { + if (BaseName(n) == area + "_bg") { + found = n; + break; + } + } + if (std::getenv("TORCH_PM64_DEBUG") != nullptr) { + fprintf(stderr, "[bg] default for %s: area=%s -> '%s' (%zu candidates)\n", shapeName.c_str(), area.c_str(), + found.c_str(), BackgroundNames().size()); + } + return found; +} + +std::map<std::string, ShapeModel> sShapeCache; +std::map<std::string, UI::OrbitView> sShapeViews; + +const ShapeModel& BuildShapeModel(const ParseResultData& item) { + auto it = sShapeCache.find(item.name); + if (it != sShapeCache.end()) { + return it->second; + } + ShapeModel model; + auto data = std::static_pointer_cast<PM64ShapeData>(item.data.value()); + auto blob = std::make_shared<std::vector<uint8_t>>(data->mBuffer); + + UI::GfxBundle bundle; + bundle.blob = blob; + bundle.vtxBase = data->mVertexTableOffset; + bundle.vtxSize = data->mVertexDataSize; + const bool useFloats = Companion::Instance != nullptr && Companion::Instance->GetConfig().gbi.useFloats; + for (const auto& dl : data->mDisplayLists) { + UI::GfxBundleDList out; + out.offset = dl.offset; + out.words = dl.commands; + if (useFloats) { + // Parser rescaled G_VTX offsets for 24-byte float vertices; the + // blob still holds 16-byte ones. + for (size_t i = 0; i + 1 < out.words.size(); i += 2) { + if ((out.words[i] >> 24) == 0x01) { + out.words[i + 1] = out.words[i + 1] / 24 * 16; + } + } + } + bundle.dlists.push_back(std::move(out)); + } + + WalkCtx ctx; + ctx.blob = blob.get(); + ctx.blobRef = blob; + ctx.bundle = &bundle; + ctx.tex = TexArchiveForShape(item.name); + ctx.itemName = item.name; + ctx.out = &model; + + float identity[4][4]; + MatIdentity(identity); + WalkNode(ctx, RD32(*blob, 0), identity); + if (const char* cap = std::getenv("TORCH_PM64_MAXPARTS")) { + const size_t n = (size_t)atoi(cap); + if (n > 0 && model.parts.size() > n) { + model.parts.resize(n); + } + } + return sShapeCache.emplace(item.name, std::move(model)).first->second; +} + +} // namespace + +void PM64ShapeDebugBuild(const ParseResultData& item) { + if (!item.data.has_value()) { + return; + } + const ShapeModel& model = BuildShapeModel(item); + printf("[shape] %s: %zu nodes, %zu parts, %zu textured\n", item.name.c_str(), model.nodeCount, model.parts.size(), + model.texturedParts); +} + +float PM64ShapeFactoryUI::GetItemHeight(const ParseResultData& item) { + return 86.0f + UI::PreviewBlockHeight(item.name); +} + +void PM64ShapeFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + const ShapeModel& model = BuildShapeModel(item); + ImGui::TextDisabled("map shape \xe2\x80\x94 %zu nodes, %zu parts (%zu textured)", model.nodeCount, + model.parts.size(), model.texturedParts); + if (model.parts.empty()) { + ImGui::TextDisabled("nothing drawable"); + return; + } + + static std::map<std::string, std::string> sBgChoice; + auto bgIt = sBgChoice.find(item.name); + if (bgIt == sBgChoice.end()) { + bgIt = sBgChoice.emplace(item.name, DefaultBackgroundFor(item.name)).first; + } + std::string& bgChoice = bgIt->second; + ImGui::SetNextItemWidth(170.0f); + const std::string bgLabel = "bg: " + (bgChoice.empty() ? "none" : BaseName(bgChoice)); + if (ImGui::BeginCombo("##shapebg", bgLabel.c_str())) { + if (ImGui::Selectable("none", bgChoice.empty())) { + bgChoice.clear(); + } + for (const auto& n : BackgroundNames()) { + if (ImGui::Selectable(BaseName(n).c_str(), n == bgChoice)) { + bgChoice = n; + } + } + ImGui::EndCombo(); + } + ImGui::SameLine(); + UI::LightingControls(); + + UI::OrbitView& view = sShapeViews[item.name]; + if (std::getenv("TORCH_UI_SPIN") != nullptr) { + static float sSpinT = 0.0f; + sSpinT += 0.008f; + view.yaw += 0.02f; + view.zoom = 1.0f + 0.75f * std::sin(sSpinT); + if (view.zoom < 0.3f) { + view.zoom = 0.3f; + } + } + // Apply the backdrop when the selection changes (the backend re-renders). + static std::map<std::string, std::string> sBgApplied; + if (sBgApplied[item.name] != bgChoice) { + sBgApplied[item.name] = bgChoice; + if (std::getenv("TORCH_PM64_DEBUG") != nullptr) { + fprintf(stderr, "[bg] apply %s -> '%s'\n", item.name.c_str(), bgChoice.c_str()); + } + const BgImage* bg = bgChoice.empty() ? nullptr : GetBackground(bgChoice); + if (bg != nullptr) { + UI::GetBackend()->SetPreviewBackdrop(item.name, bg->rgba.data(), bg->w, bg->h); + } else { + UI::GetBackend()->SetPreviewBackdrop(item.name, nullptr, 0, 0); + } + } + + const UI::PreviewCanvas canvas = UI::BeginResizableCanvas("##shapeview", item.name, view); + if (canvas.visible) { + UI::GetBackend()->DrawModelParts(item.name, model.parts, canvas.origin, canvas.size, view); + } +} +#endif // BUILD_UI diff --git a/src/factories/pm64/ShapeFactory.h b/src/factories/pm64/ShapeFactory.h index 70822fa..bab4583 100644 --- a/src/factories/pm64/ShapeFactory.h +++ b/src/factories/pm64/ShapeFactory.h @@ -33,6 +33,17 @@ class PM64ShapeHeaderExporter : public BaseExporter { ExportResult Export(std::ostream& write, std::shared_ptr<IParsedData> data, std::string& entryName, YAML::Node& node, std::string* replacement) override; }; +#ifdef BUILD_UI +// Headless build of a shape's parts (diagnostics; no backend required). +void PM64ShapeDebugBuild(const ParseResultData& item); + +class PM64ShapeFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; +#endif + class PM64ShapeFactory : public BaseFactory { public: std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; diff --git a/src/factories/sf64/AnimFactory.h b/src/factories/sf64/AnimFactory.h index f3fec76..cef531d 100644 --- a/src/factories/sf64/AnimFactory.h +++ b/src/factories/sf64/AnimFactory.h @@ -35,6 +35,9 @@ class AnimCodeExporter : public BaseExporter { class AnimFactory : public BaseFactory { public: std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; + bool CanPreviewCode() override { + return true; + } inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { return { REGISTER(Code, AnimCodeExporter) diff --git a/src/factories/sf64/ColPolyFactory.h b/src/factories/sf64/ColPolyFactory.h index c245d48..281ce7f 100644 --- a/src/factories/sf64/ColPolyFactory.h +++ b/src/factories/sf64/ColPolyFactory.h @@ -36,6 +36,9 @@ class ColPolyCodeExporter : public BaseExporter { class ColPolyFactory : public BaseFactory { public: std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; + bool CanPreviewCode() override { + return true; + } inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { return { REGISTER(Code, ColPolyCodeExporter) diff --git a/src/factories/sf64/EnvironmentFactory.h b/src/factories/sf64/EnvironmentFactory.h index d5d1f88..75f4f5e 100644 --- a/src/factories/sf64/EnvironmentFactory.h +++ b/src/factories/sf64/EnvironmentFactory.h @@ -46,6 +46,9 @@ class EnvironmentXMLExporter : public BaseExporter { class EnvironmentFactory : public BaseFactory { public: std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; + bool CanPreviewCode() override { + return true; + } inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { return { REGISTER(XML, EnvironmentXMLExporter) diff --git a/src/factories/sf64/HitboxFactory.h b/src/factories/sf64/HitboxFactory.h index f269d72..43e5670 100644 --- a/src/factories/sf64/HitboxFactory.h +++ b/src/factories/sf64/HitboxFactory.h @@ -27,6 +27,9 @@ class HitboxCodeExporter : public BaseExporter { class HitboxFactory : public BaseFactory { public: std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; + bool CanPreviewCode() override { + return true; + } inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { return { REGISTER(Code, HitboxCodeExporter) diff --git a/src/factories/sf64/MessageLookupFactory.h b/src/factories/sf64/MessageLookupFactory.h index 932f08c..732d7d5 100644 --- a/src/factories/sf64/MessageLookupFactory.h +++ b/src/factories/sf64/MessageLookupFactory.h @@ -35,6 +35,9 @@ class MessageLookupXMLExporter : public BaseExporter { class MessageLookupFactory : public BaseFactory { public: std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; + bool CanPreviewCode() override { + return true; + } inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { return { REGISTER(XML, MessageLookupXMLExporter) diff --git a/src/factories/sf64/ObjInitFactory.h b/src/factories/sf64/ObjInitFactory.h index 2646c17..6f36656 100644 --- a/src/factories/sf64/ObjInitFactory.h +++ b/src/factories/sf64/ObjInitFactory.h @@ -40,6 +40,9 @@ class ObjInitXMLExporter : public BaseExporter { class ObjInitFactory : public BaseFactory { public: std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; + bool CanPreviewCode() override { + return true; + } inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { return { REGISTER(Header, ObjInitHeaderExporter) diff --git a/src/factories/sf64/SF64Preview.cpp b/src/factories/sf64/SF64Preview.cpp new file mode 100644 index 0000000..044d56d --- /dev/null +++ b/src/factories/sf64/SF64Preview.cpp @@ -0,0 +1,646 @@ +#include "SF64Preview.h" + +#ifdef BUILD_UI + +#include <cmath> +#include <cstring> +#include <map> +#include <string> +#include <unordered_map> +#include <vector> + +#include "AnimFactory.h" +#include "ColPolyFactory.h" +#include "Companion.h" +#include "HitboxFactory.h" +#include "MessageFactory.h" +#include "SkeletonFactory.h" +#include "TriangleFactory.h" +#include "factories/Vec3fFactory.h" +#include "factories/Vec3sFactory.h" +#include "imgui.h" +#include "ui/BaseBackend.h" +#include "ui/Widgets.h" + +namespace SF64 { +namespace { + +const std::string* OwningFile(const ParseResultData& item) { + for (const auto& [file, results] : Companion::Instance->GetParseResults()) { + for (const auto& r : results) { + if (&r == &item) { + return &file; + } + } + } + return nullptr; +} + +// Row-vector 4x4 (v' = v * M), translation in row 3 — matches the backend. +using Mat4 = float[4][4]; + +void MatIdentity(Mat4 m) { + for (int i = 0; i < 4; ++i) { + for (int j = 0; j < 4; ++j) { + m[i][j] = i == j ? 1.0f : 0.0f; + } + } +} + +void MatMul(Mat4 out, const Mat4 a, const Mat4 b) { + Mat4 r; + for (int i = 0; i < 4; ++i) { + for (int j = 0; j < 4; ++j) { + r[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j] + a[i][3] * b[3][j]; + } + } + std::memcpy(out, r, sizeof(r)); +} + +// Limb local transform: rotate (radians) then translate, row-vector form. +// Rotation order matches the game (RotateZ, RotateY, RotateX post-multiplied, +// i.e. a point is rotated X first): R = Rx * Ry * Rz in row-vector form. +void LimbMatrix(Mat4 m, const Vec3f& t, float rx, float ry, float rz) { + const float cx = std::cos(rx), sx = std::sin(rx); + const float cy = std::cos(ry), sy = std::sin(ry); + const float cz = std::cos(rz), sz = std::sin(rz); + m[0][0] = cy * cz; + m[0][1] = cy * sz; + m[0][2] = -sy; + m[0][3] = 0.0f; + m[1][0] = sx * sy * cz - cx * sz; + m[1][1] = sx * sy * sz + cx * cz; + m[1][2] = sx * cy; + m[1][3] = 0.0f; + m[2][0] = cx * sy * cz + sx * sz; + m[2][1] = cx * sy * sz - sx * cz; + m[2][2] = cx * cy; + m[2][3] = 0.0f; + m[3][0] = t.x; + m[3][1] = t.y; + m[3][2] = t.z; + m[3][3] = 1.0f; +} + +// Resolved skeleton structure (built once); limb transforms are recomputed per +// frame from a joint table so animations can drive them. +struct SkeletonModel { + std::shared_ptr<SkeletonData> skel; + std::string file; + std::unordered_map<uint32_t, const LimbData*> byAddr; + std::unordered_map<uint32_t, std::string> resource; // limb addr -> GFX path + uint32_t rootAddr = 0; + size_t limbCount = 0; + size_t meshCount = 0; +}; + +// Per-limb rotation source. jointTable is in the game's frame-table layout: +// index 0 = root translation (raw), 1..N = limb rotations in degrees. Empty = +// bind pose (limb.mRot binary angles). +struct Pose { + const std::vector<Vec3f>* jointTable = nullptr; +}; + +void WalkLimb(const SkeletonModel& model, uint32_t addr, const Mat4 parent, const Pose& pose, + std::vector<UI::ModelPart>& out, std::unordered_map<uint32_t, int>& visited, int depth) { + while (addr != 0 && depth < 256) { + if (++visited[addr] > 1) { + break; // cycle guard + } + const auto it = model.byAddr.find(addr); + if (it == model.byAddr.end()) { + break; + } + const LimbData& limb = *it->second; + + float rx, ry, rz; + if (pose.jointTable != nullptr) { + // jointTable[limbIndex] with limbIndex = array position + 1. + const size_t idx = (size_t)limb.mIndex + 1; + const Vec3f r = idx < pose.jointTable->size() ? (*pose.jointTable)[idx] : Vec3f(0, 0, 0); + rx = r.x * (float)M_PI / 180.0f; + ry = r.y * (float)M_PI / 180.0f; + rz = r.z * (float)M_PI / 180.0f; + } else { + rx = limb.mRot.x * (float)M_PI / 32768.0f; + ry = limb.mRot.y * (float)M_PI / 32768.0f; + rz = limb.mRot.z * (float)M_PI / 32768.0f; + } + // The root limb can take an animated translation from jointTable[0]. + Vec3f trans = limb.mTrans; + if (addr == model.rootAddr && pose.jointTable != nullptr && !pose.jointTable->empty()) { + trans = (*pose.jointTable)[0]; + } + + Mat4 local, world; + LimbMatrix(local, trans, rx, ry, rz); + MatMul(world, local, parent); + + const auto rit = model.resource.find(addr); + if (rit != model.resource.end()) { + UI::ModelPart part; + part.resource = rit->second; + part.layer = 1; + std::memcpy(part.mtx, world, sizeof(world)); + out.push_back(std::move(part)); + } + if (limb.mChild != 0) { + WalkLimb(model, limb.mChild, world, pose, out, visited, depth + 1); + } + addr = limb.mSibling; // iterate siblings without deepening the stack + } +} + +std::vector<UI::ModelPart> BuildParts(const SkeletonModel& model, const Pose& pose) { + std::vector<UI::ModelPart> parts; + if (model.rootAddr == 0) { + return parts; + } + Mat4 identity; + MatIdentity(identity); + std::unordered_map<uint32_t, int> visited; + WalkLimb(model, model.rootAddr, identity, pose, parts, visited, 0); + return parts; +} + +const SkeletonModel& BuildSkeleton(const ParseResultData& item) { + static std::map<std::string, SkeletonModel> sCache; + const auto cached = sCache.find(item.name); + if (cached != sCache.end()) { + return cached->second; + } + SkeletonModel model; + model.skel = std::static_pointer_cast<SkeletonData>(item.data.value()); + model.limbCount = model.skel->mSkeleton.size(); + const std::string* file = OwningFile(item); + if (file != nullptr && !model.skel->mSkeleton.empty()) { + model.file = *file; + model.rootAddr = model.skel->mSkeleton.front().mAddr; + for (const auto& limb : model.skel->mSkeleton) { + model.byAddr[limb.mAddr] = &limb; + if (limb.mDList != 0) { + auto node = Companion::Instance->GetNodeByAddr(limb.mDList, *file); + if (node.has_value()) { + auto type = GetSafeNode<std::string>(std::get<1>(node.value()), "type", ""); + std::transform(type.begin(), type.end(), type.begin(), ::toupper); + if (type == "GFX") { + model.resource[limb.mAddr] = std::get<0>(node.value()); + } + } + } + } + model.meshCount = model.resource.size(); + } + return sCache.emplace(item.name, std::move(model)).first->second; +} + +// Ports Animation_GetFrameData: index 0 = root translation (raw s16), 1..limbCount +// = per-limb rotations in degrees. len<=frame clamps to the last (constant) key. +std::vector<Vec3f> ComputeJointTable(const AnimData& anim, int frame) { + std::vector<Vec3f> table((size_t)anim.mLimbCount + 1); + const auto& fd = anim.mFrameData; + const auto val = [&](uint16_t len, uint16_t idx) -> int16_t { + const uint32_t i = (uint32_t)frame < len ? (uint32_t)idx + frame : idx; + return i < fd.size() ? (int16_t)fd[i] : 0; + }; + for (size_t i = 0; i < table.size() && i < anim.mJointKeys.size(); ++i) { + const auto& k = anim.mJointKeys[i].keys; // {xLen, x, yLen, y, zLen, z} + const int16_t x = val(k[0], k[1]); + const int16_t y = val(k[2], k[3]); + const int16_t z = val(k[4], k[5]); + if (i == 0) { + table[0] = Vec3f(x, y, z); // root translation, raw units + } else { + table[i] = Vec3f(x * 360.0f / 65536.0f, y * 360.0f / 65536.0f, z * 360.0f / 65536.0f); + } + } + return table; +} + +struct AnimEntry { + std::string name; + std::shared_ptr<AnimData> data; +}; + +// SF64 pairs anims to skeletons in code, not data. Offer anims from the same +// file whose limb count fits this skeleton (a strong, reliable filter). +std::vector<AnimEntry> CollectAnims(const std::string& file, size_t limbCount) { + std::vector<AnimEntry> anims; + const auto& results = Companion::Instance->GetParseResults(); + const auto it = results.find(file); + if (it == results.end()) { + return anims; + } + for (const auto& r : it->second) { + if (r.type != "SF64:ANIM" || !r.data.has_value()) { + continue; + } + auto data = std::static_pointer_cast<AnimData>(r.data.value()); + if ((size_t)data->mLimbCount + 1 >= limbCount && (size_t)data->mLimbCount <= limbCount + 1) { + anims.push_back({ r.name, data }); + } + } + return anims; +} + +struct SkelState { + UI::OrbitView view; + int setup = -1; // resolved from config on first draw (fallback: textured + lit) + int animIndex = -1; // -1 = bind pose + float frame = 0.0f; + bool playing = true; +}; +std::map<std::string, SkelState> sSkelState; + +} // namespace + +float SkeletonFactoryUI::GetItemHeight(const ParseResultData& item) { + const float line = ImGui::GetTextLineHeightWithSpacing(); + const float frame = ImGui::GetFrameHeightWithSpacing(); + const float sep = ImGui::GetStyle().ItemSpacing.y * 2.0f + 1.0f; + return line * 2.0f + frame * 2.0f + sep + UI::PreviewBlockHeight(item.name); +} + +void SkeletonFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + const SkeletonModel& model = BuildSkeleton(item); + if (model.meshCount == 0) { + ImGui::TextDisabled("skeleton \xe2\x80\x94 %zu limbs, no drawable meshes", model.limbCount); + return; + } + const std::vector<AnimEntry> anims = CollectAnims(model.file, model.limbCount); + ImGui::TextDisabled("skeleton \xe2\x80\x94 %zu limbs, %zu meshes, %zu anims", model.limbCount, model.meshCount, + anims.size()); + + SkelState& st = sSkelState[item.name]; + if (st.setup < 0) { + const int cfg = UI::ShadeSetupIndexByName(Companion::Instance->GetConfig().defaultShading); + st.setup = cfg >= 0 ? cfg : 2; // fallback: textured + lit + } + + UI::ShadeSetupCombo("##sf64setup", st.setup); + ImGui::SameLine(); + UI::LightingControls(); + + st.animIndex = std::clamp(st.animIndex, -1, (int)anims.size() - 1); + const std::string animLabel = st.animIndex < 0 ? "bind pose" : anims[st.animIndex].name; + ImGui::SetNextItemWidth(220.0f); + if (ImGui::BeginCombo("##sf64anim", animLabel.c_str())) { + if (ImGui::Selectable("bind pose", st.animIndex < 0)) { + st.animIndex = -1; + st.frame = 0.0f; + } + for (int i = 0; i < (int)anims.size(); ++i) { + if (ImGui::Selectable(anims[i].name.c_str(), st.animIndex == i)) { + st.animIndex = i; + st.frame = 0.0f; + } + } + ImGui::EndCombo(); + } + + const AnimData* anim = st.animIndex >= 0 ? anims[st.animIndex].data.get() : nullptr; + const int frameCount = anim != nullptr ? std::max<int>(1, anim->mFrameCount) : 1; + if (anim != nullptr) { + ImGui::SameLine(); + if (ImGui::SmallButton(st.playing ? "Pause##sf64" : "Play##sf64")) { + st.playing = !st.playing; + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(180.0f); + ImGui::SliderFloat("##sf64frame", &st.frame, 0.0f, (float)(frameCount - 1), "frame %.0f"); + if (st.playing) { + st.frame += ImGui::GetIO().DeltaTime * 30.0f; + if (st.frame >= (float)frameCount) { + st.frame = 0.0f; + } + } + } + + std::vector<Vec3f> jointTable; + Pose pose; + if (anim != nullptr) { + jointTable = ComputeJointTable(*anim, std::clamp((int)st.frame, 0, frameCount - 1)); + pose.jointTable = &jointTable; + } + std::vector<UI::ModelPart> parts = BuildParts(model, pose); + + const UI::ShadeSetup shade = UI::ShadeSetupFor(st.setup); + for (auto& part : parts) { + part.gameShade = shade.gameShade; + part.unlit = shade.unlit; + part.fullAmbient = shade.fullAmbient; + } + const UI::PreviewCanvas canvas = UI::BeginResizableCanvas("##sf64skel", item.name, st.view); + if (canvas.visible) { + UI::GetBackend()->DrawModelParts(item.name, parts, canvas.origin, canvas.size, st.view); + } +} + +float MessageFactoryUI::GetItemHeight(const ParseResultData&) { + return ImGui::GetTextLineHeightWithSpacing() + 120.0f + ImGui::GetStyle().ItemSpacing.y * 3.0f; +} + +void MessageFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + const auto msg = std::static_pointer_cast<MessageData>(item.data.value()); + ImGui::TextDisabled("message \xe2\x80\x94 %zu chars", msg->mMessage.size()); + ImGui::BeginChild("##sf64msg", ImVec2(0, 100.0f), true, ImGuiWindowFlags_HorizontalScrollbar); + ImGui::PushTextWrapPos(ImGui::GetContentRegionAvail().x); + ImGui::TextUnformatted(msg->mMesgStr.c_str()); + ImGui::PopTextWrapPos(); + ImGui::EndChild(); +} + +namespace { + +// Finds a parsed vertex array in the asset's file by segment offset. +const std::vector<Vec3s>* ResolveVec3s(const std::string& file, uint32_t offset) { + const auto& results = Companion::Instance->GetParseResults(); + const auto it = results.find(file); + if (it == results.end()) { + return nullptr; + } + for (const auto& r : it->second) { + YAML::Node n = r.node; + if (r.type == "VEC3S" && r.data.has_value() && GetSafeNode<uint32_t>(n, "offset", 0) == offset) { + return &std::static_pointer_cast<Vec3sData>(r.data.value())->mVecs; + } + } + return nullptr; +} + +const std::vector<Vec3f>* ResolveVec3f(const std::string& file, uint32_t offset) { + const auto& results = Companion::Instance->GetParseResults(); + const auto it = results.find(file); + if (it == results.end()) { + return nullptr; + } + for (const auto& r : it->second) { + YAML::Node n = r.node; + if (r.type == "VEC3F" && r.data.has_value() && GetSafeNode<uint32_t>(n, "offset", 0) == offset) { + return &std::static_pointer_cast<Vec3fData>(r.data.value())->mVecs; + } + } + return nullptr; +} + +// Normal-shaded triangle from three positions, tinted with a base color. +void EmitTri(std::vector<UI::PreviewVertex>& out, const float p[3][3], float r, float g, float b) { + const float ux = p[1][0] - p[0][0], uy = p[1][1] - p[0][1], uz = p[1][2] - p[0][2]; + const float vx = p[2][0] - p[0][0], vy = p[2][1] - p[0][1], vz = p[2][2] - p[0][2]; + float nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx; + const float nl = std::sqrt(nx * nx + ny * ny + nz * nz); + if (nl > 0.0001f) { + nx /= nl; + ny /= nl; + nz /= nl; + } + const float light = 0.55f + 0.45f * std::max(0.0f, nx * 0.3f + ny * 0.8f + nz * 0.52f); + for (int k = 0; k < 3; ++k) { + UI::PreviewVertex pv{}; + pv.position[0] = p[k][0]; + pv.position[1] = p[k][1]; + pv.position[2] = p[k][2]; + pv.color[0] = (unsigned char)std::clamp(r * light * 255.0f, 0.0f, 255.0f); + pv.color[1] = (unsigned char)std::clamp(g * light * 255.0f, 0.0f, 255.0f); + pv.color[2] = (unsigned char)std::clamp(b * light * 255.0f, 0.0f, 255.0f); + pv.color[3] = 255; + out.push_back(pv); + } +} + +std::map<std::string, std::vector<UI::PreviewVertex>> sMeshTris; +std::map<std::string, UI::OrbitView> sMeshViews; + +// Shared body for the two mesh viewers. +void DrawMeshViewer(const ParseResultData& item, const char* label, size_t polyCount, + const std::vector<UI::PreviewVertex>& tris) { + UI::AssetHeader(item.name, item.type); + ImGui::TextDisabled("%s \xe2\x80\x94 %zu triangles", label, polyCount); + if (tris.empty()) { + ImGui::TextDisabled("no resolvable mesh vertices"); + return; + } + UI::OrbitView& view = sMeshViews[item.name]; + const UI::PreviewCanvas canvas = UI::BeginResizableCanvas("##sf64mesh", item.name, view); + if (canvas.visible) { + UI::GetBackend()->DrawTriangles(item.name, tris, canvas.origin, canvas.size, view); + } +} + +} // namespace + +float ColPolyFactoryUI::GetItemHeight(const ParseResultData& item) { + return 40.0f + UI::PreviewBlockHeight(item.name); +} + +void ColPolyFactoryUI::DrawUI(const ParseResultData& item) { + if (!item.data.has_value()) { + UI::AssetHeader(item.name, item.type); + ImGui::TextDisabled("no data"); + return; + } + auto cached = sMeshTris.find(item.name); + if (cached == sMeshTris.end()) { + std::vector<UI::PreviewVertex> tris; + const auto data = std::static_pointer_cast<ColPolyData>(item.data.value()); + const std::string* file = OwningFile(item); + const std::vector<Vec3s>* verts = nullptr; + if (file != nullptr && !data->mMeshNodes.empty()) { + YAML::Node mn = data->mMeshNodes.front(); + verts = ResolveVec3s(*file, GetSafeNode<uint32_t>(mn, "offset", 0)); + } + if (verts != nullptr) { + for (const auto& poly : data->mPolys) { + const int16_t idx[3] = { poly.tri.x, poly.tri.y, poly.tri.z }; + float p[3][3]; + bool ok = true; + for (int k = 0; k < 3; ++k) { + if (idx[k] < 0 || (size_t)idx[k] >= verts->size()) { + ok = false; + break; + } + p[k][0] = (*verts)[idx[k]].x; + p[k][1] = (*verts)[idx[k]].y; + p[k][2] = (*verts)[idx[k]].z; + } + if (ok) { + EmitTri(tris, p, 0.45f, 0.75f, 0.95f); // collision blue + } + } + } + cached = sMeshTris.emplace(item.name, std::move(tris)).first; + } + DrawMeshViewer(item, "collision mesh", cached->second.size() / 3, cached->second); +} + +float TriangleFactoryUI::GetItemHeight(const ParseResultData& item) { + return 40.0f + UI::PreviewBlockHeight(item.name); +} + +void TriangleFactoryUI::DrawUI(const ParseResultData& item) { + if (!item.data.has_value()) { + UI::AssetHeader(item.name, item.type); + ImGui::TextDisabled("no data"); + return; + } + auto cached = sMeshTris.find(item.name); + if (cached == sMeshTris.end()) { + std::vector<UI::PreviewVertex> tris; + const auto data = std::static_pointer_cast<TriangleData>(item.data.value()); + const std::string* file = OwningFile(item); + const std::vector<Vec3f>* verts = nullptr; + if (file != nullptr && !data->mMeshNodes.empty()) { + YAML::Node mn = data->mMeshNodes.front(); + verts = ResolveVec3f(*file, GetSafeNode<uint32_t>(mn, "offset", 0)); + } + if (verts != nullptr) { + for (const auto& tri : data->mTris) { + const int16_t idx[3] = { tri.x, tri.y, tri.z }; + float p[3][3]; + bool ok = true; + for (int k = 0; k < 3; ++k) { + if (idx[k] < 0 || (size_t)idx[k] >= verts->size()) { + ok = false; + break; + } + p[k][0] = (*verts)[idx[k]].x; + p[k][1] = (*verts)[idx[k]].y; + p[k][2] = (*verts)[idx[k]].z; + } + if (ok) { + EmitTri(tris, p, 0.6f, 0.85f, 0.5f); // mesh green + } + } + } + cached = sMeshTris.emplace(item.name, std::move(tris)).first; + } + DrawMeshViewer(item, "triangle mesh", cached->second.size() / 3, cached->second); +} + +namespace { + +// Appends a rotated axis-aligned box (12 tris) centered at c with half-extents h. +void EmitBox(std::vector<UI::PreviewVertex>& out, const float c[3], const float h[3], const float rotDeg[3], float r, + float g, float b) { + const float rx = rotDeg[0] * (float)M_PI / 180.0f; + const float ry = rotDeg[1] * (float)M_PI / 180.0f; + const float rz = rotDeg[2] * (float)M_PI / 180.0f; + const float cx = std::cos(rx), sx = std::sin(rx), cy = std::cos(ry), sy = std::sin(ry), cz = std::cos(rz), + sz = std::sin(rz); + const auto xform = [&](float x, float y, float z, float out3[3]) { + // Rz * Ry * Rx applied to the local corner, then translate to center. + float y1 = cx * y - sx * z, z1 = sx * y + cx * z; + float x2 = cy * x + sy * z1, z2 = -sy * x + cy * z1; + float x3 = cz * x2 - sz * y1, y3 = sz * x2 + cz * y1; + out3[0] = c[0] + x3; + out3[1] = c[1] + y3; + out3[2] = c[2] + z2; + }; + float v[8][3]; + int n = 0; + for (int sxi = -1; sxi <= 1; sxi += 2) { + for (int syi = -1; syi <= 1; syi += 2) { + for (int szi = -1; szi <= 1; szi += 2) { + xform(sxi * h[0], syi * h[1], szi * h[2], v[n++]); + } + } + } + // corner index = (xi<<2)|(yi<<1)|zi with -1->0, +1->1 + static const int faces[12][3] = { { 0, 1, 3 }, { 0, 3, 2 }, { 4, 7, 5 }, { 4, 6, 7 }, { 0, 4, 5 }, { 0, 5, 1 }, + { 2, 3, 7 }, { 2, 7, 6 }, { 0, 2, 6 }, { 0, 6, 4 }, { 1, 5, 7 }, { 1, 7, 3 } }; + for (const auto& f : faces) { + float p[3][3]; + for (int k = 0; k < 3; ++k) { + p[k][0] = v[f[k]][0]; + p[k][1] = v[f[k]][1]; + p[k][2] = v[f[k]][2]; + } + EmitTri(out, p, r, g, b); + } +} + +std::map<std::string, std::vector<UI::PreviewVertex>> sHitboxTris; +std::map<std::string, UI::OrbitView> sHitboxViews; + +} // namespace + +float HitboxFactoryUI::GetItemHeight(const ParseResultData& item) { + return 40.0f + UI::PreviewBlockHeight(item.name); +} + +void HitboxFactoryUI::DrawUI(const ParseResultData& item) { + if (!item.data.has_value()) { + UI::AssetHeader(item.name, item.type); + ImGui::TextDisabled("no data"); + return; + } + auto cached = sHitboxTris.find(item.name); + if (cached == sHitboxTris.end()) { + std::vector<UI::PreviewVertex> tris; + const auto data = std::static_pointer_cast<HitboxData>(item.data.value()); + const auto& d = data->mData; + // Box floats {z.off, z.size, y.off, y.size, x.off, x.size}; the entry + // stride depends on type (see HitboxData parse). + size_t idx = 1; // skip count + static const float kHue[5][3] = { + { 0.9f, 0.4f, 0.4f }, { 0.4f, 0.9f, 0.5f }, { 0.5f, 0.6f, 0.95f }, + { 0.9f, 0.85f, 0.4f }, { 0.85f, 0.5f, 0.9f }, + }; + for (size_t t = 0; t < data->mTypes.size(); ++t) { + const int type = data->mTypes[t]; + float rot[3] = { 0, 0, 0 }; + size_t box = idx; + if (type == 2) { // rotated: typecode + 3 rot, then 6 box + rot[0] = idx + 1 < d.size() ? d[idx + 1] : 0.0f; + rot[1] = idx + 2 < d.size() ? d[idx + 2] : 0.0f; + rot[2] = idx + 3 < d.size() ? d[idx + 3] : 0.0f; + box = idx + 4; + idx += 10; + } else if (type == 3 || type == 4) { // shadow/whoosh: typecode + 6 box + box = idx + 1; + idx += 7; + } else { // standard: 6 box floats (first is z.off) + box = idx; + idx += 6; + } + if (box + 6 > d.size()) { + break; + } + const float c[3] = { d[box + 4], d[box + 2], d[box + 0] }; // x,y,z offset + const float h[3] = { d[box + 5], d[box + 3], d[box + 1] }; // x,y,z size + if (h[0] == 0.0f && h[1] == 0.0f && h[2] == 0.0f) { + continue; + } + const float* col = kHue[(type - 1) & 3]; + EmitBox(tris, c, h, rot, col[0], col[1], col[2]); + } + cached = sHitboxTris.emplace(item.name, std::move(tris)).first; + } + + UI::AssetHeader(item.name, item.type); + const auto data = std::static_pointer_cast<HitboxData>(item.data.value()); + ImGui::TextDisabled("hitbox \xe2\x80\x94 %zu boxes", data->mTypes.size()); + if (cached->second.empty()) { + ImGui::TextDisabled("no drawable boxes"); + return; + } + UI::OrbitView& view = sHitboxViews[item.name]; + const UI::PreviewCanvas canvas = UI::BeginResizableCanvas("##sf64hitbox", item.name, view); + if (canvas.visible) { + UI::GetBackend()->DrawTriangles(item.name, cached->second, canvas.origin, canvas.size, view); + } +} + +} // namespace SF64 + +#endif // BUILD_UI diff --git a/src/factories/sf64/SF64Preview.h b/src/factories/sf64/SF64Preview.h new file mode 100644 index 0000000..35c0472 --- /dev/null +++ b/src/factories/sf64/SF64Preview.h @@ -0,0 +1,44 @@ +#pragma once + +#include "factories/BaseFactory.h" + +// SF64 viewer cards: a 3D skeleton preview (limb hierarchy + display lists) and +// a decoded-text card for messages. Other SF64 data/script types use the +// default code-preview card (their factories opt in via CanPreviewCode). +#ifdef BUILD_UI +namespace SF64 { + +class SkeletonFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; + +class MessageFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; + +// 3D collision-mesh preview (indexed triangles + a resolved vertex array). +class ColPolyFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; + +class TriangleFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; + +// 3D hitbox-volume preview (axis-aligned boxes from the hitbox float array). +class HitboxFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; + +} // namespace SF64 +#endif diff --git a/src/factories/sf64/ScriptFactory.h b/src/factories/sf64/ScriptFactory.h index ccabca6..f09a78d 100644 --- a/src/factories/sf64/ScriptFactory.h +++ b/src/factories/sf64/ScriptFactory.h @@ -34,6 +34,9 @@ class ScriptXMLExporter : public BaseExporter { class ScriptFactory : public BaseFactory { public: std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; + bool CanPreviewCode() override { + return true; + } inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { return { REGISTER(XML, ScriptXMLExporter) diff --git a/src/factories/sf64/TriangleFactory.h b/src/factories/sf64/TriangleFactory.h index 57acac4..c1b62d7 100644 --- a/src/factories/sf64/TriangleFactory.h +++ b/src/factories/sf64/TriangleFactory.h @@ -28,6 +28,9 @@ class TriangleCodeExporter : public BaseExporter { class TriangleFactory : public BaseFactory { public: std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; + bool CanPreviewCode() override { + return true; + } inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { return { REGISTER(Code, TriangleCodeExporter) diff --git a/src/factories/sm64/AnimationFactory.cpp b/src/factories/sm64/AnimationFactory.cpp index cde52dd..c35ffca 100644 --- a/src/factories/sm64/AnimationFactory.cpp +++ b/src/factories/sm64/AnimationFactory.cpp @@ -90,4 +90,23 @@ std::optional<std::shared_ptr<IParsedData>> SM64::AnimationFactory::parse(std::v return std::make_shared<AnimationData>(flags, animYTransDivisor, startFrame, loopStart, loopEnd, unusedBoneCount, length, indicesData, valuesData); -}
\ No newline at end of file +} +#ifdef BUILD_UI +#include "Companion.h" +#include "ui/Widgets.h" + +void SM64::AnimationFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + auto anim = std::static_pointer_cast<SM64::AnimationData>(item.data.value()); + UI::KV("flags", std::to_string(anim->mFlags)); + UI::KV("start frame", std::to_string(anim->mStartFrame)); + UI::KV("loop", std::to_string(anim->mLoopStart) + " - " + std::to_string(anim->mLoopEnd)); + UI::KV("y-trans divisor", std::to_string(anim->mAnimYTransDivisor)); + UI::KV("channels", std::to_string(anim->mIndices.size() / 2)); + UI::KV("values", std::to_string(anim->mEntries.size())); +} +#endif // BUILD_UI diff --git a/src/factories/sm64/AnimationFactory.h b/src/factories/sm64/AnimationFactory.h index 9819e5f..cfef5ee 100644 --- a/src/factories/sm64/AnimationFactory.h +++ b/src/factories/sm64/AnimationFactory.h @@ -29,4 +29,12 @@ public: return { REGISTER(Binary, AnimationBinaryExporter) }; } }; -}
\ No newline at end of file + +#ifdef BUILD_UI +// Shows the animation header fields. +class AnimationFactoryUI : public BaseFactoryUI { +public: + void DrawUI(const ParseResultData& data) override; +}; +#endif +} diff --git a/src/factories/sm64/BehaviorScriptFactory.h b/src/factories/sm64/BehaviorScriptFactory.h index d1c451a..3c8a89d 100644 --- a/src/factories/sm64/BehaviorScriptFactory.h +++ b/src/factories/sm64/BehaviorScriptFactory.h @@ -39,6 +39,7 @@ class BehaviorScriptCodeExporter : public BaseExporter { class BehaviorScriptFactory : public BaseFactory { public: + bool CanPreviewCode() override { return true; } std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { return { diff --git a/src/factories/sm64/CollisionFactory.cpp b/src/factories/sm64/CollisionFactory.cpp index 6271af6..90b3508 100644 --- a/src/factories/sm64/CollisionFactory.cpp +++ b/src/factories/sm64/CollisionFactory.cpp @@ -479,4 +479,102 @@ std::optional<std::shared_ptr<IParsedData>> SM64::CollisionFactory::parse(std::v } return std::make_shared<Collision>(vertices, surfaces, specialObjects, envRegionBoxes); -}
\ No newline at end of file +} +#ifdef BUILD_UI +#include <cmath> +#include <unordered_map> + +#include "ui/BaseBackend.h" +#include "ui/Widgets.h" + +namespace { + +std::unordered_map<std::string, UI::OrbitView> sCollisionViews; + +// Distinct pastel per surface type (golden-ratio hue). +void SurfaceColor(uint32_t type, float out[3]) { + if (type == 0) { // SURFACE_DEFAULT + out[0] = out[1] = out[2] = 0.72f; + return; + } + const float h = std::fmod((float)type * 0.61803f, 1.0f) * 6.0f; + const float x = 1.0f - std::fabs(std::fmod(h, 2.0f) - 1.0f); + const float rgb[6][3] = { { 1, x, 0 }, { x, 1, 0 }, { 0, 1, x }, { 0, x, 1 }, { x, 0, 1 }, { 1, 0, x } }; + const int i = std::min((int)h, 5); + for (int k = 0; k < 3; ++k) { + out[k] = 0.35f + 0.65f * rgb[i][k]; + } +} + +const std::vector<UI::PreviewVertex>& CollisionTris(const ParseResultData& item) { + static std::unordered_map<std::string, std::vector<UI::PreviewVertex>> sCache; + auto it = sCache.find(item.name); + if (it != sCache.end()) { + return it->second; + } + + std::vector<UI::PreviewVertex> tris; + auto collision = std::static_pointer_cast<SM64::Collision>(item.data.value()); + const auto& verts = collision->mVertices; + for (const auto& surface : collision->mSurfaces) { + float color[3]; + SurfaceColor((uint32_t)surface.surfaceType, color); + for (const auto& tri : surface.tris) { + if (tri.x < 0 || tri.y < 0 || tri.z < 0 || (size_t)tri.x >= verts.size() || (size_t)tri.y >= verts.size() || + (size_t)tri.z >= verts.size()) { + continue; + } + const SM64::CollisionVertex* v[3] = { &verts[tri.x], &verts[tri.y], &verts[tri.z] }; + const float e1[3] = { (float)(v[1]->x - v[0]->x), (float)(v[1]->y - v[0]->y), (float)(v[1]->z - v[0]->z) }; + const float e2[3] = { (float)(v[2]->x - v[0]->x), (float)(v[2]->y - v[0]->y), (float)(v[2]->z - v[0]->z) }; + float n[3] = { e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], + e1[0] * e2[1] - e1[1] * e2[0] }; + const float len = std::sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]); + float shade = 0.6f; + if (len > 0.0001f) { + shade = 0.45f + 0.55f * std::max(0.0f, (n[0] * 0.30f + n[1] * 0.85f + n[2] * 0.42f) / len); + } + for (const auto* cv : v) { + UI::PreviewVertex pv{}; + pv.position[0] = cv->x; + pv.position[1] = cv->y; + pv.position[2] = cv->z; + for (int k = 0; k < 3; ++k) { + pv.color[k] = (unsigned char)std::min(255.0f, color[k] * shade * 255.0f); + } + pv.color[3] = 255; + tris.push_back(pv); + } + } + } + return sCache.emplace(item.name, std::move(tris)).first->second; +} + +} // namespace + +float SM64::CollisionFactoryUI::GetItemHeight(const ParseResultData& item) { + return ImGui::GetTextLineHeightWithSpacing() * 2.0f + UI::PreviewBlockHeight(item.name) + 4.0f + + ImGui::GetStyle().ItemSpacing.y * 3.0f; +} + +void SM64::CollisionFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + auto collision = std::static_pointer_cast<SM64::Collision>(item.data.value()); + size_t triCount = 0; + for (const auto& surface : collision->mSurfaces) { + triCount += surface.tris.size(); + } + ImGui::TextDisabled("collision \xe2\x80\x94 %zu surfaces, %zu tris, %zu verts", collision->mSurfaces.size(), + triCount, collision->mVertices.size()); + + UI::OrbitView& view = sCollisionViews[item.name]; + const UI::PreviewCanvas canvas = UI::BeginResizableCanvas("##colview", item.name, view); + if (canvas.visible) { + UI::GetBackend()->DrawTriangles(item.name, CollisionTris(item), canvas.origin, canvas.size, view); + } +} +#endif // BUILD_UI diff --git a/src/factories/sm64/CollisionFactory.h b/src/factories/sm64/CollisionFactory.h index 28272ee..1a807c2 100644 --- a/src/factories/sm64/CollisionFactory.h +++ b/src/factories/sm64/CollisionFactory.h @@ -70,6 +70,7 @@ class CollisionBinaryExporter : public BaseExporter { class CollisionFactory : public BaseFactory { public: + bool CanPreviewCode() override { return true; } std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { return { @@ -79,4 +80,13 @@ public: }; } }; + +#ifdef BUILD_UI +// Previews the collision mesh, colored per surface type with baked shading. +class CollisionFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; +#endif } diff --git a/src/factories/sm64/GeoLayoutFactory.cpp b/src/factories/sm64/GeoLayoutFactory.cpp index 124a6ad..01cbd2c 100644 --- a/src/factories/sm64/GeoLayoutFactory.cpp +++ b/src/factories/sm64/GeoLayoutFactory.cpp @@ -717,3 +717,704 @@ std::optional<std::shared_ptr<IParsedData>> SM64::GeoLayoutFactory::parse(std::v return std::make_shared<GeoLayout>(commands); } + +#ifdef BUILD_UI +#include <algorithm> +#include <climits> +#include <cmath> +#include <cstring> + +#include "AnimationFactory.h" +#include "ui/BaseBackend.h" +#include "ui/Widgets.h" + +namespace { + +// 4x4 in row-vector convention (v' = v * M), matching the N64 Mat4 layout: +// rotation in the upper 3x3, translation in row 3. +struct Mat4 { + float m[4][4]; +}; + +Mat4 Mat4Identity() { + Mat4 r{}; + r.m[0][0] = r.m[1][1] = r.m[2][2] = r.m[3][3] = 1.0f; + return r; +} + +// r = a * b (a applied first). +Mat4 Mat4Mul(const Mat4& a, const Mat4& b) { + Mat4 r{}; + for (int i = 0; i < 4; ++i) { + for (int j = 0; j < 4; ++j) { + r.m[i][j] = a.m[i][0] * b.m[0][j] + a.m[i][1] * b.m[1][j] + a.m[i][2] * b.m[2][j] + a.m[i][3] * b.m[3][j]; + } + } + return r; +} + +// SM64's mtxf_rotate_zxy_and_translate; rotation given in degrees (the geo +// format stores degrees, converted to binary angles at runtime). +Mat4 Mat4RotZXYTranslate(const Vec3s& t, const Vec3s& rotDeg) { + const float d2r = (float)M_PI / 180.0f; + const float sx = std::sin(rotDeg.x * d2r), cx = std::cos(rotDeg.x * d2r); + const float sy = std::sin(rotDeg.y * d2r), cy = std::cos(rotDeg.y * d2r); + const float sz = std::sin(rotDeg.z * d2r), cz = std::cos(rotDeg.z * d2r); + + Mat4 r = Mat4Identity(); + r.m[0][0] = cy * cz + sx * sy * sz; + r.m[1][0] = -cy * sz + sx * sy * cz; + r.m[2][0] = cx * sy; + r.m[3][0] = t.x; + r.m[0][1] = cx * sz; + r.m[1][1] = cx * cz; + r.m[2][1] = -sx; + r.m[3][1] = t.y; + r.m[0][2] = -sy * cz + sx * cy * sz; + r.m[1][2] = sy * sz + sx * cy * cz; + r.m[2][2] = cx * cy; + r.m[3][2] = t.z; + return r; +} + +Mat4 Mat4Scale(float s) { + Mat4 r = Mat4Identity(); + r.m[0][0] = r.m[1][1] = r.m[2][2] = s; + return r; +} + +// SM64's mtxf_rotate_xyz_and_translate (used by animated parts); rotation in +// s16 binary angles, translation in float units. +Mat4 Mat4RotXYZTranslate(const float t[3], const int16_t rotBin[3]) { + const float b2r = (float)M_PI / 32768.0f; + const float sx = std::sin(rotBin[0] * b2r), cx = std::cos(rotBin[0] * b2r); + const float sy = std::sin(rotBin[1] * b2r), cy = std::cos(rotBin[1] * b2r); + const float sz = std::sin(rotBin[2] * b2r), cz = std::cos(rotBin[2] * b2r); + + Mat4 r = Mat4Identity(); + r.m[0][0] = cy * cz; + r.m[0][1] = cy * sz; + r.m[0][2] = -sy; + r.m[1][0] = sx * sy * cz - cx * sz; + r.m[1][1] = sx * sy * sz + cx * cz; + r.m[1][2] = sx * cy; + r.m[2][0] = cx * sy * cz + sx * sz; + r.m[2][1] = cx * sy * sz - sx * cz; + r.m[2][2] = cx * cy; + r.m[3][0] = t[0]; + r.m[3][1] = t[1]; + r.m[3][2] = t[2]; + return r; +} + +const uint8_t* ArgU8(const std::vector<GeoArgument>& args, size_t i) { + return i < args.size() ? std::get_if<uint8_t>(&args[i]) : nullptr; +} +const int16_t* ArgS16(const std::vector<GeoArgument>& args, size_t i) { + return i < args.size() ? std::get_if<int16_t>(&args[i]) : nullptr; +} +const uint32_t* ArgU32(const std::vector<GeoArgument>& args, size_t i) { + return i < args.size() ? std::get_if<uint32_t>(&args[i]) : nullptr; +} +const uint64_t* ArgU64(const std::vector<GeoArgument>& args, size_t i) { + return i < args.size() ? std::get_if<uint64_t>(&args[i]) : nullptr; +} +const Vec3s* ArgVec3s(const std::vector<GeoArgument>& args, size_t i) { + return i < args.size() ? std::get_if<Vec3s>(&args[i]) : nullptr; +} + +bool IsNodeCommand(GeoOpcode op) { + switch (op) { + case GeoOpcode::NodeRoot: + case GeoOpcode::NodeOrthoProjection: + case GeoOpcode::NodePerspective: + case GeoOpcode::NodeStart: + case GeoOpcode::NodeMasterList: + case GeoOpcode::NodeLevelOfDetail: + case GeoOpcode::NodeSwitchCase: + case GeoOpcode::NodeCamera: + case GeoOpcode::NodeTranslationRotation: + case GeoOpcode::NodeTranslation: + case GeoOpcode::NodeRotation: + case GeoOpcode::NodeAnimatedPart: + case GeoOpcode::NodeBillboard: + case GeoOpcode::NodeDisplayList: + case GeoOpcode::NodeShadow: + case GeoOpcode::NodeObjectParent: + case GeoOpcode::NodeAsm: + case GeoOpcode::NodeBackground: + case GeoOpcode::CopyView: + case GeoOpcode::NodeHeldObj: + case GeoOpcode::NodeScale: + case GeoOpcode::NodeCullingRadius: + return true; + default: + return false; + } +} + +// Flattens the graph like rendering_graph_node.c: transform stack per node +// (child world = local * parent), first switch case only, camera/shadow/ASM +// nodes pass through untransformed. +struct GeoWalk { + std::vector<UI::ModelPart> parts; + // Transform per open scope; inside a GEO_BILLBOARD subtree `rel` restarts + // at the billboard node so parts can be rebuilt camera-facing. + struct XformEntry { + Mat4 world = Mat4Identity(); + Mat4 rel = Mat4Identity(); + bool inBillboard = false; + float anchor[3] = { 0.0f, 0.0f, 0.0f }; + }; + std::vector<XformEntry> stack{ XformEntry{} }; + struct Frame { + bool switchMode = false; + int children = 0; + }; + std::vector<Frame> frames{ Frame{} }; + Mat4 lastLocal = Mat4Identity(); + bool lastWasSwitch = false; + bool lastWasBillboard = false; + bool pendingSkip = false; // last node culled; skip its subtree if one opens + int skipDepth = -1; // raw depth to return to before resuming + int rawDepth = 0; // open/close nesting, counted even while skipping + const std::string* file = nullptr; + int branchDepth = 0; + + // Animation state (gCurrAnimType/gCurrAnimAttribute): the first animated + // part consumes the translation channels, every part its rotation triplet. + enum AnimType { kAnimNone = 0, kAnimTranslation, kAnimVertical, kAnimLateral, kAnimNoTranslation, kAnimRotation }; + const SM64::AnimationData* anim = nullptr; + AnimType animType = kAnimNone; + size_t animAttr = 0; // cursor into anim->mIndices (count/offset pairs) + int animFrame = 0; + + // retrieve_animation_index: values[offset + min(frame, count-1)]. + int16_t AnimValue() { + if (anim == nullptr || animAttr + 1 >= anim->mIndices.size()) { + return 0; + } + const uint16_t count = anim->mIndices[animAttr]; + const uint16_t offset = anim->mIndices[animAttr + 1]; + animAttr += 2; + const int32_t idx = offset + std::min<int32_t>(animFrame, (int32_t)count - 1); + if (idx < 0 || idx >= (int32_t)anim->mEntries.size()) { + return 0; + } + return anim->mEntries[idx]; + } +}; + +const ParseResultData* FindResultByName(const std::string& name) { + for (const auto& [file, results] : Companion::Instance->GetParseResults()) { + for (const auto& r : results) { + if (r.name == name) { + return &r; + } + } + } + return nullptr; +} + +// Resolves an asset pointer to (o2r path, asset type); segment address +// collisions can land on non-GFX assets, so callers must check the type. +std::optional<std::tuple<std::string, std::string>> ResolveAddr(uint64_t ptr, const std::string& file) { + if (ptr == 0) { + return std::nullopt; + } + auto node = Companion::Instance->GetNodeByAddr((uint32_t)ptr, file); + if (!node.has_value()) { + return std::nullopt; + } + auto type = GetSafeNode<std::string>(std::get<1>(node.value()), "type", ""); + std::transform(type.begin(), type.end(), type.begin(), ::toupper); + return std::make_tuple(std::get<0>(node.value()), type); +} + +void WalkGeoCommands(const std::vector<SM64::GeoCommand>& cmds, GeoWalk& ctx); + +void WalkGeoTarget(uint64_t ptr, GeoWalk& ctx) { + if (ctx.branchDepth > 8) { + return; + } + const auto resolved = ResolveAddr(ptr, *ctx.file); + if (!resolved.has_value()) { + return; + } + const ParseResultData* target = FindResultByName(std::get<0>(resolved.value())); + if (target == nullptr || !target->data.has_value() || target->type != "SM64:GEO_LAYOUT") { + return; + } + auto geo = std::static_pointer_cast<SM64::GeoLayout>(target->data.value()); + if (geo == nullptr) { + return; + } + ctx.branchDepth++; + WalkGeoCommands(geo->commands, ctx); + ctx.branchDepth--; +} + +void WalkGeoCommands(const std::vector<SM64::GeoCommand>& cmds, GeoWalk& ctx) { + for (const auto& cmd : cmds) { + // While culling a switch's non-selected child, only track nesting. + if (ctx.skipDepth >= 0) { + if (cmd.opcode == GeoOpcode::OpenNode) { + ctx.rawDepth++; + } else if (cmd.opcode == GeoOpcode::CloseNode) { + ctx.rawDepth--; + if (ctx.rawDepth <= ctx.skipDepth) { + ctx.skipDepth = -1; + } + } else if (cmd.opcode == GeoOpcode::End || cmd.opcode == GeoOpcode::Return || cmd.skipped) { + return; + } + continue; + } + if (cmd.skipped) { + return; // unbalanced stream; exported as GEO_END + } + + switch (cmd.opcode) { + case GeoOpcode::OpenNode: { + ctx.rawDepth++; + if (ctx.pendingSkip) { + ctx.pendingSkip = false; + ctx.skipDepth = ctx.rawDepth - 1; + break; + } + const GeoWalk::XformEntry& top = ctx.stack.back(); + GeoWalk::XformEntry e; + e.world = Mat4Mul(ctx.lastLocal, top.world); + if (top.inBillboard) { + e.inBillboard = true; + e.rel = Mat4Mul(ctx.lastLocal, top.rel); + std::memcpy(e.anchor, top.anchor, sizeof(e.anchor)); + } else if (ctx.lastWasBillboard) { + e.inBillboard = true; // children hang off the camera-facing node + e.anchor[0] = e.world.m[3][0]; + e.anchor[1] = e.world.m[3][1]; + e.anchor[2] = e.world.m[3][2]; + } + ctx.stack.push_back(e); + ctx.frames.push_back({ ctx.lastWasSwitch, 0 }); + ctx.lastLocal = Mat4Identity(); + ctx.lastWasSwitch = false; + ctx.lastWasBillboard = false; + break; + } + case GeoOpcode::CloseNode: { + ctx.rawDepth--; + if (ctx.stack.size() > 1) { + ctx.stack.pop_back(); + ctx.frames.pop_back(); + } + ctx.lastLocal = Mat4Identity(); + ctx.lastWasSwitch = false; + ctx.lastWasBillboard = false; + ctx.pendingSkip = false; + break; + } + case GeoOpcode::End: + case GeoOpcode::Return: { + return; + } + case GeoOpcode::Branch: { + const auto jmp = ArgU8(cmd.arguments, 0); + const auto ptr = ArgU64(cmd.arguments, 1); + if (ptr != nullptr) { + WalkGeoTarget(*ptr, ctx); + } + if (jmp == nullptr || *jmp != 1) { + return; // branch without return replaces this stream + } + break; + } + case GeoOpcode::BranchAndLink: { + const auto ptr = ArgU64(cmd.arguments, 0); + if (ptr != nullptr) { + WalkGeoTarget(*ptr, ctx); + } + break; + } + default: { + if (!IsNodeCommand(cmd.opcode)) { + break; + } + ctx.pendingSkip = false; + GeoWalk::Frame& parent = ctx.frames.back(); + parent.children++; + if (parent.switchMode && parent.children > 1) { + // Only the first switch case previews (no game state to pick). + ctx.pendingSkip = true; + ctx.lastLocal = Mat4Identity(); + ctx.lastWasSwitch = false; + ctx.lastWasBillboard = false; + break; + } + + Mat4 local = Mat4Identity(); + uint64_t dlPtr = 0; + uint8_t dlLayer = 1; // LAYER_OPAQUE + const auto& args = cmd.arguments; + switch (cmd.opcode) { + case GeoOpcode::NodeTranslationRotation: { + const auto params = ArgU8(args, 0); + if (params == nullptr) { + break; + } + Vec3s trans{}, rot{}; + size_t next = 1; + switch ((*params & 0x70) >> 4) { + case 0: + if (const auto t = ArgVec3s(args, 1)) + trans = *t; + if (const auto r = ArgVec3s(args, 2)) + rot = *r; + next = 3; + break; + case 1: + if (const auto t = ArgVec3s(args, 1)) + trans = *t; + next = 2; + break; + case 2: + if (const auto r = ArgVec3s(args, 1)) + rot = *r; + next = 2; + break; + case 3: + if (const auto y = ArgS16(args, 1)) + rot = Vec3s(0, *y, 0); + next = 2; + break; + } + local = Mat4RotZXYTranslate(trans, rot); + if ((*params & 0x80) != 0) { + if (const auto dl = ArgU64(args, next)) + dlPtr = *dl; + dlLayer = *params & 0x0F; + } + break; + } + case GeoOpcode::NodeTranslation: + case GeoOpcode::NodeBillboard: { // billboard: translation here, camera-facing at emit + const auto params = ArgU8(args, 0); + Vec3s trans{}; + if (const auto t = ArgVec3s(args, 1)) + trans = *t; + local = Mat4RotZXYTranslate(trans, Vec3s()); + if (params != nullptr && (*params & 0x80) != 0) { + if (const auto dl = ArgU64(args, 2)) + dlPtr = *dl; + dlLayer = *params & 0x0F; + } + break; + } + case GeoOpcode::NodeRotation: { + const auto params = ArgU8(args, 0); + Vec3s rot{}; + if (const auto r = ArgVec3s(args, 1)) + rot = *r; + local = Mat4RotZXYTranslate(Vec3s(), rot); + if (params != nullptr && (*params & 0x80) != 0) { + if (const auto dl = ArgU64(args, 2)) + dlPtr = *dl; + dlLayer = *params & 0x0F; + } + break; + } + case GeoOpcode::NodeScale: { + const auto params = ArgU8(args, 0); + if (const auto s = ArgU32(args, 1)) { + local = Mat4Scale((float)*s / 65536.0f); + } + if (params != nullptr && (*params & 0x80) != 0) { + if (const auto dl = ArgU64(args, 2)) + dlPtr = *dl; + dlLayer = *params & 0x0F; + } + break; + } + case GeoOpcode::NodeAnimatedPart: { + Vec3s trans{}; + if (const auto layer = ArgU8(args, 0)) + dlLayer = *layer & 0x0F; + if (const auto t = ArgVec3s(args, 1)) + trans = *t; + if (const auto dl = ArgU64(args, 2)) + dlPtr = *dl; + + // geo_process_animated_part; bind pose without an anim. + float t[3] = { (float)trans.x, (float)trans.y, (float)trans.z }; + int16_t rot[3] = { 0, 0, 0 }; + switch (ctx.animType) { + case GeoWalk::kAnimTranslation: + t[0] += ctx.AnimValue(); + t[1] += ctx.AnimValue(); + t[2] += ctx.AnimValue(); + ctx.animType = GeoWalk::kAnimRotation; + break; + case GeoWalk::kAnimLateral: + t[0] += ctx.AnimValue(); + ctx.animAttr += 2; + t[2] += ctx.AnimValue(); + ctx.animType = GeoWalk::kAnimRotation; + break; + case GeoWalk::kAnimVertical: + ctx.animAttr += 2; + t[1] += ctx.AnimValue(); + ctx.animAttr += 2; + ctx.animType = GeoWalk::kAnimRotation; + break; + case GeoWalk::kAnimNoTranslation: + ctx.animAttr += 6; + ctx.animType = GeoWalk::kAnimRotation; + break; + default: + break; + } + if (ctx.animType == GeoWalk::kAnimRotation) { + rot[0] = ctx.AnimValue(); + rot[1] = ctx.AnimValue(); + rot[2] = ctx.AnimValue(); + } + local = Mat4RotXYZTranslate(t, rot); + break; + } + case GeoOpcode::NodeDisplayList: { + if (const auto layer = ArgU8(args, 0)) + dlLayer = *layer & 0x0F; + if (const auto dl = ArgU64(args, 1)) + dlPtr = *dl; + break; + } + default: + break; + } + + ctx.lastLocal = local; + ctx.lastWasSwitch = cmd.opcode == GeoOpcode::NodeSwitchCase; + ctx.lastWasBillboard = cmd.opcode == GeoOpcode::NodeBillboard; + + if (dlPtr != 0) { + const auto resolved = ResolveAddr(dlPtr, *ctx.file); + if (resolved.has_value() && std::get<1>(resolved.value()) == "GFX") { + const GeoWalk::XformEntry& top = ctx.stack.back(); + const Mat4 world = Mat4Mul(local, top.world); + UI::ModelPart part; + part.resource = std::get<0>(resolved.value()); + part.layer = dlLayer; + if (top.inBillboard) { + // Relative to the camera-facing billboard node. + const Mat4 rel = Mat4Mul(local, top.rel); + std::memcpy(part.mtx, rel.m, sizeof(rel.m)); + part.billboard = true; + std::memcpy(part.anchor, top.anchor, sizeof(part.anchor)); + } else if (cmd.opcode == GeoOpcode::NodeBillboard) { + // The billboard node's own display list. + static const Mat4 kIdent = Mat4Identity(); + std::memcpy(part.mtx, kIdent.m, sizeof(kIdent.m)); + part.billboard = true; + part.anchor[0] = world.m[3][0]; + part.anchor[1] = world.m[3][1]; + part.anchor[2] = world.m[3][2]; + } else { + std::memcpy(part.mtx, world.m, sizeof(world.m)); + } + ctx.parts.push_back(std::move(part)); + } + } + break; + } + } + } +} + +const std::string* FindOwningFile(const ParseResultData& item) { + for (const auto& [file, results] : Companion::Instance->GetParseResults()) { + for (const auto& r : results) { + if (&r == &item) { + return &file; + } + } + } + return nullptr; +} + +// Geo layouts don't reference their animations (objects bind them at +// runtime), so offer every SM64:ANIM from the actor's directory. +struct AnimEntry { + std::string name; + const SM64::AnimationData* data; +}; + +const std::vector<AnimEntry>& CollectAnims(const std::string& file) { + static std::unordered_map<std::string, std::vector<AnimEntry>> sCache; + const auto slash = file.find_last_of('/'); + const std::string dir = slash != std::string::npos ? file.substr(0, slash + 1) : ""; + auto it = sCache.find(dir); + if (it != sCache.end()) { + return it->second; + } + + const auto collectFrom = [](const std::vector<ParseResultData>& results, std::vector<AnimEntry>& out) { + for (const auto& r : results) { + if (r.type == "SM64:ANIM" && r.data.has_value()) { + out.push_back({ r.name, std::static_pointer_cast<SM64::AnimationData>(r.data.value()).get() }); + } + } + }; + + // Actor directory first; if it has no animations, fall back to the + // root-most anims.yml (the gMarioAnims DMA bank). + std::vector<AnimEntry> anims; + const auto& parseResults = Companion::Instance->GetParseResults(); + for (const auto& [f, results] : parseResults) { + if (dir.empty() || f.rfind(dir, 0) == 0) { + collectFrom(results, anims); + } + } + if (anims.empty()) { + const std::string* bank = nullptr; + for (const auto& [f, results] : parseResults) { + const auto fslash = f.find_last_of('/'); + const std::string base = fslash != std::string::npos ? f.substr(fslash + 1) : f; + if (base != "anims.yml") { + continue; + } + if (bank == nullptr || f.size() < bank->size()) { + bank = &f; + } + } + if (bank != nullptr) { + collectFrom(parseResults.at(*bank), anims); + } + } + std::sort(anims.begin(), anims.end(), [](const AnimEntry& a, const AnimEntry& b) { return a.name < b.name; }); + return sCache.emplace(dir, std::move(anims)).first->second; +} + +std::vector<UI::ModelPart> FlattenGeoLayout(const ParseResultData& item, const std::string& file, + const SM64::AnimationData* anim, int frame) { + GeoWalk ctx; + if (!item.data.has_value()) { + return {}; + } + if (anim != nullptr) { + ctx.anim = anim; + ctx.animFrame = frame; + const int16_t flags = anim->mFlags; + if ((flags & (1 << 3)) != 0) { // ANIM_FLAG_HOR_TRANS + ctx.animType = GeoWalk::kAnimLateral; + } else if ((flags & (1 << 4)) != 0) { // ANIM_FLAG_VERT_TRANS + ctx.animType = GeoWalk::kAnimVertical; + } else if ((flags & (1 << 6)) != 0) { // ANIM_FLAG_6 (no translation) + ctx.animType = GeoWalk::kAnimNoTranslation; + } else { + ctx.animType = GeoWalk::kAnimTranslation; + } + } + if (auto geo = std::static_pointer_cast<SM64::GeoLayout>(item.data.value())) { + ctx.file = &file; + WalkGeoCommands(geo->commands, ctx); + } + return std::move(ctx.parts); +} + +struct GeoPreviewState { + UI::OrbitView view; + int animIndex = 0; // index into CollectAnims list; -1 = bind pose + int frame = 0; + bool playing = false; + float playAccum = 0.0f; // fractional frames pending at the N64's 30 fps + std::vector<UI::ModelPart> parts; + int builtAnim = INT_MIN; // (anim, frame) the parts were flattened with + int builtFrame = INT_MIN; +}; +std::unordered_map<std::string, GeoPreviewState> sGeoState; + +} // namespace + +float SM64::GeoLayoutFactoryUI::GetItemHeight(const ParseResultData& item) { + return ImGui::GetTextLineHeightWithSpacing() * 2.0f + ImGui::GetFrameHeightWithSpacing() + + UI::PreviewBlockHeight(item.name) + 4.0f + ImGui::GetStyle().ItemSpacing.y * 4.0f; +} + +void SM64::GeoLayoutFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + ImGui::TextDisabled("geo layout \xe2\x80\x94 drag to orbit, shift+drag to pan, \xe2\x8c\x98/Ctrl+scroll to zoom"); + ImGui::SameLine(); + UI::LightingControls(); + + GeoPreviewState& view = sGeoState[item.name]; + const std::string* file = FindOwningFile(item); + static const std::vector<AnimEntry> kNoAnims; + const std::vector<AnimEntry>& anims = file != nullptr ? CollectAnims(*file) : kNoAnims; + if (view.animIndex >= (int)anims.size()) { + view.animIndex = anims.empty() ? -1 : 0; + } + const AnimEntry* anim = + view.animIndex >= 0 && view.animIndex < (int)anims.size() ? &anims[view.animIndex] : nullptr; + + // Animation picker + frame scrubber. + const auto shortName = [](const std::string& n) { + const auto pos = n.find_last_of('/'); + return pos != std::string::npos ? n.c_str() + pos + 1 : n.c_str(); + }; + ImGui::SetNextItemWidth(std::min(ImGui::GetContentRegionAvail().x * 0.5f, 340.0f)); + if (ImGui::BeginCombo("##geoanim", anim != nullptr ? shortName(anim->name) : "bind pose")) { + if (ImGui::Selectable("bind pose", anim == nullptr)) { + view.animIndex = -1; + } + for (int i = 0; i < (int)anims.size(); ++i) { + if (ImGui::Selectable(shortName(anims[i].name), i == view.animIndex)) { + view.animIndex = i; + view.frame = anims[i].data->mStartFrame; + } + } + ImGui::EndCombo(); + } + if (anim != nullptr) { + const int maxFrame = std::max((int)anim->data->mLoopEnd - 1, 0); + ImGui::SameLine(); + if (ImGui::Button(view.playing ? "Stop##geoplay" : "Loop##geoplay")) { + view.playing = !view.playing; + view.playAccum = 0.0f; + } + if (view.playing) { + // Advance at the game's 30 fps, wrapping loopEnd -> loopStart. + const int loopStart = std::clamp((int)anim->data->mLoopStart, 0, maxFrame); + view.playAccum += ImGui::GetIO().DeltaTime * 30.0f; + while (view.playAccum >= 1.0f) { + view.playAccum -= 1.0f; + view.frame = view.frame >= maxFrame ? loopStart : view.frame + 1; + } + } + view.frame = std::clamp(view.frame, 0, maxFrame); + ImGui::SameLine(); + ImGui::SetNextItemWidth(std::min(ImGui::GetContentRegionAvail().x - 8.0f, 260.0f)); + ImGui::SliderInt("##geoframe", &view.frame, 0, maxFrame, "frame %d"); + } + + // Re-flatten when the selected animation or frame changes. + if (file != nullptr && (view.builtAnim != view.animIndex || view.builtFrame != view.frame)) { + view.parts = FlattenGeoLayout(item, *file, anim != nullptr ? anim->data : nullptr, view.frame); + view.builtAnim = view.animIndex; + view.builtFrame = view.frame; + } + const std::vector<UI::ModelPart>& parts = view.parts; + + const UI::PreviewCanvas canvas = UI::BeginResizableCanvas("##geoview", item.name, view.view); + if (canvas.visible) { + if (parts.empty()) { + const char* label = "no drawable display lists"; + const ImVec2 ts = ImGui::CalcTextSize(label); + ImGui::GetWindowDrawList()->AddText(ImVec2(canvas.origin.x + (canvas.size.x - ts.x) * 0.5f, + canvas.origin.y + (canvas.size.y - ts.y) * 0.5f), + IM_COL32(120, 120, 130, 255), label); + } else { + UI::GetBackend()->DrawModelParts(item.name, parts, canvas.origin, canvas.size, view.view); + } + } +} +#endif // BUILD_UI diff --git a/src/factories/sm64/GeoLayoutFactory.h b/src/factories/sm64/GeoLayoutFactory.h index b572896..6a56c31 100644 --- a/src/factories/sm64/GeoLayoutFactory.h +++ b/src/factories/sm64/GeoLayoutFactory.h @@ -41,6 +41,7 @@ class GeoBinaryExporter : public BaseExporter { class GeoLayoutFactory : public BaseFactory { public: GeoLayoutFactory(); + bool CanPreviewCode() override { return true; } std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { @@ -51,4 +52,14 @@ public: }; } }; + +#ifdef BUILD_UI +// Previews the geo layout as an assembled model: the node tree is flattened +// into per-part display lists rendered via Fast3D. +class GeoLayoutFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; +#endif } diff --git a/src/factories/sm64/LevelScriptFactory.h b/src/factories/sm64/LevelScriptFactory.h index 460105f..e33c9ee 100644 --- a/src/factories/sm64/LevelScriptFactory.h +++ b/src/factories/sm64/LevelScriptFactory.h @@ -39,6 +39,7 @@ class LevelScriptCodeExporter : public BaseExporter { class LevelScriptFactory : public BaseFactory { public: + bool CanPreviewCode() override { return true; } std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { return { diff --git a/src/factories/sm64/MacroFactory.h b/src/factories/sm64/MacroFactory.h index c158f10..6336462 100644 --- a/src/factories/sm64/MacroFactory.h +++ b/src/factories/sm64/MacroFactory.h @@ -44,6 +44,7 @@ class MacroCodeExporter : public BaseExporter { class MacroFactory : public BaseFactory { public: + bool CanPreviewCode() override { return true; } std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { return { diff --git a/src/factories/sm64/MovtexFactory.h b/src/factories/sm64/MovtexFactory.h index 219e189..71f7859 100644 --- a/src/factories/sm64/MovtexFactory.h +++ b/src/factories/sm64/MovtexFactory.h @@ -29,6 +29,9 @@ class MovtexCodeExporter : public BaseExporter { class MovtexFactory : public BaseFactory { public: std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; + bool CanPreviewCode() override { + return true; + } inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { return { REGISTER(Code, MovtexCodeExporter) diff --git a/src/factories/sm64/MovtexQuadFactory.h b/src/factories/sm64/MovtexQuadFactory.h index 806c080..e51c35b 100644 --- a/src/factories/sm64/MovtexQuadFactory.h +++ b/src/factories/sm64/MovtexQuadFactory.h @@ -27,6 +27,9 @@ class MovtexQuadCodeExporter : public BaseExporter { class MovtexQuadFactory : public BaseFactory { public: std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; + bool CanPreviewCode() override { + return true; + } inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { return { REGISTER(Code, MovtexQuadCodeExporter) diff --git a/src/factories/sm64/PaintingFactory.h b/src/factories/sm64/PaintingFactory.h index 8d0d6ad..115e49a 100644 --- a/src/factories/sm64/PaintingFactory.h +++ b/src/factories/sm64/PaintingFactory.h @@ -142,6 +142,9 @@ class PaintingCodeExporter : public BaseExporter { class PaintingFactory : public BaseFactory { public: std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; + bool CanPreviewCode() override { + return true; + } inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { return { REGISTER(Code, PaintingCodeExporter) diff --git a/src/factories/sm64/PaintingMapFactory.h b/src/factories/sm64/PaintingMapFactory.h index d73a651..70aaefd 100644 --- a/src/factories/sm64/PaintingMapFactory.h +++ b/src/factories/sm64/PaintingMapFactory.h @@ -35,6 +35,9 @@ class PaintingMapCodeExporter : public BaseExporter { class PaintingMapFactory : public BaseFactory { public: std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; + bool CanPreviewCode() override { + return true; + } inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { return { REGISTER(Code, PaintingMapCodeExporter) diff --git a/src/factories/sm64/TextPreview.cpp b/src/factories/sm64/TextPreview.cpp new file mode 100644 index 0000000..90bb0c9 --- /dev/null +++ b/src/factories/sm64/TextPreview.cpp @@ -0,0 +1,250 @@ +#include "TextPreview.h" + +#ifdef BUILD_UI + +#include <cstdio> +#include <string> +#include <unordered_map> +#include <vector> + +#include "Companion.h" +#include "DialogFactory.h" +#include "DictionaryFactory.h" +#include "imgui.h" +#include "types/RawBuffer.h" +#include "ui/Widgets.h" + +namespace SM64 { +namespace { + +// SM64 US charmap (generated from the decomp charmap.txt), byte -> UTF-8. +// 0x9E space and 0xFE/0xFF control codes are handled in DecodeText. Bytes with +// no US glyph (Japanese kana, multi-byte lead bytes) fall through to \xNN. +const std::unordered_map<uint8_t, const char*>& Charmap() { + static const std::unordered_map<uint8_t, const char*> kMap = { + { 0x00, "0" }, + { 0x01, "1" }, + { 0x02, "2" }, + { 0x03, "3" }, + { 0x04, "4" }, + { 0x05, "5" }, + { 0x06, "6" }, + { 0x07, "7" }, + { 0x08, "8" }, + { 0x09, "9" }, + { 0x0A, "A" }, + { 0x0B, "B" }, + { 0x0C, "C" }, + { 0x0D, "D" }, + { 0x0E, "E" }, + { 0x0F, "F" }, + { 0x10, "G" }, + { 0x11, "H" }, + { 0x12, "I" }, + { 0x13, "J" }, + { 0x14, "K" }, + { 0x15, "L" }, + { 0x16, "M" }, + { 0x17, "N" }, + { 0x18, "O" }, + { 0x19, "P" }, + { 0x1A, "Q" }, + { 0x1B, "R" }, + { 0x1C, "S" }, + { 0x1D, "T" }, + { 0x1E, "U" }, + { 0x1F, "V" }, + { 0x20, "W" }, + { 0x21, "X" }, + { 0x22, "Y" }, + { 0x23, "Z" }, + { 0x24, "a" }, + { 0x25, "b" }, + { 0x26, "c" }, + { 0x27, "d" }, + { 0x28, "e" }, + { 0x29, "f" }, + { 0x2A, "g" }, + { 0x2B, "h" }, + { 0x2C, "i" }, + { 0x2D, "j" }, + { 0x2E, "k" }, + { 0x2F, "l" }, + { 0x30, "m" }, + { 0x31, "n" }, + { 0x32, "o" }, + { 0x33, "p" }, + { 0x34, "q" }, + { 0x35, "r" }, + { 0x36, "s" }, + { 0x37, "t" }, + { 0x38, "u" }, + { 0x39, "v" }, + { 0x3A, "w" }, + { 0x3B, "x" }, + { 0x3C, "y" }, + { 0x3D, "z" }, + { 0x3E, "'" }, + { 0x3F, "." }, + { 0x50, "\xe2\x96\xb2" }, + { 0x51, "\xe2\x96\xbc" }, + { 0x52, "\xe2\x97\x80" }, + { 0x53, "\xe2\x96\xb6" }, + { 0x54, "[A]" }, + { 0x55, "[B]" }, + { 0x56, "[C]" }, + { 0x57, "[Z]" }, + { 0x58, "[R]" }, + { 0x60, "\xc3\xa0" }, + { 0x61, "\xc3\xa2" }, + { 0x62, "\xc3\xa4" }, + { 0x64, "\xc3\x80" }, + { 0x65, "\xc3\x82" }, + { 0x66, "\xc3\x84" }, + { 0x6F, "," }, + { 0x70, "\xc3\xa8" }, + { 0x71, "\xc3\xaa" }, + { 0x72, "\xc3\xab" }, + { 0x73, "\xc3\xa9" }, + { 0x74, "\xc3\x88" }, + { 0x75, "\xc3\x8a" }, + { 0x76, "\xc3\x8b" }, + { 0x77, "\xc3\x89" }, + { 0x80, "\xc3\xb9" }, + { 0x81, "\xc3\xbb" }, + { 0x82, "\xc3\xbc" }, + { 0x84, "\xc3\x99" }, + { 0x85, "\xc3\x9b" }, + { 0x86, "\xc3\x9c" }, + { 0x91, "\xc3\xb4" }, + { 0x92, "\xc3\xb6" }, + { 0x95, "\xc3\x94" }, + { 0x96, "\xc3\x96" }, + { 0x9F, "-" }, + { 0xA1, "\xc3\xae" }, + { 0xA2, "\xc3\xaf" }, + { 0xD0, "/" }, + { 0xD1, "the" }, + { 0xD2, "you" }, + { 0xE0, "[%]" }, + { 0xE1, "(" }, + { 0xE3, ")" }, + { 0xE4, "+" }, + { 0xE5, "&" }, + { 0xE6, ":" }, + { 0xEC, "\xc3\x9f" }, + { 0xED, "\xc3\x87" }, + { 0xEE, "\xc3\xa7" }, + { 0xF2, "!" }, + { 0xF3, "%" }, + { 0xF4, "?" }, + { 0xF7, "~" }, + { 0xF9, "$" }, + { 0xFA, "\xe2\x98\x85" }, + { 0xFB, "\xc3\x97" }, + { 0xFD, "\xe2\x98\x86" }, + }; + return kMap; +} + +std::string DecodeText(const uint8_t* data, size_t len) { + std::string out; + const auto& map = Charmap(); + for (size_t i = 0; i < len; ++i) { + const uint8_t b = data[i]; + if (b == 0xFF) { // string terminator + break; + } + if (b == 0xFE) { // newline + out += '\n'; + continue; + } + if (b == 0x9E) { // space + out += ' '; + continue; + } + const auto it = map.find(b); + if (it != map.end()) { + out += it->second; + } else { + char buf[8]; + snprintf(buf, sizeof(buf), "\\x%02X", b); + out += buf; + } + } + return out; +} + +// Read-only wrapped text box sized to the card body. +void TextBox(const char* id, const std::string& text, float height) { + ImGui::BeginChild(id, ImVec2(0, height), true, ImGuiWindowFlags_HorizontalScrollbar); + ImGui::PushTextWrapPos(ImGui::GetContentRegionAvail().x); + ImGui::TextUnformatted(text.c_str()); + ImGui::PopTextWrapPos(); + ImGui::EndChild(); +} + +} // namespace + +float DialogFactoryUI::GetItemHeight(const ParseResultData&) { + return ImGui::GetTextLineHeightWithSpacing() * 2.0f + 180.0f + ImGui::GetStyle().ItemSpacing.y * 3.0f; +} + +void DialogFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + const auto dialog = std::static_pointer_cast<DialogData>(item.data.value()); + ImGui::TextDisabled("dialog \xe2\x80\x94 %u lines/box, width %u, left offset %u", dialog->mLinesPerBox, + dialog->mWidth, dialog->mLeftOffset); + TextBox("##dialogtext", DecodeText(dialog->mText.data(), dialog->mText.size()), 150.0f); +} + +float TextFactoryUI::GetItemHeight(const ParseResultData&) { + return ImGui::GetTextLineHeightWithSpacing() + 120.0f + ImGui::GetStyle().ItemSpacing.y * 3.0f; +} + +void TextFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + const auto& bytes = std::static_pointer_cast<RawBuffer>(item.data.value())->mBuffer; + TextBox("##text", DecodeText(bytes.data(), bytes.size()), 100.0f); +} + +float DictionaryFactoryUI::GetItemHeight(const ParseResultData&) { + return ImGui::GetTextLineHeightWithSpacing() * 2.0f + 220.0f + ImGui::GetStyle().ItemSpacing.y * 3.0f; +} + +void DictionaryFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + const auto dict = std::static_pointer_cast<DictionaryData>(item.data.value()); + ImGui::TextDisabled("dictionary \xe2\x80\x94 %zu entries", dict->mDictionary.size()); + if (ImGui::BeginTable("##dicttable", 2, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY, + ImVec2(0, 200.0f))) { + ImGui::TableSetupColumn("key"); + ImGui::TableSetupColumn("text"); + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableHeadersRow(); + for (const auto& [key, value] : dict->mDictionary) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(key.c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(DecodeText(value.data(), value.size()).c_str()); + } + ImGui::EndTable(); + } +} + +} // namespace SM64 + +#endif // BUILD_UI diff --git a/src/factories/sm64/TextPreview.h b/src/factories/sm64/TextPreview.h new file mode 100644 index 0000000..55afe63 --- /dev/null +++ b/src/factories/sm64/TextPreview.h @@ -0,0 +1,30 @@ +#pragma once + +#include "factories/BaseFactory.h" + +// Decoded-text viewer cards for the SM64 string assets (dialog, course/menu +// text, and the compression dictionary). These decode the game's custom +// character encoding into readable UTF-8. +#ifdef BUILD_UI +namespace SM64 { + +class DialogFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; + +class TextFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; + +class DictionaryFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; + +} // namespace SM64 +#endif diff --git a/src/factories/sm64/TrajectoryFactory.cpp b/src/factories/sm64/TrajectoryFactory.cpp index 5849a96..3766eb1 100644 --- a/src/factories/sm64/TrajectoryFactory.cpp +++ b/src/factories/sm64/TrajectoryFactory.cpp @@ -94,3 +94,121 @@ std::optional<std::shared_ptr<IParsedData>> SM64::TrajectoryFactory::parse(std:: return std::make_shared<SM64::TrajectoryData>(trajectoryData); } + +#ifdef BUILD_UI +#include <cmath> +#include <cstring> +#include <unordered_map> + +#include "ui/BaseBackend.h" +#include "ui/Widgets.h" + +namespace { + +std::unordered_map<std::string, UI::OrbitView> sTrajectoryViews; + +void PushTri(std::vector<UI::PreviewVertex>& out, const float a[3], const float b[3], const float c[3], + const unsigned char color[4]) { + const float* pts[3] = { a, b, c }; + for (const auto* p : pts) { + UI::PreviewVertex v{}; + std::memcpy(v.position, p, sizeof(v.position)); + std::memcpy(v.color, color, 4); + out.push_back(v); + } +} + +// Each segment becomes two crossed ribbons so the path reads from any angle; +// color runs green (start) to red (end). +const std::vector<UI::PreviewVertex>& TrajectoryTris(const ParseResultData& item) { + static std::unordered_map<std::string, std::vector<UI::PreviewVertex>> sCache; + auto it = sCache.find(item.name); + if (it != sCache.end()) { + return it->second; + } + + auto data = std::static_pointer_cast<SM64::TrajectoryData>(item.data.value()); + const auto& pts = data->mTrajectoryData; + + std::vector<UI::PreviewVertex> tris; + float mn[3] = { 1e18f, 1e18f, 1e18f }, mx[3] = { -1e18f, -1e18f, -1e18f }; + for (const auto& p : pts) { + const float pos[3] = { (float)p.posX, (float)p.posY, (float)p.posZ }; + for (int k = 0; k < 3; ++k) { + mn[k] = std::min(mn[k], pos[k]); + mx[k] = std::max(mx[k], pos[k]); + } + } + const float dx = mx[0] - mn[0], dy = mx[1] - mn[1], dz = mx[2] - mn[2]; + const float w = std::max(std::sqrt(dx * dx + dy * dy + dz * dz) * 0.008f, 2.0f); + + for (size_t i = 0; i + 1 < pts.size(); ++i) { + const float a[3] = { (float)pts[i].posX, (float)pts[i].posY, (float)pts[i].posZ }; + const float b[3] = { (float)pts[i + 1].posX, (float)pts[i + 1].posY, (float)pts[i + 1].posZ }; + float d[3] = { b[0] - a[0], b[1] - a[1], b[2] - a[2] }; + const float len = std::sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]); + if (len < 0.0001f) { + continue; + } + for (float& v : d) { + v /= len; + } + float u[3] = { d[2], 0.0f, -d[0] }; + const float ulen = std::sqrt(u[0] * u[0] + u[2] * u[2]); + if (ulen > 0.0001f) { + u[0] = u[0] / ulen * w; + u[2] = u[2] / ulen * w; + } else { + u[0] = w; + u[2] = 0.0f; + } + const float v2[3] = { (d[1] * u[2] - d[2] * u[1]), (d[2] * u[0] - d[0] * u[2]), (d[0] * u[1] - d[1] * u[0]) }; + + const float t = pts.size() > 2 ? (float)i / (float)(pts.size() - 2) : 0.0f; + const unsigned char color[4] = { (unsigned char)(60 + 195 * t), (unsigned char)(220 - 160 * t), 60, 255 }; + + const float* offs[2] = { u, v2 }; + for (const auto* o : offs) { + const float q0[3] = { a[0] - o[0], a[1] - o[1], a[2] - o[2] }; + const float q1[3] = { a[0] + o[0], a[1] + o[1], a[2] + o[2] }; + const float q2[3] = { b[0] + o[0], b[1] + o[1], b[2] + o[2] }; + const float q3[3] = { b[0] - o[0], b[1] - o[1], b[2] - o[2] }; + PushTri(tris, q0, q1, q2, color); + PushTri(tris, q0, q2, q3, color); + } + } + return sCache.emplace(item.name, std::move(tris)).first->second; +} + +} // namespace + +float SM64::TrajectoryFactoryUI::GetItemHeight(const ParseResultData& item) { + return ImGui::GetTextLineHeightWithSpacing() * 2.0f + UI::PreviewBlockHeight(item.name) + 4.0f + + ImGui::GetStyle().ItemSpacing.y * 3.0f; +} + +void SM64::TrajectoryFactoryUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + if (!item.data.has_value()) { + ImGui::TextDisabled("no data"); + return; + } + auto data = std::static_pointer_cast<SM64::TrajectoryData>(item.data.value()); + ImGui::TextDisabled("trajectory \xe2\x80\x94 %zu points, green start to red end", data->mTrajectoryData.size()); + + UI::OrbitView& view = sTrajectoryViews[item.name]; + const UI::PreviewCanvas canvas = UI::BeginResizableCanvas("##trajview", item.name, view); + if (canvas.visible) { + const auto& tris = TrajectoryTris(item); + if (tris.empty()) { + const char* label = "not enough points"; + const ImVec2 ts = ImGui::CalcTextSize(label); + ImGui::GetWindowDrawList()->AddText(ImVec2(canvas.origin.x + (canvas.size.x - ts.x) * 0.5f, + canvas.origin.y + (canvas.size.y - ts.y) * 0.5f), + IM_COL32(120, 120, 130, 255), label); + } else { + UI::GetBackend()->DrawTriangles(item.name, tris, canvas.origin, canvas.size, view); + } + } +} +#endif // BUILD_UI diff --git a/src/factories/sm64/TrajectoryFactory.h b/src/factories/sm64/TrajectoryFactory.h index 376e458..45f317d 100644 --- a/src/factories/sm64/TrajectoryFactory.h +++ b/src/factories/sm64/TrajectoryFactory.h @@ -34,6 +34,7 @@ class TrajectoryCodeExporter : public BaseExporter { class TrajectoryFactory : public BaseFactory { public: + bool CanPreviewCode() override { return true; } std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { return { @@ -43,4 +44,13 @@ public: }; } }; + +#ifdef BUILD_UI +// Previews the trajectory as a 3D path. +class TrajectoryFactoryUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; +#endif } diff --git a/src/factories/sm64/WaterDropletFactory.h b/src/factories/sm64/WaterDropletFactory.h index e4f4eff..51d34b3 100644 --- a/src/factories/sm64/WaterDropletFactory.h +++ b/src/factories/sm64/WaterDropletFactory.h @@ -48,6 +48,9 @@ class WaterDropletCodeExporter : public BaseExporter { class WaterDropletFactory : public BaseFactory { public: std::optional<std::shared_ptr<IParsedData>> parse(std::vector<uint8_t>& buffer, YAML::Node& data) override; + bool CanPreviewCode() override { + return true; + } inline std::unordered_map<ExportType, std::shared_ptr<BaseExporter>> GetExporters() override { return { REGISTER(Code, WaterDropletCodeExporter) diff --git a/src/factories/sm64/geo/GeoUtils.cpp b/src/factories/sm64/geo/GeoUtils.cpp index 075c473..d8358f9 100644 --- a/src/factories/sm64/geo/GeoUtils.cpp +++ b/src/factories/sm64/geo/GeoUtils.cpp @@ -1,6 +1,6 @@ #include "GeoUtils.h" -#define next_s16_in_geo_script(src) (int16_t) BSWAP16((*(*src)++)) +#define next_s16_in_geo_script(src) (int16_t)BSWAP16((*(*src)++)) int16_t* read_vec3s_to_vec3f(Vec3f& dst, int16_t* src) { dst.x = next_s16_in_geo_script(&src); diff --git a/src/lib/web.cpp b/src/lib/web.cpp index cc8b27f..0c9d349 100644 --- a/src/lib/web.cpp +++ b/src/lib/web.cpp @@ -45,14 +45,12 @@ EMSCRIPTEN_BINDINGS(Torch) { .constructor<std::vector<uint8_t>, ArchiveType, bool, bool, std::string, std::string>() .constructor<std::vector<uint8_t>, ArchiveType, bool, bool, std::string>() .constructor<std::vector<uint8_t>, ArchiveType, bool, bool>() - .function("Init", optional_override([](Companion& self, ExportType type) { - self.Init(type); - })) + .function("Init", optional_override([](Companion& self, ExportType type) { self.Init(type); })) .function("GetCartridge", &Companion::GetCartridge, allow_raw_pointers()) .function("Process", optional_override([](Companion& self) { - std::atomic<size_t> dummy{0}; - self.Process(dummy); - })) + std::atomic<size_t> dummy{ 0 }; + self.Process(dummy); + })) .function("GetRomData", &Companion::GetRomData, allow_raw_pointers()); } #endif
\ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 0beffa5..0593ed6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,10 @@ #include <iostream> +#include <memory> #include "CLI11.hpp" #include "Companion.h" +#ifdef PM64_SUPPORT +#include "factories/pm64/AudioPreview.h" +#endif #ifdef BK64_SUPPORT #include "factories/bk64/ConfigFactory.h" #endif @@ -9,6 +13,36 @@ Companion* Companion::Instance; #if defined(STANDALONE) && !defined(__EMSCRIPTEN__) +#ifdef BUILD_UI +#include "imgui.h" +#include "ui/BaseBackend.h" +#include "ui/View.h" +#include "ui/list/Main.h" +#include "ui/backends/LusBackend.h" +#include "ui/audio/SequenceDriver.h" +#ifdef PM64_SUPPORT +#include "factories/pm64/ShapeFactory.h" +#endif + +static void LaunchUI() { + if (Companion::Instance == nullptr) { + std::cout << "No ROM loaded; nothing to display." << std::endl; + return; + } + + auto backend = UI::CreateLusBackend(); + UI::SetBackend(backend.get()); + + auto views = std::make_shared<ViewManager>(); + views->SetView(std::make_shared<MainView>()); + + // The backend owns the window + frame loop; returns when the user quits. + backend->RunViewer(views); + + UI::SetBackend(nullptr); +} +#endif + int main(int argc, char* argv[]) { CLI::App app{ "Torch - [T]orch is [O]ur [R]esource [C]onversion [H]elper\n\ * It extracts from a baserom and generates code or an otr.\n\ @@ -146,8 +180,8 @@ int main(int argc, char* argv[]) { #ifdef BK64_SUPPORT /* Emit per-slot SHA-1s of the BK64 asset table */ std::string hashesOut; - const auto hashes = app.add_subcommand( - "hashes", "Hashes - Emit per-slot SHA-1 of BK64 asset table for v1.0 baseline\n"); + const auto hashes = + app.add_subcommand("hashes", "Hashes - Emit per-slot SHA-1 of BK64 asset table for v1.0 baseline\n"); hashes->add_option("<baserom.z64>", filename, "")->required()->check(CLI::ExistingFile); hashes->add_option("-o,--output", hashesOut, "Output yaml path (default: hashes.yaml in cwd)"); @@ -216,6 +250,107 @@ int main(int argc, char* argv[]) { } }); +#ifdef BUILD_UI + /* Launch the interactive viewer */ + const auto ui = app.add_subcommand("ui", "UI - Parses a baserom and opens the interactive asset viewer\n"); + + ui->add_option("<baserom.z64>", filename, "")->required()->check(CLI::ExistingFile); + ui->add_flag("-v,--verbose", debug, "Verbose Debug Mode"); + ui->add_option("-s,--srcdir", srcdir, "Set source directory to locate config.yml and asset metadata for processing") + ->check(CLI::ExistingDirectory); + + ui->parse_complete_callback([&] { + const auto instance = Companion::Instance = new Companion(filename, ArchiveType::None, debug, srcdir, destdir); +#ifdef PM64_SUPPORT + PM64Audio::SetPreviewAssets(true); +#endif + // Parse-only: populate the asset results for the viewer without exporting + // anything (shouldProcess = false skips all writes). Export type is + // irrelevant since nothing is exported. + std::atomic<size_t> assetCount{ 0 }; + instance->Init(ExportType::Binary, assetCount, false); + if (std::getenv("TORCH_PM64_TEST") != nullptr) { // headless shape walk, no window +#ifdef PM64_SUPPORT + for (const auto& [file, results] : instance->GetParseResults()) { + for (const auto& item : results) { + if (item.type != "PM64:SHAPE") { + continue; + } + if (const char* filter = std::getenv("TORCH_SEQ_FILTER"); + filter != nullptr && item.name.find(filter) == std::string::npos) { + continue; + } + PM64ShapeDebugBuild(item); + } + } +#endif + return; + } + if (std::getenv("TORCH_SEQ_TEST") != nullptr) { // headless driver check, no window + for (const auto& [file, results] : instance->GetParseResults()) { + for (const auto& item : results) { + if (UI::GetSequenceDriver(item.type) == nullptr || !item.data.has_value()) { + continue; + } + if (const char* filter = std::getenv("TORCH_SEQ_FILTER"); + filter != nullptr && item.name.find(filter) == std::string::npos) { + continue; + } + auto* driver = UI::GetSequenceDriver(item.type); + if (driver == nullptr) { + continue; + } + UI::RenderedAudio out; + const bool ok = driver->Render(item, 0, out); + float peak = 0.0f; + double sumSq = 0.0; + size_t loud = 0; + for (const auto v : out.pcm) { + const float f = std::fabs((float)v) / 32768.0f; + peak = std::max(peak, f); + sumSq += (double)f * f; + loud += f > 0.01f ? 1 : 0; + } + const double rms = out.pcm.empty() ? 0.0 : std::sqrt(sumSq / (double)out.pcm.size()); + printf("[seqtest] %s ok=%d notes=%zu frames=%zu peak=%.3f rms=%.4f loud=%.1f%%\n", + item.name.c_str(), ok, out.noteCount, out.pcm.size() / 2, peak, rms, + out.pcm.empty() ? 0.0 : 100.0 * (double)loud / (double)out.pcm.size()); + if (const char* wavDir = std::getenv("TORCH_SEQ_WAV")) { + std::string base = item.name; + std::replace(base.begin(), base.end(), '/', '_'); + const std::string wavPath = std::string(wavDir) + "/" + base + ".wav"; + FILE* f = fopen(wavPath.c_str(), "wb"); + if (f != nullptr) { + const uint32_t dataSize = (uint32_t)(out.pcm.size() * 2); + const uint32_t riffSize = 36 + dataSize; + const uint32_t rate = 32000, byteRate = rate * 4; + const uint16_t fmt = 1, ch = 2, align = 4, bits = 16; + const uint32_t fmtSize = 16; + fwrite("RIFF", 1, 4, f); + fwrite(&riffSize, 4, 1, f); + fwrite("WAVE", 1, 4, f); + fwrite("fmt ", 1, 4, f); + fwrite(&fmtSize, 4, 1, f); + fwrite(&fmt, 2, 1, f); + fwrite(&ch, 2, 1, f); + fwrite(&rate, 4, 1, f); + fwrite(&byteRate, 4, 1, f); + fwrite(&align, 2, 1, f); + fwrite(&bits, 2, 1, f); + fwrite("data", 1, 4, f); + fwrite(&dataSize, 4, 1, f); + fwrite(out.pcm.data(), 2, out.pcm.size(), f); + fclose(f); + } + } + } + } + return; + } + LaunchUI(); + }); +#endif + try { app.parse(argc, argv); } catch (const CLI::ParseError& e) { diff --git a/src/preprocess/CompTool.cpp b/src/preprocess/CompTool.cpp index da0afe5..6ba3694 100644 --- a/src/preprocess/CompTool.cpp +++ b/src/preprocess/CompTool.cpp @@ -93,7 +93,8 @@ std::vector<uint8_t> CompTool::Decompress(std::vector<uint8_t> rom) { v_size = p_size; break; case CompType::COMPRESSED: - decoded = Decompressor::Decode(std::vector(bytes, bytes + p_size), 0, CompressionType::MIO0, p_size, true); + decoded = + Decompressor::Decode(std::vector(bytes, bytes + p_size), 0, CompressionType::MIO0, p_size, true); bytes = decoded->data; v_size = decoded->size; break; diff --git a/src/ui/BaseBackend.h b/src/ui/BaseBackend.h new file mode 100644 index 0000000..fe31dfe --- /dev/null +++ b/src/ui/BaseBackend.h @@ -0,0 +1,218 @@ +#pragma once + +#include <cstdint> +#include <cstdio> +#include <cstdlib> +#include <memory> +#include <string> +#include <vector> + +#define IMGUI_DEFINE_MATH_OPERATORS +#include "imgui.h" + +class ViewManager; + +// Renderer/windowing abstraction for the viewer. Implement BaseBackend and +// expose a Create<Name>Backend() factory; only src/ui/backends may include a +// backend's own headers. +namespace UI { + +using TextureHandle = ImTextureID; +constexpr TextureHandle kInvalidTexture = (TextureHandle)0; + +// A single point for DrawPointCloud (object-space position + RGBA color). +struct PreviewVertex { + float position[3]; + unsigned char color[4]; +}; + +// One display list of an assembled model, with its object->world transform +// (row-vector convention, translation in row 3) and SM64 drawing layer. +// Billboarded parts store their transform relative to the billboard node plus +// the node's world position; the backend rebuilds them facing the camera. +// Texture bound to a model part; the backend emits the tile setup. Offsets +// index into blob; fmt/siz are G_IM_FMT_*/G_IM_SIZ_* values, cm* are +// G_TX_WRAP/MIRROR/CLAMP flags. +struct PartTexture { + std::shared_ptr<std::vector<uint8_t>> blob; + uint32_t rasterOffset = 0; + uint32_t paletteOffset = 0; // CI formats only + uint16_t width = 0; + uint16_t height = 0; + uint8_t fmt = 0; + uint8_t siz = 0; + uint8_t cmS = 0; + uint8_t cmT = 0; + // Cycle-1 combine: 0 modulate, 1 blend, 2 decal (PM64 main-only subtypes). + uint8_t combine = 0; + // Second tile blended into cycle 1 (PM64 aux textures): 0 = none, + // 2 = shared raster (aux is the second half of the main raster), + // 3 = independent raster/palette. + uint8_t auxMode = 0; + uint32_t auxRasterOffset = 0; + uint32_t auxPaletteOffset = 0; + uint16_t auxWidth = 0; + uint16_t auxHeight = 0; + uint8_t auxFmt = 0; + uint8_t auxSiz = 0; + uint8_t auxCmS = 0; + uint8_t auxCmT = 0; +}; + +struct ModelPart { + std::string resource; + float mtx[4][4]; + uint8_t layer = 1; // LAYER_OPAQUE + // Exact render mode pair (othermode L cycle words); 0 = layer default. + uint32_t renderMode1 = 0; + uint32_t renderMode2 = 0; + uint8_t cycleType = 1; // pipeline cycles the render mode pair expects (1 or 2) + bool billboard = false; + float anchor[3] = { 0.0f, 0.0f, 0.0f }; + bool unlit = false; // vertex-colored geometry; preview lighting must not apply + bool fullAmbient = false; // light with white ambient (bright, for lit previews) + // Untextured combine for game DLs that load their own textures (SF64): + // 0 = shade only (vertex color), 1 = texture modulate (enables texturing so + // the DL's own texture shows), 2 = flat primitive color, 3 = auto (impose no + // combine/texture state; the display list renders as authored). + uint8_t gameShade = 0; + std::shared_ptr<PartTexture> texture; +}; + +// A display-list bundle parsed from a game blob: byte-swapped f3dex2 command +// words whose pointer operands are file offsets. G_VTX operands are relative +// to vtxBase within the blob; G_DL operands reference other dlists by their +// blob offset; G_SETTIMG operands are blob offsets. +struct GfxBundleDList { + uint32_t offset = 0; + std::vector<uint32_t> words; // w0/w1 pairs +}; + +struct GfxBundle { + std::shared_ptr<std::vector<uint8_t>> blob; + uint32_t vtxBase = 0; + uint32_t vtxSize = 0; + std::vector<GfxBundleDList> dlists; +}; + +// Orbit camera: yaw/pitch in radians, zoom > 1 moves closer, pan in screen +// pixels shifting the look-at target in the view plane. +struct OrbitView { + float yaw = 0.6f; + float pitch = 0.3f; + float zoom = 1.0f; + float panX = 0.0f; + float panY = 0.0f; +}; + +// Shared preview point light. Position is in bounding radii from the model's +// center; intensity falls off quadratically with distance. Parts that bind +// their own material lights (how SM64 colors body parts) override it. +struct PreviewLighting { + bool enabled = true; // G_LIGHTING on/off + float ambient[3] = { 0.25f, 0.25f, 0.25f }; + float color[3] = { 1.0f, 1.0f, 1.0f }; + float position[3] = { 1.5f, 2.0f, 1.5f }; + float intensity = 1.0f; + float falloff = 0.3f; +}; + +inline PreviewLighting& GetPreviewLighting() { + static PreviewLighting lighting; + return lighting; +} + +// Shared preview fog (N64 vertex fog: blender cycle-1 fog shade). Start/end +// are gSPFogPosition units (0..1000 across the near..far range). +struct PreviewAtmosphere { + bool fogEnabled = false; + float fogColor[3] = { 0.45f, 0.50f, 0.65f }; + int fogStart = 890; + int fogEnd = 1000; +}; + +inline PreviewAtmosphere& GetPreviewAtmosphere() { + static PreviewAtmosphere atmosphere = [] { + PreviewAtmosphere a; + if (const char* f = std::getenv("TORCH_UI_FOG")) { + a.fogEnabled = true; + int s0 = 0, e0 = 0; + if (sscanf(f, "%d:%d", &s0, &e0) == 2 && e0 > s0) { + a.fogStart = s0; + a.fogEnd = e0; + } + } + return a; + }(); + return atmosphere; +} + +class BaseBackend { +public: + virtual ~BaseBackend() = default; + + // Owns the window and render loop: creates the window, applies the theme, + // draws `views` every frame until the user closes it. + virtual void RunViewer(const std::shared_ptr<ViewManager>& views) = 0; + + // Uploads 8-bit RGBA pixels; kInvalidTexture on failure. The backend owns + // the GPU resource. + virtual TextureHandle UploadRGBA8(const uint8_t* pixels, int width, int height) = 0; + + // Draws a colored point cloud at the given screen rect. `id` keys a + // reusable render target. Optional. + virtual void DrawPointCloud(uint64_t id, const std::vector<PreviewVertex>& points, + const ImVec2& topLeft, const ImVec2& size, const OrbitView& view) {} + + // Previews a display-list resource as a shaded model at the given screen + // rect. Requires the resource's archive to be mounted. Optional. + virtual void DrawModel(const std::string& resourceName, const ImVec2& topLeft, const ImVec2& size, + const OrbitView& view) {} + + // Previews a multi-part model (e.g. a flattened geo layout). `key` + // identifies the assembled model for render-target reuse. Optional. + virtual void DrawModelParts(const std::string& key, const std::vector<ModelPart>& parts, + const ImVec2& topLeft, const ImVec2& size, const OrbitView& view) {} + + // Registers an entry display list from a parsed game blob under `name` so + // ModelPart::resource can reference it. Returns false if unsupported. + virtual bool RegisterGameDList(const std::string& name, const GfxBundle& bundle, uint32_t entryOffset) { + return false; + } + + // Screen-space backdrop image (RGBA8, cover-fit) drawn behind the model + // keyed by DrawModelParts' `key`. nullptr clears it. Optional. + virtual void SetPreviewBackdrop(const std::string& key, const uint8_t* rgba, int width, int height) {} + + // Renders a colored triangle soup (three PreviewVertex per triangle) with + // the same camera and render-target reuse as DrawModelParts. Optional. + virtual void DrawTriangles(const std::string& key, const std::vector<PreviewVertex>& tris, + const ImVec2& topLeft, const ImVec2& size, const OrbitView& view) {} + + // Plays interleaved 16-bit PCM, replacing whatever is currently playing. + virtual bool PlaySamples(const int16_t* frames, size_t frameCount, int sampleRate, int channels) { return false; } + virtual void StopAudio() {} + // Playback position in [0,1]; negative when idle/finished. + virtual float AudioProgress() { return -1.0f; } + virtual void SeekAudio(float progress) {} + virtual void SetAudioVolume(float volume) {} + virtual float GetAudioVolume() { return 1.0f; } + // Playback-rate multiplier (1 = the sample's own rate). + virtual void SetAudioSpeed(float speed) {} + virtual float GetAudioSpeed() { return 1.0f; } +}; + +inline BaseBackend*& ActiveBackend() { + static BaseBackend* backend = nullptr; + return backend; +} + +inline BaseBackend* GetBackend() { + return ActiveBackend(); +} + +inline void SetBackend(BaseBackend* backend) { + ActiveBackend() = backend; +} + +} // namespace UI diff --git a/src/ui/ExportUtils.h b/src/ui/ExportUtils.h new file mode 100644 index 0000000..d655d08 --- /dev/null +++ b/src/ui/ExportUtils.h @@ -0,0 +1,77 @@ +#pragma once + +#ifdef BUILD_UI + +#include <cstdint> +#include <cstdio> +#include <filesystem> +#include <string> +#include <unordered_map> + +#include "imgui.h" + +namespace UI { + +// Exports land under torch-exports/ mirroring the asset path. +inline std::filesystem::path ExportFilePath(const std::string& assetName, const char* ext) { + std::filesystem::path path = std::filesystem::path("torch-exports") / (assetName + "." + ext); + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + return path; +} + +inline bool WriteWavFile(const std::filesystem::path& path, const int16_t* samples, size_t frames, int channels, + int rate) { + FILE* f = fopen(path.string().c_str(), "wb"); + if (f == nullptr) { + return false; + } + const uint32_t dataSize = (uint32_t)(frames * channels * 2); + const uint32_t riffSize = 36 + dataSize; + const uint16_t fmt = 1, ch = (uint16_t)channels, bits = 16; + const uint16_t align = (uint16_t)(channels * 2); + const uint32_t rate32 = (uint32_t)rate, byteRate = rate32 * align, fmtSize = 16; + fwrite("RIFF", 1, 4, f); + fwrite(&riffSize, 4, 1, f); + fwrite("WAVE", 1, 4, f); + fwrite("fmt ", 1, 4, f); + fwrite(&fmtSize, 4, 1, f); + fwrite(&fmt, 2, 1, f); + fwrite(&ch, 2, 1, f); + fwrite(&rate32, 4, 1, f); + fwrite(&byteRate, 4, 1, f); + fwrite(&align, 2, 1, f); + fwrite(&bits, 2, 1, f); + fwrite("data", 1, 4, f); + fwrite(&dataSize, 4, 1, f); + fwrite(samples, 2, frames * channels, f); + fclose(f); + return true; +} + +// Last export result per asset; shown as a "saved" marker with the full +// path in the tooltip. +inline std::unordered_map<std::string, std::string>& ExportResults() { + static std::unordered_map<std::string, std::string> results; + return results; +} + +inline void NoteExport(const std::string& assetName, const std::string& result) { + ExportResults()[assetName] = result; +} + +inline void DrawExportMarker(const std::string& assetName) { + const auto it = ExportResults().find(assetName); + if (it == ExportResults().end()) { + return; + } + ImGui::SameLine(); + ImGui::TextDisabled("saved"); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("%s", it->second.c_str()); + } +} + +} // namespace UI + +#endif // BUILD_UI diff --git a/src/ui/Theme.h b/src/ui/Theme.h new file mode 100644 index 0000000..91e4ebd --- /dev/null +++ b/src/ui/Theme.h @@ -0,0 +1,51 @@ +#pragma once + +#define IMGUI_DEFINE_MATH_OPERATORS +#include "imgui.h" + +// ImGui styling for the viewer; call once after the backend is initialised. +namespace UI { + +inline void ApplyTorchTheme() { + ImGuiStyle& style = ImGui::GetStyle(); + + style.WindowRounding = 6.0f; + style.FrameRounding = 4.0f; + style.ChildRounding = 6.0f; + style.PopupRounding = 4.0f; + style.GrabRounding = 4.0f; + style.TabRounding = 4.0f; + style.ScrollbarRounding = 8.0f; + style.WindowBorderSize = 0.0f; + style.FrameBorderSize = 0.0f; + style.WindowPadding = ImVec2(12, 12); + style.FramePadding = ImVec2(8, 5); + style.ItemSpacing = ImVec2(8, 7); + style.ScrollbarSize = 12.0f; + + ImVec4* colors = style.Colors; + const ImVec4 accent = ImVec4(0.93f, 0.49f, 0.20f, 1.00f); // torch orange + const ImVec4 accentDim = ImVec4(0.93f, 0.49f, 0.20f, 0.45f); + + colors[ImGuiCol_WindowBg] = ImVec4(0.10f, 0.10f, 0.11f, 1.00f); + colors[ImGuiCol_ChildBg] = ImVec4(0.13f, 0.13f, 0.15f, 1.00f); + colors[ImGuiCol_PopupBg] = ImVec4(0.08f, 0.08f, 0.09f, 0.96f); + colors[ImGuiCol_Border] = ImVec4(1.00f, 1.00f, 1.00f, 0.06f); + colors[ImGuiCol_FrameBg] = ImVec4(0.18f, 0.18f, 0.20f, 1.00f); + colors[ImGuiCol_FrameBgHovered] = ImVec4(0.24f, 0.24f, 0.27f, 1.00f); + colors[ImGuiCol_FrameBgActive] = ImVec4(0.28f, 0.28f, 0.31f, 1.00f); + colors[ImGuiCol_TitleBgActive] = ImVec4(0.12f, 0.12f, 0.14f, 1.00f); + colors[ImGuiCol_Header] = accentDim; + colors[ImGuiCol_HeaderHovered] = ImVec4(0.93f, 0.49f, 0.20f, 0.65f); + colors[ImGuiCol_HeaderActive] = accent; + colors[ImGuiCol_Button] = ImVec4(0.24f, 0.24f, 0.27f, 1.00f); + colors[ImGuiCol_ButtonHovered] = ImVec4(0.31f, 0.31f, 0.35f, 1.00f); + colors[ImGuiCol_ButtonActive] = accent; + colors[ImGuiCol_SeparatorHovered] = accentDim; + colors[ImGuiCol_CheckMark] = accent; + colors[ImGuiCol_SliderGrab] = accent; + colors[ImGuiCol_ScrollbarGrab] = ImVec4(0.30f, 0.30f, 0.33f, 1.00f); + colors[ImGuiCol_ScrollbarGrabHovered] = ImVec4(0.38f, 0.38f, 0.42f, 1.00f); +} + +} // namespace UI diff --git a/src/ui/View.h b/src/ui/View.h new file mode 100644 index 0000000..4ca512a --- /dev/null +++ b/src/ui/View.h @@ -0,0 +1,57 @@ +#pragma once + +#include <memory> + +#define IMGUI_DEFINE_MATH_OPERATORS +#include "imgui.h" + +class ViewManager; + +// A single screen of the UI, swapped in through the ViewManager. +class View { +public: + virtual ~View() = default; + + virtual void Init() {} + virtual void Update() {} + virtual void Render() = 0; + + void InternalInit(const std::shared_ptr<ViewManager>& manager) { + // Weak to avoid a cycle: the ViewManager owns the View. + this->mManager = manager; + this->Init(); + } + +protected: + std::shared_ptr<ViewManager> Manager() const { + return mManager.lock(); + } + +private: + std::weak_ptr<ViewManager> mManager; +}; + +// Owns the active View and drives its per-frame Update/Render. +class ViewManager : public std::enable_shared_from_this<ViewManager> { +public: + void SetView(const std::shared_ptr<View>& view) { + mCurrent = view; + if (mCurrent != nullptr) { + mCurrent->InternalInit(shared_from_this()); + } + } + + const std::shared_ptr<View>& Current() const { + return mCurrent; + } + + void Render() { + if (mCurrent != nullptr) { + mCurrent->Update(); + mCurrent->Render(); + } + } + +private: + std::shared_ptr<View> mCurrent = nullptr; +}; diff --git a/src/ui/Widgets.h b/src/ui/Widgets.h new file mode 100644 index 0000000..000f6b8 --- /dev/null +++ b/src/ui/Widgets.h @@ -0,0 +1,289 @@ +#pragma once + +#ifdef BUILD_UI + +#define IMGUI_DEFINE_MATH_OPERATORS +#include "imgui.h" +#include <algorithm> +#include <cctype> +#include <map> +#include <string> + +#include "ui/BaseBackend.h" + +// Small shared building blocks for factory preview UIs (BaseFactoryUI::DrawUI). +namespace UI { + +// Fixed body height for an asset card. The asset list precomputes each row's +// height (see MainView's manual clipper), so a card must never grow with its +// content — overflow scrolls inside this body instead. +constexpr float kAssetBodyHeight = 132.0f; + +inline float AssetCardHeight() { + const ImGuiStyle& style = ImGui::GetStyle(); + return ImGui::GetTextLineHeightWithSpacing() + style.ItemSpacing.y * 3 + kAssetBodyHeight; +} + +// Asset name on the left, resource type on the right, then a divider. +inline void AssetHeader(const std::string& name, const std::string& type) { + ImGui::TextUnformatted(name.c_str()); + const ImVec2 typeSize = ImGui::CalcTextSize(type.c_str()); + ImGui::SameLine(ImGui::GetContentRegionAvail().x - typeSize.x); + ImGui::TextDisabled("%s", type.c_str()); + ImGui::Separator(); +} + +// Scrolling, fixed-height body region. Pair with EndAssetBody. The caller must +// have a unique ID on the ID stack (MainView pushes the asset index). +inline void BeginAssetBody() { + ImGui::BeginChild("##assetBody", ImVec2(0, kAssetBodyHeight), false); +} + +inline void EndAssetBody() { + ImGui::EndChild(); +} + +// Aligned key/value line. +inline void KV(const char* label, const std::string& value) { + ImGui::TextDisabled("%-16s", label); + ImGui::SameLine(); + ImGui::TextUnformatted(value.c_str()); +} + +// Shading setup for game display lists that impose their own render state. +// Maps a dropdown index to the ModelPart shade fields. "auto" imposes nothing +// (the DL renders as authored); the others force a combine + lighting. +struct ShadeSetup { + uint8_t gameShade; // 0 shade, 1 texture-modulate, 2 flat prim, 3 auto + bool unlit; + bool fullAmbient; +}; + +inline int ShadeSetupCount() { + return 6; +} + +inline const char* ShadeSetupName(int idx) { + static const char* kNames[] = { "auto", "textured", "textured + lit", "vertex color", "vertex color + lit", + "flat" }; + return kNames[idx >= 0 && idx < ShadeSetupCount() ? idx : 0]; +} + +// Maps a config name to a shade-setup index, or -1 if unrecognized. Accepts +// the display names case-insensitively and with '_' standing in for ' ' +// (e.g. "textured + lit", "textured_lit", "TEXTURED + LIT"). +inline int ShadeSetupIndexByName(const std::string& raw) { + if (raw.empty()) { + return -1; + } + std::string s; + for (char c : raw) { + if (c == '_') { + c = ' '; + } + s.push_back((char)std::tolower((unsigned char)c)); + } + for (int i = 0; i < ShadeSetupCount(); ++i) { + if (s == ShadeSetupName(i)) { + return i; + } + } + return -1; +} + +inline ShadeSetup ShadeSetupFor(int idx) { + switch (idx) { + case 1: return { 1, true, false }; // textured + case 2: return { 1, false, true }; // textured + lit (white ambient) + case 3: return { 0, true, false }; // vertex color + case 4: return { 0, false, true }; // vertex color + lit + case 5: return { 2, true, false }; // flat primitive + default: return { 3, false, false }; // auto — no imposed state + } +} + +// Dropdown editing a shade-setup index; returns true when changed. +inline bool ShadeSetupCombo(const char* id, int& idx) { + idx = std::clamp(idx, 0, ShadeSetupCount() - 1); + bool changed = false; + ImGui::SetNextItemWidth(150.0f); + if (ImGui::BeginCombo(id, ShadeSetupName(idx))) { + for (int i = 0; i < ShadeSetupCount(); ++i) { + if (ImGui::Selectable(ShadeSetupName(i), i == idx)) { + idx = i; + changed = true; + } + } + ImGui::EndCombo(); + } + return changed; +} + +// Button + popup editing the shared preview light. +inline void LightingControls() { + if (ImGui::SmallButton("light")) { + ImGui::OpenPopup("##previewlight"); + } + if (ImGui::BeginPopup("##previewlight")) { + PreviewLighting& light = GetPreviewLighting(); + ImGui::Checkbox("enabled", &light.enabled); + ImGui::ColorEdit3("ambient", light.ambient, ImGuiColorEditFlags_NoInputs); + ImGui::ColorEdit3("color", light.color, ImGuiColorEditFlags_NoInputs); + ImGui::SetNextItemWidth(200.0f); + ImGui::SliderFloat3("position", light.position, -4.0f, 4.0f, "%.2f"); + ImGui::SetNextItemWidth(200.0f); + ImGui::SliderFloat("intensity", &light.intensity, 0.0f, 3.0f, "%.2f"); + ImGui::SetNextItemWidth(200.0f); + ImGui::SliderFloat("falloff", &light.falloff, 0.0f, 2.0f, "%.2f"); + ImGui::Separator(); + PreviewAtmosphere& atmo = GetPreviewAtmosphere(); + ImGui::Checkbox("fog", &atmo.fogEnabled); + ImGui::ColorEdit3("fog color", atmo.fogColor, ImGuiColorEditFlags_NoInputs); + ImGui::SetNextItemWidth(200.0f); + ImGui::DragIntRange2("fog range", &atmo.fogStart, &atmo.fogEnd, 2, 0, 1000); + if (atmo.fogEnd <= atmo.fogStart) { + atmo.fogEnd = atmo.fogStart + 1; + } + if (ImGui::SmallButton("reset##light")) { + light = PreviewLighting{}; + atmo = PreviewAtmosphere{}; + } + ImGui::EndPopup(); + } +} + +// Camera controls for the item submitted just before this call: drag orbits, +// shift+drag or middle-drag pans, cmd/ctrl+scroll zooms, double-click resets. +inline void OrbitControls(OrbitView& view) { + ImGuiIO& io = ImGui::GetIO(); + const bool hovered = ImGui::IsItemHovered(); + + const bool panning = (ImGui::IsItemActive() && io.KeyShift) || + (hovered && ImGui::IsMouseDragging(ImGuiMouseButton_Middle)); + if (panning) { + view.panX += io.MouseDelta.x; + view.panY += io.MouseDelta.y; + } else if (ImGui::IsItemActive()) { + view.yaw -= io.MouseDelta.x * 0.01f; + view.pitch = std::clamp(view.pitch + io.MouseDelta.y * 0.01f, -1.55f, 1.55f); + } + // Zoom needs a modifier so plain scroll keeps scrolling the asset list. + if (hovered && io.MouseWheel != 0.0f && (io.KeyCtrl || io.KeySuper)) { + view.zoom = std::clamp(view.zoom * (1.0f + io.MouseWheel * 0.1f), 0.02f, 50.0f); + io.MouseWheel = 0.0f; + } + if (hovered && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + view = OrbitView{}; + } +} + +// Centered preview box (aspect capped at 3:1) with orbit controls and a dark +// backdrop. `visible` is an on-screen test against the scroll viewport. +struct PreviewCanvas { + ImVec2 origin; + ImVec2 size; + bool visible; +}; + +inline PreviewCanvas BeginPreviewCanvas(const char* strId, float height, OrbitView& view) { + const float availW = ImGui::GetContentRegionAvail().x; + const float vw = std::min(availW, height * 3.0f); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (availW - vw) * 0.5f); + const ImVec2 origin = ImGui::GetCursorScreenPos(); + ImGui::InvisibleButton(strId, ImVec2(vw, height)); + const float winTop = ImGui::GetWindowPos().y; + const float winBot = winTop + ImGui::GetWindowHeight(); + const bool visible = ImGui::GetItemRectMax().y > winTop && ImGui::GetItemRectMin().y < winBot; + OrbitControls(view); + if (visible) { + ImGui::GetWindowDrawList()->AddRectFilled(origin, ImVec2(origin.x + vw, origin.y + height), + IM_COL32(18, 18, 22, 255)); + } + return { origin, ImVec2(vw, height), visible }; +} + +// Preview canvases can be resized vertically by dragging their bottom edge. +// The width keeps the default cap (it does not grow with the height). Heights +// are persisted per asset and read back by GetItemHeight so the virtualized +// asset list allocates the right row height. + +constexpr float kPreviewDefaultHeight = 320.0f; +constexpr float kPreviewMinHeight = 140.0f; +constexpr float kPreviewMaxHeight = 1400.0f; +constexpr float kPreviewGripHeight = 11.0f; + +inline std::map<std::string, float>& PreviewHeights() { + static std::map<std::string, float> heights; + return heights; +} + +inline float PreviewHeight(const std::string& key) { + const auto it = PreviewHeights().find(key); + if (it != PreviewHeights().end()) { + return it->second; + } + if (const char* forced = std::getenv("TORCH_UI_CANVASH")) { + const float h = (float)atof(forced); + if (h >= kPreviewMinHeight && h <= kPreviewMaxHeight) { + return h; + } + } + return kPreviewDefaultHeight; +} + +// Canvas plus grip, for GetItemHeight sizing. +inline float PreviewBlockHeight(const std::string& key) { + return PreviewHeight(key) + kPreviewGripHeight; +} + +// Preview canvas with a drag-to-resize bottom edge (double-click resets). +inline PreviewCanvas BeginResizableCanvas(const char* strId, const std::string& key, OrbitView& view) { + const float height = PreviewHeight(key); + const float availW = ImGui::GetContentRegionAvail().x; + // Width limit is independent of the dragged height. + const float vw = std::min(availW, kPreviewDefaultHeight * 3.0f); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (availW - vw) * 0.5f); + const ImVec2 origin = ImGui::GetCursorScreenPos(); + ImGui::InvisibleButton(strId, ImVec2(vw, height)); + const float winTop = ImGui::GetWindowPos().y; + const float winBot = winTop + ImGui::GetWindowHeight(); + const bool visible = ImGui::GetItemRectMax().y > winTop && ImGui::GetItemRectMin().y < winBot; + OrbitControls(view); + if (visible) { + ImGui::GetWindowDrawList()->AddRectFilled(origin, ImVec2(origin.x + vw, origin.y + height), + IM_COL32(18, 18, 22, 255)); + } + + ImGui::SetCursorScreenPos(ImVec2(origin.x, origin.y + height)); + ImGui::PushID(strId); + ImGui::InvisibleButton("##canvasgrip", ImVec2(vw, kPreviewGripHeight)); + ImGui::PopID(); + const bool hovered = ImGui::IsItemHovered(); + const bool active = ImGui::IsItemActive(); + if (hovered || active) { + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); + } + if (active) { + const float dy = ImGui::GetIO().MouseDelta.y; + if (dy != 0.0f) { + PreviewHeights()[key] = std::clamp(height + dy, kPreviewMinHeight, kPreviewMaxHeight); + } + } + if (hovered && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + PreviewHeights().erase(key); + } + if (visible) { + // Grip bar, brighter while interacting. + const ImVec2 mid(origin.x + vw * 0.5f, origin.y + height + kPreviewGripHeight * 0.5f); + const ImU32 col = active ? IM_COL32(160, 160, 170, 255) + : hovered ? IM_COL32(120, 120, 130, 255) + : IM_COL32(70, 70, 78, 255); + ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(mid.x - 24.0f, mid.y - 1.5f), + ImVec2(mid.x + 24.0f, mid.y + 1.5f), col, 1.5f); + } + return { origin, ImVec2(vw, height), visible }; +} + +} // namespace UI + +#endif // BUILD_UI diff --git a/src/ui/audio/SequenceDriver.cpp b/src/ui/audio/SequenceDriver.cpp new file mode 100644 index 0000000..af8a7ac --- /dev/null +++ b/src/ui/audio/SequenceDriver.cpp @@ -0,0 +1,121 @@ +#ifdef BUILD_UI + +#include "SequenceDriver.h" + +#include <algorithm> +#include <unordered_map> + +#include "ui/BaseBackend.h" +#include "ui/ExportUtils.h" +#include "ui/Widgets.h" + +namespace UI { +namespace { + +RenderedAudio sRendered; +std::string sRenderedName; +int sRenderedOption = -1; +std::string sPlayingName; +std::unordered_map<std::string, int> sOptionChoice; + +bool RenderCached(SequenceDriver* driver, const ParseResultData& item, int option) { + if (sRenderedName == item.name && sRenderedOption == option) { + return !sRendered.pcm.empty(); + } + sRendered = {}; + sRenderedName = item.name; + sRenderedOption = option; + if (!driver->Render(item, option, sRendered)) { + sRendered = {}; + return false; + } + sRendered.seconds = (double)sRendered.pcm.size() / 2.0 / kSynthRate; + return !sRendered.pcm.empty(); +} + +} // namespace + +float SequencePreviewUI::GetItemHeight(const ParseResultData&) { + return ImGui::GetTextLineHeightWithSpacing() * 3.0f + ImGui::GetFrameHeightWithSpacing() * 2.0f + + ImGui::GetStyle().ItemSpacing.y * 4.0f; +} + +void SequencePreviewUI::DrawUI(const ParseResultData& item) { + UI::AssetHeader(item.name, item.type); + SequenceDriver* driver = GetSequenceDriver(item.type); + if (driver == nullptr || !item.data.has_value()) { + ImGui::TextDisabled(driver == nullptr ? "no sequence driver for this type" : "no data"); + return; + } + ImGui::TextDisabled("sequence"); + + const std::vector<std::string> options = driver->Options(item); + auto [choiceIt, inserted] = sOptionChoice.try_emplace(item.name, 0); + if (inserted) { + choiceIt->second = driver->DefaultOption(item); + } + int& choice = choiceIt->second; + if (!options.empty()) { + choice = std::clamp(choice, 0, (int)options.size() - 1); + ImGui::SetNextItemWidth(std::min(ImGui::GetContentRegionAvail().x * 0.4f, 260.0f)); + if (ImGui::BeginCombo("##seqopt", options[choice].c_str())) { + for (int i = 0; i < (int)options.size(); ++i) { + if (ImGui::Selectable(options[i].c_str(), i == choice)) { + choice = i; + } + } + ImGui::EndCombo(); + } + ImGui::SameLine(); + } + + const bool playingThis = sPlayingName == item.name && GetBackend()->AudioProgress() >= 0.0f; + if (ImGui::Button(playingThis ? "Stop##seq" : "Play##seq")) { + if (playingThis) { + GetBackend()->StopAudio(); + sPlayingName.clear(); + } else if (RenderCached(driver, item, choice)) { + GetBackend()->SetAudioSpeed(1.0f); + if (GetBackend()->PlaySamples(sRendered.pcm.data(), sRendered.pcm.size() / 2, kSynthRate, 2)) { + sPlayingName = item.name; + } + } + } + ImGui::SameLine(); + if (ImGui::Button("WAV##seqexp")) { + if (RenderCached(driver, item, choice)) { + const auto path = ExportFilePath(item.name, "wav"); + NoteExport(item.name, WriteWavFile(path, sRendered.pcm.data(), sRendered.pcm.size() / 2, 2, kSynthRate) + ? path.string() + : "export failed"); + } else { + NoteExport(item.name, "render failed"); + } + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Export rendered sequence to torch-exports/"); + } + DrawExportMarker(item.name); + ImGui::SameLine(); + float volume = GetBackend()->GetAudioVolume(); + ImGui::SetNextItemWidth(140.0f); + if (ImGui::SliderFloat("##seqvol", &volume, 0.0f, 1.0f, "vol %.2f")) { + GetBackend()->SetAudioVolume(volume); + } + ImGui::SameLine(); + if (sRenderedName == item.name && !sRendered.pcm.empty()) { + ImGui::TextDisabled("%zu notes, %.1fs", sRendered.noteCount, sRendered.seconds); + } else { + ImGui::TextDisabled("press play to render"); + } + + float progress = playingThis ? std::max(GetBackend()->AudioProgress(), 0.0f) : 0.0f; + ImGui::SetNextItemWidth(std::min(ImGui::GetContentRegionAvail().x, 420.0f)); + if (ImGui::SliderFloat("##seqseek", &progress, 0.0f, 1.0f, "") && playingThis) { + GetBackend()->SeekAudio(progress); + } +} + +} // namespace UI + +#endif // BUILD_UI diff --git a/src/ui/audio/SequenceDriver.h b/src/ui/audio/SequenceDriver.h new file mode 100644 index 0000000..9a34700 --- /dev/null +++ b/src/ui/audio/SequenceDriver.h @@ -0,0 +1,53 @@ +#pragma once + +#ifdef BUILD_UI + +#include <memory> +#include <string> +#include <unordered_map> +#include <vector> + +#include "Companion.h" +#include "factories/BaseFactory.h" +#include "ui/audio/SequenceSynth.h" + +// Sequence-format drivers: each game/library version interprets its own +// sequence data into a RenderedAudio. Drivers register by asset type string; +// SequencePreviewUI provides the playback controls for any registered type. +namespace UI { + +class SequenceDriver { +public: + virtual ~SequenceDriver() = default; + virtual bool Render(const ParseResultData& item, int option, RenderedAudio& out) = 0; + // Selectable render options (e.g. sound fonts); empty hides the picker. + virtual std::vector<std::string> Options(const ParseResultData& item) { return {}; } + // Initial option index (e.g. the font the game maps to this sequence). + virtual int DefaultOption(const ParseResultData& item) { return 0; } +}; + +inline std::unordered_map<std::string, std::shared_ptr<SequenceDriver>>& SequenceDrivers() { + static std::unordered_map<std::string, std::shared_ptr<SequenceDriver>> drivers; + return drivers; +} + +inline void RegisterSequenceDriver(const std::string& type, std::shared_ptr<SequenceDriver> driver) { + SequenceDrivers()[type] = std::move(driver); +} + +inline SequenceDriver* GetSequenceDriver(const std::string& type) { + auto& drivers = SequenceDrivers(); + const auto it = drivers.find(type); + return it != drivers.end() ? it->second.get() : nullptr; +} + +// Playback row (play/stop, volume, seek) for any asset type with a driver. +class SequencePreviewUI : public BaseFactoryUI { +public: + float GetItemHeight(const ParseResultData& data) override; + void DrawUI(const ParseResultData& data) override; +}; + +} // namespace UI + +#endif // BUILD_UI diff --git a/src/ui/audio/SequenceSynth.cpp b/src/ui/audio/SequenceSynth.cpp new file mode 100644 index 0000000..d667e99 --- /dev/null +++ b/src/ui/audio/SequenceSynth.cpp @@ -0,0 +1,210 @@ +#ifdef BUILD_UI + +#include "SequenceSynth.h" + +#include <algorithm> +#include <cmath> + +namespace UI { +namespace { + +float EvalEnv(const SynthNote& note, double t) { + const auto& pts = note.envPoints; + if (pts.empty()) { + return t < 0.002 ? (float)(t / 0.002) : 1.0f; + } + const float endT = pts.back().first; + if (t >= endT) { + if (note.envLoopStartT >= 0.0f && endT > note.envLoopStartT + 0.0001f) { + t = note.envLoopStartT + std::fmod(t - note.envLoopStartT, endT - note.envLoopStartT); + } else { + return pts.back().second; + } + } + float prevT = 0.0f, prevL = 0.0f; + for (const auto& [pt, lvl] : pts) { + if (t < pt) { + const float span = pt - prevT; + return span > 0.0001f ? prevL + (lvl - prevL) * (float)((t - prevT) / span) : lvl; + } + prevT = pt; + prevL = lvl; + } + return pts.back().second; +} + +// Step-wise automation lookup with a monotonic cursor. +struct AutoCursor { + const std::vector<std::pair<float, float>>* track = nullptr; + size_t idx = 0; + float value = 1.0f; + + void Init(const std::vector<std::pair<float, float>>& t, float startT) { + track = &t; + idx = 0; + value = t.empty() ? 1.0f : t.front().second; + Advance(startT); + } + void Advance(float t) { + while (track != nullptr && idx < track->size() && (*track)[idx].first <= t) { + value = (*track)[idx].second; + idx++; + } + } +}; + +} // namespace + +std::vector<int16_t> Synthesize(const std::vector<SynthNote>& notes, const GainAutomation (&gainAuto)[16], + const PitchAutomation (&pitchAuto)[16], double lengthSec) { + const size_t frames = (size_t)(std::min(lengthSec + 1.5, kSynthMaxSeconds) * kSynthRate); + std::vector<float> mix(frames * 2, 0.0f); + std::vector<float> wet(frames * 2, 0.0f); + + for (const auto& note : notes) { + if (note.sample == nullptr || note.sample->pcm.empty()) { + continue; + } + const std::vector<int16_t>& pcm = note.sample->pcm; + const bool looped = + note.sample->looped && note.sample->loopEnd > note.sample->loopStart && note.sample->loopEnd <= pcm.size(); + const double loopStart = note.sample->loopStart; + const double loopEnd = note.sample->loopEnd; + + const size_t start = (size_t)(note.startSec * kSynthRate); + const float panR = std::sqrt(std::clamp(note.pan, 0.0f, 1.0f)); + const float panL = std::sqrt(1.0f - std::clamp(note.pan, 0.0f, 1.0f)); + const int chan = std::clamp(note.chan, 0, 15); + + AutoCursor gainCur, pitchCur; + gainCur.Init(gainAuto[chan], (float)note.startSec); + pitchCur.Init(pitchAuto[chan], (float)note.startSec); + + // Release: linear at full-scale/releaseSec, optional sustain plateau. + const float relSlope = 1.0f / std::max(note.releaseSec, 0.001f); + float gateLevel = -1.0f; + float susLevel = 0.0f; + double susHoldEnd = 0.0; + + // Release + sustain can extend well past the gate; bound generously. + const size_t total = (size_t)((note.soundSec + note.releaseSec + 1.0) * kSynthRate); + + size_t seg = 0; + float segFreq = note.freqScale; + float segGain = 1.0f; + double pos = 0.0; + double vibPhase = 0.0; + for (size_t k = 0; k < total; ++k) { + const size_t out = start + k; + if (out >= frames) { + break; + } + if (looped && pos >= loopEnd) { + pos -= loopEnd - loopStart; + } + const size_t idx = (size_t)pos; + if (idx + 1 >= pcm.size()) { + break; + } + const double frac = pos - (double)idx; + const float smp = (float)((1.0 - frac) * pcm[idx] + frac * pcm[idx + 1]); + + const double t = (double)k / kSynthRate; + while (seg < note.segs.size() && t >= note.segs[seg].t) { + segFreq = note.segs[seg].freq; + segGain *= note.segs[seg].gainMul; + seg++; + } + + float env; + if (t <= note.soundSec) { + env = EvalEnv(note, t); + } else { + if (gateLevel < 0.0f) { + gateLevel = EvalEnv(note, note.soundSec); + susLevel = note.sustainLevel > 0.001f ? gateLevel * note.sustainLevel : 0.0f; + if (susLevel > 0.0f) { + // Time to fade to the sustain floor, then the engine's hold. + susHoldEnd = note.soundSec + (gateLevel - susLevel) / relSlope + note.sustainHoldSec; + } + } + const double rel = t - note.soundSec; + env = gateLevel - (float)rel * relSlope; + if (susLevel > 0.0f) { + if (t <= susHoldEnd) { + env = std::max(env, susLevel); + } else { + env = susLevel - (float)(t - susHoldEnd) * relSlope; + } + } + if (env <= 0.0f) { + break; + } + } + + gainCur.Advance((float)(note.startSec + t)); + pitchCur.Advance((float)(note.startSec + t)); + const float v = smp * note.gain * segGain * env * gainCur.value; + mix[out * 2] += v * panL; + mix[out * 2 + 1] += v * panR; + if (note.reverb > 0.003f) { + wet[out * 2] += v * panL * note.reverb; + wet[out * 2 + 1] += v * panR * note.reverb; + } + + double step = (double)segFreq * pitchCur.value; + if (note.portaSec > 0.0f && seg == 0) { + step *= std::pow((double)note.portaRatio, std::min(t / note.portaSec, 1.0)); + } + if ((note.vibRateEndHz > 0.1f || note.vibRateStartHz > 0.1f) && t > note.vibDelaySec) { + const double vt = t - note.vibDelaySec; + const float rate = note.vibRateRampSec > 0.001f + ? note.vibRateStartHz + (note.vibRateEndHz - note.vibRateStartHz) * + (float)std::min(vt / note.vibRateRampSec, 1.0) + : note.vibRateEndHz; + vibPhase += 6.2831853 * rate / kSynthRate; + const float depth = note.vibRampSec > 0.001f + ? note.vibDepthStart + (note.vibDepthEnd - note.vibDepthStart) * + (float)std::min(vt / note.vibRampSec, 1.0) + : note.vibDepthEnd; + if (depth > 0.001f) { + step *= std::pow(2.0, depth * std::sin(vibPhase) / 12.0); + } + } + pos += step; + } + } + + // Two feedback combs per side on the reverb send. + if (frames > 0) { + const int d1 = (int)(0.043 * kSynthRate); + const int d2 = (int)(0.061 * kSynthRate); + for (int side = 0; side < 2; ++side) { + for (size_t i = 0; i < frames; ++i) { + float acc = wet[i * 2 + side]; + if ((int)i >= d1) { + acc += 0.42f * wet[(i - d1) * 2 + side]; + } + if ((int)i >= d2) { + acc += 0.33f * wet[(i - d2) * 2 + side]; + } + wet[i * 2 + side] = acc; + mix[i * 2 + side] += acc * 0.55f; + } + } + } + + float peak = 1.0f; + for (const float v : mix) { + peak = std::max(peak, std::fabs(v) / 30000.0f); + } + std::vector<int16_t> out(mix.size()); + for (size_t i = 0; i < mix.size(); ++i) { + out[i] = (int16_t)std::clamp(mix[i] / peak, -32000.0f, 32000.0f); + } + return out; +} + +} // namespace UI + +#endif // BUILD_UI diff --git a/src/ui/audio/SequenceSynth.h b/src/ui/audio/SequenceSynth.h new file mode 100644 index 0000000..085e9af --- /dev/null +++ b/src/ui/audio/SequenceSynth.h @@ -0,0 +1,80 @@ +#pragma once + +#ifdef BUILD_UI + +#include <cstdint> +#include <memory> +#include <vector> + +// Game-agnostic offline sequence synthesizer. Drivers interpret their own +// sequence format into SynthNotes referencing decoded samples, plus per-channel +// gain automation; Synthesize mixes them into stereo PCM. +namespace UI { + +constexpr int kSynthRate = 32000; +constexpr double kSynthMaxSeconds = 330.0; + +struct SynthSample { + std::vector<int16_t> pcm; // decoded, 1:1 with the source sample units + uint32_t loopStart = 0; + uint32_t loopEnd = 0; + bool looped = false; +}; + +struct SynthNote { + double startSec = 0.0; + double soundSec = 0.0; // sounding time before the release begins + float freqScale = 1.0f; + float gain = 1.0f; // velocity term; channel gain comes from automation + float pan = 0.5f; // 0..1 + int chan = 0; + std::shared_ptr<SynthSample> sample; + float reverb = 0.0f; + + // Envelope as (time sec, level 0..1) breakpoints; loopStartT >= 0 cycles + // the tail. Empty = default fast attack + hold. + std::vector<std::pair<float, float>> envPoints; + float envLoopStartT = -1.0f; + + // Release is the game's linear fade: full scale to zero in releaseSec. + // sustainLevel (0..1) floors the fade at that fraction of the gate-end + // level, holds ~0.53s, then resumes. + float releaseSec = 0.1f; + float sustainLevel = 0.0f; + float sustainHoldSec = 128.0f / 240.0f; + float vibDelaySec = 0.0f; + float vibRampSec = 0.0f; + float vibDepthStart = 0.0f; + float vibDepthEnd = 0.0f; // semitones + float vibRateStartHz = 0.0f; + float vibRateEndHz = 0.0f; + float vibRateRampSec = 0.0f; + float portaRatio = 1.0f; // end/start pitch ratio (1 = none) + float portaSec = 0.0f; + + // Legato continuations: pitch/velocity switches without retriggering. + struct Seg { + float t; + float freq; + float gainMul; + }; + std::vector<Seg> segs; +}; + +// Per-channel (time sec, value) points; applied to notes while they sound. +// Gain tracks channel/master volume, pitch tracks the channel freq multiplier. +using GainAutomation = std::vector<std::pair<float, float>>; +using PitchAutomation = std::vector<std::pair<float, float>>; + +struct RenderedAudio { + std::vector<int16_t> pcm; // interleaved stereo at kSynthRate + size_t noteCount = 0; + double seconds = 0.0; +}; + +std::vector<int16_t> Synthesize(const std::vector<SynthNote>& notes, const GainAutomation (&gainAuto)[16], + const PitchAutomation (&pitchAuto)[16], double lengthSec); + +} // namespace UI + +#endif // BUILD_UI diff --git a/src/ui/backends/LusBackend.cpp b/src/ui/backends/LusBackend.cpp new file mode 100644 index 0000000..d25cede --- /dev/null +++ b/src/ui/backends/LusBackend.cpp @@ -0,0 +1,1714 @@ +#ifdef BUILD_UI + +#include "ui/backends/LusBackend.h" + +#include <filesystem> +#include <vector> + +#include "Companion.h" +#include "ui/Theme.h" +#include "ui/View.h" + +#include <ship/Context.h> +#include <ship/window/gui/GuiWindow.h> +#include <ship/controller/controldeck/ControlDeck.h> +#include <ship/controller/controldevice/controller/mapping/ControllerDefaultMappings.h> +#include <fast/Fast3dWindow.h> +#include <fast/Fast3dGui.h> +#include <fast/interpreter.h> +#include <fast/backends/gfx_rendering_api.h> +#include <fast/resource/type/DisplayList.h> +#include <fast/resource/type/Vertex.h> +#include <fast/resource/ResourceType.h> +#include <fast/resource/factory/DisplayListFactory.h> +#include <fast/resource/factory/VertexFactory.h> +#include <fast/resource/factory/TextureFactory.h> +#include <fast/resource/factory/MatrixFactory.h> +#include <fast/resource/factory/LightFactory.h> +#include <fast/types.h> +#include <fast/lus_gbi.h> +#include <ship/resource/ResourceManager.h> +#include <ship/audio/Audio.h> +#include <ship/resource/ResourceLoader.h> +#include <ship/resource/File.h> +#include <ship/resource/archive/ArchiveManager.h> +#include <libultraship/libultra/gbi.h> + +#include <algorithm> +#include <cmath> +#include <cstring> +#include <unordered_map> +#include <unordered_set> + +// The only translation unit that touches libultraship. +namespace UI { + +namespace { + +// Hosts the Torch views inside LUS's gui draw loop. Draw() is overridden to +// drive the active View directly; the View owns its own fullscreen window. +class ViewHostWindow : public Ship::GuiWindow { + public: + explicit ViewHostWindow(std::shared_ptr<ViewManager> views) + : Ship::GuiWindow("gTorchViewer", true, "Torch"), mViews(std::move(views)) { + } + + void InitElement() override { + } + void UpdateElement() override { + } + void DrawElement() override { + } + + void Draw() override { + // Single OS window: no platform viewports or docking. + ImGui::GetIO().ConfigFlags &= ~(ImGuiConfigFlags_ViewportsEnable | ImGuiConfigFlags_DockingEnable); + if (mViews != nullptr) { + mViews->Render(); + } + } + + private: + std::shared_ptr<ViewManager> mViews; +}; + +// Minimal control deck: the viewer takes no game input, but Context requires a +// non-null deck. WriteToPad is a no-op. +class ViewerControlDeck final : public Ship::ControlDeck { + public: + ViewerControlDeck() : Ship::ControlDeck({}, std::make_shared<Ship::ControllerDefaultMappings>(), {}) { + } + void WriteToPad(void*) override { + } +}; + +// Suppress Fast3dGui's fullscreen "Main Game" window; there is no game scene +// and it would cover the viewer. +class ViewerGui final : public Fast::Fast3dGui { + public: + using Fast::Fast3dGui::Fast3dGui; + void DrawGame() override { + } +}; + +class LusBackend final : public BaseBackend { + public: + void RunViewer(const std::shared_ptr<ViewManager>& views) override { + auto ctx = Ship::Context::CreateUninitializedInstance("Torch", "torch", "torch.cfg.json"); + ctx->InitConfiguration(); + ctx->InitConsoleVariables(); + ctx->InitLogging(); + ctx->InitControlDeck(std::make_shared<ViewerControlDeck>()); + + // Mount the .o2r archives from the working directory so Fast3D can + // resolve the resources referenced by the previewed assets. + std::vector<std::string> archives; + std::error_code ec; + for (const auto& entry : std::filesystem::directory_iterator(std::filesystem::current_path(), ec)) { + if (entry.is_regular_file() && entry.path().extension() == ".o2r") { + 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); + } +#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(); + loader->RegisterResourceFactory(std::make_shared<Fast::ResourceFactoryBinaryDisplayListV0>(), + RESOURCE_FORMAT_BINARY, "DisplayList", + static_cast<uint32_t>(Fast::ResourceType::DisplayList), 0); + loader->RegisterResourceFactory(std::make_shared<Fast::ResourceFactoryBinaryVertexV0>(), RESOURCE_FORMAT_BINARY, + "Vertex", static_cast<uint32_t>(Fast::ResourceType::Vertex), 0); + loader->RegisterResourceFactory(std::make_shared<Fast::ResourceFactoryBinaryTextureV0>(), + RESOURCE_FORMAT_BINARY, "Texture", + static_cast<uint32_t>(Fast::ResourceType::Texture), 0); + loader->RegisterResourceFactory(std::make_shared<Fast::ResourceFactoryBinaryTextureV1>(), + RESOURCE_FORMAT_BINARY, "Texture", + static_cast<uint32_t>(Fast::ResourceType::Texture), 1); + loader->RegisterResourceFactory(std::make_shared<Fast::ResourceFactoryBinaryMatrixV0>(), RESOURCE_FORMAT_BINARY, + "Matrix", static_cast<uint32_t>(Fast::ResourceType::Matrix), 0); + loader->RegisterResourceFactory(std::make_shared<Fast::ResourceFactoryBinaryLightV0>(), RESOURCE_FORMAT_BINARY, + "Light", static_cast<uint32_t>(Fast::ResourceType::Light), 0); + + // GuiWindows must be added after InitWindow; their setup dereferences + // Context::GetWindow(). + auto gui = std::make_shared<ViewerGui>(std::vector<std::shared_ptr<Ship::GuiWindow>>{}); + auto window = std::make_shared<Fast::Fast3dWindow>(gui); + ctx->InitWindow(window); + ctx->InitEventSystem(); + + window->GetGui()->AddGuiWindow(std::make_shared<ViewHostWindow>(views)); + + window->SetTargetFps(60); + // MSAA > 1 forces Fast3D into the offscreen game framebuffer + // (mRendersToFb), which the previews rely on. + window->SetMsaaLevel(4); + + window->SetRendererUCode(ucode_f3d); + + ApplyTorchTheme(); + + static Gfx emptyDl[] = { gsSPEndDisplayList() }; + while (window->IsRunning()) { + window->HandleEvents(); + PumpAudio(); + + // Render the previous frame's preview requests; the gui draw blits + // the results and queues the next batch. + mRenderList.swap(mRequests); + mRequests.clear(); + Gfx* commands = BuildModelCommands(window); + if (commands != nullptr) { + window->DrawAndRunGraphicsCommands(commands, mMtxReplacements); + } else { + window->DrawAndRunGraphicsCommands(emptyDl, {}); + } + } + } + + TextureHandle UploadRGBA8(const uint8_t* pixels, int width, int height) override { + if (pixels == nullptr || width <= 0 || height <= 0) { + return kInvalidTexture; + } + // Mirrors Fast3dGui::LoadGuiTexture's upload path. + auto window = std::dynamic_pointer_cast<Fast::Fast3dWindow>(Ship::Context::GetInstance()->GetWindow()); + if (window == nullptr) { + return kInvalidTexture; + } + auto interpreter = window->GetInterpreterWeak().lock(); + if (interpreter == nullptr) { + return kInvalidTexture; + } + auto* api = interpreter->GetCurrentRenderingAPI(); + if (api == nullptr) { + return kInvalidTexture; + } + + const uint32_t id = api->NewTexture(); + api->SelectTexture(0, id); + api->SetSamplerParameters(0, false, 0, 0); // nearest + clamp: crisp N64 texels + api->UploadTexture(pixels, (uint32_t)width, (uint32_t)height); + return api->GetTextureById((int)id); + } + + void DrawPointCloud(uint64_t id, const std::vector<PreviewVertex>& points, const ImVec2& topLeft, + const ImVec2& size, const OrbitView& view) override { + if (points.empty()) { + return; + } + // Octahedron marker per point, sized against the cloud's bounds. + float mn[3] = { 1e18f, 1e18f, 1e18f }, mx[3] = { -1e18f, -1e18f, -1e18f }; + for (const auto& pt : points) { + for (int k = 0; k < 3; ++k) { + mn[k] = std::min(mn[k], pt.position[k]); + mx[k] = std::max(mx[k], pt.position[k]); + } + } + const float dx = mx[0] - mn[0], dy = mx[1] - mn[1], dz = mx[2] - mn[2]; + const float r = std::max(std::sqrt(dx * dx + dy * dy + dz * dz) * 0.008f, 1.0f); + + static const int kFaces[8][3] = { + { 0, 2, 4 }, { 2, 1, 4 }, { 1, 3, 4 }, { 3, 0, 4 }, { 2, 0, 5 }, { 1, 2, 5 }, { 3, 1, 5 }, { 0, 3, 5 }, + }; + constexpr size_t kMaxPoints = 4000; + std::vector<PreviewVertex> tris; + tris.reserve(std::min(points.size(), kMaxPoints) * 24); + for (size_t i = 0; i < points.size() && i < kMaxPoints; ++i) { + const auto& pt = points[i]; + const float c[6][3] = { + { pt.position[0] + r, pt.position[1], pt.position[2] }, + { pt.position[0] - r, pt.position[1], pt.position[2] }, + { pt.position[0], pt.position[1], pt.position[2] + r }, + { pt.position[0], pt.position[1], pt.position[2] - r }, + { pt.position[0], pt.position[1] + r, pt.position[2] }, + { pt.position[0], pt.position[1] - r, pt.position[2] }, + }; + for (const auto& face : kFaces) { + for (int k = 0; k < 3; ++k) { + PreviewVertex v = pt; + std::memcpy(v.position, c[face[k]], sizeof(v.position)); + tris.push_back(v); + } + } + } + // Thin ribbons between consecutive vertices; the buffer is laid out in + // triangle order, so the links trace the mesh structure. + const float lw = r * 0.35f; + for (size_t i = 0; i + 1 < points.size() && i + 1 < kMaxPoints; ++i) { + const float* a = points[i].position; + const float* b = points[i + 1].position; + float d[3] = { b[0] - a[0], b[1] - a[1], b[2] - a[2] }; + const float len = std::sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]); + if (len < 0.0001f) { + continue; + } + unsigned char color[4]; + for (int k = 0; k < 4; ++k) { + color[k] = (unsigned char)((points[i].color[k] + points[i + 1].color[k]) / 3); + } + color[3] = 255; + float u[3] = { d[2] / len * lw, 0.0f, -d[0] / len * lw }; + if (std::fabs(u[0]) + std::fabs(u[2]) < 0.0001f) { + u[0] = lw; + } + const float v2[3] = { (d[1] * u[2] - d[2] * u[1]) / len, (d[2] * u[0] - d[0] * u[2]) / len, + (d[0] * u[1] - d[1] * u[0]) / len }; + const float* offs[2] = { u, v2 }; + for (const auto* o : offs) { + const float q0[3] = { a[0] - o[0], a[1] - o[1], a[2] - o[2] }; + const float q1[3] = { a[0] + o[0], a[1] + o[1], a[2] + o[2] }; + const float q2[3] = { b[0] + o[0], b[1] + o[1], b[2] + o[2] }; + const float q3[3] = { b[0] - o[0], b[1] - o[1], b[2] - o[2] }; + const float* quads[2][3] = { { q0, q1, q2 }, { q0, q2, q3 } }; + for (const auto& q : quads) { + for (int k = 0; k < 3; ++k) { + PreviewVertex v{}; + std::memcpy(v.position, q[k], sizeof(v.position)); + std::memcpy(v.color, color, 4); + tris.push_back(v); + } + } + } + } + DrawTriangles("points://" + std::to_string(id), tris, topLeft, size, view); + } + + void DrawTriangles(const std::string& key, const std::vector<PreviewVertex>& tris, const ImVec2& topLeft, + const ImVec2& size, const OrbitView& view) override { + if (tris.size() < 3) { + return; + } + RawModel& raw = mRawModels[key]; + const uint64_t hash = HashBytes(tris.data(), tris.size() * sizeof(PreviewVertex)); + if (raw.hash != hash) { + BuildRawModel(raw, tris, hash); + mBoundsCache[key] = raw.bounds; + for (auto& slot : mFbPool) { + if (slot.owner == key) { + slot.lastPartsHash = 0; + } + } + } + + ModelPart part; + part.resource = key; + static const float kIdentity[4][4] = { { 1, 0, 0, 0 }, { 0, 1, 0, 0 }, { 0, 0, 1, 0 }, { 0, 0, 0, 1 } }; + std::memcpy(part.mtx, kIdentity, sizeof(kIdentity)); + DrawModelParts(key, { part }, topLeft, size, view); + } + + void DrawModel(const std::string& resourceName, const ImVec2& topLeft, const ImVec2& size, + const OrbitView& view) override { + // A single display list is just a one-part model with an identity transform. + static const float kIdentity[4][4] = { { 1, 0, 0, 0 }, { 0, 1, 0, 0 }, { 0, 0, 1, 0 }, { 0, 0, 0, 1 } }; + ModelPart part; + part.resource = resourceName; + std::memcpy(part.mtx, kIdentity, sizeof(kIdentity)); + DrawModelParts(resourceName, { part }, topLeft, size, view); + } + + void DrawModelParts(const std::string& key, const std::vector<ModelPart>& parts, const ImVec2& topLeft, + const ImVec2& size, const OrbitView& view) override { + if (size.x < 1.0f || size.y < 1.0f || parts.empty()) { + return; + } + // Queue for next frame's render pass, then blit this model's framebuffer + // (sized to its rect, so it fills the canvas centered). + mRequests.push_back({ key, parts, topLeft, size, view }); + + auto it = mNameToFb.find(key); + if (it == mNameToFb.end()) { + return; + } + auto window = std::dynamic_pointer_cast<Fast::Fast3dWindow>(Ship::Context::GetInstance()->GetWindow()); + if (window == nullptr) { + return; + } + auto interp = window->GetInterpreterWeak().lock(); + if (interp == nullptr) { + return; + } + auto* api = interp->GetCurrentRenderingAPI(); + if (api == nullptr) { + return; + } + void* tex = api->GetFramebufferTextureId(it->second); + if (tex == nullptr) { + return; + } + // Metal/D3D render targets are top-down (match ImGui), OpenGL bottom-up. + const bool flipV = api->GetClipParameters().invertY; + const ImVec2 uvMin(0.0f, flipV ? 1.0f : 0.0f); + const ImVec2 uvMax(1.0f, flipV ? 0.0f : 1.0f); + ImGui::GetWindowDrawList()->AddImage(reinterpret_cast<ImTextureID>(tex), topLeft, + ImVec2(topLeft.x + size.x, topLeft.y + size.y), uvMin, uvMax); + } + + void SetPreviewBackdrop(const std::string& key, const uint8_t* rgba, int width, int height) override { + if (rgba == nullptr || width <= 0 || height <= 0) { + mBackdrops.erase(key); + return; + } + // Texel buffers are content-keyed and never freed: the interpreter's + // texture cache keys on the data address, so reusing one buffer for a + // different image would serve the stale GPU texture. + const uint64_t hash = + HashBytes(rgba, (size_t)width * height * 4, 1469598103934665603ull + width * 131 + height); + auto& texels = mBackdropTexelCache[hash]; + if (texels.empty()) { + // N64 RGBA16 texels (big-endian 5551) for the texture-rectangle strips. + texels.resize((size_t)width * height * 2); + for (int i = 0; i < width * height; ++i) { + const uint16_t v = (uint16_t)(((rgba[i * 4 + 0] >> 3) << 11) | ((rgba[i * 4 + 1] >> 3) << 6) | + ((rgba[i * 4 + 2] >> 3) << 1) | 1); + texels[i * 2 + 0] = (uint8_t)(v >> 8); + texels[i * 2 + 1] = (uint8_t)(v & 0xFF); + } + } + Backdrop& bd = mBackdrops[key]; + bd.texels = &texels; + bd.w = width; + bd.h = height; + bd.generation = hash; + } + + bool RegisterGameDList(const std::string& name, const UI::GfxBundle& bundle, uint32_t entryOffset) override { + if (mGameGfx.count(name) != 0) { + return true; + } + if (bundle.blob == nullptr || bundle.blob->empty()) { + return false; + } + auto bit = mGameBundles.find(bundle.blob.get()); + if (bit == mGameBundles.end()) { + auto gb = std::make_shared<GameBundle>(); + gb->blob = bundle.blob; + gb->vtxBase = bundle.vtxBase; + gb->vtxSize = bundle.vtxSize; + // Two passes: allocate every dlist first so G_DL can cross-reference. + for (const auto& dl : bundle.dlists) { + gb->dlists[dl.offset].resize(dl.words.size() / 2); + } + uint8_t* blobData = gb->blob->data(); + const size_t blobSize = gb->blob->size(); + for (const auto& dl : bundle.dlists) { + auto& out = gb->dlists[dl.offset]; + for (size_t i = 0; i + 1 < dl.words.size(); i += 2) { + const uint32_t w0 = dl.words[i]; + const uint32_t w1 = dl.words[i + 1]; + const uint8_t op = w0 >> 24; + Gfx& g = out[i / 2]; + g.words.w0 = w0; + g.words.w1 = w1; + const auto noop = [&g] { + g.words.w0 = 0; + g.words.w1 = 0; + }; + if (op == 0x01) { // G_VTX: vtxBase-relative byte offset + const uint32_t numv = (w0 >> 12) & 0xFF; + const uint64_t off = (uint64_t)gb->vtxBase + w1; + if (numv != 0 && off + (uint64_t)numv * 16 <= blobSize) { + g.words.w1 = (uintptr_t)(blobData + off); + gb->meta[dl.offset].vtxSpans.emplace_back((uint32_t)off, numv); + } else { + noop(); + } + } else if (op == 0xDE) { // G_DL: blob offset of another dlist + auto tit = gb->dlists.find(w1); + if (tit != gb->dlists.end()) { + g.words.w1 = (uintptr_t)tit->second.data(); + gb->meta[dl.offset].children.push_back(w1); + } else { + g.words.w0 = (uintptr_t)0xDF << 24; // missing target: end + g.words.w1 = 0; + } + } else if (op == 0xFD) { // G_SETTIMG + if (w1 < blobSize) { + g.words.w1 = (uintptr_t)(blobData + w1); + } else { + noop(); + } + } else if (op == 0x04 || op == 0xDA || op == 0xDC) { + // BRANCH_Z / G_MTX / G_MOVEMEM carry addresses we can't + // resolve; drop them rather than dereference garbage. + noop(); + } + } + } + bit = mGameBundles.emplace(bundle.blob.get(), std::move(gb)).first; + } + auto& gb = bit->second; + auto eit = gb->dlists.find(entryOffset); + if (eit == gb->dlists.end() || eit->second.empty()) { + return false; + } + // Bounds from the vertices this dlist (and its G_DL children) actually + // loads, so the camera frames each part instead of the whole table. + if (mBoundsCache.find(name) == mBoundsCache.end()) { + float mn[3] = { 1e18f, 1e18f, 1e18f }, mx[3] = { -1e18f, -1e18f, -1e18f }; + bool any = false; + std::vector<uint32_t> queue{ entryOffset }; + std::unordered_set<uint32_t> seen{ entryOffset }; + while (!queue.empty()) { + const uint32_t off = queue.back(); + queue.pop_back(); + const auto mit = gb->meta.find(off); + if (mit == gb->meta.end()) { + continue; + } + for (const auto& [vtxOff, numv] : mit->second.vtxSpans) { + for (uint32_t i = 0; i < numv; ++i) { + const int16_t* pos = (const int16_t*)(gb->blob->data() + vtxOff + i * 16); + for (int a = 0; a < 3; ++a) { + mn[a] = std::min(mn[a], (float)pos[a]); + mx[a] = std::max(mx[a], (float)pos[a]); + } + any = true; + } + } + for (const uint32_t child : mit->second.children) { + if (seen.insert(child).second) { + queue.push_back(child); + } + } + } + // Fallback: the blob's whole vertex table. + if (!any && gb->vtxSize >= 16) { + const uint8_t* base = gb->blob->data() + gb->vtxBase; + const size_t count = std::min<size_t>(gb->vtxSize / 16, (gb->blob->size() - gb->vtxBase) / 16); + for (size_t i = 0; i < count; ++i) { + const int16_t* pos = (const int16_t*)(base + i * 16); + for (int a = 0; a < 3; ++a) { + mn[a] = std::min(mn[a], (float)pos[a]); + mx[a] = std::max(mx[a], (float)pos[a]); + } + any = true; + } + } + if (any) { + ModelBounds b; + b.cx = (mn[0] + mx[0]) * 0.5f; + b.cy = (mn[1] + mx[1]) * 0.5f; + b.cz = (mn[2] + mx[2]) * 0.5f; + const float dx = mx[0] - mn[0], dy = mx[1] - mn[1], dz = mx[2] - mn[2]; + b.radius = std::max(0.5f * std::sqrt(dx * dx + dy * dy + dz * dz), 1.0f); + mBoundsCache.emplace(name, b); + } + } + mGameGfx[name] = { gb, eit->second.data() }; + return true; + } + + bool PlaySamples(const int16_t* frames, size_t frameCount, int sampleRate, int channels) override { + if (frames == nullptr || frameCount == 0 || sampleRate <= 0 || channels <= 0) { + return false; + } + auto audio = Ship::Context::GetInstance()->GetAudio(); + if (audio == nullptr || audio->GetAudioPlayer() == nullptr || !audio->GetAudioPlayer()->IsInitialized()) { + return false; + } + mAudioPcm.assign(frames, frames + frameCount * channels); + mAudioSrcRate = sampleRate; + mAudioSrcChannels = channels; + mAudioTotalFrames = frameCount; + mAudioPos = 0.0; + return true; + } + + void StopAudio() override { + mAudioPcm.clear(); + mAudioTotalFrames = 0; + mAudioPos = 0.0; + } + + float AudioProgress() override { + if (mAudioTotalFrames == 0) { + return -1.0f; + } + return (float)(mAudioPos / (double)mAudioTotalFrames); + } + + void SeekAudio(float progress) override { + if (mAudioTotalFrames != 0) { + mAudioPos = std::clamp((double)progress, 0.0, 1.0) * (double)mAudioTotalFrames; + } + } + + void SetAudioVolume(float volume) override { + mAudioVolume = std::clamp(volume, 0.0f, 1.0f); + } + + float GetAudioVolume() override { + return mAudioVolume; + } + + void SetAudioSpeed(float speed) override { + mAudioSpeed = std::clamp(speed, 0.25f, 4.0f); + } + + float GetAudioSpeed() override { + return mAudioSpeed; + } + + private: + // out = a * b in row-vector convention (a applied first). + static void MulMtxF(MtxF& out, const float a[4][4], const MtxF& b) { + for (int i = 0; i < 4; ++i) { + for (int j = 0; j < 4; ++j) { + out.mf[i][j] = + a[i][0] * b.mf[0][j] + a[i][1] * b.mf[1][j] + a[i][2] * b.mf[2][j] + a[i][3] * b.mf[3][j]; + } + } + } + + // Row-vector (v' = v*M) perspective + look-at, matching N64/Fast3D convention. + static void Perspective(MtxF& m, float fovyDeg, float aspect, float n, float f) { + for (auto& row : m.mf) { + for (auto& v : row) { + v = 0.0f; + } + } + const float cot = 1.0f / std::tan(fovyDeg * (float)M_PI / 360.0f); + m.mf[0][0] = cot / aspect; + m.mf[1][1] = cot; + m.mf[2][2] = (n + f) / (n - f); + m.mf[2][3] = -1.0f; + m.mf[3][2] = 2.0f * n * f / (n - f); + } + + static void LookAt(MtxF& m, float ex, float ey, float ez, float ax, float ay, float az) { + float zx = ex - ax, zy = ey - ay, zz = ez - az; + float zl = std::sqrt(zx * zx + zy * zy + zz * zz); + if (zl == 0.0f) { + zl = 1.0f; + } + zx /= zl; + zy /= zl; + zz /= zl; + // x = up(0,1,0) x z, normalized + float xx = zz, xy = 0.0f, xz = -zx; + float xl = std::sqrt(xx * xx + xy * xy + xz * xz); + if (xl == 0.0f) { + xl = 1.0f; + } + xx /= xl; + xy /= xl; + xz /= xl; + // y = z x x + const float yx = zy * xz - zz * xy; + const float yy = zz * xx - zx * xz; + const float yz = zx * xy - zy * xx; + m.mf[0][0] = xx; + m.mf[0][1] = yx; + m.mf[0][2] = zx; + m.mf[0][3] = 0.0f; + m.mf[1][0] = xy; + m.mf[1][1] = yy; + m.mf[1][2] = zy; + m.mf[1][3] = 0.0f; + m.mf[2][0] = xz; + m.mf[2][1] = yz; + m.mf[2][2] = zz; + m.mf[2][3] = 0.0f; + m.mf[3][0] = -(xx * ex + xy * ey + xz * ez); + m.mf[3][1] = -(yx * ex + yy * ey + yz * ez); + m.mf[3][2] = -(zx * ex + zy * ey + zz * ez); + m.mf[3][3] = 1.0f; + } + + // Accumulate vertex bounds across a display list, following vertex loads + // (G_VTX_OTR_HASH) and sub-DL branches (G_DL_OTR_HASH). The resource hash + // sits in the Gfx word after the command. + void AccumulateBounds(const std::shared_ptr<Fast::DisplayList>& dl, + const std::shared_ptr<Ship::ResourceManager>& rm, float mn[3], float mx[3], bool& any, + std::unordered_set<std::string>& visited, int depth) { + if (dl == nullptr || depth > 24) { + return; + } + auto am = rm->GetArchiveManager(); + const auto& instr = dl->Instructions; + for (size_t i = 0; i + 1 < instr.size(); ++i) { + const int8_t op = (int8_t)(instr[i].words.w0 >> 24); + const bool isVtx = op == (int8_t)0x32; + const bool isDl = op == (int8_t)0x31; + if (!isVtx && !isDl) { + continue; + } + const uint64_t hash = ((uint64_t)(uint32_t)instr[i + 1].words.w0 << 32) | (uint32_t)instr[i + 1].words.w1; + ++i; // the hash occupies the next Gfx word + const std::string* path = am ? am->HashToString(hash) : nullptr; + if (path == nullptr || !visited.insert(*path).second) { + continue; + } + if (isVtx) { + auto vtx = std::static_pointer_cast<Fast::Vertex>(rm->LoadResource(*path)); + if (vtx == nullptr) { + continue; + } + for (const auto& v : vtx->VertexList) { + for (int k = 0; k < 3; ++k) { + const float val = (float)v.v.ob[k]; + mn[k] = std::min(mn[k], val); + mx[k] = std::max(mx[k], val); + } + any = true; + } + } else { // branch into the sub-display-list + auto sub = std::static_pointer_cast<Fast::DisplayList>(rm->LoadResource(*path)); + AccumulateBounds(sub, rm, mn, mx, any, visited, depth + 1); + } + } + } + + // Vertex bounds of a display list, used to frame it (centered, fit to view). + struct ModelBounds { + float cx = 0.0f, cy = 0.0f, cz = 0.0f, radius = 150.0f; + }; + + ModelBounds ComputeBounds(const std::shared_ptr<Fast::DisplayList>& dl, + const std::shared_ptr<Ship::ResourceManager>& rm) { + ModelBounds b; + if (dl == nullptr) { + return b; + } + float mn[3] = { 1e18f, 1e18f, 1e18f }; + float mx[3] = { -1e18f, -1e18f, -1e18f }; + bool any = false; + std::unordered_set<std::string> visited; + AccumulateBounds(dl, rm, mn, mx, any, visited, 0); + if (any) { + b.cx = (mn[0] + mx[0]) * 0.5f; + b.cy = (mn[1] + mx[1]) * 0.5f; + b.cz = (mn[2] + mx[2]) * 0.5f; + const float dx = mx[0] - mn[0], dy = mx[1] - mn[1], dz = mx[2] - mn[2]; + b.radius = std::max(0.5f * std::sqrt(dx * dx + dy * dy + dz * dz), 1.0f); + } + if (!any || b.radius > 50000.0f || !std::isfinite(b.radius)) { + b = ModelBounds{}; + } + return b; + } + + // Union of each part's bounds transformed by its matrix. Parts far larger + // than the rest (sky domes, backdrops) are excluded from framing so the + // camera isn't pushed inside them. + ModelBounds ComputePartsBounds(const std::vector<ModelPart>& parts, + const std::shared_ptr<Ship::ResourceManager>& rm) { + struct Sphere { + float c[3]; + float r; + }; + std::vector<Sphere> spheres; + spheres.reserve(parts.size()); + for (const auto& part : parts) { + auto bit = mBoundsCache.find(part.resource); + if (bit == mBoundsCache.end() && mGameGfx.count(part.resource) == 0) { + auto dl = std::static_pointer_cast<Fast::DisplayList>(rm->LoadResource(part.resource)); + if (dl == nullptr) { + continue; + } + bit = mBoundsCache.emplace(part.resource, ComputeBounds(dl, rm)).first; + } + if (bit == mBoundsCache.end()) { + continue; + } + const ModelBounds& pb = bit->second; + const auto& m = part.mtx; + // Billboard matrices are anchor-relative; shift by the anchor. + const float ax = part.billboard ? part.anchor[0] : 0.0f; + const float ay = part.billboard ? part.anchor[1] : 0.0f; + const float az = part.billboard ? part.anchor[2] : 0.0f; + Sphere s; + s.c[0] = pb.cx * m[0][0] + pb.cy * m[1][0] + pb.cz * m[2][0] + m[3][0] + ax; + s.c[1] = pb.cx * m[0][1] + pb.cy * m[1][1] + pb.cz * m[2][1] + m[3][1] + ay; + s.c[2] = pb.cx * m[0][2] + pb.cy * m[1][2] + pb.cz * m[2][2] + m[3][2] + az; + float scale = 0.0f; + for (int r = 0; r < 3; ++r) { + scale = std::max(scale, std::sqrt(m[r][0] * m[r][0] + m[r][1] * m[r][1] + m[r][2] * m[r][2])); + } + s.r = pb.radius * std::max(scale, 0.001f); + spheres.push_back(s); + } + + float cutoff = 1e18f; + if (spheres.size() >= 4) { + std::vector<float> radii; + radii.reserve(spheres.size()); + for (const auto& s : spheres) { + radii.push_back(s.r); + } + std::nth_element(radii.begin(), radii.begin() + radii.size() / 2, radii.end()); + cutoff = std::max(radii[radii.size() / 2] * 6.0f, 1.0f); + } + + float mn[3] = { 1e18f, 1e18f, 1e18f }; + float mx[3] = { -1e18f, -1e18f, -1e18f }; + bool any = false; + for (const auto& s : spheres) { + if (s.r > cutoff) { + continue; + } + for (int a = 0; a < 3; ++a) { + mn[a] = std::min(mn[a], s.c[a] - s.r); + mx[a] = std::max(mx[a], s.c[a] + s.r); + } + any = true; + } + ModelBounds b; + if (any) { + b.cx = (mn[0] + mx[0]) * 0.5f; + b.cy = (mn[1] + mx[1]) * 0.5f; + b.cz = (mn[2] + mx[2]) * 0.5f; + const float dx = mx[0] - mn[0], dy = mx[1] - mn[1], dz = mx[2] - mn[2]; + b.radius = std::max(0.5f * std::sqrt(dx * dx + dy * dy + dz * dz), 1.0f); + } + if (!any || b.radius > 50000.0f || !std::isfinite(b.radius)) { + b = ModelBounds{}; + } + return b; + } + + // Render target for one model preview, pooled and reassigned by name as + // rows scroll. + struct FbSlot { + int fbId = -1; + uint32_t w = 0, h = 0; + std::string owner; // model currently rendered into this framebuffer + Vp vp{}; + Mtx proj{}; // stable address: key into the Mtx->MtxF replacement map + // Last rendered state; the framebuffer persists until these change. + bool rendered = false; + OrbitView lastView{}; + uint64_t lastPartsHash = 0; + }; + + static bool SameView(const OrbitView& a, const OrbitView& b) { + return a.yaw == b.yaw && a.pitch == b.pitch && a.zoom == b.zoom && a.panX == b.panX && a.panY == b.panY; + } + + static uint64_t HashBytes(const void* data, size_t n, uint64_t h = 1469598103934665603ull) { + const auto* p = static_cast<const uint8_t*>(data); + for (size_t i = 0; i < n; ++i) { + h ^= p[i]; + h *= 1099511628211ull; + } + return h; + } + + // In-memory geometry for DrawTriangles: vertex-colored, unlit. + struct RawModel { + std::vector<Fast::F3DVtx> vtx; + std::vector<Gfx> gfx; + ModelBounds bounds; + uint64_t hash = 0; + }; + + void BuildRawModel(RawModel& raw, const std::vector<PreviewVertex>& tris, uint64_t hash) { + static_assert(sizeof(Fast::F3DVtx) == 16, "F3D vertex encoding assumes 16-byte vertices"); + const size_t triCount = tris.size() / 3; + raw.hash = hash; + raw.vtx.clear(); + raw.gfx.clear(); + raw.vtx.reserve(triCount * 3); + + float mn[3] = { 1e18f, 1e18f, 1e18f }, mx[3] = { -1e18f, -1e18f, -1e18f }; + for (size_t i = 0; i < triCount * 3; ++i) { + const PreviewVertex& src = tris[i]; + Fast::F3DVtx v{}; + for (int k = 0; k < 3; ++k) { + v.v.ob[k] = (short)std::clamp(src.position[k], -32768.0f, 32767.0f); + mn[k] = std::min(mn[k], src.position[k]); + mx[k] = std::max(mx[k], src.position[k]); + } + std::memcpy(v.v.cn, src.color, 4); + raw.vtx.push_back(v); + } + raw.bounds.cx = (mn[0] + mx[0]) * 0.5f; + raw.bounds.cy = (mn[1] + mx[1]) * 0.5f; + raw.bounds.cz = (mn[2] + mx[2]) * 0.5f; + const float dx = mx[0] - mn[0], dy = mx[1] - mn[1], dz = mx[2] - mn[2]; + raw.bounds.radius = std::max(0.5f * std::sqrt(dx * dx + dy * dy + dz * dz), 1.0f); + + // Vertex colors: no lighting, shade-only combiner. F3D loads at most 16 + // vertices, so batch 5 triangles per load (raw F3D G_VTX/G_TRI1 words; + // the gbi.h macros emit F3DEX encodings the f3d handlers misread). + raw.gfx.reserve(triCount * 3 + 8); + Gfx g{}; + gSPClearGeometryMode(&g, G_LIGHTING); + raw.gfx.push_back(g); + gDPSetCombineMode(&g, G_CC_SHADE, G_CC_SHADE); + raw.gfx.push_back(g); + for (size_t base = 0; base < triCount * 3; base += 15) { + const size_t n = std::min<size_t>(15, triCount * 3 - base); + g.words.w0 = ((uintptr_t)0x04 << 24) | (uintptr_t)(n * sizeof(Fast::F3DVtx)); // G_VTX, v0=0 + g.words.w1 = (uintptr_t)&raw.vtx[base]; + raw.gfx.push_back(g); + for (size_t t = 0; t + 2 < n; t += 3) { + g.words.w0 = (uintptr_t)0xBF << 24; // G_TRI1, indices * 10 + g.words.w1 = ((uintptr_t)(t * 10) << 16) | ((uintptr_t)((t + 1) * 10) << 8) | (uintptr_t)((t + 2) * 10); + raw.gfx.push_back(g); + } + } + gSPEndDisplayList(&g); + raw.gfx.push_back(g); + } + + // FNV-1a over the parts' resources, layers and transforms. + static uint64_t HashParts(const std::vector<ModelPart>& parts) { + uint64_t h = 1469598103934665603ull; + const auto mix = [&h](const void* data, size_t n) { + const auto* p = static_cast<const uint8_t*>(data); + for (size_t i = 0; i < n; ++i) { + h ^= p[i]; + h *= 1099511628211ull; + } + }; + for (const auto& part : parts) { + h = h * 1099511628211ULL + (part.texture != nullptr ? part.texture->rasterOffset + 1 : 0); + mix(part.resource.data(), part.resource.size()); + mix(&part.layer, sizeof(part.layer)); + mix(part.mtx, sizeof(part.mtx)); + mix(&part.billboard, sizeof(part.billboard)); + mix(part.anchor, sizeof(part.anchor)); + mix(&part.gameShade, sizeof(part.gameShade)); + mix(&part.unlit, sizeof(part.unlit)); + mix(&part.fullAmbient, sizeof(part.fullAmbient)); + } + // Light edits must re-render every framebuffer. + const PreviewLighting& light = GetPreviewLighting(); + mix(&light.enabled, sizeof(light.enabled)); + mix(light.ambient, sizeof(light.ambient)); + mix(light.color, sizeof(light.color)); + mix(light.position, sizeof(light.position)); + mix(&light.intensity, sizeof(light.intensity)); + mix(&light.falloff, sizeof(light.falloff)); + const PreviewAtmosphere& atmo = GetPreviewAtmosphere(); + mix(&atmo.fogEnabled, sizeof(atmo.fogEnabled)); + mix(atmo.fogColor, sizeof(atmo.fogColor)); + mix(&atmo.fogStart, sizeof(atmo.fogStart)); + mix(&atmo.fogEnd, sizeof(atmo.fogEnd)); + return h; + } + + // Reuse the slot owned by `name`, else one whose owner isn't visible, else + // any free slot; -1 when exhausted. + int AssignSlot(const std::string& name, const std::vector<bool>& used, + const std::unordered_set<std::string>& visible) { + for (size_t i = 0; i < mFbPool.size(); ++i) { + if (!used[i] && mFbPool[i].fbId >= 0 && mFbPool[i].owner == name) { + return (int)i; + } + } + for (size_t i = 0; i < mFbPool.size(); ++i) { + if (!used[i] && (mFbPool[i].owner.empty() || visible.count(mFbPool[i].owner) == 0)) { + return (int)i; + } + } + for (size_t i = 0; i < mFbPool.size(); ++i) { + if (!used[i]) { + return (int)i; + } + } + return -1; + } + + void PumpAudio() { + if (mAudioTotalFrames == 0) { + return; + } + auto audio = Ship::Context::GetInstance()->GetAudio(); + auto player = audio != nullptr ? audio->GetAudioPlayer() : nullptr; + if (player == nullptr || !player->IsInitialized()) { + return; + } + const int outCh = player->GetNumOutputChannels(); + const int need = player->GetDesiredBuffered() - player->Buffered(); + if (need <= 0) { + return; + } + const int chunk = std::min(need, 4096); + const double step = (double)mAudioSrcRate * mAudioSpeed / (double)player->GetSampleRate(); + std::vector<int16_t> out; + out.reserve((size_t)chunk * outCh); + for (int i = 0; i < chunk; ++i) { + const size_t frame = (size_t)mAudioPos; + if (frame >= mAudioTotalFrames) { + break; + } + const int16_t l = (int16_t)(mAudioPcm[frame * mAudioSrcChannels] * mAudioVolume); + const int16_t r = (int16_t)((mAudioSrcChannels > 1 ? mAudioPcm[frame * mAudioSrcChannels + 1] + : mAudioPcm[frame * mAudioSrcChannels]) * + mAudioVolume); + out.push_back(l); + out.push_back(r); + for (int c = 2; c < outCh; ++c) { + out.push_back(0); + } + mAudioPos += step; + } + if (!out.empty()) { + player->Play((const uint8_t*)out.data(), out.size() * sizeof(int16_t)); + } + if ((size_t)mAudioPos >= mAudioTotalFrames) { + StopAudio(); + } + } + + // Emits the tile setup for a part texture using the game's conventions + // (texel wrap masks from dimensions, CI palettes via TLUT). + static int ILog2(uint16_t v) { + int m = 0; + while ((1 << (m + 1)) <= v) { + m++; + } + return m; + } + + Gfx* EmitPartTexture(Gfx* p, const UI::PartTexture& t, bool fog) { + uint8_t* raster = t.blob->data() + t.rasterOffset; + const int maskS = ILog2(t.width); + const int maskT = ILog2(t.height); + const bool aux = t.auxMode != 0 && t.auxRasterOffset != 0 && t.auxWidth != 0 && t.auxHeight != 0; + gDPPipeSync(p++); + gSPTexture(p++, 0xFFFF, 0xFFFF, 0, G_TX_RENDERTILE, G_ON); + // pmret SolidCombineModes, TINT_COMBINE_NONE / TINT_COMBINE_FOG + // columns (fog moves the second cycle to PASS so blender cycle 1 can + // fog the combined color). + if (aux) { + if (fog) { // G_CC_INTERFERENCE, PM_CC2_MULTIPLY_SHADE + gDPSetCombineLERP(p++, TEXEL0, 0, TEXEL1, 0, TEXEL0, 0, TEXEL1, 0, COMBINED, 0, SHADE, 0, 0, 0, 0, + COMBINED); + } else { // PM_CC_ALT_INTERFERENCE, G_CC_MODULATEIA2 + gDPSetCombineLERP(p++, TEXEL1, 0, TEXEL0, 0, TEXEL1, 0, TEXEL0, 0, COMBINED, 0, SHADE, 0, COMBINED, 0, + SHADE, 0); + } + } else if (t.combine == 1) { + if (fog) { + gDPSetCombineMode(p++, G_CC_BLENDRGBA, G_CC_PASS2); + } else { + gDPSetCombineMode(p++, G_CC_BLENDRGBA, G_CC_BLENDRGBA); + } + } else if (t.combine == 2) { + if (fog) { + gDPSetCombineMode(p++, G_CC_DECALRGBA, G_CC_PASS2); + } else { + gDPSetCombineMode(p++, G_CC_DECALRGBA, G_CC_DECALRGBA); + } + } else { + if (fog) { + gDPSetCombineMode(p++, G_CC_MODULATEIDECALA, G_CC_PASS2); + } else { + gDPSetCombineMode(p++, G_CC_MODULATEIA, G_CC_MODULATEIA); + } + } + const bool auxCi = aux && t.auxFmt == G_IM_FMT_CI; + if (t.fmt == G_IM_FMT_CI || auxCi) { + gDPSetTextureLUT(p++, G_TT_RGBA16); + if (t.fmt == G_IM_FMT_CI) { + uint8_t* pal = t.blob->data() + t.paletteOffset; + if (t.siz == G_IM_SIZ_8b) { + gDPLoadTLUT_pal256(p++, pal); + } else { + gDPLoadTLUT_pal16(p++, 0, pal); + } + } + // Independent aux palette rides in slot 1 (shared-raster aux reuses + // the main palette). + if (auxCi && t.auxMode == 3 && t.auxPaletteOffset != 0 && t.auxSiz != G_IM_SIZ_8b) { + gDPLoadTLUT_pal16(p++, 1, t.blob->data() + t.auxPaletteOffset); + } + } else { + gDPSetTextureLUT(p++, G_TT_NONE); + } + switch (t.siz) { + case G_IM_SIZ_4b: + gDPLoadTextureBlock_4b(p++, raster, t.fmt, t.width, t.height, 0, t.cmS, t.cmT, maskS, maskT, G_TX_NOLOD, + G_TX_NOLOD); + break; + case G_IM_SIZ_8b: + gDPLoadTextureBlock(p++, raster, t.fmt, G_IM_SIZ_8b, t.width, t.height, 0, t.cmS, t.cmT, maskS, maskT, + G_TX_NOLOD, G_TX_NOLOD); + break; + case G_IM_SIZ_32b: + gDPLoadTextureBlock(p++, raster, t.fmt, G_IM_SIZ_32b, t.width, t.height, 0, t.cmS, t.cmT, maskS, maskT, + G_TX_NOLOD, G_TX_NOLOD); + break; + default: + gDPLoadTextureBlock(p++, raster, t.fmt, G_IM_SIZ_16b, t.width, t.height, 0, t.cmS, t.cmT, maskS, maskT, + G_TX_NOLOD, G_TX_NOLOD); + break; + } + if (aux) { + // Second tile into the upper TMEM slot (any nonzero tmem selects + // the interpreter's slot 1); TEXEL1 blends it in cycle 1. + uint8_t* auxRaster = t.blob->data() + t.auxRasterOffset; + const uint32_t mainBytes = ((uint32_t)t.width * t.height * (4u << t.siz)) / 8; + const uint32_t tmem = std::max(1u, (mainBytes + 7) >> 3); + const int auxPalIdx = t.auxMode == 3 && auxCi && t.auxSiz != G_IM_SIZ_8b ? 1 : 0; + const int auxMaskS = ILog2(t.auxWidth); + const int auxMaskT = ILog2(t.auxHeight); + switch (t.auxSiz) { + case G_IM_SIZ_4b: + gDPLoadMultiBlock_4b(p++, auxRaster, tmem, 1, t.auxFmt, t.auxWidth, t.auxHeight, auxPalIdx, + t.auxCmS, t.auxCmT, auxMaskS, auxMaskT, G_TX_NOLOD, G_TX_NOLOD); + break; + case G_IM_SIZ_8b: + gDPLoadMultiBlock(p++, auxRaster, tmem, 1, t.auxFmt, G_IM_SIZ_8b, t.auxWidth, t.auxHeight, + auxPalIdx, t.auxCmS, t.auxCmT, auxMaskS, auxMaskT, G_TX_NOLOD, G_TX_NOLOD); + break; + case G_IM_SIZ_32b: + gDPLoadMultiBlock(p++, auxRaster, tmem, 1, t.auxFmt, G_IM_SIZ_32b, t.auxWidth, t.auxHeight, + auxPalIdx, t.auxCmS, t.auxCmT, auxMaskS, auxMaskT, G_TX_NOLOD, G_TX_NOLOD); + break; + default: + gDPLoadMultiBlock(p++, auxRaster, tmem, 1, t.auxFmt, G_IM_SIZ_16b, t.auxWidth, t.auxHeight, + auxPalIdx, t.auxCmS, t.auxCmT, auxMaskS, auxMaskT, G_TX_NOLOD, G_TX_NOLOD); + break; + } + } + return p; + } + + // Assigns a framebuffer to every visible model and builds a command list + // rendering the one whose content changed this frame. Returns nullptr if + // nothing needs rendering. + Gfx* BuildModelCommands(const std::shared_ptr<Fast::Fast3dWindow>& window) { + mMtxReplacements.clear(); + if (mRenderList.empty()) { + return nullptr; + } + auto rm = Ship::Context::GetInstance()->GetResourceManager(); + auto interp = window->GetInterpreterWeak().lock(); + if (rm == nullptr || interp == nullptr) { + return nullptr; + } + auto* api = interp->GetCurrentRenderingAPI(); + if (api == nullptr) { + return nullptr; + } + if ((int)mFbPool.size() != kFbPoolSize) { + mFbPool.resize(kFbPoolSize); + } + + std::unordered_set<std::string> visible; + for (const auto& r : mRenderList) { + visible.insert(r.name); + } + std::vector<bool> used(mFbPool.size(), false); + + // Phase 1: assign framebuffer slots and pick one dirty model to render. + // The Metal backend only presents the first offscreen framebuffer drawn + // per frame, so the rest keep their persisted render. + int targetIdx = -1; + int targetReqIdx = -1; + std::vector<std::pair<int, int>> dirtySlots; // (pool idx, request idx) + for (size_t ri = 0; ri < mRenderList.size(); ++ri) { + const ModelRequest& req = mRenderList[ri]; + const int idx = AssignSlot(req.name, used, visible); + if (idx < 0) { + continue; + } + used[idx] = true; + FbSlot& slot = mFbPool[idx]; + const bool newOwner = slot.owner != req.name; + slot.owner = req.name; + + uint32_t w = std::max(1u, (uint32_t)req.size.x); + uint32_t h = std::max(1u, (uint32_t)req.size.y); + // Clamp to a sane max (preserving aspect): a giant rect would overflow + // the int16 viewport scale and exceed GPU texture limits. + constexpr uint32_t kMaxFb = 2048; + if (w > kMaxFb || h > kMaxFb) { + const float s = (float)kMaxFb / (float)std::max(w, h); + w = std::max(1u, (uint32_t)(w * s)); + h = std::max(1u, (uint32_t)(h * s)); + } + if (slot.fbId < 0) { + slot.fbId = interp->CreateFrameBuffer(w, h, w, h, 0, false); + slot.w = w; + slot.h = h; + slot.rendered = false; + } else if (slot.w != w || slot.h != h) { + api->UpdateFramebufferParameters(slot.fbId, w, h, 1, true, true, true, true); + interp->mFrameBuffers[slot.fbId] = { w, h, w, h, w, h, false, false }; + slot.w = w; + slot.h = h; + slot.rendered = false; // resize discards the old contents + } + if (newOwner) { + slot.rendered = false; + } + + // Content hash catches pose changes (e.g. animation frames). + const uint64_t partsHash = HashRequest(req); + const bool dirty = !slot.rendered || !SameView(slot.lastView, req.view) || slot.lastPartsHash != partsHash; + if (dirty) { + dirtySlots.emplace_back(idx, (int)ri); + } + } + + // Round-robin so simultaneous animations don't starve each other. + for (const auto& [idx, ri] : dirtySlots) { + if (ri >= (int)mScanStart) { + targetIdx = idx; + targetReqIdx = ri; + break; + } + } + if (targetIdx < 0 && !dirtySlots.empty()) { + targetIdx = dirtySlots.front().first; + targetReqIdx = dirtySlots.front().second; + } + if (targetReqIdx >= 0) { + mScanStart = (size_t)targetReqIdx + 1; + } + if (std::getenv("TORCH_UI_RENDERLOG") != nullptr) { + static int sFrame = 0; + fprintf(stderr, "[render] f%d reqs=%zu dirty=%zu target=%s\n", sFrame++, mRenderList.size(), + dirtySlots.size(), targetReqIdx >= 0 ? mRenderList[targetReqIdx].name.c_str() : "(none)"); + } + + // Mirror the pool's owners so DrawModelParts can blit persisted slots. + // Unrendered slots are excluded: a freshly created/resized Metal + // texture holds stale VRAM (other previews' old renders) until this + // slot's round-robin turn comes; blitting it bleeds display lists. + mNameToFb.clear(); + for (const auto& s : mFbPool) { + if (s.fbId >= 0 && !s.owner.empty() && s.rendered) { + mNameToFb[s.owner] = s.fbId; + } + } + + if (targetIdx < 0) { + return nullptr; // nothing changed; every framebuffer keeps its last render + } + + // Phase 2: render the target model into its framebuffer. + const ModelRequest& req = mRenderList[targetReqIdx]; + FbSlot& slot = mFbPool[targetIdx]; + + // Parts that fail to load are dropped rather than failing the model. + struct DrawEntry { + const ModelPart* first; + Gfx* second; + bool raw; + }; + std::vector<DrawEntry> drawable; + drawable.reserve(req.parts.size()); + for (const auto& part : req.parts) { + Gfx* gfx = nullptr; + bool raw = false; + auto rawIt = mRawModels.find(part.resource); + auto gameIt = mGameGfx.find(part.resource); + if (rawIt != mRawModels.end()) { + gfx = rawIt->second.gfx.data(); + raw = true; + } else if (gameIt != mGameGfx.end()) { + gfx = gameIt->second.second; + } else { + auto dl = std::static_pointer_cast<Fast::DisplayList>(rm->LoadResource(part.resource)); + gfx = dl != nullptr ? dl->GetPointer() : nullptr; + } + if (gfx != nullptr) { + drawable.push_back({ &part, gfx, raw }); + } + } + if (drawable.empty()) { + slot.rendered = true; // nothing loadable; don't retry every frame + slot.lastView = req.view; + slot.lastPartsHash = HashRequest(req); + return nullptr; + } + // Master-list order (layer 0..7) so transparency blends over opaque. + std::stable_sort(drawable.begin(), drawable.end(), + [](const auto& a, const auto& b) { return a.first->layer < b.first->layer; }); + + auto bit = mBoundsCache.find(req.name); + if (bit == mBoundsCache.end()) { + bit = mBoundsCache.emplace(req.name, ComputePartsBounds(req.parts, rm)).first; + } + const ModelBounds b = bit->second; + + const OrbitView& v = req.view; + const float aspect = (float)slot.w / (float)slot.h; + const float dist = (b.radius * 2.5f) / std::max(v.zoom, 0.02f); + + // Camera basis (matches LookAt). Pan shifts eye and target along x/y, + // scaled by distance so drag speed is zoom-independent. + const float zx = std::cos(v.pitch) * std::sin(v.yaw); + const float zy = std::sin(v.pitch); + const float zz = std::cos(v.pitch) * std::cos(v.yaw); + float xx = zz, xz = -zx; + const float xl = std::sqrt(xx * xx + xz * xz); + if (xl > 0.0001f) { + xx /= xl; + xz /= xl; + } + const float yx = zy * xz; + const float yy = zz * xx - zx * xz; + const float yz = -zy * xx; + const float panScale = dist * 0.0015f; + const float cx = b.cx + (-v.panX * xx) * panScale + (v.panY * yx) * panScale; + const float cy = b.cy + (v.panY * yy) * panScale; + const float cz = b.cz + (-v.panX * xz) * panScale + (v.panY * yz) * panScale; + + const float eyeX = cx + dist * zx; + const float eyeY = cy + dist * zy; + const float eyeZ = cz + dist * zz; + + MtxF projF{}; + Perspective(projF, 45.0f, aspect, std::max(dist * 0.08f, 1.0f), dist * 4.0f + b.radius * 4.0f); + MtxF viewF{}; + LookAt(viewF, eyeX, eyeY, eyeZ, cx, cy, cz); + mMtxReplacements[&slot.proj] = projF; + + // Modelview per part = world * view. Billboards get an identity rotation + // in view space at their anchor (mtxf_billboard). mPartMtxKeys provides + // stable addresses for the replacement map; no reallocation before Run. + mPartMtxKeys.resize(drawable.size()); + for (size_t i = 0; i < drawable.size(); ++i) { + const ModelPart& part = *drawable[i].first; + MtxF mv{}; + if (part.billboard) { + MtxF bb{}; + bb.mf[0][0] = bb.mf[1][1] = bb.mf[2][2] = bb.mf[3][3] = 1.0f; + for (int c = 0; c < 3; ++c) { + bb.mf[3][c] = part.anchor[0] * viewF.mf[0][c] + part.anchor[1] * viewF.mf[1][c] + + part.anchor[2] * viewF.mf[2][c] + viewF.mf[3][c]; + } + MulMtxF(mv, part.mtx, bb); + } else { + MulMtxF(mv, part.mtx, viewF); + } + mMtxReplacements[&mPartMtxKeys[i]] = mv; + } + + const int16_t vx = (int16_t)(slot.w * 2); + const int16_t vy = (int16_t)(slot.h * 2); + slot.vp.vp.vscale[0] = vx; + slot.vp.vp.vscale[1] = vy; + slot.vp.vp.vscale[2] = G_MAXZ / 2; + slot.vp.vp.vscale[3] = 0; + slot.vp.vp.vtrans[0] = vx; + slot.vp.vp.vtrans[1] = vy; + slot.vp.vp.vtrans[2] = G_MAXZ / 2; + slot.vp.vp.vtrans[3] = 0; + + // Point light approximated per part (the RSP only has directional + // lights): direction from the light position to the part, color + // attenuated by distance, supplied in view space. Parts that bind their + // own material lights override it. + const PreviewLighting& lighting = GetPreviewLighting(); + const auto to8 = [](float v) { return (uint8_t)std::clamp((int)(v * 255.0f + 0.5f), 0, 255); }; + const float lpx = b.cx + lighting.position[0] * b.radius; + const float lpy = b.cy + lighting.position[1] * b.radius; + const float lpz = b.cz + lighting.position[2] * b.radius; + mPartLights.resize(drawable.size()); + for (size_t i = 0; i < drawable.size(); ++i) { + const ModelPart& part = *drawable[i].first; + const float px = part.billboard ? part.anchor[0] : part.mtx[3][0]; + const float py = part.billboard ? part.anchor[1] : part.mtx[3][1]; + const float pz = part.billboard ? part.anchor[2] : part.mtx[3][2]; + float dx = lpx - px, dy = lpy - py, dz = lpz - pz; + const float distSq = dx * dx + dy * dy + dz * dz; + const float dlen = std::sqrt(distSq); + if (dlen > 0.0001f) { + dx /= dlen; + dy /= dlen; + dz /= dlen; + } else { + dy = 1.0f; + dx = dz = 0.0f; + } + const float radii = b.radius > 0.0001f ? dlen / b.radius : 0.0f; + const float atten = std::clamp(lighting.intensity / (1.0f + lighting.falloff * radii * radii), 0.0f, 1.0f); + // World direction -> view space (rotation rows of the view matrix). + const float vx = dx * viewF.mf[0][0] + dy * viewF.mf[1][0] + dz * viewF.mf[2][0]; + const float vy = dx * viewF.mf[0][1] + dy * viewF.mf[1][1] + dz * viewF.mf[2][1]; + const float vz = dx * viewF.mf[0][2] + dy * viewF.mf[1][2] + dz * viewF.mf[2][2]; + const float amb[3] = { part.fullAmbient ? 1.0f : lighting.ambient[0], + part.fullAmbient ? 1.0f : lighting.ambient[1], + part.fullAmbient ? 1.0f : lighting.ambient[2] }; + mPartLights[i] = gdSPDefLights1(to8(amb[0]), to8(amb[1]), to8(amb[2]), to8(lighting.color[0] * atten), + to8(lighting.color[1] * atten), to8(lighting.color[2] * atten), + (int8_t)(vx * 127.0f), (int8_t)(vy * 127.0f), (int8_t)(vz * 127.0f)); + } + + const Backdrop* backdrop = nullptr; + if (const auto bdit = mBackdrops.find(req.name); bdit != mBackdrops.end() && bdit->second.w > 0) { + backdrop = &bdit->second; + } + size_t backdropCmds = 0; + if (backdrop != nullptr) { + const int rowsPer = std::max(1, 2048 / backdrop->w); + backdropCmds = 32 + (size_t)((backdrop->h + rowsPer - 1) / rowsPer) * 12; + } + mCmd.assign(80 + drawable.size() * 64 + backdropCmds, Gfx{}); + Gfx* p = mCmd.data(); + // Load f3d ucode + reset segment 0 (global state). + p->words.w0 = ((uintptr_t)0xDD << 24) | ((uintptr_t)ucode_f3d & 0xFFFFFF); + p->words.w1 = 0; + ++p; + __gSPSegment(p++, 0, 0x0); + // Non-zero color image address so FILL rects clear color instead of + // being treated as depth clears. + gDPSetColorImage(p++, G_IM_FMT_RGBA, G_IM_SIZ_16b, 1, (void*)(uintptr_t)0x10); + + // Switch the render target to this model's framebuffer (G_SETFB 0x21). + p->words.w0 = (uintptr_t)0x21 << 24; + p->words.w1 = (uintptr_t)slot.fbId; + ++p; + // Color clear (depth was cleared by G_SETFB). The fill quad sits at the + // near plane, so it must not write depth. + gDPPipeSync(p++); + gDPSetScissor(p++, G_SC_NON_INTERLACE, 0, 0, (int)slot.w, (int)slot.h); + gDPSetRenderMode(p++, G_RM_OPA_SURF, G_RM_OPA_SURF2); + gDPSetCycleType(p++, G_CYC_FILL); + gDPSetFillColor(p++, 0x10851085); // ~(18,18,22) packed RGBA5551 twice + gDPFillRectangle(p++, 0, 0, (int)slot.w - 1, (int)slot.h - 1); + gDPPipeSync(p++); + + gSPViewport(p++, &slot.vp); + gSPClearGeometryMode(p++, 0xFFFFFFFF); + gSPTexture(p++, 0xFFFF, 0xFFFF, 0, G_TX_RENDERTILE, G_OFF); + gDPSetTexturePersp(p++, G_TP_PERSP); + gDPSetTextureLOD(p++, G_TL_TILE); + gDPSetTextureDetail(p++, G_TD_CLAMP); + gDPSetTextureLUT(p++, G_TT_NONE); + gDPSetTextureFilter(p++, G_TF_BILERP); + gDPSetTextureConvert(p++, G_TC_FILT); + gDPSetCombineKey(p++, G_CK_NONE); + gDPSetAlphaCompare(p++, G_AC_NONE); + gDPSetColorDither(p++, G_CD_DISABLE); + gSPMatrix(p++, &slot.proj, G_MTX_PROJECTION | G_MTX_LOAD | G_MTX_NOPUSH); + gDPSetCombineMode(p++, G_CC_SHADE, G_CC_SHADE); + gDPSetDepthSource(p++, G_ZS_PIXEL); + gDPSetCycleType(p++, G_CYC_1CYCLE); + if (backdrop != nullptr) { + // Cover-fit texture-rectangle strips (TMEM holds 2048 RGBA16 + // texels per load), drawn before any geometry, no depth writes. + gDPPipeSync(p++); + gDPSetTexturePersp(p++, G_TP_NONE); + gDPSetRenderMode(p++, G_RM_OPA_SURF, G_RM_OPA_SURF2); + gDPSetCombineMode(p++, G_CC_DECALRGBA, G_CC_DECALRGBA); + gSPTexture(p++, 0xFFFF, 0xFFFF, 0, G_TX_RENDERTILE, G_ON); + const float sc = std::max((float)slot.w / backdrop->w, (float)slot.h / backdrop->h); + const float offX = ((float)slot.w - backdrop->w * sc) * 0.5f; + const float offY = ((float)slot.h - backdrop->h * sc) * 0.5f; + const int rowsPer = std::max(1, 2048 / backdrop->w); + const int dsdx = (int)(1024.0f / sc); // s5.10 texels per pixel + for (int row = 0; row < backdrop->h; row += rowsPer) { + const int rows = std::min(rowsPer, backdrop->h - row); + gDPLoadTextureBlock(p++, backdrop->texels->data() + (size_t)row * backdrop->w * 2, G_IM_FMT_RGBA, + G_IM_SIZ_16b, backdrop->w, rows, 0, G_TX_CLAMP, G_TX_CLAMP, G_TX_NOMASK, + G_TX_NOMASK, G_TX_NOLOD, G_TX_NOLOD); + const float y0f = offY + row * sc; + const int x0 = std::max((int)offX, 0); + const int x1 = std::min((int)std::ceil(offX + backdrop->w * sc), (int)slot.w); + const int y0 = std::clamp((int)y0f, 0, (int)slot.h); + const int y1 = std::clamp((int)std::ceil(offY + (row + rows) * sc), 0, (int)slot.h); + if (x1 <= x0 || y1 <= y0) { + continue; + } + const int S = (int)((x0 - offX) / sc * 32.0f); + const int T = (int)((y0 - y0f) / sc * 32.0f); + gSPTextureRectangle(p++, x0 << 2, y0 << 2, x1 << 2, y1 << 2, G_TX_RENDERTILE, S, T, dsdx, dsdx); + } + gDPPipeSync(p++); + gDPSetTexturePersp(p++, G_TP_PERSP); + gSPTexture(p++, 0xFFFF, 0xFFFF, 0, G_TX_RENDERTILE, G_OFF); + gDPSetCombineMode(p++, G_CC_SHADE, G_CC_SHADE); + } + const PreviewAtmosphere& atmo = GetPreviewAtmosphere(); + const bool fog = atmo.fogEnabled; + uint32_t fogWord = 0; + if (fog) { + gDPSetFogColor(p++, to8(atmo.fogColor[0]), to8(atmo.fogColor[1]), to8(atmo.fogColor[2]), 255); + // G_MOVEWORD/G_MW_FOG, raw-encoded per dialect (the compiled gbi + // macros target another one). w1 = fog scale << 16 | fog offset. + const int range = std::max(atmo.fogEnd - atmo.fogStart, 1); + const uint16_t fm = (uint16_t)(int16_t)(128000 / range); + const uint16_t fo = (uint16_t)(int16_t)(((500 - atmo.fogStart) * 256) / range); + fogWord = ((uint32_t)fm << 16) | fo; + p->words.w0 = ((uintptr_t)0xBC << 24) | 0x08; + p->words.w1 = fogWord; + ++p; + } + const uint32_t baseGeo = G_ZBUFFER | G_SHADE | G_SHADING_SMOOTH | (fog ? G_FOG : 0); + gSPSetGeometryMode(p++, baseGeo | (lighting.enabled ? G_LIGHTING : 0)); + // Per-layer render modes (SM64 renderModeTable_1Cycle, z-buffered). + static const uint32_t kLayerCycle1[8] = { + G_RM_ZB_OPA_SURF, G_RM_AA_ZB_OPA_SURF, G_RM_AA_ZB_OPA_DECAL, G_RM_AA_ZB_OPA_INTER, + G_RM_AA_ZB_TEX_EDGE, G_RM_AA_ZB_XLU_SURF, G_RM_AA_ZB_XLU_DECAL, G_RM_AA_ZB_XLU_INTER, + }; + static const uint32_t kLayerCycle2[8] = { + G_RM_ZB_OPA_SURF2, G_RM_AA_ZB_OPA_SURF2, G_RM_AA_ZB_OPA_DECAL2, G_RM_AA_ZB_OPA_INTER2, + G_RM_AA_ZB_TEX_EDGE2, G_RM_AA_ZB_XLU_SURF2, G_RM_AA_ZB_XLU_DECAL2, G_RM_AA_ZB_XLU_INTER2, + }; + uint32_t lastRm1 = 0, lastRm2 = 0; + int lastCyc = -1; + for (size_t i = 0; i < drawable.size(); ++i) { + const int layer = drawable[i].first->layer & 7; + uint32_t rm1, rm2; + int cyc; + if (drawable[i].first->renderMode1 != 0 || drawable[i].first->renderMode2 != 0) { + rm1 = drawable[i].first->renderMode1; + rm2 = drawable[i].first->renderMode2; + cyc = drawable[i].first->cycleType; + } else { + rm1 = kLayerCycle1[layer]; + rm2 = kLayerCycle2[layer]; + cyc = 1; + } + if (fog) { + // Fog runs in blender cycle 1 (the game's RENDER_CLASS_FOG). + rm1 = G_RM_FOG_SHADE_A; + cyc = 2; + } + if (rm1 != lastRm1 || rm2 != lastRm2 || cyc != lastCyc) { + gDPPipeSync(p++); + gDPSetCycleType(p++, cyc == 2 ? G_CYC_2CYCLE : G_CYC_1CYCLE); + gDPSetRenderMode(p++, rm1, rm2); + lastRm1 = rm1; + lastRm2 = rm2; + lastCyc = cyc; + } + gSPSetLights1(p++, mPartLights[i]); + gSPMatrix(p++, &mPartMtxKeys[i], G_MTX_MODELVIEW | G_MTX_LOAD | G_MTX_NOPUSH); + const ModelPart& partRef = *drawable[i].first; + if (partRef.texture != nullptr && partRef.texture->blob != nullptr) { + p = EmitPartTexture(p, *partRef.texture, fog); + } else if (partRef.gameShade == 3) { + // auto: impose no combine/texture state — the DL renders as + // authored (it sets its own texture/combine). + } else if (partRef.gameShade == 1) { + // Game DL supplies its own texture; enable texturing and + // modulate it by shade so the model's textures show. + gDPPipeSync(p++); + gSPTexture(p++, 0xFFFF, 0xFFFF, 0, G_TX_RENDERTILE, G_ON); + gDPSetTextureLUT(p++, G_TT_NONE); + gDPSetTextureFilter(p++, G_TF_BILERP); + if (fog) { + gDPSetCombineMode(p++, G_CC_MODULATERGB, G_CC_PASS2); + } else { + gDPSetCombineMode(p++, G_CC_MODULATERGB, G_CC_MODULATERGB); + } + } else if (partRef.gameShade == 2) { + gDPPipeSync(p++); + gSPTexture(p++, 0xFFFF, 0xFFFF, 0, G_TX_RENDERTILE, G_OFF); + gDPSetTextureLUT(p++, G_TT_NONE); + gDPSetPrimColor(p++, 0, 0, 200, 200, 210, 255); + if (fog) { + gDPSetCombineMode(p++, G_CC_PRIMITIVE, G_CC_PASS2); + } else { + gDPSetCombineMode(p++, G_CC_PRIMITIVE, G_CC_PRIMITIVE); + } + } else { + gDPPipeSync(p++); + gSPTexture(p++, 0xFFFF, 0xFFFF, 0, G_TX_RENDERTILE, G_OFF); + gDPSetTextureLUT(p++, G_TT_NONE); + // Shade-only: MODULATE variants still sample TEXEL0, which + // would multiply in whatever texture the previous part bound. + if (fog) { + gDPSetCombineMode(p++, G_CC_SHADE, G_CC_PASS2); + } else { + gDPSetCombineMode(p++, G_CC_SHADE, G_CC_SHADE); + } + } + const UcodeHandlers gameUcode = ConfigUcode(); + if (!drawable[i].raw && gameUcode != ucode_f3d) { + // Run the game's list under its own dialect, then return to + // F3D for the prefix commands. G_DL is 0xDE on f3dex2. + p->words.w0 = ((uintptr_t)0xDD << 24) | ((uintptr_t)gameUcode & 0xFFFFFF); + p->words.w1 = 0; + ++p; + if (fog) { + // Ucode loads reset the RSP fog factor; re-arm it in the + // game dialect (f3dex2 moveword: index in w0 bits 16-23). + if (gameUcode == ucode_f3dex2) { + p->words.w0 = ((uintptr_t)0xDB << 24) | ((uintptr_t)0x08 << 16); + } else { + p->words.w0 = ((uintptr_t)0xBC << 24) | 0x08; + } + p->words.w1 = fogWord; + ++p; + } + const bool partLit = lighting.enabled && !partRef.unlit; + if (gameUcode == ucode_f3dex2) { + // Geometry-mode bits are stored raw and read per-dialect: + // F3D's SMOOTH (0x200) is f3dex2's CULL_FRONT. Re-set the + // baseline with f3dex2 values (SMOOTH = 0x200000). + p->words.w0 = (uintptr_t)0xD9 << 24; // clear all + p->words.w1 = 0x1 | 0x4 | 0x200000 | (fog ? 0x10000 : 0) | (partLit ? 0x20000 : 0); + ++p; + } else { + // f3dex/f3dexb share f3d's geometry bit layout. Re-set per + // part so unlit (vertex-colored) limbs skip lighting. + p->words.w0 = (uintptr_t)0xB6 << 24; // G_CLEARGEOMETRYMODE (all) + p->words.w1 = 0xFFFFFFFF; + ++p; + p->words.w0 = (uintptr_t)0xB7 << 24; // G_SETGEOMETRYMODE + p->words.w1 = 0x1 | 0x4 | 0x200 | 0x2000 | (partLit ? 0x20000 : 0); + ++p; + } + p->words.w0 = (uintptr_t)(gameUcode == ucode_f3dex2 ? 0xDE : 0x06) << 24; + p->words.w1 = (uintptr_t)drawable[i].second; + ++p; + p->words.w0 = ((uintptr_t)0xDD << 24) | ((uintptr_t)ucode_f3d & 0xFFFFFF); + p->words.w1 = 0; + ++p; + if (fog) { + p->words.w0 = ((uintptr_t)0xBC << 24) | 0x08; + p->words.w1 = fogWord; + ++p; + } + if (gameUcode == ucode_f3dex2) { + gSPClearGeometryMode(p++, 0xFFFFFFFF); + gSPSetGeometryMode(p++, baseGeo | (lighting.enabled ? G_LIGHTING : 0)); + } + } else { + __gSPDisplayList(p++, drawable[i].second); + } + } + gDPFullSync(p++); + // Restore the main framebuffer (G_RESETFB 0x22) before the gui draws. + p->words.w0 = (uintptr_t)0x22 << 24; + p->words.w1 = 0; + ++p; + gSPEndDisplayList(p++); + if ((size_t)(p - mCmd.data()) > mCmd.size()) { + SPDLOG_ERROR("Preview command list overflow: {} > {}", (size_t)(p - mCmd.data()), mCmd.size()); + } + + if (const char* dump = std::getenv("TORCH_UI_DUMPCMDS"); + dump != nullptr && req.name.find(dump) != std::string::npos) { + static int sRenderCount = 0; + size_t texCount = 0; + for (const auto& d : drawable) { + texCount += d.first->texture != nullptr ? 1 : 0; + } + fprintf(stderr, "[cmds] render %d of %s: %zu cmds, %zu/%zu parts textured in request\n", sRenderCount++, + req.name.c_str(), (size_t)(p - mCmd.data()), texCount, drawable.size()); + for (Gfx* g = mCmd.data(); g < p; ++g) { + fprintf(stderr, "[cmds] %02X %08X %016llX\n", (unsigned)(g->words.w0 >> 24) & 0xFF, + (unsigned)(g->words.w0 & 0xFFFFFF), (unsigned long long)g->words.w1); + } + } + + slot.rendered = true; + slot.lastView = req.view; + slot.lastPartsHash = HashRequest(req); + return mCmd.data(); + } + + // The game's display lists use the configured GBI dialect; our generated + // preview meshes are always F3D. + static UcodeHandlers ConfigUcode() { + if (Companion::Instance == nullptr) { + return ucode_f3d; + } + switch (Companion::Instance->GetGBIVersion()) { + case GBIVersion::f3db: + return ucode_f3db; + case GBIVersion::f3dex: + return ucode_f3dex; + case GBIVersion::f3dexb: + return ucode_f3dexb; + case GBIVersion::f3dex2: + return ucode_f3dex2; + default: + return ucode_f3d; + } + } + + static constexpr int kFbPoolSize = 24; + + // A model preview render request, queued by DrawModelParts during the gui draw. + struct ModelRequest { + std::string name; + std::vector<ModelPart> parts; + ImVec2 topLeft; + ImVec2 size; + OrbitView view; + }; + + // Parts hash plus the backdrop generation, so backdrop edits re-render. + uint64_t HashRequest(const ModelRequest& req) { + uint64_t h = HashParts(req.parts); + const auto it = mBackdrops.find(req.name); + if (it != mBackdrops.end()) { + h = h * 1099511628211ull + it->second.generation; + } + return h; + } + + std::vector<ModelRequest> mRequests; // filled this frame, rendered next + std::vector<ModelRequest> mRenderList; // snapshot being rendered this frame + std::unordered_map<std::string, int> mNameToFb; // model -> framebuffer id + std::unordered_map<std::string, ModelBounds> mBoundsCache; + std::vector<FbSlot> mFbPool; + size_t mScanStart = 0; // round-robin cursor for the one-render-per-frame pick + // Stable keys for the Mtx->MtxF replacement map. + std::vector<Mtx> mPartMtxKeys; + // Per-part light evaluation; alive through the interpreter Run. + std::vector<Lights1> mPartLights; + std::unordered_map<std::string, RawModel> mRawModels; + // Game-dialect display lists registered from parsed blobs. Pointer + // operands are resolved once into owned native Gfx arrays. + struct GameBundle { + std::shared_ptr<std::vector<uint8_t>> blob; + uint32_t vtxBase = 0; + uint32_t vtxSize = 0; + std::unordered_map<uint32_t, std::vector<Gfx>> dlists; + // Vertex spans (blob offset, count) and G_DL children per dlist, for + // per-dlist bounds. + struct DlMeta { + std::vector<std::pair<uint32_t, uint32_t>> vtxSpans; + std::vector<uint32_t> children; + }; + std::unordered_map<uint32_t, DlMeta> meta; + }; + std::unordered_map<void*, std::shared_ptr<GameBundle>> mGameBundles; // keyed by blob + std::unordered_map<std::string, std::pair<std::shared_ptr<GameBundle>, Gfx*>> mGameGfx; + + // Screen-space backdrops (N64 RGBA16 texels) drawn as texture-rectangle + // strips before a model's parts. + struct Backdrop { + const std::vector<uint8_t>* texels = nullptr; + int w = 0, h = 0; + uint64_t generation = 0; + }; + std::unordered_map<std::string, Backdrop> mBackdrops; + // Content-hash -> converted texels; entries outlive backdrop switches. + std::unordered_map<uint64_t, std::vector<uint8_t>> mBackdropTexelCache; + + // One-shot sample playback, pushed to the LUS audio player each frame with + // nearest-neighbor resampling to the device rate. + std::vector<int16_t> mAudioPcm; + size_t mAudioTotalFrames = 0; + double mAudioPos = 0.0; + int mAudioSrcRate = 0; + int mAudioSrcChannels = 1; + float mAudioVolume = 1.0f; + float mAudioSpeed = 1.0f; + std::vector<Gfx> mCmd; + std::unordered_map<Mtx*, MtxF> mMtxReplacements; +}; + +} // namespace + +std::unique_ptr<BaseBackend> CreateLusBackend() { + return std::make_unique<LusBackend>(); +} + +} // namespace UI + +#endif // BUILD_UI diff --git a/src/ui/backends/LusBackend.h b/src/ui/backends/LusBackend.h new file mode 100644 index 0000000..f00fc6e --- /dev/null +++ b/src/ui/backends/LusBackend.h @@ -0,0 +1,14 @@ +#pragma once + +#include <memory> + +#include "ui/BaseBackend.h" + +// libultraship-backed implementation of UI::BaseBackend (Fast3D renderer + SDL +// window + its bundled ImGui). Like the raylib factory, only this function is +// exposed so the concrete type and every LUS header stay in LusBackend.cpp. +namespace UI { + +std::unique_ptr<BaseBackend> CreateLusBackend(); + +} // namespace UI diff --git a/src/ui/list/Main.h b/src/ui/list/Main.h new file mode 100644 index 0000000..8211475 --- /dev/null +++ b/src/ui/list/Main.h @@ -0,0 +1,427 @@ +#pragma once + +#include <algorithm> +#include <cctype> +#include <cstdio> +#include <cstdlib> +#include <map> +#include <optional> +#include <set> +#include <string> +#include <unordered_set> +#include <utility> +#include <vector> + +#include "ui/View.h" +#include "Companion.h" + +// File list on the left, assets of the selected file on the right. Each asset is +// drawn by its factory's BaseFactoryUI, or a plain text row if it has none. +class MainView : public View { +public: + std::optional<std::string> selectedFile = std::nullopt; + // When set, the asset panel aggregates every file's assets instead of one. + bool allAssets = false; + std::vector<std::string> files; + char filter[256] = {}; + + // Asset-panel filters: name substring + type dropdown (index into + // fileTypes; 0 = all). fileTypes lists the distinct UI types in the + // selected file. + char assetSearch[256] = {}; + int typeFilterIdx = 0; + std::vector<std::string> fileTypes; + + // A directory in the file tree: child folders keyed by name, plus the files + // directly inside it as (filename, full path) pairs. + struct FileNode { + std::map<std::string, FileNode> dirs; + std::vector<std::pair<std::string, std::string>> files; + }; + FileNode tree; + + void Init() override { + ReloadFiles(); + if (const char* sel = std::getenv("TORCH_UI_AUTOSELECT")) { + for (const auto& f : files) { + if (f.find(sel) != std::string::npos) { + selectedFile = f; + break; + } + } + } + } + + // Only assets whose type registered a dedicated UI are shown. + static bool HasUI(const ParseResultData& asset) { + return asset.data.has_value() && Companion::Instance->GetUIFactory(asset.type).has_value(); + } + + void ReloadFiles() { + files.clear(); + for (const auto& [file, assets] : Companion::Instance->GetParseResults()) { + const bool anyUI = std::any_of(assets.begin(), assets.end(), HasUI); + if (anyUI) { + files.push_back(file); + } + } + std::sort(files.begin(), files.end()); + BuildTree(); + } + + void Render() override { + const ImGuiViewport* vp = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(vp->WorkPos, ImGuiCond_Always); + ImGui::SetNextWindowSize(vp->WorkSize, ImGuiCond_Always); + ImGui::Begin("Torch GUI", nullptr, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar); + + DrawHeader(); + ImGui::Spacing(); + + DrawFilesPanel(); + ImGui::SameLine(); + DrawAssetsPanel(); + + ImGui::End(); + } + +private: + void DrawHeader() { + ImGui::PushFont(nullptr); + ImGui::TextColored(ImVec4(0.93f, 0.49f, 0.20f, 1.00f), "TORCH"); + ImGui::PopFont(); + ImGui::SameLine(); + ImGui::TextDisabled("resource viewer"); + ImGui::SameLine(); + char status[128]; + snprintf(status, sizeof(status), "build %s %s %zu files %.0f fps (%.1f ms)", __DATE__, __TIME__, + files.size(), ImGui::GetIO().Framerate, 1000.0f / std::max(ImGui::GetIO().Framerate, 0.001f)); + ImGui::SameLine(ImGui::GetContentRegionAvail().x - ImGui::CalcTextSize(status).x); + ImGui::TextDisabled("%s", status); + ImGui::Separator(); + } + + void DrawFilesPanel() { + ImGui::BeginChild("FilesPanel", ImVec2(300, 0), true); + { + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputTextWithHint("##filter", "Search files...", filter, sizeof(filter)); + ImGui::Separator(); + + if (ImGui::Selectable("All assets", allAssets)) { + allAssets = true; + selectedFile = std::nullopt; + } + ImGui::Separator(); + + ImGui::BeginChild("FilesList"); + DrawTree(tree); + ImGui::EndChild(); + } + ImGui::EndChild(); + } + + void DrawAssetsPanel() { + ImGui::BeginChild("AssetsPanel", ImVec2(0, 0), true); + { + if (allAssets) { + ImGui::Text("All files"); + ImGui::SameLine(); + ImGui::TextDisabled("(%zu shown across %zu files)", rows.size(), files.size()); + DrawAssetFilters(); + } else if (selectedFile.has_value()) { + ImGui::Text("%s", fs::path(selectedFile.value()).filename().string().c_str()); + ImGui::SameLine(); + ImGui::TextDisabled("(%zu shown)", rows.size()); + DrawAssetFilters(); + } else { + ImGui::TextDisabled("Assets"); + } + ImGui::Separator(); + // Only the asset list scrolls; the header and filters stay pinned. + ImGui::BeginChild("AssetList"); + DrawAssets(); + ImGui::EndChild(); + } + ImGui::EndChild(); + } + + // Case-insensitive substring test for the name search. + static bool ContainsCI(const std::string& haystack, const char* needle) { + if (needle == nullptr || needle[0] == '\0') { + return true; + } + const auto lower = [](unsigned char c) { return (char)std::tolower(c); }; + std::string h(haystack.size(), '\0'); + std::transform(haystack.begin(), haystack.end(), h.begin(), lower); + std::string n(needle); + std::transform(n.begin(), n.end(), n.begin(), lower); + return h.find(n) != std::string::npos; + } + + // Distinct UI-backed asset types in the selected file; index 0 = "all". + std::string typesFile; + void RebuildFileTypes() { + fileTypes.assign(1, "all types"); + std::set<std::string> seen; + const auto collect = [&](const std::vector<ParseResultData>& v) { + for (const auto& a : v) { + if (HasUI(a) && seen.insert(a.type).second) { + fileTypes.push_back(a.type); + } + } + }; + if (allAssets) { + for (const auto& [file, v] : Companion::Instance->GetParseResults()) { + collect(v); + } + std::sort(fileTypes.begin() + 1, fileTypes.end()); + } else if (const auto* assets = SelectedAssets()) { + collect(*assets); + } + if (typeFilterIdx >= (int)fileTypes.size()) { + typeFilterIdx = 0; + } + } + + // Key identifying which asset set the type dropdown was built for. + std::string TypesKey() const { + return allAssets ? std::string("\x01""all") : selectedFile.value_or(std::string()); + } + + // Name search + type dropdown, drawn under the asset-panel header. + void DrawAssetFilters() { + if (typesFile != TypesKey()) { + typesFile = TypesKey(); + typeFilterIdx = 0; + RebuildFileTypes(); + } + ImGui::SetNextItemWidth(200.0f); + ImGui::InputTextWithHint("##assetsearch", "Search by name...", assetSearch, sizeof(assetSearch)); + ImGui::SameLine(); + ImGui::SetNextItemWidth(220.0f); + const char* cur = typeFilterIdx < (int)fileTypes.size() ? fileTypes[typeFilterIdx].c_str() : "all types"; + if (ImGui::BeginCombo("##assettype", cur)) { + for (int i = 0; i < (int)fileTypes.size(); ++i) { + if (ImGui::Selectable(fileTypes[i].c_str(), i == typeFilterIdx)) { + typeFilterIdx = i; + } + } + ImGui::EndCombo(); + } + } + + const std::vector<ParseResultData>* SelectedAssets() { + if (!selectedFile.has_value()) { + return nullptr; + } + const auto& results = Companion::Instance->GetParseResults(); + const auto it = results.find(selectedFile.value()); + return it == results.end() ? nullptr : &it->second; + } + + // Deduped, filtered row indices for the selected file (shared audio samples + // get registered once per referencing bank). Rebuilt when the file or the + // name/type filters change. + std::string rowsSig; + std::vector<const ParseResultData*> rows; + + void DrawAssets() { + if (!allAssets && (SelectedAssets() == nullptr || SelectedAssets()->empty())) { + ImGui::TextDisabled("Select a file to inspect its assets."); + return; + } + + const std::string typeSel = typeFilterIdx > 0 && typeFilterIdx < (int)fileTypes.size() + ? fileTypes[typeFilterIdx] + : std::string(); + const std::string sig = TypesKey() + '\n' + typeSel + '\n' + assetSearch; + if (rowsSig != sig) { + rowsSig = sig; + rows.clear(); + std::unordered_set<std::string> seen; + const char* only = std::getenv("TORCH_UI_ONLY"); + const auto scan = [&](const std::string& file, const std::vector<ParseResultData>& v) { + for (const auto& a : v) { + if (only != nullptr && a.name.find(only) == std::string::npos) { + continue; + } + if (!typeSel.empty() && a.type != typeSel) { + continue; + } + if (!ContainsCI(a.name, assetSearch)) { + continue; + } + // Dedup per file so shared samples (registered once per bank) + // collapse, while same-named assets in different files stay. + if (HasUI(a) && seen.insert(file + '\0' + a.name).second) { + rows.push_back(&a); + } + } + }; + if (allAssets) { + for (const auto& [file, v] : Companion::Instance->GetParseResults()) { + scan(file, v); + } + } else { + scan(selectedFile.value(), *SelectedAssets()); + } + } + + // Virtualized: lay rows out by reported height and only submit the + // visible range. Files with thousands of assets stay responsive. + const float sepH = ImGui::GetStyle().ItemSpacing.y * 2.0f + 1.0f; + std::vector<float> offs(rows.size() + 1); + float y = 0.0f; + for (size_t i = 0; i < rows.size(); ++i) { + offs[i] = y; + const auto& a = *rows[i]; + y += (a.data.has_value() ? UIFor(a)->GetItemHeight(a) : ImGui::GetTextLineHeightWithSpacing()) + sepH; + } + offs[rows.size()] = y; + + if (std::getenv("TORCH_UI_AUTOSCROLL") != nullptr) { + const float cur = ImGui::GetScrollY(); + ImGui::SetScrollY(cur + 2.0f >= ImGui::GetScrollMaxY() ? 0.0f : cur + 2.0f); + } + const float top = ImGui::GetCursorPosY(); + const float scrollY = ImGui::GetScrollY(); + const float viewH = ImGui::GetWindowSize().y; + size_t first = (size_t)(std::upper_bound(offs.begin(), offs.end(), scrollY - top) - offs.begin()); + if (first > 0) { + first--; + } + for (size_t i = first; i < rows.size() && top + offs[i] < scrollY + viewH; ++i) { + ImGui::SetCursorPosY(top + offs[i]); + ImGui::PushID((int)i); + DrawAsset(*rows[i]); + ImGui::PopID(); + ImGui::Separator(); + } + ImGui::SetCursorPosY(top + offs[rows.size()]); + ImGui::Dummy(ImVec2(0.0f, 0.0f)); + } + + BaseFactoryUI defaultUI; + + void DrawAsset(const ParseResultData& asset) { + if (!asset.data.has_value()) { + ImGui::Text("%s (%s)", asset.name.c_str(), asset.type.c_str()); + return; + } + UIFor(asset)->DrawUI(asset); + } + + BaseFactoryUI* UIFor(const ParseResultData& asset) { + const auto custom = Companion::Instance->GetUIFactory(asset.type); + return custom.has_value() ? custom.value().get() : &defaultUI; + } + + + // Build the directory tree from the full paths, stripping the directory + // prefix common to every file so the tree starts where the paths diverge. + void BuildTree() { + tree = FileNode{}; + if (files.empty()) { + return; + } + + std::vector<std::vector<std::string>> comps; + comps.reserve(files.size()); + size_t minLen = SIZE_MAX; + for (const auto& f : files) { + std::vector<std::string> parts; + for (const auto& p : fs::path(f)) { + const auto s = p.string(); + if (!s.empty() && s != "/") { + parts.push_back(s); + } + } + minLen = std::min(minLen, parts.size()); + comps.push_back(std::move(parts)); + } + + // Common prefix length, capped so every file keeps at least its name. + size_t common = minLen > 0 ? minLen - 1 : 0; + for (size_t i = 1; i < comps.size() && common > 0; ++i) { + size_t k = 0; + while (k < common && comps[0][k] == comps[i][k]) { + ++k; + } + common = k; + } + + for (size_t i = 0; i < files.size(); ++i) { + Insert(tree, comps[i], common, files[i]); + } + } + + static void Insert(FileNode& node, const std::vector<std::string>& parts, size_t idx, const std::string& full) { + if (idx + 1 >= parts.size()) { + node.files.emplace_back(parts.back(), full); + return; + } + Insert(node.dirs[parts[idx]], parts, idx + 1, full); + } + + void DrawTree(const FileNode& node) { + const bool filtering = filter[0] != '\0'; + + for (const auto& [name, child] : node.dirs) { + if (filtering && !SubtreeMatches(child)) { + continue; + } + if (filtering) { + ImGui::SetNextItemOpen(true, ImGuiCond_Always); + } + if (ImGui::TreeNodeEx(name.c_str(), ImGuiTreeNodeFlags_SpanAvailWidth)) { + DrawTree(child); + ImGui::TreePop(); + } + } + + for (const auto& [name, full] : node.files) { + // Match the full path so folder names count too (SM64 has many + // identically-named files like geo.yml under different folders). + if (filtering && !ContainsCI(full, filter)) { + continue; + } + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen | + ImGuiTreeNodeFlags_SpanAvailWidth; + if (selectedFile == full) { + flags |= ImGuiTreeNodeFlags_Selected; + } + ImGui::TreeNodeEx(full.c_str(), flags, "%s", name.c_str()); + if (ImGui::IsItemClicked()) { + selectedFile = full; + allAssets = false; + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("%s", full.c_str()); + } + } + } + + bool SubtreeMatches(const FileNode& node) const { + for (const auto& [name, full] : node.files) { + if (ContainsCI(full, filter)) { + return true; + } + } + for (const auto& [name, child] : node.dirs) { + if (SubtreeMatches(child)) { + return true; + } + } + return false; + } + + static bool ContainsCI(const std::string& haystack, const std::string& needle) { + const auto it = std::search( + haystack.begin(), haystack.end(), needle.begin(), needle.end(), + [](char a, char b) { return std::tolower(a) == std::tolower(b); }); + return it != haystack.end(); + } +}; diff --git a/src/utils/TorchUtils.cpp b/src/utils/TorchUtils.cpp index ec95c4a..f754234 100644 --- a/src/utils/TorchUtils.cpp +++ b/src/utils/TorchUtils.cpp @@ -10,8 +10,7 @@ namespace fs = std::filesystem; uint32_t Torch::translate(const uint32_t offset) { // Segment 0 is a raw offset. BK64 also translates segment 1 (it stores assets // in compressed segments); other games leave segment 1 raw. - const uint32_t firstSegment = - Companion::Instance->GetGBIMinorVersion() == GBIMinorVersion::BK64 ? 0x00 : 0x01; + const uint32_t firstSegment = Companion::Instance->GetGBIMinorVersion() == GBIMinorVersion::BK64 ? 0x00 : 0x01; if (SEGMENT_NUMBER(offset) > firstSegment) { auto segment = SEGMENT_NUMBER(offset); const auto addr = Companion::Instance->GetFileOffsetFromSegmentedAddr(segment); |
