summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/linux_build.yml12
-rw-r--r--.github/workflows/mac_build.yml10
-rw-r--r--.github/workflows/release.yml11
-rw-r--r--.github/workflows/windows_build.yml17
-rw-r--r--CMakeLists.txt72
-rw-r--r--Makefile13
-rw-r--r--lib/n64graphics/CMakeLists.txt24
-rw-r--r--src/Companion.cpp177
-rw-r--r--src/Companion.h18
-rw-r--r--src/factories/BaseFactory.cpp50
-rw-r--r--src/factories/BaseFactory.h28
-rw-r--r--src/factories/DisplayListFactory.cpp48
-rw-r--r--src/factories/DisplayListFactory.h10
-rw-r--r--src/factories/LightsFactory.cpp25
-rw-r--r--src/factories/LightsFactory.h10
-rw-r--r--src/factories/MtxFactory.cpp14
-rw-r--r--src/factories/TextureFactory.cpp218
-rw-r--r--src/factories/TextureFactory.h11
-rw-r--r--src/factories/VtxFactory.cpp41
-rw-r--r--src/factories/VtxFactory.h12
-rw-r--r--src/factories/bk64/BKAssetFactory.cpp440
-rw-r--r--src/factories/bk64/ConfigFactory.cpp19
-rw-r--r--src/factories/bk64/ModelFactory.cpp6
-rw-r--r--src/factories/bk64/SpriteFactory.cpp48
-rw-r--r--src/factories/mk64/PackedDisplayListFactory.cpp5
-rw-r--r--src/factories/naudio/v0/AudioManager.cpp3
-rw-r--r--src/factories/naudio/v0/AudioManager.h7
-rw-r--r--src/factories/naudio/v0/SampleFactory.cpp218
-rw-r--r--src/factories/naudio/v0/SampleFactory.h19
-rw-r--r--src/factories/naudio/v0/SequenceFactory.cpp2
-rw-r--r--src/factories/naudio/v0/SequenceFactory.h2
-rw-r--r--src/factories/naudio/v0/SequencePlayerV0.cpp1220
-rw-r--r--src/factories/naudio/v0/SequencePlayerV0.h13
-rw-r--r--src/factories/naudio/v1/AudioContext.cpp5
-rw-r--r--src/factories/naudio/v1/AudioContext.h4
-rw-r--r--src/factories/naudio/v1/AudioConverter.cpp10
-rw-r--r--src/factories/naudio/v1/AudioConverter.h3
-rw-r--r--src/factories/naudio/v1/AudioTableFactory.cpp25
-rw-r--r--src/factories/naudio/v1/SampleFactory.cpp117
-rw-r--r--src/factories/naudio/v1/SampleFactory.h8
-rw-r--r--src/factories/naudio/v1/SequenceFactory.cpp5
-rw-r--r--src/factories/naudio/v1/SequencePlayerV1.cpp1458
-rw-r--r--src/factories/naudio/v1/SequencePlayerV1.h18
-rw-r--r--src/factories/pm64/AudioFactory.cpp7
-rw-r--r--src/factories/pm64/AudioPreview.cpp1227
-rw-r--r--src/factories/pm64/AudioPreview.h134
-rw-r--r--src/factories/pm64/CollisionFactory.cpp125
-rw-r--r--src/factories/pm64/CollisionFactory.h8
-rw-r--r--src/factories/pm64/ImagePreview.cpp929
-rw-r--r--src/factories/pm64/ImagePreview.h37
-rw-r--r--src/factories/pm64/ShapeFactory.cpp702
-rw-r--r--src/factories/pm64/ShapeFactory.h11
-rw-r--r--src/factories/sf64/AnimFactory.h3
-rw-r--r--src/factories/sf64/ColPolyFactory.h3
-rw-r--r--src/factories/sf64/EnvironmentFactory.h3
-rw-r--r--src/factories/sf64/HitboxFactory.h3
-rw-r--r--src/factories/sf64/MessageLookupFactory.h3
-rw-r--r--src/factories/sf64/ObjInitFactory.h3
-rw-r--r--src/factories/sf64/SF64Preview.cpp646
-rw-r--r--src/factories/sf64/SF64Preview.h44
-rw-r--r--src/factories/sf64/ScriptFactory.h3
-rw-r--r--src/factories/sf64/TriangleFactory.h3
-rw-r--r--src/factories/sm64/AnimationFactory.cpp21
-rw-r--r--src/factories/sm64/AnimationFactory.h10
-rw-r--r--src/factories/sm64/BehaviorScriptFactory.h1
-rw-r--r--src/factories/sm64/CollisionFactory.cpp100
-rw-r--r--src/factories/sm64/CollisionFactory.h10
-rw-r--r--src/factories/sm64/GeoLayoutFactory.cpp701
-rw-r--r--src/factories/sm64/GeoLayoutFactory.h11
-rw-r--r--src/factories/sm64/LevelScriptFactory.h1
-rw-r--r--src/factories/sm64/MacroFactory.h1
-rw-r--r--src/factories/sm64/MovtexFactory.h3
-rw-r--r--src/factories/sm64/MovtexQuadFactory.h3
-rw-r--r--src/factories/sm64/PaintingFactory.h3
-rw-r--r--src/factories/sm64/PaintingMapFactory.h3
-rw-r--r--src/factories/sm64/TextPreview.cpp250
-rw-r--r--src/factories/sm64/TextPreview.h30
-rw-r--r--src/factories/sm64/TrajectoryFactory.cpp118
-rw-r--r--src/factories/sm64/TrajectoryFactory.h10
-rw-r--r--src/factories/sm64/WaterDropletFactory.h3
-rw-r--r--src/factories/sm64/geo/GeoUtils.cpp2
-rw-r--r--src/lib/web.cpp10
-rw-r--r--src/main.cpp139
-rw-r--r--src/preprocess/CompTool.cpp3
-rw-r--r--src/ui/BaseBackend.h218
-rw-r--r--src/ui/ExportUtils.h77
-rw-r--r--src/ui/Theme.h51
-rw-r--r--src/ui/View.h57
-rw-r--r--src/ui/Widgets.h289
-rw-r--r--src/ui/audio/SequenceDriver.cpp121
-rw-r--r--src/ui/audio/SequenceDriver.h53
-rw-r--r--src/ui/audio/SequenceSynth.cpp210
-rw-r--r--src/ui/audio/SequenceSynth.h80
-rw-r--r--src/ui/backends/LusBackend.cpp1714
-rw-r--r--src/ui/backends/LusBackend.h14
-rw-r--r--src/ui/list/Main.h427
-rw-r--r--src/utils/TorchUtils.cpp3
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)
diff --git a/Makefile b/Makefile
index 617364d..633f071 100644
--- a/Makefile
+++ b/Makefile
@@ -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);